# 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 miniapp’s 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:

```python
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:

```python
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:

```python
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:

```bash
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:

```bash
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:

```bash
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`:

```python
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`:

```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:

```bash
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:

```bash
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:

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

If git is available:

```bash
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
@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:

```bash
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:

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

If git is available:

```bash
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`:

```python
    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:

```bash
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:

```python
from app.services.operations import build_inventory_lot_no
```

In `create_purchase_receipt`, remove this block:

```python
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:

```python
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:

```python
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:

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

Keep explicit merge behavior unchanged:

```python
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:

```bash
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:

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

If git is available:

```bash
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```bash
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:

```bash
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:

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

If git is available:

```bash
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:

```python
WorkOrderMaterialIssue.material_sub_batch_no == batch_no
```

with a resolver that first uses stock lot number:

```python
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:

```python
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:

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

with:

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

Replace:

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

with:

```python
"该库存批次缺少可匹配的小程序报工工序"
```

- [ ] **Step 3: Rename frontend operation report labels**

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

```text
材料分批次号
原材料分批次号
```

with:

```text
库存批次号
```

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

- [ ] **Step 4: Run production route tests**

Run:

```bash
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:

```bash
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:

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

If git is available:

```bash
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:

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

to:

```python
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:

```python
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:

```python
row["material_sub_batch_no"]
```

with:

```python
row["source_lot_no"]
```

The summary line should be:

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

Set completion source fields as:

```python
"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:

```python
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:

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

And:

```python
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:

```bash
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:

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

If git is available:

```bash
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`:

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

Add to `PurchaseOrderRead`:

```python
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:

```python
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:

```python
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:

```python
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:

```bash
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:

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

If git is available:

```bash
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`:

```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:

```vue
<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:

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

Add state:

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

Load options:

```js
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:

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

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

```vue
<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:

```js
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:

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

with:

```text
生产出库
库存批次号
选择原材料库存批次出库，后续小程序报工按库存批次号推进。
```

- [ ] **Step 4: Run frontend build**

Run:

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

Expected: PASS.

- [ ] **Step 5: Checkpoint**

Run:

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

If git is available:

```bash
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:

```js
sales_order_id: "",
```

with:

```js
sales_order_ids: [],
```

Add computed selected orders:

```js
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:

```vue
<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:

```js
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:

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

Remove all remaining payload uses of:

```js
sales_order_id: form.sales_order_id
```

- [ ] **Step 4: Update demand calculation flow**

Change `applySalesOrderPurchase` to loop selected order ids:

```js
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:

```js
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:

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

Expected: PASS.

- [ ] **Step 6: Checkpoint**

Run:

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

If git is available:

```bash
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:

```bash
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:

```text
库存批次号
来源库存批次号
```

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:

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

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

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

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

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

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

```text
原料批次号 -> 库存批次号
分批领料 -> 生产出库
```

- [ ] **Step 3: Update search placeholder text**

Replace placeholders such as:

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

with:

```text
搜索报废单号、库存批次号、工序、产品、状态
```

- [ ] **Step 4: Run frontend text scan**

Run:

```bash
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:

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

Expected: PASS.

- [ ] **Step 6: Checkpoint**

Run:

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

If git is available:

```bash
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:

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

with:

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

Add `source_lot_no` to the production issue query:

```python
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:

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

to:

```python
title=row["source_lot_no"]
summary="原材料按库存批次生产出库，记录领用重量、单价和成本。"
```

- [ ] **Step 3: Keep compatibility fallback**

For older rows without `source_lot_no`, use:

```python
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:

```bash
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:

```bash
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:

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

If git is available:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```bash
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:

```text
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:

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

If git is available:

```bash
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.
