# Production Batch Ledger And Smart Reporting Toggle 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:** Replace the production execution core from “生产工单” to “生产台账”, where each row is uniquely identified by `材料库存批次号 + 产品`, while keeping the intelligent operation-reporting miniapp as an optional integration controlled by a system switch. **Architecture:** Add a new production ledger aggregate table keyed by `material_lot_id + product_item_id`, with an internal numeric ID for stable references and a ledger transaction table for every production issue, finished inbound, surplus inbound, scrap inbound, material lock, reopen, and write-off event. New production flows write to this ledger instead of creating new `pp_work_order` rows; old work-order tables remain for historical compatibility and rework flows until a separate cleanup project. The system switch `对接智能报工小程序` controls whether miniapp operation reports drive progress calculations and UI visibility; when disabled, progress is inferred from ERP inbound data and BOM gross/net weights. **Tech Stack:** FastAPI, SQLAlchemy, Pydantic, MySQL patch SQL, Vue 3, Vite, Node static UI checks, Python `unittest`/`pytest`, miniapp backend FastAPI service under `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint`. --- ## Business Rules Locked In - Production ledger business key: ```text 材料库存批次号 + 产品 ``` - Physical database primary key: ```text production_ledger_id ``` - Unique database constraint: ```text material_lot_id + product_item_id ``` - Ledger statuses are Chinese user/business values: ```text 在生产 锁单 ``` - “该批材料结单” means this material lot is no longer outside the warehouse for this product. Any remaining outside weight is confirmed by a write-off ledger transaction and the row status becomes `锁单`. - If the same `材料库存批次号 + 产品` is issued again after locking, the same ledger row reopens to `在生产`, the new issue weight is accumulated, and a `重新开工` transaction is recorded. - Miniapp dropdown source is active production ledger rows, not `pp_work_order`. The miniapp shows material stock lot numbers for rows where `状态 = 在生产` and product matches the scanned product/process context. - When the smart reporting switch is enabled, operation reports are linked to production ledger rows and drive operation-detail progress. - When the smart reporting switch is disabled, `工序报工` UI is hidden and production calculations use ERP data: ```text 理论已用料 = 已入库成品数量 × BOM毛重 理论废料 = 已入库成品数量 × (BOM毛重 - BOM净重) 理论余料 = 累计领料重量 - 理论已用料 ``` - Scrap rate is not separately modeled in disabled mode; it is handled through the 15% deviation explanation mechanism. --- ## File Structure - Create: `backend/sql/add_production_batch_ledger_patch.sql` - Create `pp_production_batch_ledger` and `pp_production_batch_ledger_txn`. - Add optional `production_ledger_id` columns to existing ERP operation report tables for compatibility. - Seed `SMART_OPERATION_REPORT_ENABLED` config as enabled. - Modify: `backend/app/models/operations.py` - Add `ProductionBatchLedger` and `ProductionBatchLedgerTxn`. - Add nullable `production_ledger_id` to `OperationReport`. - Modify: `backend/app/schemas/operations.py` - Add read/create schemas for production ledger, ledger transactions, ledger inbound preview, ledger inbound create, and material lock request. - Modify: `backend/app/services/system_config.py` - Add `SMART_OPERATION_REPORT_ENABLED` helpers. - Modify: `backend/app/schemas/database.py` - Add `SmartOperationReportConfigRead` and `SmartOperationReportConfigUpdate`. - Modify: `backend/app/api/routes/system_extension.py` - Add switch GET/PUT endpoints. - Create: `backend/app/services/production_batch_ledger.py` - Own all new production ledger aggregation, reopen, lock, progress, and inbound calculations. - Modify: `backend/app/api/routes/production.py` - Add new production ledger endpoints. - Change production issue endpoint to upsert ledger rows instead of creating new `pp_work_order` rows. - Keep legacy work-order endpoints as read-compatible routes while frontend migrates. - Bind miniapp report sync to production ledger IDs. - Modify: `backend/app/api/routes/inventory.py` - Change production surplus and scrap branch inbound to select and update production ledger. - Keep rework work-order logic untouched. - Modify: `backend/app/services/production_work_order_ledger.py` - Keep only as legacy compatibility, delegating normal production ledger reads to the new service if needed. - Modify: `backend/app/services/production_work_order_inbound.py` - Keep legacy compatibility, but normal unified production inbound should call the new production ledger service. - Create: `backend/tests/test_production_batch_ledger.py` - Covers issue accumulation, reopen, lock, write-off, enabled/disabled calculations. - Create: `backend/tests/test_smart_operation_report_config.py` - Covers system switch defaults and updates. - Modify: `backend/tests/test_selected_stock_lot_production_issue.py` - Update expectations from work-order creation to production-ledger upsert. - Modify: `backend/tests/test_production_work_order_inbound.py` - Split legacy tests and add new ledger inbound tests, or migrate assertions to production ledger APIs. - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` - Change material batch options SQL from `pp_work_order` to `pp_production_batch_ledger`. - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py` - Rename test intent to production ledger material batch options. - Modify: `frontend/src/router/index.js` - Add `production-ledger` route and redirect old `work-order-ledger` to it. - Hide direct `operation-reports` when smart reporting switch is disabled. - Modify: `frontend/src/App.vue` - Rename menu `工单台账` to `生产台账`. - Hide `工序报工` when disabled. - Create: `frontend/src/views/ProductionLedgerView.vue` - Replace `WorkOrderManagementView.vue` as the main production ledger page. - Modify: `frontend/src/views/ProductionWorkspaceView.vue` - Rename and explain the new production ledger concept. - Modify: `frontend/src/views/InventoryLedgerView.vue` - Rename `生产工单入库` to `生产台账入库`. - Branch selectors use production ledger rows. - Main close action becomes `该批材料结单`. - Modify: `frontend/src/views/SystemExtensionView.vue` - Add `对接智能报工小程序` switch. - Create: `frontend/src/services/systemFeatures.js` - Cache feature switch config. - Create: `frontend/scripts/test-production-batch-ledger-ui.mjs` - Static checks for renamed UI and route migration. - Create: `frontend/scripts/test-smart-operation-report-toggle.mjs` - Static checks for switch and hidden miniapp UI. --- ## Task 1: Add Database Patch And ORM Models **Files:** - Create: `backend/sql/add_production_batch_ledger_patch.sql` - Modify: `backend/app/models/operations.py` - Modify: `backend/app/schemas/operations.py` - Test: `backend/tests/test_production_batch_ledger.py` - [ ] **Step 1: Write failing model test** Create `backend/tests/test_production_batch_ledger.py`: ```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 ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402 class ProductionBatchLedgerModelTest(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() def tearDown(self) -> None: self.db.close() def test_models_persist_chinese_status_and_business_key(self) -> None: raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.5"), status="ACTIVE") warehouse = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") lot = StockLot( id=1, lot_no="YL0001", lot_role="RAW_MATERIAL", item_id=1, warehouse_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", ) self.db.add_all([raw_item, product, warehouse, lot]) self.db.flush() ledger = ProductionBatchLedger( 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("200"), outside_weight_kg=Decimal("200"), 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=datetime(2026, 6, 8, 8, 30), last_issue_time=datetime(2026, 6, 8, 8, 30), ) self.db.add(ledger) self.db.flush() self.db.add( ProductionBatchLedgerTxn( 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=10, biz_time=datetime(2026, 6, 8, 8, 30), operator_user_id=1, remark="生产出库累计到生产台账", ) ) self.db.commit() saved = self.db.scalar(select(ProductionBatchLedger).where(ProductionBatchLedger.material_lot_no == "YL0001")) self.assertEqual(saved.status, "在生产") self.assertEqual(saved.miniapp_selectable, 1) self.assertEqual(float(saved.outside_weight_kg), 200) txns = self.db.scalars(select(ProductionBatchLedgerTxn)).all() self.assertEqual(txns[0].txn_type, "生产出库") ``` - [ ] **Step 2: Run model test and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_production_batch_ledger.py -q ``` Expected: FAIL because `ProductionBatchLedger` and `ProductionBatchLedgerTxn` do not exist. - [ ] **Step 3: Add SQL patch** Create `backend/sql/add_production_batch_ledger_patch.sql`: ```sql CREATE TABLE IF NOT EXISTS pp_production_batch_ledger ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '生产台账ID', material_lot_id BIGINT NOT NULL COMMENT '材料库存批次ID', material_lot_no VARCHAR(128) NOT NULL COMMENT '材料库存批次号', material_item_id BIGINT NOT NULL COMMENT '原材料物料ID', product_item_id BIGINT NOT NULL COMMENT '产品物料ID', status VARCHAR(32) NOT NULL DEFAULT '在生产' COMMENT '状态:在生产/锁单', miniapp_selectable TINYINT(1) NOT NULL DEFAULT 1 COMMENT '小程序是否可选', total_issued_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '累计领料重量', outside_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '当前库外未闭环重量', finished_inbound_qty DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已入库成品数量', surplus_inbound_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已归还余料重量', scrap_inbound_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '已入库废料重量', writeoff_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '结单核销重量', first_issue_time DATETIME NULL COMMENT '首次生产出库时间', last_issue_time DATETIME NULL COMMENT '最近生产出库时间', locked_at DATETIME NULL COMMENT '最近锁单时间', reopened_at DATETIME NULL COMMENT '最近重新开工时间', lock_count INT NOT NULL DEFAULT 0 COMMENT '锁单次数', reopen_count INT NOT NULL DEFAULT 0 COMMENT '重新开工次数', remark VARCHAR(500) NULL COMMENT '备注', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', UNIQUE KEY uk_production_batch_ledger_lot_product (material_lot_id, product_item_id), KEY idx_production_batch_ledger_status (status, miniapp_selectable), KEY idx_production_batch_ledger_product (product_item_id), CONSTRAINT fk_production_batch_ledger_lot FOREIGN KEY (material_lot_id) REFERENCES wh_stock_lot(id), CONSTRAINT fk_production_batch_ledger_material FOREIGN KEY (material_item_id) REFERENCES md_item(id), CONSTRAINT fk_production_batch_ledger_product FOREIGN KEY (product_item_id) REFERENCES md_item(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生产台账:材料库存批次号+产品'; CREATE TABLE IF NOT EXISTS pp_production_batch_ledger_txn ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '生产台账流水ID', production_ledger_id BIGINT NOT NULL COMMENT '生产台账ID', txn_type VARCHAR(32) NOT NULL COMMENT '流水类型:生产出库/成品入库/生产余料入库/生产废料入库/该批材料结单/重新开工/结单核销', qty_delta DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '数量变化', weight_delta_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '重量变化', outside_weight_after_kg DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '流水后库外未闭环重量', source_doc_type VARCHAR(64) NULL COMMENT '来源单据类型', source_doc_id BIGINT NULL COMMENT '来源单据ID', source_line_id BIGINT NULL COMMENT '来源单据明细ID', biz_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '业务时间', operator_user_id BIGINT NULL COMMENT '操作人ID', remark VARCHAR(500) NULL COMMENT '备注', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_production_batch_ledger_txn_ledger (production_ledger_id, biz_time), KEY idx_production_batch_ledger_txn_type (txn_type), CONSTRAINT fk_production_batch_ledger_txn_ledger FOREIGN KEY (production_ledger_id) REFERENCES pp_production_batch_ledger(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='生产台账流水'; ALTER TABLE pp_operation_report ADD COLUMN production_ledger_id BIGINT NULL COMMENT '生产台账ID' AFTER work_order_operation_id; INSERT INTO sys_system_config (config_code, config_name, config_value, remark, status) SELECT 'SMART_OPERATION_REPORT_ENABLED', '对接智能报工小程序', '开启', '开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度', 'ACTIVE' FROM DUAL WHERE NOT EXISTS ( SELECT 1 FROM sys_system_config WHERE config_code = 'SMART_OPERATION_REPORT_ENABLED' ); ``` Use this duplicate-column-safe form on MySQL if the simple `ALTER TABLE` fails because the column already exists: ```sql SET @column_exists := ( SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pp_operation_report' AND COLUMN_NAME = 'production_ledger_id' ); SET @ddl := IF( @column_exists = 0, 'ALTER TABLE pp_operation_report ADD COLUMN production_ledger_id BIGINT NULL COMMENT ''生产台账ID'' AFTER work_order_operation_id', 'SELECT ''pp_operation_report.production_ledger_id already exists''' ); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; ``` - [ ] **Step 4: Add ORM models** Modify `backend/app/models/operations.py`: ```python class ProductionBatchLedger(Base): __tablename__ = "pp_production_batch_ledger" __table_args__ = (UniqueConstraint("material_lot_id", "product_item_id", name="uk_production_batch_ledger_lot_product"),) id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) material_lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id")) material_lot_no: Mapped[str] = mapped_column(String(128)) material_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) product_item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id")) status: Mapped[str] = mapped_column(String(32), server_default=text("'在生产'")) miniapp_selectable: Mapped[int] = mapped_column(Integer, server_default=text("1")) total_issued_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) outside_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) finished_inbound_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) surplus_inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) scrap_inbound_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) writeoff_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) first_issue_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) last_issue_time: Mapped[object | None] = mapped_column(DateTime, nullable=True) locked_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) reopened_at: Mapped[object | None] = mapped_column(DateTime, nullable=True) lock_count: Mapped[int] = mapped_column(Integer, server_default=text("0")) reopen_count: Mapped[int] = mapped_column(Integer, server_default=text("0")) remark: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime) class ProductionBatchLedgerTxn(Base): __tablename__ = "pp_production_batch_ledger_txn" id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) production_ledger_id: Mapped[int] = mapped_column(ForeignKey("pp_production_batch_ledger.id")) txn_type: Mapped[str] = mapped_column(String(32)) qty_delta: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) weight_delta_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) outside_weight_after_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0")) source_doc_type: Mapped[str | None] = mapped_column(String(64), nullable=True) source_doc_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) source_line_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) biz_time: Mapped[object] = mapped_column(DateTime) operator_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) remark: Mapped[str | None] = mapped_column(String(500), nullable=True) created_at: Mapped[object] = mapped_column(DateTime) ``` Add to `OperationReport`: ```python production_ledger_id: Mapped[int | None] = mapped_column(ForeignKey("pp_production_batch_ledger.id"), nullable=True) ``` - [ ] **Step 5: Add schemas** Modify `backend/app/schemas/operations.py`: ```python class ProductionBatchLedgerTxnRead(BaseModel): model_config = ConfigDict(from_attributes=True) id: int production_ledger_id: int txn_type: str qty_delta: float = 0 weight_delta_kg: float = 0 outside_weight_after_kg: float = 0 source_doc_type: str | None = None source_doc_id: int | None = None source_line_id: int | None = None biz_time: datetime operator_user_id: int | None = None remark: str | None = None class ProductionBatchLedgerRead(BaseModel): production_ledger_id: int material_lot_id: int material_lot_no: str material_item_id: int material_code: str | None = None material_name: str | None = None product_item_id: int product_code: str | None = None product_name: str | None = None status: str miniapp_selectable: bool = True total_issued_weight_kg: float = 0 outside_weight_kg: float = 0 finished_inbound_qty: float = 0 surplus_inbound_weight_kg: float = 0 scrap_inbound_weight_kg: float = 0 writeoff_weight_kg: float = 0 first_issue_time: datetime | None = None last_issue_time: datetime | None = None locked_at: datetime | None = None reopened_at: datetime | None = None lock_count: int = 0 reopen_count: int = 0 remark: str | None = None txns: list[ProductionBatchLedgerTxnRead] = [] operations: list[WorkOrderLedgerOperationRow] = [] ``` Use existing operation-row schema names if they differ; keep response fields readable for frontend. - [ ] **Step 6: Run model test** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_production_batch_ledger.py -q python3 -m py_compile backend/app/models/operations.py backend/app/schemas/operations.py ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash git add backend/sql/add_production_batch_ledger_patch.sql backend/app/models/operations.py backend/app/schemas/operations.py backend/tests/test_production_batch_ledger.py git commit -m "feat: add production batch ledger schema" ``` --- ## Task 2: Build Production Ledger Service **Files:** - Create: `backend/app/services/production_batch_ledger.py` - Modify: `backend/tests/test_production_batch_ledger.py` - [ ] **Step 1: Add failing service tests** Append to `backend/tests/test_production_batch_ledger.py`: ```python def test_record_issue_accumulates_same_lot_and_product(self) -> None: from app.services.production_batch_ledger import record_production_issue_to_ledger self._seed_basic_items_and_lot() first = record_production_issue_to_ledger( self.db, material_lot_id=1, product_item_id=2, issued_weight_kg=Decimal("120"), operator_user_id=1, source_doc_type="库存流水", source_doc_id=100, source_line_id=1001, biz_time=datetime(2026, 6, 8, 9, 0), remark="第一次出库", ) second = record_production_issue_to_ledger( self.db, material_lot_id=1, product_item_id=2, issued_weight_kg=Decimal("80"), operator_user_id=1, source_doc_type="库存流水", source_doc_id=101, source_line_id=1002, biz_time=datetime(2026, 6, 8, 14, 0), remark="第二次出库", ) self.db.commit() self.assertEqual(first.id, second.id) self.assertEqual(second.status, "在生产") self.assertEqual(float(second.total_issued_weight_kg), 200) self.assertEqual(float(second.outside_weight_kg), 200) txns = self.db.scalars(select(ProductionBatchLedgerTxn).order_by(ProductionBatchLedgerTxn.id)).all() self.assertEqual([row.txn_type for row in txns], ["生产出库", "生产出库"]) def test_lock_and_reopen_same_lot_product(self) -> None: from app.services.production_batch_ledger import lock_production_batch_ledger, record_production_issue_to_ledger self._seed_basic_items_and_lot() ledger = record_production_issue_to_ledger( self.db, material_lot_id=1, product_item_id=2, issued_weight_kg=Decimal("200"), operator_user_id=1, source_doc_type="库存流水", source_doc_id=100, source_line_id=1001, biz_time=datetime(2026, 6, 8, 9, 0), remark="生产出库", ) locked = lock_production_batch_ledger( self.db, production_ledger_id=ledger.id, operator_user_id=1, biz_time=datetime(2026, 6, 8, 18, 0), remark="现场确认该批材料已无库外余量", ) self.assertEqual(locked.status, "锁单") self.assertEqual(locked.miniapp_selectable, 0) self.assertEqual(float(locked.outside_weight_kg), 0) self.assertEqual(float(locked.writeoff_weight_kg), 200) reopened = record_production_issue_to_ledger( self.db, material_lot_id=1, product_item_id=2, issued_weight_kg=Decimal("50"), operator_user_id=1, source_doc_type="库存流水", source_doc_id=102, source_line_id=1003, biz_time=datetime(2026, 6, 9, 9, 0), remark="继续出库生产", ) self.assertEqual(reopened.id, locked.id) self.assertEqual(reopened.status, "在生产") self.assertEqual(reopened.miniapp_selectable, 1) self.assertEqual(float(reopened.total_issued_weight_kg), 250) self.assertEqual(float(reopened.outside_weight_kg), 50) txns = self.db.scalars(select(ProductionBatchLedgerTxn).order_by(ProductionBatchLedgerTxn.id)).all() self.assertEqual([row.txn_type for row in txns], ["生产出库", "结单核销", "该批材料结单", "重新开工", "生产出库"]) ``` Add helper inside the test class: ```python def _seed_basic_items_and_lot(self) -> None: raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.5"), status="ACTIVE") warehouse = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") lot = StockLot( id=1, lot_no="YL0001", lot_role="RAW_MATERIAL", item_id=1, warehouse_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", ) self.db.add_all([raw_item, product, warehouse, lot]) self.db.flush() ``` - [ ] **Step 2: Run tests and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_production_batch_ledger.py -q ``` Expected: FAIL because `production_batch_ledger.py` service does not exist. - [ ] **Step 3: Implement service** Create `backend/app/services/production_batch_ledger.py`: ```python from __future__ import annotations from datetime import datetime from decimal import Decimal, ROUND_FLOOR from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from app.models.master_data import BomItem, Item from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot from app.services.operations import to_decimal INBOUND_TOLERANCE_MULTIPLIER = Decimal("1.15") def _round_weight(value: Decimal) -> Decimal: return to_decimal(value, "0.001") def _non_negative(value: Decimal) -> Decimal: return value if value > 0 else Decimal("0") def _append_txn( db: Session, *, ledger: ProductionBatchLedger, txn_type: str, qty_delta: Decimal = Decimal("0"), weight_delta_kg: Decimal = Decimal("0"), source_doc_type: str | None = None, source_doc_id: int | None = None, source_line_id: int | None = None, biz_time: datetime | None = None, operator_user_id: int | None = None, remark: str | None = None, ) -> ProductionBatchLedgerTxn: txn = ProductionBatchLedgerTxn( production_ledger_id=ledger.id, txn_type=txn_type, qty_delta=qty_delta, weight_delta_kg=weight_delta_kg, outside_weight_after_kg=to_decimal(ledger.outside_weight_kg), source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=biz_time or datetime.now(), operator_user_id=operator_user_id, remark=remark, ) db.add(txn) return txn def _get_locked_ledger(db: Session, material_lot_id: int, product_item_id: int) -> ProductionBatchLedger | None: return db.scalar( select(ProductionBatchLedger) .where( ProductionBatchLedger.material_lot_id == material_lot_id, ProductionBatchLedger.product_item_id == product_item_id, ) .with_for_update() ) def record_production_issue_to_ledger( db: Session, *, material_lot_id: int, product_item_id: int, issued_weight_kg: Decimal, operator_user_id: int | None, source_doc_type: str | None, source_doc_id: int | None, source_line_id: int | None, biz_time: datetime | None = None, remark: str | None = None, ) -> ProductionBatchLedger: if issued_weight_kg <= 0: raise HTTPException(status_code=400, detail="生产出库重量必须大于0") lot = db.get(StockLot, material_lot_id) if not lot: raise HTTPException(status_code=404, detail="材料库存批次号不存在") product = db.get(Item, product_item_id) if not product: raise HTTPException(status_code=404, detail="产品不存在") now = biz_time or datetime.now() ledger = _get_locked_ledger(db, material_lot_id, product_item_id) if ledger and str(ledger.status or "") == "锁单": ledger.status = "在生产" ledger.miniapp_selectable = 1 ledger.reopened_at = now ledger.reopen_count = int(ledger.reopen_count or 0) + 1 _append_txn( db, ledger=ledger, txn_type="重新开工", source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=now, operator_user_id=operator_user_id, remark="锁单后再次生产出库,生产台账重新进入在生产状态", ) if not ledger: ledger = ProductionBatchLedger( material_lot_id=material_lot_id, material_lot_no=lot.lot_no, material_item_id=lot.item_id, product_item_id=product_item_id, status="在生产", miniapp_selectable=1, total_issued_weight_kg=Decimal("0"), outside_weight_kg=Decimal("0"), 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=now, last_issue_time=now, remark=remark, ) db.add(ledger) db.flush() ledger.status = "在生产" ledger.miniapp_selectable = 1 ledger.total_issued_weight_kg = _round_weight(to_decimal(ledger.total_issued_weight_kg) + issued_weight_kg) ledger.outside_weight_kg = _round_weight(to_decimal(ledger.outside_weight_kg) + issued_weight_kg) ledger.last_issue_time = now ledger.updated_at = now db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="生产出库", weight_delta_kg=issued_weight_kg, source_doc_type=source_doc_type, source_doc_id=source_doc_id, source_line_id=source_line_id, biz_time=now, operator_user_id=operator_user_id, remark=remark or "生产出库累计到生产台账", ) return ledger def lock_production_batch_ledger( db: Session, *, production_ledger_id: int, operator_user_id: int | None, biz_time: datetime | None = None, remark: str | None = None, ) -> ProductionBatchLedger: ledger = db.get(ProductionBatchLedger, production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") now = biz_time or datetime.now() remaining = _round_weight(to_decimal(ledger.outside_weight_kg)) if remaining > 0: ledger.writeoff_weight_kg = _round_weight(to_decimal(ledger.writeoff_weight_kg) + remaining) ledger.outside_weight_kg = Decimal("0") db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="结单核销", weight_delta_kg=-remaining, biz_time=now, operator_user_id=operator_user_id, remark=remark or "该批材料结单时确认库外剩余重量已无现场存量", ) ledger.status = "锁单" ledger.miniapp_selectable = 0 ledger.locked_at = now ledger.lock_count = int(ledger.lock_count or 0) + 1 ledger.updated_at = now db.add(ledger) db.flush() _append_txn( db, ledger=ledger, txn_type="该批材料结单", biz_time=now, operator_user_id=operator_user_id, remark=remark or "该批材料已结单,小程序不再显示该材料库存批次号", ) return ledger ``` - [ ] **Step 4: Run service tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_production_batch_ledger.py -q python3 -m py_compile backend/app/services/production_batch_ledger.py ``` Expected: PASS. - [ ] **Step 5: Commit** ```bash git add backend/app/services/production_batch_ledger.py backend/tests/test_production_batch_ledger.py git commit -m "feat: manage production ledger issue lock and reopen" ``` --- ## Task 3: Refactor Production Issue To Upsert Production Ledger **Files:** - Modify: `backend/app/api/routes/production.py` - Modify: `backend/tests/test_selected_stock_lot_production_issue.py` - Modify: `backend/tests/test_production_batch_ledger.py` - [ ] **Step 1: Write failing API/service behavior test** Update `backend/tests/test_selected_stock_lot_production_issue.py` so production issue asserts: ```python def test_production_issue_creates_or_updates_production_batch_ledger(...): response = client.post("/api/production/work-orders", json=payload) assert response.status_code == 200 data = response.json() assert data["production_ledger_id"] > 0 assert data["material_lot_no"] == "YL0001" assert data["product_name"] == "钢制碗" assert data["status"] == "在生产" assert data["total_issued_weight_kg"] == 200 ``` If this test file is not client-based, add a direct route/service test using its existing setup style. Keep endpoint path `/production/work-orders` for one release as compatibility, but make the returned object a production ledger read model. - [ ] **Step 2: Run test and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_selected_stock_lot_production_issue.py -q ``` Expected: FAIL because route still creates `pp_work_order`. - [ ] **Step 3: Change production issue route** Modify `backend/app/api/routes/production.py`: ```python from app.models.operations import ProductionBatchLedger from app.schemas.operations import ProductionBatchLedgerRead from app.services.production_batch_ledger import record_production_issue_to_ledger, production_batch_ledger_to_read ``` Update route decorator: ```python @router.post("/work-orders", response_model=ProductionBatchLedgerRead) ``` Inside `create_work_order`, after stock lot validation and after `issue_selected_stock_lots` or equivalent inventory deduction, call: ```python ledger = record_production_issue_to_ledger( db, material_lot_id=int(selected_lot.id), product_item_id=int(payload.product_item_id), issued_weight_kg=issue_weight, operator_user_id=context.user.id, source_doc_type="生产出库", source_doc_id=None, source_line_id=None, biz_time=biz_time, remark=payload.remark or "原材料库生产出库形成生产台账", ) db.commit() return production_batch_ledger_to_read(db, ledger.id) ``` Do not create a new `WorkOrder` for normal production issue. Rework flows still use `WorkOrder`. - [ ] **Step 4: Preserve legacy endpoint names with new semantics** Add new endpoint alias: ```python @router.get("/production-ledger", response_model=list[ProductionBatchLedgerRead]) def list_production_batch_ledger(...): ... ``` Keep old endpoint: ```python @router.get("/work-order-ledger", response_model=list[ProductionBatchLedgerRead]) def list_work_order_ledger(...): return list_production_batch_ledger(...) ``` This lets frontend migrate route names without breaking old bookmarks immediately. - [ ] **Step 5: Run focused tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_batch_ledger.py -q ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add backend/app/api/routes/production.py backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_production_batch_ledger.py git commit -m "feat: make production issue update batch ledger" ``` --- ## Task 4: Implement Ledger Progress Calculations And Inbound APIs **Files:** - Modify: `backend/app/services/production_batch_ledger.py` - Modify: `backend/app/api/routes/production.py` - Modify: `backend/app/api/routes/inventory.py` - Modify: `backend/app/schemas/operations.py` - Modify: `backend/tests/test_production_batch_ledger.py` - [ ] **Step 1: Add calculation tests** Append tests: ```python def test_disabled_mode_calculates_from_finished_inbound_and_bom(self) -> None: from app.services.production_batch_ledger import calculate_production_batch_ledger_snapshot self._seed_basic_items_bom_and_lot() ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200")) ledger.finished_inbound_qty = Decimal("100") snapshot = calculate_production_batch_ledger_snapshot(self.db, ledger.id, smart_reporting_enabled=False) self.assertEqual(float(snapshot.reference_finished_qty), 333) self.assertEqual(float(snapshot.used_material_weight_kg), 60) self.assertEqual(float(snapshot.theoretical_scrap_weight_kg), 10) self.assertEqual(float(snapshot.theoretical_surplus_weight_kg), 140) def test_lock_writes_off_remaining_outside_weight_after_inbounds(self) -> None: from app.services.production_batch_ledger import lock_production_batch_ledger, post_finished_inbound_to_ledger self._seed_basic_items_bom_and_lot() ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200"), outside_weight_kg=Decimal("200")) post_finished_inbound_to_ledger( self.db, production_ledger_id=ledger.id, finished_qty=Decimal("100"), operator_user_id=1, source_doc_type="成品入库", source_doc_id=1, source_line_id=1, biz_time=datetime(2026, 6, 8, 12, 0), remark="成品入库", ) locked = lock_production_batch_ledger( self.db, production_ledger_id=ledger.id, operator_user_id=1, biz_time=datetime(2026, 6, 8, 18, 0), remark="该批材料结单", ) self.assertEqual(locked.status, "锁单") self.assertEqual(float(locked.outside_weight_kg), 0) ``` - [ ] **Step 2: Run tests and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_production_batch_ledger.py -q ``` Expected: FAIL until calculation and inbound helpers exist. - [ ] **Step 3: Add snapshot and inbound helpers** In `backend/app/services/production_batch_ledger.py`, add: ```python @dataclass class ProductionBatchLedgerSnapshot: production_ledger_id: int material_lot_no: str product_item_id: int gross_weight_per_piece_kg: Decimal net_weight_per_piece_kg: Decimal total_issued_weight_kg: Decimal finished_inbound_qty: Decimal surplus_inbound_weight_kg: Decimal scrap_inbound_weight_kg: Decimal writeoff_weight_kg: Decimal outside_weight_kg: Decimal reference_finished_qty: Decimal used_material_weight_kg: Decimal theoretical_scrap_weight_kg: Decimal theoretical_surplus_weight_kg: Decimal ``` Implement: ```python def calculate_production_batch_ledger_snapshot( db: Session, production_ledger_id: int, *, smart_reporting_enabled: bool, selected_finished_qty: Decimal | None = None, ) -> ProductionBatchLedgerSnapshot: ledger = db.get(ProductionBatchLedger, production_ledger_id) if not ledger: raise HTTPException(status_code=404, detail="生产台账不存在") gross, net = _get_bom_weight_profile(db, ledger.product_item_id, ledger.material_item_id) total_issued = to_decimal(ledger.total_issued_weight_kg) finished_qty = to_decimal(ledger.finished_inbound_qty) basis_qty = finished_qty + (selected_finished_qty or Decimal("0")) reference_finished_qty = Decimal("0") if gross > 0: reference_finished_qty = (total_issued / gross).to_integral_value(rounding=ROUND_FLOOR) used_weight = _round_weight(basis_qty * gross) theoretical_scrap = _round_weight(_non_negative(basis_qty * _non_negative(gross - net) - to_decimal(ledger.scrap_inbound_weight_kg))) theoretical_surplus = _round_weight(_non_negative(total_issued - used_weight - to_decimal(ledger.surplus_inbound_weight_kg))) return ProductionBatchLedgerSnapshot( production_ledger_id=ledger.id, material_lot_no=ledger.material_lot_no, product_item_id=ledger.product_item_id, gross_weight_per_piece_kg=gross, net_weight_per_piece_kg=net, total_issued_weight_kg=total_issued, finished_inbound_qty=finished_qty, surplus_inbound_weight_kg=to_decimal(ledger.surplus_inbound_weight_kg), scrap_inbound_weight_kg=to_decimal(ledger.scrap_inbound_weight_kg), writeoff_weight_kg=to_decimal(ledger.writeoff_weight_kg), outside_weight_kg=to_decimal(ledger.outside_weight_kg), reference_finished_qty=reference_finished_qty, used_material_weight_kg=used_weight, theoretical_scrap_weight_kg=theoretical_scrap, theoretical_surplus_weight_kg=theoretical_surplus, ) ``` Add helpers: ```python def post_finished_inbound_to_ledger(...): ... def post_surplus_inbound_to_ledger(...): ... def post_scrap_inbound_to_ledger(...): ... ``` Each helper updates the matching aggregate field, subtracts the correct weight from `outside_weight_kg`, writes a Chinese transaction type, and returns the refreshed ledger. - [ ] **Step 4: Update APIs** Add in `backend/app/api/routes/production.py`: ```python @router.get("/production-ledger-inbounds/preview", response_model=ProductionBatchLedgerInboundPreviewRead) def preview_production_batch_ledger_inbound(...): ... @router.post("/production-ledger-inbounds", response_model=ProductionBatchLedgerInboundRead) def create_production_batch_ledger_inbound(...): ... ``` Payload fields: ```python production_ledger_id: int finished_qty: float = 0 surplus_weight_kg: float = 0 scrap_weight_kg: float = 0 lock_material_batch: bool = False lock_confirmed: bool = False deviation_remark: str | None = None remark: str | None = None ``` If `lock_material_batch` is true, call `lock_production_batch_ledger` after posting inbound rows. - [ ] **Step 5: Update branch entries** In `backend/app/api/routes/inventory.py`, for normal production surplus/scrap only: - Read `payload.source_doc_type == "PRODUCTION_LEDGER"`. - Read `payload.source_doc_id` as `production_ledger_id`. - Call `post_surplus_inbound_to_ledger` or `post_scrap_inbound_to_ledger`. - If payload requests lock, call `lock_production_batch_ledger`. - Keep rework paths using `WORK_ORDER`. - [ ] **Step 6: Run tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py backend/tests/test_scrap_warehouse_flow.py -q ``` Expected: PASS after updating legacy tests to the new API where normal production is involved. - [ ] **Step 7: Commit** ```bash git add backend/app/services/production_batch_ledger.py backend/app/api/routes/production.py backend/app/api/routes/inventory.py backend/app/schemas/operations.py backend/tests/test_production_batch_ledger.py backend/tests/test_production_work_order_inbound.py backend/tests/test_scrap_warehouse_flow.py git commit -m "feat: calculate and post production ledger inbounds" ``` --- ## Task 5: Add Smart Operation Reporting Switch **Files:** - Modify: `backend/app/services/system_config.py` - Modify: `backend/app/schemas/database.py` - Modify: `backend/app/api/routes/system_extension.py` - Create: `backend/tests/test_smart_operation_report_config.py` - [ ] **Step 1: Write config tests** Create `backend/tests/test_smart_operation_report_config.py`: ```python from __future__ import annotations import unittest 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.models.base import Base # noqa: E402 from app.services.system_config import ( # noqa: E402 get_smart_operation_report_enabled, normalize_enabled_config_value, upsert_smart_operation_report_config, ) class SmartOperationReportConfigTest(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() def tearDown(self) -> None: self.db.close() def test_defaults_enabled(self) -> None: self.assertTrue(get_smart_operation_report_enabled(self.db)) def test_normalizes_chinese_values(self) -> None: self.assertEqual(normalize_enabled_config_value("开启"), "开启") self.assertEqual(normalize_enabled_config_value("关闭"), "关闭") self.assertEqual(normalize_enabled_config_value(True), "开启") self.assertEqual(normalize_enabled_config_value(False), "关闭") def test_upsert_changes_enabled_state(self) -> None: upsert_smart_operation_report_config(self.db, enabled=False, updated_by=1, remark="未购买小程序") self.db.commit() self.assertFalse(get_smart_operation_report_enabled(self.db)) ``` - [ ] **Step 2: Run tests and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_smart_operation_report_config.py -q ``` Expected: FAIL until helpers exist. - [ ] **Step 3: Implement config helpers** Use Chinese persisted values: ```python SMART_OPERATION_REPORT_CONFIG_CODE = "SMART_OPERATION_REPORT_ENABLED" SMART_OPERATION_REPORT_DEFAULT = "开启" def normalize_enabled_config_value(value: object) -> str: if isinstance(value, bool): return "开启" if value else "关闭" normalized = str(value or "").strip().upper() if normalized in {"1", "TRUE", "YES", "ON", "ENABLED", "开启", "打开", "启用", "是"}: return "开启" if normalized in {"0", "FALSE", "NO", "OFF", "DISABLED", "关闭", "停用", "否"}: return "关闭" return SMART_OPERATION_REPORT_DEFAULT def get_smart_operation_report_enabled(db: Session) -> bool: value = get_system_config_value(db, SMART_OPERATION_REPORT_CONFIG_CODE, SMART_OPERATION_REPORT_DEFAULT) return normalize_enabled_config_value(value) == "开启" def upsert_smart_operation_report_config(...): return upsert_system_config( db, config_code=SMART_OPERATION_REPORT_CONFIG_CODE, config_name="对接智能报工小程序", config_value=normalize_enabled_config_value(enabled), remark=remark or "开启后显示工序报工并按小程序报工数据计算生产进度;关闭后按ERP入库数据反推生产进度", updated_by=updated_by, now=now, ) ``` - [ ] **Step 4: Add API schemas and endpoints** Add to `backend/app/schemas/database.py`: ```python class SmartOperationReportConfigUpdate(BaseModel): enabled: bool = True remark: str | None = Field(default=None, max_length=500) class SmartOperationReportConfigRead(BaseModel): config_code: str config_name: str enabled: bool config_value: str remark: str | None = None updated_at: datetime | None = None ``` Add endpoints in `backend/app/api/routes/system_extension.py`: ```python @router.get("/smart-operation-report-config", response_model=SmartOperationReportConfigRead) def get_smart_operation_report_config(...): ... @router.put("/smart-operation-report-config", response_model=SmartOperationReportConfigRead) def save_smart_operation_report_config(...): ... ``` - [ ] **Step 5: Run tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_smart_operation_report_config.py -q python3 -m py_compile backend/app/services/system_config.py backend/app/schemas/database.py backend/app/api/routes/system_extension.py ``` Expected: PASS. - [ ] **Step 6: Commit** ```bash git add backend/app/services/system_config.py backend/app/schemas/database.py backend/app/api/routes/system_extension.py backend/tests/test_smart_operation_report_config.py git commit -m "feat: add smart reporting integration switch" ``` --- ## Task 6: Refactor Miniapp Batch Options And ERP Report Sync **Files:** - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py` - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py` - Modify: `backend/app/api/routes/production.py` - Modify: `backend/tests/test_miniapp_operation_report_date_filter.py` - Modify: `backend/tests/test_production_batch_ledger.py` - [ ] **Step 1: Update miniapp backend tests** In `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`, replace setup tables from `pp_work_order` to `pp_production_batch_ledger`: ```sql create table pp_production_batch_ledger ( id integer primary key, material_lot_id integer, material_lot_no text, material_item_id integer, product_item_id integer, status text, miniapp_selectable integer ); ``` Expected assertions: ```python def test_raw_material_batch_options_returns_active_production_ledger_lot_numbers_for_product_process(monkeypatch): ... assert options == ["YL0001"] def test_raw_material_batch_options_excludes_locked_ledgers(monkeypatch): ... assert options == ["YL0002"] ``` - [ ] **Step 2: Run miniapp backend test and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint python -m pytest tests/test_work_order_batch_options.py -q ``` Expected: FAIL because `_raw_material_batch_options` still queries `pp_work_order`. - [ ] **Step 3: Change miniapp dropdown SQL** Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`: ```sql select distinct ledger.material_lot_no from pp_production_batch_ledger ledger join md_item product on product.id = ledger.product_item_id join md_process_route route on route.product_item_id = product.id and route.status = 'ACTIVE' join md_process_route_operation op on op.route_id = route.id where ledger.material_lot_no is not null and trim(ledger.material_lot_no) <> '' and ledger.status = '在生产' and coalesce(ledger.miniapp_selectable, 1) = 1 and (:process_name = '' or op.operation_name = :process_name) and ( (:product_name <> '' and :project_no <> '' and product.item_name = :product_name and product.item_code = :project_no) or (:product_name <> '' and :project_no = '' and product.item_name = :product_name) or (:product_name = '' and :project_no <> '' and product.item_code = :project_no) ) order by ledger.material_lot_no asc ``` - [ ] **Step 4: Bind ERP operation sync to production ledger** In `backend/app/api/routes/production.py`, replace `_find_work_order_for_miniapp_item` for normal production with `_find_production_ledger_for_miniapp_item`. Rules: ```text raw_material_batch_no = miniapp submitted material stock lot number product = matched ERP product from miniapp product name/project no production ledger = pp_production_batch_ledger where material_lot_no = raw_material_batch_no and product_item_id = product.id and status = 在生产 ``` Operation reports should write: ```python report.production_ledger_id = ledger.id report.work_order_id = None report.work_order_operation_id = None ``` Keep `work_order_id` for legacy/rework only. - [ ] **Step 5: Add ERP sync test** Add to `backend/tests/test_production_batch_ledger.py`: ```python def test_miniapp_report_resolution_uses_production_ledger_lot_and_product(self) -> None: from app.api.routes.production import _find_production_ledger_for_miniapp_item self._seed_basic_items_bom_and_lot() ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200"), status="在生产") row = { "raw_material_batch_no": "YL0001", "product_name": "钢制碗", "project_no": "FG-001", } matched = _find_production_ledger_for_miniapp_item(self.db, row, cache=None) self.assertEqual(matched.id, ledger.id) ``` - [ ] **Step 6: Run miniapp and ERP tests** Run: ```bash cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint python -m pytest tests/test_work_order_batch_options.py -q cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest backend/tests/test_production_batch_ledger.py backend/tests/test_miniapp_operation_report_date_filter.py -q ``` Expected: PASS. - [ ] **Step 7: Commit** ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP git add backend/app/api/routes/production.py backend/tests/test_production_batch_ledger.py backend/tests/test_miniapp_operation_report_date_filter.py git commit -m "feat: bind miniapp reports to production ledger" cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint git add app/routers/reports.py tests/test_work_order_batch_options.py git commit -m "feat: source material batch options from ERP production ledger" ``` --- ## Task 7: Frontend Route And Page Migration **Files:** - Create: `frontend/src/services/systemFeatures.js` - Create: `frontend/src/views/ProductionLedgerView.vue` - Modify: `frontend/src/router/index.js` - Modify: `frontend/src/App.vue` - Modify: `frontend/src/views/ProductionWorkspaceView.vue` - Modify: `frontend/src/views/SystemExtensionView.vue` - Create: `frontend/scripts/test-production-batch-ledger-ui.mjs` - Create: `frontend/scripts/test-smart-operation-report-toggle.mjs` - [ ] **Step 1: Write static UI tests** Create `frontend/scripts/test-production-batch-ledger-ui.mjs`: ```javascript import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; const root = process.cwd(); const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8"); const router = read("src/router/index.js"); const app = read("src/App.vue"); const productionLedger = read("src/views/ProductionLedgerView.vue"); const inventory = read("src/views/InventoryLedgerView.vue"); assert.match(router, /ProductionLedgerView/, "router should import ProductionLedgerView"); assert.match(router, /name:\s*"production-ledger"/, "router should expose production-ledger route"); assert.match(router, /path:\s*"\/work-order-ledger"[\s\S]*redirect:\s*\{\s*name:\s*"production-ledger"\s*\}/, "old work-order-ledger route should redirect to production-ledger"); assert.match(app, /生产台账/, "sidebar should show 生产台账"); assert.doesNotMatch(app, /工单台账/, "sidebar should no longer show 工单台账"); assert.match(productionLedger, /材料库存批次号/, "production ledger page should use material stock lot number"); assert.match(productionLedger, /产品/, "production ledger page should show product"); assert.match(productionLedger, /库外未闭环重量/, "production ledger page should show outside weight"); assert.match(productionLedger, /该批材料结单/, "production ledger page should expose material batch lock action"); assert.match(inventory, /生产台账入库/, "inventory unified inbound should be renamed to production ledger inbound"); assert.match(inventory, /该批材料结单/, "inventory close action should use material batch close wording"); console.log("production batch ledger UI static checks passed"); ``` Create `frontend/scripts/test-smart-operation-report-toggle.mjs`: ```javascript import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; const root = process.cwd(); const read = (relativePath) => readFileSync(path.join(root, relativePath), "utf8"); const featureService = read("src/services/systemFeatures.js"); const router = read("src/router/index.js"); const app = read("src/App.vue"); const systemExtension = read("src/views/SystemExtensionView.vue"); const productionLedger = read("src/views/ProductionLedgerView.vue"); assert.match(featureService, /smart-operation-report-config/, "feature service should load switch config"); assert.match(router, /operation-reports/, "router should still define operation report route"); assert.match(router, /loadSmartOperationReportConfig/, "router should guard operation-reports by switch"); assert.match(app, /smartOperationReportEnabled/, "App should track switch state"); assert.match(app, /child\.key\s*===\s*"operation-reports"/, "App should hide operation reports child when disabled"); assert.match(systemExtension, /对接智能报工小程序/, "system extension should show switch"); assert.match(productionLedger, /工序明细/, "production ledger page should show operation detail only when enabled"); assert.match(productionLedger, /当前未对接智能报工小程序/, "production ledger page should explain disabled mode"); console.log("smart operation report toggle static checks passed"); ``` - [ ] **Step 2: Run static tests and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-batch-ledger-ui.mjs node scripts/test-smart-operation-report-toggle.mjs ``` Expected: FAIL until frontend files are migrated. - [ ] **Step 3: Add feature service** Create `frontend/src/services/systemFeatures.js`: ```javascript import { fetchResource } from "./api"; let smartOperationReportConfigCache = null; let smartOperationReportConfigPromise = null; export async function loadSmartOperationReportConfig(force = false) { if (!force && smartOperationReportConfigCache) { return smartOperationReportConfigCache; } if (!force && smartOperationReportConfigPromise) { return smartOperationReportConfigPromise; } smartOperationReportConfigPromise = fetchResource("/system-extension/smart-operation-report-config", { enabled: true, config_value: "开启", config_name: "对接智能报工小程序", remark: "" }).then((config) => { smartOperationReportConfigCache = { ...config, enabled: config?.enabled !== false }; smartOperationReportConfigPromise = null; return smartOperationReportConfigCache; }); return smartOperationReportConfigPromise; } export function resetSmartOperationReportConfigCache(config = null) { smartOperationReportConfigCache = config; smartOperationReportConfigPromise = null; } ``` - [ ] **Step 4: Create ProductionLedgerView** Create `frontend/src/views/ProductionLedgerView.vue` using the structure of `WorkOrderManagementView.vue`, but rename concepts: ```vue ``` Load rows from: ```javascript fetchResource("/production/production-ledger?limit=500", []) ``` - [ ] **Step 5: Update router and sidebar** Modify `frontend/src/router/index.js`: ```javascript import ProductionLedgerView from "../views/ProductionLedgerView.vue"; import { loadSmartOperationReportConfig } from "../services/systemFeatures"; ``` Routes: ```javascript { path: "/production-ledger", name: "production-ledger", component: ProductionLedgerView, meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" } }, { path: "/work-order-ledger", name: "work-order-ledger", redirect: { name: "production-ledger" }, meta: { title: "生产台账", requiresAuth: true, permission: "MENU_WORK_ORDER" } } ``` Route guard: ```javascript if (to.name === "operation-reports") { const config = await loadSmartOperationReportConfig(); if (config.enabled === false) { return { name: "production-ledger" }; } } ``` Modify `frontend/src/App.vue` child menu: ```javascript { key: "production-ledger", label: "生产台账", to: { name: "production-ledger" }, routeNames: ["production-ledger", "work-order-ledger"], permission: "MENU_WORK_ORDER", ... } ``` - [ ] **Step 6: Add SystemExtension switch UI** Add a panel in `frontend/src/views/SystemExtensionView.vue`: ```vue

生产模式配置

对接智能报工小程序

{{ smartOperationReportForm.enabled ? "已对接" : "未对接" }}
{{ smartOperationReportForm.enabled ? "开启后显示工序报工,并按小程序报工数据计算生产进度。" : "关闭后隐藏工序报工,生产进度按ERP入库数据和BOM反推。" }}
``` - [ ] **Step 7: Run frontend checks** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-batch-ledger-ui.mjs node scripts/test-smart-operation-report-toggle.mjs npm run build ``` Expected: PASS. - [ ] **Step 8: Commit** ```bash git add frontend/src/services/systemFeatures.js frontend/src/views/ProductionLedgerView.vue frontend/src/router/index.js frontend/src/App.vue frontend/src/views/ProductionWorkspaceView.vue frontend/src/views/SystemExtensionView.vue frontend/scripts/test-production-batch-ledger-ui.mjs frontend/scripts/test-smart-operation-report-toggle.mjs git commit -m "feat: migrate frontend to production batch ledger" ``` --- ## Task 8: Update Inventory Inbound UI To Production Ledger **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - Modify: `frontend/scripts/test-production-batch-ledger-ui.mjs` - Modify: `frontend/scripts/test-production-work-order-inbound-ui.mjs` - [ ] **Step 1: Extend static checks** Add to `frontend/scripts/test-production-batch-ledger-ui.mjs`: ```javascript assert.doesNotMatch(inventory, /生产工单入库/, "inventory should not show old production work-order inbound wording"); assert.doesNotMatch(inventory, /工单号/, "production ledger inbound summary should not use 工单号"); assert.match(inventory, /生产台账入库/, "inventory should show production ledger inbound"); assert.match(inventory, /生产台账/, "branch selectors should use production ledger wording"); assert.match(inventory, /该批材料结单/, "close action should lock material batch production ledger"); assert.match(inventory, /库外未闭环重量/, "summary should show outside unclosed weight"); ``` - [ ] **Step 2: Run static test and verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-batch-ledger-ui.mjs ``` Expected: FAIL until inventory UI wording and payloads are migrated. - [ ] **Step 3: Rename unified inbound drawer** In `frontend/src/views/InventoryLedgerView.vue`, rename: ```text 生产工单入库 -> 生产台账入库 工单号 -> 生产台账 本次入库后结单 -> 本次入库后该批材料结单 保存生产工单入库 -> 保存生产台账入库 ``` Preview endpoint: ```javascript fetchResource(`/production/production-ledger-inbounds/preview?production_ledger_id=${id}&finished_qty=${finishedQty}`, null) ``` Submit endpoint: ```javascript postResource("/production/production-ledger-inbounds", { production_ledger_id: Number(selectedProductionLedgerId.value), finished_qty: finishedQty, surplus_weight_kg: surplusWeight, scrap_weight_kg: scrapWeight, lock_material_batch: Boolean(productionLedgerInboundForm.lock_material_batch), lock_confirmed: Boolean(productionLedgerInboundForm.lock_material_batch), deviation_remark: productionLedgerInboundForm.deviation_remark || null, remark: productionLedgerInboundForm.remark || null }) ``` - [ ] **Step 4: Update branch payloads** For 原材料库 · 生产余料入库: ```javascript source_doc_type: "PRODUCTION_LEDGER", source_doc_id: Number(selectedProductionLedger.value.production_ledger_id) ``` For 废料库 · 生产废料入库: ```javascript source_doc_type: "PRODUCTION_LEDGER", source_doc_id: Number(selectedProductionLedger.value.production_ledger_id) ``` Close checkbox label: ```text 该批材料结单 ``` Confirmation text: ```text 确认后该材料库存批次号将从小程序待选列表中剔除;系统会把当前库外未闭环重量作为结单核销记录。 ``` - [ ] **Step 5: Run frontend checks** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-batch-ledger-ui.mjs node scripts/test-production-work-order-inbound-ui.mjs npm run build ``` Expected: PASS after updating old test names or replacing the old script with production ledger checks. - [ ] **Step 6: Commit** ```bash git add frontend/src/views/InventoryLedgerView.vue frontend/scripts/test-production-batch-ledger-ui.mjs frontend/scripts/test-production-work-order-inbound-ui.mjs git commit -m "feat: use production ledger for warehouse production inbound" ``` --- ## Task 9: Compatibility And Data Migration **Files:** - Create: `backend/sql/migrate_work_orders_to_production_batch_ledger.sql` - Modify: `backend/app/api/routes/production.py` - Test: manual SQL verification - [ ] **Step 1: Create migration SQL** Create `backend/sql/migrate_work_orders_to_production_batch_ledger.sql`: ```sql INSERT INTO pp_production_batch_ledger ( material_lot_id, material_lot_no, material_item_id, product_item_id, status, miniapp_selectable, total_issued_weight_kg, outside_weight_kg, finished_inbound_qty, surplus_inbound_weight_kg, scrap_inbound_weight_kg, writeoff_weight_kg, first_issue_time, last_issue_time, remark ) SELECT issue.source_lot_id, lot.lot_no, issue.material_item_id, wo.product_item_id, CASE WHEN UPPER(COALESCE(wo.status, '')) = 'SETTLED' THEN '锁单' ELSE '在生产' END, CASE WHEN UPPER(COALESCE(wo.status, '')) = 'SETTLED' THEN 0 ELSE 1 END, SUM(COALESCE(issue.issued_weight_kg, 0)), GREATEST(SUM(COALESCE(issue.issued_weight_kg, 0)) - COALESCE(MAX(wom.returned_weight_kg), 0), 0), COALESCE(MAX(wo.finished_qty), 0), COALESCE(MAX(wom.returned_weight_kg), 0), 0, 0, MIN(issue.issue_time), MAX(issue.issue_time), '由历史生产工单迁移生成' FROM pp_work_order wo JOIN pp_work_order_material_issue issue ON issue.work_order_id = wo.id JOIN wh_stock_lot lot ON lot.id = issue.source_lot_id LEFT JOIN pp_work_order_material wom ON wom.work_order_id = wo.id AND wom.material_item_id = issue.material_item_id WHERE wo.work_order_type = 'NORMAL' GROUP BY issue.source_lot_id, lot.lot_no, issue.material_item_id, wo.product_item_id, wo.status ON DUPLICATE KEY UPDATE total_issued_weight_kg = VALUES(total_issued_weight_kg), outside_weight_kg = VALUES(outside_weight_kg), finished_inbound_qty = VALUES(finished_inbound_qty), surplus_inbound_weight_kg = VALUES(surplus_inbound_weight_kg), status = VALUES(status), miniapp_selectable = VALUES(miniapp_selectable), remark = CONCAT(COALESCE(pp_production_batch_ledger.remark, ''), ';历史工单迁移已更新'); ``` Follow with transaction inserts: ```sql INSERT INTO pp_production_batch_ledger_txn ( production_ledger_id, txn_type, qty_delta, weight_delta_kg, outside_weight_after_kg, source_doc_type, source_doc_id, source_line_id, biz_time, remark ) SELECT ledger.id, '生产出库', 0, issue.issued_weight_kg, ledger.outside_weight_kg, '历史生产工单', issue.work_order_id, issue.id, issue.issue_time, '由历史生产工单领料记录迁移' FROM pp_work_order_material_issue issue JOIN pp_work_order wo ON wo.id = issue.work_order_id JOIN pp_production_batch_ledger ledger ON ledger.material_lot_id = issue.source_lot_id AND ledger.product_item_id = wo.product_item_id WHERE wo.work_order_type = 'NORMAL' AND NOT EXISTS ( SELECT 1 FROM pp_production_batch_ledger_txn existing WHERE existing.production_ledger_id = ledger.id AND existing.source_doc_type = '历史生产工单' AND existing.source_doc_id = issue.work_order_id AND existing.source_line_id = issue.id AND existing.txn_type = '生产出库' ); ``` - [ ] **Step 2: Run migration in test DB** Run: ```bash mysql -h "$DB_HOST" -P "${DB_PORT:-3306}" -u "$DB_USER" -p"$DB_PASSWORD" --default-character-set=utf8mb4 "$DB_NAME" < /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/add_production_batch_ledger_patch.sql mysql -h "$DB_HOST" -P "${DB_PORT:-3306}" -u "$DB_USER" -p"$DB_PASSWORD" --default-character-set=utf8mb4 "$DB_NAME" < /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/migrate_work_orders_to_production_batch_ledger.sql ``` Expected: both commands exit `0`. - [ ] **Step 3: Verify migration** Run: ```sql SELECT material_lot_no, product_item_id, status, total_issued_weight_kg, outside_weight_kg FROM pp_production_batch_ledger ORDER BY id DESC LIMIT 20; ``` Expected: rows exist for historical normal work-order issue data. - [ ] **Step 4: Commit** ```bash git add backend/sql/migrate_work_orders_to_production_batch_ledger.sql git commit -m "chore: add legacy work order to production ledger migration" ``` --- ## Task 10: Full Verification **Files:** - Verify all touched backend, frontend, and miniapp backend files. - [ ] **Step 1: Backend focused tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP python3 -m pytest \ backend/tests/test_production_batch_ledger.py \ backend/tests/test_smart_operation_report_config.py \ backend/tests/test_selected_stock_lot_production_issue.py \ backend/tests/test_production_work_order_inbound.py \ backend/tests/test_scrap_warehouse_flow.py \ backend/tests/test_miniapp_operation_report_date_filter.py \ -q ``` Expected: PASS. - [ ] **Step 2: Frontend static checks and build** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-batch-ledger-ui.mjs node scripts/test-smart-operation-report-toggle.mjs node scripts/test-operation-report-date-filter.mjs npm run build ``` Expected: PASS. Vite chunk-size warnings are acceptable. - [ ] **Step 3: Miniapp backend checks** Run: ```bash cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint python -m pytest tests/test_work_order_batch_options.py -q ``` Expected: PASS. - [ ] **Step 4: Manual smoke checks** Start ERP frontend/backend locally and verify: - `生产执行` menu shows `生产台账`, not `工单台账`. - `生产台账` table rows are keyed by `材料库存批次号 + 产品`. -原材料库生产出库 twice for the same material lot and product updates one ledger row. - `该批材料结单` changes status to `锁单` and removes the material lot from miniapp dropdown. - Issuing the same material lot/product after locking reopens the same ledger row to `在生产`. - When `对接智能报工小程序` is enabled, `工序报工` is visible and ledger detail shows `工序明细`. - When the switch is disabled, `工序报工` is hidden and ledger detail hides `工序明细`. - `生产台账入库` calculates theoretical surplus/scrap immediately after finished quantity input. - Branch entries for production surplus and production scrap update the same production ledger row. - [ ] **Step 5: Final status check** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP git status --short ``` Expected: only intentional files are modified. --- ## Self-Review - The plan no longer treats `pp_work_order` as the normal production core. - The new core is `pp_production_batch_ledger`, uniquely representing `材料库存批次号 + 产品`. - The old `工单台账` wording is replaced by `生产台账`. - The old “工单结单” concept is replaced by `该批材料结单`. - The miniapp dropdown source is changed from work orders to active production ledger rows. - The smart reporting switch remains part of the plan and controls UI/calculation mode. - Rework work orders are intentionally left on legacy `pp_work_order` because they are a separate return/rework lifecycle.