ForgeFlow-ERP/docs/superpowers/plans/2026-05-31-inventory-lot-unified-trace.md
2026-06-12 16:00:56 +08:00

75 KiB
Raw Blame History

Inventory Lot Unified Trace 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 separate “材料分批次号” concept with a single canonical “库存批次号” trace identity across raw material purchase receipt, production issue, miniapp report sync, finished goods receipt, delivery trace, DAG, and purchase-order-to-sales-order linkage.

Architecture: wh_stock_lot.lot_no becomes the canonical raw material trace key. Existing downstream records keep foreign-key links such as source_lot_id and store display snapshots only for compatibility; new UI and APIs expose “库存批次号” instead of “材料分批次号”. Purchase orders support multiple linked sales orders via a normalized join table, while purchase items still carry demand-level links for cost and MRP detail.

Tech Stack: FastAPI, SQLAlchemy ORM, Pydantic schemas, MySQL/PyMySQL migrations, SQLite-backed unit tests, Vue 3, Vite.


Scope And Sequencing

This is a cross-module business refactor. Execute in this order:

  1. Add compatibility schema and tests.
  2. Introduce a single inventory-lot-number service.
  3. Change purchase receipt lot generation and remove forced same-PO-line merge.
  4. Change production issue to select stock lots instead of generating material sub-batches.
  5. Rename and remap miniapp report sync from “原材料分批次号” to “库存批次号”.
  6. Update finished goods receipt, delivery trace, sales order trace, and DAG labels/data.
  7. Add multi-sales-order purchase order links.
  8. Update frontend forms and labels.
  9. Run full verification.

Do not remove legacy database columns in the first pass. Keep them synchronized for compatibility:

  • pp_work_order_material_issue.material_sub_batch_no: write the selected StockLot.lot_no into this field during the transition.
  • wh_stock_lot.material_sub_batch_no: keep nullable and unused for new raw material lots.
  • wh_stock_lot.source_material_sub_batch_no: keep nullable, but new finished/semi-finished lots should store source lot summaries and source lot ids instead of relying on this field.

The user confirmed that the miniapp field previously named “原材料分批次号” should be treated as “库存批次号” going forward.


File Structure

Backend Files

  • Modify: backend/app/models/operations.py

    • Add purchase-order-to-sales-order association model.
    • Keep legacy material-sub-batch fields for compatibility.
  • Modify: backend/app/schemas/operations.py

    • Add sales_order_ids to purchase order create/update payloads.
    • Add selected stock lot issue payload types.
    • Add lot availability fields for frontend multi-select.
    • Rename user-facing schema aliases/comments where safe.
  • Modify: backend/app/services/operations.py

    • Replace build_material_sub_batch_no usage with build_inventory_lot_no.
    • Add raw stock lot availability query sorted by usable-first order.
    • Change material issue service to consume selected lots.
    • Update purchase-order query source summaries for multiple sales orders.
  • Modify: backend/app/api/routes/purchase.py

    • Use JH_{material_code}_{0001} lot number generation for raw purchase receipts.
    • Stop forcing subsequent arrivals on the same purchase line to merge.
    • Preserve explicit user-selected merge behavior.
    • Support multiple linked sales orders on create/update/read.
  • Modify: backend/app/api/routes/production.py

    • Change work-order creation to accept selected stock lot issues.
    • Stop assigning work order number from generated material sub-batch number.
    • Resolve miniapp report sync by库存批次号.
    • Update completion receipt source summaries.
  • Modify: backend/app/api/routes/inventory.py

    • Update raw warehouse production-out endpoint wording and payload mapping.
    • Ensure stock lot list exposes purchase order, receipt, unit cost, available/depleted state.
  • Modify: backend/app/api/routes/dashboard.py

    • Rename DAG nodes and details from “材料分批次号” to “库存批次号”.
    • Link production/report/finished/delivery nodes through source stock lot ids.
  • Modify: backend/app/api/routes/sales.py

    • Ensure delivery trace reports source inventory lot numbers and no longer depends on material-sub-batch labels.
  • Create: backend/sql/unify_inventory_lot_trace_patch.sql

    • Add normalized purchase-order sales-order link table.
    • Add indexes for lot-number sequence and selected lot lookup.
    • Backfill compatibility values safely.

Backend Tests

  • Create: backend/tests/test_inventory_lot_numbering.py
  • Create: backend/tests/test_selected_stock_lot_production_issue.py
  • Create: backend/tests/test_purchase_order_multi_sales_links.py
  • Modify: backend/tests/test_raw_material_weight_only_flow.py
  • Modify: backend/tests/test_sales_order_delivery_trace.py
  • Modify: backend/tests/test_sales_order_purchase_lock_flow.py

Frontend Files

  • Modify: frontend/src/components/GlobalSearchableSelect.vue

    • Confirm it supports searchable multi-select tag mode, or add this mode without changing existing single-select behavior.
  • Create: frontend/src/components/StockLotTagSelect.vue

    • Focused searchable multi-select for inventory lots.
  • Modify: frontend/src/views/PurchaseOrderView.vue

    • Replace single sales order select with multi-select tags for sales-order purchase.
    • Display multiple linked sales orders in source cards.
  • Modify: frontend/src/views/PurchaseReceiptView.vue

    • Use new default lot number from backend response or frontend helper.
    • Remove forced same-purchase-line merge messaging.
  • Modify: frontend/src/views/InventoryLedgerView.vue

    • Rename production out operation from “生成材料分批次号” to selecting/using库存批次号.
    • Show raw stock lot detail as 原材料物料 -> 库存批次号.
  • Modify: frontend/src/views/WorkOrderManagementView.vue

    • Rename list/form labels away from材料分批次号.
    • Add stock lot tag selector and per-lot issue weight input.
  • Modify: frontend/src/views/OperationReportView.vue

    • Rename field display to库存批次号 while accepting miniapps existing field data.
  • Modify: frontend/src/views/CompletionReceiptView.vue

    • Replace source material sub-batch labels with source inventory lot labels.
  • Modify: frontend/src/views/DeliveryManagementView.vue

  • Modify: frontend/src/views/SalesPlanningView.vue

  • Modify: frontend/src/views/DashboardView.vue

    • Replace material-sub-batch labels and search fields with source inventory lot wording.
  • Modify: frontend/src/utils/dictionaries.js

  • Modify: frontend/src/utils/statusBadges.js

    • Add any missing display label mappings caused by the rename.

Task 1: Add Regression Tests For The New Canonical Batch Model

Files:

  • Create: backend/tests/test_inventory_lot_numbering.py

  • Create: backend/tests/test_selected_stock_lot_production_issue.py

  • Create: backend/tests/test_purchase_order_multi_sales_links.py

  • Modify: backend/tests/test_raw_material_weight_only_flow.py

  • Step 1: Write failing lot-numbering tests

Create backend/tests/test_inventory_lot_numbering.py with this content:

from __future__ import annotations

import unittest
from decimal import Decimal

from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker


@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
    _ = type_, compiler, kw
    return "INTEGER"


import app.models.master_data  # noqa: E402,F401
import app.models.miniapp  # noqa: E402,F401
import app.models.operations  # noqa: E402,F401
import app.models.org  # noqa: E402,F401
import app.models.planning  # noqa: E402,F401
import app.models.sales  # noqa: E402,F401
from app.models.base import Base  # noqa: E402
from app.models.master_data import Item, Warehouse  # noqa: E402
from app.models.operations import StockLot  # noqa: E402
from app.services.operations import build_inventory_lot_no  # noqa: E402


class InventoryLotNumberingTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()
        self.material = Item(
            id=1,
            item_code="110501000185",
            item_name="左右后纵梁后段毛坯",
            item_type="RAW_MATERIAL",
            unit_weight_kg=Decimal("1"),
            status="ACTIVE",
        )
        self.warehouse = Warehouse(
            id=1,
            warehouse_code="WH-RAW",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
        )
        self.db.add_all([self.material, self.warehouse])
        self.db.commit()

    def tearDown(self) -> None:
        self.db.close()

    def test_inventory_lot_no_starts_from_one_per_material(self) -> None:
        self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "JH_110501000185_0001")

    def test_inventory_lot_no_increments_from_existing_stock_lots(self) -> None:
        self.db.add(
            StockLot(
                lot_no="JH_110501000185_0001",
                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.add(
            StockLot(
                lot_no="JH_110501000185_0003",
                lot_role="INBOUND_RAW",
                item_id=self.material.id,
                warehouse_id=self.warehouse.id,
                source_doc_type="TEST",
                source_doc_id=2,
                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), "JH_110501000185_0004")
  • Step 2: Write failing production selected-lot tests

Create backend/tests/test_selected_stock_lot_production_issue.py with this content:

from __future__ import annotations

import unittest
from decimal import Decimal

from fastapi import HTTPException
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 Bom, BomItem, Item, StockBalance, Warehouse  # noqa: E402
from app.models.operations import ProcessRoute, ProcessRouteOperation, StockLot, WorkCenter, WorkOrderMaterialIssue  # noqa: E402
from app.services.operations import issue_selected_stock_lots  # noqa: E402


class SelectedStockLotProductionIssueTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()
        self.material = Item(id=1, item_code="RM-SEL-001", item_name="选择批次原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE")
        self.product = Item(id=2, item_code="FG-SEL-001", item_name="选择批次成品", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("2"), status="ACTIVE")
        self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE")
        self.db.add_all([self.material, self.product, self.raw_wh])
        self.db.flush()
        self.bom = Bom(id=1, bom_code="BOM-SEL-001", product_item_id=self.product.id, version_no="V1.0", is_default=1, status="ACTIVE")
        self.bom_item = BomItem(id=1, bom_id=1, seq_no=1, material_item_id=self.material.id, usage_type="WEIGHT", gross_weight_per_piece_kg=Decimal("1.5"), loss_rate=Decimal("0"))
        self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="冲压", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE")
        self.route = ProcessRoute(id=1, route_code="RT-SEL-001", product_item_id=self.product.id, version_no="V1.0", is_default=1, status="ACTIVE")
        self.route_op = ProcessRouteOperation(id=1, route_id=1, seq_no=1, operation_name="1序", work_center_id=1, standard_seconds_per_piece=Decimal("10"), status="ACTIVE")
        self.db.add_all([self.bom, self.bom_item, self.work_center, self.route, self.route_op])
        self.lot1 = StockLot(
            id=1,
            lot_no="JH_RM-SEL-001_0001",
            lot_role="INBOUND_RAW",
            item_id=self.material.id,
            warehouse_id=self.raw_wh.id,
            source_doc_type="PURCHASE_RECEIPT",
            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("4"),
            quality_status="PASS",
            status="AVAILABLE",
        )
        self.lot2 = StockLot(
            id=2,
            lot_no="JH_RM-SEL-001_0002",
            lot_role="INBOUND_RAW",
            item_id=self.material.id,
            warehouse_id=self.raw_wh.id,
            source_doc_type="PURCHASE_RECEIPT",
            source_doc_id=2,
            inbound_qty=Decimal("0"),
            inbound_weight_kg=Decimal("50"),
            remaining_qty=Decimal("0"),
            remaining_weight_kg=Decimal("50"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("5"),
            quality_status="PASS",
            status="AVAILABLE",
        )
        self.balance = StockBalance(
            id=1,
            item_id=self.material.id,
            warehouse_id=self.raw_wh.id,
            location_id=None,
            qty_on_hand=Decimal("0"),
            weight_on_hand_kg=Decimal("150"),
            qty_available=Decimal("0"),
            weight_available_kg=Decimal("150"),
            qty_allocated=Decimal("0"),
            weight_allocated_kg=Decimal("0"),
            avg_unit_cost=Decimal("4.3333"),
        )
        self.db.add_all([self.lot1, self.lot2, self.balance])
        self.db.commit()

    def tearDown(self) -> None:
        self.db.close()

    def test_issue_selected_stock_lots_uses_lot_no_as_trace_key(self) -> None:
        issues = issue_selected_stock_lots(
            self.db,
            work_order_id=1,
            work_order_material_id=1,
            material_item_id=self.material.id,
            selected_lots=[
                {"source_lot_id": self.lot1.id, "issued_weight_kg": Decimal("80")},
                {"source_lot_id": self.lot2.id, "issued_weight_kg": Decimal("30")},
            ],
            operator_user_id=1,
        )

        self.assertEqual([issue.material_sub_batch_no for issue in issues], ["JH_RM-SEL-001_0001", "JH_RM-SEL-001_0002"])
        self.assertEqual(float(self.db.get(StockLot, self.lot1.id).remaining_weight_kg), 20)
        self.assertEqual(float(self.db.get(StockLot, self.lot2.id).remaining_weight_kg), 20)
        persisted = self.db.scalars(select(WorkOrderMaterialIssue).order_by(WorkOrderMaterialIssue.id)).all()
        self.assertEqual(len(persisted), 2)
        self.assertEqual(float(persisted[0].amount), 320)
        self.assertEqual(float(persisted[1].amount), 150)

    def test_issue_selected_stock_lots_rejects_depleted_or_overdrawn_lots(self) -> None:
        self.lot2.remaining_weight_kg = Decimal("0")
        self.lot2.status = "DEPLETED"
        self.db.commit()

        with self.assertRaises(HTTPException) as exc:
            issue_selected_stock_lots(
                self.db,
                work_order_id=1,
                work_order_material_id=1,
                material_item_id=self.material.id,
                selected_lots=[{"source_lot_id": self.lot2.id, "issued_weight_kg": Decimal("1")}],
                operator_user_id=1,
            )

        self.assertEqual(exc.exception.status_code, 400)
        self.assertIn("不可用", exc.exception.detail)
  • Step 3: Write failing multi-sales-order purchase tests

Create backend/tests/test_purchase_order_multi_sales_links.py with this content:

from __future__ import annotations

import unittest
from datetime import date
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.api.routes.purchase import create_purchase_order, update_purchase_order  # noqa: E402
from app.models.base import Base  # noqa: E402
from app.models.master_data import Item, Material  # noqa: E402
from app.models.operations import PurchaseOrderSalesOrderLink, Supplier  # noqa: E402
from app.models.sales import Customer, SalesOrder, SalesOrderItem  # noqa: E402
from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseOrderUpdate  # noqa: E402


class PurchaseOrderMultiSalesLinksTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()
        self.supplier = Supplier(id=1, supplier_code="SUP-MULTI-001", supplier_name="多订单供应商", short_name="多订单供应商", lead_time_days=0, default_tax_rate=Decimal("0"), status="ACTIVE")
        self.customer = Customer(id=1, customer_code="CUS-MULTI-001", customer_name="多订单客户", short_name="客户", status="ACTIVE")
        self.product = Item(id=1, item_code="FG-MULTI-001", item_name="多订单成品", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("1"), status="ACTIVE")
        self.material = Item(id=2, item_code="RM-MULTI-001", item_name="多订单原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE")
        self.db.add_all([self.supplier, self.customer, self.product, self.material])
        self.db.flush()
        self.db.add(Material(item_id=self.material.id, default_supplier_id=self.supplier.id, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal("0"), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal("0"), lead_time_days=0))
        self.order1 = SalesOrder(id=1, order_no="销售2026-00001", customer_id=self.customer.id, order_date=date.today(), status="OPEN")
        self.order2 = SalesOrder(id=2, order_no="销售2026-00002", customer_id=self.customer.id, order_date=date.today(), status="OPEN")
        self.item1 = SalesOrderItem(id=1, sales_order_id=1, line_no=1, product_item_id=self.product.id, order_qty=Decimal("100"), delivered_qty=Decimal("0"), unit_price=Decimal("10"), line_amount=Decimal("1000"), status="OPEN")
        self.item2 = SalesOrderItem(id=2, sales_order_id=2, line_no=1, product_item_id=self.product.id, order_qty=Decimal("200"), delivered_qty=Decimal("0"), unit_price=Decimal("10"), line_amount=Decimal("2000"), status="OPEN")
        self.db.add_all([self.order1, self.order2, self.item1, self.item2])
        self.db.commit()

    def tearDown(self) -> None:
        self.db.close()

    def test_purchase_order_can_link_multiple_sales_orders(self) -> None:
        result = create_purchase_order(
            PurchaseOrderCreate(
                supplier_id=self.supplier.id,
                expected_date=date.today(),
                target_warehouse_type="RAW",
                sales_order_ids=[self.order1.id, self.order2.id],
                items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))],
            ),
            db=self.db,
        )

        links = self.db.scalars(select(PurchaseOrderSalesOrderLink).order_by(PurchaseOrderSalesOrderLink.sales_order_id)).all()
        self.assertEqual([link.sales_order_id for link in links], [self.order1.id, self.order2.id])
        self.assertEqual(result.source_type, "SALES_ORDER")
        self.assertEqual(result.source_order_nos, "销售2026-00001、销售2026-00002")

    def test_purchase_order_update_replaces_sales_order_links(self) -> None:
        result = create_purchase_order(
            PurchaseOrderCreate(
                supplier_id=self.supplier.id,
                expected_date=date.today(),
                target_warehouse_type="RAW",
                sales_order_ids=[self.order1.id, self.order2.id],
                items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))],
            ),
            db=self.db,
        )

        updated = update_purchase_order(
            result.purchase_order_id,
            PurchaseOrderUpdate(
                supplier_id=self.supplier.id,
                expected_date=date.today(),
                target_warehouse_type="RAW",
                sales_order_ids=[self.order2.id],
                items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("250"), unit_price=Decimal("6"))],
            ),
            db=self.db,
        )

        links = self.db.scalars(select(PurchaseOrderSalesOrderLink).order_by(PurchaseOrderSalesOrderLink.sales_order_id)).all()
        self.assertEqual([link.sales_order_id for link in links], [self.order2.id])
        self.assertEqual(updated.source_order_nos, "销售2026-00002")
  • Step 4: Run new tests and verify they fail for missing implementation

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_inventory_lot_numbering.py tests/test_selected_stock_lot_production_issue.py tests/test_purchase_order_multi_sales_links.py -q

Expected: FAIL because build_inventory_lot_no, issue_selected_stock_lots, PurchaseOrderSalesOrderLink, and sales_order_ids do not exist yet.

  • Step 5: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

Expected: The current workspace may not be a git repository. If it is a git repository, commit the failing tests with:

git add backend/tests/test_inventory_lot_numbering.py backend/tests/test_selected_stock_lot_production_issue.py backend/tests/test_purchase_order_multi_sales_links.py
git commit -m "test: cover unified inventory lot trace model"

Task 2: Add Database Compatibility Schema

Files:

  • Modify: backend/app/models/operations.py

  • Create: backend/sql/unify_inventory_lot_trace_patch.sql

  • Step 1: Add purchase-order sales-order link ORM model

In backend/app/models/operations.py, add this model near PurchaseOrder and PurchaseOrderItem:

class PurchaseOrderSalesOrderLink(Base):
    __tablename__ = "po_purchase_order_sales_order"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    purchase_order_id: Mapped[int] = mapped_column(ForeignKey("po_purchase_order.id"))
    sales_order_id: Mapped[int] = mapped_column(ForeignKey("so_sales_order.id"))
    created_at: Mapped[object] = mapped_column(DateTime)
    updated_at: Mapped[object] = mapped_column(DateTime)
  • Step 2: Add SQL migration

Create backend/sql/unify_inventory_lot_trace_patch.sql:

CREATE TABLE IF NOT EXISTS po_purchase_order_sales_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    purchase_order_id BIGINT NOT NULL,
    sales_order_id BIGINT NOT NULL,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uq_po_so_link (purchase_order_id, sales_order_id),
    KEY idx_po_so_link_po (purchase_order_id),
    KEY idx_po_so_link_so (sales_order_id),
    CONSTRAINT fk_po_so_link_po FOREIGN KEY (purchase_order_id) REFERENCES po_purchase_order(id) ON DELETE CASCADE,
    CONSTRAINT fk_po_so_link_so FOREIGN KEY (sales_order_id) REFERENCES so_sales_order(id)
);

CREATE INDEX idx_wh_stock_lot_item_lot_no ON wh_stock_lot (item_id, lot_no);
CREATE INDEX idx_pp_issue_source_lot ON pp_work_order_material_issue (source_lot_id);

INSERT INTO po_purchase_order_sales_order (purchase_order_id, sales_order_id, created_at, updated_at)
SELECT DISTINCT po.id, so.id, NOW(), NOW()
FROM po_purchase_order po
JOIN po_purchase_order_item poi ON poi.purchase_order_id = po.id
JOIN pl_material_demand md ON md.id = poi.source_demand_id
JOIN so_sales_order_item soi ON soi.id = md.sales_order_item_id
JOIN so_sales_order so ON so.id = soi.sales_order_id
WHERE po.source_type = 'SALES_ORDER'
  AND NOT EXISTS (
      SELECT 1
      FROM po_purchase_order_sales_order existing
      WHERE existing.purchase_order_id = po.id
        AND existing.sales_order_id = so.id
  );

UPDATE pp_work_order_material_issue issue
JOIN wh_stock_lot lot ON lot.id = issue.source_lot_id
SET issue.material_sub_batch_no = lot.lot_no
WHERE issue.material_sub_batch_no IS NULL
   OR issue.material_sub_batch_no = ''
   OR issue.material_sub_batch_no NOT LIKE 'JH\\_%';
  • Step 3: Run model/test import smoke check

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_purchase_order_multi_sales_links.py::PurchaseOrderMultiSalesLinksTest::test_purchase_order_can_link_multiple_sales_orders -q

Expected: The test should still fail on missing schema fields/service logic, but no longer fail with ImportError for PurchaseOrderSalesOrderLink.

  • Step 4: Apply migration to local database when implementing against MySQL

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
mysql --default-character-set=utf8mb4 -u root -p jiaheng_erp < backend/sql/unify_inventory_lot_trace_patch.sql

Expected: MySQL prints no error. If the local project uses a different DB user, use the same command style already used for prior project SQL patches.

  • Step 5: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/models/operations.py backend/sql/unify_inventory_lot_trace_patch.sql
git commit -m "feat: add unified inventory lot trace schema"

Task 3: Implement Inventory Lot Number Service And Availability Query

Files:

  • Modify: backend/app/services/operations.py

  • Modify: backend/app/schemas/operations.py

  • Modify: backend/app/api/routes/inventory.py

  • Step 1: Add lot availability schema

In backend/app/schemas/operations.py, add:

class StockLotOptionRead(BaseModel):
    lot_id: int
    lot_no: str
    item_id: int
    item_code: str
    item_name: str
    warehouse_id: int
    warehouse_name: str
    remaining_weight_kg: float
    unit_cost: float
    quality_status: str
    status: str
    is_available_for_issue: bool
    disabled_reason: str | None = None
  • Step 2: Replace generator service

In backend/app/services/operations.py, keep build_material_sub_batch_no temporarily but add the new canonical generator above it:

def build_inventory_lot_no(db: Session, material_item_id: int) -> str:
    material = db.get(Item, material_item_id)
    material_code = material.item_code if material else f"MAT{material_item_id}"
    prefix = f"JH_{material_code}_"
    existing_lot_nos = db.scalars(
        select(StockLot.lot_no).where(
            StockLot.item_id == material_item_id,
            StockLot.lot_no.like(f"{prefix}%"),
        )
    ).all()
    max_sequence = 0
    for lot_no in existing_lot_nos:
        tail = str(lot_no).replace(prefix, "", 1)
        if len(tail) == 4 and tail.isdigit():
            max_sequence = max(max_sequence, int(tail))
    sequence = max_sequence + 1
    candidate = f"{prefix}{sequence:04d}"
    while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)):
        sequence += 1
        candidate = f"{prefix}{sequence:04d}"
    return candidate
  • Step 3: Add usable-first stock lot query

In backend/app/services/operations.py, add:

def get_stock_lot_options_query(material_item_id: int | None = None) -> Select:
    is_available_expr = case(
        (
            (StockLot.quality_status == "PASS")
            & (StockLot.status == "AVAILABLE")
            & (StockLot.remaining_weight_kg > 0),
            1,
        ),
        else_=0,
    )
    stmt = (
        select(
            StockLot.id.label("lot_id"),
            StockLot.lot_no.label("lot_no"),
            StockLot.item_id.label("item_id"),
            Item.item_code.label("item_code"),
            Item.item_name.label("item_name"),
            StockLot.warehouse_id.label("warehouse_id"),
            Warehouse.warehouse_name.label("warehouse_name"),
            StockLot.remaining_weight_kg.label("remaining_weight_kg"),
            StockLot.unit_cost.label("unit_cost"),
            StockLot.quality_status.label("quality_status"),
            StockLot.status.label("status"),
            is_available_expr.label("is_available_for_issue"),
            case(
                (StockLot.quality_status != "PASS", "未质检放行"),
                (StockLot.remaining_weight_kg <= 0, "已用完"),
                (StockLot.status != "AVAILABLE", "当前状态不可用"),
                else_=None,
            ).label("disabled_reason"),
        )
        .join(Item, Item.id == StockLot.item_id)
        .join(Warehouse, Warehouse.id == StockLot.warehouse_id)
        .where(Warehouse.warehouse_type == "RAW")
        .order_by(is_available_expr.desc(), StockLot.lot_no.asc(), StockLot.id.asc())
    )
    if material_item_id:
        stmt = stmt.where(StockLot.item_id == material_item_id)
    return stmt

Ensure case is imported from SQLAlchemy in this file:

from sqlalchemy import Select, and_, case, false, func, or_, select
  • Step 4: Add stock lot options endpoint

In backend/app/api/routes/inventory.py, import StockLotOptionRead and get_stock_lot_options_query, then add:

@router.get("/stock-lot-options", response_model=list[StockLotOptionRead])
def list_stock_lot_options(
    material_item_id: int | None = Query(default=None),
    limit: int = Query(default=500, ge=1, le=1000),
    db: Session = Depends(get_db),
) -> list[StockLotOptionRead]:
    rows = db.execute(get_stock_lot_options_query(material_item_id=material_item_id).limit(limit)).mappings().all()
    return [StockLotOptionRead.model_validate(dict(row)) for row in rows]
  • Step 5: Run lot-numbering test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_inventory_lot_numbering.py -q

Expected: PASS.

  • Step 6: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/services/operations.py backend/app/schemas/operations.py backend/app/api/routes/inventory.py backend/tests/test_inventory_lot_numbering.py
git commit -m "feat: add canonical inventory lot numbering"

Task 4: Change Purchase Receipt To Generate Canonical Inventory Lot Numbers

Files:

  • Modify: backend/app/api/routes/purchase.py

  • Modify: backend/tests/test_raw_material_weight_only_flow.py

  • Step 1: Update receipt tests for new lot numbers and no forced merge

In backend/tests/test_raw_material_weight_only_flow.py, add this test to RawMaterialWeightOnlyFlowTest:

    def test_split_arrivals_create_sequential_inventory_lots_for_same_purchase_line(self) -> None:
        order = create_purchase_order(
            PurchaseOrderCreate(
                supplier_id=self.supplier.id,
                expected_date=date.today(),
                items=[
                    PurchaseOrderItemCreate(
                        material_item_id=self.material_item.id,
                        order_weight_kg=Decimal("500"),
                        unit_price=Decimal("4"),
                    )
                ],
            ),
            db=self.db,
        )
        po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
        lock_purchase_order(order.purchase_order_id, db=self.db)

        create_purchase_receipt(
            PurchaseReceiptCreate(
                purchase_order_id=order.purchase_order_id,
                warehouse_id=self.warehouse.id,
                receiver_employee_id=self.employee.id,
                items=[
                    PurchaseReceiptItemCreate(
                        purchase_order_item_id=po_item.id,
                        material_item_id=self.material_item.id,
                        received_weight_kg=Decimal("300"),
                        unit_cost=Decimal("4"),
                    )
                ],
            ),
            context=self._auth_context(),
            db=self.db,
        )
        create_purchase_receipt(
            PurchaseReceiptCreate(
                purchase_order_id=order.purchase_order_id,
                warehouse_id=self.warehouse.id,
                receiver_employee_id=self.employee.id,
                items=[
                    PurchaseReceiptItemCreate(
                        purchase_order_item_id=po_item.id,
                        material_item_id=self.material_item.id,
                        received_weight_kg=Decimal("250"),
                        unit_cost=Decimal("4.5"),
                    )
                ],
            ),
            context=self._auth_context(),
            db=self.db,
        )

        lots = self.db.scalars(select(StockLot).order_by(StockLot.id)).all()
        receipt_items = self.db.scalars(select(PurchaseReceiptItem).order_by(PurchaseReceiptItem.id)).all()
        self.assertEqual([lot.lot_no for lot in lots], ["JH_RM-WEIGHT-001_0001", "JH_RM-WEIGHT-001_0002"])
        self.assertEqual(float(receipt_items[0].unit_cost), 4)
        self.assertEqual(float(receipt_items[1].unit_cost), 4.5)
        self.assertEqual(float(lots[0].unit_cost), 4)
        self.assertEqual(float(lots[1].unit_cost), 4.5)
  • Step 2: Run the updated raw-material test and verify failure

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_raw_material_weight_only_flow.py::RawMaterialWeightOnlyFlowTest::test_split_arrivals_create_sequential_inventory_lots_for_same_purchase_line -q

Expected: FAIL because existing receipt logic forces merge and uses the old lot number format.

  • Step 3: Replace purchase receipt lot number creation

In backend/app/api/routes/purchase.py, import:

from app.services.operations import build_inventory_lot_no

In create_purchase_receipt, remove this block:

existing_po_item_lot = _find_existing_receipt_lot_for_po_item(db, item_payload.purchase_order_item_id)
if existing_po_item_lot:
    if item_payload.merge_to_lot_id and item_payload.merge_to_lot_id != existing_po_item_lot.id:
        raise HTTPException(
            status_code=400,
            detail=f"采购行 {po_item.line_no} 已有入库批次 {existing_po_item_lot.lot_no},本次到货必须合并到该批次",
        )
    merge_target_lot = existing_po_item_lot
elif item_payload.merge_to_lot_id:
    merge_target_lot = db.get(StockLot, item_payload.merge_to_lot_id)
    if not merge_target_lot:
        raise HTTPException(status_code=404, detail=f"合并目标批次不存在: {item_payload.merge_to_lot_id}")

Replace it with:

if item_payload.merge_to_lot_id:
    merge_target_lot = db.get(StockLot, item_payload.merge_to_lot_id)
    if not merge_target_lot:
        raise HTTPException(status_code=404, detail=f"合并目标批次不存在: {item_payload.merge_to_lot_id}")

Then replace old default lot generation:

lot_no = item_payload.lot_no or _build_purchase_lot_no(
    db,
    material_code=material_item.item_code,
    receipt_dt=receipt.receipt_date,
    po_no=purchase_order.po_no,
    line_no=po_item.line_no,
)

with:

lot_no = item_payload.lot_no or build_inventory_lot_no(db, item_payload.material_item_id)

Keep explicit merge behavior unchanged:

if merge_target_lot:
    final_unit_cost = to_decimal(merge_target_lot.unit_cost, "0.0001")
else:
    final_unit_cost = entered_unit_cost
  • Step 4: Run purchase receipt tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_raw_material_weight_only_flow.py -q

Expected: PASS, including the existing explicit merge test where merge uses the first lot unit cost.

  • Step 5: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/api/routes/purchase.py backend/tests/test_raw_material_weight_only_flow.py
git commit -m "feat: create sequential inventory lots on receipt"

Task 5: Implement Selected Stock Lot Production Issue

Files:

  • Modify: backend/app/schemas/operations.py

  • Modify: backend/app/services/operations.py

  • Modify: backend/app/api/routes/production.py

  • Step 1: Add selected lot payload schemas

In backend/app/schemas/operations.py, add:

class SelectedStockLotIssueCreate(BaseModel):
    source_lot_id: int
    issued_weight_kg: float


class WorkOrderCreate(BaseModel):
    product_item_id: int
    source_sales_order_item_id: int | None = None
    planned_qty: float = 0
    issue_weight_kg: float = 0
    auto_issue_materials: bool = True
    selected_stock_lots: list[SelectedStockLotIssueCreate] = Field(default_factory=list)
    planned_start_time: datetime | None = None
    planned_end_time: datetime | None = None
    priority_level: int | None = None
    remark: str | None = None

WorkOrderCreate already exists in backend/app/schemas/operations.py; modify that existing class and do not add a duplicate class.

  • Step 2: Add selected-lot issue service

In backend/app/services/operations.py, add:

def issue_selected_stock_lots(
    db: Session,
    *,
    work_order_id: int,
    work_order_material_id: int,
    material_item_id: int,
    selected_lots: list[dict[str, Decimal]],
    operator_user_id: int | None = None,
) -> list[WorkOrderMaterialIssue]:
    if not selected_lots:
        raise HTTPException(status_code=400, detail="请选择至少一个库存批次")

    issues: list[WorkOrderMaterialIssue] = []
    for selected in selected_lots:
        source_lot_id = int(selected["source_lot_id"])
        issued_weight = to_decimal(selected["issued_weight_kg"])
        if issued_weight <= 0:
            raise HTTPException(status_code=400, detail="领用重量必须大于0")

        lot = db.get(StockLot, source_lot_id)
        if not lot or lot.item_id != material_item_id:
            raise HTTPException(status_code=404, detail="库存批次不存在或物料不匹配")
        if lot.quality_status != "PASS" or lot.status != "AVAILABLE" or to_decimal(lot.remaining_weight_kg) <= 0:
            raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 不可用")
        if to_decimal(lot.remaining_weight_kg) < issued_weight:
            raise HTTPException(status_code=400, detail=f"库存批次 {lot.lot_no} 剩余重量不足")

        unit_cost = to_decimal(lot.unit_cost, "0.0001")
        amount = (issued_weight * unit_cost).quantize(Decimal("0.01"))
        lot.remaining_qty = to_decimal(0)
        lot.remaining_weight_kg = to_decimal(lot.remaining_weight_kg) - issued_weight
        if to_decimal(lot.remaining_weight_kg) <= 0:
            lot.remaining_weight_kg = to_decimal(0)
            lot.status = "DEPLETED"
        db.add(lot)

        issue = WorkOrderMaterialIssue(
            work_order_material_id=work_order_material_id,
            work_order_id=work_order_id,
            material_item_id=material_item_id,
            source_lot_id=source_lot_id,
            material_sub_batch_no=lot.lot_no,
            issued_qty=to_decimal(0),
            issued_weight_kg=issued_weight,
            unit_cost=unit_cost,
            amount=amount,
            issue_time=datetime.now(),
            operator_user_id=operator_user_id,
            remark="按库存批次生产出库",
        )
        db.add(issue)
        issues.append(issue)

        txn = create_inventory_txn(
            db,
            txn_type="ISSUE_TO_PRODUCTION",
            item_id=material_item_id,
            warehouse_id=lot.warehouse_id,
            location_id=lot.location_id,
            lot_id=lot.id,
            qty_change=to_decimal(0),
            weight_change=-issued_weight,
            unit_cost=unit_cost,
            source_doc_type="WORK_ORDER",
            source_doc_id=work_order_id,
            source_line_id=work_order_material_id,
            biz_time=datetime.now(),
            operator_user_id=operator_user_id,
            remark=f"生产出库:{lot.lot_no}",
            amount_basis="WEIGHT",
        )
        txn.amount = -abs(to_decimal(txn.amount, "0.01"))
        db.add(txn)
        upsert_stock_balance(
            db,
            item_id=material_item_id,
            warehouse_id=lot.warehouse_id,
            location_id=lot.location_id,
            qty_delta=to_decimal(0),
            weight_delta=-issued_weight,
            available_qty_delta=to_decimal(0),
            available_weight_delta=-issued_weight,
            unit_cost=unit_cost,
        )

    db.flush()
    return issues

Call create_inventory_txn and upsert_stock_balance from backend/app/services/operations.py; do not create a second transaction-number helper.

  • Step 3: Wire selected lots into work order creation

In backend/app/api/routes/production.py, change create_work_order so that:

selected_lots = [
    {"source_lot_id": int(row.source_lot_id), "issued_weight_kg": to_decimal(row.issued_weight_kg)}
    for row in payload.selected_stock_lots
]

When selected_lots is non-empty:

payload.auto_issue_materials = False
issue_weight = sum((to_decimal(row["issued_weight_kg"]) for row in selected_lots), Decimal("0"))

After create_work_order_materials_and_operations(...), find the first material row and call:

if selected_lots:
    first_material = db.scalar(
        select(WorkOrderMaterial)
        .where(WorkOrderMaterial.work_order_id == work_order.id)
        .order_by(WorkOrderMaterial.id)
        .limit(1)
    )
    if not first_material:
        raise HTTPException(status_code=400, detail="产品需规缺少默认用料,无法按库存批次出库")
    issue_selected_stock_lots(
        db,
        work_order_id=work_order.id,
        work_order_material_id=first_material.id,
        material_item_id=first_material.material_item_id,
        selected_lots=selected_lots,
        operator_user_id=context.user.id,
    )

Remove this legacy assignment:

if first_issue:
    work_order.work_order_no = first_issue.material_sub_batch_no

Keep _build_work_order_no(db, product) as the production order identifier. The UI should no longer call it “材料分批次号”.

  • Step 4: Run selected-lot tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py -q

Expected: PASS.

  • Step 5: Run production-adjacent tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_raw_material_weight_only_flow.py tests/test_scrap_warehouse_flow.py -q

Expected: PASS.

  • Step 6: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/schemas/operations.py backend/app/services/operations.py backend/app/api/routes/production.py backend/tests/test_selected_stock_lot_production_issue.py
git commit -m "feat: issue production materials by inventory lot"

Task 6: Update Miniapp Report Sync To Treat The Miniapp Field As 库存批次号

Files:

  • Modify: backend/app/api/routes/production.py

  • Modify: frontend/src/views/OperationReportView.vue

  • Step 1: Add a backend resolver for miniapp lot number

In backend/app/api/routes/production.py, replace resolver logic that queries:

WorkOrderMaterialIssue.material_sub_batch_no == batch_no

with a resolver that first uses stock lot number:

def _resolve_work_order_by_inventory_lot_no(db: Session, lot_no: str) -> WorkOrderMaterialIssue | None:
    return db.scalar(
        select(WorkOrderMaterialIssue)
        .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
        .where(StockLot.lot_no == lot_no)
        .order_by(WorkOrderMaterialIssue.id.desc())
        .limit(1)
    )

For compatibility, if this returns None, fall back to:

db.scalar(
    select(WorkOrderMaterialIssue)
    .where(WorkOrderMaterialIssue.material_sub_batch_no == lot_no)
    .order_by(WorkOrderMaterialIssue.id.desc())
    .limit(1)
)
  • Step 2: Change miniapp error text

Replace:

"小程序报工缺少有效原材料分批次号,不能同步为 ERP 报工"

with:

"小程序报工缺少有效库存批次号,不能同步为 ERP 报工"

Replace:

"该材料分批次缺少可匹配的小程序报工工序"

with:

"该库存批次缺少可匹配的小程序报工工序"
  • Step 3: Rename frontend operation report labels

In frontend/src/views/OperationReportView.vue, replace user-facing strings:

材料分批次号
原材料分批次号

with:

库存批次号

Keep the raw API field name if the miniapp still sends raw_material_batch_no.

  • Step 4: Run production route tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_selected_stock_lot_production_issue.py tests/test_product_spec_miniapp_material_sync.py -q

Expected: PASS.

  • Step 5: Run frontend build smoke

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: PASS. Existing Vite chunk-size warnings are acceptable if no new error is introduced.

  • Step 6: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/api/routes/production.py frontend/src/views/OperationReportView.vue
git commit -m "feat: sync miniapp reports by inventory lot"

Task 7: Update Finished Goods Receipt And Delivery Trace

Files:

  • Modify: backend/app/api/routes/production.py

  • Modify: backend/app/api/routes/sales.py

  • Modify: backend/app/services/operations.py

  • Modify: backend/tests/test_sales_order_delivery_trace.py

  • Step 1: Update sales delivery trace test expectation

In backend/tests/test_sales_order_delivery_trace.py, change the seeded finished lot:

source_material_sub_batch_no="JH_MAT001_0001",
source_material_summary="JH_MAT001_0001 / 冷轧钢板 / 40kg / ¥200",

to:

source_material_sub_batch_no="RML-001",
source_material_summary="RML-001 / 冷轧钢板 / 40kg / ¥200",

Then update assertions so trace rows expect source inventory lot number:

self.assertEqual(trace_row["source_material_lot_no"], "RML-001")
self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001")
  • Step 2: Change completion source summary builder

In backend/app/api/routes/production.py, where source material rows are summarized, replace summary pieces using:

row["material_sub_batch_no"]

with:

row["source_lot_no"]

The summary line should be:

f"{row['source_lot_no']} / {row['material_name']} / {weight}kg / ¥{amount}"

Set completion source fields as:

"source_material_lot_id": first["source_lot_id"],
"source_material_sub_batch_no": first["source_lot_no"],
  • Step 3: Update delivery item query labels

In backend/app/services/operations.py, update get_delivery_items_query so:

source_material_lot_no

comes from source_lot.lot_no where available, and source_material_sub_batch_no is a compatibility alias equal to that same inventory lot number for new rows.

Use this expression:

func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label("source_material_lot_no")

And:

func.coalesce(source_material_lot.lot_no, StockLot.source_material_sub_batch_no).label("source_material_sub_batch_no")
  • Step 4: Run delivery trace test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q

Expected: PASS.

  • Step 5: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/api/routes/production.py backend/app/services/operations.py backend/tests/test_sales_order_delivery_trace.py
git commit -m "feat: trace finished goods to inventory lots"

Task 8: Implement Multiple Sales Orders Per Purchase Order

Files:

  • Modify: backend/app/schemas/operations.py

  • Modify: backend/app/api/routes/purchase.py

  • Modify: backend/app/services/operations.py

  • Modify: backend/tests/test_purchase_order_multi_sales_links.py

  • Modify: backend/tests/test_sales_order_purchase_lock_flow.py

  • Step 1: Add schema fields

In backend/app/schemas/operations.py, add to PurchaseOrderCreate:

sales_order_ids: list[int] = Field(default_factory=list)

Add to PurchaseOrderRead:

sales_order_ids: list[int] = Field(default_factory=list)
  • Step 2: Add helper functions in purchase route

In backend/app/api/routes/purchase.py, add:

def _replace_purchase_order_sales_links(db: Session, purchase_order_id: int, sales_order_ids: list[int]) -> None:
    normalized_ids = sorted({int(order_id) for order_id in sales_order_ids if int(order_id) > 0})
    db.query(PurchaseOrderSalesOrderLink).filter(
        PurchaseOrderSalesOrderLink.purchase_order_id == purchase_order_id
    ).delete(synchronize_session=False)
    for sales_order_id in normalized_ids:
        sales_order = db.get(SalesOrder, sales_order_id)
        if not sales_order:
            raise HTTPException(status_code=404, detail=f"销售订单不存在: {sales_order_id}")
        db.add(
            PurchaseOrderSalesOrderLink(
                purchase_order_id=purchase_order_id,
                sales_order_id=sales_order_id,
                created_at=datetime.now(),
                updated_at=datetime.now(),
            )
        )


def _sales_order_ids_from_items(db: Session, items: list[PurchaseOrderItemCreate]) -> list[int]:
    ids: set[int] = set()
    for item in items:
        if not item.source_demand_id:
            continue
        row = db.execute(
            select(SalesOrder.id)
            .join(SalesOrderItem, SalesOrderItem.sales_order_id == SalesOrder.id)
            .join(MaterialDemand, MaterialDemand.sales_order_item_id == SalesOrderItem.id)
            .where(MaterialDemand.id == item.source_demand_id)
        ).scalar_one_or_none()
        if row:
            ids.add(int(row))
    return sorted(ids)

Import PurchaseOrderSalesOrderLink, SalesOrder, SalesOrderItem, MaterialDemand, and datetime if they are not already imported in this file.

  • Step 3: Call helper from create/update

After purchase_order is flushed in create_purchase_order, call:

explicit_sales_order_ids = payload.sales_order_ids or []
inferred_sales_order_ids = _sales_order_ids_from_items(db, payload.items)
linked_sales_order_ids = sorted({*explicit_sales_order_ids, *inferred_sales_order_ids})
_replace_purchase_order_sales_links(db, purchase_order.id, linked_sales_order_ids)
if linked_sales_order_ids:
    purchase_order.source_type = "SALES_ORDER"

In update_purchase_order, after replacing items, call the same logic.

  • Step 4: Update purchase order query source summary

In backend/app/api/routes/purchase.py, add a post-query enrichment helper. This avoids dialect-specific GROUP_CONCAT syntax and works in both MySQL and SQLite tests:

def _enrich_purchase_order_sources(db: Session, rows: list[dict]) -> list[dict]:
    purchase_order_ids = [int(row["purchase_order_id"]) for row in rows]
    if not purchase_order_ids:
        return rows
    link_rows = db.execute(
        select(
            PurchaseOrderSalesOrderLink.purchase_order_id.label("purchase_order_id"),
            SalesOrder.id.label("sales_order_id"),
            SalesOrder.order_no.label("order_no"),
        )
        .join(SalesOrder, SalesOrder.id == PurchaseOrderSalesOrderLink.sales_order_id)
        .where(PurchaseOrderSalesOrderLink.purchase_order_id.in_(purchase_order_ids))
        .order_by(PurchaseOrderSalesOrderLink.purchase_order_id, SalesOrder.order_no)
    ).mappings().all()
    links_by_po: dict[int, list[dict]] = {}
    for link in link_rows:
        links_by_po.setdefault(int(link["purchase_order_id"]), []).append(dict(link))
    for row in rows:
        links = links_by_po.get(int(row["purchase_order_id"]), [])
        if links:
            row["sales_order_ids"] = [int(link["sales_order_id"]) for link in links]
            row["source_order_nos"] = "、".join(str(link["order_no"]) for link in links)
            row["source_type"] = "SALES_ORDER"
        else:
            row["sales_order_ids"] = []
    return rows

Call _enrich_purchase_order_sources in list/detail read paths before converting rows to PurchaseOrderRead.

  • Step 5: Run multi-sales-order tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_purchase_order_multi_sales_links.py tests/test_sales_order_purchase_lock_flow.py -q

Expected: PASS.

  • Step 6: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/schemas/operations.py backend/app/api/routes/purchase.py backend/app/services/operations.py backend/tests/test_purchase_order_multi_sales_links.py backend/tests/test_sales_order_purchase_lock_flow.py
git commit -m "feat: link purchase orders to multiple sales orders"

Task 9: Update Frontend Stock Lot Multi-Select And Production Flow

Files:

  • Create: frontend/src/components/StockLotTagSelect.vue

  • Modify: frontend/src/views/WorkOrderManagementView.vue

  • Modify: frontend/src/views/InventoryLedgerView.vue

  • Modify: frontend/src/services/api.js

  • Step 1: Create stock lot tag select component

Create frontend/src/components/StockLotTagSelect.vue:

<template>
  <div class="stock-lot-tag-select" :class="{ open: dropdownOpen }">
    <div class="tag-input-shell" @click="focusInput">
      <span v-for="lot in selectedLots" :key="lot.lot_id" class="selected-lot-tag">
        {{ lot.lot_no }}
        <button type="button" class="tag-remove" :title="`移除 ${lot.lot_no}`" @click.stop="removeLot(lot.lot_id)">×</button>
      </span>
      <input
        ref="inputRef"
        v-model.trim="keyword"
        type="text"
        :placeholder="selectedLots.length ? '' : placeholder"
        @focus="dropdownOpen = true"
      />
    </div>
    <div v-if="dropdownOpen" class="stock-lot-dropdown">
      <button
        v-for="lot in filteredLots"
        :key="lot.lot_id"
        type="button"
        class="stock-lot-option"
        :class="{ disabled: !lot.is_available_for_issue, selected: selectedIds.includes(lot.lot_id) }"
        :disabled="!lot.is_available_for_issue"
        @click="toggleLot(lot)"
      >
        <span class="lot-main">{{ lot.lot_no }} · {{ lot.item_name }}</span>
        <span class="lot-meta">剩余 {{ formatWeight(lot.remaining_weight_kg) }} · ¥{{ formatAmount(lot.unit_cost) }}/kg</span>
        <span v-if="!lot.is_available_for_issue" class="lot-disabled">{{ lot.disabled_reason || '不可用' }}</span>
      </button>
      <p v-if="!filteredLots.length" class="lot-empty">没有匹配的库存批次</p>
    </div>
  </div>
</template>

<script setup>
import { computed, ref } from "vue";
import { formatAmount, formatWeight } from "../utils/formatters";

const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  options: { type: Array, default: () => [] },
  placeholder: { type: String, default: "搜索库存批次号" }
});
const emit = defineEmits(["update:modelValue"]);

const keyword = ref("");
const dropdownOpen = ref(false);
const inputRef = ref(null);

const selectedIds = computed(() => props.modelValue.map((item) => Number(item.lot_id)));
const selectedLots = computed(() => props.modelValue);
const filteredLots = computed(() => {
  const text = keyword.value.toLowerCase();
  return props.options
    .filter((lot) => {
      if (!text) return true;
      return [lot.lot_no, lot.item_code, lot.item_name, lot.disabled_reason].some((value) =>
        String(value || "").toLowerCase().includes(text)
      );
    })
    .sort((left, right) => {
      if (Number(left.is_available_for_issue) !== Number(right.is_available_for_issue)) {
        return Number(right.is_available_for_issue) - Number(left.is_available_for_issue);
      }
      return String(left.lot_no || "").localeCompare(String(right.lot_no || ""));
    });
});

function focusInput() {
  dropdownOpen.value = true;
  inputRef.value?.focus();
}

function toggleLot(lot) {
  if (!lot.is_available_for_issue) return;
  if (selectedIds.value.includes(Number(lot.lot_id))) {
    removeLot(lot.lot_id);
    return;
  }
  emit("update:modelValue", [...props.modelValue, lot]);
  keyword.value = "";
}

function removeLot(lotId) {
  emit("update:modelValue", props.modelValue.filter((lot) => Number(lot.lot_id) !== Number(lotId)));
}
</script>

Add styles in the same file matching existing Ruoyi/card UI style:

<style scoped>
.stock-lot-tag-select {
  position: relative;
}
.tag-input-shell {
  min-height: 40px;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: center;
  padding: 6px 10px;
  border: 1px solid var(--border-color, #dcdfe6);
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.92);
}
.tag-input-shell input {
  flex: 1;
  min-width: 140px;
  border: 0;
  outline: 0;
  background: transparent;
}
.selected-lot-tag {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 8px;
  border-radius: 999px;
  color: #155bd4;
  background: #e8f1ff;
  border: 1px solid #b8d5ff;
  font-size: 12px;
}
.tag-remove {
  border: 0;
  background: transparent;
  color: #155bd4;
  cursor: pointer;
}
.stock-lot-dropdown {
  position: absolute;
  z-index: 30;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 280px;
  overflow: auto;
  border: 1px solid #dcdfe6;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 14px 30px rgba(15, 23, 42, 0.16);
}
.stock-lot-option {
  width: 100%;
  display: grid;
  gap: 2px;
  padding: 9px 12px;
  border: 0;
  border-bottom: 1px solid #f1f5f9;
  background: #fff;
  text-align: left;
  cursor: pointer;
}
.stock-lot-option:hover:not(.disabled),
.stock-lot-option.selected {
  background: #eef6ff;
}
.stock-lot-option.disabled {
  color: #9ca3af;
  background: #f8fafc;
  cursor: not-allowed;
}
.lot-main {
  font-weight: 700;
}
.lot-meta,
.lot-disabled,
.lot-empty {
  font-size: 12px;
  color: #64748b;
}
.lot-empty {
  padding: 12px;
}
</style>
  • Step 2: Wire component into work order creation

In frontend/src/views/WorkOrderManagementView.vue, import:

import StockLotTagSelect from "../components/StockLotTagSelect.vue";

Add state:

const stockLotOptions = ref([]);
const selectedStockLots = ref([]);

Load options:

async function loadStockLotOptions(materialItemId) {
  if (!materialItemId) {
    stockLotOptions.value = [];
    return;
  }
  stockLotOptions.value = await fetchResource(`/inventory/stock-lot-options?material_item_id=${Number(materialItemId)}&limit=1000`);
}

In the create drawer, replace material-sub-batch generation wording with:

<StockLotTagSelect
  v-model="selectedStockLots"
  :options="stockLotOptions"
  placeholder="搜索并选择库存批次号,可多选"
/>

For each selected lot, render an issue-weight input:

<div v-for="lot in selectedStockLots" :key="lot.lot_id" class="selected-lot-weight-row">
  <span>{{ lot.lot_no }}</span>
  <input v-model.number="lot.issued_weight_kg" type="number" min="0" step="0.001" :max="lot.remaining_weight_kg" />
  <span>可用 {{ formatWeight(lot.remaining_weight_kg) }}</span>
</div>

Build payload:

selected_stock_lots: selectedStockLots.value.map((lot) => ({
  source_lot_id: Number(lot.lot_id),
  issued_weight_kg: Number(lot.issued_weight_kg || lot.remaining_weight_kg || 0)
}))
  • Step 3: Rename production wording in inventory ledger

In frontend/src/views/InventoryLedgerView.vue, replace:

生成材料分批次号
材料分批次号
领用原材料生成材料分批次号,后续小程序报工围绕该分批次号推进。

with:

生产出库
库存批次号
选择原材料库存批次出库,后续小程序报工按库存批次号推进。
  • Step 4: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: PASS.

  • Step 5: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add frontend/src/components/StockLotTagSelect.vue frontend/src/views/WorkOrderManagementView.vue frontend/src/views/InventoryLedgerView.vue frontend/src/services/api.js
git commit -m "feat: select production inventory lots in frontend"

Task 10: Update Purchase Order Frontend For Multiple Sales Orders

Files:

  • Modify: frontend/src/views/PurchaseOrderView.vue

  • Reuse: frontend/src/components/GlobalSearchableSelect.vue

  • Step 1: Replace single sales order field with multi-tag state

In frontend/src/views/PurchaseOrderView.vue, replace:

sales_order_id: "",

with:

sales_order_ids: [],

Add computed selected orders:

const selectedSalesOrders = computed(() =>
  salesOrders.value.filter((order) => form.sales_order_ids.map(Number).includes(Number(order.order_id)))
);
  • Step 2: Add multi-select UI

Replace the single <select v-model.number="form.sales_order_id"> with a local searchable multi-select tag field:

<div v-if="form.purchase_type === 'SALES_ORDER'" class="order-tag-picker">
  <label class="form-field">
    <span>关联销售订单</span>
    <input v-model.trim="salesOrderKeyword" type="text" placeholder="搜索销售订单号、客户、产品" @focus="salesOrderPickerOpen = true" />
  </label>
  <div class="selected-order-tags">
    <span v-for="order in selectedSalesOrders" :key="order.order_id" class="selected-lot-tag">
      {{ order.order_no }}
      <button type="button" class="tag-remove" @click="removeSalesOrder(order.order_id)">×</button>
    </span>
  </div>
  <div v-if="salesOrderPickerOpen" class="stock-lot-dropdown">
    <button
      v-for="order in filteredSalesOrders"
      :key="order.order_id"
      type="button"
      class="stock-lot-option"
      @click="toggleSalesOrder(order.order_id)"
    >
      <span class="lot-main">{{ order.order_no }} · {{ order.customer_name }}</span>
      <span class="lot-meta">{{ order.product_names || '-' }} · {{ order.status }}</span>
    </button>
  </div>
</div>

Add functions:

function toggleSalesOrder(orderId) {
  const id = Number(orderId);
  if (form.sales_order_ids.map(Number).includes(id)) {
    form.sales_order_ids = form.sales_order_ids.filter((value) => Number(value) !== id);
    return;
  }
  form.sales_order_ids = [...form.sales_order_ids, id];
}

function removeSalesOrder(orderId) {
  form.sales_order_ids = form.sales_order_ids.filter((value) => Number(value) !== Number(orderId));
}
  • Step 3: Update payload

When submitting purchase orders, send:

sales_order_ids: form.purchase_type === "SALES_ORDER" ? form.sales_order_ids.map(Number) : [],

Remove all remaining payload uses of:

sales_order_id: form.sales_order_id
  • Step 4: Update demand calculation flow

Change applySalesOrderPurchase to loop selected order ids:

const allDemandRows = [];
for (const orderId of form.sales_order_ids) {
  const result = await postResource(`/planning/generate-mrp/${Number(orderId)}`, {});
  allDemandRows.push(...(result.demands || []));
}
applyDemandRowsToPurchaseItems(allDemandRows);

Add applyDemandRowsToPurchaseItems(rows) that groups by material_item_id and sums suggested_purchase_weight_kg, preserving one source demand id per material row for detail:

function applyDemandRowsToPurchaseItems(rows) {
  const grouped = new Map();
  rows.forEach((row) => {
    const key = Number(row.material_item_id);
    if (!grouped.has(key)) {
      grouped.set(key, { ...row, suggested_purchase_weight_kg: 0 });
    }
    grouped.get(key).suggested_purchase_weight_kg += Number(row.suggested_purchase_weight_kg || 0);
  });
  form.items = Array.from(grouped.values()).map((row, index) => ({
    line_no: index + 1,
    material_item_id: Number(row.material_item_id),
    source_demand_id: row.demand_id || row.source_demand_id || null,
    order_weight_kg: Number(row.suggested_purchase_weight_kg || 0),
    unit_price: Number(row.reference_unit_price || 0),
    remark: `销售订单采购:${form.sales_order_ids.length}张订单合并需求`
  }));
}
  • Step 5: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: PASS.

  • Step 6: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add frontend/src/views/PurchaseOrderView.vue
git commit -m "feat: select multiple sales orders for purchase"

Task 11: Rename Downstream UI Labels And Search Fields

Files:

  • Modify: frontend/src/views/CompletionReceiptView.vue

  • Modify: frontend/src/views/DeliveryManagementView.vue

  • Modify: frontend/src/views/SalesPlanningView.vue

  • Modify: frontend/src/views/DashboardView.vue

  • Modify: frontend/src/components/StocktakeDialog.vue

  • Step 1: Replace user-facing material-sub-batch wording

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "材料分批次|原材料分批次|来源分批次|材料分批号|来源分批号" frontend/src

Expected: The command prints remaining frontend occurrences.

For each occurrence that is user-facing, replace with:

库存批次号
来源库存批次号

Keep JavaScript object keys unchanged only when they match backend compatibility fields.

  • Step 2: Update key table columns

In frontend/src/views/CompletionReceiptView.vue, change table headers:

材料分批次号 -> 来源库存批次号

In frontend/src/views/DeliveryManagementView.vue, change table headers:

来源材料分批次号 -> 来源库存批次号

In frontend/src/views/SalesPlanningView.vue, change contract and trace labels:

材料分批次号 -> 来源库存批次号

In frontend/src/views/DashboardView.vue, change:

原料批次号 -> 库存批次号
分批领料 -> 生产出库
  • Step 3: Update search placeholder text

Replace placeholders such as:

搜索报废单号、材料分批次号、工序、产品、状态

with:

搜索报废单号、库存批次号、工序、产品、状态
  • Step 4: Run frontend text scan

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "材料分批次|原材料分批次|来源分批次|材料分批号|来源分批号" frontend/src

Expected: No user-facing labels remain. Compatibility field names such as source_material_sub_batch_no may remain in script code.

  • Step 5: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: PASS.

  • Step 6: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add frontend/src/views/CompletionReceiptView.vue frontend/src/views/DeliveryManagementView.vue frontend/src/views/SalesPlanningView.vue frontend/src/views/DashboardView.vue frontend/src/components/StocktakeDialog.vue
git commit -m "refactor: rename material sub-batch UI to inventory lot"

Task 12: Update Dashboard DAG Backend To Use Inventory Lot Nodes

Files:

  • Modify: backend/app/api/routes/dashboard.py

  • Modify: backend/app/schemas/dashboard.py

  • Step 1: Rename DAG node detail labels

In backend/app/api/routes/dashboard.py, replace DAG detail labels:

("材料分批号", row["material_sub_batch_no"])
("来源分批号", row["source_material_sub_batch_no"])

with:

("库存批次号", row["source_lot_no"])
("来源库存批次号", row["source_material_lot_no"])

Add source_lot_no to the production issue query:

StockLot.lot_no.label("source_lot_no")

on the join to WorkOrderMaterialIssue.source_lot_id.

  • Step 2: Rename production issue nodes

Change issue node titles from:

title=row["material_sub_batch_no"]
summary="原材料分批领料..."

to:

title=row["source_lot_no"]
summary="原材料按库存批次生产出库,记录领用重量、单价和成本。"
  • Step 3: Keep compatibility fallback

For older rows without source_lot_no, use:

source_lot_no = row["source_lot_no"] or row["material_sub_batch_no"] or "未记录库存批次号"

Use source_lot_no in node titles and details.

  • Step 4: Run backend dashboard import smoke

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python - <<'PY'
import app.api.routes.dashboard as dashboard
print("dashboard import ok", dashboard.router.prefix if hasattr(dashboard.router, "prefix") else "router")
PY

Expected: Prints dashboard import ok.

  • Step 5: Run backend tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py tests/test_selected_stock_lot_production_issue.py -q

Expected: PASS.

  • Step 6: Checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend/app/api/routes/dashboard.py backend/app/schemas/dashboard.py
git commit -m "refactor: show inventory lot trace in dashboard dag"

Task 13: Final Full Verification

Files:

  • No new files.

  • Validate all modified backend and frontend files.

  • Step 1: Run full backend test suite

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
../backend/.venv/bin/python -m pytest tests -q

Expected: All tests PASS.

  • Step 2: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: Build PASS. Existing Vite chunk warnings are acceptable; syntax/type errors are not.

  • Step 3: Run terminology scan

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "材料分批次|原材料分批次|材料分批号|来源分批号" frontend/src backend/app

Expected: Only backend compatibility field names, migration comments, and intentional fallback logic remain. No user-facing Chinese labels should remain.

  • Step 4: Run raw material quantity regression scan

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "原材料.*数量|收货数量|领用数量|remaining_qty|received_qty|issued_qty" frontend/src/views backend/app/api backend/app/services

Expected: Raw material UI does not show quantity concepts. Backend compatibility decimal fields may remain but should be written as zero for raw material flows.

  • Step 5: Manual smoke scenario

Use the local ERP UI and run this business scenario:

1. 新建或选择一个原材料。
2. 新建采购订单,选择原材料库、备料采购,采购重量 500kg锁单。
3. 创建第 1 张到货入库单,入库 200kg单价默认采购单价可手动修改。
4. 创建第 2 张到货入库单,入库 300kg。
5. 确认原材料库详情显示 JH_原材料编码_0001 和 JH_原材料编码_0002。
6. 生产出库时搜索该原材料,选择两个库存批次,分别填写领用重量。
7. 确认工序报工和成品入库能按库存批次号追溯。
8. 创建销售订单采购,关联两个销售订单,确认采购来源小卡片展示两个销售订单。
9. 打开 DAG确认链路为库存批次号 -> 生产出库/报工 -> 成品批次 -> 发货。

Expected: No “材料分批次号”用户概念出现,所有追溯都围绕库存批次号。

  • Step 6: Final checkpoint

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git status --short || true

If git is available:

git add backend frontend docs/superpowers/plans/2026-05-31-inventory-lot-unified-trace.md
git commit -m "feat: unify raw material trace by inventory lot"

Self-Review

Spec coverage: Covered removal of “材料分批次号” as a user concept, new 原材料物料 -> 库存批次号 hierarchy, JH_材料号_0001 numbering, split purchase arrivals, final receipt unit cost, selected multi-lot production issue, disabled depleted lots, miniapp field remapping, sales order multi-select for purchase orders, and downstream DAG/trace updates.

Placeholder scan: This plan contains no TBD, no unresolved TODO, and no intentionally vague “handle errors” step. Compatibility cases are spelled out.

Type consistency: Backend payload names use sales_order_ids, selected_stock_lots, source_lot_id, and issued_weight_kg. Frontend payload names match those schemas. Legacy source_material_sub_batch_no remains only as compatibility alias.