完善系统拓展与原材料生命周期视图

This commit is contained in:
汤学会
2026-06-14 20:38:53 +08:00
parent 453b19399b
commit df9a4b485d
14 changed files with 5960 additions and 384 deletions
+20 -9
View File
@@ -43,6 +43,7 @@ from app.schemas.dashboard import (
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)])
@@ -358,7 +359,7 @@ def _material_lot_rows(limit: int = 100):
.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")
.where(Item.item_type == "RAW_MATERIAL", Warehouse.warehouse_type == "RAW")
.order_by(StockLot.created_at.desc(), StockLot.id.desc())
.limit(limit)
)
@@ -387,13 +388,10 @@ def list_dashboard_material_lots(
return [DashboardMaterialLotRead.model_validate(dict(row)) for row in rows]
@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))
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()
@@ -465,7 +463,7 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
.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_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})
@@ -741,6 +739,19 @@ def get_material_lot_lifecycle(lot_id: int, db: Session = Depends(get_db)) -> Ma
)
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)
@@ -0,0 +1,456 @@
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
@@ -0,0 +1,510 @@
from __future__ import annotations
import unittest
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.dashboard import get_material_lot_lifecycle, list_dashboard_material_lots # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item, Warehouse # noqa: E402
from app.models.operations import Delivery, DeliveryItem, OperationReport, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402
from app.models.org import Department, Employee, SystemConfig # noqa: E402
from app.schemas.dashboard import DashboardMaterialLotRead # noqa: E402
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph # noqa: E402
class DashboardMaterialLotLifecycleTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self.now = datetime(2026, 6, 14, 9, 0)
self._seed_lot_and_ledger()
def tearDown(self) -> None:
self.db.close()
def _seed_lot_and_ledger(self) -> None:
raw_item = Item(
id=1,
item_code="RM-001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
product = Item(
id=2,
item_code="FG-001",
item_name="钢制碗",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("0.5"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
lot = StockLot(
id=1,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="期初入库",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("500"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("300"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
ledger = ProductionBatchLedger(
id=1,
material_lot_id=1,
material_lot_no="YL0001",
material_item_id=1,
product_item_id=2,
status="在生产",
miniapp_selectable=1,
total_issued_weight_kg=Decimal("120"),
outside_weight_kg=Decimal("42"),
finished_inbound_qty=Decimal("100"),
surplus_inbound_weight_kg=Decimal("18"),
scrap_inbound_weight_kg=Decimal("6"),
writeoff_weight_kg=Decimal("54"),
first_issue_time=self.now,
last_issue_time=self.now,
created_at=self.now,
updated_at=self.now,
)
dept = Department(
id=1,
dept_code="D-001",
dept_name="生产部",
dept_type="PRODUCTION",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
employee = Employee(
id=1,
employee_code="EMP-001",
employee_name="张三",
dept_id=1,
is_operator=1,
is_workshop_staff=1,
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
txns = [
ProductionBatchLedgerTxn(
id=1,
production_ledger_id=1,
txn_type="生产出库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("120"),
outside_weight_after_kg=Decimal("120"),
source_doc_type="库存流水",
source_doc_id=101,
biz_time=self.now,
operator_user_id=1,
remark="生产出库累计到生产台账",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=2,
production_ledger_id=1,
txn_type="成品入库",
qty_delta=Decimal("100"),
weight_delta_kg=Decimal("-50"),
outside_weight_after_kg=Decimal("70"),
source_doc_type="库存流水",
source_doc_id=102,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号:PB20260614001",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=3,
production_ledger_id=1,
txn_type="生产余料入库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-18"),
outside_weight_after_kg=Decimal("52"),
source_doc_type="库存流水",
source_doc_id=103,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号:PB20260614001",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=4,
production_ledger_id=1,
txn_type="生产废料入库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-6"),
outside_weight_after_kg=Decimal("46"),
source_doc_type="库存流水",
source_doc_id=104,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号:PB20260614001",
created_at=self.now,
updated_at=self.now,
),
ProductionBatchLedgerTxn(
id=5,
production_ledger_id=1,
txn_type="结单核销",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-4"),
outside_weight_after_kg=Decimal("42"),
source_doc_type="结单",
source_doc_id=105,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号:PB20260614001",
created_at=self.now,
updated_at=self.now,
),
]
report = OperationReport(
id=1,
report_no="BG20260614001",
production_ledger_id=1,
employee_id=1,
report_source="MINIAPP",
start_time=self.now,
end_time=self.now,
report_qty=Decimal("100"),
good_qty=Decimal("96"),
scrap_qty=Decimal("4"),
rework_qty=Decimal("0"),
downtime_minutes=Decimal("0"),
extra_cost_amount=Decimal("0"),
is_abnormal=0,
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([raw_item, product, warehouse, lot, ledger, dept, employee, *txns, report])
self.db.commit()
def _lot_read(self) -> DashboardMaterialLotRead:
return DashboardMaterialLotRead(
lot_id=1,
lot_no="YL0001",
material_item_id=1,
material_code="RM-001",
material_name="冷轧钢板",
warehouse_name="原材料库",
inbound_weight_kg=500,
remaining_weight_kg=300,
unit_cost=4,
inbound_amount=2000,
issued_batch_count=0,
issued_weight_kg=0,
finished_batch_count=0,
finished_qty=0,
delivery_count=0,
status="AVAILABLE",
quality_status="PASS",
created_at=self.now,
)
def test_production_ledger_graph_replaces_work_order_main_chain(self) -> None:
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
node_types = {node.type for node in nodes}
self.assertIn("production_ledger", node_types)
self.assertIn("production_issue_txn", node_types)
self.assertIn("ledger_outside_balance", node_types)
self.assertIn("production_ledger_settlement", node_types)
self.assertIn("finished_inbound", node_types)
self.assertIn("surplus_inbound", node_types)
self.assertIn("scrap_inbound", node_types)
self.assertNotIn("work_order", node_types)
def test_production_ledger_title_stage_and_outside_balance_metric(self) -> None:
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
ledger_node = next(node for node in nodes if node.type == "production_ledger")
metrics = {item.label: item.value for item in ledger_node.metrics}
self.assertEqual(ledger_node.title, "YL0001 · 钢制碗")
self.assertEqual(ledger_node.stage, "生产台账")
self.assertEqual(metrics["库外余额"], "42 kg")
def test_material_lot_dashboard_list_excludes_scrap_warehouse_lots(self) -> None:
scrap_warehouse = Warehouse(
id=3,
warehouse_code="WH-SCRAP",
warehouse_name="废料库",
warehouse_type="SCRAP",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
scrap_lot = StockLot(
id=30,
lot_no="SCRAPI-YL0001-001",
lot_role="PRODUCTION_SCRAP",
item_id=1,
warehouse_id=3,
source_doc_type="PRODUCTION_SCRAP_IN",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("5"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("5"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="SCRAP",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([scrap_warehouse, scrap_lot])
self.db.commit()
rows = list_dashboard_material_lots(limit=100, db=self.db)
lot_nos = {row.lot_no for row in rows}
self.assertIn("YL0001", lot_nos)
self.assertNotIn("SCRAPI-YL0001-001", lot_nos)
self.assertTrue(all(row.warehouse_name == "原材料库" for row in rows))
def test_operation_report_respects_smart_operation_report_switch(self) -> None:
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
self.assertIn("operation_report", {node.type for node in nodes})
self.db.add(
SystemConfig(
config_code="SMART_OPERATION_REPORT_ENABLED",
config_name="对接智能报工小程序",
config_value="关闭",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
)
self.db.commit()
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
self.assertNotIn("operation_report", {node.type for node in nodes})
def test_route_returns_new_graph_when_production_ledger_exists(self) -> None:
response = get_material_lot_lifecycle(1, self.db)
node_types = {node.type for node in response.nodes}
self.assertIn("production_ledger", node_types)
self.assertNotIn("work_order", node_types)
def test_route_legacy_fallback_does_not_crash_without_production_ledger(self) -> None:
legacy_lot = StockLot(
id=2,
lot_no="YL9999",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="期初入库",
source_doc_id=2,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("80"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("80"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.db.add(legacy_lot)
self.db.commit()
response = get_material_lot_lifecycle(2, self.db)
self.assertEqual(response.lot.lot_no, "YL9999")
node_types = {node.type for node in response.nodes}
self.assertIn("raw_material_lot", node_types)
self.assertNotIn("production_ledger", node_types)
def test_delivery_edges_target_finished_inbound_for_exact_finished_lot(self) -> None:
finished_lot_a = StockLot(
id=10,
lot_no="FGL-A",
lot_role="INVENTORY",
item_id=2,
warehouse_id=1,
source_doc_type="PRODUCTION_LEDGER_IN",
source_doc_id=1,
inbound_qty=Decimal("40"),
inbound_weight_kg=Decimal("20"),
remaining_qty=Decimal("40"),
remaining_weight_kg=Decimal("20"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("8"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
finished_lot_b = StockLot(
id=11,
lot_no="FGL-B",
lot_role="INVENTORY",
item_id=2,
warehouse_id=1,
source_doc_type="PRODUCTION_LEDGER_IN",
source_doc_id=1,
inbound_qty=Decimal("60"),
inbound_weight_kg=Decimal("30"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("0"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("8"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
finished_txn_a = ProductionBatchLedgerTxn(
id=20,
production_ledger_id=1,
txn_type="成品入库",
qty_delta=Decimal("40"),
weight_delta_kg=Decimal("-20"),
outside_weight_after_kg=Decimal("70"),
source_doc_type="库存流水",
source_doc_id=201,
source_line_id=10,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号:PB-FGL-A",
created_at=self.now,
updated_at=self.now,
)
finished_txn_b = ProductionBatchLedgerTxn(
id=21,
production_ledger_id=1,
txn_type="成品入库",
qty_delta=Decimal("60"),
weight_delta_kg=Decimal("-30"),
outside_weight_after_kg=Decimal("42"),
source_doc_type="库存流水",
source_doc_id=202,
source_line_id=11,
biz_time=self.now,
operator_user_id=1,
remark="单据批次号:PB-FGL-B",
created_at=self.now,
updated_at=self.now,
)
delivery = Delivery(
id=1,
delivery_no="FH20260614001",
warehouse_id=1,
delivery_date=self.now,
status="POSTED",
created_at=self.now,
updated_at=self.now,
)
delivery_item = DeliveryItem(
id=1,
delivery_id=1,
line_no=1,
product_item_id=2,
lot_id=11,
delivery_qty=Decimal("10"),
delivery_weight_kg=Decimal("5"),
unit_price=Decimal("12"),
line_amount=Decimal("120"),
status="POSTED",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([finished_lot_a, finished_lot_b, finished_txn_a, finished_txn_b, delivery, delivery_item])
self.db.commit()
nodes, edges = build_material_lot_lifecycle_graph(self.db, self._lot_read())
delivery_node = next(node for node in nodes if node.type == "delivery")
finished_a = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-A")
finished_b = next(node for node in nodes if node.type == "finished_inbound" and node.subtitle == "PB-FGL-B")
delivery_source_ids = {edge.source for edge in edges if edge.target == delivery_node.id}
self.assertIn(finished_b.id, delivery_source_ids)
self.assertNotIn(finished_a.id, delivery_source_ids)
self.assertNotIn("ledger-1", delivery_source_ids)
def test_reopen_txn_uses_reopen_node_type_not_writeoff(self) -> None:
self.db.add(
ProductionBatchLedgerTxn(
id=30,
production_ledger_id=1,
txn_type="重新开工",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("0"),
outside_weight_after_kg=Decimal("42"),
source_doc_type="生产台账",
source_doc_id=1,
biz_time=self.now,
operator_user_id=1,
remark="重新开工",
created_at=self.now,
updated_at=self.now,
)
)
self.db.commit()
nodes, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
reopen_node = next(node for node in nodes if node.title == "重新开工")
self.assertEqual(reopen_node.type, "ledger_reopen")
self.assertEqual(reopen_node.stage, "重新开工")
if __name__ == "__main__":
unittest.main()