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

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