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

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