457 lines
19 KiB
Python
457 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
from sqlalchemy import false, select
|
|
from sqlalchemy.orm import Session, aliased
|
|
|
|
from app.models.master_data import Item
|
|
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot
|
|
from app.models.org import Employee
|
|
from app.models.sales import Customer, SalesOrder
|
|
from app.schemas.dashboard import DashboardMaterialLotRead, LifecycleDetailItem, LifecycleEdge, LifecycleNode
|
|
from app.services.document_archives import production_document_batch_no_from_text
|
|
from app.services.system_config import get_smart_operation_report_enabled
|
|
|
|
|
|
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_datetime(value: Any) -> str:
|
|
if not value:
|
|
return "-"
|
|
return str(value).replace("T", " ")[:19]
|
|
|
|
|
|
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 _append_node(nodes: list[LifecycleNode], node_ids: set[str], node: LifecycleNode) -> None:
|
|
if node.id not in node_ids:
|
|
node_ids.add(node.id)
|
|
nodes.append(node)
|
|
|
|
|
|
def _append_edge(edges: list[LifecycleEdge], edge_ids: set[str], node_ids: set[str], 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)
|
|
|
|
|
|
def _settlement_group_key(txn: ProductionBatchLedgerTxn) -> tuple[str, str]:
|
|
batch_no = production_document_batch_no_from_text(txn.remark)
|
|
if batch_no:
|
|
return f"batch:{batch_no}", batch_no
|
|
return f"txn:{txn.id}", f"{txn.txn_type}-{txn.id}"
|
|
|
|
|
|
def _txn_node_type(txn_type: str) -> str:
|
|
return {
|
|
"成品入库": "finished_inbound",
|
|
"生产余料入库": "surplus_inbound",
|
|
"生产废料入库": "scrap_inbound",
|
|
"重新开工": "ledger_reopen",
|
|
}.get(txn_type, "ledger_writeoff")
|
|
|
|
|
|
def build_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()
|
|
|
|
raw_node_id = f"raw-lot-{lot.lot_id}"
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=raw_node_id,
|
|
node_type="raw_material_lot",
|
|
title=lot.lot_no,
|
|
subtitle=f"{lot.material_code} · {lot.material_name}",
|
|
status=lot.status,
|
|
stage="原料入库",
|
|
level=0,
|
|
summary="原材料批次入库后,按生产台账追踪生产出库、库外余额、入库结算和销售去向。",
|
|
metrics=[
|
|
("入库重量", _fmt_weight(lot.inbound_weight_kg)),
|
|
("剩余重量", _fmt_weight(lot.remaining_weight_kg)),
|
|
],
|
|
details=[
|
|
("原材料编码", lot.material_code),
|
|
("原材料名称", lot.material_name),
|
|
("仓库", lot.warehouse_name),
|
|
],
|
|
raw=lot.model_dump(),
|
|
),
|
|
)
|
|
|
|
ledger_rows = db.execute(
|
|
select(
|
|
ProductionBatchLedger,
|
|
Item.item_code.label("product_code"),
|
|
Item.item_name.label("product_name"),
|
|
)
|
|
.join(Item, Item.id == ProductionBatchLedger.product_item_id)
|
|
.where(ProductionBatchLedger.material_lot_id == lot.lot_id)
|
|
.order_by(ProductionBatchLedger.id)
|
|
).all()
|
|
ledgers = [row.ProductionBatchLedger for row in ledger_rows]
|
|
ledger_ids = [int(ledger.id) for ledger in ledgers]
|
|
|
|
txns_by_ledger_id: dict[int, list[ProductionBatchLedgerTxn]] = {ledger_id: [] for ledger_id in ledger_ids}
|
|
if ledger_ids:
|
|
txns = db.scalars(
|
|
select(ProductionBatchLedgerTxn)
|
|
.where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids))
|
|
.order_by(ProductionBatchLedgerTxn.biz_time, ProductionBatchLedgerTxn.id)
|
|
).all()
|
|
for txn in txns:
|
|
txns_by_ledger_id.setdefault(int(txn.production_ledger_id), []).append(txn)
|
|
|
|
finished_node_id_by_lot_id: dict[int, str] = {}
|
|
settlement_txn_types = {"成品入库", "生产余料入库", "生产废料入库", "结单核销", "该批材料结单", "重新开工"}
|
|
for row in ledger_rows:
|
|
ledger = row.ProductionBatchLedger
|
|
ledger_id = int(ledger.id)
|
|
ledger_node_id = f"ledger-{ledger_id}"
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=ledger_node_id,
|
|
node_type="production_ledger",
|
|
title=f"{ledger.material_lot_no} · {row.product_name}",
|
|
subtitle=row.product_code,
|
|
status=ledger.status,
|
|
stage="生产台账",
|
|
level=1,
|
|
summary="一行生产台账代表同一材料库存批次号与产品组合,是材料批次闭环的主链路。",
|
|
metrics=[
|
|
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
|
|
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
|
|
("成品入库", _fmt_qty(ledger.finished_inbound_qty)),
|
|
("余料入库", _fmt_weight(ledger.surplus_inbound_weight_kg)),
|
|
("废料入库", _fmt_weight(ledger.scrap_inbound_weight_kg)),
|
|
],
|
|
details=[
|
|
("产品编码", row.product_code),
|
|
("产品名称", row.product_name),
|
|
("首次领料", _fmt_datetime(ledger.first_issue_time)),
|
|
("最近领料", _fmt_datetime(ledger.last_issue_time)),
|
|
("结单次数", ledger.lock_count),
|
|
("备注", ledger.remark),
|
|
],
|
|
raw={
|
|
"production_ledger_id": ledger.id,
|
|
"material_lot_id": ledger.material_lot_id,
|
|
"product_item_id": ledger.product_item_id,
|
|
},
|
|
),
|
|
)
|
|
_append_edge(edges, edge_ids, node_ids, _edge(raw_node_id, ledger_node_id, "进入生产台账"))
|
|
|
|
issue_txns = [txn for txn in txns_by_ledger_id.get(ledger_id, []) if txn.txn_type == "生产出库"]
|
|
issue_node_ids: list[str] = []
|
|
for txn in issue_txns:
|
|
issue_node_id = f"issue-txn-{txn.id}"
|
|
issue_node_ids.append(issue_node_id)
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=issue_node_id,
|
|
node_type="production_issue_txn",
|
|
title=f"生产出库 {txn.id}",
|
|
subtitle=ledger.material_lot_no,
|
|
status="POSTED",
|
|
tone="success",
|
|
stage="生产出库",
|
|
level=2,
|
|
summary="生产出库事务累加到生产台账,并增加该批材料的库外余额。",
|
|
metrics=[
|
|
("出库重量", _fmt_weight(txn.weight_delta_kg)),
|
|
("出库后库外余额", _fmt_weight(txn.outside_weight_after_kg)),
|
|
],
|
|
details=[
|
|
("业务时间", _fmt_datetime(txn.biz_time)),
|
|
("来源单据", txn.source_doc_type),
|
|
("来源ID", txn.source_doc_id),
|
|
("备注", txn.remark),
|
|
],
|
|
raw={"txn_id": txn.id, "production_ledger_id": txn.production_ledger_id},
|
|
),
|
|
)
|
|
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, issue_node_id, "生产出库"))
|
|
|
|
outside_node_id = f"outside-balance-{ledger_id}"
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=outside_node_id,
|
|
node_type="ledger_outside_balance",
|
|
title="库外余额",
|
|
subtitle=ledger.material_lot_no,
|
|
status=ledger.status,
|
|
stage="库外闭环",
|
|
level=3,
|
|
summary="库外余额用于衡量该材料批次尚未通过成品、余料、废料入库或结单核销闭环的重量。",
|
|
metrics=[
|
|
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
|
|
("累计领料", _fmt_weight(ledger.total_issued_weight_kg)),
|
|
("结单核销", _fmt_weight(ledger.writeoff_weight_kg)),
|
|
],
|
|
raw={"production_ledger_id": ledger.id},
|
|
),
|
|
)
|
|
if issue_node_ids:
|
|
for issue_node_id in issue_node_ids:
|
|
_append_edge(edges, edge_ids, node_ids, _edge(issue_node_id, outside_node_id, "形成库外余额"))
|
|
else:
|
|
_append_edge(edges, edge_ids, node_ids, _edge(ledger_node_id, outside_node_id, "库外余额"))
|
|
|
|
settlement_groups: dict[tuple[str, str], list[ProductionBatchLedgerTxn]] = {}
|
|
for txn in txns_by_ledger_id.get(ledger_id, []):
|
|
if txn.txn_type in settlement_txn_types:
|
|
settlement_groups.setdefault(_settlement_group_key(txn), []).append(txn)
|
|
|
|
for group_index, ((group_key, group_title), group_txns) in enumerate(settlement_groups.items(), start=1):
|
|
settlement_node_id = f"settlement-{ledger_id}-{group_key}"
|
|
total_weight_delta = sum(_num(txn.weight_delta_kg) for txn in group_txns)
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=settlement_node_id,
|
|
node_type="production_ledger_settlement",
|
|
title=group_title,
|
|
subtitle=f"{len(group_txns)} 笔台账事务",
|
|
status="POSTED",
|
|
tone="success",
|
|
stage="入库结算",
|
|
level=4,
|
|
summary="按生产单据批次号聚合成品、余料、废料入库及结单核销事务;没有批次号的事务独立成组。",
|
|
metrics=[
|
|
("事务数", len(group_txns)),
|
|
("重量变化", _fmt_weight(total_weight_delta)),
|
|
],
|
|
details=[
|
|
("分组", group_title),
|
|
("序号", group_index),
|
|
],
|
|
raw={"production_ledger_id": ledger.id, "group_key": group_key},
|
|
),
|
|
)
|
|
_append_edge(edges, edge_ids, node_ids, _edge(outside_node_id, settlement_node_id, "入库结算"))
|
|
|
|
txns_by_type: dict[str, list[ProductionBatchLedgerTxn]] = {}
|
|
for txn in group_txns:
|
|
txns_by_type.setdefault(str(txn.txn_type), []).append(txn)
|
|
for txn_type, type_txns in txns_by_type.items():
|
|
child_node_id = f"{_txn_node_type(txn_type)}-{ledger_id}-{group_key}"
|
|
child_weight_delta = sum(_num(txn.weight_delta_kg) for txn in type_txns)
|
|
child_qty_delta = sum(_num(txn.qty_delta) for txn in type_txns)
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=child_node_id,
|
|
node_type=_txn_node_type(txn_type),
|
|
title=txn_type,
|
|
subtitle=group_title,
|
|
status="POSTED",
|
|
tone="success",
|
|
stage=txn_type,
|
|
level=5,
|
|
summary="该节点来自生产台账事务,并参与材料批次库外闭环。",
|
|
metrics=[
|
|
("数量变化", _fmt_qty(child_qty_delta)),
|
|
("重量变化", _fmt_weight(child_weight_delta)),
|
|
("事务数", len(type_txns)),
|
|
],
|
|
details=[
|
|
("最近库外余额", _fmt_weight(type_txns[-1].outside_weight_after_kg)),
|
|
("业务时间", _fmt_datetime(type_txns[-1].biz_time)),
|
|
],
|
|
raw={
|
|
"production_ledger_id": ledger.id,
|
|
"txn_ids": [txn.id for txn in type_txns],
|
|
"group_key": group_key,
|
|
},
|
|
),
|
|
)
|
|
_append_edge(edges, edge_ids, node_ids, _edge(settlement_node_id, child_node_id, txn_type))
|
|
if _txn_node_type(txn_type) == "finished_inbound":
|
|
for txn in type_txns:
|
|
if txn.source_line_id:
|
|
finished_node_id_by_lot_id[int(txn.source_line_id)] = child_node_id
|
|
|
|
if ledger_ids and get_smart_operation_report_enabled(db):
|
|
report_rows = db.execute(
|
|
select(
|
|
OperationReport,
|
|
Employee.employee_name.label("employee_name"),
|
|
)
|
|
.join(Employee, Employee.id == OperationReport.employee_id)
|
|
.where(OperationReport.production_ledger_id.in_(ledger_ids))
|
|
.order_by(OperationReport.start_time, OperationReport.id)
|
|
).all()
|
|
for row in report_rows:
|
|
report = row.OperationReport
|
|
node_id = f"report-{report.id}"
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=node_id,
|
|
node_type="operation_report",
|
|
title=report.report_no,
|
|
subtitle=row.employee_name,
|
|
status="ABNORMAL" if report.is_abnormal else "NORMAL",
|
|
tone="danger" if report.is_abnormal else "success",
|
|
stage="现场报工证据",
|
|
level=2,
|
|
summary="只作为现场报工证据展示,不参与材料批次闭环判定。",
|
|
metrics=[
|
|
("报工数", _fmt_qty(report.report_qty)),
|
|
("合格数", _fmt_qty(report.good_qty)),
|
|
("报废数", _fmt_qty(report.scrap_qty)),
|
|
],
|
|
details=[
|
|
("员工", row.employee_name),
|
|
("开始", _fmt_datetime(report.start_time)),
|
|
("结束", _fmt_datetime(report.end_time)),
|
|
("来源", report.report_source),
|
|
],
|
|
raw={"report_id": report.id, "production_ledger_id": report.production_ledger_id},
|
|
),
|
|
)
|
|
_append_edge(edges, edge_ids, node_ids, _edge(f"ledger-{report.production_ledger_id}", node_id, "报工证据"))
|
|
|
|
finished_lot = aliased(StockLot)
|
|
order_customer = aliased(Customer)
|
|
direct_customer = aliased(Customer)
|
|
delivery_rows = db.execute(
|
|
select(
|
|
DeliveryItem.id.label("delivery_item_id"),
|
|
DeliveryItem.lot_id.label("delivery_lot_id"),
|
|
finished_lot.id.label("finished_lot_id"),
|
|
finished_lot.source_doc_id.label("production_ledger_id"),
|
|
finished_lot.lot_no.label("lot_no"),
|
|
Delivery.delivery_no.label("delivery_no"),
|
|
SalesOrder.order_no.label("order_no"),
|
|
direct_customer.customer_name.label("direct_customer_name"),
|
|
order_customer.customer_name.label("order_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"),
|
|
)
|
|
.join(finished_lot, finished_lot.id == DeliveryItem.lot_id)
|
|
.join(Item, Item.id == finished_lot.item_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_doc_id.in_(ledger_ids) if ledger_ids else false(),
|
|
finished_lot.source_doc_type.in_(["PRODUCTION_LEDGER_IN", "生产台账"]),
|
|
Item.item_type == "FINISHED_GOOD",
|
|
)
|
|
.order_by(Delivery.delivery_date, DeliveryItem.id)
|
|
).mappings().all()
|
|
for row in delivery_rows:
|
|
delivery_node_id = f"delivery-{row['delivery_item_id']}"
|
|
_append_node(
|
|
nodes,
|
|
node_ids,
|
|
_node(
|
|
node_id=delivery_node_id,
|
|
node_type="delivery",
|
|
title=row["delivery_no"],
|
|
subtitle=row["order_no"],
|
|
status=row["status"],
|
|
stage="销售去向",
|
|
level=6,
|
|
summary="销售出库从生产台账生成的成品库存批次追溯到材料批次。",
|
|
metrics=[
|
|
("发货数量", _fmt_qty(row["delivery_qty"])),
|
|
("发货重量", _fmt_weight(row["delivery_weight_kg"])),
|
|
],
|
|
details=[
|
|
("客户", row["direct_customer_name"] or row["order_customer_name"]),
|
|
("库存批次号", row["lot_no"]),
|
|
("发货时间", _fmt_datetime(row["delivery_date"])),
|
|
],
|
|
raw=dict(row),
|
|
),
|
|
)
|
|
source_node_id = finished_node_id_by_lot_id.get(int(row["delivery_lot_id"] or 0))
|
|
if not source_node_id:
|
|
source_node_id = f"ledger-{row['production_ledger_id']}"
|
|
_append_edge(edges, edge_ids, node_ids, _edge(source_node_id, delivery_node_id, "销售出库"))
|
|
|
|
return nodes, edges
|