feat: initial commit and ignore qwen files
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# 前端 API 地址
|
||||
VITE_API_BASE_URL=http://localhost:8000/api
|
||||
|
||||
# 库存数据库配置(只读)
|
||||
DATABASE_URL_INVENTORY=postgresql://pms_user:pms_pass@localhost:5432/inventory_db
|
||||
|
||||
# PMS 数据库配置(可读写)
|
||||
DATABASE_URL_PMS=postgresql://pms_user:pms_pass@localhost:5432/pms_db
|
||||
|
||||
# 兼容旧的单数据库配置(如果设置则优先使用,覆盖上述两个配置)
|
||||
# DATABASE_URL=postgresql://pms_user:pms_pass@localhost:5432/single_db
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
.qwen/
|
||||
-p/
|
||||
# 顺便建议忽略一些常见的开发冗余文件
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
node_modules/
|
||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
6
.idea/inspectionProfiles/profiles_settings.xml
generated
Normal file
@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<settings>
|
||||
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||
<version value="1.0" />
|
||||
</settings>
|
||||
</component>
|
||||
12
.idea/manufacturing.iml
generated
Normal file
12
.idea/manufacturing.iml
generated
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.8.10 WSL (Ubuntu-20.04): (/home/yueli/projects/test/venv/bin/python)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="PyDocumentationSettings">
|
||||
<option name="format" value="PLAIN" />
|
||||
<option name="myDocStringFormat" value="Plain" />
|
||||
</component>
|
||||
</module>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="sdkName" value="Python 3.8.10 WSL (Ubuntu-20.04): (/home/yueli/projects/test/venv/bin/python)" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.8.10 WSL (Ubuntu-20.04): (/home/yueli/projects/test/venv/bin/python)" project-jdk-type="Python SDK" />
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/manufacturing.iml" filepath="$PROJECT_DIR$/.idea/manufacturing.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
23
backend/.dockerignore
Normal file
23
backend/.dockerignore
Normal file
@ -0,0 +1,23 @@
|
||||
# Ignore unnecessary files in backend
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
README.md
|
||||
tests
|
||||
.pytest_cache
|
||||
.vscode
|
||||
.idea
|
||||
*-alpine
|
||||
*-slim
|
||||
32
backend/Dockerfile
Normal file
32
backend/Dockerfile
Normal file
@ -0,0 +1,32 @@
|
||||
# ================================================
|
||||
# PMS 后端 Dockerfile
|
||||
# Python 3.10 + FastAPI
|
||||
# ================================================
|
||||
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 设置环境变量
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# 安装系统依赖(psycopg2 需要)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制依赖文件并安装
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 复制应用代码 (修复路径)
|
||||
COPY backend/app/ ./app/
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 8001
|
||||
|
||||
# 启动命令
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# PMS Backend Application
|
||||
25
backend/app/config.py
Normal file
25
backend/app/config.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""应用配置模块"""
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置"""
|
||||
|
||||
# 库存数据库配置(只读)
|
||||
DATABASE_URL_INVENTORY: str = "postgresql://user:password@host:port/inventory_db"
|
||||
|
||||
# PMS 数据库配置(可读写)
|
||||
DATABASE_URL_PMS: str = "postgresql://user:password@host:port/pms_db"
|
||||
|
||||
# 兼容旧的单数据库配置(如果设置则优先使用)
|
||||
DATABASE_URL: str = ""
|
||||
|
||||
# CORS配置
|
||||
CORS_ORIGINS: list[str] = ["*"]
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
case_sensitive = True
|
||||
|
||||
|
||||
settings = Settings()
|
||||
67
backend/app/database.py
Normal file
67
backend/app/database.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""数据库连接与会话管理模块"""
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
from app.config import settings
|
||||
|
||||
# 处理兼容:如果设置了 DATABASE_URL(单数据库模式),则两个都使用它
|
||||
if settings.DATABASE_URL:
|
||||
inventory_url = settings.DATABASE_URL
|
||||
pms_url = settings.DATABASE_URL
|
||||
else:
|
||||
inventory_url = settings.DATABASE_URL_INVENTORY
|
||||
pms_url = settings.DATABASE_URL_PMS
|
||||
|
||||
# ============ 库存数据库(只读)============
|
||||
|
||||
engine_inventory = create_engine(
|
||||
inventory_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
# 库存数据库只读,不需要写入优化
|
||||
)
|
||||
|
||||
SessionLocalInventory = sessionmaker(autocommit=False, autoflush=False, bind=engine_inventory)
|
||||
|
||||
|
||||
def get_db_inventory():
|
||||
"""获取库存数据库会话的依赖函数(只读)"""
|
||||
db = SessionLocalInventory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============ PMS 数据库(可读写)============
|
||||
|
||||
engine_pms = create_engine(
|
||||
pms_url,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
SessionLocalPMS = sessionmaker(autocommit=False, autoflush=False, bind=engine_pms)
|
||||
|
||||
|
||||
def get_db_pms():
|
||||
"""获取 PMS 数据库会话的依赖函数(可读写)"""
|
||||
db = SessionLocalPMS()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============ 兼容旧接口 ============
|
||||
|
||||
# 为向后兼容提供默认的 engine 和 SessionLocal
|
||||
engine = engine_pms
|
||||
SessionLocal = SessionLocalPMS
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""获取数据库会话的依赖函数(兼容旧接口,默认使用 PMS 数据库)"""
|
||||
yield from get_db_pms()
|
||||
67
backend/app/main.py
Normal file
67
backend/app/main.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""FastAPI 主入口文件
|
||||
|
||||
核心原则:
|
||||
- 所有写操作仅在 pms_db 中进行
|
||||
- 绝对禁止对 inventory_db 进行任何写操作
|
||||
- 库存相关查询仅使用只读连接
|
||||
"""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.routers import (
|
||||
deduce_bom_router, # 齐套性推演
|
||||
bom_targets_router, # BOM 成品搜索
|
||||
work_order_router, # 工单 CRUD
|
||||
approval_router, # 缺料审批
|
||||
approvals_router, # 缺料审批列表
|
||||
material_router, # 物料查询
|
||||
preference_router, # 用户偏好
|
||||
project_stats_router, # 项目统计
|
||||
work_order_kanban_router, # 工单看板
|
||||
)
|
||||
|
||||
# 自动创建表(仅 pms_db)
|
||||
from app.database import engine_pms, Base
|
||||
from app.models import PmsProject, PmsWorkOrder, PmsMaterialApproval, PmsUserPreference
|
||||
from app.models.inventory import MaterialBase
|
||||
|
||||
Base.metadata.create_all(bind=engine_pms)
|
||||
|
||||
# 创建FastAPI应用实例
|
||||
app = FastAPI(
|
||||
title="Production Management System API",
|
||||
description="生产管理系统后端API",
|
||||
version="1.0.0",
|
||||
)
|
||||
|
||||
# 配置CORS中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# 路由注册说明:
|
||||
# 1. FastAPI 按 include_router 顺序注册路由
|
||||
# 2. 更具体的路径(如 /summary)必须在通用路径(如 /{id})之前定义
|
||||
# 3. 同一 prefix 的路由必须集中注册,避免路径匹配混乱
|
||||
# ============================================================
|
||||
|
||||
app.include_router(deduce_bom_router)
|
||||
app.include_router(bom_targets_router)
|
||||
app.include_router(work_order_router)
|
||||
app.include_router(approval_router)
|
||||
app.include_router(approvals_router)
|
||||
app.include_router(material_router)
|
||||
app.include_router(preference_router)
|
||||
app.include_router(project_stats_router)
|
||||
app.include_router(work_order_kanban_router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查接口"""
|
||||
return {"status": "healthy", "service": "PMS API"}
|
||||
29
backend/app/models/__init__.py
Normal file
29
backend/app/models/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""ORM 模型导出模块"""
|
||||
from app.models.inventory import MaterialBase, BomTable, StockProduct, TransOutbound, StockBuy
|
||||
from app.models.production import (
|
||||
PmsProject,
|
||||
PmsWorkOrder,
|
||||
PmsMaterialRequisition,
|
||||
PmsMaterialApproval,
|
||||
PmsUserPreference,
|
||||
ProjectStatus,
|
||||
WorkOrderStatus,
|
||||
ApprovalStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 库存表(只读)
|
||||
"MaterialBase",
|
||||
"BomTable",
|
||||
"StockProduct",
|
||||
"TransOutbound",
|
||||
# 生产系统新表
|
||||
"PmsProject",
|
||||
"PmsWorkOrder",
|
||||
"PmsMaterialRequisition",
|
||||
"PmsMaterialApproval",
|
||||
"PmsUserPreference",
|
||||
"ProjectStatus",
|
||||
"WorkOrderStatus",
|
||||
"ApprovalStatus",
|
||||
]
|
||||
78
backend/app/models/inventory.py
Normal file
78
backend/app/models/inventory.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""库存系统 ORM 模型(只读,从现有数据库逆向生成)"""
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class SysUser(Base):
|
||||
"""系统用户表(只读,从旧库映射)"""
|
||||
__tablename__ = "sys_user"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||
password = Column(String(255), nullable=False)
|
||||
real_name = Column(String(100))
|
||||
department = Column(String(100))
|
||||
is_active = Column(Integer, default=1)
|
||||
|
||||
|
||||
class MaterialBase(Base):
|
||||
"""物料基础表(只读)"""
|
||||
__tablename__ = "material_base"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
spec_model = Column(String(255))
|
||||
unit = Column(String(50))
|
||||
|
||||
|
||||
class BomTable(Base):
|
||||
"""BOM物料清单表(只读)"""
|
||||
__tablename__ = "bom_table"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
parent_id = Column(Integer, ForeignKey("material_base.id"), nullable=False)
|
||||
child_id = Column(Integer, ForeignKey("material_base.id"), nullable=False)
|
||||
dosage = Column(Numeric(10, 4), nullable=False)
|
||||
bom_no = Column(String(100), nullable=True)
|
||||
version = Column(String(50), nullable=True)
|
||||
is_enabled = Column(Boolean, default=True)
|
||||
|
||||
parent = relationship("MaterialBase", foreign_keys=[parent_id])
|
||||
child = relationship("MaterialBase", foreign_keys=[child_id])
|
||||
|
||||
|
||||
class StockProduct(Base):
|
||||
"""库存产品表(只读)"""
|
||||
__tablename__ = "stock_product"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
base_id = Column(Integer, ForeignKey("material_base.id"), nullable=False)
|
||||
stock_quantity = Column(Numeric(10, 2), default=0)
|
||||
work_order_id = Column(Integer, nullable=True)
|
||||
|
||||
material = relationship("MaterialBase", foreign_keys=[base_id])
|
||||
|
||||
|
||||
class TransOutbound(Base):
|
||||
"""出库记录表(只读)"""
|
||||
__tablename__ = "trans_outbound"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
outbound_no = Column(String(100), unique=True, nullable=False)
|
||||
stock_id = Column(Integer, ForeignKey("stock_product.id"), nullable=False)
|
||||
quantity = Column(Numeric(10, 2), nullable=False)
|
||||
consumer_name = Column(String(255))
|
||||
|
||||
stock = relationship("StockProduct", foreign_keys=[stock_id])
|
||||
|
||||
|
||||
class StockBuy(Base):
|
||||
"""原材料库存表(只读)"""
|
||||
__tablename__ = "stock_buy"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
base_id = Column(Integer, ForeignKey("material_base.id"), nullable=False)
|
||||
stock_quantity = Column(Numeric(10, 2), default=0)
|
||||
|
||||
material = relationship("MaterialBase", foreign_keys=[base_id])
|
||||
116
backend/app/models/production.py
Normal file
116
backend/app/models/production.py
Normal file
@ -0,0 +1,116 @@
|
||||
"""生产系统 ORM 模型(新建表)"""
|
||||
from sqlalchemy import Column, Integer, String, Date, DateTime, ForeignKey, Enum as SQLEnum, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import enum
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
"""项目状态枚举"""
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class WorkOrderStatus(str, enum.Enum):
|
||||
"""工单状态枚举"""
|
||||
PENDING = "pending"
|
||||
IN_PROGRESS = "in_progress"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ApprovalStatus(str, enum.Enum):
|
||||
"""缺料审批状态枚举"""
|
||||
PENDING = "PENDING"
|
||||
APPROVED = "APPROVED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class PmsProject(Base):
|
||||
"""项目表"""
|
||||
__tablename__ = "pms_project"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_no = Column(String(50), unique=True, nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
start_date = Column(Date, nullable=True)
|
||||
end_date = Column(Date, nullable=True)
|
||||
status = Column(
|
||||
SQLEnum(ProjectStatus),
|
||||
default=ProjectStatus.DRAFT,
|
||||
nullable=False
|
||||
)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
work_orders = relationship("PmsWorkOrder", back_populates="project")
|
||||
|
||||
|
||||
class PmsWorkOrder(Base):
|
||||
"""工单表"""
|
||||
__tablename__ = "pms_work_order"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
work_order_no = Column(String(50), unique=True, nullable=False, index=True)
|
||||
project_id = Column(Integer, ForeignKey("pms_project.id"), nullable=False)
|
||||
target_base_id = Column(Integer, ForeignKey("material_base.id"), nullable=False)
|
||||
target_quantity = Column(Integer, nullable=False, default=0)
|
||||
assignee_name = Column(String(100), nullable=True)
|
||||
status = Column(
|
||||
SQLEnum(WorkOrderStatus),
|
||||
default=WorkOrderStatus.PENDING,
|
||||
nullable=False
|
||||
)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
|
||||
project = relationship("PmsProject", back_populates="work_orders")
|
||||
material = relationship("MaterialBase", foreign_keys=[target_base_id])
|
||||
material_requisitions = relationship("PmsMaterialRequisition", back_populates="work_order")
|
||||
|
||||
|
||||
class PmsMaterialRequisition(Base):
|
||||
"""领料映射表"""
|
||||
__tablename__ = "pms_material_requisition"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
work_order_id = Column(Integer, ForeignKey("pms_work_order.id"), nullable=False)
|
||||
inventory_outbound_no = Column(String(100), nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
work_order = relationship("PmsWorkOrder", back_populates="material_requisitions")
|
||||
|
||||
|
||||
class PmsMaterialApproval(Base):
|
||||
"""缺料审批表"""
|
||||
__tablename__ = "pms_material_approval"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
work_order_id = Column(Integer, ForeignKey("pms_work_order.id"), nullable=False)
|
||||
missing_material_id = Column(Integer, ForeignKey("material_base.id"), nullable=False)
|
||||
required_qty = Column(Integer, nullable=False)
|
||||
reason = Column(String(500), nullable=True)
|
||||
status = Column(
|
||||
SQLEnum(ApprovalStatus),
|
||||
default=ApprovalStatus.PENDING,
|
||||
nullable=False
|
||||
)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
|
||||
work_order = relationship("PmsWorkOrder")
|
||||
material = relationship("MaterialBase", foreign_keys=[missing_material_id])
|
||||
|
||||
|
||||
class PmsUserPreference(Base):
|
||||
"""用户偏好表"""
|
||||
__tablename__ = "pms_user_preference"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True) # 对应 SysUser.id
|
||||
default_bom_target_id = Column(Integer, nullable=True) # 默认关注的成品ID
|
||||
favorite_target_ids = Column(JSON, nullable=True) # 常看成品ID列表,PostgreSQL JSON 数组
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
|
||||
22
backend/app/routers/__init__.py
Normal file
22
backend/app/routers/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
"""路由模块"""
|
||||
from app.routers.deduce_bom import router as deduce_bom_router
|
||||
from app.routers.work_order import router as work_order_router
|
||||
from app.routers.approval import router as approval_router
|
||||
from app.routers.approvals import router as approvals_router
|
||||
from app.routers.material import router as material_router
|
||||
from app.routers.preference import router as preference_router
|
||||
from app.routers.bom_targets import router as bom_targets_router
|
||||
from app.routers.project_stats import router as project_stats_router
|
||||
from app.routers.work_order_kanban import router as work_order_kanban_router
|
||||
|
||||
__all__ = [
|
||||
"deduce_bom_router",
|
||||
"work_order_router",
|
||||
"approval_router",
|
||||
"approvals_router",
|
||||
"material_router",
|
||||
"preference_router",
|
||||
"bom_targets_router",
|
||||
"project_stats_router",
|
||||
"work_order_kanban_router",
|
||||
]
|
||||
105
backend/app/routers/approval.py
Normal file
105
backend/app/routers/approval.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""缺料审批 CRUD 路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db_pms
|
||||
from app.models import PmsWorkOrder, PmsMaterialApproval, MaterialBase, ApprovalStatus
|
||||
from app.schemas.approval import (
|
||||
ApprovalCreate,
|
||||
ApprovalStatusUpdate,
|
||||
ApprovalResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/pms/approval", tags=["缺料审批"])
|
||||
|
||||
|
||||
@router.post("", response_model=ApprovalResponse, status_code=201)
|
||||
async def create_approval(data: ApprovalCreate, db: Session = Depends(get_db_pms)):
|
||||
"""
|
||||
提交缺料审批申请
|
||||
|
||||
- 员工提交缺料审批,系统自动设置状态为 PENDING
|
||||
"""
|
||||
work_order = db.query(PmsWorkOrder).filter(
|
||||
PmsWorkOrder.id == data.work_order_id
|
||||
).first()
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=400, detail=f"工单 ID={data.work_order_id} 不存在")
|
||||
|
||||
material = db.query(MaterialBase).filter(
|
||||
MaterialBase.id == data.missing_material_id
|
||||
).first()
|
||||
if not material:
|
||||
raise HTTPException(status_code=400, detail=f"物料 ID={data.missing_material_id} 不存在")
|
||||
|
||||
approval = PmsMaterialApproval(
|
||||
work_order_id=data.work_order_id,
|
||||
missing_material_id=data.missing_material_id,
|
||||
required_qty=data.required_qty,
|
||||
reason=data.reason,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
db.add(approval)
|
||||
db.commit()
|
||||
db.refresh(approval)
|
||||
return approval
|
||||
|
||||
|
||||
@router.get("", response_model=list[ApprovalResponse])
|
||||
async def list_pending_approvals(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, gt=0, le=100),
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""
|
||||
分页查询所有待审批(状态为 PENDING)的审批流
|
||||
|
||||
供主管查看待处理的缺料审批申请
|
||||
"""
|
||||
return db.query(PmsMaterialApproval).filter(
|
||||
PmsMaterialApproval.status == ApprovalStatus.PENDING
|
||||
).order_by(PmsMaterialApproval.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.put("/{approval_id}/status", response_model=ApprovalResponse)
|
||||
async def update_approval_status(
|
||||
approval_id: int,
|
||||
data: ApprovalStatusUpdate,
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""
|
||||
更新审批状态
|
||||
|
||||
- 仅允许状态从 PENDING 流转到 APPROVED 或 REJECTED
|
||||
- 终态不可再次修改
|
||||
"""
|
||||
approval = db.query(PmsMaterialApproval).filter(
|
||||
PmsMaterialApproval.id == approval_id
|
||||
).first()
|
||||
if not approval:
|
||||
raise HTTPException(status_code=404, detail=f"审批记录 ID={approval_id} 不存在")
|
||||
|
||||
if approval.status != ApprovalStatus.PENDING:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"当前状态为 {approval.status.value},不可修改(终态不可变更)"
|
||||
)
|
||||
|
||||
if data.status == ApprovalStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail="不允许将状态改回 PENDING")
|
||||
|
||||
approval.status = data.status
|
||||
db.commit()
|
||||
db.refresh(approval)
|
||||
return approval
|
||||
|
||||
|
||||
@router.get("/{approval_id}", response_model=ApprovalResponse)
|
||||
async def get_approval(approval_id: int, db: Session = Depends(get_db_pms)):
|
||||
"""获取单个审批详情"""
|
||||
approval = db.query(PmsMaterialApproval).filter(
|
||||
PmsMaterialApproval.id == approval_id
|
||||
).first()
|
||||
if not approval:
|
||||
raise HTTPException(status_code=404, detail=f"审批记录 ID={approval_id} 不存在")
|
||||
return approval
|
||||
195
backend/app/routers/approvals.py
Normal file
195
backend/app/routers/approvals.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""缺料审批接口
|
||||
|
||||
核心原则:
|
||||
- 所有写操作仅在 pms_db(PmsMaterialApproval 表)中进行
|
||||
- 绝对禁止对 inventory_db 进行任何写操作
|
||||
- 物料信息(MaterialBase)仅从只读库查询,不进行写操作
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db_pms
|
||||
from app.models import PmsWorkOrder, PmsMaterialApproval, MaterialBase, ApprovalStatus
|
||||
from app.schemas.approval import (
|
||||
ApprovalCreate,
|
||||
ApprovalStatusUpdate,
|
||||
ApprovalDetailResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/approvals", tags=["缺料审批"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=ApprovalDetailResponse,
|
||||
status_code=201,
|
||||
summary="员工提交缺料审批申请",
|
||||
)
|
||||
async def create_approval(data: ApprovalCreate, db: Session = Depends(get_db_pms)):
|
||||
work_order = db.query(PmsWorkOrder).filter(
|
||||
PmsWorkOrder.id == data.work_order_id
|
||||
).first()
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=400, detail=f"工单 ID={data.work_order_id} 不存在")
|
||||
|
||||
material = db.query(MaterialBase).filter(
|
||||
MaterialBase.id == data.missing_material_id
|
||||
).first()
|
||||
if not material:
|
||||
raise HTTPException(status_code=400, detail=f"物料 ID={data.missing_material_id} 不存在")
|
||||
|
||||
approval = PmsMaterialApproval(
|
||||
work_order_id=data.work_order_id,
|
||||
missing_material_id=data.missing_material_id,
|
||||
required_qty=data.required_qty,
|
||||
reason=data.reason,
|
||||
status=ApprovalStatus.PENDING,
|
||||
)
|
||||
db.add(approval)
|
||||
db.commit()
|
||||
db.refresh(approval)
|
||||
|
||||
return ApprovalDetailResponse(
|
||||
id=approval.id,
|
||||
work_order_id=approval.work_order_id,
|
||||
work_order_no=work_order.work_order_no,
|
||||
missing_material_id=approval.missing_material_id,
|
||||
material_name=material.name,
|
||||
required_qty=approval.required_qty,
|
||||
reason=approval.reason,
|
||||
status=approval.status,
|
||||
created_at=approval.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=list[ApprovalDetailResponse],
|
||||
summary="查询待审批列表",
|
||||
)
|
||||
async def list_pending_approvals(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, gt=0, le=100),
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
approvals = db.query(PmsMaterialApproval).filter(
|
||||
PmsMaterialApproval.status == ApprovalStatus.PENDING
|
||||
).order_by(
|
||||
PmsMaterialApproval.created_at.desc()
|
||||
).offset(skip).limit(limit).all()
|
||||
|
||||
if not approvals:
|
||||
return []
|
||||
|
||||
work_order_ids = list(set(a.work_order_id for a in approvals))
|
||||
material_ids = list(set(a.missing_material_id for a in approvals))
|
||||
|
||||
work_orders = {
|
||||
wo.id: wo for wo in db.query(PmsWorkOrder).filter(
|
||||
PmsWorkOrder.id.in_(work_order_ids)
|
||||
).all()
|
||||
}
|
||||
materials = {
|
||||
m.id: m for m in db.query(MaterialBase).filter(
|
||||
MaterialBase.id.in_(material_ids)
|
||||
).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for approval in approvals:
|
||||
wo = work_orders.get(approval.work_order_id)
|
||||
mat = materials.get(approval.missing_material_id)
|
||||
result.append(ApprovalDetailResponse(
|
||||
id=approval.id,
|
||||
work_order_id=approval.work_order_id,
|
||||
work_order_no=wo.work_order_no if wo else f"WO-{approval.work_order_id}",
|
||||
missing_material_id=approval.missing_material_id,
|
||||
material_name=mat.name if mat else f"物料-{approval.missing_material_id}",
|
||||
required_qty=approval.required_qty,
|
||||
reason=approval.reason,
|
||||
status=approval.status,
|
||||
created_at=approval.created_at,
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{approval_id}/status",
|
||||
response_model=ApprovalDetailResponse,
|
||||
summary="主管审批",
|
||||
)
|
||||
async def update_approval_status(
|
||||
approval_id: int,
|
||||
data: ApprovalStatusUpdate,
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
approval = db.query(PmsMaterialApproval).filter(
|
||||
PmsMaterialApproval.id == approval_id
|
||||
).first()
|
||||
if not approval:
|
||||
raise HTTPException(status_code=404, detail=f"审批记录 ID={approval_id} 不存在")
|
||||
|
||||
if approval.status != ApprovalStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail=f"当前状态为 {approval.status.value},终态不可再次修改")
|
||||
|
||||
if data.status == ApprovalStatus.PENDING:
|
||||
raise HTTPException(status_code=400, detail="不允许将状态改回 PENDING")
|
||||
|
||||
approval.status = data.status
|
||||
db.commit()
|
||||
db.refresh(approval)
|
||||
|
||||
work_order = db.query(PmsWorkOrder).filter(
|
||||
PmsWorkOrder.id == approval.work_order_id
|
||||
).first()
|
||||
material = db.query(MaterialBase).filter(
|
||||
MaterialBase.id == approval.missing_material_id
|
||||
).first()
|
||||
|
||||
return ApprovalDetailResponse(
|
||||
id=approval.id,
|
||||
work_order_id=approval.work_order_id,
|
||||
work_order_no=work_order.work_order_no if work_order else f"WO-{approval.work_order_id}",
|
||||
missing_material_id=approval.missing_material_id,
|
||||
material_name=material.name if material else f"物料-{approval.missing_material_id}",
|
||||
required_qty=approval.required_qty,
|
||||
reason=approval.reason,
|
||||
status=approval.status,
|
||||
created_at=approval.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{approval_id}",
|
||||
response_model=ApprovalDetailResponse,
|
||||
summary="获取审批详情",
|
||||
)
|
||||
async def get_approval(
|
||||
approval_id: int,
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
approval = db.query(PmsMaterialApproval).filter(
|
||||
PmsMaterialApproval.id == approval_id
|
||||
).first()
|
||||
if not approval:
|
||||
raise HTTPException(status_code=404, detail=f"审批记录 ID={approval_id} 不存在")
|
||||
|
||||
work_order = db.query(PmsWorkOrder).filter(
|
||||
PmsWorkOrder.id == approval.work_order_id
|
||||
).first()
|
||||
material = db.query(MaterialBase).filter(
|
||||
MaterialBase.id == approval.missing_material_id
|
||||
).first()
|
||||
|
||||
return ApprovalDetailResponse(
|
||||
id=approval.id,
|
||||
work_order_id=approval.work_order_id,
|
||||
work_order_no=work_order.work_order_no if work_order else f"WO-{approval.work_order_id}",
|
||||
missing_material_id=approval.missing_material_id,
|
||||
material_name=material.name if material else f"物料-{approval.missing_material_id}",
|
||||
required_qty=approval.required_qty,
|
||||
reason=approval.reason,
|
||||
status=approval.status,
|
||||
created_at=approval.created_at,
|
||||
)
|
||||
60
backend/app/routers/bom_targets.py
Normal file
60
backend/app/routers/bom_targets.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""BOM 成品搜索 API
|
||||
|
||||
注意:prefix 必须唯一,不能与 deduce_bom.py 的 /api/pms 冲突
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models import MaterialBase, BomTable
|
||||
|
||||
# 修改 prefix 为唯一值,避免与 deduce_bom.py 的 /api/pms 冲突
|
||||
router = APIRouter(prefix="/api/pms/bom", tags=["BOM 成品搜索"])
|
||||
|
||||
|
||||
class BomTargetItem(BaseModel):
|
||||
"""成品下拉项"""
|
||||
id: int
|
||||
name: str
|
||||
spec_model: str | None
|
||||
|
||||
|
||||
@router.get("/targets", response_model=list[BomTargetItem])
|
||||
async def search_bom_targets(q: str = Query("", description="搜索词")):
|
||||
"""
|
||||
成品模糊搜索接口
|
||||
|
||||
- 仅返回在 bom_table 中作为 parent_id 出现过的物料(即真正的成品)
|
||||
- 支持按 name 或 spec_model 模糊匹配
|
||||
"""
|
||||
from app.database import SessionLocalInventory
|
||||
db = SessionLocalInventory()
|
||||
|
||||
try:
|
||||
# 子查询:找出所有作为 parent_id 的物料ID
|
||||
parent_ids = db.query(BomTable.parent_id).distinct().subquery()
|
||||
|
||||
# 基础查询:仅成品
|
||||
query = db.query(MaterialBase).filter(
|
||||
MaterialBase.id.in_(parent_ids)
|
||||
)
|
||||
|
||||
# 如果有搜索词,添加模糊匹配
|
||||
if q:
|
||||
search_pattern = f"%{q}%"
|
||||
query = query.filter(
|
||||
(MaterialBase.name.ilike(search_pattern)) |
|
||||
(MaterialBase.spec_model.ilike(search_pattern))
|
||||
)
|
||||
|
||||
materials = query.limit(50).all()
|
||||
|
||||
return [
|
||||
BomTargetItem(
|
||||
id=m.id,
|
||||
name=m.name,
|
||||
spec_model=m.spec_model
|
||||
)
|
||||
for m in materials
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
126
backend/app/routers/deduce_bom.py
Normal file
126
backend/app/routers/deduce_bom.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""齐套性推演接口路由"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from decimal import Decimal
|
||||
from collections import defaultdict
|
||||
|
||||
from app.database import get_db_inventory
|
||||
from app.models import MaterialBase, BomTable, StockBuy
|
||||
|
||||
router = APIRouter(prefix="/api/pms", tags=["齐套性推演"])
|
||||
|
||||
|
||||
def calculate_bom_requirements(
|
||||
base_id: int,
|
||||
quantity: int,
|
||||
db: Session,
|
||||
bom_no: str | None = None,
|
||||
version: str | None = None,
|
||||
visited: set | None = None,
|
||||
) -> dict[int, Decimal]:
|
||||
"""
|
||||
递归计算BOM所需物料及用量
|
||||
|
||||
Args:
|
||||
base_id: 目标成品物料ID
|
||||
quantity: 目标数量
|
||||
db: 数据库会话
|
||||
bom_no: BOM编号(精确匹配,可为 None)
|
||||
version: BOM版本(精确匹配,可为 None)
|
||||
visited: 已访问物料ID集合(防循环)
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
|
||||
if base_id in visited:
|
||||
return {}
|
||||
visited.add(base_id)
|
||||
|
||||
requirements = defaultdict(Decimal)
|
||||
|
||||
# 关键:按 bom_no / version 精确匹配 BOM 层级
|
||||
bom_query = db.query(BomTable).filter(BomTable.parent_id == base_id)
|
||||
if bom_no is not None:
|
||||
bom_query = bom_query.filter(BomTable.bom_no == bom_no)
|
||||
if version is not None:
|
||||
bom_query = bom_query.filter(BomTable.version == version)
|
||||
bom_items = bom_query.all()
|
||||
|
||||
if not bom_items:
|
||||
visited.discard(base_id)
|
||||
return {base_id: Decimal(quantity)}
|
||||
|
||||
for item in bom_items:
|
||||
child_requirements = calculate_bom_requirements(
|
||||
item.child_id,
|
||||
int(quantity * item.dosage),
|
||||
db,
|
||||
bom_no=None,
|
||||
version=None,
|
||||
visited=visited,
|
||||
)
|
||||
for child_id, qty in child_requirements.items():
|
||||
requirements[child_id] += qty
|
||||
|
||||
return dict(requirements)
|
||||
|
||||
|
||||
@router.get("/deduce_bom")
|
||||
async def deduce_bom(
|
||||
target_base_id: int = Query(..., description="目标成品ID"),
|
||||
target_quantity: int = Query(..., gt=0, description="计划生产数量"),
|
||||
bom_no: str | None = Query(None, description="BOM编号(精确匹配,可为空)"),
|
||||
version: str | None = Query(None, description="BOM版本(精确匹配,可为空)"),
|
||||
db: Session = Depends(get_db_inventory),
|
||||
):
|
||||
"""
|
||||
齐套性推演接口
|
||||
|
||||
- 若传 bom_no 和 version,则只查对应版本 BOM
|
||||
- 若不传 bom_no/version,则查该成品所有可用 BOM(兼容旧行为)
|
||||
- 返回 material_id / material_name / spec_model / unit / required_quantity / current_stock / shortage_quantity
|
||||
"""
|
||||
target_material = db.query(MaterialBase).filter(MaterialBase.id == target_base_id).first()
|
||||
if not target_material:
|
||||
raise HTTPException(status_code=404, detail=f"目标物料 ID={target_base_id} 不存在")
|
||||
|
||||
requirements = calculate_bom_requirements(
|
||||
target_base_id, target_quantity, db,
|
||||
bom_no=bom_no, version=version,
|
||||
)
|
||||
|
||||
stock_records = {
|
||||
r.base_id: r.stock_quantity
|
||||
for r in db.query(StockBuy).filter(StockBuy.base_id.in_(requirements.keys())).all()
|
||||
}
|
||||
|
||||
material_requirements = []
|
||||
total_shortage = 0
|
||||
|
||||
for base_id, required_qty in sorted(requirements.items()):
|
||||
material = db.query(MaterialBase).filter(MaterialBase.id == base_id).first()
|
||||
current_stock = stock_records.get(base_id, Decimal("0"))
|
||||
shortage = max(Decimal("0"), required_qty - current_stock)
|
||||
if shortage > 0:
|
||||
total_shortage += 1
|
||||
|
||||
material_requirements.append({
|
||||
"material_id": base_id,
|
||||
"material_name": material.name if material else f"未知物料({base_id})",
|
||||
"spec_model": material.spec_model if material else None,
|
||||
"unit": material.unit if material else None,
|
||||
"required_quantity": required_qty,
|
||||
"current_stock": current_stock,
|
||||
"shortage_quantity": shortage,
|
||||
"is_shortage": shortage > 0,
|
||||
})
|
||||
|
||||
return {
|
||||
"target_base_id": target_base_id,
|
||||
"target_quantity": target_quantity,
|
||||
"bom_no": bom_no,
|
||||
"version": version,
|
||||
"is_shortage": total_shortage > 0,
|
||||
"total_shortage_count": total_shortage,
|
||||
"material_requirements": material_requirements,
|
||||
}
|
||||
132
backend/app/routers/material.py
Normal file
132
backend/app/routers/material.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""物料查询路由(只读,从库存库查询)"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.database import SessionLocalInventory
|
||||
from app.models import MaterialBase, BomTable
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["物料查询"])
|
||||
|
||||
|
||||
class MaterialSearchItem(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
spec_model: str | None
|
||||
bom_no: str | None
|
||||
version: str | None
|
||||
bom_key: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MaterialSearchResponse(BaseModel):
|
||||
items: list[MaterialSearchItem]
|
||||
total: int
|
||||
page: int
|
||||
size: int
|
||||
|
||||
|
||||
@router.get("/material", response_model=list[dict])
|
||||
async def get_material_list(db: Session = Depends(lambda: SessionLocalInventory())):
|
||||
materials = db.query(MaterialBase).all()
|
||||
return [
|
||||
{
|
||||
"id": m.id,
|
||||
"material_code": str(m.id),
|
||||
"material_name": m.name,
|
||||
"specification": m.spec_model or "",
|
||||
"unit": m.unit or "",
|
||||
"unit_price": 0
|
||||
}
|
||||
for m in materials
|
||||
]
|
||||
|
||||
|
||||
@router.get("/material/search", response_model=MaterialSearchResponse)
|
||||
async def search_material(
|
||||
q: str = Query("", description="搜索词"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
):
|
||||
"""
|
||||
成品搜索接口
|
||||
|
||||
Step 1: distinct() 查出所有启用的不重复 (parent_id, bom_no, version) 组合
|
||||
Step 2: 关联 material_base 获取成品名称和规格型号
|
||||
Step 3: 按搜索词过滤,生成唯一 bom_key 返回
|
||||
"""
|
||||
from sqlalchemy import cast, String
|
||||
|
||||
db = SessionLocalInventory()
|
||||
try:
|
||||
# ── Step 1: 所有启用的不重复 (parent_id, bom_no, version) ──
|
||||
active_boms = (
|
||||
db.query(
|
||||
BomTable.parent_id,
|
||||
BomTable.bom_no,
|
||||
BomTable.version
|
||||
)
|
||||
.filter(BomTable.is_enabled == True)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
|
||||
# ── Step 2: 关联 material_base 获取名称和规格 ──
|
||||
base = (
|
||||
db.query(
|
||||
active_boms.c.parent_id.label("id"),
|
||||
MaterialBase.name,
|
||||
MaterialBase.spec_model,
|
||||
active_boms.c.bom_no,
|
||||
active_boms.c.version
|
||||
)
|
||||
.join(MaterialBase, MaterialBase.id == active_boms.c.parent_id)
|
||||
)
|
||||
|
||||
if q:
|
||||
pat = f"%{q}%"
|
||||
rows = (
|
||||
base
|
||||
.filter(
|
||||
(MaterialBase.name.ilike(pat)) |
|
||||
(MaterialBase.spec_model.ilike(pat)) |
|
||||
(active_boms.c.bom_no.ilike(pat)) |
|
||||
(cast(active_boms.c.parent_id, String).ilike(pat))
|
||||
)
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
return MaterialSearchResponse(
|
||||
items=[_make_item(r) for r in rows],
|
||||
total=len(rows),
|
||||
page=1,
|
||||
size=len(rows),
|
||||
)
|
||||
|
||||
total = base.count()
|
||||
offset = (page - 1) * size
|
||||
rows = base.order_by(active_boms.c.parent_id).offset(offset).limit(size).all()
|
||||
return MaterialSearchResponse(
|
||||
items=[_make_item(r) for r in rows],
|
||||
total=total,
|
||||
page=page,
|
||||
size=size,
|
||||
)
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def _make_item(row) -> MaterialSearchItem:
|
||||
mapping = dict(row._mapping)
|
||||
bom_key = f"{mapping['id']}_{mapping.get('bom_no') or ''}_{mapping.get('version') or ''}"
|
||||
return MaterialSearchItem(
|
||||
id=mapping["id"],
|
||||
name=mapping["name"],
|
||||
spec_model=mapping.get("spec_model"),
|
||||
bom_no=mapping.get("bom_no"),
|
||||
version=mapping.get("version"),
|
||||
bom_key=bom_key,
|
||||
)
|
||||
159
backend/app/routers/preference.py
Normal file
159
backend/app/routers/preference.py
Normal file
@ -0,0 +1,159 @@
|
||||
"""用户偏好 API"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
from app.database import SessionLocalPMS
|
||||
|
||||
router = APIRouter(prefix="/api/pms/preference", tags=["用户偏好"])
|
||||
|
||||
|
||||
class PreferenceResponse(BaseModel):
|
||||
user_id: int
|
||||
default_bom_target_id: int | None
|
||||
favorite_target_ids: list[str] # 存储 bom_key 字符串列表
|
||||
|
||||
|
||||
class PreferenceUpdate(BaseModel):
|
||||
default_bom_target_id: int | None
|
||||
favorite_target_ids: list[str] | None
|
||||
|
||||
|
||||
def _parse_fav(raw) -> list[str]:
|
||||
"""从 JSON 字段安全解析字符串列表(支持旧格式纯数字字符串)"""
|
||||
if raw is None:
|
||||
return []
|
||||
if isinstance(raw, list):
|
||||
return [str(x) for x in raw if x is not None]
|
||||
return []
|
||||
|
||||
|
||||
def _get_or_create(db, user_id: int):
|
||||
from app.models import PmsUserPreference
|
||||
pref = db.query(PmsUserPreference).filter(
|
||||
PmsUserPreference.user_id == user_id
|
||||
).first()
|
||||
if not pref:
|
||||
pref = PmsUserPreference(user_id=user_id)
|
||||
db.add(pref)
|
||||
db.commit()
|
||||
db.refresh(pref)
|
||||
return pref
|
||||
|
||||
|
||||
# ── 1. 获取偏好 ────────────────────────────────────────────────────────
|
||||
@router.get("", response_model=PreferenceResponse)
|
||||
async def get_preference(user_id: int = Query(...)):
|
||||
from app.models import PmsUserPreference
|
||||
db = SessionLocalPMS()
|
||||
try:
|
||||
pref = db.query(PmsUserPreference).filter(
|
||||
PmsUserPreference.user_id == user_id
|
||||
).first()
|
||||
if not pref:
|
||||
return PreferenceResponse(user_id=user_id, default_bom_target_id=None, favorite_target_ids=[])
|
||||
return PreferenceResponse(
|
||||
user_id=pref.user_id,
|
||||
default_bom_target_id=pref.default_bom_target_id,
|
||||
favorite_target_ids=_parse_fav(pref.favorite_target_ids)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── 2. 完整更新偏好 ────────────────────────────────────────────────────
|
||||
@router.put("", response_model=PreferenceResponse)
|
||||
async def put_preference(user_id: int = Query(...), data: PreferenceUpdate = ...):
|
||||
db = SessionLocalPMS()
|
||||
try:
|
||||
pref = _get_or_create(db, user_id)
|
||||
if data.default_bom_target_id is not None:
|
||||
pref.default_bom_target_id = data.default_bom_target_id
|
||||
if data.favorite_target_ids is not None:
|
||||
pref.favorite_target_ids = data.favorite_target_ids
|
||||
db.commit()
|
||||
db.refresh(pref)
|
||||
return PreferenceResponse(
|
||||
user_id=pref.user_id,
|
||||
default_bom_target_id=pref.default_bom_target_id,
|
||||
favorite_target_ids=_parse_fav(pref.favorite_target_ids)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── 3. 添加常看(bom_key)───────────────────────────────────────────────
|
||||
@router.post("/favorite/add", response_model=PreferenceResponse)
|
||||
async def add_favorite(user_id: int = Query(...), target_id: str = Query(..., description="bom_key")):
|
||||
"""
|
||||
添加 bom_key 到用户常看列表
|
||||
路径: POST /api/pms/preference/favorite/add?user_id=1&target_id=181_v1_1
|
||||
"""
|
||||
db = SessionLocalPMS()
|
||||
try:
|
||||
pref = _get_or_create(db, user_id)
|
||||
current: list[str] = _parse_fav(pref.favorite_target_ids)
|
||||
if target_id not in current:
|
||||
current.append(target_id)
|
||||
pref.favorite_target_ids = current
|
||||
db.commit()
|
||||
db.refresh(pref)
|
||||
return PreferenceResponse(
|
||||
user_id=pref.user_id,
|
||||
default_bom_target_id=pref.default_bom_target_id,
|
||||
favorite_target_ids=_parse_fav(pref.favorite_target_ids)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── 4. 移除常看(bom_key)───────────────────────────────────────────────
|
||||
@router.post("/favorite/remove", response_model=PreferenceResponse)
|
||||
async def remove_favorite(user_id: int = Query(...), target_id: str = Query(..., description="bom_key")):
|
||||
"""
|
||||
从用户常看列表移除 bom_key
|
||||
路径: POST /api/pms/preference/favorite/remove?user_id=1&target_id=181_v1_1
|
||||
"""
|
||||
db = SessionLocalPMS()
|
||||
try:
|
||||
pref = _get_or_create(db, user_id)
|
||||
current: list[str] = _parse_fav(pref.favorite_target_ids)
|
||||
if target_id in current:
|
||||
current.remove(target_id)
|
||||
pref.favorite_target_ids = current
|
||||
db.commit()
|
||||
db.refresh(pref)
|
||||
return PreferenceResponse(
|
||||
user_id=pref.user_id,
|
||||
default_bom_target_id=pref.default_bom_target_id,
|
||||
favorite_target_ids=_parse_fav(pref.favorite_target_ids)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── 5. 保存排序(替换全部顺序)────────────────────────────────────────
|
||||
class ReorderRequest(BaseModel):
|
||||
favorite_keys: list[str]
|
||||
|
||||
|
||||
@router.post("/favorite/reorder")
|
||||
async def reorder_favorites(user_id: int = Query(...), data: ReorderRequest = ...):
|
||||
"""
|
||||
用新数组完整替换用户常看列表的顺序
|
||||
路径: POST /api/pms/preference/favorite/reorder?user_id=1
|
||||
Body: {"favorite_keys": ["181_OF-L2_V1.0", "1784_KY*HPPA_V1.0"]}
|
||||
"""
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
db = SessionLocalPMS()
|
||||
try:
|
||||
pref = _get_or_create(db, user_id)
|
||||
pref.favorite_target_ids = data.favorite_keys
|
||||
flag_modified(pref, "favorite_target_ids")
|
||||
db.commit()
|
||||
db.refresh(pref)
|
||||
return {
|
||||
"user_id": pref.user_id,
|
||||
"default_bom_target_id": pref.default_bom_target_id,
|
||||
"favorite_target_ids": _parse_fav(pref.favorite_target_ids)
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
280
backend/app/routers/project_stats.py
Normal file
280
backend/app/routers/project_stats.py
Normal file
@ -0,0 +1,280 @@
|
||||
"""项目统计聚合接口
|
||||
|
||||
路由路径:
|
||||
- GET /api/pms/projects/summary - 获取所有项目汇总
|
||||
- GET /api/pms/projects/{id}/stats - 获取单个项目详情
|
||||
|
||||
注意: /summary 路由必须写在 /{project_id}/stats 之前,
|
||||
否则 FastAPI 会将 "summary" 误匹配为 {project_id} 参数。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db_pms
|
||||
from app.models.production import (
|
||||
PmsProject,
|
||||
PmsWorkOrder,
|
||||
PmsMaterialApproval,
|
||||
ProjectStatus,
|
||||
WorkOrderStatus,
|
||||
ApprovalStatus,
|
||||
)
|
||||
from app.schemas.project import ProjectStats, ProjectSummary, ProjectCreate, ProjectResponse
|
||||
|
||||
router = APIRouter(prefix="/api/pms/projects", tags=["项目管理"])
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 路由定义顺序很重要!更具体的路径必须写在前面!
|
||||
# ============================================================
|
||||
|
||||
@router.post("", response_model=ProjectResponse, status_code=201)
|
||||
async def create_project(
|
||||
project_data: ProjectCreate,
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""
|
||||
创建新项目
|
||||
|
||||
请求体:
|
||||
- project_no: 项目编号(必填,唯一)
|
||||
- project_name: 项目名称(必填)
|
||||
- start_date: 计划开始日期(可选)
|
||||
- end_date: 计划结束日期(可选)
|
||||
|
||||
返回创建后的项目信息,包含自动生成的 id 和 created_at
|
||||
"""
|
||||
# 检查项目编号是否重复
|
||||
existing = db.query(PmsProject).filter(
|
||||
PmsProject.project_no == project_data.project_no
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"项目编号 '{project_data.project_no}' 已存在"
|
||||
)
|
||||
|
||||
# 创建项目实例
|
||||
project = PmsProject(
|
||||
project_no=project_data.project_no,
|
||||
name=project_data.name,
|
||||
start_date=project_data.start_date,
|
||||
end_date=project_data.end_date,
|
||||
status=project_data.status,
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
def calculate_project_stats(project: PmsProject, work_orders: list, approvals: list) -> ProjectStats:
|
||||
"""计算单个项目的统计信息"""
|
||||
today = date.today()
|
||||
|
||||
# 统计工单状态
|
||||
total_work_orders = len(work_orders)
|
||||
completed_work_orders = sum(1 for wo in work_orders if wo.status == WorkOrderStatus.COMPLETED)
|
||||
in_progress_work_orders = sum(1 for wo in work_orders if wo.status == WorkOrderStatus.IN_PROGRESS)
|
||||
pending_work_orders = sum(1 for wo in work_orders if wo.status == WorkOrderStatus.PENDING)
|
||||
|
||||
# 计算进度百分比
|
||||
progress_percentage = (
|
||||
round(completed_work_orders / total_work_orders * 100, 1)
|
||||
if total_work_orders > 0 else 0.0
|
||||
)
|
||||
|
||||
# 统计审批状态
|
||||
total_approvals = len(approvals)
|
||||
pending_approvals = sum(1 for a in approvals if a.status == ApprovalStatus.PENDING)
|
||||
approved_approvals = sum(1 for a in approvals if a.status == ApprovalStatus.APPROVED)
|
||||
rejected_approvals = sum(1 for a in approvals if a.status == ApprovalStatus.REJECTED)
|
||||
|
||||
# 物料到位率 = 已批准 / 总申请
|
||||
material_ready_rate = (
|
||||
round(approved_approvals / total_approvals * 100, 1)
|
||||
if total_approvals > 0 else 100.0
|
||||
)
|
||||
|
||||
# 延期预警计算
|
||||
is_overdue = False
|
||||
overdue_days: int | None = None
|
||||
if project.end_date and project.status != ProjectStatus.COMPLETED:
|
||||
if today > project.end_date:
|
||||
is_overdue = True
|
||||
overdue_days = (today - project.end_date).days
|
||||
|
||||
return ProjectStats(
|
||||
project_id=project.id,
|
||||
project_no=project.project_no,
|
||||
project_name=project.name,
|
||||
status=project.status,
|
||||
start_date=project.start_date,
|
||||
end_date=project.end_date,
|
||||
total_work_orders=total_work_orders,
|
||||
completed_work_orders=completed_work_orders,
|
||||
in_progress_work_orders=in_progress_work_orders,
|
||||
pending_work_orders=pending_work_orders,
|
||||
progress_percentage=progress_percentage,
|
||||
total_approvals=total_approvals,
|
||||
pending_approvals=pending_approvals,
|
||||
approved_approvals=approved_approvals,
|
||||
rejected_approvals=rejected_approvals,
|
||||
material_ready_rate=material_ready_rate,
|
||||
is_overdue=is_overdue,
|
||||
overdue_days=overdue_days,
|
||||
created_at=project.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 路由定义顺序很重要!更具体的路径必须写在前面!
|
||||
# ============================================================
|
||||
|
||||
@router.get("/summary", response_model=ProjectSummary)
|
||||
async def get_project_summary(
|
||||
status: Optional[ProjectStatus] = Query(None, description="项目状态筛选"),
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""
|
||||
获取项目总览汇总数据
|
||||
|
||||
包含所有项目的统计信息:
|
||||
- 项目总数、活跃项目数、已完成项目数、延期项目数
|
||||
- 全局工单统计和进度
|
||||
- 物料到位率汇总
|
||||
- 每个项目的详细统计
|
||||
"""
|
||||
print("Endpoint /summary reached!") # 调试日志
|
||||
|
||||
# 查询所有项目
|
||||
query = db.query(PmsProject)
|
||||
if status:
|
||||
query = query.filter(PmsProject.status == status)
|
||||
projects = query.order_by(PmsProject.created_at.desc()).all()
|
||||
|
||||
# 无项目时返回空数据
|
||||
if not projects:
|
||||
return ProjectSummary(
|
||||
total_projects=0,
|
||||
active_projects=0,
|
||||
completed_projects=0,
|
||||
overdue_projects=0,
|
||||
total_work_orders=0,
|
||||
completed_work_orders=0,
|
||||
overall_progress=0.0,
|
||||
total_pending_approvals=0,
|
||||
overall_material_ready_rate=100.0,
|
||||
projects=[],
|
||||
)
|
||||
|
||||
# 获取所有项目ID
|
||||
project_ids = [p.id for p in projects]
|
||||
|
||||
# 查询所有相关工单
|
||||
all_work_orders = (
|
||||
db.query(PmsWorkOrder)
|
||||
.filter(PmsWorkOrder.project_id.in_(project_ids))
|
||||
.all()
|
||||
)
|
||||
all_work_order_ids = [wo.id for wo in all_work_orders]
|
||||
|
||||
# 查询所有相关审批
|
||||
all_approvals = (
|
||||
db.query(PmsMaterialApproval)
|
||||
.filter(PmsMaterialApproval.work_order_id.in_(all_work_order_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
# 按项目分组工单
|
||||
work_orders_by_project: dict[int, list] = {}
|
||||
for wo in all_work_orders:
|
||||
work_orders_by_project.setdefault(wo.project_id, []).append(wo)
|
||||
|
||||
# 按工单分组审批(需要通过工单找到项目)
|
||||
approvals_by_project: dict[int, list] = {}
|
||||
work_order_to_project: dict[int, int] = {wo.id: wo.project_id for wo in all_work_orders}
|
||||
for ap in all_approvals:
|
||||
project_id = work_order_to_project.get(ap.work_order_id)
|
||||
if project_id:
|
||||
approvals_by_project.setdefault(project_id, []).append(ap)
|
||||
|
||||
# 计算每个项目的统计
|
||||
project_stats_list: list[ProjectStats] = []
|
||||
total_work_orders = 0
|
||||
completed_work_orders = 0
|
||||
total_pending_approvals = 0
|
||||
overdue_projects = 0
|
||||
|
||||
for project in projects:
|
||||
wo_list = work_orders_by_project.get(project.id, [])
|
||||
ap_list = approvals_by_project.get(project.id, [])
|
||||
|
||||
stats = calculate_project_stats(project, wo_list, ap_list)
|
||||
project_stats_list.append(stats)
|
||||
|
||||
total_work_orders += stats.total_work_orders
|
||||
completed_work_orders += stats.completed_work_orders
|
||||
total_pending_approvals += stats.pending_approvals
|
||||
if stats.is_overdue:
|
||||
overdue_projects += 1
|
||||
|
||||
# 计算全局进度
|
||||
overall_progress = (
|
||||
round(completed_work_orders / total_work_orders * 100, 1)
|
||||
if total_work_orders > 0 else 0.0
|
||||
)
|
||||
|
||||
# 计算全局物料到位率
|
||||
total_approvals_count = sum(s.total_approvals for s in project_stats_list)
|
||||
total_approved = sum(s.approved_approvals for s in project_stats_list)
|
||||
overall_material_ready_rate = (
|
||||
round(total_approved / total_approvals_count * 100, 1)
|
||||
if total_approvals_count > 0 else 100.0
|
||||
)
|
||||
|
||||
return ProjectSummary(
|
||||
total_projects=len(projects),
|
||||
active_projects=sum(1 for p in projects if p.status == ProjectStatus.ACTIVE),
|
||||
completed_projects=sum(1 for p in projects if p.status == ProjectStatus.COMPLETED),
|
||||
overdue_projects=overdue_projects,
|
||||
total_work_orders=total_work_orders,
|
||||
completed_work_orders=completed_work_orders,
|
||||
overall_progress=overall_progress,
|
||||
total_pending_approvals=total_pending_approvals,
|
||||
overall_material_ready_rate=overall_material_ready_rate,
|
||||
projects=project_stats_list,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/stats", response_model=ProjectStats)
|
||||
async def get_project_stats(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""获取单个项目的详细统计信息"""
|
||||
print(f"Endpoint /{project_id}/stats reached!") # 调试日志
|
||||
|
||||
project = db.query(PmsProject).filter(PmsProject.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail=f"Project ID={project_id} not found")
|
||||
|
||||
# 查询该项目的工单
|
||||
work_orders = (
|
||||
db.query(PmsWorkOrder)
|
||||
.filter(PmsWorkOrder.project_id == project_id)
|
||||
.all()
|
||||
)
|
||||
work_order_ids = [wo.id for wo in work_orders]
|
||||
|
||||
# 查询该项目的审批
|
||||
approvals = (
|
||||
db.query(PmsMaterialApproval)
|
||||
.filter(PmsMaterialApproval.work_order_id.in_(work_order_ids))
|
||||
.all()
|
||||
)
|
||||
|
||||
return calculate_project_stats(project, work_orders, approvals)
|
||||
161
backend/app/routers/work_order.py
Normal file
161
backend/app/routers/work_order.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""?? CRUD ??"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db_pms
|
||||
from app.models import PmsWorkOrder, PmsProject, MaterialBase, WorkOrderStatus
|
||||
from app.schemas.work_order import (
|
||||
WorkOrderCreate,
|
||||
WorkOrderUpdate,
|
||||
WorkOrderStatusUpdate,
|
||||
WorkOrderResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/pms/work_order", tags=["????"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[WorkOrderResponse])
|
||||
async def list_work_orders(
|
||||
project_id: Optional[int] = Query(None, description="???ID??"),
|
||||
status: Optional[WorkOrderStatus] = Query(None, description="?????"),
|
||||
assignee_name: Optional[str] = Query(None, description="??????"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, gt=0, le=500),
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""??????"""
|
||||
query = db.query(PmsWorkOrder)
|
||||
|
||||
if project_id is not None:
|
||||
query = query.filter(PmsWorkOrder.project_id == project_id)
|
||||
if status is not None:
|
||||
query = query.filter(PmsWorkOrder.status == status)
|
||||
if assignee_name is not None:
|
||||
query = query.filter(PmsWorkOrder.assignee_name == assignee_name)
|
||||
|
||||
return query.order_by(PmsWorkOrder.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/{work_order_id}", response_model=WorkOrderResponse)
|
||||
async def get_work_order(work_order_id: int, db: Session = Depends(get_db_pms)):
|
||||
"""????????"""
|
||||
work_order = db.query(PmsWorkOrder).filter(PmsWorkOrder.id == work_order_id).first()
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail=f"?? ID={work_order_id} ???")
|
||||
return work_order
|
||||
|
||||
|
||||
@router.post("", response_model=WorkOrderResponse, status_code=201)
|
||||
async def create_work_order(data: WorkOrderCreate, db: Session = Depends(get_db_pms)):
|
||||
"""?????"""
|
||||
project = db.query(PmsProject).filter(PmsProject.id == data.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=400, detail=f"?? ID={data.project_id} ???")
|
||||
|
||||
material = db.query(MaterialBase).filter(MaterialBase.id == data.target_base_id).first()
|
||||
if not material:
|
||||
raise HTTPException(status_code=400, detail=f"???? ID={data.target_base_id} ???")
|
||||
|
||||
existing = db.query(PmsWorkOrder).filter(
|
||||
PmsWorkOrder.work_order_no == data.work_order_no
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"??? {data.work_order_no} ???")
|
||||
|
||||
work_order = PmsWorkOrder(
|
||||
work_order_no=data.work_order_no,
|
||||
project_id=data.project_id,
|
||||
target_base_id=data.target_base_id,
|
||||
target_quantity=data.target_quantity,
|
||||
assignee_name=data.assignee_name,
|
||||
status=WorkOrderStatus.PENDING,
|
||||
)
|
||||
db.add(work_order)
|
||||
db.commit()
|
||||
db.refresh(work_order)
|
||||
return work_order
|
||||
|
||||
|
||||
@router.patch("/{work_order_id}", response_model=WorkOrderResponse)
|
||||
async def update_work_order(
|
||||
work_order_id: int,
|
||||
data: WorkOrderUpdate,
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""????"""
|
||||
work_order = db.query(PmsWorkOrder).filter(PmsWorkOrder.id == work_order_id).first()
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail=f"?? ID={work_order_id} ???")
|
||||
|
||||
if work_order.status == WorkOrderStatus.COMPLETED:
|
||||
raise HTTPException(status_code=400, detail="??????????")
|
||||
|
||||
if data.target_quantity is not None:
|
||||
work_order.target_quantity = data.target_quantity
|
||||
if data.assignee_name is not None:
|
||||
work_order.assignee_name = data.assignee_name
|
||||
|
||||
db.commit()
|
||||
db.refresh(work_order)
|
||||
return work_order
|
||||
|
||||
|
||||
@router.delete("/{work_order_id}", status_code=204)
|
||||
async def delete_work_order(work_order_id: int, db: Session = Depends(get_db_pms)):
|
||||
"""????"""
|
||||
work_order = db.query(PmsWorkOrder).filter(PmsWorkOrder.id == work_order_id).first()
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail=f"?? ID={work_order_id} ???")
|
||||
|
||||
if work_order.status == WorkOrderStatus.IN_PROGRESS:
|
||||
raise HTTPException(status_code=400, detail="??????????")
|
||||
|
||||
db.delete(work_order)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ??????
|
||||
@router.put("/{work_order_id}/status", response_model=WorkOrderResponse)
|
||||
async def update_work_order_status(
|
||||
work_order_id: int,
|
||||
data: WorkOrderStatusUpdate,
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""
|
||||
????????
|
||||
|
||||
???????:
|
||||
- PENDING (???) -> IN_PROGRESS (???)
|
||||
- IN_PROGRESS (???) -> COMPLETED (???)
|
||||
- IN_PROGRESS (???) -> CANCELLED (???)
|
||||
- PENDING (???) -> CANCELLED (???)
|
||||
|
||||
?????????????????????
|
||||
???????????????????
|
||||
???????????????
|
||||
"""
|
||||
work_order = db.query(PmsWorkOrder).filter(PmsWorkOrder.id == work_order_id).first()
|
||||
if not work_order:
|
||||
raise HTTPException(status_code=404, detail=f"?? ID={work_order_id} ???")
|
||||
|
||||
current_status = work_order.status
|
||||
new_status = data.status
|
||||
|
||||
valid_transitions = {
|
||||
WorkOrderStatus.PENDING: {WorkOrderStatus.IN_PROGRESS, WorkOrderStatus.CANCELLED},
|
||||
WorkOrderStatus.IN_PROGRESS: {WorkOrderStatus.COMPLETED, WorkOrderStatus.CANCELLED},
|
||||
WorkOrderStatus.COMPLETED: set(),
|
||||
WorkOrderStatus.CANCELLED: set(),
|
||||
}
|
||||
|
||||
if new_status not in valid_transitions.get(current_status, set()):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"???????: {current_status.value} -> {new_status.value}"
|
||||
)
|
||||
|
||||
work_order.status = new_status
|
||||
db.commit()
|
||||
db.refresh(work_order)
|
||||
return work_order
|
||||
144
backend/app/routers/work_order_kanban.py
Normal file
144
backend/app/routers/work_order_kanban.py
Normal file
@ -0,0 +1,144 @@
|
||||
"""工单看板接口
|
||||
|
||||
路由路径:
|
||||
- GET /api/pms/work_order_kanban - 获取工单列表(分页)
|
||||
- GET /api/pms/work_order_kanban/status-counts - 获取各状态工单数量
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_
|
||||
from typing import Optional
|
||||
|
||||
from app.database import get_db_pms
|
||||
from app.models.production import PmsWorkOrder, PmsProject, WorkOrderStatus
|
||||
from app.models.inventory import MaterialBase
|
||||
|
||||
router = APIRouter(prefix="/api/pms/work_order_kanban", tags=["工单看板"])
|
||||
|
||||
|
||||
class WorkOrderKanbanResponse:
|
||||
"""工单看板响应"""
|
||||
def __init__(
|
||||
self,
|
||||
id: int,
|
||||
work_order_no: str,
|
||||
project_id: int,
|
||||
target_base_id: int,
|
||||
target_quantity: int,
|
||||
assignee_name: str | None,
|
||||
status: WorkOrderStatus,
|
||||
created_at,
|
||||
updated_at,
|
||||
project_name: str | None,
|
||||
material_name: str | None,
|
||||
material_spec: str | None,
|
||||
):
|
||||
self.id = id
|
||||
self.work_order_no = work_order_no
|
||||
self.project_id = project_id
|
||||
self.target_base_id = target_base_id
|
||||
self.target_quantity = target_quantity
|
||||
self.assignee_name = assignee_name
|
||||
self.status = status
|
||||
self.created_at = created_at
|
||||
self.updated_at = updated_at
|
||||
self.project_name = project_name
|
||||
self.material_name = material_name
|
||||
self.material_spec = material_spec
|
||||
|
||||
|
||||
class PaginatedWorkOrders:
|
||||
"""分页工单响应"""
|
||||
def __init__(self, items: list, total: int, page: int, size: int, total_pages: int):
|
||||
self.items = items
|
||||
self.total = total
|
||||
self.page = page
|
||||
self.size = size
|
||||
self.total_pages = total_pages
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def get_work_orders_kanban(
|
||||
status: Optional[WorkOrderStatus] = Query(None, description="工单状态筛选"),
|
||||
project_id: Optional[int] = Query(None, description="项目ID筛选"),
|
||||
assignee_name: Optional[str] = Query(None, description="负责人筛选"),
|
||||
search: Optional[str] = Query(None, description="工单号/产品名称搜索"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
db: Session = Depends(get_db_pms)
|
||||
):
|
||||
"""工单看板查询接口(分页)"""
|
||||
query = db.query(PmsWorkOrder)
|
||||
|
||||
if status:
|
||||
query = query.filter(PmsWorkOrder.status == status)
|
||||
if project_id:
|
||||
query = query.filter(PmsWorkOrder.project_id == project_id)
|
||||
if assignee_name:
|
||||
query = query.filter(PmsWorkOrder.assignee_name.ilike(f"%{assignee_name}%"))
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.outerjoin(
|
||||
PmsProject,
|
||||
PmsWorkOrder.project_id == PmsProject.id
|
||||
).outerjoin(
|
||||
MaterialBase,
|
||||
PmsWorkOrder.target_base_id == MaterialBase.id
|
||||
).filter(
|
||||
or_(
|
||||
PmsWorkOrder.work_order_no.ilike(search_pattern),
|
||||
MaterialBase.name.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * size
|
||||
|
||||
work_orders = (
|
||||
query.order_by(PmsWorkOrder.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(size)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = []
|
||||
for wo in work_orders:
|
||||
project = db.query(PmsProject).filter(PmsProject.id == wo.project_id).first()
|
||||
material = db.query(MaterialBase).filter(MaterialBase.id == wo.target_base_id).first()
|
||||
|
||||
item = {
|
||||
"id": wo.id,
|
||||
"work_order_no": wo.work_order_no,
|
||||
"project_id": wo.project_id,
|
||||
"target_base_id": wo.target_base_id,
|
||||
"target_quantity": wo.target_quantity,
|
||||
"assignee_name": wo.assignee_name,
|
||||
"status": wo.status.value,
|
||||
"created_at": wo.created_at.isoformat() if wo.created_at else None,
|
||||
"updated_at": wo.updated_at.isoformat() if wo.updated_at else None,
|
||||
"project_name": project.name if project else None,
|
||||
"material_name": material.name if material else None,
|
||||
"material_spec": material.spec_model if material else None,
|
||||
}
|
||||
items.append(item)
|
||||
|
||||
total_pages = (total + size - 1) // size if total > 0 else 1
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"total_pages": total_pages,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/status-counts")
|
||||
async def get_status_counts(db: Session = Depends(get_db_pms)):
|
||||
"""获取各状态工单数量统计"""
|
||||
result = {}
|
||||
for status in WorkOrderStatus:
|
||||
count = db.query(PmsWorkOrder).filter(PmsWorkOrder.status == status).count()
|
||||
result[status.value] = count
|
||||
return result
|
||||
27
backend/app/schemas/__init__.py
Normal file
27
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""Pydantic Schema 导出模块"""
|
||||
from app.schemas.deduce_bom import DeduceBomResponse, MaterialRequirementItem
|
||||
from app.schemas.work_order import (
|
||||
WorkOrderCreate,
|
||||
WorkOrderUpdate,
|
||||
WorkOrderStatusUpdate,
|
||||
WorkOrderResponse,
|
||||
)
|
||||
from app.schemas.approval import (
|
||||
ApprovalCreate,
|
||||
ApprovalStatusUpdate,
|
||||
ApprovalResponse,
|
||||
ApprovalDetailResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DeduceBomResponse",
|
||||
"MaterialRequirementItem",
|
||||
"WorkOrderCreate",
|
||||
"WorkOrderUpdate",
|
||||
"WorkOrderStatusUpdate",
|
||||
"WorkOrderResponse",
|
||||
"ApprovalCreate",
|
||||
"ApprovalStatusUpdate",
|
||||
"ApprovalResponse",
|
||||
"ApprovalDetailResponse",
|
||||
]
|
||||
62
backend/app/schemas/approval.py
Normal file
62
backend/app/schemas/approval.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""缺料审批 Schema
|
||||
|
||||
核心原则:
|
||||
- 所有写操作仅在 pms_db 中进行
|
||||
- 绝对禁止对 inventory_db 进行任何写操作
|
||||
- 物料信息仅从 inventory_db 查询获取,不进行写操作
|
||||
"""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from app.models.production import ApprovalStatus
|
||||
|
||||
|
||||
class ApprovalCreate(BaseModel):
|
||||
"""创建缺料审批申请"""
|
||||
work_order_id: int = Field(..., description="工单ID")
|
||||
missing_material_id: int = Field(..., description="缺失物料ID")
|
||||
required_qty: int = Field(gt=0, description="需要补充的数量")
|
||||
reason: str | None = Field(None, max_length=500, description="申请原因")
|
||||
|
||||
|
||||
class ApprovalStatusUpdate(BaseModel):
|
||||
"""审批状态更新"""
|
||||
status: ApprovalStatus = Field(
|
||||
...,
|
||||
description="新状态(APPROVED 或 REJECTED)"
|
||||
)
|
||||
|
||||
|
||||
class ApprovalResponse(BaseModel):
|
||||
"""基础缺料审批响应"""
|
||||
id: int
|
||||
work_order_id: int
|
||||
missing_material_id: int
|
||||
required_qty: int
|
||||
reason: str | None
|
||||
status: ApprovalStatus
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ApprovalDetailResponse(BaseModel):
|
||||
"""带详情的缺料审批响应
|
||||
|
||||
包含:
|
||||
- 工单编号
|
||||
- 物料名称
|
||||
- 其他基本信息
|
||||
"""
|
||||
id: int
|
||||
work_order_id: int
|
||||
work_order_no: str = Field(..., description="工单编号")
|
||||
missing_material_id: int
|
||||
material_name: str = Field(..., description="物料名称")
|
||||
required_qty: int
|
||||
reason: str | None
|
||||
status: ApprovalStatus
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
28
backend/app/schemas/deduce_bom.py
Normal file
28
backend/app/schemas/deduce_bom.py
Normal file
@ -0,0 +1,28 @@
|
||||
"""齐套性推演接口 Schema"""
|
||||
from pydantic import BaseModel, Field
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class MaterialRequirementItem(BaseModel):
|
||||
"""物料需求项"""
|
||||
material_id: int = Field(alias="base_id")
|
||||
material_name: str
|
||||
spec_model: str | None
|
||||
unit: str | None
|
||||
required_quantity: Decimal = Field(ge=0)
|
||||
current_stock: Decimal = Field(default=Decimal("0"))
|
||||
shortage_quantity: Decimal = Field(ge=0)
|
||||
is_shortage: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class DeduceBomResponse(BaseModel):
|
||||
"""齐套性推演响应"""
|
||||
target_base_id: int
|
||||
target_quantity: int
|
||||
is_shortage: bool
|
||||
total_shortage_count: int
|
||||
material_requirements: list[MaterialRequirementItem]
|
||||
85
backend/app/schemas/project.py
Normal file
85
backend/app/schemas/project.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""项目相关 Schema"""
|
||||
from pydantic import BaseModel
|
||||
from datetime import date, datetime
|
||||
from app.models.production import ProjectStatus
|
||||
|
||||
|
||||
class ProjectBase(BaseModel):
|
||||
"""项目基础 Schema"""
|
||||
project_no: str
|
||||
name: str
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
status: ProjectStatus = ProjectStatus.DRAFT
|
||||
|
||||
|
||||
class ProjectCreate(ProjectBase):
|
||||
"""创建项目"""
|
||||
pass
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
"""更新项目"""
|
||||
name: str | None = None
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
status: ProjectStatus | None = None
|
||||
|
||||
|
||||
class ProjectResponse(ProjectBase):
|
||||
"""项目响应"""
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProjectStats(BaseModel):
|
||||
"""项目统计信息"""
|
||||
project_id: int
|
||||
project_no: str
|
||||
project_name: str
|
||||
status: ProjectStatus
|
||||
start_date: date | None
|
||||
end_date: date | None
|
||||
|
||||
# 进度相关
|
||||
total_work_orders: int
|
||||
completed_work_orders: int
|
||||
in_progress_work_orders: int
|
||||
pending_work_orders: int
|
||||
progress_percentage: float # 完成百分比
|
||||
|
||||
# 物料到位率(基于缺料审批)
|
||||
total_approvals: int
|
||||
pending_approvals: int
|
||||
approved_approvals: int
|
||||
rejected_approvals: int
|
||||
material_ready_rate: float # 物料到位率 (approved / total_approvals * 100)
|
||||
|
||||
# 延期预警
|
||||
is_overdue: bool # 当前日期超过 end_date 且状态未完成
|
||||
overdue_days: int | None # 延期天数(正数表示延期,负数表示提前)
|
||||
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ProjectSummary(BaseModel):
|
||||
"""项目总览汇总"""
|
||||
total_projects: int
|
||||
active_projects: int
|
||||
completed_projects: int
|
||||
overdue_projects: int
|
||||
|
||||
# 全局统计
|
||||
total_work_orders: int
|
||||
completed_work_orders: int
|
||||
overall_progress: float # 全局进度
|
||||
|
||||
total_pending_approvals: int
|
||||
overall_material_ready_rate: float
|
||||
|
||||
# 项目列表
|
||||
projects: list[ProjectStats]
|
||||
40
backend/app/schemas/work_order.py
Normal file
40
backend/app/schemas/work_order.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""工单 Schema"""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from app.models.production import WorkOrderStatus
|
||||
|
||||
|
||||
class WorkOrderBase(BaseModel):
|
||||
"""工单基础 Schema"""
|
||||
project_id: int
|
||||
target_base_id: int
|
||||
target_quantity: int = Field(gt=0)
|
||||
assignee_name: str | None = None
|
||||
|
||||
|
||||
class WorkOrderCreate(WorkOrderBase):
|
||||
"""创建工单"""
|
||||
work_order_no: str
|
||||
|
||||
|
||||
class WorkOrderUpdate(BaseModel):
|
||||
"""更新工单"""
|
||||
target_quantity: int | None = None
|
||||
assignee_name: str | None = None
|
||||
|
||||
|
||||
class WorkOrderStatusUpdate(BaseModel):
|
||||
"""状态流转"""
|
||||
status: WorkOrderStatus
|
||||
|
||||
|
||||
class WorkOrderResponse(WorkOrderBase):
|
||||
"""工单响应"""
|
||||
id: int
|
||||
work_order_no: str
|
||||
status: WorkOrderStatus
|
||||
created_at: datetime
|
||||
updated_at: datetime | None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
61
docker-compose.yml
Normal file
61
docker-compose.yml
Normal file
@ -0,0 +1,61 @@
|
||||
# ================================================
|
||||
# PMS Docker Compose 配置
|
||||
# ================================================
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PMS 数据库(可读写)
|
||||
postgres-pms:
|
||||
image: postgres:15-alpine
|
||||
container_name: pms-db
|
||||
environment:
|
||||
POSTGRES_DB: pms_db
|
||||
POSTGRES_USER: pms_user
|
||||
POSTGRES_PASSWORD: pms_pass
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres-pms-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U pms_user -d pms_db"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# 后端服务
|
||||
backend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: backend/Dockerfile
|
||||
container_name: pms-backend
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# 连接真实的旧库存数据库
|
||||
DATABASE_URL_INVENTORY: postgresql://test:1234@host.docker.internal:5435/inventory_system
|
||||
DATABASE_URL_PMS: postgresql://pms_user:pms_pass@postgres-pms:5432/pms_db
|
||||
ports:
|
||||
- "8001:8001"
|
||||
depends_on:
|
||||
postgres-pms:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
# 把宿主机 backend/app/ 映射到容器 /app/app/,与 from app.xxx 匹配
|
||||
- ./backend/app:/app/app
|
||||
|
||||
# 前端服务
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: pms-frontend
|
||||
ports:
|
||||
- "5176:5173" # 将内部的 5173 映射到外部的 5176
|
||||
depends_on:
|
||||
- backend
|
||||
volumes:
|
||||
- ./frontend:/app # 将外面的 Vue 代码实时映射到容器内(热更新)
|
||||
- /app/node_modules # 保护容器内的 node_modules 不被外部覆盖
|
||||
|
||||
volumes:
|
||||
postgres-pms-data:
|
||||
24
frontend/Dockerfile
Normal file
24
frontend/Dockerfile
Normal file
@ -0,0 +1,24 @@
|
||||
# ================================================
|
||||
# PMS 前端 Dockerfile
|
||||
# Node 18 + Vue 3 + Vite
|
||||
# ================================================
|
||||
|
||||
FROM node:18-alpine
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 复制 package.json 和 package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# 安装依赖
|
||||
RUN npm install
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 5173
|
||||
|
||||
# 启动命令(开发模式,监听所有网络接口)
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PMS 生产管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
frontend/package.json
Normal file
27
frontend/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "pms-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.5",
|
||||
"element-plus": "^2.5.2",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@types/node": "^20.11.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.11",
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
24
frontend/src/App.vue
Normal file
24
frontend/src/App.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<el-config-provider :locale="zhCn">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
</script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
117
frontend/src/api/bom.ts
Normal file
117
frontend/src/api/bom.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 统一使用 /api 作为 baseURL,与 Vite proxy 配置匹配
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// ─── 成品搜索 ────────────────────────────────────────────────────────────
|
||||
export interface BomTarget {
|
||||
id: number
|
||||
name: string
|
||||
spec_model: string | null
|
||||
bom_no: string | null
|
||||
version: string | null
|
||||
bom_key: string // 唯一组合键 "parent_id_bom_no_version"
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
items: BomTarget[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
}
|
||||
|
||||
export const searchBomTargets = async (
|
||||
q: string,
|
||||
page: number = 1,
|
||||
size: number = 20
|
||||
): Promise<BomTarget[]> => {
|
||||
const response = await api.get('/material/search', {
|
||||
params: { q, page, size }
|
||||
})
|
||||
const data = response.data
|
||||
return Array.isArray(data) ? data : (data?.items ?? [])
|
||||
}
|
||||
|
||||
// ─── 齐套性推演 ──────────────────────────────────────────────────────────
|
||||
export interface MaterialRequirement {
|
||||
material_id: number
|
||||
material_name: string
|
||||
spec_model: string | null
|
||||
unit: string | null
|
||||
required_quantity: number
|
||||
current_stock: number
|
||||
shortage_quantity: number
|
||||
is_shortage: boolean
|
||||
}
|
||||
|
||||
export interface DeduceResult {
|
||||
target_base_id: number
|
||||
target_quantity: number
|
||||
bom_no: string | null
|
||||
version: string | null
|
||||
is_shortage: boolean
|
||||
total_shortage_count: number
|
||||
material_requirements: MaterialRequirement[]
|
||||
}
|
||||
|
||||
export const deduceBom = async (
|
||||
target_base_id: number,
|
||||
target_quantity: number,
|
||||
bom_no?: string | null,
|
||||
version?: string | null,
|
||||
): Promise<DeduceResult> => {
|
||||
const params: Record<string, unknown> = { target_base_id, target_quantity }
|
||||
if (bom_no != null) params.bom_no = bom_no
|
||||
if (version != null) params.version = version
|
||||
const response = await api.get('/pms/deduce_bom', { params })
|
||||
return response.data as DeduceResult
|
||||
}
|
||||
|
||||
// ─── 用户偏好 ────────────────────────────────────────────────────────────
|
||||
// favorite_target_ids 存储的是 bom_key 字符串
|
||||
export interface UserPreference {
|
||||
user_id: number
|
||||
default_bom_target_id: number | null
|
||||
favorite_target_ids: string[] // bom_key 列表
|
||||
}
|
||||
|
||||
export const getUserPreference = async (user_id: number): Promise<UserPreference> => {
|
||||
const response = await api.get('/pms/preference', { params: { user_id } })
|
||||
return response.data as UserPreference
|
||||
}
|
||||
|
||||
export const putUserPreference = async (
|
||||
user_id: number,
|
||||
data: { default_bom_target_id?: number | null; favorite_target_ids?: string[] | null }
|
||||
): Promise<UserPreference> => {
|
||||
const response = await api.put('/pms/preference', data, { params: { user_id } })
|
||||
return response.data as UserPreference
|
||||
}
|
||||
|
||||
// ─── 常看增删(精确路径)───────────────────────────────────────────────
|
||||
export const addFavoriteTarget = async (user_id: number, bom_key: string): Promise<UserPreference> => {
|
||||
const response = await api.post('/pms/preference/favorite/add', null, {
|
||||
params: { user_id, target_id: bom_key }
|
||||
})
|
||||
return response.data as UserPreference
|
||||
}
|
||||
|
||||
export const removeFavoriteTarget = async (user_id: number, bom_key: string): Promise<UserPreference> => {
|
||||
const response = await api.post('/pms/preference/favorite/remove', null, {
|
||||
params: { user_id, target_id: bom_key }
|
||||
})
|
||||
return response.data as UserPreference
|
||||
}
|
||||
|
||||
// ─── 保存排序(替换全部顺序)────────────────────────────────────────────
|
||||
export const reorderFavorites = (user_id: number, favorite_keys: string[]): Promise<UserPreference> => {
|
||||
return api.post(`/pms/preference/favorite/reorder?user_id=${user_id}`, {
|
||||
favorite_keys
|
||||
}).then(res => res.data as UserPreference)
|
||||
}
|
||||
|
||||
import { getMaterialList } from '@/api/material'
|
||||
export { getMaterialList }
|
||||
41
frontend/src/api/material.ts
Normal file
41
frontend/src/api/material.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import axios from 'axios'
|
||||
import type { ApprovalItem, ApprovalForm, MaterialBase } from '@/types'
|
||||
|
||||
// 统一使用 /api 作为 baseURL,与 Vite proxy 配置匹配
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
// 获取物料列表(从库存库读取,只读)
|
||||
export const getMaterialList = async (): Promise<MaterialBase[]> => {
|
||||
const response = await api.get('/material')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 获取待审批列表
|
||||
export const getPendingApprovals = async (): Promise<ApprovalItem[]> => {
|
||||
const response = await api.get('/approvals')
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 提交缺料申请
|
||||
export const createApproval = async (data: ApprovalForm): Promise<ApprovalItem> => {
|
||||
const response = await api.post('/approvals', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 更新审批状态(主管操作)
|
||||
export const updateApprovalStatus = async (
|
||||
id: number,
|
||||
status: 'APPROVED' | 'REJECTED'
|
||||
): Promise<ApprovalItem> => {
|
||||
const response = await api.put(`/approvals/${id}/status`, { status })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// 获取审批详情
|
||||
export const getApprovalDetail = async (id: number): Promise<ApprovalItem> => {
|
||||
const response = await api.get(`/approvals/${id}`)
|
||||
return response.data
|
||||
}
|
||||
145
frontend/src/api/project.ts
Normal file
145
frontend/src/api/project.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 统一使用 /api 作为 baseURL,与 Vite proxy 配置匹配
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
// ─── 类型定义 ──────────────────────────────────────────────────────────
|
||||
export enum ProjectStatus {
|
||||
DRAFT = 'draft',
|
||||
ACTIVE = 'active',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
export interface ProjectStats {
|
||||
project_id: number
|
||||
project_no: string
|
||||
project_name: string
|
||||
status: ProjectStatus
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
|
||||
// 进度相关
|
||||
total_work_orders: number
|
||||
completed_work_orders: number
|
||||
in_progress_work_orders: number
|
||||
pending_work_orders: number
|
||||
progress_percentage: number
|
||||
|
||||
// 物料到位率
|
||||
total_approvals: number
|
||||
pending_approvals: number
|
||||
approved_approvals: number
|
||||
rejected_approvals: number
|
||||
material_ready_rate: number
|
||||
|
||||
// 延期预警
|
||||
is_overdue: boolean
|
||||
overdue_days: number | null
|
||||
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
total_projects: number
|
||||
active_projects: number
|
||||
completed_projects: number
|
||||
overdue_projects: number
|
||||
|
||||
// 全局统计
|
||||
total_work_orders: number
|
||||
completed_work_orders: number
|
||||
overall_progress: number
|
||||
|
||||
total_pending_approvals: number
|
||||
overall_material_ready_rate: number
|
||||
|
||||
// 项目列表
|
||||
projects: ProjectStats[]
|
||||
}
|
||||
|
||||
// ─── API 接口 ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 获取项目总览汇总数据
|
||||
* 后端路径: GET /api/pms/projects/summary
|
||||
* @param status 可选的项目状态筛选
|
||||
*/
|
||||
export const getProjectSummary = async (
|
||||
status?: ProjectStatus
|
||||
): Promise<ProjectSummary> => {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (status) {
|
||||
params.status = status
|
||||
}
|
||||
|
||||
const response = await api.get('/pms/projects/summary', { params })
|
||||
return response.data as ProjectSummary
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单个项目的详细统计信息
|
||||
* 后端路径: GET /api/pms/projects/{project_id}/stats
|
||||
* @param projectId 项目ID
|
||||
*/
|
||||
export const getProjectStats = async (projectId: number): Promise<ProjectStats> => {
|
||||
const response = await api.get(`/pms/projects/${projectId}/stats`)
|
||||
return response.data as ProjectStats
|
||||
}
|
||||
|
||||
// ─── 类型定义 ──────────────────────────────────────────────────────────
|
||||
export interface ProjectCreateParams {
|
||||
project_no: string
|
||||
project_name: string
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
status?: ProjectStatus
|
||||
}
|
||||
|
||||
export interface ProjectResponse {
|
||||
id: number
|
||||
project_no: string
|
||||
name: string
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
status: ProjectStatus
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新项目
|
||||
* 后端路径: POST /api/pms/projects
|
||||
* @param data 项目创建参数
|
||||
*/
|
||||
export const createProject = async (
|
||||
data: ProjectCreateParams
|
||||
): Promise<ProjectResponse> => {
|
||||
const payload = {
|
||||
project_no: data.project_no,
|
||||
name: data.project_name,
|
||||
start_date: data.start_date ?? null,
|
||||
end_date: data.end_date ?? null,
|
||||
status: data.status ?? ProjectStatus.DRAFT,
|
||||
}
|
||||
const response = await api.post('/pms/projects', payload)
|
||||
return response.data as ProjectResponse
|
||||
}
|
||||
|
||||
// 状态显示映射
|
||||
export const projectStatusMap: Record<ProjectStatus, { label: string; type: string }> = {
|
||||
[ProjectStatus.DRAFT]: { label: '草稿', type: 'info' },
|
||||
[ProjectStatus.ACTIVE]: { label: '进行中', type: 'primary' },
|
||||
[ProjectStatus.COMPLETED]: { label: '已完成', type: 'success' },
|
||||
[ProjectStatus.CANCELLED]: { label: '已取消', type: 'warning' },
|
||||
}
|
||||
|
||||
// 延期状态显示
|
||||
export const overdueStatusMap = {
|
||||
normal: { label: '正常', type: 'success' },
|
||||
warning: { label: '即将到期', type: 'warning' },
|
||||
danger: { label: '已延期', type: 'danger' }
|
||||
}
|
||||
123
frontend/src/api/workOrder.ts
Normal file
123
frontend/src/api/workOrder.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// 统一使用 /api 作为 baseURL,与 Vite proxy 配置匹配
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
// ─── 类型定义 ──────────────────────────────────────────────────────────
|
||||
export enum WorkOrderStatus {
|
||||
PENDING = 'pending',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
export interface WorkOrderKanbanItem {
|
||||
id: number
|
||||
work_order_no: string
|
||||
project_id: number
|
||||
target_base_id: number
|
||||
target_quantity: number
|
||||
assignee_name: string | null
|
||||
status: WorkOrderStatus
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
|
||||
// 关联信息
|
||||
project_name: string | null
|
||||
material_name: string | null
|
||||
material_spec: string | null
|
||||
}
|
||||
|
||||
export interface PaginatedWorkOrders {
|
||||
items: WorkOrderKanbanItem[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
export interface StatusCounts {
|
||||
pending: number
|
||||
in_progress: number
|
||||
completed: number
|
||||
cancelled: number
|
||||
}
|
||||
|
||||
// ─── API 接口 ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface WorkOrderKanbanParams {
|
||||
status?: WorkOrderStatus
|
||||
project_id?: number
|
||||
assignee_name?: string
|
||||
search?: string
|
||||
page?: number
|
||||
size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工单看板列表
|
||||
*/
|
||||
export const getWorkOrderKanban = async (
|
||||
params: WorkOrderKanbanParams = {}
|
||||
): Promise<PaginatedWorkOrders> => {
|
||||
const response = await api.get('/pms/work_order_kanban', { params })
|
||||
return response.data as PaginatedWorkOrders
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取各状态工单数量统计
|
||||
*/
|
||||
export const getStatusCounts = async (): Promise<StatusCounts> => {
|
||||
const response = await api.get('/pms/work_order_kanban/status-counts')
|
||||
return response.data as StatusCounts
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新工单状态
|
||||
*/
|
||||
export const updateWorkOrderStatus = async (
|
||||
workOrderId: number,
|
||||
status: WorkOrderStatus
|
||||
): Promise<WorkOrderKanbanItem> => {
|
||||
const response = await api.put(`/pms/work_order/${workOrderId}/status`, { status })
|
||||
return response.data as WorkOrderKanbanItem
|
||||
}
|
||||
|
||||
// ─── 状态配置映射 ──────────────────────────────────────────────────────
|
||||
export const workOrderStatusConfig: Record<WorkOrderStatus, { label: string; type: string; color: string }> = {
|
||||
[WorkOrderStatus.PENDING]: {
|
||||
label: '待生产',
|
||||
type: 'warning',
|
||||
color: '#E6A23C'
|
||||
},
|
||||
[WorkOrderStatus.IN_PROGRESS]: {
|
||||
label: '生产中',
|
||||
type: 'primary',
|
||||
color: '#409EFF'
|
||||
},
|
||||
[WorkOrderStatus.COMPLETED]: {
|
||||
label: '已完成',
|
||||
type: 'success',
|
||||
color: '#67C23A'
|
||||
},
|
||||
[WorkOrderStatus.CANCELLED]: {
|
||||
label: '已取消',
|
||||
type: 'info',
|
||||
color: '#909399'
|
||||
}
|
||||
}
|
||||
|
||||
// 看板列配置
|
||||
export const kanbanColumns: Array<{
|
||||
key: WorkOrderStatus
|
||||
title: string
|
||||
color: string
|
||||
}> = [
|
||||
{ key: WorkOrderStatus.PENDING, title: '待生产', color: '#E6A23C' },
|
||||
{ key: WorkOrderStatus.IN_PROGRESS, title: '生产中', color: '#409EFF' },
|
||||
{ key: WorkOrderStatus.COMPLETED, title: '已完成', color: '#67C23A' },
|
||||
{ key: WorkOrderStatus.CANCELLED, title: '已取消', color: '#909399' }
|
||||
]
|
||||
80
frontend/src/components/AppLayout.vue
Normal file
80
frontend/src/components/AppLayout.vue
Normal file
@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<!-- 左侧菜单 -->
|
||||
<aside class="sidebar">
|
||||
<div class="logo">PMS 系统</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
router
|
||||
class="sidebar-menu"
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409EFF"
|
||||
>
|
||||
<el-menu-item index="/project">
|
||||
<el-icon><Folder /></el-icon>
|
||||
<span>项目总览</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/kanban">
|
||||
<el-icon><Grid /></el-icon>
|
||||
<span>工单看板</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/bom">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>BOM分析</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/approval">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>缺料审批</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</aside>
|
||||
|
||||
<!-- 右侧内容 -->
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Folder, Grid, Document, List } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const activeMenu = computed(() => route.path)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background-color: #304156;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
line-height: 60px;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
background-color: #263445;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
14
frontend/src/main.ts
Normal file
14
frontend/src/main.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
43
frontend/src/router/index.ts
Normal file
43
frontend/src/router/index.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/project'
|
||||
},
|
||||
{
|
||||
path: 'project',
|
||||
name: 'ProjectOverview',
|
||||
component: () => import('@/views/ProjectOverview.vue')
|
||||
},
|
||||
{
|
||||
path: 'kanban',
|
||||
name: 'WorkOrderKanban',
|
||||
component: () => import('@/views/WorkOrderKanban.vue')
|
||||
},
|
||||
{
|
||||
path: 'bom',
|
||||
name: 'BomAnalysis',
|
||||
component: () => import('@/views/BomAnalysis.vue')
|
||||
},
|
||||
{
|
||||
path: 'approval',
|
||||
name: 'MaterialApproval',
|
||||
component: () => import('@/views/MaterialApproval.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
33
frontend/src/types/index.ts
Normal file
33
frontend/src/types/index.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export enum ApprovalStatus {
|
||||
PENDING = 'PENDING',
|
||||
APPROVED = 'APPROVED',
|
||||
REJECTED = 'REJECTED'
|
||||
}
|
||||
|
||||
export interface MaterialBase {
|
||||
id: number
|
||||
material_code: string
|
||||
material_name: string
|
||||
specification: string
|
||||
unit: string
|
||||
unit_price: number
|
||||
}
|
||||
|
||||
export interface ApprovalItem {
|
||||
id: number
|
||||
work_order_id: number
|
||||
work_order_no: string
|
||||
missing_material_id: number
|
||||
material_name: string
|
||||
required_qty: number
|
||||
reason: string
|
||||
status: ApprovalStatus
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApprovalForm {
|
||||
work_order_id: number
|
||||
missing_material_id: number
|
||||
required_qty: number
|
||||
reason: string
|
||||
}
|
||||
767
frontend/src/views/BomAnalysis.vue
Normal file
767
frontend/src/views/BomAnalysis.vue
Normal file
@ -0,0 +1,767 @@
|
||||
<template>
|
||||
<div class="bom-page">
|
||||
<h1 class="page-title">BOM 齐套性看板</h1>
|
||||
|
||||
<!-- ① 顶部搜索添加区 -->
|
||||
<el-card class="search-card">
|
||||
<div class="search-row">
|
||||
<el-select
|
||||
v-model="searchSelectedKey"
|
||||
placeholder="搜索成品名称或 BOM 编号..."
|
||||
filterable
|
||||
remote
|
||||
:remote-method="doSearch"
|
||||
:loading="searching"
|
||||
style="width: 420px"
|
||||
clearable
|
||||
@focus="onSelectFocus"
|
||||
@clear="searchSelectedKey = null; searchResults = []"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in searchResults"
|
||||
:key="item.bom_key"
|
||||
:label="`${item.name} | ${item.bom_no || '无编号'} ${item.version ? `(${item.version})` : ''}`"
|
||||
:value="item.bom_key"
|
||||
:disabled="isTracked(item.bom_key)"
|
||||
>
|
||||
<div class="search-option">
|
||||
<span class="opt-name">{{ item.name }}</span>
|
||||
<span class="opt-meta">{{ item.bom_no || '无编号' }} {{ item.version ? `(${item.version})` : '' }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-button type="primary" :disabled="!searchSelectedKey" @click="addTracked">
|
||||
+ 添加到看板
|
||||
</el-button>
|
||||
<span class="tracked-count">已监控 {{ trackedProducts.length }} 个成品</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- ② 单行全宽垂直卡片列表 -->
|
||||
<div v-if="trackedProducts.length > 0" class="card-list">
|
||||
<el-card
|
||||
v-for="(card, index) in trackedProducts"
|
||||
:key="card.bom_key"
|
||||
class="product-card"
|
||||
shadow="hover"
|
||||
>
|
||||
<!-- 卡片头部 -->
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div class="card-header-left">
|
||||
<span class="card-title">{{ card.name }}</span>
|
||||
<span class="card-meta">
|
||||
{{ card.bom_no || '无编号' }}
|
||||
{{ card.version ? ` (${card.version})` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-header-actions">
|
||||
<el-tag v-if="card.maxProduction === 0" type="danger" size="small">库存不足</el-tag>
|
||||
<el-tag v-else type="success" size="small">可产 {{ card.maxProduction }} 套</el-tag>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="index === 0"
|
||||
@click="moveCard(index, 'up')"
|
||||
>
|
||||
↑ 上移
|
||||
</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="index === trackedProducts.length - 1"
|
||||
@click="moveCard(index, 'down')"
|
||||
>
|
||||
↓ 下移
|
||||
</el-button>
|
||||
<el-button link type="warning" size="small" @click="removeCard(card, index)">
|
||||
✕ 移除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Loading 骨架 -->
|
||||
<div v-if="card.loading" class="loading-area">
|
||||
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
|
||||
<span class="loading-text">正在分析中...</span>
|
||||
</div>
|
||||
|
||||
<!-- 分析完成:左侧大字 + 右侧排产模拟 -->
|
||||
<div v-else-if="card.result" class="card-content">
|
||||
<!-- 左侧:最大产能大字 -->
|
||||
<div class="left-max">
|
||||
<div class="max-label">当前库存最大可产</div>
|
||||
<div class="max-number" :class="card.maxProduction === 0 ? 'num-zero' : 'num-ok'">
|
||||
{{ card.maxProduction }}
|
||||
</div>
|
||||
<div class="max-unit">套</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:排产模拟 -->
|
||||
<div class="right-detail">
|
||||
<!-- 计划排产模拟输入框 -->
|
||||
<div class="simulate-header">
|
||||
<span class="simulate-label">计划排产模拟:</span>
|
||||
<el-input-number
|
||||
v-model="card.targetQty"
|
||||
:min="1"
|
||||
:max="99999"
|
||||
size="small"
|
||||
@change="recalculateCard(card, index)"
|
||||
/>
|
||||
<span class="simulate-unit">套</span>
|
||||
</div>
|
||||
|
||||
<!-- 目标量 ≤ 库存最大产能 → 齐套 -->
|
||||
<template v-if="card.targetQty <= card.maxProduction">
|
||||
<div class="full-tip">
|
||||
<el-icon color="#67c23a" :size="16"><CircleCheck /></el-icon>
|
||||
物料齐套,可直接下发生产!
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 目标量 > 库存最大产能 → 显示缺口 -->
|
||||
<template v-else>
|
||||
<div class="detail-tip danger">
|
||||
<el-icon color="#f56c6c" :size="16"><WarningFilled /></el-icon>
|
||||
存在缺口!距离目标 {{ card.targetQty }} 套还缺以下物料:
|
||||
</div>
|
||||
<el-table
|
||||
v-if="shortageRows(card.result).length > 0"
|
||||
:data="shortageRows(card.result)"
|
||||
border
|
||||
stripe
|
||||
size="small"
|
||||
class="shortage-table"
|
||||
>
|
||||
<el-table-column prop="material_name" label="物料名称" min-width="160" />
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="unit" label="单位" width="60" align="center" />
|
||||
<el-table-column label="计划需求" width="90" align="right">
|
||||
<template #default="{ row }">{{ Number(row.required_quantity).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="当前库存" width="90" align="right">
|
||||
<template #default="{ row }">{{ Number(row.current_stock).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="缺口" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="shortage-val">-{{ Number(row.shortage_quantity).toLocaleString() }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="full-tip">
|
||||
<el-icon color="#67c23a" :size="16"><CircleCheck /></el-icon>
|
||||
物料齐套,可直接下发生产!
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分析失败 -->
|
||||
<div v-else class="error-area">
|
||||
<el-icon color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
|
||||
<span>分析失败,请检查网络后重试</span>
|
||||
<el-button type="primary" link size="small" @click="retryAnalyze(card, index)">
|
||||
重试
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-card v-else class="empty-card">
|
||||
<el-empty description="监控列表为空,请在上方搜索并添加成品" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { CircleCheck, WarningFilled, Loading, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
searchBomTargets,
|
||||
deduceBom,
|
||||
getUserPreference,
|
||||
addFavoriteTarget,
|
||||
removeFavoriteTarget,
|
||||
reorderFavorites,
|
||||
type DeduceResult,
|
||||
type MaterialRequirement,
|
||||
} from '@/api/bom'
|
||||
|
||||
const CURRENT_USER_ID = 1
|
||||
|
||||
// ─── 路由 ─────────────────────────────────────────────────────────────
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ─── 类型 ─────────────────────────────────────────────────────────────
|
||||
interface BomTarget {
|
||||
id: number
|
||||
name: string
|
||||
spec_model: string | null
|
||||
bom_no: string | null
|
||||
version: string | null
|
||||
bom_key: string
|
||||
}
|
||||
|
||||
interface TrackedCard {
|
||||
id: number
|
||||
name: string
|
||||
spec_model: string | null
|
||||
bom_no: string | null
|
||||
version: string | null
|
||||
bom_key: string
|
||||
targetQty: number
|
||||
maxProduction: number
|
||||
loading: boolean
|
||||
result: DeduceResult | null
|
||||
analyzeError: boolean
|
||||
}
|
||||
|
||||
// ─── 状态 ─────────────────────────────────────────────────────────────
|
||||
// 标记是否正在执行初始化(防止 watch 多次触发时重复执行)
|
||||
const isInitializing = ref(false)
|
||||
const searchSelectedKey = ref<string | null>(null)
|
||||
const searchResults = ref<BomTarget[]>([])
|
||||
const searching = ref(false)
|
||||
const trackedProducts = ref<TrackedCard[]>([])
|
||||
|
||||
// ─── 辅助 ─────────────────────────────────────────────────────────────
|
||||
const parseItems = (raw: unknown): BomTarget[] => {
|
||||
if (!raw) return []
|
||||
return Array.isArray(raw) ? raw : (Array.isArray((raw as any).items) ? (raw as any).items : [])
|
||||
}
|
||||
|
||||
const shortageRows = (result: DeduceResult) =>
|
||||
(result?.material_requirements ?? []).filter(r => Number(r.shortage_quantity) > 0)
|
||||
|
||||
const isTracked = (bom_key: string) =>
|
||||
trackedProducts.value.some(c => c.bom_key === bom_key)
|
||||
|
||||
// ─── 搜索 ─────────────────────────────────────────────────────────────
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const doSearch = (q: string) => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (!q) {
|
||||
searchBomTargets('', 1, 20)
|
||||
.then(items => { searchResults.value = items })
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
searchTimer = setTimeout(async () => {
|
||||
searching.value = true
|
||||
try {
|
||||
searchResults.value = parseItems(await searchBomTargets(q, 1, 50))
|
||||
} catch (e) {
|
||||
console.error('[搜索失败]', e)
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
searching.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const onSelectFocus = () => {
|
||||
if (searchResults.value.length === 0) {
|
||||
searchBomTargets('', 1, 20)
|
||||
.then(items => { searchResults.value = items })
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 添加到看板 ──────────────────────────────────────────────────────
|
||||
const addCardToKanban = async (
|
||||
item: BomTarget,
|
||||
options?: { skipFavorite?: boolean; autoAnalyze?: boolean }
|
||||
) => {
|
||||
if (isTracked(item.bom_key)) {
|
||||
// 已存在则滚动到该卡片位置
|
||||
ElMessage.warning(`「${item.name}」已在看板中`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!options?.skipFavorite) {
|
||||
try {
|
||||
await addFavoriteTarget(CURRENT_USER_ID, item.bom_key)
|
||||
} catch (e) {
|
||||
console.warn('[添加常看失败]', e)
|
||||
}
|
||||
}
|
||||
|
||||
const card: TrackedCard = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
spec_model: item.spec_model,
|
||||
bom_no: item.bom_no,
|
||||
version: item.version,
|
||||
bom_key: item.bom_key,
|
||||
targetQty: 1,
|
||||
maxProduction: 0,
|
||||
loading: true,
|
||||
result: null,
|
||||
analyzeError: false,
|
||||
}
|
||||
|
||||
const index = trackedProducts.value.length
|
||||
trackedProducts.value.push(card)
|
||||
|
||||
searchSelectedKey.value = null
|
||||
searchResults.value = []
|
||||
|
||||
if (options?.autoAnalyze !== false) {
|
||||
autoAnalyze(card, index)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 全自动推演(两阶段)──────────────────────────────────────────────
|
||||
const autoAnalyze = async (card: TrackedCard, index: number) => {
|
||||
// 确保卡片仍存在于看板中(可能被用户移除)
|
||||
if (index >= trackedProducts.value.length ||
|
||||
trackedProducts.value[index].bom_key !== card.bom_key) {
|
||||
return
|
||||
}
|
||||
|
||||
trackedProducts.value[index] = { ...card, loading: true, result: null }
|
||||
|
||||
try {
|
||||
// 第一阶段:推演 1 套,计算最大产能
|
||||
const res1 = await deduceBom(card.id, 1, card.bom_no, card.version)
|
||||
const rows: MaterialRequirement[] = res1.material_requirements || []
|
||||
let min = Infinity
|
||||
for (const row of rows) {
|
||||
if (Number(row.required_quantity) > 0) {
|
||||
const possible = Math.floor(
|
||||
Number(row.current_stock) / Number(row.required_quantity)
|
||||
)
|
||||
min = Math.min(min, possible)
|
||||
}
|
||||
}
|
||||
const maxProd = min === Infinity ? 0 : Math.max(0, min)
|
||||
const targetQty = maxProd === 0 ? 1 : maxProd + 1
|
||||
|
||||
// 第二阶段:用 targetQty 推演,获取缺料明细
|
||||
const res2 = await deduceBom(card.id, targetQty, card.bom_no, card.version)
|
||||
|
||||
// 再次校验:卡片是否仍对应同一 bom_key(避免竞态)
|
||||
if (index < trackedProducts.value.length &&
|
||||
trackedProducts.value[index].bom_key === card.bom_key) {
|
||||
trackedProducts.value[index] = {
|
||||
...card,
|
||||
targetQty,
|
||||
maxProduction: maxProd,
|
||||
loading: false,
|
||||
result: res2,
|
||||
analyzeError: false,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[自动分析失败]', card.name, e)
|
||||
if (index < trackedProducts.value.length &&
|
||||
trackedProducts.value[index].bom_key === card.bom_key) {
|
||||
trackedProducts.value[index] = {
|
||||
...card,
|
||||
targetQty: 1,
|
||||
maxProduction: 0,
|
||||
loading: false,
|
||||
result: null,
|
||||
analyzeError: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 重试分析 ─────────────────────────────────────────────────────────
|
||||
const retryAnalyze = (card: TrackedCard, index: number) => {
|
||||
autoAnalyze(card, index)
|
||||
}
|
||||
|
||||
// ─── 重新计算(用户修改数量时触发)───────────────────────────────────
|
||||
const recalculateCard = async (card: TrackedCard, index: number) => {
|
||||
trackedProducts.value[index] = { ...card, loading: true }
|
||||
try {
|
||||
const res = await deduceBom(card.id, card.targetQty, card.bom_no, card.version)
|
||||
trackedProducts.value[index] = { ...card, loading: false, result: res }
|
||||
} catch (e) {
|
||||
console.error('[重新计算失败]', card.name, e)
|
||||
ElMessage.error('重新计算失败')
|
||||
trackedProducts.value[index] = { ...card, loading: false }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 上移 / 下移 ─────────────────────────────────────────────────────
|
||||
const moveCard = async (index: number, direction: 'up' | 'down') => {
|
||||
const arr = trackedProducts.value
|
||||
const target = direction === 'up' ? index - 1 : index + 1
|
||||
if (target < 0 || target >= arr.length) return
|
||||
;[arr[index], arr[target]] = [arr[target], arr[index]]
|
||||
reorderFavorites(CURRENT_USER_ID, arr.map(c => c.bom_key)).catch(err => {
|
||||
console.warn('[保存排序失败]', err)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 添加(搜索下拉)─────────────────────────────────────────────────
|
||||
const addTracked = async () => {
|
||||
const item = searchResults.value.find(i => i.bom_key === searchSelectedKey.value)
|
||||
if (!item) return
|
||||
await addCardToKanban(item)
|
||||
}
|
||||
|
||||
// ─── 移除 ─────────────────────────────────────────────────────────────
|
||||
const removeCard = async (card: TrackedCard, index: number) => {
|
||||
try {
|
||||
await removeFavoriteTarget(CURRENT_USER_ID, card.bom_key)
|
||||
} catch (e) {
|
||||
console.warn('[移除常看失败]', e)
|
||||
}
|
||||
trackedProducts.value.splice(index, 1)
|
||||
ElMessage.success(`已移除「${card.name}」`)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 统一入口:watch route.query
|
||||
// 负责:①加载收藏列表 ②处理路由参数(接力加载)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
watch(
|
||||
() => route.query,
|
||||
async (query) => {
|
||||
console.log('[BomAnalysis] route.query 变化:', query)
|
||||
|
||||
// 防止重复触发
|
||||
if (isInitializing.value) {
|
||||
console.log('[BomAnalysis] 正在初始化,跳过')
|
||||
return
|
||||
}
|
||||
isInitializing.value = true
|
||||
|
||||
try {
|
||||
// ── 步骤 1:加载完整成品列表(解析 bom_key 需要用到) ──
|
||||
let allTargets: BomTarget[] = []
|
||||
try {
|
||||
allTargets = parseItems(await searchBomTargets('', 1, 100))
|
||||
} catch (e) {
|
||||
console.error('[BomAnalysis] 成品列表加载失败', e)
|
||||
allTargets = []
|
||||
}
|
||||
|
||||
const allMap = new Map<string, BomTarget>()
|
||||
allTargets.forEach(t => allMap.set(t.bom_key, t))
|
||||
|
||||
// searchResults 同步更新(保障搜索下拉可用)
|
||||
if (searchResults.value.length === 0) {
|
||||
searchResults.value = allTargets
|
||||
}
|
||||
|
||||
// ── 步骤 2:从收藏加载已有卡片 ──
|
||||
try {
|
||||
const pref = await getUserPreference(CURRENT_USER_ID)
|
||||
const favKeys: string[] = Array.isArray(pref?.favorite_target_ids)
|
||||
? pref.favorite_target_ids
|
||||
: []
|
||||
|
||||
for (const key of favKeys) {
|
||||
if (allMap.has(key) && !isTracked(key)) {
|
||||
const item = allMap.get(key)!
|
||||
const card: TrackedCard = {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
spec_model: item.spec_model,
|
||||
bom_no: item.bom_no,
|
||||
version: item.version,
|
||||
bom_key: item.bom_key,
|
||||
targetQty: 1,
|
||||
maxProduction: 0,
|
||||
loading: true,
|
||||
result: null,
|
||||
analyzeError: false,
|
||||
}
|
||||
const idx = trackedProducts.value.length
|
||||
trackedProducts.value.push(card)
|
||||
autoAnalyze(card, idx)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[BomAnalysis] 收藏加载失败', e)
|
||||
}
|
||||
|
||||
// ── 步骤 3:处理路由参数(接力加载) ──
|
||||
// 期望参数格式:?target_id=123&bom_no=XXX&version=V1
|
||||
// 或者直接传 bom_key:?bom_key=123_XXX_V1
|
||||
const targetId = query.target_id
|
||||
const bomKeyFromQuery = query.bom_key
|
||||
|
||||
if (targetId || bomKeyFromQuery) {
|
||||
let matchedItem: BomTarget | null = null
|
||||
|
||||
if (bomKeyFromQuery && typeof bomKeyFromQuery === 'string') {
|
||||
// 优先使用完整的 bom_key 匹配
|
||||
matchedItem = allMap.get(bomKeyFromQuery) ?? null
|
||||
if (!matchedItem) {
|
||||
console.warn(`[BomAnalysis] bom_key="${bomKeyFromQuery}" 在列表中未找到`)
|
||||
}
|
||||
} else if (targetId) {
|
||||
// fallback:用 target_id + bom_no + version 拼装 bom_key 尝试匹配
|
||||
const qId = String(targetId)
|
||||
const qNo = query.bom_no ? String(query.bom_no) : undefined
|
||||
const qVer = query.version ? String(query.version) : undefined
|
||||
|
||||
matchedItem =
|
||||
allTargets.find(t => {
|
||||
const idMatch = String(t.id) === qId
|
||||
const noMatch = !qNo || t.bom_no === qNo || t.bom_no == null
|
||||
const verMatch = !qVer || t.version === qVer || t.version == null
|
||||
return idMatch && noMatch && verMatch
|
||||
}) ?? null
|
||||
|
||||
if (!matchedItem) {
|
||||
console.warn(
|
||||
`[BomAnalysis] target_id="${targetId}" 在列表中未找到,尝试匹配首项`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedItem) {
|
||||
console.log('[BomAnalysis] 路由参数命中成品,自动添加到看板:', matchedItem.name)
|
||||
await addCardToKanban(matchedItem)
|
||||
} else if (allTargets.length > 0) {
|
||||
// 兜底:默认选中列表第一项
|
||||
ElMessage.warning(
|
||||
`未找到指定成品(ID=${targetId ?? bomKeyFromQuery}),已自动选中列表第一项`
|
||||
)
|
||||
await addCardToKanban(allTargets[0])
|
||||
} else {
|
||||
ElMessage.error('成品列表为空,无法加载路由指定的 BOM')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[BomAnalysis] 初始化完成')
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true } // 组件首次创建时立即执行一次
|
||||
)
|
||||
|
||||
// ─── 生命周期 ─────────────────────────────────────────────────────────
|
||||
onUnmounted(() => {
|
||||
console.log('[BomAnalysis] onUnmounted - 清理状态')
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
}
|
||||
isInitializing.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bom-page {
|
||||
padding: 24px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 搜索区 */
|
||||
.search-card { margin-bottom: 24px; }
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tracked-count {
|
||||
margin-left: auto;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── 单行全宽垂直卡片列表 ── */
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.product-card {
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.card-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 280px;
|
||||
}
|
||||
.card-meta {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 卡片内容 */
|
||||
.card-content {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 左侧:最大产能大字 */
|
||||
.left-max {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #f0f9ff;
|
||||
border: 2px solid #3370ff;
|
||||
border-radius: 12px;
|
||||
padding: 20px 28px;
|
||||
min-width: 200px;
|
||||
}
|
||||
.max-label {
|
||||
font-size: 13px;
|
||||
color: #3370ff;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.max-number {
|
||||
font-size: 60px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -4px;
|
||||
}
|
||||
.num-zero { color: #f56c6c; }
|
||||
.num-ok { color: #3370ff; }
|
||||
.max-unit {
|
||||
font-size: 14px;
|
||||
color: #8c9dbe;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* 右侧:排产模拟 */
|
||||
.right-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 模拟输入框区域 */
|
||||
.simulate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.simulate-label {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
}
|
||||
.simulate-unit {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 提示文字 */
|
||||
.detail-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.detail-tip.danger { color: #f56c6c; }
|
||||
.detail-tip.warning { color: #e6a23c; }
|
||||
.shortage-table { margin-top: 0; }
|
||||
.shortage-val {
|
||||
color: #f56c6c;
|
||||
font-weight: bold;
|
||||
}
|
||||
.full-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
background: #f0f9eb;
|
||||
border-radius: 6px;
|
||||
color: #67c23a;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Loading / Error */
|
||||
.loading-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 0;
|
||||
color: #909399;
|
||||
}
|
||||
.loading-text { font-size: 13px; }
|
||||
.error-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #f56c6c;
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 下拉选项 */
|
||||
.search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
.opt-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.opt-meta {
|
||||
color: #8492a6;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.empty-card { margin-top: 20px; }
|
||||
</style>
|
||||
321
frontend/src/views/MaterialApproval.vue
Normal file
321
frontend/src/views/MaterialApproval.vue
Normal file
@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="approval-container">
|
||||
<h1 class="page-title">缺料审批</h1>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<!-- 左侧:员工申请表单 -->
|
||||
<el-col :span="8">
|
||||
<el-card class="form-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>提交缺料申请</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
class="approval-form"
|
||||
>
|
||||
<el-form-item label="工单ID" prop="work_order_id">
|
||||
<el-input
|
||||
v-model.number="form.work_order_id"
|
||||
placeholder="请输入工单ID"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="缺少物料" prop="missing_material_id">
|
||||
<el-select
|
||||
v-model="form.missing_material_id"
|
||||
placeholder="请选择缺少的物料"
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in materialList"
|
||||
:key="item.id"
|
||||
:label="`${item.material_name} (${item.material_code})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="需求数量" prop="required_qty">
|
||||
<el-input-number
|
||||
v-model="form.required_qty"
|
||||
:min="1"
|
||||
:max="99999"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="缺料原因" prop="reason">
|
||||
<el-input
|
||||
v-model="form.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请描述缺料原因..."
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="submitForm" :loading="submitting">
|
||||
提交申请
|
||||
</el-button>
|
||||
<el-button @click="resetForm">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 右侧:主管审批列表 -->
|
||||
<el-col :span="16">
|
||||
<el-card class="table-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>待审批列表</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
@click="loadApprovals"
|
||||
style="margin-left: auto"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
:data="approvalList"
|
||||
border
|
||||
stripe
|
||||
v-loading="loading"
|
||||
empty-text="暂无待审批数据"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="work_order_no" label="工单编号" width="100" />
|
||||
<el-table-column prop="material_name" label="缺少物料" min-width="150" />
|
||||
<el-table-column prop="required_qty" label="需求数量" width="90" align="center" />
|
||||
<el-table-column prop="reason" label="缺料原因" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" effect="dark" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'PENDING'">
|
||||
<el-button type="success" size="small" @click="handleApprove(row)" :loading="row.processing">
|
||||
批准
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="handleReject(row)" :loading="row.processing">
|
||||
拒绝
|
||||
</el-button>
|
||||
</template>
|
||||
<span v-else class="processed-text">已处理</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus, List, Refresh } from '@element-plus/icons-vue'
|
||||
import { getMaterialList, getPendingApprovals, createApproval, updateApprovalStatus } from '@/api/material'
|
||||
import type { ApprovalItem, MaterialBase, ApprovalForm } from '@/types'
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const materialList = ref<MaterialBase[]>([])
|
||||
const approvalList = ref<(ApprovalItem & { processing?: boolean })[]>([])
|
||||
|
||||
const form = reactive<ApprovalForm>({
|
||||
work_order_id: 0,
|
||||
missing_material_id: 0,
|
||||
required_qty: 1,
|
||||
reason: ''
|
||||
})
|
||||
|
||||
const rules: FormRules = {
|
||||
work_order_id: [
|
||||
{ required: true, message: '请输入工单ID', trigger: 'blur' },
|
||||
{ type: 'number', min: 1, message: '工单ID必须大于0', trigger: 'blur' }
|
||||
],
|
||||
missing_material_id: [
|
||||
{ required: true, message: '请选择缺少的物料', trigger: 'change' }
|
||||
],
|
||||
required_qty: [
|
||||
{ required: true, message: '请输入需求数量', trigger: 'blur' }
|
||||
],
|
||||
reason: [
|
||||
{ required: true, message: '请描述缺料原因', trigger: 'blur' },
|
||||
{ min: 5, message: '原因描述至少5个字符', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'warning',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'danger'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: '待审批',
|
||||
APPROVED: '已批准',
|
||||
REJECTED: '已拒绝'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const loadMaterials = async () => {
|
||||
try {
|
||||
materialList.value = await getMaterialList()
|
||||
} catch (error) {
|
||||
console.error('加载物料列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadApprovals = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
approvalList.value = await getPendingApprovals()
|
||||
} catch (error: any) {
|
||||
ElMessage.error('加载审批列表失败')
|
||||
console.error('加载审批列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) return
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
submitting.value = true
|
||||
await createApproval(form)
|
||||
ElMessage.success('申请提交成功')
|
||||
resetForm()
|
||||
loadApprovals()
|
||||
} catch (error: any) {
|
||||
if (error?.message) {
|
||||
ElMessage.error(error.message)
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
formRef.value?.resetFields()
|
||||
form.required_qty = 1
|
||||
}
|
||||
|
||||
const handleApprove = async (row: ApprovalItem & { processing?: boolean }) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定批准该缺料申请吗?', '审批确认', {
|
||||
confirmButtonText: '批准',
|
||||
cancelButtonText: '取消',
|
||||
type: 'success'
|
||||
})
|
||||
row.processing = true
|
||||
await updateApprovalStatus(row.id, 'APPROVED')
|
||||
ElMessage.success('审批通过')
|
||||
loadApprovals()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
} finally {
|
||||
row.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = async (row: ApprovalItem & { processing?: boolean }) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定拒绝该缺料申请吗?', '审批确认', {
|
||||
confirmButtonText: '拒绝',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
})
|
||||
row.processing = true
|
||||
await updateApprovalStatus(row.id, 'REJECTED')
|
||||
ElMessage.warning('已拒绝')
|
||||
loadApprovals()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('操作失败')
|
||||
}
|
||||
} finally {
|
||||
row.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMaterials()
|
||||
loadApprovals()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approval-container {
|
||||
padding: 24px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.table-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.approval-form {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.processed-text {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
672
frontend/src/views/ProjectOverview.vue
Normal file
672
frontend/src/views/ProjectOverview.vue
Normal file
@ -0,0 +1,672 @@
|
||||
<template>
|
||||
<div class="project-overview">
|
||||
<h1 class="page-title">
|
||||
项目总览
|
||||
<el-button type="primary" size="default" @click="openCreateDialog">
|
||||
+ 创建项目
|
||||
</el-button>
|
||||
</h1>
|
||||
|
||||
<!-- 全局统计卡片 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value">{{ summary?.total_projects || 0 }}</div>
|
||||
<div class="stat-label">项目总数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value warning">{{ summary?.active_projects || 0 }}</div>
|
||||
<div class="stat-label">进行中</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value danger">{{ summary?.overdue_projects || 0 }}</div>
|
||||
<div class="stat-label">延期项目</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-value success">{{ summary?.completed_projects || 0 }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 进度和物料到位率 -->
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-header-title">全局生产进度</span>
|
||||
</template>
|
||||
<div class="progress-area">
|
||||
<el-progress
|
||||
:percentage="summary?.overall_progress || 0"
|
||||
:stroke-width="20"
|
||||
:color="progressColor"
|
||||
:format="(val: number) => `${val}%`"
|
||||
/>
|
||||
<div class="progress-detail">
|
||||
<span>已完成工单: {{ summary?.completed_work_orders || 0 }} / {{ summary?.total_work_orders || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<span class="card-header-title">物料到位率</span>
|
||||
</template>
|
||||
<div class="progress-area">
|
||||
<el-progress
|
||||
:percentage="summary?.overall_material_ready_rate || 0"
|
||||
:stroke-width="20"
|
||||
:color="materialReadyColor"
|
||||
:format="(val: number) => `${val}%`"
|
||||
/>
|
||||
<div class="progress-detail">
|
||||
<span>待审批缺料: {{ summary?.total_pending_approvals || 0 }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<el-card shadow="hover" class="project-list-card">
|
||||
<template #header>
|
||||
<div class="list-header">
|
||||
<span class="card-header-title">项目列表</span>
|
||||
<el-select
|
||||
v-model="statusFilter"
|
||||
placeholder="筛选状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
@change="loadSummary"
|
||||
>
|
||||
<el-option label="全部" :value="undefined" />
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="进行中" value="active" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="summary?.projects || []" stripe style="width: 100%">
|
||||
<el-table-column prop="project_no" label="项目编号" width="140" />
|
||||
<el-table-column prop="project_name" label="项目名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="projectStatusMap[row.status]?.type || 'info'" size="small">
|
||||
{{ projectStatusMap[row.status]?.label || row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="生产进度" width="200" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-progress
|
||||
:percentage="row.progress_percentage"
|
||||
:stroke-width="10"
|
||||
:color="getProgressColor(row.progress_percentage)"
|
||||
style="width: 120px; display: inline-block"
|
||||
/>
|
||||
<span style="margin-left: 8px; font-size: 12px; color: #909399">
|
||||
{{ row.completed_work_orders }}/{{ row.total_work_orders }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="物料到位率" width="150" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: getMaterialReadyColor(row.material_ready_rate) }">
|
||||
{{ row.material_ready_rate }}%
|
||||
</span>
|
||||
<span v-if="row.pending_approvals > 0" style="font-size: 12px; color: #E6A23C">
|
||||
({{ row.pending_approvals }}待审)
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="计划周期" width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.start_date || row.end_date">
|
||||
{{ row.start_date || '-' }} ~ {{ row.end_date || '-' }}
|
||||
</span>
|
||||
<span v-else style="color: #909399">未设置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="延期预警" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_overdue" type="danger" size="small">
|
||||
延期 {{ row.overdue_days }} 天
|
||||
</el-tag>
|
||||
<span v-else style="color: #67C23A; font-size: 12px">正常</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="viewProjectDetail(row)">
|
||||
查看详情
|
||||
</el-button>
|
||||
<el-button type="success" link size="small" @click="goToBomAnalysis(row)">
|
||||
查看 BOM
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && (!summary?.projects || summary.projects.length === 0)" description="暂无项目数据" />
|
||||
</el-card>
|
||||
|
||||
<!-- 项目详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="项目物料齐套情况" size="600px">
|
||||
<div v-if="selectedProject" class="project-detail">
|
||||
<h3>{{ selectedProject.project_name }}</h3>
|
||||
<p class="project-meta">
|
||||
项目编号: {{ selectedProject.project_no }} |
|
||||
状态: {{ projectStatusMap[selectedProject.status]?.label }}
|
||||
</p>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<h4>工单进度</h4>
|
||||
<el-row :gutter="16" class="detail-stats">
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item">
|
||||
<div class="detail-stat-value">{{ selectedProject.total_work_orders }}</div>
|
||||
<div class="detail-stat-label">总工单</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item success">
|
||||
<div class="detail-stat-value">{{ selectedProject.completed_work_orders }}</div>
|
||||
<div class="detail-stat-label">已完成</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item warning">
|
||||
<div class="detail-stat-value">{{ selectedProject.in_progress_work_orders }}</div>
|
||||
<div class="detail-stat-label">进行中</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item info">
|
||||
<div class="detail-stat-value">{{ selectedProject.pending_work_orders }}</div>
|
||||
<div class="detail-stat-label">待生产</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<h4>物料审批情况</h4>
|
||||
<el-row :gutter="16" class="detail-stats">
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item">
|
||||
<div class="detail-stat-value">{{ selectedProject.total_approvals }}</div>
|
||||
<div class="detail-stat-label">总申请</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item success">
|
||||
<div class="detail-stat-value">{{ selectedProject.approved_approvals }}</div>
|
||||
<div class="detail-stat-label">已批准</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item warning">
|
||||
<div class="detail-stat-value">{{ selectedProject.pending_approvals }}</div>
|
||||
<div class="detail-stat-label">待审批</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="detail-stat-item danger">
|
||||
<div class="detail-stat-value">{{ selectedProject.rejected_approvals }}</div>
|
||||
<div class="detail-stat-label">已驳回</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<div class="material-rate">
|
||||
<el-progress
|
||||
:percentage="selectedProject.material_ready_rate"
|
||||
:stroke-width="15"
|
||||
:color="getMaterialReadyColor(selectedProject.material_ready_rate)"
|
||||
/>
|
||||
<span class="rate-label">物料到位率: {{ selectedProject.material_ready_rate }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- 创建项目弹窗 -->
|
||||
<el-dialog
|
||||
v-model="createDialogVisible"
|
||||
title="创建项目"
|
||||
width="500px"
|
||||
:close-on-click-modal="false"
|
||||
@close="resetCreateForm"
|
||||
>
|
||||
<el-form
|
||||
ref="createFormRef"
|
||||
:model="createForm"
|
||||
:rules="createFormRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="项目编号" prop="project_no">
|
||||
<el-input
|
||||
v-model="createForm.project_no"
|
||||
placeholder="请输入项目编号,如 PRJ-001"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称" prop="project_name">
|
||||
<el-input
|
||||
v-model="createForm.project_name"
|
||||
placeholder="请输入项目名称"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划开始日期" prop="start_date">
|
||||
<el-date-picker
|
||||
v-model="createForm.start_date"
|
||||
type="date"
|
||||
placeholder="选择开始日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disabledStartDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="计划结束日期" prop="end_date">
|
||||
<el-date-picker
|
||||
v-model="createForm.end_date"
|
||||
type="date"
|
||||
placeholder="选择结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disabledEndDate"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="handleCreate">
|
||||
确认创建
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
getProjectSummary,
|
||||
projectStatusMap,
|
||||
createProject,
|
||||
ProjectStatus,
|
||||
type ProjectSummary,
|
||||
type ProjectStats,
|
||||
} from '@/api/project'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ─── 状态 ─────────────────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const statusFilter = ref<string | undefined>(undefined)
|
||||
const summary = ref<ProjectSummary | null>(null)
|
||||
const detailVisible = ref(false)
|
||||
const selectedProject = ref<ProjectStats | null>(null)
|
||||
|
||||
// ─── 创建项目弹窗 ────────────────────────────────────────────────────
|
||||
const createDialogVisible = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const createFormRef = ref<FormInstance>()
|
||||
|
||||
interface CreateForm {
|
||||
project_no: string
|
||||
project_name: string
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
}
|
||||
|
||||
const createForm = ref<CreateForm>({
|
||||
project_no: '',
|
||||
project_name: '',
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
})
|
||||
|
||||
// 开始日期:不能小于今天(允许选今天)
|
||||
const disabledStartDate = (time: Date): boolean => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
return time.getTime() < today.getTime()
|
||||
}
|
||||
|
||||
// 结束日期:不能小于开始日期(允许同一天);未选开始日期时不能小于今天
|
||||
const disabledEndDate = (time: Date): boolean => {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
if (!createForm.value.start_date) {
|
||||
return time.getTime() < today.getTime()
|
||||
}
|
||||
const start = new Date(createForm.value.start_date)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
return time.getTime() < start.getTime()
|
||||
}
|
||||
|
||||
const createFormRules: FormRules<CreateForm> = {
|
||||
project_no: [
|
||||
{ required: true, message: '请输入项目编号', trigger: 'blur' },
|
||||
{ min: 1, max: 50, message: '长度不超过 50 个字符', trigger: 'blur' },
|
||||
],
|
||||
project_name: [
|
||||
{ required: true, message: '请输入项目名称', trigger: 'blur' },
|
||||
{ min: 1, max: 200, message: '长度不超过 200 个字符', trigger: 'blur' },
|
||||
],
|
||||
start_date: [
|
||||
{
|
||||
validator: (_rule, _value, callback) => {
|
||||
if (createForm.value.start_date && createForm.value.end_date) {
|
||||
if (createForm.value.start_date > createForm.value.end_date) {
|
||||
callback(new Error('开始日期不能晚于结束日期'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
end_date: [
|
||||
{
|
||||
validator: (_rule, _value, callback) => {
|
||||
if (createForm.value.start_date && createForm.value.end_date) {
|
||||
if (createForm.value.start_date > createForm.value.end_date) {
|
||||
callback(new Error('开始日期不能晚于结束日期'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
},
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
resetCreateForm()
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.value = {
|
||||
project_no: '',
|
||||
project_name: '',
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
}
|
||||
createFormRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!createFormRef.value) return
|
||||
try {
|
||||
await createFormRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
createLoading.value = true
|
||||
try {
|
||||
await createProject({
|
||||
project_no: createForm.value.project_no,
|
||||
project_name: createForm.value.project_name,
|
||||
start_date: createForm.value.start_date,
|
||||
end_date: createForm.value.end_date,
|
||||
})
|
||||
ElMessage.success('项目创建成功')
|
||||
createDialogVisible.value = false
|
||||
await loadSummary()
|
||||
} catch (e: any) {
|
||||
console.error('[创建项目失败]', e)
|
||||
ElMessage.error(e?.response?.data?.detail || '创建项目失败')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 计算属性 ─────────────────────────────────────────────────────────
|
||||
const progressColor = computed(() => {
|
||||
const progress = summary.value?.overall_progress || 0
|
||||
if (progress >= 80) return '#67C23A'
|
||||
if (progress >= 50) return '#409EFF'
|
||||
return '#E6A23C'
|
||||
})
|
||||
|
||||
const materialReadyColor = computed(() => {
|
||||
const rate = summary.value?.overall_material_ready_rate || 0
|
||||
if (rate >= 90) return '#67C23A'
|
||||
if (rate >= 70) return '#409EFF'
|
||||
return '#E6A23C'
|
||||
})
|
||||
|
||||
// ─── 方法 ─────────────────────────────────────────────────────────────
|
||||
const loadSummary = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const status = statusFilter.value as any
|
||||
summary.value = await getProjectSummary(status || undefined)
|
||||
} catch (e) {
|
||||
console.error('[加载项目汇总失败]', e)
|
||||
ElMessage.error('加载项目汇总失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getProgressColor = (percentage: number): string => {
|
||||
if (percentage >= 80) return '#67C23A'
|
||||
if (percentage >= 50) return '#409EFF'
|
||||
return '#E6A23C'
|
||||
}
|
||||
|
||||
const getMaterialReadyColor = (rate: number): string => {
|
||||
if (rate >= 90) return '#67C23A'
|
||||
if (rate >= 70) return '#409EFF'
|
||||
return '#E6A23C'
|
||||
}
|
||||
|
||||
const viewProjectDetail = (project: ProjectStats) => {
|
||||
selectedProject.value = project
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const goToBomAnalysis = (project?: ProjectStats) => {
|
||||
// 跳转到 BOM 分析页面,可选传递项目参数
|
||||
if (project) {
|
||||
router.push({
|
||||
name: 'BomAnalysis',
|
||||
query: {
|
||||
project_id: String(project.project_id),
|
||||
project_name: project.project_name
|
||||
}
|
||||
})
|
||||
} else {
|
||||
router.push({ name: 'BomAnalysis' })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 生命周期 ─────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
loadSummary()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-overview {
|
||||
padding: 24px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-value.warning {
|
||||
color: #E6A23C;
|
||||
}
|
||||
|
||||
.stat-value.danger {
|
||||
color: #F56C6C;
|
||||
}
|
||||
|
||||
.stat-value.success {
|
||||
color: #67C23A;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.card-header-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-area {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.progress-detail {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.project-list-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 详情抽屉样式 */
|
||||
.project-detail h3 {
|
||||
margin: 0 0 8px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.project-meta {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.project-detail h4 {
|
||||
margin: 16px 0 12px;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.detail-stat-item {
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.detail-stat-item.success {
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
.detail-stat-item.warning {
|
||||
background: #fdf6ec;
|
||||
}
|
||||
|
||||
.detail-stat-item.info {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.detail-stat-item.danger {
|
||||
background: #fef0f0;
|
||||
}
|
||||
|
||||
.detail-stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.detail-stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.material-rate {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.rate-label {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
481
frontend/src/views/WorkOrderKanban.vue
Normal file
481
frontend/src/views/WorkOrderKanban.vue
Normal file
@ -0,0 +1,481 @@
|
||||
<template>
|
||||
<div class="work-order-kanban">
|
||||
<h1 class="page-title">工单看板</h1>
|
||||
|
||||
<!-- 搜索和筛选区域 -->
|
||||
<el-card shadow="never" class="search-card">
|
||||
<div class="search-row">
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索工单号或产品名称..."
|
||||
style="width: 280px"
|
||||
clearable
|
||||
@clear="handleSearch"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select
|
||||
v-model="statusFilter"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
>
|
||||
<el-option label="全部状态" :value="undefined" />
|
||||
<el-option
|
||||
v-for="col in kanbanColumns"
|
||||
:key="col.key"
|
||||
:label="col.title"
|
||||
:value="col.key"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
|
||||
<div class="kanban-summary">
|
||||
<span>共 {{ paginatedData.total }} 个工单</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 看板视图 -->
|
||||
<div v-loading="loading" class="kanban-board">
|
||||
<div
|
||||
v-for="column in kanbanColumns"
|
||||
:key="column.key"
|
||||
class="kanban-column"
|
||||
>
|
||||
<!-- 列头 -->
|
||||
<div class="column-header" :style="{ borderTopColor: column.color }">
|
||||
<span class="column-title">{{ column.title }}</span>
|
||||
<el-badge
|
||||
:value="getColumnCount(column.key)"
|
||||
:type="workOrderStatusConfig[column.key]?.type as any"
|
||||
:max="99"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 卡片列表 -->
|
||||
<div class="column-content">
|
||||
<div
|
||||
v-for="item in getColumnItems(column.key)"
|
||||
:key="item.id"
|
||||
class="kanban-card"
|
||||
:style="{ borderLeftColor: column.color }"
|
||||
shadow="hover"
|
||||
@click="viewWorkOrderDetail(item)"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="work-order-no">{{ item.work_order_no }}</span>
|
||||
<el-tag :type="workOrderStatusConfig[item.status]?.type as any" size="small">
|
||||
{{ workOrderStatusConfig[item.status]?.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="product-name">
|
||||
<el-icon><Box /></el-icon>
|
||||
<span>{{ item.material_name || '未指定产品' }}</span>
|
||||
</div>
|
||||
<div v-if="item.material_spec" class="product-spec">
|
||||
{{ item.material_spec }}
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="meta-item">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ item.assignee_name || '未分配' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<el-icon><Collection /></el-icon>
|
||||
<span>{{ item.target_quantity }} 套</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="project-name">{{ item.project_name || '未知项目' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空列提示 -->
|
||||
<div v-if="getColumnItems(column.key).length === 0" class="empty-column">
|
||||
暂无工单
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="currentPage"
|
||||
v-model:page-size="pageSize"
|
||||
:total="paginatedData.total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 工单详情抽屉 -->
|
||||
<el-drawer v-model="detailVisible" title="工单详情" size="500px">
|
||||
<div v-if="selectedWorkOrder" class="work-order-detail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="工单编号">
|
||||
{{ selectedWorkOrder.work_order_no }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="产品名称">
|
||||
{{ selectedWorkOrder.material_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="规格型号">
|
||||
{{ selectedWorkOrder.material_spec || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标数量">
|
||||
{{ selectedWorkOrder.target_quantity }} 套
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">
|
||||
{{ selectedWorkOrder.assignee_name || '未分配' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="所属项目">
|
||||
{{ selectedWorkOrder.project_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="工单状态">
|
||||
<el-tag :type="workOrderStatusConfig[selectedWorkOrder.status]?.type as any">
|
||||
{{ workOrderStatusConfig[selectedWorkOrder.status]?.label }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ selectedWorkOrder.created_at }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="status-actions">
|
||||
<span class="action-label">状态变更:</span>
|
||||
<el-button
|
||||
v-for="status in validNextStatuses"
|
||||
:key="status"
|
||||
:type="workOrderStatusConfig[status]?.type"
|
||||
size="small"
|
||||
@click="handleStatusChange(selectedWorkOrder.id, status)"
|
||||
>
|
||||
{{ workOrderStatusConfig[status]?.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Box, User, Collection } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getWorkOrderKanban,
|
||||
updateWorkOrderStatus,
|
||||
workOrderStatusConfig,
|
||||
kanbanColumns,
|
||||
WorkOrderStatus,
|
||||
type WorkOrderKanbanItem,
|
||||
type PaginatedWorkOrders
|
||||
} from '@/api/workOrder'
|
||||
|
||||
// ─── 状态 ─────────────────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const searchKeyword = ref('')
|
||||
const statusFilter = ref<WorkOrderStatus | undefined>(undefined)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const paginatedData = ref<PaginatedWorkOrders>({
|
||||
items: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
size: 20,
|
||||
total_pages: 1
|
||||
})
|
||||
const detailVisible = ref(false)
|
||||
const selectedWorkOrder = ref<WorkOrderKanbanItem | null>(null)
|
||||
|
||||
// ─── 计算属性 ─────────────────────────────────────────────────────────
|
||||
const validNextStatuses: WorkOrderStatus[] = [
|
||||
WorkOrderStatus.PENDING,
|
||||
WorkOrderStatus.IN_PROGRESS,
|
||||
WorkOrderStatus.COMPLETED,
|
||||
WorkOrderStatus.CANCELLED
|
||||
]
|
||||
|
||||
// 按状态分组
|
||||
const groupedItems = computed(() => {
|
||||
const groups: Record<WorkOrderStatus, WorkOrderKanbanItem[]> = {
|
||||
[WorkOrderStatus.PENDING]: [],
|
||||
[WorkOrderStatus.IN_PROGRESS]: [],
|
||||
[WorkOrderStatus.COMPLETED]: [],
|
||||
[WorkOrderStatus.CANCELLED]: []
|
||||
}
|
||||
|
||||
for (const item of paginatedData.value.items) {
|
||||
if (!statusFilter.value || item.status === statusFilter.value) {
|
||||
groups[item.status].push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return groups
|
||||
})
|
||||
|
||||
// ─── 方法 ─────────────────────────────────────────────────────────────
|
||||
const getColumnItems = (status: WorkOrderStatus): WorkOrderKanbanItem[] => {
|
||||
return groupedItems.value[status] || []
|
||||
}
|
||||
|
||||
const getColumnCount = (status: WorkOrderStatus): number => {
|
||||
return getColumnItems(status).length
|
||||
}
|
||||
|
||||
const loadWorkOrders = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getWorkOrderKanban({
|
||||
status: statusFilter.value,
|
||||
search: searchKeyword.value || undefined,
|
||||
page: currentPage.value,
|
||||
size: pageSize.value
|
||||
})
|
||||
paginatedData.value = data
|
||||
} catch (e) {
|
||||
console.error('[加载工单失败]', e)
|
||||
ElMessage.error('加载工单失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadWorkOrders()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
searchKeyword.value = ''
|
||||
statusFilter.value = undefined
|
||||
currentPage.value = 1
|
||||
loadWorkOrders()
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
loadWorkOrders()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadWorkOrders()
|
||||
}
|
||||
|
||||
const viewWorkOrderDetail = (item: WorkOrderKanbanItem) => {
|
||||
selectedWorkOrder.value = item
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
const handleStatusChange = async (workOrderId: number, newStatus: WorkOrderStatus) => {
|
||||
try {
|
||||
await updateWorkOrderStatus(workOrderId, newStatus)
|
||||
ElMessage.success('状态更新成功')
|
||||
detailVisible.value = false
|
||||
loadWorkOrders()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.detail || '状态更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 监听筛选变化 ─────────────────────────────────────────────────────
|
||||
watch(statusFilter, () => {
|
||||
currentPage.value = 1
|
||||
loadWorkOrders()
|
||||
})
|
||||
|
||||
// ─── 生命周期 ─────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
loadWorkOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.work-order-kanban {
|
||||
padding: 24px;
|
||||
background-color: #f5f7fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.kanban-summary {
|
||||
margin-left: auto;
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 看板样式 */
|
||||
.kanban-board {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.kanban-column {
|
||||
flex: 1;
|
||||
min-width: 280px;
|
||||
max-width: 340px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: white;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-top: 3px solid;
|
||||
}
|
||||
|
||||
.column-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.column-content {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
max-height: calc(100vh - 380px);
|
||||
}
|
||||
|
||||
.kanban-card {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.work-order-no {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.product-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.product-spec {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-left: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.empty-column {
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
color: #c0c4cc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* 工单详情 */
|
||||
.work-order-detail {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://backend:8001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
43
init-frontend.cmd
Normal file
43
init-frontend.cmd
Normal file
@ -0,0 +1,43 @@
|
||||
@echo off
|
||||
REM ================================================
|
||||
REM PMS 前端项目初始化脚本 (Windows)
|
||||
REM ================================================
|
||||
|
||||
echo >>> 创建 Vue 3 + Vite + TypeScript 项目...
|
||||
|
||||
REM 检测包管理器
|
||||
where pnpm >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
set PKG=pnpm
|
||||
) else (
|
||||
where npm >nul 2>&1
|
||||
if %errorlevel%==0 (
|
||||
set PKG=npm
|
||||
) else (
|
||||
echo 未找到 pnpm 或 npm,请先安装 Node.js
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
echo >>> 使用: %PKG%
|
||||
|
||||
REM 初始化项目
|
||||
%PKG% create vite@latest pms-frontend -- --template vue-ts
|
||||
|
||||
cd pms-frontend
|
||||
|
||||
REM 安装依赖
|
||||
%PKG% install
|
||||
|
||||
REM 安装 Element Plus、Axios、Vue Router、Pinia、vuedraggable
|
||||
%PKG% add element-plus @element-plus/icons-vue axios vue-router pinia vuedraggable@next
|
||||
|
||||
REM 安装类型定义
|
||||
%PKG% add -D @types/node
|
||||
|
||||
echo.
|
||||
echo >>> 项目初始化完成!
|
||||
echo.
|
||||
echo >>> 下一步:
|
||||
echo cd pms-frontend
|
||||
echo %PKG% run dev
|
||||
37
init-frontend.sh
Normal file
37
init-frontend.sh
Normal file
@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# ================================================
|
||||
# PMS 前端项目初始化脚本
|
||||
# ================================================
|
||||
|
||||
echo ">>> 创建 Vue 3 + Vite + TypeScript 项目..."
|
||||
|
||||
# 使用 pnpm(推荐)或 npm
|
||||
if command -v pnpm &> /dev/null; then
|
||||
PACKAGE_MANAGER="pnpm"
|
||||
elif command -v yarn &> /dev/null; then
|
||||
PACKAGE_MANAGER="yarn"
|
||||
else
|
||||
PACKAGE_MANAGER="npm"
|
||||
fi
|
||||
|
||||
echo ">>> 使用: $PACKAGE_MANAGER"
|
||||
|
||||
# 初始化项目
|
||||
$PACKAGE_MANAGER create vite@latest pms-frontend -- --template vue-ts
|
||||
|
||||
cd pms-frontend
|
||||
|
||||
# 安装依赖
|
||||
$PACKAGE_MANAGER install
|
||||
|
||||
# 安装 Element Plus、Axios、Vue Router、Pinia、vuedraggable
|
||||
$PACKAGE_MANAGER add element-plus @element-plus/icons-vue axios vue-router pinia vuedraggable@next
|
||||
|
||||
# 安装类型定义
|
||||
$PACKAGE_MANAGER add -D @types/node
|
||||
|
||||
echo ">>> 项目初始化完成!"
|
||||
echo ""
|
||||
echo ">>> 下一步:"
|
||||
echo " cd pms-frontend"
|
||||
echo " $PACKAGE_MANAGER run dev"
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn[standard]==0.27.0
|
||||
sqlalchemy==2.0.25
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic==2.5.3
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
Reference in New Issue
Block a user