from __future__ import annotations from decimal import Decimal from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import false, func, select from sqlalchemy.orm import Session, aliased from app.db.session import get_db from app.models.master_data import Item, Warehouse from app.models.operations import ( CompletionReceipt, CompletionReceiptItem, Delivery, DeliveryItem, OperationReport, PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, ReturnDisposition, ReturnItem, ReturnOrder, ScrapRecord, StockLot, Supplier, WorkOrder, WorkOrderMaterialIssue, WorkOrderOperation, ) from app.models.org import Employee from app.models.planning import MaterialDemand from app.models.sales import Customer, SalesOrder, SalesOrderItem from app.schemas.dashboard import ( DashboardPayload, DashboardMaterialLotRead, DashboardSalesOrderRead, LifecycleDetailItem, LifecycleEdge, LifecycleNode, MaterialLotLifecycleRead, SalesOrderLifecycleRead, ) from app.services.auth import require_authenticated_user from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph from app.services.mock_data import get_dashboard_payload router = APIRouter(dependencies=[Depends(require_authenticated_user)]) STATUS_LABELS = { "ACTIVE": "启用", "INACTIVE": "停用", "OPEN": "未开始", "PARTIAL": "部分完成", "CLOSED": "已关闭", "ORDERED": "已下单", "RECEIVED": "已收货", "RELEASED": "已下达", "IN_PROGRESS": "进行中", "READY_TO_RECEIPT": "待成品入库", "COMPLETED": "已完工", "DONE": "已完成", "PENDING": "待处理", "PENDING_QC": "待质检", "PASSED": "已放行", "PASS": "已放行", "REJECTED": "已拒收", "REJECT": "已拒收", "POSTED": "已过账", "AVAILABLE": "可用", "LOCKED": "锁定", "DEPLETED": "已耗尽", "CONFIRMED": "已确认", "OVERRIDDEN": "已覆盖", "CREATED": "已创建", "IDLE": "空闲", "SUPERSEDED": "已替代", "SCRAP": "已报废", "SCRAPPED": "已报废", "NORMAL": "正常", "ABNORMAL": "异常", } WORK_ORDER_TYPE_LABELS = { "NORMAL": "常规生产", "REWORK": "返工生产", } DISPOSAL_TYPE_LABELS = { "SCRAP": "报废", "REWORK": "返工", } def _num(value: Any) -> float: if value is None: return 0.0 if isinstance(value, Decimal): return float(value) try: return float(value) except (TypeError, ValueError): return 0.0 def _fmt_qty(value: Any, digits: int = 2) -> str: numeric = _num(value) if abs(numeric - round(numeric)) < 0.000001: return f"{int(round(numeric))}" return f"{numeric:.{digits}f}".rstrip("0").rstrip(".") def _fmt_weight(value: Any) -> str: return f"{_fmt_qty(value, 3)} kg" def _fmt_money(value: Any) -> str: return f"¥{_num(value):,.2f}" def _fmt_date(value: Any) -> str: if not value: return "-" return str(value)[:10] def _fmt_datetime(value: Any) -> str: if not value: return "-" return str(value).replace("T", " ")[:19] def _label(mapping: dict[str, str], value: Any) -> str: if value is None or value == "": return "-" key = str(value).upper() return mapping.get(key, str(value)) def _status_label(value: Any) -> str: return _label(STATUS_LABELS, value) def _disposal_label(value: Any) -> str: return _label(DISPOSAL_TYPE_LABELS, value) def _detail(label: str, value: Any) -> LifecycleDetailItem: text = "-" if value is None or value == "" else str(value) return LifecycleDetailItem(label=label, value=text) def _node( *, node_id: str, node_type: str, title: str, stage: str, level: int, subtitle: str | None = None, status: str | None = None, tone: str = "normal", summary: str | None = None, metrics: list[tuple[str, Any]] | None = None, details: list[tuple[str, Any]] | None = None, raw: dict[str, Any] | None = None, ) -> LifecycleNode: return LifecycleNode( id=node_id, type=node_type, title=title, subtitle=subtitle, status=status, stage=stage, level=level, tone=tone, summary=summary, metrics=[_detail(label, value) for label, value in (metrics or [])], details=[_detail(label, value) for label, value in (details or [])], raw=raw or {}, ) def _edge(source: str, target: str, label: str | None = None) -> LifecycleEdge: return LifecycleEdge(id=f"{source}->{target}", source=source, target=target, label=label) def _tone_for_status(status: str | None) -> str: value = str(status or "").upper() if value in {"REJECTED", "SCRAP", "SCRAPPED", "LOCKED"}: return "danger" if value in {"PENDING", "PENDING_QC", "OPEN", "PARTIAL", "IN_PROGRESS", "RELEASED", "NEW", "CREATED"}: return "warning" if value in {"CLOSED", "COMPLETED", "DONE", "POSTED", "PASSED", "RECEIVED", "CONFIRMED"}: return "success" return "normal" def _sales_order_rows(limit: int = 100): line_subquery = ( select( SalesOrderItem.sales_order_id.label("sales_order_id"), func.count(SalesOrderItem.id).label("line_count"), func.coalesce(func.sum(SalesOrderItem.order_qty), 0).label("total_order_qty"), func.coalesce(func.sum(SalesOrderItem.delivered_qty), 0).label("total_delivered_qty"), ) .group_by(SalesOrderItem.sales_order_id) .subquery() ) mrp_subquery = ( select( SalesOrderItem.sales_order_id.label("sales_order_id"), func.count(MaterialDemand.id).label("mrp_count"), ) .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id) .where(MaterialDemand.status != "SUPERSEDED") .group_by(SalesOrderItem.sales_order_id) .subquery() ) purchase_subquery = ( select( SalesOrderItem.sales_order_id.label("sales_order_id"), func.count(func.distinct(PurchaseOrderItem.purchase_order_id)).label("purchase_order_count"), ) .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id) .join(PurchaseOrderItem, PurchaseOrderItem.source_demand_id == MaterialDemand.id) .group_by(SalesOrderItem.sales_order_id) .subquery() ) work_order_subquery = ( select( SalesOrderItem.sales_order_id.label("sales_order_id"), func.count(WorkOrder.id).label("work_order_count"), ) .join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id) .group_by(SalesOrderItem.sales_order_id) .subquery() ) completion_subquery = ( select( SalesOrderItem.sales_order_id.label("sales_order_id"), func.count(func.distinct(CompletionReceipt.id)).label("completion_receipt_count"), ) .join(WorkOrder, WorkOrder.source_sales_order_item_id == SalesOrderItem.id) .join(CompletionReceipt, CompletionReceipt.work_order_id == WorkOrder.id) .group_by(SalesOrderItem.sales_order_id) .subquery() ) delivery_subquery = ( select( Delivery.sales_order_id.label("sales_order_id"), func.count(Delivery.id).label("delivery_count"), ) .group_by(Delivery.sales_order_id) .subquery() ) return_subquery = ( select( ReturnOrder.sales_order_id.label("sales_order_id"), func.count(ReturnOrder.id).label("return_count"), ) .where(ReturnOrder.sales_order_id.is_not(None)) .group_by(ReturnOrder.sales_order_id) .subquery() ) return ( select( SalesOrder.id.label("order_id"), SalesOrder.order_no.label("order_no"), Customer.customer_code.label("customer_code"), Customer.customer_name.label("customer_name"), SalesOrder.order_date.label("order_date"), SalesOrder.promised_date.label("promised_date"), SalesOrder.delivery_address.label("delivery_address"), SalesOrder.total_amount.label("total_amount"), SalesOrder.status.label("status"), func.coalesce(line_subquery.c.line_count, 0).label("line_count"), func.coalesce(line_subquery.c.total_order_qty, 0).label("total_order_qty"), func.coalesce(line_subquery.c.total_delivered_qty, 0).label("total_delivered_qty"), func.coalesce(mrp_subquery.c.mrp_count, 0).label("mrp_count"), func.coalesce(purchase_subquery.c.purchase_order_count, 0).label("purchase_order_count"), func.coalesce(work_order_subquery.c.work_order_count, 0).label("work_order_count"), func.coalesce(completion_subquery.c.completion_receipt_count, 0).label("completion_receipt_count"), func.coalesce(delivery_subquery.c.delivery_count, 0).label("delivery_count"), func.coalesce(return_subquery.c.return_count, 0).label("return_count"), SalesOrder.updated_at.label("updated_at"), ) .join(Customer, Customer.id == SalesOrder.customer_id) .outerjoin(line_subquery, line_subquery.c.sales_order_id == SalesOrder.id) .outerjoin(mrp_subquery, mrp_subquery.c.sales_order_id == SalesOrder.id) .outerjoin(purchase_subquery, purchase_subquery.c.sales_order_id == SalesOrder.id) .outerjoin(work_order_subquery, work_order_subquery.c.sales_order_id == SalesOrder.id) .outerjoin(completion_subquery, completion_subquery.c.sales_order_id == SalesOrder.id) .outerjoin(delivery_subquery, delivery_subquery.c.sales_order_id == SalesOrder.id) .outerjoin(return_subquery, return_subquery.c.sales_order_id == SalesOrder.id) .order_by(SalesOrder.id.desc()) .limit(limit) ) def _material_lot_rows(limit: int = 100): issue_subquery = ( select( WorkOrderMaterialIssue.source_lot_id.label("source_lot_id"), func.count(func.distinct(WorkOrderMaterialIssue.material_sub_batch_no)).label("issued_batch_count"), func.coalesce(func.sum(WorkOrderMaterialIssue.issued_weight_kg), 0).label("issued_weight_kg"), ) .group_by(WorkOrderMaterialIssue.source_lot_id) .subquery() ) finished_subquery = ( select( CompletionReceiptItem.source_material_lot_id.label("source_material_lot_id"), func.count(func.distinct(CompletionReceiptItem.id)).label("finished_batch_count"), func.coalesce(func.sum(CompletionReceiptItem.receipt_qty), 0).label("finished_qty"), ) .where(CompletionReceiptItem.source_material_lot_id.is_not(None)) .group_by(CompletionReceiptItem.source_material_lot_id) .subquery() ) finished_lot = aliased(StockLot) delivery_subquery = ( select( finished_lot.source_material_lot_id.label("source_material_lot_id"), func.count(func.distinct(Delivery.id)).label("delivery_count"), ) .join(DeliveryItem, DeliveryItem.lot_id == finished_lot.id) .join(Delivery, Delivery.id == DeliveryItem.delivery_id) .where(finished_lot.source_material_lot_id.is_not(None)) .group_by(finished_lot.source_material_lot_id) .subquery() ) return ( select( StockLot.id.label("lot_id"), StockLot.lot_no.label("lot_no"), StockLot.item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), Item.specification.label("specification"), Warehouse.warehouse_name.label("warehouse_name"), StockLot.inbound_weight_kg.label("inbound_weight_kg"), StockLot.remaining_weight_kg.label("remaining_weight_kg"), StockLot.unit_cost.label("unit_cost"), (StockLot.inbound_weight_kg * StockLot.unit_cost).label("inbound_amount"), func.coalesce(issue_subquery.c.issued_batch_count, 0).label("issued_batch_count"), func.coalesce(issue_subquery.c.issued_weight_kg, 0).label("issued_weight_kg"), func.coalesce(finished_subquery.c.finished_batch_count, 0).label("finished_batch_count"), func.coalesce(finished_subquery.c.finished_qty, 0).label("finished_qty"), func.coalesce(delivery_subquery.c.delivery_count, 0).label("delivery_count"), StockLot.status.label("status"), StockLot.quality_status.label("quality_status"), StockLot.created_at.label("created_at"), ) .join(Item, Item.id == StockLot.item_id) .join(Warehouse, Warehouse.id == StockLot.warehouse_id) .outerjoin(issue_subquery, issue_subquery.c.source_lot_id == StockLot.id) .outerjoin(finished_subquery, finished_subquery.c.source_material_lot_id == StockLot.id) .outerjoin(delivery_subquery, delivery_subquery.c.source_material_lot_id == StockLot.id) .where(Item.item_type == "RAW_MATERIAL", Warehouse.warehouse_type == "RAW") .order_by(StockLot.created_at.desc(), StockLot.id.desc()) .limit(limit) ) @router.get("/overview", response_model=DashboardPayload) def overview() -> DashboardPayload: return get_dashboard_payload() @router.get("/sales-orders", response_model=list[DashboardSalesOrderRead]) def list_dashboard_sales_orders( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[DashboardSalesOrderRead]: rows = db.execute(_sales_order_rows(limit=limit)).mappings().all() return [DashboardSalesOrderRead.model_validate(dict(row)) for row in rows] @router.get("/material-lots", response_model=list[DashboardMaterialLotRead]) def list_dashboard_material_lots( limit: int = Query(default=100, ge=1, le=500), db: Session = Depends(get_db), ) -> list[DashboardMaterialLotRead]: rows = db.execute(_material_lot_rows(limit=limit)).mappings().all() return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows] def _build_legacy_material_lot_lifecycle_graph( db: Session, lot: DashboardMaterialLotRead, ) -> tuple[list[LifecycleNode], list[LifecycleEdge]]: nodes: list[LifecycleNode] = [] edges: list[LifecycleEdge] = [] node_ids: set[str] = set() edge_ids: set[str] = set() def add_node(node: LifecycleNode) -> None: if node.id not in node_ids: node_ids.add(node.id) nodes.append(node) def add_edge(edge: LifecycleEdge) -> None: if edge.source in node_ids and edge.target in node_ids and edge.id not in edge_ids: edge_ids.add(edge.id) edges.append(edge) lot_node_id = f"raw-lot-{lot.lot_id}" add_node( _node( node_id=lot_node_id, node_type="raw_material_lot", title=lot.lot_no, subtitle=f"{lot.material_code} · {lot.material_name}", status=lot.status, tone=_tone_for_status(lot.status), stage="原料入库", level=0, summary="原材料驱动的起点。该批次记录入库成本、剩余库存、质检状态,并向后追踪所有生产出库、成品入库和发货。", metrics=[ ("入库重量", _fmt_weight(lot.inbound_weight_kg)), ("剩余重量", _fmt_weight(lot.remaining_weight_kg)), ("入库金额", _fmt_money(lot.inbound_amount)), ], details=[ ("原材料编码", lot.material_code), ("原材料名称", lot.material_name), ("规格", lot.specification), ("仓库", lot.warehouse_name), ("入库单价", f"{_fmt_money(lot.unit_cost)}/kg"), ("质检状态", _status_label(lot.quality_status)), ("生产出库数", lot.issued_batch_count), ("已领用重量", _fmt_weight(lot.issued_weight_kg)), ("成品批次数", lot.finished_batch_count), ("已发货次数", lot.delivery_count), ], raw=lot.model_dump(mode="json"), ) ) issues = db.execute( select( WorkOrderMaterialIssue.id.label("issue_id"), WorkOrderMaterialIssue.material_sub_batch_no.label("material_sub_batch_no"), StockLot.lot_no.label("source_lot_no"), WorkOrderMaterialIssue.work_order_id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), WorkOrder.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), WorkOrder.planned_qty.label("planned_qty"), WorkOrder.finished_qty.label("finished_qty"), WorkOrder.status.label("work_order_status"), WorkOrderMaterialIssue.issued_qty.label("issued_qty"), WorkOrderMaterialIssue.issued_weight_kg.label("issued_weight_kg"), WorkOrderMaterialIssue.unit_cost.label("unit_cost"), WorkOrderMaterialIssue.amount.label("amount"), WorkOrderMaterialIssue.issue_time.label("issue_time"), WorkOrderMaterialIssue.remark.label("remark"), ) .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) .join(Item, Item.id == WorkOrder.product_item_id) .outerjoin(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) .where(WorkOrderMaterialIssue.source_lot_id == lot.lot_id) .order_by(WorkOrderMaterialIssue.issue_time, WorkOrderMaterialIssue.id) ).mappings().all() work_order_ids = sorted({row["work_order_id"] for row in issues}) for row in issues: source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"] or "未记录库存批次号" issue_node_id = f"issue-{row['issue_id']}" add_node( _node( node_id=issue_node_id, node_type="material_issue_batch", title=source_lot_no, subtitle=row["work_order_no"], status="POSTED", tone="success", stage="生产出库", level=1, summary="原材料按库存批次生产出库,记录领用重量、单价和成本。", metrics=[ ("领用重量", _fmt_weight(row["issued_weight_kg"])), ("领用金额", _fmt_money(row["amount"])), ("单价", f"{_fmt_money(row['unit_cost'])}/kg"), ], details=[ ("库存批次号", source_lot_no), ("工单号", row["work_order_no"]), ("产品", f"{row['product_code']} · {row['product_name']}"), ("领用时间", _fmt_datetime(row["issue_time"])), ("领用重量", _fmt_weight(row["issued_weight_kg"])), ("备注", row["remark"]), ], raw=dict(row), ) ) add_edge(_edge(lot_node_id, issue_node_id, "生产出库")) work_order_node_id = f"wo-{row['work_order_id']}" add_node( _node( node_id=work_order_node_id, node_type="work_order", title=row["work_order_no"], subtitle=f"{row['product_code']} · {row['product_name']}", status=row["work_order_status"], tone=_tone_for_status(row["work_order_status"]), stage="生产工单", level=2, summary="原材料按库存批次生产出库进入生产工单,后续工序报工、报废返工和完工入库均挂在该工单下。", metrics=[ ("计划数", _fmt_qty(row["planned_qty"])), ("完工数", _fmt_qty(row["finished_qty"])), ("本批领用", _fmt_weight(row["issued_weight_kg"])), ], details=[ ("工单号", row["work_order_no"]), ("产品编码", row["product_code"]), ("产品名称", row["product_name"]), ("状态", _status_label(row["work_order_status"])), ], raw=dict(row), ) ) add_edge(_edge(issue_node_id, work_order_node_id, "投入生产")) operations = db.execute( select( WorkOrderOperation.id.label("operation_id"), WorkOrderOperation.work_order_id.label("work_order_id"), WorkOrderOperation.seq_no.label("seq_no"), WorkOrderOperation.operation_name.label("operation_name"), WorkOrderOperation.planned_qty.label("planned_qty"), WorkOrderOperation.report_qty.label("report_qty"), WorkOrderOperation.good_qty.label("good_qty"), WorkOrderOperation.scrap_qty.label("scrap_qty"), WorkOrderOperation.rework_qty.label("rework_qty"), WorkOrderOperation.is_abnormal.label("is_abnormal"), WorkOrderOperation.status.label("status"), ) .where(WorkOrderOperation.work_order_id.in_(work_order_ids) if work_order_ids else false()) .order_by(WorkOrderOperation.work_order_id, WorkOrderOperation.seq_no) ).mappings().all() for row in operations: node_id = f"op-{row['operation_id']}" add_node( _node( node_id=node_id, node_type="work_order_operation", title=f"OP{row['seq_no']:02d} {row['operation_name']}", subtitle="异常工序" if row["is_abnormal"] else "工序执行", status=row["status"], tone="danger" if row["is_abnormal"] else _tone_for_status(row["status"]), stage="工序执行", level=3, summary="工序节点展示该库存批次对应工单的加工进度、报废返工和异常情况。", metrics=[ ("计划数", _fmt_qty(row["planned_qty"])), ("已报工", _fmt_qty(row["report_qty"])), ("报废数", _fmt_qty(row["scrap_qty"])), ], details=[ ("合格数", _fmt_qty(row["good_qty"])), ("返工数", _fmt_qty(row["rework_qty"])), ("是否异常", "是" if row["is_abnormal"] else "否"), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(f"wo-{row['work_order_id']}", node_id, "工艺路线")) reports = db.execute( select( OperationReport.id.label("report_id"), OperationReport.report_no.label("report_no"), OperationReport.work_order_id.label("work_order_id"), OperationReport.work_order_operation_id.label("work_order_operation_id"), Employee.employee_name.label("employee_name"), OperationReport.report_qty.label("report_qty"), OperationReport.good_qty.label("good_qty"), OperationReport.scrap_qty.label("scrap_qty"), OperationReport.rework_qty.label("rework_qty"), OperationReport.is_abnormal.label("is_abnormal"), OperationReport.scrap_reason.label("scrap_reason"), OperationReport.start_time.label("start_time"), OperationReport.end_time.label("end_time"), ) .join(Employee, Employee.id == OperationReport.employee_id) .where(OperationReport.work_order_id.in_(work_order_ids) if work_order_ids else false()) .order_by(OperationReport.start_time, OperationReport.id) ).mappings().all() for row in reports: node_id = f"report-{row['report_id']}" add_node( _node( node_id=node_id, node_type="operation_report", title=row["report_no"], subtitle=row["employee_name"], status="ABNORMAL" if row["is_abnormal"] else "NORMAL", tone="danger" if row["is_abnormal"] else "success", stage="工人报工", level=4, summary="现场报工决定真实产出、报废、返工和人工/设备成本,是材料批次成本落到产品上的关键依据。", metrics=[ ("报工数", _fmt_qty(row["report_qty"])), ("合格数", _fmt_qty(row["good_qty"])), ("报废数", _fmt_qty(row["scrap_qty"])), ], details=[ ("员工", row["employee_name"]), ("开始", _fmt_datetime(row["start_time"])), ("结束", _fmt_datetime(row["end_time"])), ("返工数", _fmt_qty(row["rework_qty"])), ("异常原因", row["scrap_reason"] or ("报工异常" if row["is_abnormal"] else "-")), ], raw=dict(row), ) ) add_edge(_edge(f"op-{row['work_order_operation_id']}", node_id, "扫码报工")) source_material_lot = aliased(StockLot) completion_items = db.execute( select( CompletionReceiptItem.id.label("completion_item_id"), CompletionReceipt.work_order_id.label("work_order_id"), CompletionReceipt.receipt_no.label("receipt_no"), CompletionReceipt.receipt_time.label("receipt_time"), CompletionReceiptItem.lot_no.label("lot_no"), CompletionReceiptItem.receipt_qty.label("receipt_qty"), CompletionReceiptItem.receipt_weight_kg.label("receipt_weight_kg"), CompletionReceiptItem.unit_cost.label("unit_cost"), CompletionReceiptItem.source_material_sub_batch_no.label("source_material_sub_batch_no"), func.coalesce( source_material_lot.lot_no, CompletionReceiptItem.source_material_sub_batch_no, ).label("source_material_lot_no"), CompletionReceiptItem.source_material_summary.label("source_material_summary"), Warehouse.warehouse_name.label("warehouse_name"), ) .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) .outerjoin(source_material_lot, source_material_lot.id == CompletionReceiptItem.source_material_lot_id) .where(CompletionReceipt.work_order_id.in_(work_order_ids) if work_order_ids else false()) .order_by(CompletionReceipt.receipt_time, CompletionReceiptItem.id) ).mappings().all() completion_item_ids = [row["completion_item_id"] for row in completion_items] for row in completion_items: node_id = f"fg-{row['completion_item_id']}" add_node( _node( node_id=node_id, node_type="completion_receipt", title=row["receipt_no"], subtitle=row["lot_no"], status="POSTED", tone="success", stage="成品入库", level=5, summary="成品入库批次已记录来源库存批次号,后续发货可以从成品批次倒查到原材料入库成本。", metrics=[ ("入库数量", _fmt_qty(row["receipt_qty"])), ("入库重量", _fmt_weight(row["receipt_weight_kg"])), ("单位成本", _fmt_money(row["unit_cost"])), ], details=[ ("成品批次", row["lot_no"]), ("仓库", row["warehouse_name"]), ("入库时间", _fmt_datetime(row["receipt_time"])), ("来源库存批次号", row["source_material_lot_no"]), ("来源摘要", row["source_material_summary"]), ], raw=dict(row), ) ) add_edge(_edge(f"wo-{row['work_order_id']}", node_id, "完工入库")) finished_lot = aliased(StockLot) order_customer = aliased(Customer) direct_customer = aliased(Customer) deliveries = db.execute( select( DeliveryItem.id.label("delivery_item_id"), finished_lot.source_line_id.label("completion_item_id"), Delivery.delivery_no.label("delivery_no"), SalesOrder.order_no.label("order_no"), func.coalesce(direct_customer.customer_name, order_customer.customer_name).label("customer_name"), Delivery.delivery_date.label("delivery_date"), DeliveryItem.delivery_qty.label("delivery_qty"), DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), DeliveryItem.line_amount.label("line_amount"), DeliveryItem.status.label("status"), finished_lot.lot_no.label("lot_no"), ) .join(finished_lot, finished_lot.id == DeliveryItem.lot_id) .join(Delivery, Delivery.id == DeliveryItem.delivery_id) .outerjoin(SalesOrder, SalesOrder.id == Delivery.sales_order_id) .outerjoin(order_customer, order_customer.id == SalesOrder.customer_id) .outerjoin(direct_customer, direct_customer.id == Delivery.customer_id) .where(finished_lot.source_line_id.in_(completion_item_ids) if completion_item_ids else false()) .order_by(Delivery.delivery_date, DeliveryItem.id) ).mappings().all() for row in deliveries: node_id = f"delivery-{row['delivery_item_id']}" add_node( _node( node_id=node_id, node_type="delivery", title=row["delivery_no"], subtitle=row["order_no"], status=row["status"], tone=_tone_for_status(row["status"]), stage="发货", level=6, summary="成品库存发给客户。即使订单晚于生产发生,也能从发货批次倒查到原材料入库批次和成本。", metrics=[ ("发货数量", _fmt_qty(row["delivery_qty"])), ("发货重量", _fmt_weight(row["delivery_weight_kg"])), ("发货金额", _fmt_money(row["line_amount"])), ], details=[ ("订单", row["order_no"]), ("客户", row["customer_name"]), ("库存批次号", row["lot_no"]), ("发货时间", _fmt_datetime(row["delivery_date"])), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(f"fg-{row['completion_item_id']}", node_id, "库存发货")) return nodes, edges @router.get("/material-lots/{lot_id}/lifecycle", response_model=MaterialLotLifecycleRead) def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> MaterialLotLifecycleRead: lot_row = db.execute(_material_lot_rows(limit=1).where(StockLot.id == lot_id)).mappings().first() if not lot_row: raise HTTPException(status_code=404, detail="原材料批次不存在") lot = DashboardMaterialLotRead.model_validate(dict(lot_row)) nodes, edges = build_material_lot_lifecycle_graph(db, lot) if not any(node.type == "production_ledger" for node in nodes): nodes, edges = _build_legacy_material_lot_lifecycle_graph(db, lot) return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges) @router.get("/sales-orders/{sales_order_id}/lifecycle", response_model=SalesOrderLifecycleRead) def get_sales_order_lifecycle( sales_order_id: int, db: Session = Depends(get_db), ) -> SalesOrderLifecycleRead: order_row = db.execute(_sales_order_rows(limit=1).where(SalesOrder.id == sales_order_id)).mappings().first() if not order_row: raise HTTPException(status_code=404, detail="订单不存在") order = DashboardSalesOrderRead.model_validate(dict(order_row)) nodes: list[LifecycleNode] = [] edges: list[LifecycleEdge] = [] node_ids: set[str] = set() edge_ids: set[str] = set() def add_node(node: LifecycleNode) -> None: if node.id not in node_ids: node_ids.add(node.id) nodes.append(node) def add_edge(edge: LifecycleEdge) -> None: if edge.source in node_ids and edge.target in node_ids and edge.id not in edge_ids: edge_ids.add(edge.id) edges.append(edge) order_node_id = f"order-{order.order_id}" add_node( _node( node_id=order_node_id, node_type="sales_order", title=order.order_no, subtitle=order.customer_name, status=order.status, tone=_tone_for_status(order.status), stage="订单", level=0, summary="订单生命周期起点,后续 MRP、采购、生产、入库、发货和售后均围绕此单展开。", metrics=[ ("订单金额", _fmt_money(order.total_amount)), ("产品行数", order.line_count), ("订单数量", _fmt_qty(order.total_order_qty)), ("已发数量", _fmt_qty(order.total_delivered_qty)), ], details=[ ("订单号", order.order_no), ("客户编码", order.customer_code), ("客户名称", order.customer_name), ("订单日期", _fmt_date(order.order_date)), ("承诺交期", _fmt_date(order.promised_date)), ("送货地址", order.delivery_address), ("MRP需求数", order.mrp_count), ("采购单数", order.purchase_order_count), ("生产工单数", order.work_order_count), ("成品入库单数", order.completion_receipt_count), ("发货单数", order.delivery_count), ("退货单数", order.return_count), ("最近更新", _fmt_datetime(order.updated_at)), ], raw=order.model_dump(mode="json"), ) ) order_items = db.execute( select( SalesOrderItem.id.label("sales_order_item_id"), SalesOrderItem.line_no.label("line_no"), SalesOrderItem.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), Item.specification.label("specification"), Item.unit_weight_kg.label("unit_weight_kg"), SalesOrderItem.customer_part_no.label("customer_part_no"), SalesOrderItem.order_qty.label("order_qty"), SalesOrderItem.delivered_qty.label("delivered_qty"), SalesOrderItem.unit_price.label("unit_price"), SalesOrderItem.line_amount.label("line_amount"), SalesOrderItem.promised_date.label("promised_date"), SalesOrderItem.status.label("status"), ) .join(Item, Item.id == SalesOrderItem.product_item_id) .where(SalesOrderItem.sales_order_id == sales_order_id) .order_by(SalesOrderItem.line_no) ).mappings().all() item_ids = [row["sales_order_item_id"] for row in order_items] product_ids = [row["product_item_id"] for row in order_items] demands = db.execute( select( MaterialDemand.id.label("demand_id"), MaterialDemand.demand_no.label("demand_no"), MaterialDemand.sales_order_item_id.label("sales_order_item_id"), MaterialDemand.material_item_id.label("material_item_id"), Item.item_code.label("material_code"), Item.item_name.label("material_name"), Item.specification.label("specification"), MaterialDemand.order_qty.label("order_qty"), MaterialDemand.theoretical_weight_kg.label("theoretical_weight_kg"), MaterialDemand.planned_weight_kg.label("planned_weight_kg"), MaterialDemand.shortage_weight_kg.label("shortage_weight_kg"), MaterialDemand.suggested_purchase_weight_kg.label("suggested_purchase_weight_kg"), MaterialDemand.status.label("status"), MaterialDemand.generated_at.label("generated_at"), MaterialDemand.remark.label("remark"), ) .join(Item, Item.id == MaterialDemand.material_item_id) .where(MaterialDemand.sales_order_item_id.in_(item_ids) if item_ids else false()) .where(MaterialDemand.status != "SUPERSEDED") .order_by(MaterialDemand.id) ).mappings().all() demand_ids = [row["demand_id"] for row in demands] po_items = db.execute( select( PurchaseOrderItem.id.label("purchase_order_item_id"), PurchaseOrderItem.purchase_order_id.label("purchase_order_id"), PurchaseOrderItem.source_demand_id.label("source_demand_id"), PurchaseOrderItem.line_no.label("line_no"), PurchaseOrderItem.order_weight_kg.label("order_weight_kg"), PurchaseOrderItem.received_weight_kg.label("received_weight_kg"), PurchaseOrderItem.unit_price.label("unit_price"), PurchaseOrderItem.line_amount.label("line_amount"), PurchaseOrderItem.status.label("status"), PurchaseOrder.po_no.label("po_no"), PurchaseOrder.order_date.label("order_date"), PurchaseOrder.expected_date.label("expected_date"), PurchaseOrder.total_amount.label("total_amount"), Supplier.supplier_name.label("supplier_name"), ) .join(PurchaseOrder, PurchaseOrder.id == PurchaseOrderItem.purchase_order_id) .join(Supplier, Supplier.id == PurchaseOrder.supplier_id) .where(PurchaseOrderItem.source_demand_id.in_(demand_ids) if demand_ids else false()) .order_by(PurchaseOrder.id, PurchaseOrderItem.line_no) ).mappings().all() po_item_ids = [row["purchase_order_item_id"] for row in po_items] receipt_items = db.execute( select( PurchaseReceiptItem.id.label("receipt_item_id"), PurchaseReceiptItem.purchase_order_item_id.label("purchase_order_item_id"), PurchaseReceiptItem.line_no.label("line_no"), PurchaseReceiptItem.lot_no.label("lot_no"), PurchaseReceiptItem.received_weight_kg.label("received_weight_kg"), PurchaseReceiptItem.accepted_weight_kg.label("accepted_weight_kg"), PurchaseReceiptItem.unit_cost.label("unit_cost"), PurchaseReceiptItem.status.label("status"), PurchaseReceipt.receipt_no.label("receipt_no"), PurchaseReceipt.receipt_date.label("receipt_date"), Warehouse.warehouse_name.label("warehouse_name"), ) .join(PurchaseReceipt, PurchaseReceipt.id == PurchaseReceiptItem.receipt_id) .join(Warehouse, Warehouse.id == PurchaseReceipt.warehouse_id) .where(PurchaseReceiptItem.purchase_order_item_id.in_(po_item_ids) if po_item_ids else false()) .order_by(PurchaseReceipt.id, PurchaseReceiptItem.line_no) ).mappings().all() work_orders = db.execute( select( WorkOrder.id.label("work_order_id"), WorkOrder.work_order_no.label("work_order_no"), WorkOrder.source_sales_order_item_id.label("source_sales_order_item_id"), WorkOrder.product_item_id.label("product_item_id"), Item.item_code.label("product_code"), Item.item_name.label("product_name"), WorkOrder.work_order_type.label("work_order_type"), WorkOrder.planned_qty.label("planned_qty"), WorkOrder.finished_qty.label("finished_qty"), WorkOrder.scrap_qty.label("scrap_qty"), WorkOrder.planned_start_time.label("planned_start_time"), WorkOrder.planned_end_time.label("planned_end_time"), WorkOrder.actual_start_time.label("actual_start_time"), WorkOrder.actual_end_time.label("actual_end_time"), WorkOrder.priority_level.label("priority_level"), WorkOrder.status.label("status"), WorkOrder.remark.label("remark"), ) .join(Item, Item.id == WorkOrder.product_item_id) .where(WorkOrder.source_sales_order_item_id.in_(item_ids) if item_ids else false()) .order_by(WorkOrder.id) ).mappings().all() work_order_ids = [row["work_order_id"] for row in work_orders] operations = db.execute( select( WorkOrderOperation.id.label("work_order_operation_id"), WorkOrderOperation.work_order_id.label("work_order_id"), WorkOrderOperation.seq_no.label("seq_no"), WorkOrderOperation.operation_name.label("operation_name"), WorkOrderOperation.planned_qty.label("planned_qty"), WorkOrderOperation.report_qty.label("report_qty"), WorkOrderOperation.good_qty.label("good_qty"), WorkOrderOperation.scrap_qty.label("scrap_qty"), WorkOrderOperation.rework_qty.label("rework_qty"), WorkOrderOperation.allowed_scrap_rate.label("allowed_scrap_rate"), WorkOrderOperation.is_abnormal.label("is_abnormal"), WorkOrderOperation.status.label("status"), WorkOrderOperation.remark.label("remark"), ) .where(WorkOrderOperation.work_order_id.in_(work_order_ids) if work_order_ids else false()) .order_by(WorkOrderOperation.work_order_id, WorkOrderOperation.seq_no) ).mappings().all() operation_ids = [row["work_order_operation_id"] for row in operations] reports = db.execute( select( OperationReport.id.label("report_id"), OperationReport.report_no.label("report_no"), OperationReport.work_order_id.label("work_order_id"), OperationReport.work_order_operation_id.label("work_order_operation_id"), Employee.employee_name.label("employee_name"), OperationReport.start_time.label("start_time"), OperationReport.end_time.label("end_time"), OperationReport.report_qty.label("report_qty"), OperationReport.good_qty.label("good_qty"), OperationReport.scrap_qty.label("scrap_qty"), OperationReport.rework_qty.label("rework_qty"), OperationReport.scrap_reason.label("scrap_reason"), OperationReport.downtime_minutes.label("downtime_minutes"), OperationReport.extra_cost_amount.label("extra_cost_amount"), OperationReport.is_abnormal.label("is_abnormal"), ) .join(Employee, Employee.id == OperationReport.employee_id) .where(OperationReport.work_order_operation_id.in_(operation_ids) if operation_ids else false()) .order_by(OperationReport.id) ).mappings().all() report_ids = [row["report_id"] for row in reports] scrap_records = db.execute( select( ScrapRecord.id.label("scrap_record_id"), ScrapRecord.scrap_no.label("scrap_no"), ScrapRecord.report_id.label("report_id"), ScrapRecord.work_order_id.label("work_order_id"), ScrapRecord.work_order_operation_id.label("work_order_operation_id"), ScrapRecord.scrap_qty.label("scrap_qty"), ScrapRecord.scrap_weight_kg.label("scrap_weight_kg"), ScrapRecord.disposal_type.label("disposal_type"), ScrapRecord.saleable_scrap_weight_kg.label("saleable_scrap_weight_kg"), ScrapRecord.scrap_cost_amount.label("scrap_cost_amount"), ScrapRecord.scrap_reason.label("scrap_reason"), ScrapRecord.status.label("status"), ) .where(ScrapRecord.report_id.in_(report_ids) if report_ids else false()) .order_by(ScrapRecord.id) ).mappings().all() source_material_lot = aliased(StockLot) completion_items = db.execute( select( CompletionReceiptItem.id.label("completion_item_id"), CompletionReceiptItem.completion_receipt_id.label("completion_receipt_id"), CompletionReceipt.work_order_id.label("work_order_id"), CompletionReceipt.receipt_no.label("receipt_no"), CompletionReceipt.receipt_time.label("receipt_time"), CompletionReceiptItem.lot_no.label("lot_no"), CompletionReceiptItem.receipt_qty.label("receipt_qty"), CompletionReceiptItem.receipt_weight_kg.label("receipt_weight_kg"), CompletionReceiptItem.unit_cost.label("unit_cost"), CompletionReceiptItem.source_material_lot_id.label("source_material_lot_id"), source_material_lot.lot_no.label("source_material_lot_no"), CompletionReceiptItem.source_material_sub_batch_no.label("source_material_sub_batch_no"), CompletionReceiptItem.status.label("status"), Warehouse.warehouse_name.label("warehouse_name"), ) .join(CompletionReceipt, CompletionReceipt.id == CompletionReceiptItem.completion_receipt_id) .join(Warehouse, Warehouse.id == CompletionReceipt.warehouse_id) .outerjoin(source_material_lot, source_material_lot.id == CompletionReceiptItem.source_material_lot_id) .where(CompletionReceipt.work_order_id.in_(work_order_ids) if work_order_ids else false()) .order_by(CompletionReceipt.id, CompletionReceiptItem.line_no) ).mappings().all() deliveries = db.execute( select( DeliveryItem.id.label("delivery_item_id"), DeliveryItem.delivery_id.label("delivery_id"), Delivery.delivery_no.label("delivery_no"), DeliveryItem.sales_order_item_id.label("sales_order_item_id"), DeliveryItem.product_item_id.label("product_item_id"), DeliveryItem.lot_id.label("lot_id"), StockLot.lot_no.label("lot_no"), DeliveryItem.delivery_qty.label("delivery_qty"), DeliveryItem.delivery_weight_kg.label("delivery_weight_kg"), DeliveryItem.unit_price.label("unit_price"), DeliveryItem.line_amount.label("line_amount"), DeliveryItem.status.label("status"), Delivery.delivery_date.label("delivery_date"), Delivery.consignee_name.label("consignee_name"), Delivery.consignee_phone.label("consignee_phone"), Delivery.delivery_address.label("delivery_address"), ) .join(Delivery, Delivery.id == DeliveryItem.delivery_id) .outerjoin(StockLot, StockLot.id == DeliveryItem.lot_id) .where(Delivery.sales_order_id == sales_order_id) .order_by(Delivery.id, DeliveryItem.line_no) ).mappings().all() return_items = db.execute( select( ReturnItem.id.label("return_item_id"), ReturnItem.return_order_id.label("return_order_id"), ReturnOrder.return_no.label("return_no"), ReturnOrder.return_date.label("return_date"), ReturnOrder.return_reason.label("return_reason"), ReturnOrder.status.label("return_status"), ReturnItem.sales_order_item_id.label("sales_order_item_id"), ReturnItem.product_item_id.label("product_item_id"), ReturnItem.return_qty.label("return_qty"), ReturnItem.return_weight_kg.label("return_weight_kg"), ReturnItem.disposition_status.label("disposition_status"), ReturnItem.responsibility_type.label("responsibility_type"), ReturnItem.remark.label("remark"), ) .join(ReturnOrder, ReturnOrder.id == ReturnItem.return_order_id) .where( (ReturnOrder.sales_order_id == sales_order_id) | (ReturnItem.sales_order_item_id.in_(item_ids) if item_ids else false()) ) .order_by(ReturnOrder.id, ReturnItem.line_no) ).mappings().all() return_item_ids = [row["return_item_id"] for row in return_items] rework_wo = aliased(WorkOrder) return_dispositions = db.execute( select( ReturnDisposition.id.label("return_disposition_id"), ReturnDisposition.disposition_no.label("disposition_no"), ReturnDisposition.return_item_id.label("return_item_id"), ReturnDisposition.disposition_type.label("disposition_type"), ReturnDisposition.scrap_weight_kg.label("scrap_weight_kg"), ReturnDisposition.extra_cost_amount.label("extra_cost_amount"), ReturnDisposition.scrap_sale_amount.label("scrap_sale_amount"), ReturnDisposition.closed_at.label("closed_at"), ReturnDisposition.status.label("status"), ReturnDisposition.remark.label("remark"), rework_wo.work_order_no.label("rework_work_order_no"), ) .outerjoin(rework_wo, rework_wo.id == ReturnDisposition.rework_work_order_id) .where(ReturnDisposition.return_item_id.in_(return_item_ids) if return_item_ids else false()) .order_by(ReturnDisposition.id) ).mappings().all() # Build nodes and edges from left to right. for row in order_items: node_id = f"item-{row['sales_order_item_id']}" add_node( _node( node_id=node_id, node_type="sales_order_item", title=f"L{row['line_no']:02d} {row['product_name']}", subtitle=row["product_code"], status=row["status"], tone=_tone_for_status(row["status"]), stage="产品明细", level=1, summary="订单中的一个产品行,后续 MRP、工单、发货和退货都可追溯到该行。", metrics=[ ("订单数", _fmt_qty(row["order_qty"])), ("已发数", _fmt_qty(row["delivered_qty"])), ("行金额", _fmt_money(row["line_amount"])), ], details=[ ("产品编码", row["product_code"]), ("产品名称", row["product_name"]), ("规格", row["specification"]), ("单重", _fmt_weight(row["unit_weight_kg"])), ("客户料号", row["customer_part_no"]), ("承诺交期", _fmt_date(row["promised_date"])), ("订单单价", _fmt_money(row["unit_price"])), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(order_node_id, node_id, "订单产品")) for row in demands: node_id = f"mrp-{row['demand_id']}" source_id = f"item-{row['sales_order_item_id']}" add_node( _node( node_id=node_id, node_type="mrp_demand", title=row["demand_no"], subtitle=f"{row['material_code']} · {row['material_name']}", status=row["status"], tone=_tone_for_status(row["status"]), stage="MRP需求", level=2, summary="按产品 BOM、订单数量、损耗率和库存余额生成的原材料需求。", metrics=[ ("计划重量", _fmt_weight(row["planned_weight_kg"])), ("缺口重量", _fmt_weight(row["shortage_weight_kg"])), ("建议采购", _fmt_weight(row["suggested_purchase_weight_kg"])), ], details=[ ("需求单号", row["demand_no"]), ("原材料编码", row["material_code"]), ("原材料名称", row["material_name"]), ("规格", row["specification"]), ("理论重量", _fmt_weight(row["theoretical_weight_kg"])), ("订单数量", _fmt_qty(row["order_qty"])), ("生成时间", _fmt_datetime(row["generated_at"])), ("备注", row["remark"]), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "BOM展开")) for row in po_items: node_id = f"po-item-{row['purchase_order_item_id']}" source_id = f"mrp-{row['source_demand_id']}" add_node( _node( node_id=node_id, node_type="purchase_order_item", title=f"{row['po_no']} L{row['line_no']:02d}", subtitle=row["supplier_name"], status=row["status"], tone=_tone_for_status(row["status"]), stage="采购订单", level=3, summary="MRP需求转为采购执行,记录供应商、采购重量、到货进度和采购金额。", metrics=[ ("采购重量", _fmt_weight(row["order_weight_kg"])), ("已收重量", _fmt_weight(row["received_weight_kg"])), ("行金额", _fmt_money(row["line_amount"])), ], details=[ ("采购单号", row["po_no"]), ("供应商", row["supplier_name"]), ("采购日期", _fmt_date(row["order_date"])), ("预计到货", _fmt_date(row["expected_date"])), ("采购单价", f"{_fmt_money(row['unit_price'])}/kg"), ("采购总额", _fmt_money(row["total_amount"])), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "采购下单")) for row in receipt_items: node_id = f"receipt-item-{row['receipt_item_id']}" source_id = f"po-item-{row['purchase_order_item_id']}" add_node( _node( node_id=node_id, node_type="purchase_receipt_item", title=f"{row['receipt_no']} L{row['line_no']:02d}", subtitle=row["lot_no"], status=row["status"], tone=_tone_for_status(row["status"]), stage="到货质检", level=4, summary="采购到货后进入仓库待检/放行,批次号成为后续库存追溯的关键。", metrics=[ ("收货重量", _fmt_weight(row["received_weight_kg"])), ("合格重量", _fmt_weight(row["accepted_weight_kg"])), ("入库单价", f"{_fmt_money(row['unit_cost'])}/kg"), ], details=[ ("入库单号", row["receipt_no"]), ("批次号", row["lot_no"]), ("仓库", row["warehouse_name"]), ("收货时间", _fmt_datetime(row["receipt_date"])), ("质检/入库状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "到货入库")) for row in work_orders: node_id = f"wo-{row['work_order_id']}" source_id = f"item-{row['source_sales_order_item_id']}" add_node( _node( node_id=node_id, node_type="work_order", title=row["work_order_no"], subtitle=row["product_name"], status=row["status"], tone=_tone_for_status(row["status"]), stage="生产工单", level=5, summary="订单产品进入车间执行,工单承接 BOM 用料、工艺路线、报工和完工入库。", metrics=[ ("计划数", _fmt_qty(row["planned_qty"])), ("完工数", _fmt_qty(row["finished_qty"])), ("报废数", _fmt_qty(row["scrap_qty"])), ], details=[ ("工单号", row["work_order_no"]), ("工单类型", _label(WORK_ORDER_TYPE_LABELS, row["work_order_type"])), ("产品编码", row["product_code"]), ("产品名称", row["product_name"]), ("计划开始", _fmt_datetime(row["planned_start_time"])), ("计划完工", _fmt_datetime(row["planned_end_time"])), ("实际开始", _fmt_datetime(row["actual_start_time"])), ("实际完工", _fmt_datetime(row["actual_end_time"])), ("优先级", row["priority_level"]), ("备注", row["remark"]), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "工单下达")) operation_by_work_order: dict[int, list[dict[str, Any]]] = {} for row in operations: operation_by_work_order.setdefault(row["work_order_id"], []).append(dict(row)) node_id = f"op-{row['work_order_operation_id']}" source_id = f"wo-{row['work_order_id']}" add_node( _node( node_id=node_id, node_type="work_order_operation", title=f"OP{row['seq_no']:02d} {row['operation_name']}", subtitle="关键异常" if row["is_abnormal"] else "工序执行", status=row["status"], tone="danger" if row["is_abnormal"] else _tone_for_status(row["status"]), stage="工序执行", level=6, summary="工序节点反映计划数、报工数、合格数、报废返工数和异常情况。", metrics=[ ("计划数", _fmt_qty(row["planned_qty"])), ("已报工", _fmt_qty(row["report_qty"])), ("报废数", _fmt_qty(row["scrap_qty"])), ], details=[ ("工序序号", row["seq_no"]), ("工序名称", row["operation_name"]), ("合格数", _fmt_qty(row["good_qty"])), ("返工数", _fmt_qty(row["rework_qty"])), ("允许报废率", f"{_num(row['allowed_scrap_rate']) * 100:.2f}%"), ("是否异常", "是" if row["is_abnormal"] else "否"), ("备注", row["remark"]), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "工艺路线")) for row in reports: node_id = f"report-{row['report_id']}" source_id = f"op-{row['work_order_operation_id']}" add_node( _node( node_id=node_id, node_type="operation_report", title=row["report_no"], subtitle=row["employee_name"], status="ABNORMAL" if row["is_abnormal"] else "NORMAL", tone="danger" if row["is_abnormal"] else "success", stage="工人报工", level=7, summary="一线员工扫码报工记录,直接影响工序进度、节拍、报废返工和成本。", metrics=[ ("报工数", _fmt_qty(row["report_qty"])), ("合格数", _fmt_qty(row["good_qty"])), ("报废数", _fmt_qty(row["scrap_qty"])), ], details=[ ("报工单号", row["report_no"]), ("员工", row["employee_name"]), ("开始时间", _fmt_datetime(row["start_time"])), ("结束时间", _fmt_datetime(row["end_time"])), ("返工数", _fmt_qty(row["rework_qty"])), ("停机分钟", _fmt_qty(row["downtime_minutes"])), ("额外成本", _fmt_money(row["extra_cost_amount"])), ("异常原因", row["scrap_reason"] or ("节拍/报废异常" if row["is_abnormal"] else "-")), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "扫码报工")) for row in scrap_records: node_id = f"scrap-{row['scrap_record_id']}" source_id = f"report-{row['report_id']}" add_node( _node( node_id=node_id, node_type="scrap_rework", title=row["scrap_no"], subtitle="返工" if row["disposal_type"] == "REWORK" else "报废", status=row["status"], tone="warning" if row["disposal_type"] == "REWORK" else "danger", stage="报废/返工", level=8, summary="对报工异常、不良品进行处置,返工进入再加工,报废进入废料成本/收益口径。", metrics=[ ("处置数", _fmt_qty(row["scrap_qty"])), ("处置重量", _fmt_weight(row["scrap_weight_kg"])), ("成本", _fmt_money(row["scrap_cost_amount"])), ], details=[ ("处置单号", row["scrap_no"]), ("处置方式", _disposal_label(row["disposal_type"])), ("可售废料重量", _fmt_weight(row["saleable_scrap_weight_kg"])), ("处置原因", row["scrap_reason"]), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "异常处置")) for row in completion_items: node_id = f"fg-{row['completion_item_id']}" source_id = f"wo-{row['work_order_id']}" source_material_lot_no = row["source_material_lot_no"] or row["source_material_sub_batch_no"] or "-" add_node( _node( node_id=node_id, node_type="completion_receipt", title=row["receipt_no"], subtitle=row["lot_no"], status=row["status"], tone=_tone_for_status(row["status"]), stage="成品入库", level=9, summary="工单全部工序完成后形成成品入库批次,后续发货从该批次扣减。", metrics=[ ("入库数量", _fmt_qty(row["receipt_qty"])), ("入库重量", _fmt_weight(row["receipt_weight_kg"])), ("单位成本", _fmt_money(row["unit_cost"])), ], details=[ ("成品入库单", row["receipt_no"]), ("成品批次", row["lot_no"]), ("来源库存批次号", source_material_lot_no), ("仓库", row["warehouse_name"]), ("入库时间", _fmt_datetime(row["receipt_time"])), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "完工入库")) for row in deliveries: node_id = f"delivery-{row['delivery_item_id']}" source_id = f"item-{row['sales_order_item_id']}" lot_node = next((node for node in nodes if node.type == "completion_receipt" and node.subtitle == row["lot_no"]), None) if lot_node: source_id = lot_node.id add_node( _node( node_id=node_id, node_type="delivery", title=row["delivery_no"], subtitle=row["lot_no"] or "发货明细", status=row["status"], tone=_tone_for_status(row["status"]), stage="发货", level=10, summary="成品从库存批次发出,记录发货数量、金额、收货人与送货地址。", metrics=[ ("发货数量", _fmt_qty(row["delivery_qty"])), ("发货重量", _fmt_weight(row["delivery_weight_kg"])), ("金额", _fmt_money(row["line_amount"])), ], details=[ ("发货单号", row["delivery_no"]), ("库存批次号", row["lot_no"]), ("发货时间", _fmt_datetime(row["delivery_date"])), ("收货人", row["consignee_name"]), ("联系电话", row["consignee_phone"]), ("送货地址", row["delivery_address"]), ("单价", _fmt_money(row["unit_price"])), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "发货出库")) for row in return_items: node_id = f"return-{row['return_item_id']}" source_id = f"item-{row['sales_order_item_id']}" if row["sales_order_item_id"] else order_node_id add_node( _node( node_id=node_id, node_type="return", title=row["return_no"], subtitle=row["return_reason"] or "退货", status=row["disposition_status"], tone=_tone_for_status(row["disposition_status"]), stage="退货售后", level=11, summary="客户退货登记,进入待处置状态后可选择返工或报废。", metrics=[ ("退货数量", _fmt_qty(row["return_qty"])), ("退货重量", _fmt_weight(row["return_weight_kg"])), ("处置状态", _status_label(row["disposition_status"])), ], details=[ ("退货单号", row["return_no"]), ("退货时间", _fmt_datetime(row["return_date"])), ("退货原因", row["return_reason"]), ("责任归属", row["responsibility_type"]), ("退货单状态", _status_label(row["return_status"])), ("备注", row["remark"]), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "退货登记")) for row in return_dispositions: node_id = f"return-disposition-{row['return_disposition_id']}" source_id = f"return-{row['return_item_id']}" add_node( _node( node_id=node_id, node_type="return_disposition", title=row["disposition_no"], subtitle="返工" if row["disposition_type"] == "REWORK" else "报废", status=row["status"], tone="warning" if row["disposition_type"] == "REWORK" else "danger", stage="退货处置", level=12, summary="退货最终处置结果。返工会产生返工工单和额外人工成本,报废会形成废料销售金额。", metrics=[ ("报废重量", _fmt_weight(row["scrap_weight_kg"])), ("额外人工", _fmt_money(row["extra_cost_amount"])), ("废料销售", _fmt_money(row["scrap_sale_amount"])), ], details=[ ("处置单号", row["disposition_no"]), ("处置方式", _disposal_label(row["disposition_type"])), ("返工工单", row["rework_work_order_no"]), ("完成时间", _fmt_datetime(row["closed_at"])), ("备注", row["remark"]), ("状态", _status_label(row["status"])), ], raw=dict(row), ) ) add_edge(_edge(source_id, node_id, "售后处置")) return SalesOrderLifecycleRead(order=order, nodes=nodes, edges=edges)