feat: initial commit and ignore qwen files

This commit is contained in:
dxc
2026-04-30 10:06:32 +08:00
commit def4f7d71f
55 changed files with 5252 additions and 0 deletions

11
.env.example Normal file
View 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
View File

@ -0,0 +1,7 @@
.qwen/
-p/
# 顺便建议忽略一些常见的开发冗余文件
__pycache__/
*.pyc
.env
node_modules/

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View 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
View 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
View 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
View 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
View 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
View 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
View File

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@
# PMS Backend Application

25
backend/app/config.py Normal file
View 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
View 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
View 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"}

View 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",
]

View 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])

View 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())

View 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",
]

View 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

View File

@ -0,0 +1,195 @@
"""缺料审批接口
核心原则:
- 所有写操作仅在 pms_dbPmsMaterialApproval 表)中进行
- 绝对禁止对 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,
)

View 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()

View 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,
}

View 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,
)

View 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()

View 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)

View 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

View 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

View 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",
]

View 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

View 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]

View 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]

View 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
View 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
View 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
View 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
View 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
View 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
View 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 }

View 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
View 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' }
}

View 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' }
]

View 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
View 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')

View 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

View 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
}

View 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>

View 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>

View 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>

View 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
View 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" }]
}

View 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
View 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
View 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
View 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
View 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