1750 lines
60 KiB
Markdown
1750 lines
60 KiB
Markdown
# Material Batch Lifecycle DAG Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 把经营总览里的“库存批次号生命周期 DAG”从旧的生产工单主链路,改造成以“材料库存批次号 + 产品”的生产台账闭环为主链路的材料批次 DAG。
|
||
|
||
**Architecture:** 后端保持现有 `/dashboard/material-lots/{lot_id}/lifecycle` 响应结构不变,新增独立的材料批次 DAG 构建服务,优先读取 `ProductionBatchLedger` 与 `ProductionBatchLedgerTxn`。前端继续复用现有 DAG 抽屉、画布、节点详情和边高亮能力,只替换节点类型、阶段文案、布局分组和视觉图例。智能报工小程序只作为可选证据层,受现有 `SMART_OPERATION_REPORT_ENABLED` 开关控制,不再参与闭环判断。
|
||
|
||
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, Vue 3, Vite, Node test runner, Python unittest/pytest-compatible tests.
|
||
|
||
---
|
||
|
||
## Business DAG Model
|
||
|
||
新 DAG 的主链路必须表达下面这个业务事实:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
A["原材料库存批次号"] --> B["生产台账:库存批次号 + 产品"]
|
||
B --> C["生产出库流水"]
|
||
B --> D["库外材料余额"]
|
||
B --> E["生产台账入库/分支入库"]
|
||
E --> F["成品入库"]
|
||
E --> G["生产余料入库"]
|
||
E --> H["生产废料入库"]
|
||
E --> I["差异/结单核销"]
|
||
F --> J["销售出库"]
|
||
J --> K["退货/售后"]
|
||
```
|
||
|
||
报工小程序开启时,在生产台账下面增加“工序报工证据”节点:
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
B["生产台账:库存批次号 + 产品"] --> R["小程序报工证据"]
|
||
R --> R1["工序/员工/合格数"]
|
||
R --> R2["报废数/异常原因"]
|
||
```
|
||
|
||
报工小程序关闭时,不展示任何 `operation_report` / `work_order_operation` 节点,也不把报工数量作为 DAG 闭环条件。
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
- Create: `backend/app/services/material_lifecycle_dag.py`
|
||
- 专职构建材料库存批次 DAG。
|
||
- 输入:`Session` 与 `DashboardMaterialLotRead`。
|
||
- 输出:`tuple[list[LifecycleNode], list[LifecycleEdge]]`。
|
||
- 只负责读数据和组装节点/边,不提交事务,不修改库存。
|
||
|
||
- Modify: `backend/app/api/routes/dashboard.py`
|
||
- 保留销售订单生命周期接口。
|
||
- 原材料批次生命周期接口改为调用 `build_material_lot_lifecycle_graph()`。
|
||
- 保留旧工单图谱作为无生产台账历史数据的兼容降级路径。
|
||
|
||
- Create: `backend/tests/test_dashboard_material_lot_lifecycle.py`
|
||
- 覆盖新生产台账主链路。
|
||
- 覆盖智能报工开关开启/关闭。
|
||
- 覆盖旧工单数据降级展示。
|
||
|
||
- Modify: `frontend/src/views/DashboardView.vue`
|
||
- 标题从“原材料生命周期 DAG”调整为“材料库存批次闭环图”。
|
||
- 阶段从旧 `生产工单/工序执行/工人报工` 改为 `原料入库/生产台账/生产出库/库外闭环/入库结果/销售去向`。
|
||
- 新增图例和新节点类型布局。
|
||
|
||
- Modify: `frontend/src/styles/main.css`
|
||
- 增加新 DAG 节点 tone:`ledger`、`issue`、`balance`、`settlement`、`inbound`、`writeoff`、`evidence`。
|
||
- 优化 DAG 图例、节点状态、长文本显示和连线层级。
|
||
|
||
- Create: `frontend/src/views/DashboardView.test.js`
|
||
- 使用现有 Node 源码断言测试风格,确认关键文案、节点类型、布局分类存在。
|
||
|
||
- No schema migration:
|
||
- 现有 `pp_production_batch_ledger`、`pp_production_batch_ledger_txn`、`pp_operation_report.production_ledger_id` 已满足本次 DAG 改造。
|
||
|
||
---
|
||
|
||
### Task 1: Backend Failing Tests For Production Ledger DAG
|
||
|
||
**Files:**
|
||
- Create: `backend/tests/test_dashboard_material_lot_lifecycle.py`
|
||
- Read: `backend/tests/test_production_batch_ledger.py`
|
||
- Read: `backend/tests/test_selected_stock_lot_production_issue.py`
|
||
|
||
- [ ] **Step 1: Create SQLite test fixture and seed helpers**
|
||
|
||
Create `backend/tests/test_dashboard_material_lot_lifecycle.py` with this structure:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import BigInteger, create_engine, select
|
||
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.models.base import Base # noqa: E402
|
||
from app.models.master_data import Item, Warehouse # noqa: E402
|
||
from app.models.operations import ( # noqa: E402
|
||
CompletionReceipt,
|
||
CompletionReceiptItem,
|
||
Delivery,
|
||
DeliveryItem,
|
||
InventoryTxn,
|
||
OperationReport,
|
||
ProductionBatchLedger,
|
||
ProductionBatchLedgerTxn,
|
||
StockLot,
|
||
WorkOrder,
|
||
WorkOrderMaterialIssue,
|
||
WorkOrderOperation,
|
||
)
|
||
from app.models.org import 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 MaterialLotLifecycleDagTest(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, 30, 0)
|
||
self._seed_common_master_data()
|
||
|
||
def tearDown(self) -> None:
|
||
self.db.close()
|
||
|
||
def _seed_common_master_data(self) -> None:
|
||
self.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,
|
||
)
|
||
self.product = Item(
|
||
id=2,
|
||
item_code="FG-001",
|
||
item_name="钢制碗",
|
||
item_type="FINISHED_GOOD",
|
||
unit_weight_kg=Decimal("0.45"),
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.raw_warehouse = Warehouse(
|
||
id=1,
|
||
warehouse_code="WH-RAW",
|
||
warehouse_name="原材料库",
|
||
warehouse_type="RAW",
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.finished_warehouse = Warehouse(
|
||
id=2,
|
||
warehouse_code="WH-FG",
|
||
warehouse_name="成品库",
|
||
warehouse_type="FINISHED",
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.raw_lot = StockLot(
|
||
id=1,
|
||
lot_no="YL0001",
|
||
lot_role="INBOUND_RAW",
|
||
item_id=self.raw_item.id,
|
||
warehouse_id=self.raw_warehouse.id,
|
||
source_doc_type="PURCHASE_RECEIPT",
|
||
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.2"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.db.add_all([self.raw_item, self.product, self.raw_warehouse, self.finished_warehouse, self.raw_lot])
|
||
self.db.flush()
|
||
|
||
def _lot_read(self) -> DashboardMaterialLotRead:
|
||
return DashboardMaterialLotRead(
|
||
lot_id=self.raw_lot.id,
|
||
lot_no=self.raw_lot.lot_no,
|
||
material_item_id=self.raw_item.id,
|
||
material_code=self.raw_item.item_code,
|
||
material_name=self.raw_item.item_name,
|
||
specification=None,
|
||
warehouse_name=self.raw_warehouse.warehouse_name,
|
||
inbound_weight_kg=500,
|
||
remaining_weight_kg=300,
|
||
unit_cost=4.2,
|
||
inbound_amount=2100,
|
||
issued_batch_count=2,
|
||
issued_weight_kg=200,
|
||
finished_batch_count=1,
|
||
finished_qty=120,
|
||
delivery_count=1,
|
||
status="AVAILABLE",
|
||
quality_status="PASS",
|
||
created_at=self.now,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Add test for production ledger as the main chain**
|
||
|
||
Append this test to `MaterialLotLifecycleDagTest`:
|
||
|
||
```python
|
||
def test_material_lifecycle_uses_production_ledger_as_main_chain(self) -> None:
|
||
ledger = ProductionBatchLedger(
|
||
id=100,
|
||
material_lot_id=self.raw_lot.id,
|
||
material_lot_no=self.raw_lot.lot_no,
|
||
material_item_id=self.raw_item.id,
|
||
product_item_id=self.product.id,
|
||
status="在生产",
|
||
miniapp_selectable=1,
|
||
total_issued_weight_kg=Decimal("200"),
|
||
outside_weight_kg=Decimal("42"),
|
||
finished_inbound_qty=Decimal("120"),
|
||
surplus_inbound_weight_kg=Decimal("12"),
|
||
scrap_inbound_weight_kg=Decimal("18"),
|
||
writeoff_weight_kg=Decimal("0"),
|
||
first_issue_time=self.now,
|
||
last_issue_time=self.now,
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.db.add(ledger)
|
||
self.db.flush()
|
||
self.db.add_all(
|
||
[
|
||
ProductionBatchLedgerTxn(
|
||
id=1001,
|
||
production_ledger_id=ledger.id,
|
||
txn_type="生产出库",
|
||
qty_delta=Decimal("0"),
|
||
weight_delta_kg=Decimal("200"),
|
||
outside_weight_after_kg=Decimal("200"),
|
||
source_doc_type="库存流水",
|
||
source_doc_id=501,
|
||
source_line_id=self.raw_lot.id,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="生产出库",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
),
|
||
ProductionBatchLedgerTxn(
|
||
id=1002,
|
||
production_ledger_id=ledger.id,
|
||
txn_type="成品入库",
|
||
qty_delta=Decimal("120"),
|
||
weight_delta_kg=Decimal("-72"),
|
||
outside_weight_after_kg=Decimal("128"),
|
||
source_doc_type="库存流水",
|
||
source_doc_id=601,
|
||
source_line_id=701,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="生产台账入库;成品入库联动生产台账;单据批次号:PIN202606140001",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
),
|
||
ProductionBatchLedgerTxn(
|
||
id=1003,
|
||
production_ledger_id=ledger.id,
|
||
txn_type="生产余料入库",
|
||
qty_delta=Decimal("0"),
|
||
weight_delta_kg=Decimal("-12"),
|
||
outside_weight_after_kg=Decimal("116"),
|
||
source_doc_type="库存流水",
|
||
source_doc_id=602,
|
||
source_line_id=self.raw_lot.id,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="生产台账入库;生产余料入库联动生产台账;单据批次号:PIN202606140001",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
),
|
||
ProductionBatchLedgerTxn(
|
||
id=1004,
|
||
production_ledger_id=ledger.id,
|
||
txn_type="生产废料入库",
|
||
qty_delta=Decimal("0"),
|
||
weight_delta_kg=Decimal("-18"),
|
||
outside_weight_after_kg=Decimal("98"),
|
||
source_doc_type="库存流水",
|
||
source_doc_id=603,
|
||
source_line_id=801,
|
||
biz_time=self.now,
|
||
operator_user_id=1,
|
||
remark="生产台账入库;生产废料入库联动生产台账;单据批次号:PIN202606140001",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
),
|
||
]
|
||
)
|
||
finished_lot = StockLot(
|
||
id=701,
|
||
lot_no="FGL202606140001",
|
||
lot_role="FINISHED_FROM_RAW_BATCH",
|
||
parent_lot_id=self.raw_lot.id,
|
||
item_id=self.product.id,
|
||
warehouse_id=self.finished_warehouse.id,
|
||
source_doc_type="PRODUCTION_LEDGER_IN",
|
||
source_doc_id=ledger.id,
|
||
source_line_id=None,
|
||
source_material_lot_id=self.raw_lot.id,
|
||
source_material_sub_batch_no=self.raw_lot.lot_no,
|
||
source_material_summary=f"{self.raw_item.item_name}:{self.raw_lot.lot_no}",
|
||
inbound_qty=Decimal("120"),
|
||
inbound_weight_kg=Decimal("54"),
|
||
remaining_qty=Decimal("70"),
|
||
remaining_weight_kg=Decimal("31.5"),
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=Decimal("2.52"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.db.add(finished_lot)
|
||
self.db.flush()
|
||
|
||
nodes, edges = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||
node_types = {node.type for node in nodes}
|
||
edge_labels = {edge.label for edge in edges}
|
||
|
||
self.assertIn("raw_material_lot", node_types)
|
||
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)
|
||
self.assertIn("进入生产台账", edge_labels)
|
||
self.assertIn("入库闭环", edge_labels)
|
||
|
||
ledger_node = next(node for node in nodes if node.type == "production_ledger")
|
||
self.assertEqual(ledger_node.title, "YL0001 · 钢制碗")
|
||
self.assertEqual(ledger_node.stage, "生产台账")
|
||
self.assertIn(("库外余额", "42 kg"), [(item.label, item.value) for item in ledger_node.metrics])
|
||
```
|
||
|
||
- [ ] **Step 3: Add test for miniapp evidence switch**
|
||
|
||
Append this test:
|
||
|
||
```python
|
||
def test_material_lifecycle_hides_miniapp_evidence_when_switch_closed(self) -> None:
|
||
ledger = ProductionBatchLedger(
|
||
id=101,
|
||
material_lot_id=self.raw_lot.id,
|
||
material_lot_no=self.raw_lot.lot_no,
|
||
material_item_id=self.raw_item.id,
|
||
product_item_id=self.product.id,
|
||
status="在生产",
|
||
miniapp_selectable=1,
|
||
total_issued_weight_kg=Decimal("80"),
|
||
outside_weight_kg=Decimal("80"),
|
||
finished_inbound_qty=Decimal("0"),
|
||
surplus_inbound_weight_kg=Decimal("0"),
|
||
scrap_inbound_weight_kg=Decimal("0"),
|
||
writeoff_weight_kg=Decimal("0"),
|
||
first_issue_time=self.now,
|
||
last_issue_time=self.now,
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
employee = Employee(
|
||
id=1,
|
||
employee_code="EMP001",
|
||
employee_name="张三",
|
||
dept_id=1,
|
||
is_operator=1,
|
||
is_workshop_staff=1,
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
report = OperationReport(
|
||
id=1,
|
||
report_no="MOP-001",
|
||
work_order_id=None,
|
||
work_order_operation_id=None,
|
||
production_ledger_id=ledger.id,
|
||
employee_id=employee.id,
|
||
equipment_id=None,
|
||
report_source="MINIAPP",
|
||
shift_code=None,
|
||
start_time=self.now,
|
||
end_time=self.now,
|
||
report_qty=Decimal("30"),
|
||
good_qty=Decimal("28"),
|
||
scrap_qty=Decimal("2"),
|
||
rework_qty=Decimal("0"),
|
||
scrap_reason="调机废品",
|
||
downtime_minutes=Decimal("0"),
|
||
extra_cost_amount=Decimal("0"),
|
||
is_abnormal=1,
|
||
approved_by=None,
|
||
approved_at=None,
|
||
remark="小程序报工同步",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.db.add_all([ledger, employee, report])
|
||
self.db.flush()
|
||
|
||
nodes_open, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||
self.assertIn("operation_report", {node.type for node in nodes_open})
|
||
|
||
self.db.add(
|
||
SystemConfig(
|
||
id=1,
|
||
config_code="SMART_OPERATION_REPORT_ENABLED",
|
||
config_name="对接智能报工小程序",
|
||
config_value="关闭",
|
||
status="ACTIVE",
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
)
|
||
self.db.flush()
|
||
|
||
nodes_closed, _ = build_material_lot_lifecycle_graph(self.db, self._lot_read())
|
||
self.assertNotIn("operation_report", {node.type for node in nodes_closed})
|
||
```
|
||
|
||
- [ ] **Step 4: Run the new test and verify it fails for missing service**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend
|
||
python -m pytest tests/test_dashboard_material_lot_lifecycle.py -q
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
ModuleNotFoundError: No module named 'app.services.material_lifecycle_dag'
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Backend DAG Builder Service
|
||
|
||
**Files:**
|
||
- Create: `backend/app/services/material_lifecycle_dag.py`
|
||
- Read: `backend/app/api/routes/dashboard.py`
|
||
- Read: `backend/app/api/routes/production.py:1349-1368`
|
||
- Read: `backend/app/services/document_archives.py:168-179`
|
||
|
||
- [ ] **Step 1: Create service with formatter helpers and add-node/add-edge utilities**
|
||
|
||
Create `backend/app/services/material_lifecycle_dag.py`:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from collections import defaultdict
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from sqlalchemy import false, func, or_, select
|
||
from sqlalchemy.orm import Session, aliased
|
||
|
||
from app.models.master_data import Item, Warehouse
|
||
from app.models.operations import (
|
||
CompletionReceipt,
|
||
CompletionReceiptItem,
|
||
Delivery,
|
||
DeliveryItem,
|
||
OperationReport,
|
||
ProductionBatchLedger,
|
||
ProductionBatchLedgerTxn,
|
||
StockLot,
|
||
WorkOrder,
|
||
WorkOrderMaterialIssue,
|
||
WorkOrderOperation,
|
||
)
|
||
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
|
||
|
||
|
||
PRODUCTION_LEDGER_INBOUND_TXN_TYPES = {"成品入库", "生产余料入库", "生产废料入库"}
|
||
PRODUCTION_LEDGER_CLOSING_TXN_TYPES = {"结单核销", "该批材料结单", "重新开工"}
|
||
|
||
|
||
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_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)
|
||
```
|
||
|
||
- [ ] **Step 2: Add graph accumulator**
|
||
|
||
Append:
|
||
|
||
```python
|
||
class _LifecycleGraph:
|
||
def __init__(self) -> None:
|
||
self.nodes: list[LifecycleNode] = []
|
||
self.edges: list[LifecycleEdge] = []
|
||
self.node_ids: set[str] = set()
|
||
self.edge_ids: set[str] = set()
|
||
|
||
def add_node(self, node: LifecycleNode) -> None:
|
||
if node.id not in self.node_ids:
|
||
self.node_ids.add(node.id)
|
||
self.nodes.append(node)
|
||
|
||
def add_edge(self, edge: LifecycleEdge) -> None:
|
||
if edge.source in self.node_ids and edge.target in self.node_ids and edge.id not in self.edge_ids:
|
||
self.edge_ids.add(edge.id)
|
||
self.edges.append(edge)
|
||
```
|
||
|
||
- [ ] **Step 3: Add raw lot node builder**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def _add_raw_lot_node(graph: _LifecycleGraph, lot: DashboardMaterialLotRead) -> str:
|
||
node_id = f"raw-lot-{lot.lot_id}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=node_id,
|
||
node_type="raw_material_lot",
|
||
title=lot.lot_no,
|
||
subtitle=f"{lot.material_code} · {lot.material_name}",
|
||
status=lot.status,
|
||
tone="success" if lot.status in {"AVAILABLE", "PASS", "PASSED"} else "normal",
|
||
stage="原料入库",
|
||
level=0,
|
||
summary="材料库存批次闭环的起点。这里记录该批材料的入库成本、剩余库存、质检状态,以及后续生产台账和销售去向。",
|
||
metrics=[
|
||
("入库重量", _fmt_weight(lot.inbound_weight_kg)),
|
||
("剩余重量", _fmt_weight(lot.remaining_weight_kg)),
|
||
("入库金额", _fmt_money(lot.inbound_amount)),
|
||
],
|
||
details=[
|
||
("库存批次号", lot.lot_no),
|
||
("原材料编码", lot.material_code),
|
||
("原材料名称", lot.material_name),
|
||
("仓库", lot.warehouse_name),
|
||
("入库单价", f"{_fmt_money(lot.unit_cost)}/kg"),
|
||
("已领用重量", _fmt_weight(lot.issued_weight_kg)),
|
||
],
|
||
raw=lot.model_dump(mode="json"),
|
||
)
|
||
)
|
||
return node_id
|
||
```
|
||
|
||
- [ ] **Step 4: Add production ledger query helper**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def _production_ledger_rows(db: Session, lot_id: int):
|
||
material_item = aliased(Item)
|
||
product_item = aliased(Item)
|
||
return db.execute(
|
||
select(
|
||
ProductionBatchLedger,
|
||
material_item.item_code.label("material_code"),
|
||
material_item.item_name.label("material_name"),
|
||
product_item.item_code.label("product_code"),
|
||
product_item.item_name.label("product_name"),
|
||
)
|
||
.join(material_item, material_item.id == ProductionBatchLedger.material_item_id, isouter=True)
|
||
.join(product_item, product_item.id == ProductionBatchLedger.product_item_id, isouter=True)
|
||
.where(ProductionBatchLedger.material_lot_id == lot_id)
|
||
.order_by(ProductionBatchLedger.first_issue_time.asc(), ProductionBatchLedger.id.asc())
|
||
).all()
|
||
```
|
||
|
||
- [ ] **Step 5: Add production ledger node and balance node builders**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def _ledger_tone(ledger: ProductionBatchLedger) -> str:
|
||
status = str(ledger.status or "")
|
||
outside = _num(ledger.outside_weight_kg)
|
||
if status == "锁单":
|
||
return "success" if abs(outside) < 0.000001 else "warning"
|
||
if status == "在生产" and outside > 0:
|
||
return "warning"
|
||
return "normal"
|
||
|
||
|
||
def _ledger_visual_status(ledger: ProductionBatchLedger) -> str:
|
||
status = str(ledger.status or "在生产")
|
||
if status == "在生产" and _num(ledger.total_issued_weight_kg) > 0 and _num(ledger.outside_weight_kg) > 0:
|
||
return "部分闭环" if (
|
||
_num(ledger.finished_inbound_qty) > 0
|
||
or _num(ledger.surplus_inbound_weight_kg) > 0
|
||
or _num(ledger.scrap_inbound_weight_kg) > 0
|
||
) else "在生产"
|
||
return status
|
||
|
||
|
||
def _add_ledger_node(graph: _LifecycleGraph, raw_node_id: str, row: Any) -> str:
|
||
ledger: ProductionBatchLedger = row.ProductionBatchLedger
|
||
product_name = row.product_name or f"产品#{ledger.product_item_id}"
|
||
product_code = row.product_code or "-"
|
||
node_id = f"production-ledger-{ledger.id}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=node_id,
|
||
node_type="production_ledger",
|
||
title=f"{ledger.material_lot_no} · {product_name}",
|
||
subtitle=f"生产台账 #{ledger.id}",
|
||
status=_ledger_visual_status(ledger),
|
||
tone=_ledger_tone(ledger),
|
||
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=[
|
||
("材料库存批次号", ledger.material_lot_no),
|
||
("产品编码", product_code),
|
||
("产品名称", product_name),
|
||
("小程序可选", "是" if int(ledger.miniapp_selectable or 0) == 1 else "否"),
|
||
("首次出库", _fmt_datetime(ledger.first_issue_time)),
|
||
("最近出库", _fmt_datetime(ledger.last_issue_time)),
|
||
("锁单时间", _fmt_datetime(ledger.locked_at)),
|
||
("备注", ledger.remark),
|
||
],
|
||
raw={
|
||
"production_ledger_id": ledger.id,
|
||
"material_lot_id": ledger.material_lot_id,
|
||
"material_lot_no": ledger.material_lot_no,
|
||
"product_item_id": ledger.product_item_id,
|
||
"product_name": product_name,
|
||
},
|
||
)
|
||
)
|
||
graph.add_edge(_edge(raw_node_id, node_id, "进入生产台账"))
|
||
return node_id
|
||
|
||
|
||
def _add_outside_balance_node(graph: _LifecycleGraph, ledger: ProductionBatchLedger, ledger_node_id: str) -> str:
|
||
node_id = f"outside-balance-{ledger.id}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=node_id,
|
||
node_type="ledger_outside_balance",
|
||
title="库外材料余额",
|
||
subtitle=ledger.material_lot_no,
|
||
status=_ledger_visual_status(ledger),
|
||
tone=_ledger_tone(ledger),
|
||
stage="库外闭环",
|
||
level=3,
|
||
summary="库外余额 = 累计生产出库 - 成品耗用折算 - 余料入库 - 废料入库 - 结单核销。它表示现场仍未闭环的这批材料重量。",
|
||
metrics=[
|
||
("库外余额", _fmt_weight(ledger.outside_weight_kg)),
|
||
("结单核销", _fmt_weight(ledger.writeoff_weight_kg)),
|
||
],
|
||
details=[
|
||
("状态", _ledger_visual_status(ledger)),
|
||
("锁单次数", int(ledger.lock_count or 0)),
|
||
("重新开工时间", _fmt_datetime(ledger.reopened_at)),
|
||
],
|
||
raw={"production_ledger_id": ledger.id},
|
||
)
|
||
)
|
||
graph.add_edge(_edge(ledger_node_id, node_id, "库外余额"))
|
||
return node_id
|
||
```
|
||
|
||
- [ ] **Step 6: Add transaction grouping and transaction node builders**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def _ledger_txns_by_ledger_id(db: Session, ledger_ids: list[int]) -> dict[int, list[ProductionBatchLedgerTxn]]:
|
||
if not ledger_ids:
|
||
return {}
|
||
rows = db.scalars(
|
||
select(ProductionBatchLedgerTxn)
|
||
.where(ProductionBatchLedgerTxn.production_ledger_id.in_(ledger_ids))
|
||
.order_by(ProductionBatchLedgerTxn.biz_time.asc(), ProductionBatchLedgerTxn.id.asc())
|
||
).all()
|
||
grouped: dict[int, list[ProductionBatchLedgerTxn]] = defaultdict(list)
|
||
for txn in rows:
|
||
grouped[int(txn.production_ledger_id)].append(txn)
|
||
return grouped
|
||
|
||
|
||
def _settlement_key(txn: ProductionBatchLedgerTxn) -> str:
|
||
batch_no = production_document_batch_no_from_text(txn.remark)
|
||
if batch_no:
|
||
return f"batch:{batch_no}"
|
||
return f"txn:{txn.id}"
|
||
|
||
|
||
def _add_issue_txn_nodes(
|
||
graph: _LifecycleGraph,
|
||
ledger_node_id: str,
|
||
txns: list[ProductionBatchLedgerTxn],
|
||
) -> list[str]:
|
||
issue_node_ids: list[str] = []
|
||
for txn in txns:
|
||
if txn.txn_type != "生产出库":
|
||
continue
|
||
node_id = f"production-issue-txn-{txn.id}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=node_id,
|
||
node_type="production_issue_txn",
|
||
title=f"生产出库 #{txn.source_doc_id or txn.id}",
|
||
subtitle=txn.remark or "生产出库",
|
||
status="已出库",
|
||
tone="warning",
|
||
stage="生产出库",
|
||
level=2,
|
||
summary="本节点表示一次从该材料库存批次领料出库到生产现场。同一材料批次和同一产品的多次出库会汇总到同一生产台账。",
|
||
metrics=[
|
||
("出库重量", _fmt_weight(abs(_num(txn.weight_delta_kg)))),
|
||
("出库后库外余额", _fmt_weight(txn.outside_weight_after_kg)),
|
||
],
|
||
details=[
|
||
("业务时间", _fmt_datetime(txn.biz_time)),
|
||
("来源单据", f"{txn.source_doc_type or '-'} #{txn.source_doc_id or '-'}"),
|
||
("说明", txn.remark),
|
||
],
|
||
raw={"production_ledger_txn_id": txn.id, "production_ledger_id": txn.production_ledger_id},
|
||
)
|
||
)
|
||
graph.add_edge(_edge(ledger_node_id, node_id, "生产出库"))
|
||
issue_node_ids.append(node_id)
|
||
return issue_node_ids
|
||
```
|
||
|
||
- [ ] **Step 7: Add inbound settlement nodes**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def _add_inbound_settlement_nodes(
|
||
graph: _LifecycleGraph,
|
||
ledger_node_id: str,
|
||
balance_node_id: str,
|
||
txns: list[ProductionBatchLedgerTxn],
|
||
) -> None:
|
||
grouped: dict[str, list[ProductionBatchLedgerTxn]] = defaultdict(list)
|
||
for txn in txns:
|
||
if txn.txn_type in PRODUCTION_LEDGER_INBOUND_TXN_TYPES or txn.txn_type in PRODUCTION_LEDGER_CLOSING_TXN_TYPES:
|
||
grouped[_settlement_key(txn)].append(txn)
|
||
|
||
txn_order = {"成品入库": 1, "生产余料入库": 2, "生产废料入库": 3, "结单核销": 4, "该批材料结单": 5, "重新开工": 6}
|
||
for group_index, group_txns in enumerate(grouped.values(), start=1):
|
||
ordered = sorted(group_txns, key=lambda item: (txn_order.get(str(item.txn_type or ""), 99), item.id))
|
||
batch_no = production_document_batch_no_from_text(ordered[0].remark)
|
||
settlement_id = f"ledger-settlement-{ordered[0].production_ledger_id}-{batch_no or ordered[0].id}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=settlement_id,
|
||
node_type="production_ledger_settlement",
|
||
title=f"入库闭环 #{batch_no or group_index}",
|
||
subtitle="生产台账入库" if batch_no else "分支入库/结单事件",
|
||
status="已记录",
|
||
tone="success",
|
||
stage="入库结算",
|
||
level=4,
|
||
summary="一次生产台账入库可能同时生成成品、余料、废料和结单核销;单独分支入库也会在这里形成闭环事件。",
|
||
metrics=[
|
||
("成品数量", _fmt_qty(sum(_num(txn.qty_delta) for txn in ordered if txn.txn_type == "成品入库"))),
|
||
("余料重量", _fmt_weight(sum(abs(_num(txn.weight_delta_kg)) for txn in ordered if txn.txn_type == "生产余料入库"))),
|
||
("废料重量", _fmt_weight(sum(abs(_num(txn.weight_delta_kg)) for txn in ordered if txn.txn_type == "生产废料入库"))),
|
||
],
|
||
details=[
|
||
("业务时间", _fmt_datetime(ordered[0].biz_time)),
|
||
("单据批次", batch_no or "-"),
|
||
("说明", ";".join(str(txn.remark or "") for txn in ordered if txn.remark) or "-"),
|
||
],
|
||
raw={"production_ledger_id": ordered[0].production_ledger_id, "txn_ids": [txn.id for txn in ordered]},
|
||
)
|
||
)
|
||
graph.add_edge(_edge(ledger_node_id, settlement_id, "入库闭环"))
|
||
graph.add_edge(_edge(balance_node_id, settlement_id, "冲减库外余额"))
|
||
|
||
for txn in ordered:
|
||
if txn.txn_type == "成品入库":
|
||
node_type = "finished_inbound"
|
||
title = f"成品入库 #{txn.source_doc_id or txn.id}"
|
||
stage = "成品入库"
|
||
metric = ("入库数量", _fmt_qty(txn.qty_delta))
|
||
elif txn.txn_type == "生产余料入库":
|
||
node_type = "surplus_inbound"
|
||
title = f"余料入库 #{txn.source_doc_id or txn.id}"
|
||
stage = "余料入库"
|
||
metric = ("入库重量", _fmt_weight(abs(_num(txn.weight_delta_kg))))
|
||
elif txn.txn_type == "生产废料入库":
|
||
node_type = "scrap_inbound"
|
||
title = f"废料入库 #{txn.source_doc_id or txn.id}"
|
||
stage = "废料入库"
|
||
metric = ("入库重量", _fmt_weight(abs(_num(txn.weight_delta_kg))))
|
||
elif txn.txn_type in {"结单核销", "该批材料结单"}:
|
||
node_type = "ledger_writeoff"
|
||
title = txn.txn_type
|
||
stage = "结单核销"
|
||
metric = ("核销重量", _fmt_weight(abs(_num(txn.weight_delta_kg))))
|
||
elif txn.txn_type == "重新开工":
|
||
node_type = "ledger_reopen"
|
||
title = "重新开工"
|
||
stage = "重新开工"
|
||
metric = ("新增库外重量", _fmt_weight(abs(_num(txn.weight_delta_kg))))
|
||
else:
|
||
continue
|
||
|
||
child_id = f"{node_type}-{txn.id}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=child_id,
|
||
node_type=node_type,
|
||
title=title,
|
||
subtitle=txn.remark or txn.txn_type,
|
||
status="已记录",
|
||
tone="success" if node_type != "ledger_writeoff" else "warning",
|
||
stage=stage,
|
||
level=5,
|
||
summary="该节点来自生产台账交易流水,表示该批材料的一项入库或结单结果。",
|
||
metrics=[
|
||
metric,
|
||
("处理后库外余额", _fmt_weight(txn.outside_weight_after_kg)),
|
||
],
|
||
details=[
|
||
("业务时间", _fmt_datetime(txn.biz_time)),
|
||
("来源单据", f"{txn.source_doc_type or '-'} #{txn.source_doc_id or '-'}"),
|
||
("说明", txn.remark),
|
||
],
|
||
raw={"production_ledger_txn_id": txn.id, "production_ledger_id": txn.production_ledger_id},
|
||
)
|
||
)
|
||
graph.add_edge(_edge(settlement_id, child_id, txn.txn_type))
|
||
```
|
||
|
||
- [ ] **Step 8: Add optional miniapp report evidence nodes**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def _add_operation_report_evidence_nodes(
|
||
db: Session,
|
||
graph: _LifecycleGraph,
|
||
ledger_node_by_id: dict[int, str],
|
||
) -> None:
|
||
ledger_ids = sorted(ledger_node_by_id)
|
||
if not ledger_ids or not get_smart_operation_report_enabled(db):
|
||
return
|
||
|
||
rows = db.execute(
|
||
select(
|
||
OperationReport.id.label("report_id"),
|
||
OperationReport.report_no.label("report_no"),
|
||
OperationReport.production_ledger_id.label("production_ledger_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.production_ledger_id.in_(ledger_ids))
|
||
.order_by(OperationReport.start_time.asc(), OperationReport.id.asc())
|
||
).mappings().all()
|
||
|
||
for row in rows:
|
||
parent_id = ledger_node_by_id.get(int(row["production_ledger_id"]))
|
||
if not parent_id:
|
||
continue
|
||
node_id = f"operation-report-{row['report_id']}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=node_id,
|
||
node_type="operation_report",
|
||
title=row["report_no"],
|
||
subtitle=f"{row['employee_name']} · 小程序报工",
|
||
status="异常" if row["is_abnormal"] else "正常",
|
||
tone="danger" if row["is_abnormal"] else "normal",
|
||
stage="报工证据",
|
||
level=3,
|
||
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"])),
|
||
("异常原因", row["scrap_reason"] or ("报工异常" if row["is_abnormal"] else "-")),
|
||
],
|
||
raw=dict(row),
|
||
)
|
||
)
|
||
graph.add_edge(_edge(parent_id, node_id, "报工证据"))
|
||
```
|
||
|
||
- [ ] **Step 9: Add delivery nodes from finished stock lots**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def _add_delivery_nodes_for_ledgers(
|
||
db: Session,
|
||
graph: _LifecycleGraph,
|
||
ledger_ids: list[int],
|
||
) -> None:
|
||
if not ledger_ids:
|
||
return
|
||
|
||
finished_lots = db.execute(
|
||
select(
|
||
StockLot.id.label("lot_id"),
|
||
StockLot.lot_no.label("lot_no"),
|
||
StockLot.source_doc_id.label("production_ledger_id"),
|
||
)
|
||
.join(Item, Item.id == StockLot.item_id)
|
||
.where(
|
||
StockLot.source_doc_id.in_(ledger_ids),
|
||
StockLot.source_doc_type.in_(["PRODUCTION_LEDGER_IN", "生产台账"]),
|
||
Item.item_type == "FINISHED_GOOD",
|
||
)
|
||
).mappings().all()
|
||
finished_lot_by_id = {int(row["lot_id"]): row for row in finished_lots}
|
||
if not finished_lot_by_id:
|
||
return
|
||
|
||
order_customer = aliased(Customer)
|
||
direct_customer = aliased(Customer)
|
||
delivery_rows = db.execute(
|
||
select(
|
||
DeliveryItem.id.label("delivery_item_id"),
|
||
DeliveryItem.lot_id.label("lot_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"),
|
||
)
|
||
.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(DeliveryItem.lot_id.in_(finished_lot_by_id))
|
||
.order_by(Delivery.delivery_date.asc(), DeliveryItem.id.asc())
|
||
).mappings().all()
|
||
|
||
for row in delivery_rows:
|
||
finished_lot = finished_lot_by_id.get(int(row["lot_id"]))
|
||
if not finished_lot:
|
||
continue
|
||
inbound_parent_id = f"finished-inbound-by-lot-{row['lot_id']}"
|
||
if inbound_parent_id not in graph.node_ids:
|
||
inbound_parent_id = f"production-ledger-{finished_lot['production_ledger_id']}"
|
||
node_id = f"delivery-{row['delivery_item_id']}"
|
||
graph.add_node(
|
||
_node(
|
||
node_id=node_id,
|
||
node_type="delivery",
|
||
title=row["delivery_no"],
|
||
subtitle=row["order_no"] or row["customer_name"] or "销售出库",
|
||
status=row["status"],
|
||
tone="success",
|
||
stage="销售去向",
|
||
level=6,
|
||
summary="该成品销售出库可向前追溯到材料库存批次号与生产台账。",
|
||
metrics=[
|
||
("发货数量", _fmt_qty(row["delivery_qty"])),
|
||
("发货重量", _fmt_weight(row["delivery_weight_kg"])),
|
||
("发货金额", _fmt_money(row["line_amount"])),
|
||
],
|
||
details=[
|
||
("发货单号", row["delivery_no"]),
|
||
("销售订单", row["order_no"]),
|
||
("客户", row["customer_name"]),
|
||
("成品库存批次号", finished_lot["lot_no"]),
|
||
("发货时间", _fmt_datetime(row["delivery_date"])),
|
||
],
|
||
raw=dict(row),
|
||
)
|
||
)
|
||
graph.add_edge(_edge(inbound_parent_id, node_id, "销售出库"))
|
||
```
|
||
|
||
- [ ] **Step 10: Add public graph function**
|
||
|
||
Append:
|
||
|
||
```python
|
||
def build_material_lot_lifecycle_graph(
|
||
db: Session,
|
||
lot: DashboardMaterialLotRead,
|
||
) -> tuple[list[LifecycleNode], list[LifecycleEdge]]:
|
||
graph = _LifecycleGraph()
|
||
raw_node_id = _add_raw_lot_node(graph, lot)
|
||
|
||
ledger_rows = _production_ledger_rows(db, lot.lot_id)
|
||
ledger_ids = [int(row.ProductionBatchLedger.id) for row in ledger_rows]
|
||
txns_by_ledger_id = _ledger_txns_by_ledger_id(db, ledger_ids)
|
||
ledger_node_by_id: dict[int, str] = {}
|
||
|
||
for row in ledger_rows:
|
||
ledger: ProductionBatchLedger = row.ProductionBatchLedger
|
||
ledger_node_id = _add_ledger_node(graph, raw_node_id, row)
|
||
ledger_node_by_id[int(ledger.id)] = ledger_node_id
|
||
balance_node_id = _add_outside_balance_node(graph, ledger, ledger_node_id)
|
||
txns = txns_by_ledger_id.get(int(ledger.id), [])
|
||
_add_issue_txn_nodes(graph, ledger_node_id, txns)
|
||
_add_inbound_settlement_nodes(graph, ledger_node_id, balance_node_id, txns)
|
||
|
||
_add_operation_report_evidence_nodes(db, graph, ledger_node_by_id)
|
||
_add_delivery_nodes_for_ledgers(db, graph, ledger_ids)
|
||
return graph.nodes, graph.edges
|
||
```
|
||
|
||
- [ ] **Step 11: Run backend test and verify first behavior passes enough to expose remaining gaps**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend
|
||
python -m pytest tests/test_dashboard_material_lot_lifecycle.py -q
|
||
```
|
||
|
||
Expected after this task:
|
||
|
||
```text
|
||
2 passed
|
||
```
|
||
|
||
If `Employee.dept_id` foreign key causes a missing department failure in SQLite, add a `Department` seed to the test fixture with `id=1` before adding `Employee`.
|
||
|
||
---
|
||
|
||
### Task 3: Dashboard Route Integration And Legacy Fallback
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/routes/dashboard.py`
|
||
- Test: `backend/tests/test_dashboard_material_lot_lifecycle.py`
|
||
|
||
- [ ] **Step 1: Import new service in dashboard route**
|
||
|
||
Modify the imports in `backend/app/api/routes/dashboard.py`:
|
||
|
||
```python
|
||
from app.services.material_lifecycle_dag import build_material_lot_lifecycle_graph
|
||
```
|
||
|
||
- [ ] **Step 2: Preserve old material lifecycle body as a legacy helper**
|
||
|
||
In `backend/app/api/routes/dashboard.py`, before the current `@router.get("/material-lots/{lot_id}/lifecycle"...` route, create:
|
||
|
||
```python
|
||
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)
|
||
|
||
# Move the old route's graph-building code here unchanged, starting at:
|
||
# lot_node_id = f"raw-lot-{lot.lot_id}"
|
||
# and ending before:
|
||
# return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges)
|
||
return nodes, edges
|
||
```
|
||
|
||
When moving code, keep all old node types unchanged inside this helper. This fallback is only for historical data that does not have `ProductionBatchLedger`.
|
||
|
||
- [ ] **Step 3: Replace route body with new-first fallback logic**
|
||
|
||
Replace `get_material_lot_lifecycle()` after the lot lookup with:
|
||
|
||
```python
|
||
lot = DashboardMaterialLotRead.model_validate(dict(lot_row))
|
||
nodes, edges = build_material_lot_lifecycle_graph(db, lot)
|
||
has_production_ledger = any(node.type == "production_ledger" for node in nodes)
|
||
if not has_production_ledger:
|
||
nodes, edges = _build_legacy_material_lot_lifecycle_graph(db, lot)
|
||
return MaterialLotLifecycleRead(lot=lot, nodes=nodes, edges=edges)
|
||
```
|
||
|
||
- [ ] **Step 4: Add direct route integration test**
|
||
|
||
Append to `backend/tests/test_dashboard_material_lot_lifecycle.py`:
|
||
|
||
```python
|
||
def test_dashboard_route_returns_new_graph_when_production_ledger_exists(self) -> None:
|
||
from app.api.routes.dashboard import get_material_lot_lifecycle
|
||
|
||
ledger = ProductionBatchLedger(
|
||
id=102,
|
||
material_lot_id=self.raw_lot.id,
|
||
material_lot_no=self.raw_lot.lot_no,
|
||
material_item_id=self.raw_item.id,
|
||
product_item_id=self.product.id,
|
||
status="在生产",
|
||
miniapp_selectable=1,
|
||
total_issued_weight_kg=Decimal("60"),
|
||
outside_weight_kg=Decimal("60"),
|
||
finished_inbound_qty=Decimal("0"),
|
||
surplus_inbound_weight_kg=Decimal("0"),
|
||
scrap_inbound_weight_kg=Decimal("0"),
|
||
writeoff_weight_kg=Decimal("0"),
|
||
first_issue_time=self.now,
|
||
last_issue_time=self.now,
|
||
created_at=self.now,
|
||
updated_at=self.now,
|
||
)
|
||
self.db.add(ledger)
|
||
self.db.commit()
|
||
|
||
result = get_material_lot_lifecycle(self.raw_lot.id, self.db)
|
||
self.assertIn("production_ledger", {node.type for node in result.nodes})
|
||
self.assertEqual(result.lot.lot_no, "YL0001")
|
||
```
|
||
|
||
- [ ] **Step 5: Run backend lifecycle test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend
|
||
python -m pytest tests/test_dashboard_material_lot_lifecycle.py -q
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
3 passed
|
||
```
|
||
|
||
- [ ] **Step 6: Run nearby backend regression tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend
|
||
python -m pytest tests/test_production_batch_ledger.py tests/test_selected_stock_lot_production_issue.py tests/test_smart_operation_report_config.py -q
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
passed
|
||
```
|
||
|
||
---
|
||
|
||
### Task 4: Frontend DAG Copy, Stage Model, And Layout Classification
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/views/DashboardView.vue`
|
||
- Create: `frontend/src/views/DashboardView.test.js`
|
||
|
||
- [ ] **Step 1: Add frontend source assertions**
|
||
|
||
Create `frontend/src/views/DashboardView.test.js`:
|
||
|
||
```javascript
|
||
import assert from "node:assert/strict";
|
||
import { describe, it } from "node:test";
|
||
import { readFileSync } from "node:fs";
|
||
import { fileURLToPath } from "node:url";
|
||
import { dirname, join } from "node:path";
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const source = readFileSync(join(__dirname, "DashboardView.vue"), "utf8");
|
||
|
||
describe("DashboardView material batch lifecycle DAG", () => {
|
||
it("uses current material-batch closed-loop copy instead of legacy work-order wording", () => {
|
||
assert.match(source, /材料库存批次闭环图/);
|
||
assert.match(source, /生产台账/);
|
||
assert.match(source, /库外闭环/);
|
||
assert.match(source, /入库结果/);
|
||
assert.doesNotMatch(source, /原材料生命周期 DAG/);
|
||
});
|
||
|
||
it("classifies new production-ledger node types for layout and visual treatment", () => {
|
||
assert.match(source, /production_ledger/);
|
||
assert.match(source, /production_issue_txn/);
|
||
assert.match(source, /ledger_outside_balance/);
|
||
assert.match(source, /production_ledger_settlement/);
|
||
assert.match(source, /finished_inbound/);
|
||
assert.match(source, /surplus_inbound/);
|
||
assert.match(source, /scrap_inbound/);
|
||
assert.match(source, /ledger_writeoff/);
|
||
assert.match(source, /operation_report/);
|
||
});
|
||
|
||
it("keeps miniapp reports visually labeled as evidence, not closure control", () => {
|
||
assert.match(source, /报工证据/);
|
||
assert.match(source, /只作为现场报工证据/);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run frontend test and verify it fails**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend
|
||
node --test src/views/DashboardView.test.js
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
not ok
|
||
```
|
||
|
||
The failure should mention missing `材料库存批次闭环图` or new node type strings.
|
||
|
||
- [ ] **Step 3: Update DAG header copy**
|
||
|
||
In `frontend/src/views/DashboardView.vue`, replace the lifecycle modal header:
|
||
|
||
```vue
|
||
<p class="eyebrow">材料库存批次闭环图</p>
|
||
```
|
||
|
||
Replace the header helper text:
|
||
|
||
```vue
|
||
<span class="table-chip">拖动画布 · 点击节点看详情 · 报工仅作证据</span>
|
||
```
|
||
|
||
Replace loading text:
|
||
|
||
```vue
|
||
<div v-if="loadingLifecycle" class="dag-loading-card">正在加载材料库存批次闭环图...</div>
|
||
```
|
||
|
||
- [ ] **Step 4: Add DAG legend to modal header**
|
||
|
||
In the header action area, before close button, add:
|
||
|
||
```vue
|
||
<div class="dag-legend" aria-label="材料批次闭环图例">
|
||
<span><i class="legend-dot legend-ledger"></i>生产台账</span>
|
||
<span><i class="legend-dot legend-balance"></i>库外余额</span>
|
||
<span><i class="legend-dot legend-inbound"></i>入库结果</span>
|
||
<span><i class="legend-dot legend-evidence"></i>报工证据</span>
|
||
</div>
|
||
```
|
||
|
||
- [ ] **Step 5: Update node visual class helper**
|
||
|
||
If `DashboardView.vue` has a helper like `dagNodeClass(node)` or inline `dag-node-${node.tone}`, extend it to include type classes:
|
||
|
||
```javascript
|
||
function dagNodeTypeClass(node) {
|
||
const typeClassMap = {
|
||
raw_material_lot: "dag-node-raw",
|
||
production_ledger: "dag-node-ledger",
|
||
production_issue_txn: "dag-node-issue",
|
||
ledger_outside_balance: "dag-node-balance",
|
||
production_ledger_settlement: "dag-node-settlement",
|
||
finished_inbound: "dag-node-inbound",
|
||
surplus_inbound: "dag-node-inbound",
|
||
scrap_inbound: "dag-node-inbound",
|
||
ledger_writeoff: "dag-node-writeoff",
|
||
ledger_reopen: "dag-node-reopen",
|
||
operation_report: "dag-node-evidence",
|
||
delivery: "dag-node-delivery"
|
||
};
|
||
return typeClassMap[node?.type] || "dag-node-default";
|
||
}
|
||
```
|
||
|
||
Use it on each node card:
|
||
|
||
```vue
|
||
:class="[
|
||
`dag-node-${node.tone || 'normal'}`,
|
||
dagNodeTypeClass(node),
|
||
{
|
||
'dag-node-selected': selectedNode?.id === node.id,
|
||
'dag-node-highlighted': isNodeHighlighted(node),
|
||
'dag-node-dimmed': isNodeDimmed(node)
|
||
}
|
||
]"
|
||
```
|
||
|
||
- [ ] **Step 6: Update layout priority for new nodes**
|
||
|
||
Find `layoutNodes` and its special row handling. Replace legacy-only grouping with this priority function:
|
||
|
||
```javascript
|
||
function dagRowPriority(node) {
|
||
const priority = {
|
||
raw_material_lot: 0,
|
||
production_ledger: 0,
|
||
production_issue_txn: 0,
|
||
ledger_outside_balance: 1,
|
||
operation_report: 2,
|
||
production_ledger_settlement: 0,
|
||
finished_inbound: 0,
|
||
surplus_inbound: 1,
|
||
scrap_inbound: 2,
|
||
ledger_writeoff: 3,
|
||
ledger_reopen: 4,
|
||
delivery: 0
|
||
};
|
||
return priority[node?.type] ?? 0;
|
||
}
|
||
```
|
||
|
||
Sort nodes inside the same level with:
|
||
|
||
```javascript
|
||
.sort((left, right) => {
|
||
const levelDiff = Number(left.level ?? 0) - Number(right.level ?? 0);
|
||
if (levelDiff !== 0) {
|
||
return levelDiff;
|
||
}
|
||
const rowDiff = dagRowPriority(left) - dagRowPriority(right);
|
||
if (rowDiff !== 0) {
|
||
return rowDiff;
|
||
}
|
||
return String(left.id).localeCompare(String(right.id), "zh-Hans-CN");
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 7: Add stage label normalization**
|
||
|
||
Before `stageBands`, add:
|
||
|
||
```javascript
|
||
function dagStageLabel(level, fallback) {
|
||
const labels = {
|
||
0: "原料入库",
|
||
1: "生产台账",
|
||
2: "生产出库",
|
||
3: "库外闭环",
|
||
4: "入库结算",
|
||
5: "入库结果",
|
||
6: "销售去向"
|
||
};
|
||
return labels[Number(level)] || fallback || "流转节点";
|
||
}
|
||
```
|
||
|
||
In `stageBands`, use:
|
||
|
||
```javascript
|
||
label: dagStageLabel(node.level, node.stage),
|
||
```
|
||
|
||
- [ ] **Step 8: Run frontend DAG test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend
|
||
node --test src/views/DashboardView.test.js
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
ok
|
||
```
|
||
|
||
---
|
||
|
||
### Task 5: DAG Visual Polish
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/styles/main.css`
|
||
- Test: `frontend/src/views/DashboardView.test.js`
|
||
|
||
- [ ] **Step 1: Add legend and node type styles**
|
||
|
||
Append near existing DAG styles in `frontend/src/styles/main.css`:
|
||
|
||
```css
|
||
.dag-legend {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 7px 10px;
|
||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||
border-radius: 999px;
|
||
background: rgba(15, 23, 42, 0.38);
|
||
color: rgba(226, 232, 240, 0.9);
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.dag-legend span {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
|
||
.legend-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 999px;
|
||
box-shadow: 0 0 14px currentColor;
|
||
}
|
||
|
||
.legend-ledger {
|
||
color: #7dd3fc;
|
||
background: #7dd3fc;
|
||
}
|
||
|
||
.legend-balance {
|
||
color: #fbbf24;
|
||
background: #fbbf24;
|
||
}
|
||
|
||
.legend-inbound {
|
||
color: #86efac;
|
||
background: #86efac;
|
||
}
|
||
|
||
.legend-evidence {
|
||
color: #c4b5fd;
|
||
background: #c4b5fd;
|
||
}
|
||
|
||
.dag-node-ledger {
|
||
border-color: rgba(125, 211, 252, 0.56);
|
||
background:
|
||
linear-gradient(135deg, rgba(14, 165, 233, 0.24), rgba(15, 23, 42, 0.92)),
|
||
radial-gradient(circle at top right, rgba(125, 211, 252, 0.22), transparent 42%);
|
||
}
|
||
|
||
.dag-node-issue {
|
||
border-color: rgba(251, 191, 36, 0.48);
|
||
background:
|
||
linear-gradient(135deg, rgba(245, 158, 11, 0.22), rgba(15, 23, 42, 0.92)),
|
||
radial-gradient(circle at top left, rgba(251, 191, 36, 0.18), transparent 42%);
|
||
}
|
||
|
||
.dag-node-balance {
|
||
border-color: rgba(251, 191, 36, 0.6);
|
||
background:
|
||
linear-gradient(135deg, rgba(120, 53, 15, 0.34), rgba(15, 23, 42, 0.9)),
|
||
repeating-linear-gradient(135deg, rgba(251, 191, 36, 0.08) 0 6px, transparent 6px 14px);
|
||
}
|
||
|
||
.dag-node-settlement,
|
||
.dag-node-inbound {
|
||
border-color: rgba(134, 239, 172, 0.5);
|
||
background:
|
||
linear-gradient(135deg, rgba(22, 163, 74, 0.22), rgba(15, 23, 42, 0.92)),
|
||
radial-gradient(circle at top right, rgba(134, 239, 172, 0.18), transparent 44%);
|
||
}
|
||
|
||
.dag-node-writeoff {
|
||
border-color: rgba(251, 146, 60, 0.58);
|
||
background:
|
||
linear-gradient(135deg, rgba(194, 65, 12, 0.24), rgba(15, 23, 42, 0.92)),
|
||
radial-gradient(circle at top right, rgba(251, 146, 60, 0.2), transparent 40%);
|
||
}
|
||
|
||
.dag-node-evidence {
|
||
border-style: dashed;
|
||
border-color: rgba(196, 181, 253, 0.62);
|
||
background:
|
||
linear-gradient(135deg, rgba(109, 40, 217, 0.18), rgba(15, 23, 42, 0.9)),
|
||
radial-gradient(circle at top left, rgba(196, 181, 253, 0.16), transparent 40%);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add CSS source assertion**
|
||
|
||
Extend `frontend/src/views/DashboardView.test.js`:
|
||
|
||
```javascript
|
||
import { readFileSync } from "node:fs";
|
||
|
||
const cssSource = readFileSync(join(__dirname, "../styles/main.css"), "utf8");
|
||
|
||
describe("DashboardView DAG visual system", () => {
|
||
it("defines dedicated visual classes for material batch lifecycle nodes", () => {
|
||
assert.match(cssSource, /\.dag-node-ledger/);
|
||
assert.match(cssSource, /\.dag-node-balance/);
|
||
assert.match(cssSource, /\.dag-node-settlement/);
|
||
assert.match(cssSource, /\.dag-node-evidence/);
|
||
assert.match(cssSource, /\.dag-legend/);
|
||
});
|
||
});
|
||
```
|
||
|
||
If the duplicate `readFileSync` import conflicts, merge it with the existing import instead of adding a second import.
|
||
|
||
- [ ] **Step 3: Run frontend DAG test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend
|
||
node --test src/views/DashboardView.test.js
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
ok
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: End-To-End Verification
|
||
|
||
**Files:**
|
||
- No edits unless verification exposes defects.
|
||
|
||
- [ ] **Step 1: Run backend lifecycle tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend
|
||
python -m pytest tests/test_dashboard_material_lot_lifecycle.py -q
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
3 passed
|
||
```
|
||
|
||
- [ ] **Step 2: Run backend production ledger regressions**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd backend
|
||
python -m pytest tests/test_production_batch_ledger.py tests/test_selected_stock_lot_production_issue.py tests/test_smart_operation_report_config.py -q
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
passed
|
||
```
|
||
|
||
- [ ] **Step 3: Run frontend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend
|
||
node --test src/views/DashboardView.test.js
|
||
node --test src/views/InventoryLedgerView.test.js
|
||
node --test src/components/documentForms/documentForms.test.js
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
ok
|
||
```
|
||
|
||
- [ ] **Step 4: Run frontend production build**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
✓ built
|
||
```
|
||
|
||
The existing Vite chunk-size warning is acceptable unless a new build error appears.
|
||
|
||
- [ ] **Step 5: Browser smoke test**
|
||
|
||
Open the already logged-in browser at:
|
||
|
||
```text
|
||
http://localhost:5173/
|
||
```
|
||
|
||
Manual checks:
|
||
|
||
- The material-lot table still loads.
|
||
- Clicking `YL0001` or any material lot opens a modal titled `材料库存批次闭环图`.
|
||
- The graph has stage bands: `原料入库`、`生产台账`、`生产出库`、`库外闭环`、`入库结算`、`入库结果`、`销售去向`.
|
||
- The graph does not show old `生产工单` as the main chain when a `ProductionBatchLedger` exists.
|
||
- If system extension `对接智能报工小程序` is closed, the graph does not show `报工证据` nodes.
|
||
- If the switch is open and `OperationReport.production_ledger_id` has data, report nodes appear as dashed evidence nodes.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
After all checks pass:
|
||
|
||
```bash
|
||
git status --short
|
||
git add backend/app/services/material_lifecycle_dag.py backend/app/api/routes/dashboard.py backend/tests/test_dashboard_material_lot_lifecycle.py frontend/src/views/DashboardView.vue frontend/src/views/DashboardView.test.js frontend/src/styles/main.css
|
||
git commit -m "重构材料批次生命周期DAG"
|
||
```
|
||
|
||
Do not add `.impeccable/` unless explicitly requested.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
- Spec coverage: The plan replaces old work-order main chain with production-ledger main chain, supports material-lot/product ledger rows, production issue accumulation, production ledger inbound settlement, finished/surplus/scrap/writeoff outputs, optional miniapp evidence, delivery tracing, and legacy fallback.
|
||
- Placeholder scan: The plan contains no open implementation placeholders. The only compatibility note is an explicit legacy fallback task.
|
||
- Type consistency: Backend public function is consistently named `build_material_lot_lifecycle_graph(db, lot) -> tuple[list[LifecycleNode], list[LifecycleEdge]]`; frontend new node types are consistently `production_ledger`, `production_issue_txn`, `ledger_outside_balance`, `production_ledger_settlement`, `finished_inbound`, `surplus_inbound`, `scrap_inbound`, `ledger_writeoff`, `operation_report`, `delivery`.
|
||
- Risk note: Delivery tracing depends on finished stock lots carrying `source_doc_type` in `PRODUCTION_LEDGER_IN` or `生产台账` and `source_doc_id = production_ledger_id`; this matches current production台账入库 and单独成品生产入库路径.
|