# Raw Lot Work Order Inbound Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Implement原材料库存批次号全库递增、生产出库单批次合并工单、小程序按材料库存批次号报工、以及嘉恒仓库“生产工单入库”统一闭环成品/余料/废料。

**Architecture:** Add a small system-config service for the raw lot prefix, keep ERP production work orders as the internal container, and make raw stock lot IDs the external production-facing identifier. Production outbound becomes single-lot and merges into a deterministic daily work order; production work-order inbound becomes one transactional backend API that posts finished goods, returns surplus to the original raw lot, posts scrap, and optionally settles the work order.

**Tech Stack:** FastAPI, SQLAlchemy ORM, MySQL/SQLite-compatible tests, Vue 3 + Vite, WeChat miniapp frontend/backend, existing `pytest` and Node script tests.

---

## File Structure

Backend ERP files:

- Create `backend/sql/add_raw_lot_work_order_inbound_patch.sql`: create generic `sys_system_config` table and seed `RAW_MATERIAL_LOT_PREFIX=YL`.
- Create `backend/app/services/system_config.py`: normalize config values and read/write `sys_system_config`.
- Create `backend/app/services/production_work_order_inbound.py`: calculation and transactional posting for production work-order inbound.
- Modify `backend/app/models/org.py`: add `SystemConfig` ORM model.
- Modify `backend/app/schemas/database.py`: add config read/update schemas.
- Modify `backend/app/schemas/operations.py`: add production work-order inbound preview/create/read schemas.
- Modify `backend/app/api/routes/system_extension.py`: expose raw material lot prefix config endpoints.
- Modify `backend/app/services/operations.py`: replace material-name lot numbering with raw-warehouse global prefix numbering and add deterministic work-order number helper.
- Modify `backend/app/api/routes/purchase.py`: keep purchase receipt path on `build_inventory_lot_no`, add test coverage only unless code needs explicit import changes.
- Modify `backend/app/api/routes/inventory.py`: use new raw lot numbering for raw opening/customer-supplied/special inbound paths and add read helpers used by frontend.
- Modify `backend/app/api/routes/production.py`: enforce single stock lot production outbound, merge same-day same-lot same-product work orders, resolve miniapp reports by material stock lot no, and expose production work-order inbound endpoints.

ERP tests:

- Create `backend/tests/test_system_config_raw_lot_prefix.py`.
- Modify `backend/tests/test_inventory_lot_numbering.py`.
- Modify `backend/tests/test_customer_supplied_inbound_lot_no.py`.
- Modify `backend/tests/test_opening_inventory_import.py`.
- Modify `backend/tests/test_selected_stock_lot_production_issue.py`.
- Create `backend/tests/test_production_work_order_inbound.py`.

ERP frontend files:

- Modify `frontend/src/components/StockLotTagSelect.vue`: add `maxSelections` support for single-select production outbound.
- Modify `frontend/src/views/SystemExtensionView.vue`: add raw lot prefix config card.
- Modify `frontend/src/views/InventoryLedgerView.vue`: production outbound single-select UI and new “生产工单入库” entry/drawer.
- Create `frontend/scripts/test-production-work-order-inbound-ui.mjs`.
- Modify `frontend/scripts/test-special-adjustment-ui.mjs` only if shared warehouse action ordering assertions need updating.

Miniapp files:

- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`: return material stock lot numbers instead of `JH0001` work-order short codes.
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`.
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`.
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`.
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml`.
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`.
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`.
- Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`.

---

### Task 1: Add System Config Storage For Raw Lot Prefix

**Files:**

- Create: `backend/sql/add_raw_lot_work_order_inbound_patch.sql`
- Create: `backend/app/services/system_config.py`
- Modify: `backend/app/models/org.py`
- Modify: `backend/app/schemas/database.py`
- Modify: `backend/app/api/routes/system_extension.py`
- Test: `backend/tests/test_system_config_raw_lot_prefix.py`

- [ ] **Step 1: Write failing backend tests for config default, normalization, and update**

Add `backend/tests/test_system_config_raw_lot_prefix.py`:

```python
from __future__ import annotations

import unittest
from datetime import datetime

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


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


import app.models.master_data  # noqa: E402,F401
import app.models.miniapp  # noqa: E402,F401
import app.models.operations  # noqa: E402,F401
import app.models.org  # noqa: E402,F401
import app.models.planning  # noqa: E402,F401
import app.models.sales  # noqa: E402,F401
from app.models.base import Base  # noqa: E402
from app.models.org import SystemConfig  # noqa: E402
from app.services.system_config import (  # noqa: E402
    RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
    get_raw_material_lot_prefix,
    normalize_raw_material_lot_prefix,
    upsert_system_config,
)


class RawLotPrefixSystemConfigTest(unittest.TestCase):
    def setUp(self) -> None:
        engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
        Base.metadata.create_all(engine)
        self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
        self.db: Session = self.SessionLocal()

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

    def test_default_prefix_is_yl_when_config_missing(self) -> None:
        self.assertEqual(get_raw_material_lot_prefix(self.db), "YL")

    def test_prefix_normalization_keeps_only_uppercase_letters_and_numbers(self) -> None:
        self.assertEqual(normalize_raw_material_lot_prefix(" yl-01 "), "YL01")
        self.assertEqual(normalize_raw_material_lot_prefix(""), "YL")
        self.assertEqual(normalize_raw_material_lot_prefix("材料"), "YL")

    def test_upsert_raw_lot_prefix_config(self) -> None:
        now = datetime.now()
        upsert_system_config(
            self.db,
            config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
            config_name="原材料库存批次号前缀",
            config_value="rm",
            remark="测试配置",
            updated_by=1,
            now=now,
        )
        self.db.commit()

        row = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE))
        self.assertIsNotNone(row)
        self.assertEqual(row.config_value, "RM")
        self.assertEqual(get_raw_material_lot_prefix(self.db), "RM")
```

- [ ] **Step 2: Run failing config tests**

Run:

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

Expected: FAIL because `SystemConfig` and `app.services.system_config` do not exist.

- [ ] **Step 3: Add SQL patch**

Create `backend/sql/add_raw_lot_work_order_inbound_patch.sql`:

```sql
CREATE TABLE IF NOT EXISTS sys_system_config (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  config_code VARCHAR(100) NOT NULL,
  config_name VARCHAR(100) NOT NULL,
  config_value VARCHAR(500) NOT NULL,
  remark VARCHAR(500) NULL,
  status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
  created_by BIGINT NULL,
  updated_by BIGINT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_sys_system_config_code (config_code),
  KEY idx_sys_system_config_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

INSERT INTO sys_system_config (config_code, config_name, config_value, remark, status)
SELECT 'RAW_MATERIAL_LOT_PREFIX', '原材料库存批次号前缀', 'YL', '原材料库存批次号默认前缀，例如 YL0001', 'ACTIVE'
WHERE NOT EXISTS (
  SELECT 1 FROM sys_system_config WHERE config_code = 'RAW_MATERIAL_LOT_PREFIX'
);
```

- [ ] **Step 4: Add ORM model**

Modify `backend/app/models/org.py` after `AiAssistantConfig`:

```python
class SystemConfig(Base):
    __tablename__ = "sys_system_config"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    config_code: Mapped[str] = mapped_column(String(100))
    config_name: Mapped[str] = mapped_column(String(100))
    config_value: Mapped[str] = mapped_column(String(500))
    remark: Mapped[str | None] = mapped_column(String(500), nullable=True)
    status: Mapped[str] = mapped_column(String(32), server_default=text("'ACTIVE'"))
    created_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
    updated_by: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
    created_at: Mapped[object] = mapped_column(DateTime)
    updated_at: Mapped[object] = mapped_column(DateTime)
```

- [ ] **Step 5: Add config service**

Create `backend/app/services/system_config.py`:

```python
from __future__ import annotations

import re
from datetime import datetime

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.org import SystemConfig

RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE = "RAW_MATERIAL_LOT_PREFIX"
RAW_MATERIAL_LOT_PREFIX_DEFAULT = "YL"


def normalize_raw_material_lot_prefix(value: object | None) -> str:
    normalized = "".join(re.findall(r"[A-Za-z0-9]", str(value or "").upper()))
    return normalized or RAW_MATERIAL_LOT_PREFIX_DEFAULT


def get_system_config_value(db: Session, config_code: str, default: str = "") -> str:
    row = db.scalar(
        select(SystemConfig)
        .where(SystemConfig.config_code == config_code, SystemConfig.status == "ACTIVE")
        .limit(1)
    )
    return str(row.config_value if row else default)


def get_raw_material_lot_prefix(db: Session) -> str:
    return normalize_raw_material_lot_prefix(
        get_system_config_value(db, RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, RAW_MATERIAL_LOT_PREFIX_DEFAULT)
    )


def upsert_system_config(
    db: Session,
    *,
    config_code: str,
    config_name: str,
    config_value: object,
    remark: str | None = None,
    updated_by: int | None = None,
    now: datetime | None = None,
) -> SystemConfig:
    current_time = now or datetime.now()
    normalized_value = (
        normalize_raw_material_lot_prefix(config_value)
        if config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE
        else str(config_value or "").strip()
    )
    row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == config_code).limit(1))
    if not row:
        row = SystemConfig(
            config_code=config_code,
            config_name=config_name,
            config_value=normalized_value,
            remark=remark,
            status="ACTIVE",
            created_by=updated_by,
            updated_by=updated_by,
            created_at=current_time,
            updated_at=current_time,
        )
    else:
        row.config_name = config_name
        row.config_value = normalized_value
        row.remark = remark
        row.status = "ACTIVE"
        row.updated_by = updated_by
        row.updated_at = current_time
    db.add(row)
    db.flush()
    return row
```

- [ ] **Step 6: Add schemas and API endpoints**

Modify `backend/app/schemas/database.py`:

```python
class SystemConfigUpdate(BaseModel):
    config_value: str = Field(min_length=1, max_length=500)
    remark: str | None = None


class SystemConfigRead(BaseModel):
    config_code: str
    config_name: str
    config_value: str
    remark: str | None = None
    updated_at: datetime | None = None
```

Modify `backend/app/api/routes/system_extension.py` imports:

```python
from app.models.org import AiAssistantConfig, SystemConfig
from app.schemas.database import SystemConfigRead, SystemConfigUpdate
from app.services.system_config import RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, get_raw_material_lot_prefix, upsert_system_config
```

Add endpoints near assistant config endpoints:

```python
def _system_config_read(row: SystemConfig | None, *, config_code: str, config_name: str, default_value: str) -> SystemConfigRead:
    return SystemConfigRead(
        config_code=config_code,
        config_name=row.config_name if row else config_name,
        config_value=row.config_value if row else default_value,
        remark=row.remark if row else None,
        updated_at=row.updated_at if row else None,
    )


@router.get("/raw-material-lot-prefix", response_model=SystemConfigRead)
def get_raw_material_lot_prefix_config(
    context: AuthContext = Depends(require_system_admin),
    db: Session = Depends(get_db),
) -> SystemConfigRead:
    _ = context
    row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE).limit(1))
    return _system_config_read(
        row,
        config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
        config_name="原材料库存批次号前缀",
        default_value=get_raw_material_lot_prefix(db),
    )


@router.put("/raw-material-lot-prefix", response_model=SystemConfigRead)
def save_raw_material_lot_prefix_config(
    payload: SystemConfigUpdate,
    context: AuthContext = Depends(require_system_admin),
    db: Session = Depends(get_db),
) -> SystemConfigRead:
    row = upsert_system_config(
        db,
        config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
        config_name="原材料库存批次号前缀",
        config_value=payload.config_value,
        remark=payload.remark or "原材料库存批次号默认前缀，例如 YL0001",
        updated_by=context.user.id,
    )
    db.commit()
    db.refresh(row)
    return _system_config_read(
        row,
        config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
        config_name="原材料库存批次号前缀",
        default_value=get_raw_material_lot_prefix(db),
    )
```

- [ ] **Step 7: Run config tests**

Run:

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

Expected: PASS.

---

### Task 2: Change Raw Material Lot Numbering To Global Prefix Sequence

**Files:**

- Modify: `backend/app/services/operations.py`
- Modify: `backend/tests/test_inventory_lot_numbering.py`

- [ ] **Step 1: Replace old numbering tests with new global sequence tests**

Modify `backend/tests/test_inventory_lot_numbering.py` to assert:

```python
def test_inventory_lot_no_starts_from_global_raw_sequence(self) -> None:
    self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0001")


def test_inventory_lot_no_increments_across_materials(self) -> None:
    other_material = Item(id=2, item_code="原材料00002", item_name="冷轧板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE")
    self.db.add(other_material)
    self.db.add(
        StockLot(
            lot_no="YL0001",
            lot_role="INBOUND_RAW",
            item_id=other_material.id,
            warehouse_id=self.warehouse.id,
            source_doc_type="TEST",
            source_doc_id=1,
            inbound_qty=Decimal("0"),
            inbound_weight_kg=Decimal("100"),
            remaining_qty=Decimal("0"),
            remaining_weight_kg=Decimal("100"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("5"),
            quality_status="PASS",
            status="AVAILABLE",
        )
    )
    self.db.commit()

    self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0002")


def test_inventory_lot_no_expands_after_9999(self) -> None:
    self.db.add(
        StockLot(
            lot_no="YL9999",
            lot_role="INBOUND_RAW",
            item_id=self.material.id,
            warehouse_id=self.warehouse.id,
            source_doc_type="TEST",
            source_doc_id=1,
            inbound_qty=Decimal("0"),
            inbound_weight_kg=Decimal("100"),
            remaining_qty=Decimal("0"),
            remaining_weight_kg=Decimal("100"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("5"),
            quality_status="PASS",
            status="AVAILABLE",
        )
    )
    self.db.commit()

    self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL10000")
```

- [ ] **Step 2: Run numbering tests to verify failure**

Run:

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

Expected: FAIL because `build_inventory_lot_no` still returns `JH_物料名称编码_0001`.

- [ ] **Step 3: Implement global raw lot number helper**

Modify `backend/app/services/operations.py` imports:

```python
from app.services.system_config import get_raw_material_lot_prefix
```

Replace `_build_inventory_lot_prefix` and `build_inventory_lot_no` with:

```python
def _raw_lot_sequence_width(next_sequence: int) -> int:
    return 4 if next_sequence <= 9999 else len(str(next_sequence))


def build_inventory_lot_no(db: Session, material_item_id: int) -> str:
    _ = material_item_id
    prefix = get_raw_material_lot_prefix(db)
    existing_lot_nos = db.scalars(select(StockLot.lot_no).where(StockLot.lot_no.like(f"{prefix}%"))).all()
    max_sequence = 0
    for lot_no in existing_lot_nos:
        match = re.fullmatch(rf"{re.escape(prefix)}(\d+)", str(lot_no or ""))
        if match:
            max_sequence = max(max_sequence, int(match.group(1)))
    sequence = max_sequence + 1
    candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}"
    while db.scalar(select(func.count(StockLot.id)).where(StockLot.lot_no == candidate)):
        sequence += 1
        candidate = f"{prefix}{sequence:0{_raw_lot_sequence_width(sequence)}d}"
    return candidate
```

- [ ] **Step 4: Run numbering tests**

Run:

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

Expected: PASS.

---

### Task 3: Apply New Lot Numbering To Raw Inbound Paths

**Files:**

- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/api/routes/purchase.py`
- Modify: `backend/app/services/special_inventory_adjustment.py`
- Modify: `backend/tests/test_customer_supplied_inbound_lot_no.py`
- Modify: `backend/tests/test_opening_inventory_import.py`
- Modify: `backend/tests/test_special_warehouse_adjustment.py`

- [ ] **Step 1: Write or update tests for raw inbound paths**

Update assertions so raw inbound paths generate `YL0001`, `YL0002` and never hand-entered material-name batch numbers.

For `backend/tests/test_customer_supplied_inbound_lot_no.py`, assert:

```python
self.assertEqual(created_lot.lot_no, "YL0001")
self.assertEqual(second_created_lot.lot_no, "YL0002")
```

For `backend/tests/test_opening_inventory_import.py`, assert imported raw opening lots use:

```python
self.assertEqual([lot.lot_no for lot in raw_lots], ["YL0001", "YL0002"])
```

For `backend/tests/test_special_warehouse_adjustment.py`, assert special raw inbound uses:

```python
self.assertRegex(raw_lot.lot_no, r"^YL\d{4,}$")
```

- [ ] **Step 2: Run inbound tests to verify failures**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
  tests/test_customer_supplied_inbound_lot_no.py \
  tests/test_opening_inventory_import.py \
  tests/test_special_warehouse_adjustment.py \
  -q
```

Expected: FAIL where old raw-specific lot builders still produce `KLRK` or material-name lot numbers.

- [ ] **Step 3: Route raw warehouse generic inbound through `build_inventory_lot_no`**

Modify `backend/app/api/routes/inventory.py` in the `# build lot_no` block:

```python
    if not lot_no:
        if warehouse_type == "RAW" and biz_type in {"OPENING", "CUSTOMER_SUPPLIED"}:
            lot_no = build_inventory_lot_no(db, item.id)
        elif biz_type in ("OPENING",):
            lot_no = _build_opening_lot_no(db, item, warehouse)
        elif biz_type in ("PRODUCTION_SURPLUS", "OUTSOURCING_SURPLUS"):
            tag = "SCSY" if biz_type == "PRODUCTION_SURPLUS" else "WWSY"
            lot_no = _build_surplus_lot_no(db, item, warehouse, tag)
        elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN", "REWORK_SCRAP_IN"):
            tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "FWFL" if biz_type == "REWORK_SCRAP_IN" else "WWFL"
            lot_no = _build_surplus_lot_no(db, item, warehouse, tag)
        elif biz_type == "WIP_IN":
            lot_no = _build_wip_lot_no(db, item, warehouse)
        elif biz_type == "PRODUCTION_FINISHED":
            lot_no = _build_finished_lot_no(db, item, warehouse)
        elif biz_type == "OUTSOURCING_IN":
            lot_no = _build_surplus_lot_no(db, item, warehouse, "WWJK")
        elif biz_type == "CUSTOMER_SUPPLIED":
            lot_no = _build_customer_supplied_lot_no(db, item)
        else:
            lot_no = _build_opening_lot_no(db, item, warehouse)
```

Confirm `backend/app/api/routes/purchase.py` and `backend/app/services/special_inventory_adjustment.py` call `build_inventory_lot_no(db, item_id)` for raw material lots. If either file has a custom raw lot builder, replace that call with `build_inventory_lot_no`.

- [ ] **Step 4: Run inbound tests**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
  tests/test_customer_supplied_inbound_lot_no.py \
  tests/test_opening_inventory_import.py \
  tests/test_special_warehouse_adjustment.py \
  tests/test_inventory_lot_numbering.py \
  -q
```

Expected: PASS.

---

### Task 4: Enforce Single-Lot Production Outbound And Merge Daily Work Orders

**Files:**

- Modify: `backend/app/services/operations.py`
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/tests/test_selected_stock_lot_production_issue.py`

- [ ] **Step 1: Update production issue tests for new rules**

In `backend/tests/test_selected_stock_lot_production_issue.py`, change lot numbers in seed data to `YL0001` and `YL0002`.

Replace old yearly sequence tests with:

```python
def test_create_work_order_no_uses_date_lot_and_product_name(self) -> None:
    class FrozenDateTime(datetime):
        @classmethod
        def now(cls, tz=None):  # noqa: ANN001
            _ = tz
            return cls(2026, 6, 7, 9, 15, 0)

    with patch.object(production_routes, "datetime", FrozenDateTime):
        result = create_work_order(
            WorkOrderCreate(
                product_item_id=self.product.id,
                selected_stock_lots=[
                    SelectedStockLotIssueCreate(
                        source_lot_id=self.lot1.id,
                        material_item_id=self.material.id,
                        issued_weight_kg=Decimal("10"),
                    )
                ],
            ),
            context=SimpleNamespace(user=SimpleNamespace(id=1)),
            db=self.db,
        )

    self.assertEqual(result.work_order_no, f"20260607-{self.lot1.lot_no}-{self.product.item_name}")
```

Add merge test:

```python
def test_same_day_same_lot_same_product_merges_into_existing_work_order(self) -> None:
    class FrozenDateTime(datetime):
        @classmethod
        def now(cls, tz=None):  # noqa: ANN001
            _ = tz
            return cls(2026, 6, 7, 9, 15, 0)

    payload = WorkOrderCreate(
        product_item_id=self.product.id,
        selected_stock_lots=[
            SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10"))
        ],
    )
    with patch.object(production_routes, "datetime", FrozenDateTime):
        first = create_work_order(payload, context=SimpleNamespace(user=SimpleNamespace(id=1)), db=self.db)
        second = create_work_order(
            WorkOrderCreate(
                product_item_id=self.product.id,
                selected_stock_lots=[
                    SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("8"))
                ],
            ),
            context=SimpleNamespace(user=SimpleNamespace(id=1)),
            db=self.db,
        )

    self.assertEqual(second.work_order_id, first.work_order_id)
    issues = self.db.scalars(select(WorkOrderMaterialIssue).where(WorkOrderMaterialIssue.work_order_id == first.work_order_id)).all()
    self.assertEqual(len(issues), 2)
    material_row = self.db.scalar(select(WorkOrderMaterial).where(WorkOrderMaterial.work_order_id == first.work_order_id))
    self.assertEqual(float(material_row.issued_weight_kg), 18)
```

Add rejection test:

```python
def test_create_work_order_rejects_multiple_stock_lots(self) -> None:
    with self.assertRaises(HTTPException) as exc:
        create_work_order(
            WorkOrderCreate(
                product_item_id=self.product.id,
                selected_stock_lots=[
                    SelectedStockLotIssueCreate(source_lot_id=self.lot1.id, material_item_id=self.material.id, issued_weight_kg=Decimal("10")),
                    SelectedStockLotIssueCreate(source_lot_id=self.lot2.id, material_item_id=self.material.id, issued_weight_kg=Decimal("5")),
                ],
            ),
            context=SimpleNamespace(user=SimpleNamespace(id=1)),
            db=self.db,
        )

    self.assertEqual(exc.exception.status_code, 400)
    self.assertIn("一次生产出库只能选择一个材料库存批次号", exc.exception.detail)
```

Replace the old “requires lots for all BOM materials” expectation with a test that only the selected material is issued and other BOM rows remain unissued.

- [ ] **Step 2: Run focused production issue tests**

Run:

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

Expected: FAIL on old multi-lot and old work-order number behavior.

- [ ] **Step 3: Add deterministic production work-order number helper**

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

```python
def build_raw_lot_product_work_order_no(*, biz_time: datetime, lot_no: str, product: Item) -> str:
    date_part = biz_time.strftime("%Y%m%d")
    product_name = " ".join(str(product.item_name or product.item_code or f"产品{product.id}").split()).strip("- ") or f"产品{product.id}"
    prefix = f"{date_part}-{lot_no}-"
    max_product_name_length = max(80 - len(prefix), 1)
    return f"{prefix}{product_name[:max_product_name_length]}"
```

- [ ] **Step 4: Refactor `create_work_order` to validate one selected lot**

In `backend/app/api/routes/production.py`, after building `selected_lots`, add:

```python
    if len(selected_lots) != 1:
        raise HTTPException(status_code=400, detail="一次生产出库只能选择一个材料库存批次号")
```

Remove the requirement that every BOM material must have a selected lot. Replace it with:

```python
    selected_lot = selected_lot_by_id[int(selected_lots[0]["source_lot_id"])]
    selected_material_item_id = int(selected_lots[0]["material_item_id"] or selected_lot.item_id)
    selected_bom_item = next((item for item in bom_items if int(item.material_item_id) == selected_material_item_id), None)
    if not selected_bom_item:
        raise HTTPException(status_code=400, detail=f"库存批次 {selected_lot.lot_no} 不属于该产品需规默认用料")
```

- [ ] **Step 5: Implement same-day same-lot same-product merge**

In `backend/app/api/routes/production.py`, before creating a new `WorkOrder`, compute:

```python
    now = datetime.now()
    work_order_no = build_raw_lot_product_work_order_no(biz_time=now, lot_no=selected_lot.lot_no, product=product)
    existing_work_order = db.scalar(select(WorkOrder).where(WorkOrder.work_order_no == work_order_no).limit(1))
    if existing_work_order and str(existing_work_order.status or "").upper() == "SETTLED":
        raise HTTPException(status_code=400, detail="该库存批次今日对应产品的生产工单已结单，不能继续合并生产出库")
```

If `existing_work_order` is not settled, use it instead of adding a new one:

```python
    if existing_work_order:
        work_order = existing_work_order
    else:
        work_order = WorkOrder(
            work_order_no=work_order_no,
            work_order_type="NORMAL",
            source_sales_order_item_id=payload.source_sales_order_item_id,
            product_item_id=payload.product_item_id,
            route_id=route.id,
            bom_id=bom.id,
            planned_qty=planned_qty,
            released_qty=planned_qty,
            finished_qty=to_decimal(0),
            scrap_qty=to_decimal(0),
            planned_start_time=payload.planned_start_time or now,
            planned_end_time=payload.planned_end_time,
            actual_start_time=None,
            actual_end_time=None,
            priority_level=payload.priority_level or 3,
            status="RELEASED",
            remark=payload.remark,
        )
        db.add(work_order)
        db.flush()
        create_work_order_materials_and_operations(
            db,
            work_order=work_order,
            bom=bom,
            route=route,
            auto_issue_materials=False,
            operator_user_id=context.user.id,
            issue_weight_override=None,
        )
```

When applying the selected lot issue, update the selected material row cumulatively:

```python
    material_row.issued_qty = Decimal("0")
    material_row.issued_weight_kg = to_decimal(material_row.issued_weight_kg) + selected_lots[0]["issued_weight_kg"]
    material_row.required_weight_kg = material_row.issued_weight_kg
    material_row.status = "ISSUED"
    db.add(material_row)
    sync_work_order_reference_qty(db, work_order.id)
```

Ensure `build_raw_lot_product_work_order_no` is imported from `app.services.operations`.

- [ ] **Step 6: Run production issue tests**

Run:

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

Expected: PASS.

---

### Task 5: Match Miniapp Reports By Material Stock Lot Number

**Files:**

- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/tests/test_selected_stock_lot_production_issue.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`

- [ ] **Step 1: Update ERP miniapp resolver tests**

Replace `JH0001` tests with `YL0001` tests:

```python
def test_miniapp_material_lot_no_resolves_unsettled_work_order(self) -> None:
    work_order = self._add_issued_work_order(
        work_order_id=1,
        work_order_no=f"20260607-{self.lot1.lot_no}-{self.product.item_name}",
        product=self.product,
        issue_id=10,
        material_row_id=10,
    )
    self._add_work_order_operation(operation_id=101, work_order_id=work_order.id, operation_name="1序")
    self.db.commit()

    matched = _find_work_order_for_miniapp_item(
        self.db,
        {
            "raw_material_batch_no": self.lot1.lot_no,
            "product_name": self.product.item_name,
            "project_no": self.product.item_code,
        },
    )

    self.assertEqual(matched.id, work_order.id)
```

Add settled ignore test:

```python
def test_miniapp_material_lot_no_ignores_settled_work_order(self) -> None:
    self._add_issued_work_order(
        work_order_id=1,
        work_order_no=f"20260607-{self.lot1.lot_no}-{self.product.item_name}",
        product=self.product,
        issue_id=10,
        material_row_id=10,
    ).status = "SETTLED"
    self.db.commit()

    matched = _find_work_order_for_miniapp_item(
        self.db,
        {
            "raw_material_batch_no": self.lot1.lot_no,
            "product_name": self.product.item_name,
            "project_no": self.product.item_code,
        },
    )

    self.assertIsNone(matched)
```

Keep one compatibility test for old `JH0001` short code if existing historical data still needs syncing.

- [ ] **Step 2: Run ERP miniapp resolver tests**

Run:

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

Expected: FAIL because resolver still prioritizes `JH0001` short codes and does not filter settled work orders in lot-based candidate selection.

- [ ] **Step 3: Update ERP resolver**

Modify `_select_miniapp_issue_candidate` in `backend/app/api/routes/production.py` to receive only unsettled work orders:

```python
product_rows = db.execute(
    select(WorkOrder.id, Item.item_name, Item.item_code)
    .join(Item, Item.id == WorkOrder.product_item_id)
    .where(
        WorkOrder.id.in_(latest_by_work_order),
        func.upper(func.coalesce(WorkOrder.status, "")) != "SETTLED",
    )
).all()
```

Modify `_resolve_work_order_issue_by_inventory_lot_no` to join `WorkOrder` and ignore settled:

```python
candidates = db.scalars(
    select(WorkOrderMaterialIssue)
    .join(StockLot, StockLot.id == WorkOrderMaterialIssue.source_lot_id)
    .join(WorkOrder, WorkOrder.id == WorkOrderMaterialIssue.work_order_id)
    .where(
        StockLot.lot_no == lot_no,
        func.upper(func.coalesce(WorkOrder.status, "")) != "SETTLED",
    )
    .order_by(WorkOrderMaterialIssue.id.desc())
).all()
```

Modify `_find_work_order_for_miniapp_item` to resolve stock lot numbers before `JH0001` compatibility:

```python
issue, has_issue_candidates = _resolve_work_order_issue_by_inventory_lot_no(db, batch_no, row)
work_order = db.get(WorkOrder, issue.work_order_id) if issue else None
if work_order:
    if cache is not None:
        cache["work_order"][cache_key] = work_order
    return work_order

if _is_miniapp_work_order_short_code(batch_no):
    work_order = _resolve_work_order_by_miniapp_short_code(db, batch_no, row)
    if cache is not None:
        cache["work_order"][cache_key] = work_order
    return work_order
```

Keep the direct work-order-number fallback only when `has_issue_candidates` is false.

- [ ] **Step 4: Update miniapp backend option query**

Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/app/routers/reports.py`.

Replace `_miniapp_work_order_short_code` usage in `_raw_material_batch_options` with a query that returns source stock lot numbers:

```python
rows = db.execute(
    text(
        """
        select distinct sl.lot_no
        from pp_work_order wo
        join pp_work_order_operation op on op.work_order_id = wo.id
        join md_item product on product.id = wo.product_item_id
        join pp_work_order_material_issue issue on issue.work_order_id = wo.id
        join wh_stock_lot sl on sl.id = issue.source_lot_id
        where sl.lot_no is not null
          and sl.lot_no <> ''
          and upper(coalesce(wo.status, '')) <> 'SETTLED'
          and (:product_name = '' or product.item_name = :product_name)
          and (:project_no = '' or product.item_code = :project_no)
          and (:process_name = '' or op.operation_name = :process_name)
        order by sl.lot_no asc
        """
    ),
    {
        "product_name": product_name,
        "project_no": project_no,
        "process_name": process_name,
    },
).all()
return [str(row[0]) for row in rows if row[0]]
```

If the miniapp backend connects to the ERP schema through a different SQLAlchemy engine name, use the existing `_erp_engine()` or session helper already used by the current `_raw_material_batch_options` function.

- [ ] **Step 5: Update miniapp backend tests**

Modify `/Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint/tests/test_work_order_batch_options.py`:

```python
def test_raw_material_batch_options_returns_material_stock_lots_for_product_process(monkeypatch):
    # seed pp_work_order, pp_work_order_operation, md_item, pp_work_order_material_issue, wh_stock_lot
    options = reports._raw_material_batch_options(
        product_name="钢制碗",
        project_no="",
        process_name="1序",
    )
    assert options == ["YL0001", "YL0002"]
```

Also update the settled test to assert settled work orders do not return their lot number.

- [ ] **Step 6: Run ERP and miniapp backend tests**

Run:

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

cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
python -m pytest tests/test_work_order_batch_options.py -q
```

Expected: PASS.

---

### Task 6: Add Production Work-Order Inbound Backend API

**Files:**

- Create: `backend/app/services/production_work_order_inbound.py`
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/api/routes/production.py`
- Test: `backend/tests/test_production_work_order_inbound.py`

- [ ] **Step 1: Write backend tests for preview, deviation, posting, and settle**

Create `backend/tests/test_production_work_order_inbound.py` with the same SQLite setup pattern as `test_production_work_order_ledger.py`.

Core tests:

```python
def test_preview_calculates_default_surplus_and_scrap_from_finished_qty(self) -> None:
    preview = preview_production_work_order_inbound(self.db, work_order_id=1, finished_qty=Decimal("198"))
    self.assertEqual(float(preview.theoretical_surplus_weight_kg), 81.2)
    self.assertEqual(float(preview.theoretical_scrap_weight_kg), 23.8)


def test_submit_requires_deviation_remark_when_surplus_diff_exceeds_15_percent(self) -> None:
    with self.assertRaises(HTTPException) as exc:
        create_production_work_order_inbound(
            self.db,
            payload=ProductionWorkOrderInboundCreate(
                work_order_id=1,
                finished_warehouse_id=2,
                scrap_warehouse_id=3,
                finished_qty=198,
                surplus_weight_kg=120,
                scrap_weight_kg=23.8,
                deviation_remark=None,
                settle_work_order=False,
            ),
            operator_user_id=1,
        )
    self.assertIn("偏差说明", exc.exception.detail)


def test_submit_posts_finished_surplus_and_scrap_in_one_transaction(self) -> None:
    result = create_production_work_order_inbound(
        self.db,
        payload=ProductionWorkOrderInboundCreate(
            work_order_id=1,
            finished_warehouse_id=2,
            scrap_warehouse_id=3,
            finished_qty=198,
            surplus_weight_kg=80,
            scrap_weight_kg=24,
            deviation_remark=None,
            settle_work_order=False,
        ),
        operator_user_id=1,
    )
    self.assertEqual(result.work_order_id, 1)
    self.assertEqual(float(self.db.get(WorkOrder, 1).finished_qty), 198)
    self.assertEqual(float(self.db.get(StockLot, 1).remaining_weight_kg), 380)
    txn_types = [row.txn_type for row in self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all()]
    self.assertIn("FG_IN", txn_types)
    self.assertIn("PRODUCTION_SURPLUS_IN", txn_types)
    self.assertIn("PRODUCTION_SCRAP_IN", txn_types)


def test_submit_settles_work_order_only_when_requested(self) -> None:
    create_production_work_order_inbound(
        self.db,
        payload=ProductionWorkOrderInboundCreate(
            work_order_id=1,
            finished_warehouse_id=2,
            scrap_warehouse_id=3,
            finished_qty=198,
            surplus_weight_kg=80,
            scrap_weight_kg=24,
            settle_work_order=True,
            settle_confirmed=True,
            deviation_remark=None,
        ),
        operator_user_id=1,
    )
    self.assertEqual(self.db.get(WorkOrder, 1).status, "SETTLED")
```

- [ ] **Step 2: Run production inbound tests to verify failure**

Run:

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

Expected: FAIL because schemas and service do not exist.

- [ ] **Step 3: Add schemas**

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

```python
class ProductionWorkOrderInboundPreviewRead(BaseModel):
    work_order_id: int
    work_order_no: str
    product_item_id: int
    product_name: str
    source_lot_id: int
    source_lot_no: str
    issued_weight_kg: float
    gross_weight_per_piece_kg: float
    net_weight_per_piece_kg: float
    reported_good_qty: float
    reported_scrap_qty: float
    finished_received_qty: float
    surplus_returned_weight_kg: float
    scrap_inbound_weight_kg: float
    theoretical_finished_qty: float
    theoretical_surplus_weight_kg: float
    theoretical_scrap_weight_kg: float


class ProductionWorkOrderInboundCreate(BaseModel):
    work_order_id: int = Field(gt=0)
    finished_warehouse_id: int = Field(gt=0)
    scrap_warehouse_id: int = Field(gt=0)
    finished_qty: float = 0
    surplus_weight_kg: float = 0
    scrap_weight_kg: float = 0
    deviation_remark: str | None = None
    remark: str | None = None
    settle_work_order: bool = False
    settle_confirmed: bool = False


class ProductionWorkOrderInboundRead(BaseModel):
    work_order_id: int
    work_order_no: str
    finished_qty: float
    surplus_weight_kg: float
    scrap_weight_kg: float
    deviation_remark: str | None = None
    settled: bool
    message: str
```

- [ ] **Step 4: Implement calculation helpers**

Create `backend/app/services/production_work_order_inbound.py`:

```python
from __future__ import annotations

from decimal import Decimal

from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.master_data import BomItem, Item, StockBalance, Warehouse
from app.models.operations import InventoryTxn, StockLot, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue
from app.schemas.operations import ProductionWorkOrderInboundCreate, ProductionWorkOrderInboundPreviewRead, ProductionWorkOrderInboundRead
from app.services.operations import clamp_non_negative, create_inventory_txn, get_location_or_default, to_decimal, upsert_stock_balance
from app.services.production_work_order_ledger import get_work_order_limit_snapshot

DEVIATION_RATE = Decimal("0.15")


def _deviation_exceeds_limit(actual: Decimal, theoretical: Decimal) -> bool:
    if theoretical <= 0:
        return actual > 0
    return abs(actual - theoretical) / theoretical > DEVIATION_RATE


def _source_issue(db: Session, work_order_id: int) -> WorkOrderMaterialIssue:
    issue = db.scalar(
        select(WorkOrderMaterialIssue)
        .where(WorkOrderMaterialIssue.work_order_id == work_order_id)
        .order_by(WorkOrderMaterialIssue.id.asc())
        .limit(1)
    )
    if not issue:
        raise HTTPException(status_code=400, detail="该工单没有材料库存批次领用记录，不能进行生产工单入库")
    return issue


def preview_production_work_order_inbound(
    db: Session,
    *,
    work_order_id: int,
    finished_qty: Decimal | None = None,
) -> ProductionWorkOrderInboundPreviewRead:
    work_order = db.get(WorkOrder, work_order_id)
    if not work_order:
        raise HTTPException(status_code=404, detail="生产工单不存在")
    if str(work_order.status or "").upper() == "SETTLED":
        raise HTTPException(status_code=400, detail="该生产工单已结单，不能继续入库")
    product = db.get(Item, work_order.product_item_id)
    issue = _source_issue(db, work_order_id)
    source_lot = db.get(StockLot, issue.source_lot_id)
    snapshot = get_work_order_limit_snapshot(db, work_order_id)
    bom_item = db.scalar(
        select(BomItem)
        .join(WorkOrderMaterial, WorkOrderMaterial.bom_item_id == BomItem.id)
        .where(WorkOrderMaterial.work_order_id == work_order_id, WorkOrderMaterial.material_item_id == issue.material_item_id)
        .limit(1)
    )
    gross = to_decimal(bom_item.gross_weight_per_piece_kg if bom_item else 0)
    net = to_decimal(bom_item.net_weight_per_piece_kg if bom_item else 0)
    default_finished_qty = to_decimal(finished_qty if finished_qty is not None else snapshot.max_finished_inbound_qty)
    issued_weight = to_decimal(snapshot.issued_weight_kg)
    returned_weight = to_decimal(snapshot.returned_material_weight_kg)
    scrap_inbound_weight = to_decimal(snapshot.scrap_inbound_weight_kg)
    reported_scrap_qty = to_decimal(snapshot.total_operation_scrap_qty)
    theoretical_surplus = clamp_non_negative(issued_weight - returned_weight - default_finished_qty * gross)
    theoretical_scrap = clamp_non_negative(default_finished_qty * clamp_non_negative(gross - net) + reported_scrap_qty * gross - scrap_inbound_weight)
    return ProductionWorkOrderInboundPreviewRead(
        work_order_id=work_order.id,
        work_order_no=work_order.work_order_no,
        product_item_id=work_order.product_item_id,
        product_name=product.item_name if product else "",
        source_lot_id=source_lot.id if source_lot else issue.source_lot_id,
        source_lot_no=source_lot.lot_no if source_lot else issue.material_sub_batch_no,
        issued_weight_kg=float(issued_weight),
        gross_weight_per_piece_kg=float(gross),
        net_weight_per_piece_kg=float(net),
        reported_good_qty=float(snapshot.final_operation_good_qty),
        reported_scrap_qty=float(reported_scrap_qty),
        finished_received_qty=float(snapshot.finished_received_qty),
        surplus_returned_weight_kg=float(returned_weight),
        scrap_inbound_weight_kg=float(scrap_inbound_weight),
        theoretical_finished_qty=float(snapshot.max_finished_inbound_qty),
        theoretical_surplus_weight_kg=float(theoretical_surplus),
        theoretical_scrap_weight_kg=float(theoretical_scrap),
    )
```

- [ ] **Step 5: Implement transactional posting helpers**

Continue in `backend/app/services/production_work_order_inbound.py` with helper functions that are called by the final submit function:

```python
def _remark(base: str, payload: ProductionWorkOrderInboundCreate) -> str:
    parts = [base]
    if payload.deviation_remark:
        parts.append(f"入库偏差说明：{payload.deviation_remark}")
    if payload.remark:
        parts.append(payload.remark)
    return "；".join(part for part in parts if part)


def _source_lot(db: Session, preview: ProductionWorkOrderInboundPreviewRead) -> StockLot:
    source_lot = db.get(StockLot, preview.source_lot_id)
    if not source_lot:
        raise HTTPException(status_code=404, detail="来源材料库存批次号不存在")
    return source_lot


def _post_surplus_return(
    db: Session,
    *,
    work_order: WorkOrder,
    preview: ProductionWorkOrderInboundPreviewRead,
    payload: ProductionWorkOrderInboundCreate,
    surplus_weight: Decimal,
    operator_user_id: int | None,
) -> None:
    source_lot = _source_lot(db, preview)
    source_lot.remaining_weight_kg = to_decimal(source_lot.remaining_weight_kg) + surplus_weight
    if source_lot.status == "DEPLETED" and to_decimal(source_lot.remaining_weight_kg) > 0:
        source_lot.status = "AVAILABLE"
    db.add(source_lot)
    material_row = db.scalar(
        select(WorkOrderMaterial)
        .where(WorkOrderMaterial.work_order_id == work_order.id, WorkOrderMaterial.material_item_id == source_lot.item_id)
        .limit(1)
    )
    if material_row:
        material_row.returned_weight_kg = to_decimal(material_row.returned_weight_kg) + surplus_weight
        db.add(material_row)
    upsert_stock_balance(
        db,
        item_id=source_lot.item_id,
        warehouse_id=source_lot.warehouse_id,
        location_id=source_lot.location_id,
        qty_delta=Decimal("0"),
        weight_delta=surplus_weight,
        available_qty_delta=Decimal("0"),
        available_weight_delta=surplus_weight,
        unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
    )
    create_inventory_txn(
        db,
        txn_type="PRODUCTION_SURPLUS_IN",
        item_id=source_lot.item_id,
        warehouse_id=source_lot.warehouse_id,
        location_id=source_lot.location_id,
        lot_id=source_lot.id,
        qty_change=Decimal("0"),
        weight_change=surplus_weight,
        unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
        source_doc_type="WORK_ORDER",
        source_doc_id=work_order.id,
        source_line_id=source_lot.id,
        biz_time=datetime.now(),
        operator_user_id=operator_user_id,
        remark=_remark("生产工单入库：生产余料入库", payload),
        amount_basis="WEIGHT",
    )


def _post_finished_inbound(
    db: Session,
    *,
    work_order: WorkOrder,
    preview: ProductionWorkOrderInboundPreviewRead,
    payload: ProductionWorkOrderInboundCreate,
    finished_qty: Decimal,
    operator_user_id: int | None,
) -> None:
    product = db.get(Item, work_order.product_item_id)
    if not product:
        raise HTTPException(status_code=404, detail="工单产品不存在")
    warehouse = db.get(Warehouse, payload.finished_warehouse_id)
    if not warehouse or str(warehouse.warehouse_type or "").upper() != "FINISHED":
        raise HTTPException(status_code=400, detail="生产工单入库必须选择成品库")
    location = get_location_or_default(db, payload.finished_warehouse_id, None)
    source_lot = _source_lot(db, preview)
    gross_weight = to_decimal(preview.gross_weight_per_piece_kg)
    receipt_weight = finished_qty * gross_weight
    unit_cost = to_decimal(source_lot.unit_cost, "0.0001") * gross_weight
    lot_no = f"CP-{work_order.work_order_no}-{int(to_decimal(work_order.finished_qty) + finished_qty)}"
    lot = StockLot(
        lot_no=lot_no[:50],
        parent_lot_id=source_lot.id,
        lot_role="FINISHED_FROM_RAW_BATCH",
        material_sub_batch_no=None,
        item_id=product.id,
        warehouse_id=warehouse.id,
        location_id=location.id if location else None,
        source_doc_type="WORK_ORDER_INBOUND",
        source_doc_id=work_order.id,
        source_line_id=None,
        source_material_lot_id=source_lot.id,
        source_material_sub_batch_no=source_lot.lot_no,
        source_material_summary=f"来源库存批次号：{source_lot.lot_no}",
        inbound_qty=finished_qty,
        inbound_weight_kg=receipt_weight,
        remaining_qty=finished_qty,
        remaining_weight_kg=receipt_weight,
        locked_qty=Decimal("0"),
        locked_weight_kg=Decimal("0"),
        unit_cost=unit_cost,
        quality_status="PASS",
        status="AVAILABLE",
        remark=_remark("生产工单入库：成品入库", payload),
    )
    db.add(lot)
    db.flush()
    upsert_stock_balance(
        db,
        item_id=product.id,
        warehouse_id=warehouse.id,
        location_id=location.id if location else None,
        qty_delta=finished_qty,
        weight_delta=receipt_weight,
        available_qty_delta=finished_qty,
        available_weight_delta=receipt_weight,
        unit_cost=unit_cost,
    )
    create_inventory_txn(
        db,
        txn_type="FG_IN",
        item_id=product.id,
        warehouse_id=warehouse.id,
        location_id=location.id if location else None,
        lot_id=lot.id,
        qty_change=finished_qty,
        weight_change=receipt_weight,
        unit_cost=unit_cost,
        source_doc_type="WORK_ORDER",
        source_doc_id=work_order.id,
        source_line_id=None,
        biz_time=datetime.now(),
        operator_user_id=operator_user_id,
        remark=_remark("生产工单入库：成品入库", payload),
        amount_basis="QTY",
    )
    work_order.finished_qty = to_decimal(work_order.finished_qty) + finished_qty
    db.add(work_order)


def _post_scrap_inbound(
    db: Session,
    *,
    work_order: WorkOrder,
    preview: ProductionWorkOrderInboundPreviewRead,
    payload: ProductionWorkOrderInboundCreate,
    scrap_weight: Decimal,
    operator_user_id: int | None,
) -> None:
    source_lot = _source_lot(db, preview)
    warehouse = db.get(Warehouse, payload.scrap_warehouse_id)
    if not warehouse or str(warehouse.warehouse_type or "").upper() != "SCRAP":
        raise HTTPException(status_code=400, detail="生产工单入库必须选择废料库")
    location = get_location_or_default(db, payload.scrap_warehouse_id, None)
    lot = StockLot(
        lot_no=f"FL-{work_order.work_order_no}"[:50],
        parent_lot_id=source_lot.id,
        lot_role="PRODUCTION_SCRAP",
        material_sub_batch_no=None,
        item_id=source_lot.item_id,
        warehouse_id=warehouse.id,
        location_id=location.id if location else None,
        source_doc_type="WORK_ORDER",
        source_doc_id=work_order.id,
        source_line_id=None,
        source_material_lot_id=source_lot.id,
        source_material_sub_batch_no=source_lot.lot_no,
        source_material_summary=f"来源库存批次号：{source_lot.lot_no}",
        inbound_qty=Decimal("0"),
        inbound_weight_kg=scrap_weight,
        remaining_qty=Decimal("0"),
        remaining_weight_kg=scrap_weight,
        locked_qty=Decimal("0"),
        locked_weight_kg=Decimal("0"),
        unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
        quality_status="PASS",
        status="AVAILABLE",
        remark=_remark("生产工单入库：生产废料入库", payload),
    )
    db.add(lot)
    db.flush()
    upsert_stock_balance(
        db,
        item_id=source_lot.item_id,
        warehouse_id=warehouse.id,
        location_id=location.id if location else None,
        qty_delta=Decimal("0"),
        weight_delta=scrap_weight,
        available_qty_delta=Decimal("0"),
        available_weight_delta=scrap_weight,
        unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
    )
    create_inventory_txn(
        db,
        txn_type="PRODUCTION_SCRAP_IN",
        item_id=source_lot.item_id,
        warehouse_id=warehouse.id,
        location_id=location.id if location else None,
        lot_id=lot.id,
        qty_change=Decimal("0"),
        weight_change=scrap_weight,
        unit_cost=to_decimal(source_lot.unit_cost, "0.0001"),
        source_doc_type="WORK_ORDER",
        source_doc_id=work_order.id,
        source_line_id=None,
        biz_time=datetime.now(),
        operator_user_id=operator_user_id,
        remark=_remark("生产工单入库：生产废料入库", payload),
        amount_basis="WEIGHT",
    )
```

- [ ] **Step 6: Implement submit function**

Continue in `backend/app/services/production_work_order_inbound.py`:

```python
def create_production_work_order_inbound(
    db: Session,
    *,
    payload: ProductionWorkOrderInboundCreate,
    operator_user_id: int | None,
) -> ProductionWorkOrderInboundRead:
    work_order = db.get(WorkOrder, payload.work_order_id)
    if not work_order:
        raise HTTPException(status_code=404, detail="生产工单不存在")
    if str(work_order.status or "").upper() == "SETTLED":
        raise HTTPException(status_code=400, detail="该生产工单已结单，不能继续入库")
    finished_qty = to_decimal(payload.finished_qty)
    surplus_weight = to_decimal(payload.surplus_weight_kg)
    scrap_weight = to_decimal(payload.scrap_weight_kg)
    if finished_qty <= 0 and surplus_weight <= 0 and scrap_weight <= 0:
        raise HTTPException(status_code=400, detail="本次至少填写一项入库数据")
    preview = preview_production_work_order_inbound(db, work_order_id=payload.work_order_id, finished_qty=finished_qty)
    if (
        _deviation_exceeds_limit(surplus_weight, to_decimal(preview.theoretical_surplus_weight_kg))
        or _deviation_exceeds_limit(scrap_weight, to_decimal(preview.theoretical_scrap_weight_kg))
    ) and not str(payload.deviation_remark or "").strip():
        raise HTTPException(status_code=400, detail="余料或废料入库重量与理论值偏差超过上下15%，请填写偏差说明")
    if payload.settle_work_order and not payload.settle_confirmed:
        raise HTTPException(status_code=400, detail="结单前必须确认本次入库明细及结单影响")

    if finished_qty > 0:
        _post_finished_inbound(db, work_order=work_order, preview=preview, payload=payload, finished_qty=finished_qty, operator_user_id=operator_user_id)
    if surplus_weight > 0:
        _post_surplus_return(db, work_order=work_order, preview=preview, payload=payload, surplus_weight=surplus_weight, operator_user_id=operator_user_id)
    if scrap_weight > 0:
        _post_scrap_inbound(db, work_order=work_order, preview=preview, payload=payload, scrap_weight=scrap_weight, operator_user_id=operator_user_id)

    if payload.settle_work_order:
        work_order.status = "SETTLED"
        work_order.actual_end_time = work_order.actual_end_time or datetime.now()
        work_order.updated_at = datetime.now()
        parts = [part.strip() for part in str(work_order.remark or "").replace(";", "；").split("；") if part.strip()]
        parts.append("生产工单入库已结单")
        if payload.deviation_remark:
            parts.append(f"入库偏差说明：{payload.deviation_remark}")
        work_order.remark = "；".join(dict.fromkeys(parts))[-255:]
        db.add(work_order)
    db.commit()
    return ProductionWorkOrderInboundRead(
        work_order_id=work_order.id,
        work_order_no=work_order.work_order_no,
        finished_qty=float(finished_qty),
        surplus_weight_kg=float(surplus_weight),
        scrap_weight_kg=float(scrap_weight),
        deviation_remark=payload.deviation_remark,
        settled=bool(payload.settle_work_order),
        message="生产工单入库已完成",
    )
```

- [ ] **Step 7: Add production routes**

Modify `backend/app/api/routes/production.py` imports:

```python
from app.schemas.operations import ProductionWorkOrderInboundCreate, ProductionWorkOrderInboundPreviewRead, ProductionWorkOrderInboundRead
from app.services.production_work_order_inbound import create_production_work_order_inbound, preview_production_work_order_inbound
```

Add routes:

```python
@router.get("/work-order-inbounds/preview", response_model=ProductionWorkOrderInboundPreviewRead)
def get_work_order_inbound_preview(
    work_order_id: int = Query(gt=0),
    finished_qty: float | None = Query(default=None, ge=0),
    db: Session = Depends(get_db),
) -> ProductionWorkOrderInboundPreviewRead:
    return preview_production_work_order_inbound(
        db,
        work_order_id=work_order_id,
        finished_qty=to_decimal(finished_qty) if finished_qty is not None else None,
    )


@router.post("/work-order-inbounds", response_model=ProductionWorkOrderInboundRead)
def post_work_order_inbound(
    payload: ProductionWorkOrderInboundCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> ProductionWorkOrderInboundRead:
    return create_production_work_order_inbound(db, payload=payload, operator_user_id=context.user.id)
```

- [ ] **Step 8: Run backend production inbound tests**

Run:

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

Expected: PASS.

---

### Task 7: Add ERP Frontend UI For Prefix, Single-Lot Production Out, And Work-Order Inbound

**Files:**

- Modify: `frontend/src/components/StockLotTagSelect.vue`
- Modify: `frontend/src/views/SystemExtensionView.vue`
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Create: `frontend/scripts/test-production-work-order-inbound-ui.mjs`

- [ ] **Step 1: Add frontend tests for single-select and production inbound text**

Create `frontend/scripts/test-production-work-order-inbound-ui.mjs`:

```js
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

const inventoryView = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const stockLotSelect = readFileSync(new URL("../src/components/StockLotTagSelect.vue", import.meta.url), "utf8");
const systemExtension = readFileSync(new URL("../src/views/SystemExtensionView.vue", import.meta.url), "utf8");

assert.match(stockLotSelect, /maxSelections/);
assert.match(inventoryView, /生产工单入库/);
assert.match(inventoryView, /一次生产出库只能选择一个材料库存批次号/);
assert.match(inventoryView, /work-order-inbounds\\/preview/);
assert.match(inventoryView, /work-order-inbounds/);
assert.match(inventoryView, /本次入库后结单/);
assert.match(systemExtension, /原材料库存批次号前缀/);
assert.match(systemExtension, /raw-material-lot-prefix/);

console.log("production work-order inbound UI checks passed");
```

- [ ] **Step 2: Run frontend UI test to verify failure**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-work-order-inbound-ui.mjs
```

Expected: FAIL before UI changes.

- [ ] **Step 3: Add `maxSelections` to `StockLotTagSelect.vue`**

Modify props:

```js
maxSelections: {
  type: Number,
  default: 0
}
```

Modify `canSelect`:

```js
function canSelect(lot) {
  if (!lot || (!props.allowUnavailable && !lot.is_available_for_issue) || isSelected(lot)) {
    return false;
  }
  return !props.maxSelections || selectedLots.value.length < props.maxSelections;
}
```

Modify `selectLot` so single-select replaces existing selection:

```js
const nextSelectedLots = props.maxSelections === 1 ? [] : selectedLots.value;
emit("update:modelValue", [
  ...nextSelectedLots,
  {
    ...lot,
    issued_weight_kg: normalizeSelectedIssuedWeight(lot)
  }
]);
```

- [ ] **Step 4: Add raw lot prefix config card to System Extension**

Modify `frontend/src/views/SystemExtensionView.vue`:

Add reactive form:

```js
const rawLotPrefixConfig = ref({});
const rawLotPrefixForm = reactive({ config_value: "YL", remark: "" });
```

Add loader:

```js
async function loadRawLotPrefixConfig() {
  const result = await fetchResource("/system-extension/raw-material-lot-prefix", {});
  rawLotPrefixConfig.value = result;
  rawLotPrefixForm.config_value = result.config_value || "YL";
  rawLotPrefixForm.remark = result.remark || "";
}
```

Add saver:

```js
async function saveRawLotPrefixConfig() {
  resetFeedback();
  try {
    const result = await putResource("/system-extension/raw-material-lot-prefix", rawLotPrefixForm);
    rawLotPrefixConfig.value = result;
    rawLotPrefixForm.config_value = result.config_value || "YL";
    rawLotPrefixForm.remark = result.remark || "";
    feedbackMessage.value = "原材料库存批次号前缀已保存";
  } catch (error) {
    errorMessage.value = error.message || "原材料库存批次号前缀保存失败";
  }
}
```

Call `loadRawLotPrefixConfig()` in `loadAll()`.

Add panel:

```html
<section class="panel">
  <div class="panel-header">
    <div>
      <p class="eyebrow">仓库编号配置</p>
      <h3>原材料库存批次号前缀</h3>
    </div>
    <span class="panel-tag">{{ rawLotPrefixConfig.config_value || "YL" }}</span>
  </div>
  <form class="stack-form" @submit.prevent="saveRawLotPrefixConfig">
    <label class="form-field">
      <span>前缀</span>
      <input v-model.trim="rawLotPrefixForm.config_value" type="text" maxlength="20" placeholder="默认 YL" />
    </label>
    <label class="form-field">
      <span>说明</span>
      <input v-model.trim="rawLotPrefixForm.remark" type="text" placeholder="例如：原材料库存批次号默认前缀" />
    </label>
    <button class="primary-button" type="submit">保存前缀</button>
  </form>
</section>
```

- [ ] **Step 5: Change production outbound UI to single-select**

In `InventoryLedgerView.vue`, update production outbound `StockLotTagSelect`:

```html
<StockLotTagSelect
  v-model="selectedProductionStockLots"
  :options="productionStockLotOptions"
  :max-selections="1"
  placeholder="搜索并选择一个材料库存批次号"
/>
```

Update validation:

```js
function validateSelectedProductionStockLots(lots) {
  if (!lots.length) {
    return "请选择一个材料库存批次号";
  }
  if (lots.length > 1) {
    return "一次生产出库只能选择一个材料库存批次号";
  }
  const lot = lots[0];
  const issuedWeight = Number(lot.issued_weight_kg);
  const remainingWeight = Number(lot.remaining_weight_kg || 0);
  const lotNo = lot.lot_no || "未命名批次";
  if (!Number.isFinite(issuedWeight) || issuedWeight <= 0) {
    return `材料库存批次号 ${lotNo} 的本次出库重量必须大于0`;
  }
  if (issuedWeight > remainingWeight) {
    return `材料库存批次号 ${lotNo} 的本次出库重量不能超过剩余重量 ${formatWeight(remainingWeight)}`;
  }
  return "";
}
```

- [ ] **Step 6: Add “生产工单入库” button next to warehouse ledger button**

Find the warehouse action area near the current “流水” button in `InventoryLedgerView.vue` and add:

```html
<button class="ghost-button ledger-action-button" type="button" @click="openProductionWorkOrderInboundDrawer">
  生产工单入库
</button>
```

Add state:

```js
const productionWorkOrderInboundDrawerOpen = ref(false);
const selectedInboundWorkOrderId = ref("");
const productionWorkOrderInboundPreview = ref(null);
const productionWorkOrderInboundForm = reactive({
  finished_qty: 0,
  surplus_weight_kg: 0,
  scrap_weight_kg: 0,
  deviation_remark: "",
  remark: "",
  settle_work_order: false
});
```

Add functions:

```js
function openProductionWorkOrderInboundDrawer() {
  resetFeedback();
  selectedInboundWorkOrderId.value = "";
  productionWorkOrderInboundPreview.value = null;
  Object.assign(productionWorkOrderInboundForm, {
    finished_qty: 0,
    surplus_weight_kg: 0,
    scrap_weight_kg: 0,
    deviation_remark: "",
    remark: "",
    settle_work_order: false
  });
  productionWorkOrderInboundDrawerOpen.value = true;
}

async function loadProductionWorkOrderInboundPreview() {
  if (!selectedInboundWorkOrderId.value) {
    productionWorkOrderInboundPreview.value = null;
    return;
  }
  const query = new URLSearchParams({ work_order_id: String(selectedInboundWorkOrderId.value) });
  if (Number(productionWorkOrderInboundForm.finished_qty) > 0) {
    query.set("finished_qty", String(productionWorkOrderInboundForm.finished_qty));
  }
  const result = await fetchResource(`/production/work-order-inbounds/preview?${query.toString()}`, {});
  productionWorkOrderInboundPreview.value = result;
  if (!Number(productionWorkOrderInboundForm.finished_qty)) {
    productionWorkOrderInboundForm.finished_qty = Number(result.theoretical_finished_qty || 0);
  }
  productionWorkOrderInboundForm.surplus_weight_kg = Number(result.theoretical_surplus_weight_kg || 0);
  productionWorkOrderInboundForm.scrap_weight_kg = Number(result.theoretical_scrap_weight_kg || 0);
}
```

Add submit:

```js
async function submitProductionWorkOrderInbound() {
  resetFeedback();
  if (!selectedInboundWorkOrderId.value) {
    errorMessage.value = "请选择生产工单";
    return;
  }
  if (productionWorkOrderInboundForm.settle_work_order) {
    const confirmed = window.confirm(
      `请确认本次生产工单入库\n\n工单：${productionWorkOrderInboundPreview.value?.work_order_no || ""}\n成品入库：${productionWorkOrderInboundForm.finished_qty || 0} 件\n余料入库：${productionWorkOrderInboundForm.surplus_weight_kg || 0} kg\n废料入库：${productionWorkOrderInboundForm.scrap_weight_kg || 0} kg\n\n勾选结单后，该工单将变为已结单，小程序不再显示该工单对应的材料库存批次号，后续不能再对该工单做生产工单入库。`
    );
    if (!confirmed) {
      return;
    }
  }
  const result = await postResource("/production/work-order-inbounds", {
    work_order_id: Number(selectedInboundWorkOrderId.value),
    finished_warehouse_id: finishedWarehouseId.value,
    scrap_warehouse_id: scrapWarehouseId.value,
    finished_qty: Number(productionWorkOrderInboundForm.finished_qty || 0),
    surplus_weight_kg: Number(productionWorkOrderInboundForm.surplus_weight_kg || 0),
    scrap_weight_kg: Number(productionWorkOrderInboundForm.scrap_weight_kg || 0),
    deviation_remark: productionWorkOrderInboundForm.deviation_remark || null,
    remark: productionWorkOrderInboundForm.remark || null,
    settle_work_order: Boolean(productionWorkOrderInboundForm.settle_work_order),
    settle_confirmed: Boolean(productionWorkOrderInboundForm.settle_work_order)
  });
  feedbackMessage.value = result.message || "生产工单入库已完成";
  productionWorkOrderInboundDrawerOpen.value = false;
  await loadAll();
}
```

- [ ] **Step 7: Add production work-order inbound drawer**

Add a `FormDrawer` in `InventoryLedgerView.vue`:

```html
<FormDrawer
  :open="productionWorkOrderInboundDrawerOpen"
  eyebrow="嘉恒仓库 · 生产工单入库"
  title="生产工单入库"
  tag="工单闭环"
  wide
  @close="productionWorkOrderInboundDrawerOpen = false"
>
  <form class="stack-form" @submit.prevent="submitProductionWorkOrderInbound">
    <label class="form-field">
      <span>生产工单</span>
      <select v-model.number="selectedInboundWorkOrderId" required @change="loadProductionWorkOrderInboundPreview">
        <option disabled value="">请选择未结单生产工单</option>
        <option v-for="workOrder in openProductionWorkOrders" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
          {{ workOrder.work_order_no }} · {{ workOrder.product_name }} · 领料 {{ formatWeight(workOrder.issued_weight_kg) }}
        </option>
      </select>
    </label>
    <div v-if="productionWorkOrderInboundPreview" class="summary-grid compact-summary-grid">
      <article class="summary-card"><span>材料库存批次号</span><strong>{{ productionWorkOrderInboundPreview.source_lot_no }}</strong></article>
      <article class="summary-card"><span>领料重量</span><strong>{{ formatWeight(productionWorkOrderInboundPreview.issued_weight_kg) }}</strong></article>
      <article class="summary-card"><span>单件毛重</span><strong>{{ formatWeight(productionWorkOrderInboundPreview.gross_weight_per_piece_kg) }}</strong></article>
      <article class="summary-card"><span>单件净重</span><strong>{{ formatWeight(productionWorkOrderInboundPreview.net_weight_per_piece_kg) }}</strong></article>
    </div>
    <div class="triple-field">
      <label class="form-field">
        <span>成品入库数量</span>
        <input v-model.number="productionWorkOrderInboundForm.finished_qty" type="number" min="0" step="1" @change="loadProductionWorkOrderInboundPreview" />
      </label>
      <label class="form-field">
        <span>余料入库重量(kg)</span>
        <input v-model.number="productionWorkOrderInboundForm.surplus_weight_kg" type="number" min="0" step="0.001" />
      </label>
      <label class="form-field">
        <span>废料重量(kg)</span>
        <input v-model.number="productionWorkOrderInboundForm.scrap_weight_kg" type="number" min="0" step="0.001" />
      </label>
    </div>
    <label class="form-field">
      <span>偏差说明</span>
      <textarea v-model.trim="productionWorkOrderInboundForm.deviation_remark" rows="3" placeholder="余料或废料与理论值偏差超过上下15%时必须填写"></textarea>
    </label>
    <label class="form-field checkbox-field">
      <input v-model="productionWorkOrderInboundForm.settle_work_order" type="checkbox" />
      <span>本次入库后结单</span>
    </label>
    <label class="form-field">
      <span>备注</span>
      <textarea v-model.trim="productionWorkOrderInboundForm.remark" rows="3"></textarea>
    </label>
    <button class="primary-button" type="submit" :disabled="submittingOperation">保存生产工单入库</button>
  </form>
</FormDrawer>
```

- [ ] **Step 8: Run frontend tests and build**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-work-order-inbound-ui.mjs
node scripts/test-inventory-ledger-options.mjs
npm run build
```

Expected: scripts PASS and build PASS with only existing Vite chunk-size warning.

---

### Task 8: Update Miniapp Frontend Wording To “材料库存批次号”

**Files:**

- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records/records.wxml`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard/dashboard.wxml`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review/review.wxml`
- Modify: `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`

- [ ] **Step 1: Add a text scan test**

Run before edits:

```bash
rg -n "工单号|原材料分批次号|请选择工单号|暂无可选工单号" \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js
```

Expected: output includes old wording.

- [ ] **Step 2: Replace visible wording**

In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.wxml`:

```xml
<text class="label">材料库存批次号</text>
<view class="picker {{reportItem.rawMaterialBatchError ? 'field-error' : ''}}">{{reportItem.rawMaterialBatchNo || '请选择材料库存批次号'}}</view>
<view wx:else class="readonly">暂无可选材料库存批次号</view>
```

In `/Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm/reportForm.js`, replace validation message:

```js
title: `${blockDisplay(block)}请选择材料库存批次号`,
```

In records/dashboard/review WXML files, replace visible `原材料分批次号` with `材料库存批次号`.

In `/Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js`, keep payload field names unchanged but replace correction display label:

```js
correctionPair(corrections, 'raw_material_batch_no', '材料库存批次号', row.raw_material_batch_no || '')
```

- [ ] **Step 3: Verify old wording is gone from user-facing miniapp files**

Run:

```bash
rg -n "请选择工单号|暂无可选工单号|原材料分批次号" \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/reportForm \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/records \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/dashboard \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/pages/review \
  /Users/souplearn/Gitlab/app/JhHardwareWRS/utils/api.js
```

Expected: no output.

---

### Task 9: Add SQL Execution And Deployment Checks

**Files:**

- Use: `backend/sql/add_raw_lot_work_order_inbound_patch.sql`
- No code files modified in this task.

- [ ] **Step 1: Execute SQL patch against the target test database**

Run from ERP backend:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python - <<'PY'
from pathlib import Path
import re
import pymysql

env = {}
for line in Path(".env").read_text(encoding="utf-8").splitlines():
    line = line.strip()
    if not line or line.startswith("#") or "=" not in line:
        continue
    key, value = line.split("=", 1)
    env[key.strip()] = value.strip().strip('"').strip("'")

sql_path = Path("sql/add_raw_lot_work_order_inbound_patch.sql")
statements = [stmt.strip() for stmt in re.split(r";\s*(?:\n|$)", sql_path.read_text(encoding="utf-8")) if stmt.strip()]
conn = pymysql.connect(
    host=env["MYSQL_HOST"],
    port=int(env["MYSQL_PORT"]),
    user=env["MYSQL_USER"],
    password=env["MYSQL_PASSWORD"],
    database=env["MYSQL_DATABASE"],
    charset="utf8mb4",
)
try:
    with conn.cursor() as cur:
        for stmt in statements:
            cur.execute(stmt)
    conn.commit()
finally:
    conn.close()
print(f"executed {len(statements)} statements")
PY
```

Expected: `executed 2 statements`.

- [ ] **Step 2: Verify config table and seed row**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python - <<'PY'
from pathlib import Path
import pymysql

env = {}
for line in Path(".env").read_text(encoding="utf-8").splitlines():
    line = line.strip()
    if not line or line.startswith("#") or "=" not in line:
        continue
    key, value = line.split("=", 1)
    env[key.strip()] = value.strip().strip('"').strip("'")

conn = pymysql.connect(
    host=env["MYSQL_HOST"],
    port=int(env["MYSQL_PORT"]),
    user=env["MYSQL_USER"],
    password=env["MYSQL_PASSWORD"],
    database=env["MYSQL_DATABASE"],
    charset="utf8mb4",
)
try:
    with conn.cursor() as cur:
        cur.execute("select config_code, config_value from sys_system_config where config_code='RAW_MATERIAL_LOT_PREFIX'")
        print(cur.fetchall())
finally:
    conn.close()
PY
```

Expected: output contains `RAW_MATERIAL_LOT_PREFIX` and `YL`.

---

### Task 10: Full Verification

**Files:**

- No new files.

- [ ] **Step 1: Run backend full tests**

Run:

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

Expected: all tests pass.

- [ ] **Step 2: Run frontend checks**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-production-work-order-inbound-ui.mjs
node scripts/test-inventory-ledger-options.mjs
node scripts/test-warehouse-drawer-table-overflow.mjs
npm run build
```

Expected: script tests pass and build succeeds. Vite chunk-size warning is acceptable.

- [ ] **Step 3: Run miniapp backend tests**

Run:

```bash
cd /Users/souplearn/Gitlab/app/JhHardwareWRS_BackPoint
python -m pytest tests/test_work_order_batch_options.py tests/test_metrics.py tests/test_review_corrections.py -q
```

Expected: tests pass.

- [ ] **Step 4: Manual smoke test in browser**

Start services:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
```

In another terminal:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run dev -- --host 0.0.0.0 --port 5173
```

Smoke flow:

1. Open `http://127.0.0.1:5173`.
2. Go to 系统拓展 and verify 原材料库存批次号前缀 shows `YL`.
3. Go to 嘉恒仓库原材料库 and create a raw inbound test lot; verify lot number is `YL0001` or the next available `YL` sequence.
4. Use 原材料库 · 生产出库, select one material stock lot and one product, submit.
5. Verify 工单台账 has work order `YYYYMMDD-YLxxxx-产品名称`.
6. Submit another production outbound for the same day, same lot, same product.
7. Verify it merges into the same work order and adds another issue line.
8. Open 嘉恒仓库 · 生产工单入库, select that work order, enter finished qty, verify surplus/scrap defaults are filled.
9. Change surplus or scrap beyond 15%; verify deviation remark is required.
10. Check “本次入库后结单”, submit, confirm second prompt, verify work order status becomes 已结单.

Expected: no red overlay, no uncaught console error, and warehouse流水 shows成品入库、生产余料入库、生产废料入库 records tied to the work order.

---

## Self-Review

Spec coverage:

- `YL0001` prefix and `YL10000` expansion: Task 1 and Task 2.
- System拓展 prefix configuration: Task 1 and Task 7.
- Single-lot production outbound: Task 4 and Task 7.
- Same-day same-lot same-product merge: Task 4.
- Miniapp wording and lot-number selection: Task 5 and Task 8.
- Production work-order inbound entry and workflow: Task 6 and Task 7.
- 15% deviation remark: Task 6 and Task 7.
- No default settle and second confirmation: Task 6 and Task 7.
- SQL execution and verification: Task 9.
- Full test and smoke checks: Task 10.

Placeholder scan:

- No unfinished placeholder markers remain in the plan.
- Production work-order inbound now lists concrete helper functions and submit calls instead of leaving a comment-only implementation gap.

Type consistency:

- Backend schemas use `ProductionWorkOrderInboundPreviewRead`, `ProductionWorkOrderInboundCreate`, and `ProductionWorkOrderInboundRead`.
- Frontend routes use `/production/work-order-inbounds/preview` and `/production/work-order-inbounds`.
- Miniapp keeps the persisted field `raw_material_batch_no` but changes visible wording to “材料库存批次号”.

Git note:

- Current ERP directory has no `.git` folder. If implementation runs in this mounted directory, commit steps are replaced by checkpoint summaries after each task. If a git worktree is available at execution time, commit after each completed task with a focused message.
