commit def4f7d71f1a7d8c895fa5270a9a238616002588 Author: dxc Date: Thu Apr 30 10:06:32 2026 +0800 feat: initial commit and ignore qwen files diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..04a4038 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f353b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.qwen/ +-p/ +# 顺便建议忽略一些常见的开发冗余文件 +__pycache__/ +*.pyc +.env +node_modules/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..35410ca --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 默认忽略的文件 +/shelf/ +/workspace.xml +# 基于编辑器的 HTTP 客户端请求 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/manufacturing.iml b/.idea/manufacturing.iml new file mode 100644 index 0000000..7dee32e --- /dev/null +++ b/.idea/manufacturing.iml @@ -0,0 +1,12 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..da37608 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..fe1c475 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..823fafd --- /dev/null +++ b/backend/.dockerignore @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..cdfe469 --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..aad2c47 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# PMS Backend Application diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..2f0b0c1 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..a25db68 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..8bed5d8 --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..696a3b6 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/inventory.py b/backend/app/models/inventory.py new file mode 100644 index 0000000..e898245 --- /dev/null +++ b/backend/app/models/inventory.py @@ -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]) diff --git a/backend/app/models/production.py b/backend/app/models/production.py new file mode 100644 index 0000000..dd72543 --- /dev/null +++ b/backend/app/models/production.py @@ -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()) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..83f5b1d --- /dev/null +++ b/backend/app/routers/__init__.py @@ -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", +] diff --git a/backend/app/routers/approval.py b/backend/app/routers/approval.py new file mode 100644 index 0000000..7dcccf2 --- /dev/null +++ b/backend/app/routers/approval.py @@ -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 diff --git a/backend/app/routers/approvals.py b/backend/app/routers/approvals.py new file mode 100644 index 0000000..30813e8 --- /dev/null +++ b/backend/app/routers/approvals.py @@ -0,0 +1,195 @@ +"""缺料审批接口 + +核心原则: +- 所有写操作仅在 pms_db(PmsMaterialApproval 表)中进行 +- 绝对禁止对 inventory_db 进行任何写操作 +- 物料信息(MaterialBase)仅从只读库查询,不进行写操作 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app.database import get_db_pms +from app.models import PmsWorkOrder, PmsMaterialApproval, MaterialBase, ApprovalStatus +from app.schemas.approval import ( + ApprovalCreate, + ApprovalStatusUpdate, + ApprovalDetailResponse, +) + +router = APIRouter(prefix="/api/approvals", tags=["缺料审批"]) + + +@router.post( + "", + response_model=ApprovalDetailResponse, + status_code=201, + summary="员工提交缺料审批申请", +) +async def create_approval(data: ApprovalCreate, db: Session = Depends(get_db_pms)): + work_order = db.query(PmsWorkOrder).filter( + PmsWorkOrder.id == data.work_order_id + ).first() + if not work_order: + raise HTTPException(status_code=400, detail=f"工单 ID={data.work_order_id} 不存在") + + material = db.query(MaterialBase).filter( + MaterialBase.id == data.missing_material_id + ).first() + if not material: + raise HTTPException(status_code=400, detail=f"物料 ID={data.missing_material_id} 不存在") + + approval = PmsMaterialApproval( + work_order_id=data.work_order_id, + missing_material_id=data.missing_material_id, + required_qty=data.required_qty, + reason=data.reason, + status=ApprovalStatus.PENDING, + ) + db.add(approval) + db.commit() + db.refresh(approval) + + return ApprovalDetailResponse( + id=approval.id, + work_order_id=approval.work_order_id, + work_order_no=work_order.work_order_no, + missing_material_id=approval.missing_material_id, + material_name=material.name, + required_qty=approval.required_qty, + reason=approval.reason, + status=approval.status, + created_at=approval.created_at, + ) + + +@router.get( + "", + response_model=list[ApprovalDetailResponse], + summary="查询待审批列表", +) +async def list_pending_approvals( + skip: int = Query(0, ge=0), + limit: int = Query(20, gt=0, le=100), + db: Session = Depends(get_db_pms) +): + approvals = db.query(PmsMaterialApproval).filter( + PmsMaterialApproval.status == ApprovalStatus.PENDING + ).order_by( + PmsMaterialApproval.created_at.desc() + ).offset(skip).limit(limit).all() + + if not approvals: + return [] + + work_order_ids = list(set(a.work_order_id for a in approvals)) + material_ids = list(set(a.missing_material_id for a in approvals)) + + work_orders = { + wo.id: wo for wo in db.query(PmsWorkOrder).filter( + PmsWorkOrder.id.in_(work_order_ids) + ).all() + } + materials = { + m.id: m for m in db.query(MaterialBase).filter( + MaterialBase.id.in_(material_ids) + ).all() + } + + result = [] + for approval in approvals: + wo = work_orders.get(approval.work_order_id) + mat = materials.get(approval.missing_material_id) + result.append(ApprovalDetailResponse( + id=approval.id, + work_order_id=approval.work_order_id, + work_order_no=wo.work_order_no if wo else f"WO-{approval.work_order_id}", + missing_material_id=approval.missing_material_id, + material_name=mat.name if mat else f"物料-{approval.missing_material_id}", + required_qty=approval.required_qty, + reason=approval.reason, + status=approval.status, + created_at=approval.created_at, + )) + + return result + + +@router.put( + "/{approval_id}/status", + response_model=ApprovalDetailResponse, + summary="主管审批", +) +async def update_approval_status( + approval_id: int, + data: ApprovalStatusUpdate, + db: Session = Depends(get_db_pms) +): + approval = db.query(PmsMaterialApproval).filter( + PmsMaterialApproval.id == approval_id + ).first() + if not approval: + raise HTTPException(status_code=404, detail=f"审批记录 ID={approval_id} 不存在") + + if approval.status != ApprovalStatus.PENDING: + raise HTTPException(status_code=400, detail=f"当前状态为 {approval.status.value},终态不可再次修改") + + if data.status == ApprovalStatus.PENDING: + raise HTTPException(status_code=400, detail="不允许将状态改回 PENDING") + + approval.status = data.status + db.commit() + db.refresh(approval) + + work_order = db.query(PmsWorkOrder).filter( + PmsWorkOrder.id == approval.work_order_id + ).first() + material = db.query(MaterialBase).filter( + MaterialBase.id == approval.missing_material_id + ).first() + + return ApprovalDetailResponse( + id=approval.id, + work_order_id=approval.work_order_id, + work_order_no=work_order.work_order_no if work_order else f"WO-{approval.work_order_id}", + missing_material_id=approval.missing_material_id, + material_name=material.name if material else f"物料-{approval.missing_material_id}", + required_qty=approval.required_qty, + reason=approval.reason, + status=approval.status, + created_at=approval.created_at, + ) + + +@router.get( + "/{approval_id}", + response_model=ApprovalDetailResponse, + summary="获取审批详情", +) +async def get_approval( + approval_id: int, + db: Session = Depends(get_db_pms) +): + approval = db.query(PmsMaterialApproval).filter( + PmsMaterialApproval.id == approval_id + ).first() + if not approval: + raise HTTPException(status_code=404, detail=f"审批记录 ID={approval_id} 不存在") + + work_order = db.query(PmsWorkOrder).filter( + PmsWorkOrder.id == approval.work_order_id + ).first() + material = db.query(MaterialBase).filter( + MaterialBase.id == approval.missing_material_id + ).first() + + return ApprovalDetailResponse( + id=approval.id, + work_order_id=approval.work_order_id, + work_order_no=work_order.work_order_no if work_order else f"WO-{approval.work_order_id}", + missing_material_id=approval.missing_material_id, + material_name=material.name if material else f"物料-{approval.missing_material_id}", + required_qty=approval.required_qty, + reason=approval.reason, + status=approval.status, + created_at=approval.created_at, + ) \ No newline at end of file diff --git a/backend/app/routers/bom_targets.py b/backend/app/routers/bom_targets.py new file mode 100644 index 0000000..db507ea --- /dev/null +++ b/backend/app/routers/bom_targets.py @@ -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() diff --git a/backend/app/routers/deduce_bom.py b/backend/app/routers/deduce_bom.py new file mode 100644 index 0000000..477d4cd --- /dev/null +++ b/backend/app/routers/deduce_bom.py @@ -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, + } \ No newline at end of file diff --git a/backend/app/routers/material.py b/backend/app/routers/material.py new file mode 100644 index 0000000..2a8b273 --- /dev/null +++ b/backend/app/routers/material.py @@ -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, + ) \ No newline at end of file diff --git a/backend/app/routers/preference.py b/backend/app/routers/preference.py new file mode 100644 index 0000000..8325965 --- /dev/null +++ b/backend/app/routers/preference.py @@ -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() \ No newline at end of file diff --git a/backend/app/routers/project_stats.py b/backend/app/routers/project_stats.py new file mode 100644 index 0000000..bfdf865 --- /dev/null +++ b/backend/app/routers/project_stats.py @@ -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) diff --git a/backend/app/routers/work_order.py b/backend/app/routers/work_order.py new file mode 100644 index 0000000..b00d289 --- /dev/null +++ b/backend/app/routers/work_order.py @@ -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 diff --git a/backend/app/routers/work_order_kanban.py b/backend/app/routers/work_order_kanban.py new file mode 100644 index 0000000..b5a15df --- /dev/null +++ b/backend/app/routers/work_order_kanban.py @@ -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 diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..634054c --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/approval.py b/backend/app/schemas/approval.py new file mode 100644 index 0000000..9e3f128 --- /dev/null +++ b/backend/app/schemas/approval.py @@ -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 diff --git a/backend/app/schemas/deduce_bom.py b/backend/app/schemas/deduce_bom.py new file mode 100644 index 0000000..6a83f4a --- /dev/null +++ b/backend/app/schemas/deduce_bom.py @@ -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] diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..a50d41f --- /dev/null +++ b/backend/app/schemas/project.py @@ -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] diff --git a/backend/app/schemas/work_order.py b/backend/app/schemas/work_order.py new file mode 100644 index 0000000..06b5dd9 --- /dev/null +++ b/backend/app/schemas/work_order.py @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2498cc8 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..1d01a6d --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1cf096d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + PMS 生产管理系统 + + +
+ + + \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fd73bfe --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..b09fba4 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,24 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/api/bom.ts b/frontend/src/api/bom.ts new file mode 100644 index 0000000..8de2432 --- /dev/null +++ b/frontend/src/api/bom.ts @@ -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 => { + 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 => { + const params: Record = { 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 } \ No newline at end of file diff --git a/frontend/src/api/material.ts b/frontend/src/api/material.ts new file mode 100644 index 0000000..cc4129b --- /dev/null +++ b/frontend/src/api/material.ts @@ -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 => { + const response = await api.get('/material') + return response.data +} + +// 获取待审批列表 +export const getPendingApprovals = async (): Promise => { + const response = await api.get('/approvals') + return response.data +} + +// 提交缺料申请 +export const createApproval = async (data: ApprovalForm): Promise => { + const response = await api.post('/approvals', data) + return response.data +} + +// 更新审批状态(主管操作) +export const updateApprovalStatus = async ( + id: number, + status: 'APPROVED' | 'REJECTED' +): Promise => { + const response = await api.put(`/approvals/${id}/status`, { status }) + return response.data +} + +// 获取审批详情 +export const getApprovalDetail = async (id: number): Promise => { + const response = await api.get(`/approvals/${id}`) + return response.data +} \ No newline at end of file diff --git a/frontend/src/api/project.ts b/frontend/src/api/project.ts new file mode 100644 index 0000000..8d84927 --- /dev/null +++ b/frontend/src/api/project.ts @@ -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 => { + const params: Record = {} + 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 => { + 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 => { + 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.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' } +} diff --git a/frontend/src/api/workOrder.ts b/frontend/src/api/workOrder.ts new file mode 100644 index 0000000..0755b56 --- /dev/null +++ b/frontend/src/api/workOrder.ts @@ -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 => { + const response = await api.get('/pms/work_order_kanban', { params }) + return response.data as PaginatedWorkOrders +} + +/** + * 获取各状态工单数量统计 + */ +export const getStatusCounts = async (): Promise => { + 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 => { + const response = await api.put(`/pms/work_order/${workOrderId}/status`, { status }) + return response.data as WorkOrderKanbanItem +} + +// ─── 状态配置映射 ────────────────────────────────────────────────────── +export const workOrderStatusConfig: Record = { + [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' } +] diff --git a/frontend/src/components/AppLayout.vue b/frontend/src/components/AppLayout.vue new file mode 100644 index 0000000..27fc757 --- /dev/null +++ b/frontend/src/components/AppLayout.vue @@ -0,0 +1,80 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..06c3224 --- /dev/null +++ b/frontend/src/main.ts @@ -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') \ No newline at end of file diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..5381b68 --- /dev/null +++ b/frontend/src/router/index.ts @@ -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 \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..423e2c8 --- /dev/null +++ b/frontend/src/types/index.ts @@ -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 +} \ No newline at end of file diff --git a/frontend/src/views/BomAnalysis.vue b/frontend/src/views/BomAnalysis.vue new file mode 100644 index 0000000..3a951c3 --- /dev/null +++ b/frontend/src/views/BomAnalysis.vue @@ -0,0 +1,767 @@ + + + + + diff --git a/frontend/src/views/MaterialApproval.vue b/frontend/src/views/MaterialApproval.vue new file mode 100644 index 0000000..b9c1124 --- /dev/null +++ b/frontend/src/views/MaterialApproval.vue @@ -0,0 +1,321 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/views/ProjectOverview.vue b/frontend/src/views/ProjectOverview.vue new file mode 100644 index 0000000..8e3bdb8 --- /dev/null +++ b/frontend/src/views/ProjectOverview.vue @@ -0,0 +1,672 @@ + + + + + diff --git a/frontend/src/views/WorkOrderKanban.vue b/frontend/src/views/WorkOrderKanban.vue new file mode 100644 index 0000000..f81937f --- /dev/null +++ b/frontend/src/views/WorkOrderKanban.vue @@ -0,0 +1,481 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..63cf416 --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ac4dd22 --- /dev/null +++ b/frontend/vite.config.ts @@ -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 + } + } + } +}) diff --git a/init-frontend.cmd b/init-frontend.cmd new file mode 100644 index 0000000..41b7579 --- /dev/null +++ b/init-frontend.cmd @@ -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 diff --git a/init-frontend.sh b/init-frontend.sh new file mode 100644 index 0000000..20a2f51 --- /dev/null +++ b/init-frontend.sh @@ -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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..51adf39 --- /dev/null +++ b/requirements.txt @@ -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