# Raw Lot Work Order Inbound 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:** Implement原材料库存批次号全库递增、生产出库单批次合并工单、小程序按材料库存批次号报工、以及百华仓库“生产工单入库”统一闭环成品/余料/废料。 **Architecture:** Add a small system-config service for the raw lot prefix, keep ERP production work orders as the internal container, and make raw stock lot IDs the external production-facing identifier. Production outbound becomes single-lot and merges into a deterministic daily work order; production work-order inbound becomes one transactional backend API that posts finished goods, returns surplus to the original raw lot, posts scrap, and optionally settles the work order. **Tech Stack:** FastAPI, SQLAlchemy ORM, MySQL/SQLite-compatible tests, Vue 3 + Vite, WeChat miniapp frontend/backend, existing `pytest` and Node script tests. --- ## File Structure Backend ERP files: - Create `backend/sql/add_raw_lot_work_order_inbound_patch.sql`: create generic `sys_system_config` table and seed `RAW_MATERIAL_LOT_PREFIX=YL`. - Create `backend/app/services/system_config.py`: normalize config values and read/write `sys_system_config`. - Create `backend/app/services/production_work_order_inbound.py`: calculation and transactional posting for production work-order inbound. - Modify `backend/app/models/org.py`: add `SystemConfig` ORM model. - Modify `backend/app/schemas/database.py`: add config read/update schemas. - Modify `backend/app/schemas/operations.py`: add production work-order inbound preview/create/read schemas. - Modify `backend/app/api/routes/system_extension.py`: expose raw material lot prefix config endpoints. - Modify `backend/app/services/operations.py`: replace material-name lot numbering with raw-warehouse global prefix numbering and add deterministic work-order number helper. - Modify `backend/app/api/routes/purchase.py`: keep purchase receipt path on `build_inventory_lot_no`, add test coverage only unless code needs explicit import changes. - Modify `backend/app/api/routes/inventory.py`: use new raw lot numbering for raw opening/customer-supplied/special inbound paths and add read helpers used by frontend. - Modify `backend/app/api/routes/production.py`: enforce single stock lot production outbound, merge same-day same-lot same-product work orders, resolve miniapp reports by material stock lot no, and expose production work-order inbound endpoints. ERP tests: - Create `backend/tests/test_system_config_raw_lot_prefix.py`. - Modify `backend/tests/test_inventory_lot_numbering.py`. - Modify `backend/tests/test_customer_supplied_inbound_lot_no.py`. - Modify `backend/tests/test_opening_inventory_import.py`. - Modify `backend/tests/test_selected_stock_lot_production_issue.py`. - Create `backend/tests/test_production_work_order_inbound.py`. ERP frontend files: - Modify `frontend/src/components/StockLotTagSelect.vue`: add `maxSelections` support for single-select production outbound. - Modify `frontend/src/views/SystemExtensionView.vue`: add raw lot prefix config card. - Modify `frontend/src/views/InventoryLedgerView.vue`: production outbound single-select UI and new “生产工单入库” entry/drawer. - Create `frontend/scripts/test-production-work-order-inbound-ui.mjs`. - Modify `frontend/scripts/test-special-adjustment-ui.mjs` only if shared warehouse action ordering assertions need updating. Miniapp files: - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`: return material stock lot numbers instead of `JH0001` work-order short codes. - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`. - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`. - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`. - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml`. - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`. - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`. - Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`. --- ### Task 1: Add System Config Storage For Raw Lot Prefix **Files:** - Create: `backend/sql/add_raw_lot_work_order_inbound_patch.sql` - Create: `backend/app/services/system_config.py` - Modify: `backend/app/models/org.py` - Modify: `backend/app/schemas/database.py` - Modify: `backend/app/api/routes/system_extension.py` - Test: `backend/tests/test_system_config_raw_lot_prefix.py` - [ ] **Step 1: Write failing backend tests for config default, normalization, and update** Add `backend/tests/test_system_config_raw_lot_prefix.py`: ```python from __future__ import annotations import unittest from datetime import datetime 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.org import SystemConfig # noqa: E402 from app.services.system_config import ( # noqa: E402 RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, get_raw_material_lot_prefix, normalize_raw_material_lot_prefix, upsert_system_config, ) class RawLotPrefixSystemConfigTest(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_default_prefix_is_yl_when_config_missing(self) -> None: self.assertEqual(get_raw_material_lot_prefix(self.db), "YL") def test_prefix_normalization_keeps_only_uppercase_letters_and_numbers(self) -> None: self.assertEqual(normalize_raw_material_lot_prefix(" yl-01 "), "YL01") self.assertEqual(normalize_raw_material_lot_prefix(""), "YL") self.assertEqual(normalize_raw_material_lot_prefix("材料"), "YL") def test_upsert_raw_lot_prefix_config(self) -> None: now = datetime.now() upsert_system_config( self.db, config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, config_name="原材料库存批次号前缀", config_value="rm", remark="测试配置", updated_by=1, now=now, ) self.db.commit() row = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE)) self.assertIsNotNone(row) self.assertEqual(row.config_value, "RM") self.assertEqual(get_raw_material_lot_prefix(self.db), "RM") ``` - [ ] **Step 2: Run failing config tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_config_raw_lot_prefix.py -q ``` Expected: FAIL because `SystemConfig` and `app.services.system_config` do not exist. - [ ] **Step 3: Add SQL patch** Create `backend/sql/add_raw_lot_work_order_inbound_patch.sql`: ```sql CREATE TABLE IF NOT EXISTS sys_system_config ( id BIGINT PRIMARY KEY AUTO_INCREMENT, config_code VARCHAR(100) NOT NULL, config_name VARCHAR(100) NOT NULL, config_value VARCHAR(500) NOT NULL, remark VARCHAR(500) NULL, status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', created_by BIGINT NULL, updated_by BIGINT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_sys_system_config_code (config_code), KEY idx_sys_system_config_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; INSERT INTO sys_system_config (config_code, config_name, config_value, remark, status) SELECT 'RAW_MATERIAL_LOT_PREFIX', '原材料库存批次号前缀', 'YL', '原材料库存批次号默认前缀,例如 YL0001', 'ACTIVE' WHERE NOT EXISTS ( SELECT 1 FROM sys_system_config WHERE config_code = 'RAW_MATERIAL_LOT_PREFIX' ); ``` - [ ] **Step 4: Add ORM model** Modify `backend/app/models/org.py` after `AiAssistantConfig`: ```python class SystemConfig(Base): __tablename__ = "sys_system_config" id: Mapped[int] = mapped_column(BigInteger, primary_key=True) config_code: Mapped[str] = mapped_column(String(100)) config_name: Mapped[str] = mapped_column(String(100)) config_value: Mapped[str] = mapped_column(String(500)) remark: Mapped[str | None] = mapped_column(String(500), nullable=True) status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'")) created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True) created_at: Mapped[object] = mapped_column(DateTime) updated_at: Mapped[object] = mapped_column(DateTime) ``` - [ ] **Step 5: Add config service** Create `backend/app/services/system_config.py`: ```python from __future__ import annotations import re from datetime import datetime from sqlalchemy import select from sqlalchemy.orm import Session from app.models.org import SystemConfig RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE = "RAW_MATERIAL_LOT_PREFIX" RAW_MATERIAL_LOT_PREFIX_DEFAULT = "YL" def normalize_raw_material_lot_prefix(value: object | None) -> str: normalized = "".join(re.findall(r"[A-Za-z0-9]", str(value or "").upper())) return normalized or RAW_MATERIAL_LOT_PREFIX_DEFAULT def get_system_config_value(db: Session, config_code: str, default: str = "") -> str: row = db.scalar( select(SystemConfig) .where(SystemConfig.config_code == config_code, SystemConfig.status == "ACTIVE") .limit(1) ) return str(row.config_value if row else default) def get_raw_material_lot_prefix(db: Session) -> str: return normalize_raw_material_lot_prefix( get_system_config_value(db, RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, RAW_MATERIAL_LOT_PREFIX_DEFAULT) ) def upsert_system_config( db: Session, *, config_code: str, config_name: str, config_value: object, remark: str | None = None, updated_by: int | None = None, now: datetime | None = None, ) -> SystemConfig: current_time = now or datetime.now() normalized_value = ( normalize_raw_material_lot_prefix(config_value) if config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE else str(config_value or "").strip() ) row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == config_code).limit(1)) if not row: row = SystemConfig( config_code=config_code, config_name=config_name, config_value=normalized_value, remark=remark, status="ACTIVE", created_by=updated_by, updated_by=updated_by, created_at=current_time, updated_at=current_time, ) else: row.config_name = config_name row.config_value = normalized_value row.remark = remark row.status = "ACTIVE" row.updated_by = updated_by row.updated_at = current_time db.add(row) db.flush() return row ``` - [ ] **Step 6: Add schemas and API endpoints** Modify `backend/app/schemas/database.py`: ```python class SystemConfigUpdate(BaseModel): config_value: str = Field(min_length=1, max_length=500) remark: str | None = None class SystemConfigRead(BaseModel): config_code: str config_name: str config_value: str remark: str | None = None updated_at: datetime | None = None ``` Modify `backend/app/api/routes/system_extension.py` imports: ```python from app.models.org import AiAssistantConfig, SystemConfig from app.schemas.database import SystemConfigRead, SystemConfigUpdate from app.services.system_config import RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, get_raw_material_lot_prefix, upsert_system_config ``` Add endpoints near assistant config endpoints: ```python def _system_config_read(row: SystemConfig | None, *, config_code: str, config_name: str, default_value: str) -> SystemConfigRead: return SystemConfigRead( config_code=config_code, config_name=row.config_name if row else config_name, config_value=row.config_value if row else default_value, remark=row.remark if row else None, updated_at=row.updated_at if row else None, ) @router.get("/raw-material-lot-prefix", response_model=SystemConfigRead) def get_raw_material_lot_prefix_config( context: AuthContext = Depends(require_system_admin), db: Session = Depends(get_db), ) -> SystemConfigRead: _ = context row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE).limit(1)) return _system_config_read( row, config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, config_name="原材料库存批次号前缀", default_value=get_raw_material_lot_prefix(db), ) @router.put("/raw-material-lot-prefix", response_model=SystemConfigRead) def save_raw_material_lot_prefix_config( payload: SystemConfigUpdate, context: AuthContext = Depends(require_system_admin), db: Session = Depends(get_db), ) -> SystemConfigRead: row = upsert_system_config( db, config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, config_name="原材料库存批次号前缀", config_value=payload.config_value, remark=payload.remark or "原材料库存批次号默认前缀,例如 YL0001", updated_by=context.user.id, ) db.commit() db.refresh(row) return _system_config_read( row, config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, config_name="原材料库存批次号前缀", default_value=get_raw_material_lot_prefix(db), ) ``` - [ ] **Step 7: Run config tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_system_config_raw_lot_prefix.py -q ``` Expected: PASS. --- ### Task 2: Change Raw Material Lot Numbering To Global Prefix Sequence **Files:** - Modify: `backend/app/services/operations.py` - Modify: `backend/tests/test_inventory_lot_numbering.py` - [ ] **Step 1: Replace old numbering tests with new global sequence tests** Modify `backend/tests/test_inventory_lot_numbering.py` to assert: ```python def test_inventory_lot_no_starts_from_global_raw_sequence(self) -> None: self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0001") def test_inventory_lot_no_increments_across_materials(self) -> None: other_material = Item(id=2, item_code="原材料00002", item_name="冷轧板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE") self.db.add(other_material) self.db.add( StockLot( lot_no="YL0001", lot_role="INBOUND_RAW", item_id=other_material.id, warehouse_id=self.warehouse.id, source_doc_type="TEST", source_doc_id=1, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("100"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("5"), quality_status="PASS", status="AVAILABLE", ) ) self.db.commit() self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0002") def test_inventory_lot_no_expands_after_9999(self) -> None: self.db.add( StockLot( lot_no="YL9999", lot_role="INBOUND_RAW", item_id=self.material.id, warehouse_id=self.warehouse.id, source_doc_type="TEST", source_doc_id=1, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("100"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("5"), quality_status="PASS", status="AVAILABLE", ) ) self.db.commit() self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL10000") ``` - [ ] **Step 2: Run numbering tests to verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_inventory_lot_numbering.py -q ``` Expected: FAIL because `build_inventory_lot_no` still returns `JH_物料名称编码_0001`. - [ ] **Step 3: Implement global raw lot number helper** Modify `backend/app/services/operations.py` imports: ```python from app.services.system_config import get_raw_material_lot_prefix ``` Replace `_build_inventory_lot_prefix` and `build_inventory_lot_no` with: ```python def _raw_lot_sequence_width(next_sequence: int) -> int: return 4 if next_sequence <= 9999 else len(str(next_sequence)) def build_inventory_lot_no(db: Session, material_item_id: int) -> str: _ = material_item_id prefix = get_raw_material_lot_prefix(db) existing_lot_nos = db.scalars(select(StockLot.lot_no).where(StockLot.lot_no.like(f"{prefix}%"))).all() max_sequence = 0 for lot_no in existing_lot_nos: match = re.fullmatch(rf"{re.escape(prefix)}(\d+)", str(lot_no or "")) if match: max_sequence = max(max_sequence, int(match.group(1))) sequence = max_sequence + 1 candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}" while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)): sequence += 1 candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}" return candidate ``` - [ ] **Step 4: Run numbering tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_inventory_lot_numbering.py tests/test_system_config_raw_lot_prefix.py -q ``` Expected: PASS. --- ### Task 3: Apply New Lot Numbering To Raw Inbound Paths **Files:** - Modify: `backend/app/api/routes/inventory.py` - Modify: `backend/app/api/routes/purchase.py` - Modify: `backend/app/services/special_inventory_adjustment.py` - Modify: `backend/tests/test_customer_supplied_inbound_lot_no.py` - Modify: `backend/tests/test_opening_inventory_import.py` - Modify: `backend/tests/test_special_warehouse_adjustment.py` - [ ] **Step 1: Write or update tests for raw inbound paths** Update assertions so raw inbound paths generate `YL0001`, `YL0002` and never hand-entered material-name batch numbers. For `backend/tests/test_customer_supplied_inbound_lot_no.py`, assert: ```python self.assertEqual(created_lot.lot_no, "YL0001") self.assertEqual(second_created_lot.lot_no, "YL0002") ``` For `backend/tests/test_opening_inventory_import.py`, assert imported raw opening lots use: ```python self.assertEqual([lot.lot_no for lot in raw_lots], ["YL0001", "YL0002"]) ``` For `backend/tests/test_special_warehouse_adjustment.py`, assert special raw inbound uses: ```python self.assertRegex(raw_lot.lot_no, r"^YL\d{4,}$") ``` - [ ] **Step 2: Run inbound tests to verify failures** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest \ tests/test_customer_supplied_inbound_lot_no.py \ tests/test_opening_inventory_import.py \ tests/test_special_warehouse_adjustment.py \ -q ``` Expected: FAIL where old raw-specific lot builders still produce `KLRK` or material-name lot numbers. - [ ] **Step 3: Route raw warehouse generic inbound through `build_inventory_lot_no`** Modify `backend/app/api/routes/inventory.py` in the `# build lot_no` block: ```python if not lot_no: if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}: lot_no = build_inventory_lot_no(db, item.id) elif biz_type in ("OPENING",): lot_no = _build_opening_lot_no(db, item, warehouse) elif biz_type in ("PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"): tag = "SCSY" if biz_type == "PRODUCTION_SURPLUS" else "WWSY" lot_no = _build_surplus_lot_no(db, item, warehouse, tag) elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN", "REWORK_SCRAP_IN"): tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "FWFL" if biz_type == "REWORK_SCRAP_IN" else "WWFL" lot_no = _build_surplus_lot_no(db, item, warehouse, tag) elif biz_type == "WIP_IN": lot_no = _build_wip_lot_no(db, item, warehouse) elif biz_type == "PRODUCTION_FINISHED": lot_no = _build_finished_lot_no(db, item, warehouse) elif biz_type == "OUTSOURCING_IN": lot_no = _build_surplus_lot_no(db, item, warehouse, "WWJK") elif biz_type == "CUSTOMER_SUPPLIED": lot_no = _build_customer_supplied_lot_no(db, item) else: lot_no = _build_opening_lot_no(db, item, warehouse) ``` Confirm `backend/app/api/routes/purchase.py` and `backend/app/services/special_inventory_adjustment.py` call `build_inventory_lot_no(db, item_id)` for raw material lots. If either file has a custom raw lot builder, replace that call with `build_inventory_lot_no`. - [ ] **Step 4: Run inbound tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest \ tests/test_customer_supplied_inbound_lot_no.py \ tests/test_opening_inventory_import.py \ tests/test_special_warehouse_adjustment.py \ tests/test_inventory_lot_numbering.py \ -q ``` Expected: PASS. --- ### Task 4: Enforce Single-Lot Production Outbound And Merge Daily Work Orders **Files:** - Modify: `backend/app/services/operations.py` - Modify: `backend/app/api/routes/production.py` - Modify: `backend/tests/test_selected_stock_lot_production_issue.py` - [ ] **Step 1: Update production issue tests for new rules** In `backend/tests/test_selected_stock_lot_production_issue.py`, change lot numbers in seed data to `YL0001` and `YL0002`. Replace old yearly sequence tests with: ```python def test_create_work_order_no_uses_date_lot_and_product_name(self) -> None: class FrozenDateTime(datetime): @classmethod def now(cls, tz=None): # noqa: ANN001 _ = tz return cls(2026, 6, 7, 9, 15, 0) with patch.object(production_routes, "datetime", FrozenDateTime): result = create_work_order( WorkOrderCreate( product_item_id=self.product.id, selected_stock_lots=[ SelectedStockLotIssueCreate( source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10"), ) ], ), context=SimpleNamespace(user=SimpleNamespace(id=1)), db=self.db, ) self.assertEqual(result.work_order_no, f"20260607-{self.lot1.lot_no}-{self.product.item_name}") ``` Add merge test: ```python def test_same_day_same_lot_same_product_merges_into_existing_work_order(self) -> None: class FrozenDateTime(datetime): @classmethod def now(cls, tz=None): # noqa: ANN001 _ = tz return cls(2026, 6, 7, 9, 15, 0) payload = WorkOrderCreate( product_item_id=self.product.id, selected_stock_lots=[ SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")) ], ) with patch.object(production_routes, "datetime", FrozenDateTime): first = create_work_order(payload, context=SimpleNamespace(user=SimpleNamespace(id=1)), db=self.db) second = create_work_order( WorkOrderCreate( product_item_id=self.product.id, selected_stock_lots=[ SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("8")) ], ), context=SimpleNamespace(user=SimpleNamespace(id=1)), db=self.db, ) self.assertEqual(second.work_order_id, first.work_order_id) issues = self.db.scalars(select(WorkOrderMaterialIssue).where(WorkOrderMaterialIssue.work_order_id == first.work_order_id)).all() self.assertEqual(len(issues), 2) material_row = self.db.scalar(select(WorkOrderMaterial).where(WorkOrderMaterial.work_order_id == first.work_order_id)) self.assertEqual(float(material_row.issued_weight_kg), 18) ``` Add rejection test: ```python def test_create_work_order_rejects_multiple_stock_lots(self) -> None: with self.assertRaises(HTTPException) as exc: create_work_order( WorkOrderCreate( product_item_id=self.product.id, selected_stock_lots=[ SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")), SelectedStockLotIssueCreate(source_lot_id=self.lot2.id, material_item_id=self.material.id, issued_weight_kg=Decimal("5")), ], ), context=SimpleNamespace(user=SimpleNamespace(id=1)), db=self.db, ) self.assertEqual(exc.exception.status_code, 400) self.assertIn("一次生产出库只能选择一个材料库存批次号", exc.exception.detail) ``` Replace the old “requires lots for all BOM materials” expectation with a test that only the selected material is issued and other BOM rows remain unissued. - [ ] **Step 2: Run focused production issue tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py -q ``` Expected: FAIL on old multi-lot and old work-order number behavior. - [ ] **Step 3: Add deterministic production work-order number helper** Modify `backend/app/services/operations.py`: ```python def build_raw_lot_product_work_order_no(*, biz_time: datetime, lot_no: str, product: Item) -> str: date_part = biz_time.strftime("%Y%m%d") product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}" prefix = f"{date_part}-{lot_no}-" max_product_name_length = max(80 - len(prefix), 1) return f"{prefix}{product_name[:max_product_name_length]}" ``` - [ ] **Step 4: Refactor `create_work_order` to validate one selected lot** In `backend/app/api/routes/production.py`, after building `selected_lots`, add: ```python if len(selected_lots) != 1: raise HTTPException(status_code=400, detail="一次生产出库只能选择一个材料库存批次号") ``` Remove the requirement that every BOM material must have a selected lot. Replace it with: ```python selected_lot = selected_lot_by_id[int(selected_lots[0]["source_lot_id"])] selected_material_item_id = int(selected_lots[0]["material_item_id"] or selected_lot.item_id) selected_bom_item = next((item for item in bom_items if int(item.material_item_id) == selected_material_item_id), None) if not selected_bom_item: raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 不属于该产品需规默认用料") ``` - [ ] **Step 5: Implement same-day same-lot same-product merge** In `backend/app/api/routes/production.py`, before creating a new `WorkOrder`, compute: ```python now = datetime.now() work_order_no = build_raw_lot_product_work_order_no(biz_time=now, lot_no=selected_lot.lot_no, product=product) existing_work_order = db.scalar(select(WorkOrder).where(WorkOrder.work_order_no == work_order_no).limit(1)) if existing_work_order and str(existing_work_order.status or "").upper() == "SETTLED": raise HTTPException(status_code=400, detail="该库存批次今日对应产品的生产工单已结单,不能继续合并生产出库") ``` If `existing_work_order` is not settled, use it instead of adding a new one: ```python if existing_work_order: work_order = existing_work_order else: work_order = WorkOrder( work_order_no=work_order_no, work_order_type="NORMAL", source_sales_order_item_id=payload.source_sales_order_item_id, product_item_id=payload.product_item_id, route_id=route.id, bom_id=bom.id, planned_qty=planned_qty, released_qty=planned_qty, finished_qty=to_decimal(0), scrap_qty=to_decimal(0), planned_start_time=payload.planned_start_time or now, planned_end_time=payload.planned_end_time, actual_start_time=None, actual_end_time=None, priority_level=payload.priority_level or 3, status="RELEASED", remark=payload.remark, ) db.add(work_order) db.flush() create_work_order_materials_and_operations( db, work_order=work_order, bom=bom, route=route, auto_issue_materials=False, operator_user_id=context.user.id, issue_weight_override=None, ) ``` When applying the selected lot issue, update the selected material row cumulatively: ```python material_row.issued_qty = Decimal("0") material_row.issued_weight_kg = to_decimal(material_row.issued_weight_kg) + selected_lots[0]["issued_weight_kg"] material_row.required_weight_kg = material_row.issued_weight_kg material_row.status = "ISSUED" db.add(material_row) sync_work_order_reference_qty(db, work_order.id) ``` Ensure `build_raw_lot_product_work_order_no` is imported from `app.services.operations`. - [ ] **Step 6: Run production issue tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py tests/test_production_work_order_ledger.py -q ``` Expected: PASS. --- ### Task 5: Match Miniapp Reports By Material Stock Lot Number **Files:** - Modify: `backend/app/api/routes/production.py` - Modify: `backend/tests/test_selected_stock_lot_production_issue.py` - 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` - [ ] **Step 1: Update ERP miniapp resolver tests** Replace `JH0001` tests with `YL0001` tests: ```python def test_miniapp_material_lot_no_resolves_unsettled_work_order(self) -> None: work_order = self._add_issued_work_order( work_order_id=1, work_order_no=f"20260607-{self.lot1.lot_no}-{self.product.item_name}", product=self.product, issue_id=10, material_row_id=10, ) self._add_work_order_operation(operation_id=101, work_order_id=work_order.id, operation_name="1序") self.db.commit() matched = _find_work_order_for_miniapp_item( self.db, { "raw_material_batch_no": self.lot1.lot_no, "product_name": self.product.item_name, "project_no": self.product.item_code, }, ) self.assertEqual(matched.id, work_order.id) ``` Add settled ignore test: ```python def test_miniapp_material_lot_no_ignores_settled_work_order(self) -> None: self._add_issued_work_order( work_order_id=1, work_order_no=f"20260607-{self.lot1.lot_no}-{self.product.item_name}", product=self.product, issue_id=10, material_row_id=10, ).status = "SETTLED" self.db.commit() matched = _find_work_order_for_miniapp_item( self.db, { "raw_material_batch_no": self.lot1.lot_no, "product_name": self.product.item_name, "project_no": self.product.item_code, }, ) self.assertIsNone(matched) ``` Keep one compatibility test for old `JH0001` short code if existing historical data still needs syncing. - [ ] **Step 2: Run ERP miniapp resolver tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py -q ``` Expected: FAIL because resolver still prioritizes `JH0001` short codes and does not filter settled work orders in lot-based candidate selection. - [ ] **Step 3: Update ERP resolver** Modify `_select_miniapp_issue_candidate` in `backend/app/api/routes/production.py` to receive only unsettled work orders: ```python product_rows = db.execute( select(WorkOrder.id, Item.item_name, Item.item_code) .join(Item, Item.id == WorkOrder.product_item_id) .where( WorkOrder.id.in_(latest_by_work_order), func.upper(func.coalesce(WorkOrder.status, "")) != "SETTLED", ) ).all() ``` Modify `_resolve_work_order_issue_by_inventory_lot_no` to join `WorkOrder` and ignore settled: ```python candidates = db.scalars( select(WorkOrderMaterialIssue) .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id) .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id) .where( StockLot.lot_no == lot_no, func.upper(func.coalesce(WorkOrder.status, "")) != "SETTLED", ) .order_by(WorkOrderMaterialIssue.id.desc()) ).all() ``` Modify `_find_work_order_for_miniapp_item` to resolve stock lot numbers before `JH0001` compatibility: ```python issue, has_issue_candidates = _resolve_work_order_issue_by_inventory_lot_no(db, batch_no, row) work_order = db.get(WorkOrder, issue.work_order_id) if issue else None if work_order: if cache is not None: cache["work_order"][cache_key] = work_order return work_order if _is_miniapp_work_order_short_code(batch_no): work_order = _resolve_work_order_by_miniapp_short_code(db, batch_no, row) if cache is not None: cache["work_order"][cache_key] = work_order return work_order ``` Keep the direct work-order-number fallback only when `has_issue_candidates` is false. - [ ] **Step 4: Update miniapp backend option query** Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`. Replace `_miniapp_work_order_short_code` usage in `_raw_material_batch_options` with a query that returns source stock lot numbers: ```python rows = db.execute( text( """ select distinct sl.lot_no from pp_work_order wo join pp_work_order_operation op on op.work_order_id = wo.id join md_item product on product.id = wo.product_item_id join pp_work_order_material_issue issue on issue.work_order_id = wo.id join wh_stock_lot sl on sl.id = issue.source_lot_id where sl.lot_no is not null and sl.lot_no <> '' and upper(coalesce(wo.status, '')) <> 'SETTLED' and (:product_name = '' or product.item_name = :product_name) and (:project_no = '' or product.item_code = :project_no) and (:process_name = '' or op.operation_name = :process_name) order by sl.lot_no asc """ ), { "product_name": product_name, "project_no": project_no, "process_name": process_name, }, ).all() return [str(row[0]) for row in rows if row[0]] ``` If the miniapp backend connects to the ERP schema through a different SQLAlchemy engine name, use the existing `_erp_engine()` or session helper already used by the current `_raw_material_batch_options` function. - [ ] **Step 5: Update miniapp backend tests** Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`: ```python def test_raw_material_batch_options_returns_material_stock_lots_for_product_process(monkeypatch): # seed pp_work_order, pp_work_order_operation, md_item, pp_work_order_material_issue, wh_stock_lot options = reports._raw_material_batch_options( product_name="钢制碗", project_no="", process_name="1序", ) assert options == ["YL0001", "YL0002"] ``` Also update the settled test to assert settled work orders do not return their lot number. - [ ] **Step 6: Run ERP and miniapp backend tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py -q cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint python -m pytest tests/test_work_order_batch_options.py -q ``` Expected: PASS. --- ### Task 6: Add Production Work-Order Inbound Backend API **Files:** - Create: `backend/app/services/production_work_order_inbound.py` - Modify: `backend/app/schemas/operations.py` - Modify: `backend/app/api/routes/production.py` - Test: `backend/tests/test_production_work_order_inbound.py` - [ ] **Step 1: Write backend tests for preview, deviation, posting, and settle** Create `backend/tests/test_production_work_order_inbound.py` with the same SQLite setup pattern as `test_production_work_order_ledger.py`. Core tests: ```python def test_preview_calculates_default_surplus_and_scrap_from_finished_qty(self) -> None: preview = preview_production_work_order_inbound(self.db, work_order_id=1, finished_qty=Decimal("198")) self.assertEqual(float(preview.theoretical_surplus_weight_kg), 81.2) self.assertEqual(float(preview.theoretical_scrap_weight_kg), 23.8) def test_submit_requires_deviation_remark_when_surplus_diff_exceeds_15_percent(self) -> None: with self.assertRaises(HTTPException) as exc: create_production_work_order_inbound( self.db, payload=ProductionWorkOrderInboundCreate( work_order_id=1, finished_warehouse_id=2, scrap_warehouse_id=3, finished_qty=198, surplus_weight_kg=120, scrap_weight_kg=23.8, deviation_remark=None, settle_work_order=False, ), operator_user_id=1, ) self.assertIn("偏差说明", exc.exception.detail) def test_submit_posts_finished_surplus_and_scrap_in_one_transaction(self) -> None: result = create_production_work_order_inbound( self.db, payload=ProductionWorkOrderInboundCreate( work_order_id=1, finished_warehouse_id=2, scrap_warehouse_id=3, finished_qty=198, surplus_weight_kg=80, scrap_weight_kg=24, deviation_remark=None, settle_work_order=False, ), operator_user_id=1, ) self.assertEqual(result.work_order_id, 1) self.assertEqual(float(self.db.get(WorkOrder, 1).finished_qty), 198) self.assertEqual(float(self.db.get(StockLot, 1).remaining_weight_kg), 380) txn_types = [row.txn_type for row in self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all()] self.assertIn("FG_IN", txn_types) self.assertIn("PRODUCTION_SURPLUS_IN", txn_types) self.assertIn("PRODUCTION_SCRAP_IN", txn_types) def test_submit_settles_work_order_only_when_requested(self) -> None: create_production_work_order_inbound( self.db, payload=ProductionWorkOrderInboundCreate( work_order_id=1, finished_warehouse_id=2, scrap_warehouse_id=3, finished_qty=198, surplus_weight_kg=80, scrap_weight_kg=24, settle_work_order=True, settle_confirmed=True, deviation_remark=None, ), operator_user_id=1, ) self.assertEqual(self.db.get(WorkOrder, 1).status, "SETTLED") ``` - [ ] **Step 2: Run production inbound tests to verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_production_work_order_inbound.py -q ``` Expected: FAIL because schemas and service do not exist. - [ ] **Step 3: Add schemas** Modify `backend/app/schemas/operations.py`: ```python class ProductionWorkOrderInboundPreviewRead(BaseModel): work_order_id: int work_order_no: str product_item_id: int product_name: str source_lot_id: int source_lot_no: str issued_weight_kg: float gross_weight_per_piece_kg: float net_weight_per_piece_kg: float reported_good_qty: float reported_scrap_qty: float finished_received_qty: float surplus_returned_weight_kg: float scrap_inbound_weight_kg: float theoretical_finished_qty: float theoretical_surplus_weight_kg: float theoretical_scrap_weight_kg: float class ProductionWorkOrderInboundCreate(BaseModel): work_order_id: int = Field(gt=0) finished_warehouse_id: int = Field(gt=0) scrap_warehouse_id: int = Field(gt=0) finished_qty: float = 0 surplus_weight_kg: float = 0 scrap_weight_kg: float = 0 deviation_remark: str | None = None remark: str | None = None settle_work_order: bool = False settle_confirmed: bool = False class ProductionWorkOrderInboundRead(BaseModel): work_order_id: int work_order_no: str finished_qty: float surplus_weight_kg: float scrap_weight_kg: float deviation_remark: str | None = None settled: bool message: str ``` - [ ] **Step 4: Implement calculation helpers** Create `backend/app/services/production_work_order_inbound.py`: ```python from __future__ import annotations from decimal import Decimal from fastapi import HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from app.models.master_data import BomItem, Item, StockBalance, Warehouse from app.models.operations import InventoryTxn, StockLot, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue from app.schemas.operations import ProductionWorkOrderInboundCreate, ProductionWorkOrderInboundPreviewRead, ProductionWorkOrderInboundRead from app.services.operations import clamp_non_negative, create_inventory_txn, get_location_or_default, to_decimal, upsert_stock_balance from app.services.production_work_order_ledger import get_work_order_limit_snapshot DEVIATION_RATE = Decimal("0.15") def _deviation_exceeds_limit(actual: Decimal, theoretical: Decimal) -> bool: if theoretical <= 0: return actual > 0 return abs(actual - theoretical) / theoretical > DEVIATION_RATE def _source_issue(db: Session, work_order_id: int) -> WorkOrderMaterialIssue: issue = db.scalar( select(WorkOrderMaterialIssue) .where(WorkOrderMaterialIssue.work_order_id == work_order_id) .order_by(WorkOrderMaterialIssue.id.asc()) .limit(1) ) if not issue: raise HTTPException(status_code=400, detail="该工单没有材料库存批次领用记录,不能进行生产工单入库") return issue def preview_production_work_order_inbound( db: Session, *, work_order_id: int, finished_qty: Decimal | None = None, ) -> ProductionWorkOrderInboundPreviewRead: work_order = db.get(WorkOrder, work_order_id) if not work_order: raise HTTPException(status_code=404, detail="生产工单不存在") if str(work_order.status or "").upper() == "SETTLED": raise HTTPException(status_code=400, detail="该生产工单已结单,不能继续入库") product = db.get(Item, work_order.product_item_id) issue = _source_issue(db, work_order_id) source_lot = db.get(StockLot, issue.source_lot_id) snapshot = get_work_order_limit_snapshot(db, work_order_id) bom_item = db.scalar( select(BomItem) .join(WorkOrderMaterial, WorkOrderMaterial.bom_item_id == BomItem.id) .where(WorkOrderMaterial.work_order_id == work_order_id, WorkOrderMaterial.material_item_id == issue.material_item_id) .limit(1) ) gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0) net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0) default_finished_qty = to_decimal(finished_qty if finished_qty is not None else snapshot.max_finished_inbound_qty) issued_weight = to_decimal(snapshot.issued_weight_kg) returned_weight = to_decimal(snapshot.returned_material_weight_kg) scrap_inbound_weight = to_decimal(snapshot.scrap_inbound_weight_kg) reported_scrap_qty = to_decimal(snapshot.total_operation_scrap_qty) theoretical_surplus = clamp_non_negative(issued_weight - returned_weight - default_finished_qty * gross) theoretical_scrap = clamp_non_negative(default_finished_qty * clamp_non_negative(gross - net) + reported_scrap_qty * gross - scrap_inbound_weight) return ProductionWorkOrderInboundPreviewRead( work_order_id=work_order.id, work_order_no=work_order.work_order_no, product_item_id=work_order.product_item_id, product_name=product.item_name if product else "", source_lot_id=source_lot.id if source_lot else issue.source_lot_id, source_lot_no=source_lot.lot_no if source_lot else issue.material_sub_batch_no, issued_weight_kg=float(issued_weight), gross_weight_per_piece_kg=float(gross), net_weight_per_piece_kg=float(net), reported_good_qty=float(snapshot.final_operation_good_qty), reported_scrap_qty=float(reported_scrap_qty), finished_received_qty=float(snapshot.finished_received_qty), surplus_returned_weight_kg=float(returned_weight), scrap_inbound_weight_kg=float(scrap_inbound_weight), theoretical_finished_qty=float(snapshot.max_finished_inbound_qty), theoretical_surplus_weight_kg=float(theoretical_surplus), theoretical_scrap_weight_kg=float(theoretical_scrap), ) ``` - [ ] **Step 5: Implement transactional posting helpers** Continue in `backend/app/services/production_work_order_inbound.py` with helper functions that are called by the final submit function: ```python def _remark(base: str, payload: ProductionWorkOrderInboundCreate) -> str: parts = [base] if payload.deviation_remark: parts.append(f"入库偏差说明:{payload.deviation_remark}") if payload.remark: parts.append(payload.remark) return ";".join(part for part in parts if part) def _source_lot(db: Session, preview: ProductionWorkOrderInboundPreviewRead) -> StockLot: source_lot = db.get(StockLot, preview.source_lot_id) if not source_lot: raise HTTPException(status_code=404, detail="来源材料库存批次号不存在") return source_lot def _post_surplus_return( db: Session, *, work_order: WorkOrder, preview: ProductionWorkOrderInboundPreviewRead, payload: ProductionWorkOrderInboundCreate, surplus_weight: Decimal, operator_user_id: int | None, ) -> None: source_lot = _source_lot(db, preview) source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight if source_lot.status == "DEPLETED" and to_decimal(source_lot.remaining_weight_kg) > 0: source_lot.status = "AVAILABLE" db.add(source_lot) material_row = db.scalar( select(WorkOrderMaterial) .where(WorkOrderMaterial.work_order_id == work_order.id, WorkOrderMaterial.material_item_id == source_lot.item_id) .limit(1) ) if material_row: material_row.returned_weight_kg = to_decimal(material_row.returned_weight_kg) + surplus_weight db.add(material_row) upsert_stock_balance( db, item_id=source_lot.item_id, warehouse_id=source_lot.warehouse_id, location_id=source_lot.location_id, qty_delta=Decimal("0"), weight_delta=surplus_weight, available_qty_delta=Decimal("0"), available_weight_delta=surplus_weight, unit_cost=to_decimal(source_lot.unit_cost, "0.0001"), ) create_inventory_txn( db, txn_type="PRODUCTION_SURPLUS_IN", item_id=source_lot.item_id, warehouse_id=source_lot.warehouse_id, location_id=source_lot.location_id, lot_id=source_lot.id, qty_change=Decimal("0"), weight_change=surplus_weight, unit_cost=to_decimal(source_lot.unit_cost, "0.0001"), source_doc_type="WORK_ORDER", source_doc_id=work_order.id, source_line_id=source_lot.id, biz_time=datetime.now(), operator_user_id=operator_user_id, remark=_remark("生产工单入库:生产余料入库", payload), amount_basis="WEIGHT", ) def _post_finished_inbound( db: Session, *, work_order: WorkOrder, preview: ProductionWorkOrderInboundPreviewRead, payload: ProductionWorkOrderInboundCreate, finished_qty: Decimal, operator_user_id: int | None, ) -> None: product = db.get(Item, work_order.product_item_id) if not product: raise HTTPException(status_code=404, detail="工单产品不存在") warehouse = db.get(Warehouse, payload.finished_warehouse_id) if not warehouse or str(warehouse.warehouse_type or "").upper() != "FINISHED": raise HTTPException(status_code=400, detail="生产工单入库必须选择成品库") location = get_location_or_default(db, payload.finished_warehouse_id, None) source_lot = _source_lot(db, preview) gross_weight = to_decimal(preview.gross_weight_per_piece_kg) receipt_weight = finished_qty * gross_weight unit_cost = to_decimal(source_lot.unit_cost, "0.0001") * gross_weight lot_no = f"CP-{work_order.work_order_no}-{int(to_decimal(work_order.finished_qty) + finished_qty)}" lot = StockLot( lot_no=lot_no[:50], parent_lot_id=source_lot.id, lot_role="FINISHED_FROM_RAW_BATCH", material_sub_batch_no=None, item_id=product.id, warehouse_id=warehouse.id, location_id=location.id if location else None, source_doc_type="WORK_ORDER_INBOUND", source_doc_id=work_order.id, source_line_id=None, source_material_lot_id=source_lot.id, source_material_sub_batch_no=source_lot.lot_no, source_material_summary=f"来源库存批次号:{source_lot.lot_no}", inbound_qty=finished_qty, inbound_weight_kg=receipt_weight, remaining_qty=finished_qty, remaining_weight_kg=receipt_weight, locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=unit_cost, quality_status="PASS", status="AVAILABLE", remark=_remark("生产工单入库:成品入库", payload), ) db.add(lot) db.flush() upsert_stock_balance( db, item_id=product.id, warehouse_id=warehouse.id, location_id=location.id if location else None, qty_delta=finished_qty, weight_delta=receipt_weight, available_qty_delta=finished_qty, available_weight_delta=receipt_weight, unit_cost=unit_cost, ) create_inventory_txn( db, txn_type="FG_IN", item_id=product.id, warehouse_id=warehouse.id, location_id=location.id if location else None, lot_id=lot.id, qty_change=finished_qty, weight_change=receipt_weight, unit_cost=unit_cost, source_doc_type="WORK_ORDER", source_doc_id=work_order.id, source_line_id=None, biz_time=datetime.now(), operator_user_id=operator_user_id, remark=_remark("生产工单入库:成品入库", payload), amount_basis="QTY", ) work_order.finished_qty = to_decimal(work_order.finished_qty) + finished_qty db.add(work_order) def _post_scrap_inbound( db: Session, *, work_order: WorkOrder, preview: ProductionWorkOrderInboundPreviewRead, payload: ProductionWorkOrderInboundCreate, scrap_weight: Decimal, operator_user_id: int | None, ) -> None: source_lot = _source_lot(db, preview) warehouse = db.get(Warehouse, payload.scrap_warehouse_id) if not warehouse or str(warehouse.warehouse_type or "").upper() != "SCRAP": raise HTTPException(status_code=400, detail="生产工单入库必须选择废料库") location = get_location_or_default(db, payload.scrap_warehouse_id, None) lot = StockLot( lot_no=f"FL-{work_order.work_order_no}"[:50], parent_lot_id=source_lot.id, lot_role="PRODUCTION_SCRAP", material_sub_batch_no=None, item_id=source_lot.item_id, warehouse_id=warehouse.id, location_id=location.id if location else None, source_doc_type="WORK_ORDER", source_doc_id=work_order.id, source_line_id=None, source_material_lot_id=source_lot.id, source_material_sub_batch_no=source_lot.lot_no, source_material_summary=f"来源库存批次号:{source_lot.lot_no}", inbound_qty=Decimal("0"), inbound_weight_kg=scrap_weight, remaining_qty=Decimal("0"), remaining_weight_kg=scrap_weight, locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=to_decimal(source_lot.unit_cost, "0.0001"), quality_status="PASS", status="AVAILABLE", remark=_remark("生产工单入库:生产废料入库", payload), ) db.add(lot) db.flush() upsert_stock_balance( db, item_id=source_lot.item_id, warehouse_id=warehouse.id, location_id=location.id if location else None, qty_delta=Decimal("0"), weight_delta=scrap_weight, available_qty_delta=Decimal("0"), available_weight_delta=scrap_weight, unit_cost=to_decimal(source_lot.unit_cost, "0.0001"), ) create_inventory_txn( db, txn_type="PRODUCTION_SCRAP_IN", item_id=source_lot.item_id, warehouse_id=warehouse.id, location_id=location.id if location else None, lot_id=lot.id, qty_change=Decimal("0"), weight_change=scrap_weight, unit_cost=to_decimal(source_lot.unit_cost, "0.0001"), source_doc_type="WORK_ORDER", source_doc_id=work_order.id, source_line_id=None, biz_time=datetime.now(), operator_user_id=operator_user_id, remark=_remark("生产工单入库:生产废料入库", payload), amount_basis="WEIGHT", ) ``` - [ ] **Step 6: Implement submit function** Continue in `backend/app/services/production_work_order_inbound.py`: ```python def create_production_work_order_inbound( db: Session, *, payload: ProductionWorkOrderInboundCreate, operator_user_id: int | None, ) -> ProductionWorkOrderInboundRead: work_order = db.get(WorkOrder, payload.work_order_id) if not work_order: raise HTTPException(status_code=404, detail="生产工单不存在") if str(work_order.status or "").upper() == "SETTLED": raise HTTPException(status_code=400, detail="该生产工单已结单,不能继续入库") finished_qty = to_decimal(payload.finished_qty) surplus_weight = to_decimal(payload.surplus_weight_kg) scrap_weight = to_decimal(payload.scrap_weight_kg) if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0: raise HTTPException(status_code=400, detail="本次至少填写一项入库数据") preview = preview_production_work_order_inbound(db, work_order_id=payload.work_order_id, finished_qty=finished_qty) if ( _deviation_exceeds_limit(surplus_weight, to_decimal(preview.theoretical_surplus_weight_kg)) or _deviation_exceeds_limit(scrap_weight, to_decimal(preview.theoretical_scrap_weight_kg)) ) and not str(payload.deviation_remark or "").strip(): raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明") if payload.settle_work_order and not payload.settle_confirmed: raise HTTPException(status_code=400, detail="结单前必须确认本次入库明细及结单影响") if finished_qty > 0: _post_finished_inbound(db, work_order=work_order, preview=preview, payload=payload, finished_qty=finished_qty, operator_user_id=operator_user_id) if surplus_weight > 0: _post_surplus_return(db, work_order=work_order, preview=preview, payload=payload, surplus_weight=surplus_weight, operator_user_id=operator_user_id) if scrap_weight > 0: _post_scrap_inbound(db, work_order=work_order, preview=preview, payload=payload, scrap_weight=scrap_weight, operator_user_id=operator_user_id) if payload.settle_work_order: work_order.status = "SETTLED" work_order.actual_end_time = work_order.actual_end_time or datetime.now() work_order.updated_at = datetime.now() parts = [part.strip() for part in str(work_order.remark or "").replace(";", ";").split(";") if part.strip()] parts.append("生产工单入库已结单") if payload.deviation_remark: parts.append(f"入库偏差说明:{payload.deviation_remark}") work_order.remark = ";".join(dict.fromkeys(parts))[-255:] db.add(work_order) db.commit() return ProductionWorkOrderInboundRead( work_order_id=work_order.id, work_order_no=work_order.work_order_no, finished_qty=float(finished_qty), surplus_weight_kg=float(surplus_weight), scrap_weight_kg=float(scrap_weight), deviation_remark=payload.deviation_remark, settled=bool(payload.settle_work_order), message="生产工单入库已完成", ) ``` - [ ] **Step 7: Add production routes** Modify `backend/app/api/routes/production.py` imports: ```python from app.schemas.operations import ProductionWorkOrderInboundCreate, ProductionWorkOrderInboundPreviewRead, ProductionWorkOrderInboundRead from app.services.production_work_order_inbound import create_production_work_order_inbound, preview_production_work_order_inbound ``` Add routes: ```python @router.get("/work-order-inbounds/preview", response_model=ProductionWorkOrderInboundPreviewRead) def get_work_order_inbound_preview( work_order_id: int = Query(gt=0), finished_qty: float | None = Query(default=None, ge=0), db: Session = Depends(get_db), ) -> ProductionWorkOrderInboundPreviewRead: return preview_production_work_order_inbound( db, work_order_id=work_order_id, finished_qty=to_decimal(finished_qty) if finished_qty is not None else None, ) @router.post("/work-order-inbounds", response_model=ProductionWorkOrderInboundRead) def post_work_order_inbound( payload: ProductionWorkOrderInboundCreate, context: AuthContext = Depends(require_authenticated_user), db: Session = Depends(get_db), ) -> ProductionWorkOrderInboundRead: return create_production_work_order_inbound(db, payload=payload, operator_user_id=context.user.id) ``` - [ ] **Step 8: Run backend production inbound tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_production_work_order_inbound.py tests/test_production_work_order_ledger.py -q ``` Expected: PASS. --- ### Task 7: Add ERP Frontend UI For Prefix, Single-Lot Production Out, And Work-Order Inbound **Files:** - Modify: `frontend/src/components/StockLotTagSelect.vue` - Modify: `frontend/src/views/SystemExtensionView.vue` - Modify: `frontend/src/views/InventoryLedgerView.vue` - Create: `frontend/scripts/test-production-work-order-inbound-ui.mjs` - [ ] **Step 1: Add frontend tests for single-select and production inbound text** Create `frontend/scripts/test-production-work-order-inbound-ui.mjs`: ```js import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; const inventoryView = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8"); const stockLotSelect = readFileSync(new URL("../src/components/StockLotTagSelect.vue", import.meta.url), "utf8"); const systemExtension = readFileSync(new URL("../src/views/SystemExtensionView.vue", import.meta.url), "utf8"); assert.match(stockLotSelect, /maxSelections/); assert.match(inventoryView, /生产工单入库/); assert.match(inventoryView, /一次生产出库只能选择一个材料库存批次号/); assert.match(inventoryView, /work-order-inbounds\\/preview/); assert.match(inventoryView, /work-order-inbounds/); assert.match(inventoryView, /本次入库后结单/); assert.match(systemExtension, /原材料库存批次号前缀/); assert.match(systemExtension, /raw-material-lot-prefix/); console.log("production work-order inbound UI checks passed"); ``` - [ ] **Step 2: Run frontend UI test to verify failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-work-order-inbound-ui.mjs ``` Expected: FAIL before UI changes. - [ ] **Step 3: Add `maxSelections` to `StockLotTagSelect.vue`** Modify props: ```js maxSelections: { type: Number, default: 0 } ``` Modify `canSelect`: ```js function canSelect(lot) { if (!lot || (!props.allowUnavailable && !lot.is_available_for_issue) || isSelected(lot)) { return false; } return !props.maxSelections || selectedLots.value.length < props.maxSelections; } ``` Modify `selectLot` so single-select replaces existing selection: ```js const nextSelectedLots = props.maxSelections === 1 ? [] : selectedLots.value; emit("update:modelValue", [ ...nextSelectedLots, { ...lot, issued_weight_kg: normalizeSelectedIssuedWeight(lot) } ]); ``` - [ ] **Step 4: Add raw lot prefix config card to System Extension** Modify `frontend/src/views/SystemExtensionView.vue`: Add reactive form: ```js const rawLotPrefixConfig = ref({}); const rawLotPrefixForm = reactive({ config_value: "YL", remark: "" }); ``` Add loader: ```js async function loadRawLotPrefixConfig() { const result = await fetchResource("/system-extension/raw-material-lot-prefix", {}); rawLotPrefixConfig.value = result; rawLotPrefixForm.config_value = result.config_value || "YL"; rawLotPrefixForm.remark = result.remark || ""; } ``` Add saver: ```js async function saveRawLotPrefixConfig() { resetFeedback(); try { const result = await putResource("/system-extension/raw-material-lot-prefix", rawLotPrefixForm); rawLotPrefixConfig.value = result; rawLotPrefixForm.config_value = result.config_value || "YL"; rawLotPrefixForm.remark = result.remark || ""; feedbackMessage.value = "原材料库存批次号前缀已保存"; } catch (error) { errorMessage.value = error.message || "原材料库存批次号前缀保存失败"; } } ``` Call `loadRawLotPrefixConfig()` in `loadAll()`. Add panel: ```html

仓库编号配置

原材料库存批次号前缀

{{ rawLotPrefixConfig.config_value || "YL" }}
``` - [ ] **Step 5: Change production outbound UI to single-select** In `InventoryLedgerView.vue`, update production outbound `StockLotTagSelect`: ```html ``` Update validation: ```js function validateSelectedProductionStockLots(lots) { if (!lots.length) { return "请选择一个材料库存批次号"; } if (lots.length > 1) { return "一次生产出库只能选择一个材料库存批次号"; } const lot = lots[0]; const issuedWeight = Number(lot.issued_weight_kg); const remainingWeight = Number(lot.remaining_weight_kg || 0); const lotNo = lot.lot_no || "未命名批次"; if (!Number.isFinite(issuedWeight) || issuedWeight <= 0) { return `材料库存批次号 ${lotNo} 的本次出库重量必须大于0`; } if (issuedWeight > remainingWeight) { return `材料库存批次号 ${lotNo} 的本次出库重量不能超过剩余重量 ${formatWeight(remainingWeight)}`; } return ""; } ``` - [ ] **Step 6: Add “生产工单入库” button next to warehouse ledger button** Find the warehouse action area near the current “流水” button in `InventoryLedgerView.vue` and add: ```html ``` Add state: ```js const productionWorkOrderInboundDrawerOpen = ref(false); const selectedInboundWorkOrderId = ref(""); const productionWorkOrderInboundPreview = ref(null); const productionWorkOrderInboundForm = reactive({ finished_qty: 0, surplus_weight_kg: 0, scrap_weight_kg: 0, deviation_remark: "", remark: "", settle_work_order: false }); ``` Add functions: ```js function openProductionWorkOrderInboundDrawer() { resetFeedback(); selectedInboundWorkOrderId.value = ""; productionWorkOrderInboundPreview.value = null; Object.assign(productionWorkOrderInboundForm, { finished_qty: 0, surplus_weight_kg: 0, scrap_weight_kg: 0, deviation_remark: "", remark: "", settle_work_order: false }); productionWorkOrderInboundDrawerOpen.value = true; } async function loadProductionWorkOrderInboundPreview() { if (!selectedInboundWorkOrderId.value) { productionWorkOrderInboundPreview.value = null; return; } const query = new URLSearchParams({ work_order_id: String(selectedInboundWorkOrderId.value) }); if (Number(productionWorkOrderInboundForm.finished_qty) > 0) { query.set("finished_qty", String(productionWorkOrderInboundForm.finished_qty)); } const result = await fetchResource(`/production/work-order-inbounds/preview?${query.toString()}`, {}); productionWorkOrderInboundPreview.value = result; if (!Number(productionWorkOrderInboundForm.finished_qty)) { productionWorkOrderInboundForm.finished_qty = Number(result.theoretical_finished_qty || 0); } productionWorkOrderInboundForm.surplus_weight_kg = Number(result.theoretical_surplus_weight_kg || 0); productionWorkOrderInboundForm.scrap_weight_kg = Number(result.theoretical_scrap_weight_kg || 0); } ``` Add submit: ```js async function submitProductionWorkOrderInbound() { resetFeedback(); if (!selectedInboundWorkOrderId.value) { errorMessage.value = "请选择生产工单"; return; } if (productionWorkOrderInboundForm.settle_work_order) { const confirmed = window.confirm( `请确认本次生产工单入库\n\n工单:${productionWorkOrderInboundPreview.value?.work_order_no || ""}\n成品入库:${productionWorkOrderInboundForm.finished_qty || 0} 件\n余料入库:${productionWorkOrderInboundForm.surplus_weight_kg || 0} kg\n废料入库:${productionWorkOrderInboundForm.scrap_weight_kg || 0} kg\n\n勾选结单后,该工单将变为已结单,小程序不再显示该工单对应的材料库存批次号,后续不能再对该工单做生产工单入库。` ); if (!confirmed) { return; } } const result = await postResource("/production/work-order-inbounds", { work_order_id: Number(selectedInboundWorkOrderId.value), finished_warehouse_id: finishedWarehouseId.value, scrap_warehouse_id: scrapWarehouseId.value, finished_qty: Number(productionWorkOrderInboundForm.finished_qty || 0), surplus_weight_kg: Number(productionWorkOrderInboundForm.surplus_weight_kg || 0), scrap_weight_kg: Number(productionWorkOrderInboundForm.scrap_weight_kg || 0), deviation_remark: productionWorkOrderInboundForm.deviation_remark || null, remark: productionWorkOrderInboundForm.remark || null, settle_work_order: Boolean(productionWorkOrderInboundForm.settle_work_order), settle_confirmed: Boolean(productionWorkOrderInboundForm.settle_work_order) }); feedbackMessage.value = result.message || "生产工单入库已完成"; productionWorkOrderInboundDrawerOpen.value = false; await loadAll(); } ``` - [ ] **Step 7: Add production work-order inbound drawer** Add a `FormDrawer` in `InventoryLedgerView.vue`: ```html
材料库存批次号{{ productionWorkOrderInboundPreview.source_lot_no }}
领料重量{{ formatWeight(productionWorkOrderInboundPreview.issued_weight_kg) }}
单件毛重{{ formatWeight(productionWorkOrderInboundPreview.gross_weight_per_piece_kg) }}
单件净重{{ formatWeight(productionWorkOrderInboundPreview.net_weight_per_piece_kg) }}
``` - [ ] **Step 8: Run frontend tests and build** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-work-order-inbound-ui.mjs node scripts/test-inventory-ledger-options.mjs npm run build ``` Expected: scripts PASS and build PASS with only existing Vite chunk-size warning. --- ### Task 8: Update Miniapp Frontend Wording To “材料库存批次号” **Files:** - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml` - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js` - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml` - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml` - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml` - Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js` - [ ] **Step 1: Add a text scan test** Run before edits: ```bash rg -n "工单号|原材料分批次号|请选择工单号|暂无可选工单号" \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \ /Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js ``` Expected: output includes old wording. - [ ] **Step 2: Replace visible wording** In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`: ```xml 材料库存批次号 {{reportItem.rawMaterialBatchNo || '请选择材料库存批次号'}} 暂无可选材料库存批次号 ``` In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`, replace validation message: ```js title: `${blockDisplay(block)}请选择材料库存批次号`, ``` In records/dashboard/review WXML files, replace visible `原材料分批次号` with `材料库存批次号`. In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, keep payload field names unchanged but replace correction display label: ```js correctionPair(corrections, 'raw_material_batch_no', '材料库存批次号', row.raw_material_batch_no || '') ``` - [ ] **Step 3: Verify old wording is gone from user-facing miniapp files** Run: ```bash rg -n "请选择工单号|暂无可选工单号|原材料分批次号" \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \ /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \ /Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js ``` Expected: no output. --- ### Task 9: Add SQL Execution And Deployment Checks **Files:** - Use: `backend/sql/add_raw_lot_work_order_inbound_patch.sql` - No code files modified in this task. - [ ] **Step 1: Execute SQL patch against the target test database** Run from ERP backend: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python - <<'PY' from pathlib import Path import re import pymysql env = {} for line in Path(".env").read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) env[key.strip()] = value.strip().strip('"').strip("'") sql_path = Path("sql/add_raw_lot_work_order_inbound_patch.sql") statements = [stmt.strip() for stmt in re.split(r";\s*(?:\n|$)", sql_path.read_text(encoding="utf-8")) if stmt.strip()] conn = pymysql.connect( host=env["MYSQL_HOST"], port=int(env["MYSQL_PORT"]), user=env["MYSQL_USER"], password=env["MYSQL_PASSWORD"], database=env["MYSQL_DATABASE"], charset="utf8mb4", ) try: with conn.cursor() as cur: for stmt in statements: cur.execute(stmt) conn.commit() finally: conn.close() print(f"executed {len(statements)} statements") PY ``` Expected: `executed 2 statements`. - [ ] **Step 2: Verify config table and seed row** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python - <<'PY' from pathlib import Path import pymysql env = {} for line in Path(".env").read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, value = line.split("=", 1) env[key.strip()] = value.strip().strip('"').strip("'") conn = pymysql.connect( host=env["MYSQL_HOST"], port=int(env["MYSQL_PORT"]), user=env["MYSQL_USER"], password=env["MYSQL_PASSWORD"], database=env["MYSQL_DATABASE"], charset="utf8mb4", ) try: with conn.cursor() as cur: cur.execute("select config_code, config_value from sys_system_config where config_code='RAW_MATERIAL_LOT_PREFIX'") print(cur.fetchall()) finally: conn.close() PY ``` Expected: output contains `RAW_MATERIAL_LOT_PREFIX` and `YL`. --- ### Task 10: Full Verification **Files:** - No new files. - [ ] **Step 1: Run backend full tests** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests -q ``` Expected: all tests pass. - [ ] **Step 2: Run frontend checks** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-production-work-order-inbound-ui.mjs node scripts/test-inventory-ledger-options.mjs node scripts/test-warehouse-drawer-table-overflow.mjs npm run build ``` Expected: script tests pass and build succeeds. Vite chunk-size warning is acceptable. - [ ] **Step 3: Run miniapp backend tests** Run: ```bash cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint python -m pytest tests/test_work_order_batch_options.py tests/test_metrics.py tests/test_review_corrections.py -q ``` Expected: tests pass. - [ ] **Step 4: Manual smoke test in browser** Start services: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` In another terminal: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend npm run dev -- --host 0.0.0.0 --port 5173 ``` Smoke flow: 1. Open `http://127.0.0.1:5173`. 2. Go to 系统拓展 and verify 原材料库存批次号前缀 shows `YL`. 3. Go to 百华仓库原材料库 and create a raw inbound test lot; verify lot number is `YL0001` or the next available `YL` sequence. 4. Use 原材料库 · 生产出库, select one material stock lot and one product, submit. 5. Verify 工单台账 has work order `YYYYMMDD-YLxxxx-产品名称`. 6. Submit another production outbound for the same day, same lot, same product. 7. Verify it merges into the same work order and adds another issue line. 8. Open 百华仓库 · 生产工单入库, select that work order, enter finished qty, verify surplus/scrap defaults are filled. 9. Change surplus or scrap beyond 15%; verify deviation remark is required. 10. Check “本次入库后结单”, submit, confirm second prompt, verify work order status becomes 已结单. Expected: no red overlay, no uncaught console error, and warehouse流水 shows成品入库、生产余料入库、生产废料入库 records tied to the work order. --- ## Self-Review Spec coverage: - `YL0001` prefix and `YL10000` expansion: Task 1 and Task 2. - System拓展 prefix configuration: Task 1 and Task 7. - Single-lot production outbound: Task 4 and Task 7. - Same-day same-lot same-product merge: Task 4. - Miniapp wording and lot-number selection: Task 5 and Task 8. - Production work-order inbound entry and workflow: Task 6 and Task 7. - 15% deviation remark: Task 6 and Task 7. - No default settle and second confirmation: Task 6 and Task 7. - SQL execution and verification: Task 9. - Full test and smoke checks: Task 10. Placeholder scan: - No unfinished placeholder markers remain in the plan. - Production work-order inbound now lists concrete helper functions and submit calls instead of leaving a comment-only implementation gap. Type consistency: - Backend schemas use `ProductionWorkOrderInboundPreviewRead`, `ProductionWorkOrderInboundCreate`, and `ProductionWorkOrderInboundRead`. - Frontend routes use `/production/work-order-inbounds/preview` and `/production/work-order-inbounds`. - Miniapp keeps the persisted field `raw_material_batch_no` but changes visible wording to “材料库存批次号”. Git note: - Current ERP directory has no `.git` folder. If implementation runs in this mounted directory, commit steps are replaced by checkpoint summaries after each task. If a git worktree is available at execution time, commit after each completed task with a focused message.