# Special Warehouse Adjustment 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:** 为嘉恒仓库 6 大库的入库与出库操作区分别增加“特殊入库”“特殊出库”，允许在强制填写说明后，精确到库存明细行进行受控库存调整，并把说明写入所有变更记录。

**Architecture:** 新增独立的“特殊调整”后端服务与审计主/明细表，库存变动仍复用现有 `wh_stock_lot`、`wh_stock_balance`、`wh_inventory_txn` 三层账本。前端在现有 `InventoryLedgerView.vue` 的仓库出入库操作矩阵中增加特殊操作入口，打开专用抽屉完成说明填写、明细选择、调整前后预览和提交。

**Tech Stack:** FastAPI, SQLAlchemy ORM, MySQL 8, SQLite unit tests, Vue 3, Vite, Node.js static UI scripts.

---

## Scope And Invariants

本计划只实现“特殊入库/特殊出库”，不改变正常采购、销售、生产、盘库链路。

必须保持这些业务不变量：

- 原材料库：只按重量调整，精确到原材料库存批次号。
- 半成品库：按该半成品库存既有口径调整，按件库存只能调数量，按重库存只能调重量。
- 成品库：主维度按数量调整，重量作为辅助字段随同记录。
- 辅料库：只按数量调整。
- 废料库：只按重量调整，并保留来源原材料库存批次追溯展示。
- 退货库：按退货品库存明细调整，保留发货来源、来源原材料库存批次追溯。
- 特殊出库不能超过所选明细当前可用量。
- 盘库锁库期间禁止特殊入库和特殊出库。
- 操作说明必填，且必须写入特殊调整主单、明细、库存流水 `remark`。
- 特殊调整不联动采购订单、销售订单、生产工单状态，避免污染正常业务状态。
- 用户界面展示必须使用中文业务名，不显示英文状态或英文标志。

---

## File Structure

Create:

- `backend/sql/add_special_warehouse_adjustment_patch.sql`
  - MySQL 生产/测试库补丁，新增特殊调整主表与明细表。
- `backend/app/services/special_inventory_adjustment.py`
  - 特殊调整核心服务，负责校验、明细变更、汇总库存更新、流水生成、审计记录。
- `backend/tests/test_special_warehouse_adjustment.py`
  - 后端 SQLite 单元测试，覆盖强制说明、原材料重量调整、辅料数量调整、成品数量调整、锁库拦截。
- `frontend/src/utils/specialAdjustmentRules.js`
  - 前端特殊调整口径与展示规则，避免把口径判断全部堆进 Vue 单文件。
- `frontend/scripts/test-special-adjustment-ui.mjs`
  - 前端静态脚本测试，检查 6 大库都出现特殊入库/特殊出库入口，检查口径规则。

Modify:

- `backend/app/models/operations.py`
  - 新增 `SpecialWarehouseAdjustment`、`SpecialWarehouseAdjustmentLine` ORM 模型。
- `backend/app/schemas/operations.py`
  - 新增特殊调整请求/响应 schema。
- `backend/app/api/routes/inventory.py`
  - 新增 `/inventory/special-adjustments` 接口，接入服务。
- `backend/app/services/operations.py`
  - 扩展仓库流水方向与类型中文展示能力，确保特殊调整可在流水中查到。
- `frontend/src/views/InventoryLedgerView.vue`
  - 增加 6 大库操作按钮、专用抽屉、提交逻辑、调整前后预览。

---

## Task 1: Backend Failing Tests For Special Adjustment

**Files:**

- Create: `backend/tests/test_special_warehouse_adjustment.py`
- Future dependency: `backend/app/services/special_inventory_adjustment.py`
- Future dependency: `backend/app/schemas/operations.py`

- [ ] **Step 1: Write the failing backend test file**

Create `backend/tests/test_special_warehouse_adjustment.py` with this content:

```python
from __future__ import annotations

import unittest
from datetime import UTC, datetime
from decimal import Decimal

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


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


import app.models.master_data  # noqa: E402,F401
import app.models.operations  # noqa: E402,F401
from app.models.base import Base  # noqa: E402
from app.models.master_data import Item, StockBalance, Warehouse  # noqa: E402
from app.models.operations import InventoryTxn, StockLot, WarehouseLocation  # noqa: E402
from app.schemas.operations import SpecialWarehouseAdjustmentCreate, SpecialWarehouseAdjustmentLineCreate  # noqa: E402
from app.services.special_inventory_adjustment import create_special_inventory_adjustment  # noqa: E402


class SpecialWarehouseAdjustmentTest(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._seed()

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

    def _seed(self) -> None:
        now = datetime.now(UTC)
        self.raw = Warehouse(
            id=1,
            warehouse_code="WH-RAW",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.aux = Warehouse(
            id=2,
            warehouse_code="WH-AUX",
            warehouse_name="辅料库",
            warehouse_type="AUX",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.finished = Warehouse(
            id=3,
            warehouse_code="WH-FG",
            warehouse_name="成品库",
            warehouse_type="FINISHED",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.raw_loc = WarehouseLocation(
            id=1,
            warehouse_id=1,
            location_code="RAW-A",
            location_name="原材料默认库位",
            is_default=1,
            is_locked=0,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.aux_loc = WarehouseLocation(
            id=2,
            warehouse_id=2,
            location_code="AUX-A",
            location_name="辅料默认库位",
            is_default=1,
            is_locked=0,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.finished_loc = WarehouseLocation(
            id=3,
            warehouse_id=3,
            location_code="FG-A",
            location_name="成品默认库位",
            is_default=1,
            is_locked=0,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.raw_item = Item(
            id=1,
            item_code="原材料00001",
            item_name="冷轧钢板",
            item_type="RAW_MATERIAL",
            unit_weight_kg=Decimal("1"),
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.aux_item = Item(
            id=2,
            item_code="原材料00002",
            item_name="劳保手套",
            item_type="RAW_MATERIAL",
            unit_weight_kg=Decimal("0"),
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.finished_item = Item(
            id=3,
            item_code="产品需规00001",
            item_name="钢制碗",
            item_type="FINISHED_GOOD",
            unit_weight_kg=Decimal("0.6"),
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        self.raw_lot = StockLot(
            id=1,
            lot_no="JH_冷轧钢板00001_0001",
            lot_role="OPENING_STOCK",
            item_id=1,
            warehouse_id=1,
            location_id=1,
            source_doc_type="OPENING_STOCK",
            source_doc_id=1,
            inbound_qty=Decimal("0"),
            inbound_weight_kg=Decimal("500"),
            remaining_qty=Decimal("0"),
            remaining_weight_kg=Decimal("300"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("4.5"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=now,
            updated_at=now,
        )
        self.aux_lot = StockLot(
            id=2,
            lot_no="AUX-20260606-001",
            lot_role="OPENING_STOCK",
            item_id=2,
            warehouse_id=2,
            location_id=2,
            source_doc_type="OPENING_STOCK",
            source_doc_id=2,
            inbound_qty=Decimal("120"),
            inbound_weight_kg=Decimal("0"),
            remaining_qty=Decimal("80"),
            remaining_weight_kg=Decimal("0"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("2"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=now,
            updated_at=now,
        )
        self.finished_lot = StockLot(
            id=3,
            lot_no="FG-20260606-001",
            lot_role="INBOUND_FINISHED",
            item_id=3,
            warehouse_id=3,
            location_id=3,
            source_doc_type="FG_RECEIPT",
            source_doc_id=3,
            inbound_qty=Decimal("100"),
            inbound_weight_kg=Decimal("60"),
            remaining_qty=Decimal("70"),
            remaining_weight_kg=Decimal("42"),
            locked_qty=Decimal("0"),
            locked_weight_kg=Decimal("0"),
            unit_cost=Decimal("2.7"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=now,
            updated_at=now,
        )
        self.raw_balance = StockBalance(
            item_id=1,
            warehouse_id=1,
            location_id=1,
            qty_on_hand=Decimal("0"),
            weight_on_hand_kg=Decimal("300"),
            qty_available=Decimal("0"),
            weight_available_kg=Decimal("300"),
            qty_allocated=Decimal("0"),
            weight_allocated_kg=Decimal("0"),
            avg_unit_cost=Decimal("4.5"),
            created_at=now,
            updated_at=now,
        )
        self.aux_balance = StockBalance(
            item_id=2,
            warehouse_id=2,
            location_id=2,
            qty_on_hand=Decimal("80"),
            weight_on_hand_kg=Decimal("0"),
            qty_available=Decimal("80"),
            weight_available_kg=Decimal("0"),
            qty_allocated=Decimal("0"),
            weight_allocated_kg=Decimal("0"),
            avg_unit_cost=Decimal("2"),
            created_at=now,
            updated_at=now,
        )
        self.finished_balance = StockBalance(
            item_id=3,
            warehouse_id=3,
            location_id=3,
            qty_on_hand=Decimal("70"),
            weight_on_hand_kg=Decimal("42"),
            qty_available=Decimal("70"),
            weight_available_kg=Decimal("42"),
            qty_allocated=Decimal("0"),
            weight_allocated_kg=Decimal("0"),
            avg_unit_cost=Decimal("2.7"),
            created_at=now,
            updated_at=now,
        )
        self.db.add_all([
            self.raw,
            self.aux,
            self.finished,
            self.raw_loc,
            self.aux_loc,
            self.finished_loc,
            self.raw_item,
            self.aux_item,
            self.finished_item,
            self.raw_lot,
            self.aux_lot,
            self.finished_lot,
            self.raw_balance,
            self.aux_balance,
            self.finished_balance,
        ])
        self.db.commit()

    def test_special_outbound_requires_reason_before_stock_change(self) -> None:
        payload = SpecialWarehouseAdjustmentCreate(
            warehouse_id=self.raw.id,
            direction="OUT",
            reason="",
            lines=[
                SpecialWarehouseAdjustmentLineCreate(
                    item_id=self.raw_item.id,
                    lot_id=self.raw_lot.id,
                    adjust_weight_kg=Decimal("10"),
                )
            ],
        )

        with self.assertRaisesRegex(ValueError, "特殊出库必须填写说明"):
            create_special_inventory_adjustment(self.db, payload, user_id=9)

        unchanged_lot = self.db.get(StockLot, self.raw_lot.id)
        self.assertEqual(Decimal(str(unchanged_lot.remaining_weight_kg)), Decimal("300.000000"))

    def test_raw_special_outbound_updates_lot_balance_and_inventory_txn(self) -> None:
        payload = SpecialWarehouseAdjustmentCreate(
            warehouse_id=self.raw.id,
            direction="OUT",
            reason="现场发现边角料已线下报废，补做特殊出库",
            lines=[
                SpecialWarehouseAdjustmentLineCreate(
                    item_id=self.raw_item.id,
                    lot_id=self.raw_lot.id,
                    adjust_weight_kg=Decimal("25.5"),
                )
            ],
        )

        result = create_special_inventory_adjustment(self.db, payload, user_id=9)

        adjusted_lot = self.db.get(StockLot, self.raw_lot.id)
        balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.raw_item.id))
        txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_OUT"))

        self.assertEqual(result.direction, "OUT")
        self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("274.500000"))
        self.assertEqual(Decimal(str(balance.weight_on_hand_kg)), Decimal("274.500000"))
        self.assertEqual(Decimal(str(txn.weight_change_kg)), Decimal("-25.500000"))
        self.assertIn("特殊出库说明：现场发现边角料已线下报废，补做特殊出库", txn.remark)
        self.assertIn("调整前重量 300", txn.remark)
        self.assertIn("调整后重量 274.5", txn.remark)

    def test_special_outbound_cannot_exceed_available_lot_quantity_or_weight(self) -> None:
        payload = SpecialWarehouseAdjustmentCreate(
            warehouse_id=self.aux.id,
            direction="OUT",
            reason="盘点前发现辅料账实差异，补做出库",
            lines=[
                SpecialWarehouseAdjustmentLineCreate(
                    item_id=self.aux_item.id,
                    lot_id=self.aux_lot.id,
                    adjust_qty=Decimal("81"),
                )
            ],
        )

        with self.assertRaisesRegex(ValueError, "特殊出库数量不能超过当前可用数量"):
            create_special_inventory_adjustment(self.db, payload, user_id=9)

    def test_aux_special_inbound_uses_quantity_only(self) -> None:
        payload = SpecialWarehouseAdjustmentCreate(
            warehouse_id=self.aux.id,
            direction="IN",
            reason="供应商赠送辅料漏入账，补做特殊入库",
            lines=[
                SpecialWarehouseAdjustmentLineCreate(
                    item_id=self.aux_item.id,
                    lot_id=self.aux_lot.id,
                    adjust_qty=Decimal("12"),
                    adjust_weight_kg=Decimal("99"),
                )
            ],
        )

        create_special_inventory_adjustment(self.db, payload, user_id=9)

        adjusted_lot = self.db.get(StockLot, self.aux_lot.id)
        balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.aux_item.id))
        txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_IN"))

        self.assertEqual(Decimal(str(adjusted_lot.remaining_qty)), Decimal("92.000000"))
        self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("0.000000"))
        self.assertEqual(Decimal(str(balance.qty_on_hand)), Decimal("92.000000"))
        self.assertEqual(Decimal(str(txn.qty_change)), Decimal("12.000000"))
        self.assertEqual(Decimal(str(txn.weight_change_kg)), Decimal("0.000000"))

    def test_finished_special_outbound_uses_qty_and_keeps_weight_auxiliary(self) -> None:
        payload = SpecialWarehouseAdjustmentCreate(
            warehouse_id=self.finished.id,
            direction="OUT",
            reason="客户样品领用，补做成品特殊出库",
            lines=[
                SpecialWarehouseAdjustmentLineCreate(
                    item_id=self.finished_item.id,
                    lot_id=self.finished_lot.id,
                    adjust_qty=Decimal("5"),
                    adjust_weight_kg=Decimal("3"),
                )
            ],
        )

        create_special_inventory_adjustment(self.db, payload, user_id=9)

        adjusted_lot = self.db.get(StockLot, self.finished_lot.id)
        balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.finished_item.id))

        self.assertEqual(Decimal(str(adjusted_lot.remaining_qty)), Decimal("65.000000"))
        self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("39.000000"))
        self.assertEqual(Decimal(str(balance.qty_on_hand)), Decimal("65.000000"))
        self.assertEqual(Decimal(str(balance.weight_on_hand_kg)), Decimal("39.000000"))
```

- [ ] **Step 2: Run the failing backend tests**

Run:

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

Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.special_inventory_adjustment'` or import errors for `SpecialWarehouseAdjustmentCreate`.

- [ ] **Step 3: Commit the failing test**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/tests/test_special_warehouse_adjustment.py
git commit -m "test: cover special warehouse adjustments"
```

---

## Task 2: Database And ORM Audit Tables

**Files:**

- Create: `backend/sql/add_special_warehouse_adjustment_patch.sql`
- Modify: `backend/app/models/operations.py`

- [ ] **Step 1: Add MySQL migration patch**

Create `backend/sql/add_special_warehouse_adjustment_patch.sql`:

```sql
-- 特殊出入库调整主表和明细表
-- 说明：
-- 1. 特殊调整不替代库存流水，所有库存变动仍写入 wh_inventory_txn。
-- 2. 该表用于把一次特殊操作中涉及的多行明细聚合成一张审计单。

CREATE TABLE IF NOT EXISTS wh_special_adjustment (
  id BIGINT NOT NULL AUTO_INCREMENT,
  adjustment_no VARCHAR(50) NOT NULL,
  warehouse_id BIGINT NOT NULL,
  warehouse_type VARCHAR(32) NOT NULL,
  direction VARCHAR(16) NOT NULL,
  reason VARCHAR(1000) NOT NULL,
  status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED',
  operator_user_id BIGINT NULL,
  adjusted_at DATETIME NOT NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_wh_special_adjustment_no (adjustment_no),
  KEY idx_wh_special_adjustment_warehouse (warehouse_id, adjusted_at),
  KEY idx_wh_special_adjustment_direction (direction, adjusted_at),
  CONSTRAINT fk_wh_special_adjustment_warehouse
    FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库特殊出入库调整主表';

CREATE TABLE IF NOT EXISTS wh_special_adjustment_line (
  id BIGINT NOT NULL AUTO_INCREMENT,
  adjustment_id BIGINT NOT NULL,
  line_no INT NOT NULL,
  item_id BIGINT NOT NULL,
  warehouse_id BIGINT NOT NULL,
  location_id BIGINT NULL,
  lot_id BIGINT NOT NULL,
  inventory_txn_id BIGINT NULL,
  before_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
  change_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
  after_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
  before_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
  change_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
  after_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
  unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0,
  line_reason VARCHAR(1000) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  KEY idx_wh_special_adjustment_line_adjustment (adjustment_id, line_no),
  KEY idx_wh_special_adjustment_line_lot (lot_id),
  KEY idx_wh_special_adjustment_line_txn (inventory_txn_id),
  CONSTRAINT fk_wh_special_adjustment_line_adjustment
    FOREIGN KEY (adjustment_id) REFERENCES wh_special_adjustment(id),
  CONSTRAINT fk_wh_special_adjustment_line_item
    FOREIGN KEY (item_id) REFERENCES md_item(id),
  CONSTRAINT fk_wh_special_adjustment_line_warehouse
    FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id),
  CONSTRAINT fk_wh_special_adjustment_line_lot
    FOREIGN KEY (lot_id) REFERENCES wh_stock_lot(id),
  CONSTRAINT fk_wh_special_adjustment_line_txn
    FOREIGN KEY (inventory_txn_id) REFERENCES wh_inventory_txn(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库特殊出入库调整明细表';
```

- [ ] **Step 2: Add ORM models**

Modify `backend/app/models/operations.py` after `InventoryTxn`:

```python
class SpecialWarehouseAdjustment(Base):
    __tablename__ = "wh_special_adjustment"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    adjustment_no: Mapped[str] = mapped_column(String(50))
    warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
    warehouse_type: Mapped[str] = mapped_column(String(32))
    direction: Mapped[str] = mapped_column(String(16))
    reason: Mapped[str] = mapped_column(String(1000))
    status: Mapped[str] = mapped_column(String(32), server_default=text("'CONFIRMED'"))
    operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
    adjusted_at: Mapped[object] = mapped_column(DateTime)
    created_at: Mapped[object] = mapped_column(DateTime)
    updated_at: Mapped[object] = mapped_column(DateTime)


class SpecialWarehouseAdjustmentLine(Base):
    __tablename__ = "wh_special_adjustment_line"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
    adjustment_id: Mapped[int] = mapped_column(ForeignKey("wh_special_adjustment.id"))
    line_no: Mapped[int] = mapped_column(Integer)
    item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
    warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
    location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
    lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
    inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True)
    before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
    change_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
    after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
    before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
    change_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
    after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
    unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
    line_reason: Mapped[str | None] = mapped_column(String(1000), nullable=True)
    created_at: Mapped[object] = mapped_column(DateTime)
    updated_at: Mapped[object] = mapped_column(DateTime)
```

- [ ] **Step 3: Run model import check**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python - <<'PY'
import app.models.operations as operations
print(operations.SpecialWarehouseAdjustment.__tablename__)
print(operations.SpecialWarehouseAdjustmentLine.__tablename__)
PY
```

Expected:

```text
wh_special_adjustment
wh_special_adjustment_line
```

- [ ] **Step 4: Re-run backend tests**

Run:

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

Expected: still FAIL because schemas and service are not implemented yet.

- [ ] **Step 5: Commit**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/sql/add_special_warehouse_adjustment_patch.sql backend/app/models/operations.py
git commit -m "feat: add special adjustment audit tables"
```

---

## Task 3: Backend Schemas And Core Service

**Files:**

- Modify: `backend/app/schemas/operations.py`
- Create: `backend/app/services/special_inventory_adjustment.py`

- [ ] **Step 1: Add request and response schemas**

Modify `backend/app/schemas/operations.py` near the warehouse operation schemas:

```python
class SpecialWarehouseAdjustmentLineCreate(BaseModel):
    item_id: int
    lot_id: int | None = None
    location_id: int | None = None
    adjust_qty: float | None = 0
    adjust_weight_kg: float | None = 0
    unit_cost: float | None = None
    line_reason: str | None = None


class SpecialWarehouseAdjustmentCreate(BaseModel):
    warehouse_id: int
    direction: str
    reason: str
    lines: list[SpecialWarehouseAdjustmentLineCreate] = Field(min_length=1)


class SpecialWarehouseAdjustmentLineRead(BaseModel):
    line_id: int
    line_no: int
    item_id: int
    lot_id: int
    inventory_txn_id: int | None = None
    before_qty: float
    change_qty: float
    after_qty: float
    before_weight_kg: float
    change_weight_kg: float
    after_weight_kg: float
    unit_cost: float
    line_reason: str | None = None


class SpecialWarehouseAdjustmentRead(BaseModel):
    adjustment_id: int
    adjustment_no: str
    warehouse_id: int
    warehouse_type: str
    direction: str
    reason: str
    status: str
    adjusted_at: datetime
    lines: list[SpecialWarehouseAdjustmentLineRead] = Field(default_factory=list)
```

- [ ] **Step 2: Implement service constants and helpers**

Create `backend/app/services/special_inventory_adjustment.py` with these imports and helpers:

```python
from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.master_data import Item, StockBalance, Warehouse
from app.models.operations import (
    InventoryTxn,
    SpecialWarehouseAdjustment,
    SpecialWarehouseAdjustmentLine,
    StockLot,
)
from app.schemas.operations import (
    SpecialWarehouseAdjustmentCreate,
    SpecialWarehouseAdjustmentLineRead,
    SpecialWarehouseAdjustmentRead,
)
from app.services.operations import create_inventory_txn, get_location_or_default, next_doc_no, to_decimal, upsert_stock_balance
from app.services.stocktake import ensure_warehouses_unlocked

SPECIAL_SOURCE_DOC_TYPE = "SPECIAL_ADJUSTMENT"
SPECIAL_IN_TXN_TYPE = "SPECIAL_IN"
SPECIAL_OUT_TXN_TYPE = "SPECIAL_OUT"
WEIGHT_ONLY_WAREHOUSE_TYPES = {"RAW", "SCRAP"}
QTY_ONLY_WAREHOUSE_TYPES = {"AUX"}
QTY_PRIMARY_WAREHOUSE_TYPES = {"FINISHED", "RETURN"}


@dataclass(frozen=True)
class AdjustmentMeasure:
    qty: Decimal
    weight: Decimal
    amount_basis: str


def _clean_required_reason(direction: str, reason: str | None) -> str:
    label = "特殊入库" if direction == "IN" else "特殊出库"
    cleaned = str(reason or "").strip()
    if not cleaned:
        raise ValueError(f"{label}必须填写说明")
    if len(cleaned) < 4:
        raise ValueError(f"{label}说明不能少于4个字")
    return cleaned


def _direction_label(direction: str) -> str:
    return "特殊入库" if direction == "IN" else "特殊出库"


def _normalize_direction(value: str | None) -> str:
    direction = str(value or "").upper()
    if direction not in {"IN", "OUT"}:
        raise ValueError("特殊调整方向只能是特殊入库或特殊出库")
    return direction


def _measure_for_warehouse(warehouse_type: str, raw_qty: Decimal, raw_weight: Decimal) -> AdjustmentMeasure:
    if warehouse_type in WEIGHT_ONLY_WAREHOUSE_TYPES:
        if raw_weight <= 0:
            raise ValueError("该仓库特殊调整必须填写重量")
        return AdjustmentMeasure(qty=Decimal("0"), weight=raw_weight, amount_basis="WEIGHT")
    if warehouse_type in QTY_ONLY_WAREHOUSE_TYPES:
        if raw_qty <= 0:
            raise ValueError("该仓库特殊调整必须填写数量")
        return AdjustmentMeasure(qty=raw_qty, weight=Decimal("0"), amount_basis="QTY")
    if warehouse_type in QTY_PRIMARY_WAREHOUSE_TYPES:
        if raw_qty <= 0:
            raise ValueError("该仓库特殊调整必须填写数量")
        return AdjustmentMeasure(qty=raw_qty, weight=max(raw_weight, Decimal("0")), amount_basis="QTY")
    if raw_qty > 0 and raw_weight > 0:
        raise ValueError("半成品特殊调整只能按件或按重选择一种口径")
    if raw_qty > 0:
        return AdjustmentMeasure(qty=raw_qty, weight=Decimal("0"), amount_basis="QTY")
    if raw_weight > 0:
        return AdjustmentMeasure(qty=Decimal("0"), weight=raw_weight, amount_basis="WEIGHT")
    raise ValueError("半成品特殊调整必须填写数量或重量")


def _format_decimal(value: Decimal) -> str:
    text = f"{value.normalize():f}"
    return text.rstrip("0").rstrip(".") if "." in text else text
```

- [ ] **Step 3: Implement new lot creation for special inbound**

Append this helper to `backend/app/services/special_inventory_adjustment.py`:

```python
def _build_special_lot_no(db: Session, item: Item, warehouse: Warehouse, now: datetime) -> str:
    warehouse_type = str(warehouse.warehouse_type or "").upper()
    if warehouse_type == "RAW":
        from app.services.operations import build_inventory_lot_no

        return build_inventory_lot_no(db, item.id)
    prefix = {
        "SEMI": "半成品特殊",
        "FINISHED": "成品特殊",
        "AUX": "辅料特殊",
        "SCRAP": "废料特殊",
        "RETURN": "退货特殊",
    }.get(warehouse_type, "库存特殊")
    base = f"{prefix}{now.strftime('%Y%m%d')}"
    existing_count = db.scalar(
        select(SpecialWarehouseAdjustmentLine.id)
        .join(SpecialWarehouseAdjustment, SpecialWarehouseAdjustment.id == SpecialWarehouseAdjustmentLine.adjustment_id)
        .where(SpecialWarehouseAdjustment.warehouse_id == warehouse.id)
        .order_by(SpecialWarehouseAdjustmentLine.id.desc())
        .limit(1)
    )
    next_seq = int(existing_count or 0) + 1
    candidate = f"{base}-{next_seq:04d}"
    while db.scalar(select(StockLot.id).where(StockLot.lot_no == candidate)):
        next_seq += 1
        candidate = f"{base}-{next_seq:04d}"
    return candidate


def _create_special_inbound_lot(
    db: Session,
    *,
    item: Item,
    warehouse: Warehouse,
    location_id: int | None,
    measure: AdjustmentMeasure,
    unit_cost: Decimal,
    adjustment_id: int,
    line_id: int,
    reason: str,
    now: datetime,
) -> StockLot:
    location = get_location_or_default(db, warehouse.id, location_id)
    lot = StockLot(
        lot_no=_build_special_lot_no(db, item, warehouse, now),
        lot_role="SPECIAL_ADJUSTMENT",
        item_id=item.id,
        warehouse_id=warehouse.id,
        location_id=location.id if location else None,
        source_doc_type=SPECIAL_SOURCE_DOC_TYPE,
        source_doc_id=adjustment_id,
        source_line_id=line_id,
        source_material_summary=f"特殊入库说明：{reason}",
        inbound_qty=measure.qty,
        inbound_weight_kg=measure.weight,
        remaining_qty=measure.qty,
        remaining_weight_kg=measure.weight,
        locked_qty=Decimal("0"),
        locked_weight_kg=Decimal("0"),
        unit_cost=unit_cost,
        production_date=now.date(),
        quality_status="PASS",
        status="AVAILABLE",
        remark=f"特殊入库说明：{reason}"[:255],
        created_at=now,
        updated_at=now,
    )
    db.add(lot)
    db.flush()
    return lot
```

- [ ] **Step 4: Implement main service**

Append this function to `backend/app/services/special_inventory_adjustment.py`:

```python
def create_special_inventory_adjustment(
    db: Session,
    payload: SpecialWarehouseAdjustmentCreate,
    *,
    user_id: int | None,
) -> SpecialWarehouseAdjustmentRead:
    direction = _normalize_direction(payload.direction)
    reason = _clean_required_reason(direction, payload.reason)
    warehouse = db.get(Warehouse, payload.warehouse_id)
    if not warehouse:
        raise ValueError("仓库不存在")
    ensure_warehouses_unlocked(db, [warehouse.id], _direction_label(direction))
    warehouse_type = str(warehouse.warehouse_type or "").upper()
    now = datetime.now()
    adjustment = SpecialWarehouseAdjustment(
        adjustment_no=next_doc_no(db, SpecialWarehouseAdjustment, "adjustment_no", f"特殊调整{now.strftime('%Y%m%d')}"),
        warehouse_id=warehouse.id,
        warehouse_type=warehouse_type,
        direction=direction,
        reason=reason,
        status="CONFIRMED",
        operator_user_id=user_id,
        adjusted_at=now,
        created_at=now,
        updated_at=now,
    )
    db.add(adjustment)
    db.flush()

    read_lines: list[SpecialWarehouseAdjustmentLineRead] = []
    for index, payload_line in enumerate(payload.lines, start=1):
        item = db.get(Item, payload_line.item_id)
        if not item:
            raise ValueError(f"第 {index} 行物料不存在")

        raw_qty = to_decimal(payload_line.adjust_qty)
        raw_weight = to_decimal(payload_line.adjust_weight_kg)
        if raw_qty < 0 or raw_weight < 0:
            raise ValueError("特殊调整数量和重量不能小于0")
        measure = _measure_for_warehouse(warehouse_type, raw_qty, raw_weight)

        lot = db.get(StockLot, payload_line.lot_id) if payload_line.lot_id else None
        if direction == "OUT" and lot is None:
            raise ValueError("特殊出库必须选择库存明细")
        if lot is not None:
            if lot.item_id != item.id or lot.warehouse_id != warehouse.id:
                raise ValueError("库存明细与所选物料或仓库不一致")
            if payload_line.location_id and lot.location_id != payload_line.location_id:
                raise ValueError("库存明细不属于所选库位")

        if direction == "IN" and lot is None:
            unit_cost = to_decimal(payload_line.unit_cost, "0.0001")
            placeholder_line = SpecialWarehouseAdjustmentLine(
                adjustment_id=adjustment.id,
                line_no=index,
                item_id=item.id,
                warehouse_id=warehouse.id,
                location_id=payload_line.location_id,
                lot_id=0,
                before_qty=Decimal("0"),
                change_qty=measure.qty,
                after_qty=measure.qty,
                before_weight_kg=Decimal("0"),
                change_weight_kg=measure.weight,
                after_weight_kg=measure.weight,
                unit_cost=unit_cost,
                line_reason=payload_line.line_reason,
                created_at=now,
                updated_at=now,
            )
            db.add(placeholder_line)
            db.flush()
            lot = _create_special_inbound_lot(
                db,
                item=item,
                warehouse=warehouse,
                location_id=payload_line.location_id,
                measure=measure,
                unit_cost=unit_cost,
                adjustment_id=adjustment.id,
                line_id=placeholder_line.id,
                reason=reason,
                now=now,
            )
            placeholder_line.lot_id = lot.id
            db.add(placeholder_line)
            line = placeholder_line
            before_qty = Decimal("0")
            before_weight = Decimal("0")
        else:
            assert lot is not None
            before_qty = to_decimal(lot.remaining_qty)
            before_weight = to_decimal(lot.remaining_weight_kg)
            unit_cost = to_decimal(payload_line.unit_cost, "0.0001") if payload_line.unit_cost is not None else to_decimal(lot.unit_cost, "0.0001")
            if direction == "OUT":
                available_qty = before_qty - to_decimal(lot.locked_qty)
                available_weight = before_weight - to_decimal(lot.locked_weight_kg)
                if measure.qty > 0 and measure.qty > available_qty:
                    raise ValueError("特殊出库数量不能超过当前可用数量")
                if measure.weight > 0 and measure.weight > available_weight:
                    raise ValueError("特殊出库重量不能超过当前可用重量")
                change_qty = -measure.qty
                change_weight = -measure.weight
            else:
                change_qty = measure.qty
                change_weight = measure.weight
            line = SpecialWarehouseAdjustmentLine(
                adjustment_id=adjustment.id,
                line_no=index,
                item_id=item.id,
                warehouse_id=warehouse.id,
                location_id=lot.location_id,
                lot_id=lot.id,
                before_qty=before_qty,
                change_qty=change_qty,
                after_qty=before_qty + change_qty,
                before_weight_kg=before_weight,
                change_weight_kg=change_weight,
                after_weight_kg=before_weight + change_weight,
                unit_cost=unit_cost,
                line_reason=payload_line.line_reason,
                created_at=now,
                updated_at=now,
            )
            db.add(line)
            db.flush()
            lot.remaining_qty = line.after_qty
            lot.remaining_weight_kg = line.after_weight_kg
            lot.status = "AVAILABLE" if line.after_qty > 0 or line.after_weight_kg > 0 else "DEPLETED"
            lot.updated_at = now
            db.add(lot)

        line_reason = str(payload_line.line_reason or "").strip()
        direction_text = _direction_label(direction)
        remark_parts = [
            f"{direction_text}说明：{reason}",
            f"调整前数量 {_format_decimal(to_decimal(line.before_qty))}",
            f"调整数量 {_format_decimal(to_decimal(line.change_qty))}",
            f"调整后数量 {_format_decimal(to_decimal(line.after_qty))}",
            f"调整前重量 {_format_decimal(to_decimal(line.before_weight_kg))}",
            f"调整重量 {_format_decimal(to_decimal(line.change_weight_kg))}",
            f"调整后重量 {_format_decimal(to_decimal(line.after_weight_kg))}",
        ]
        if line_reason:
            remark_parts.append(f"明细说明：{line_reason}")
        txn = create_inventory_txn(
            db,
            txn_type=SPECIAL_IN_TXN_TYPE if direction == "IN" else SPECIAL_OUT_TXN_TYPE,
            item_id=item.id,
            warehouse_id=warehouse.id,
            location_id=lot.location_id,
            lot_id=lot.id,
            qty_change=to_decimal(line.change_qty),
            weight_change=to_decimal(line.change_weight_kg),
            unit_cost=unit_cost,
            source_doc_type=SPECIAL_SOURCE_DOC_TYPE,
            source_doc_id=adjustment.id,
            source_line_id=line.id,
            biz_time=now,
            operator_user_id=user_id,
            remark="；".join(remark_parts)[:255],
            amount_basis=measure.amount_basis,
        )
        line.inventory_txn_id = txn.id
        db.add(line)
        upsert_stock_balance(
            db,
            item_id=item.id,
            warehouse_id=warehouse.id,
            location_id=lot.location_id,
            qty_delta=to_decimal(line.change_qty),
            weight_delta=to_decimal(line.change_weight_kg),
            available_qty_delta=to_decimal(line.change_qty),
            available_weight_delta=to_decimal(line.change_weight_kg),
            unit_cost=unit_cost,
        )
        read_lines.append(
            SpecialWarehouseAdjustmentLineRead(
                line_id=line.id,
                line_no=line.line_no,
                item_id=line.item_id,
                lot_id=line.lot_id,
                inventory_txn_id=txn.id,
                before_qty=float(line.before_qty),
                change_qty=float(line.change_qty),
                after_qty=float(line.after_qty),
                before_weight_kg=float(line.before_weight_kg),
                change_weight_kg=float(line.change_weight_kg),
                after_weight_kg=float(line.after_weight_kg),
                unit_cost=float(line.unit_cost),
                line_reason=line.line_reason,
            )
        )

    db.commit()
    return SpecialWarehouseAdjustmentRead(
        adjustment_id=adjustment.id,
        adjustment_no=adjustment.adjustment_no,
        warehouse_id=adjustment.warehouse_id,
        warehouse_type=adjustment.warehouse_type,
        direction=adjustment.direction,
        reason=adjustment.reason,
        status=adjustment.status,
        adjusted_at=adjustment.adjusted_at,
        lines=read_lines,
    )
```

- [ ] **Step 5: Run the backend tests**

Run:

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

Expected: PASS for the new test file. If SQLite rejects `lot_id=0` because of a foreign key in the placeholder line, adjust the implementation by creating the special inbound `StockLot` first with `source_line_id=0`, then create `SpecialWarehouseAdjustmentLine`, then update `StockLot.source_line_id` to the line id before commit.

- [ ] **Step 6: Commit**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/schemas/operations.py backend/app/services/special_inventory_adjustment.py backend/tests/test_special_warehouse_adjustment.py
git commit -m "feat: implement special warehouse adjustment service"
```

---

## Task 4: API Route And Ledger Visibility

**Files:**

- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/services/operations.py`
- Modify: `backend/tests/test_special_warehouse_adjustment.py`

- [ ] **Step 1: Extend tests with API-style route behavior**

Append to `SpecialWarehouseAdjustmentTest`:

```python
    def test_special_adjustment_ledger_direction_and_chinese_remark(self) -> None:
        from app.services.operations import get_inventory_txn_ledger_query

        payload = SpecialWarehouseAdjustmentCreate(
            warehouse_id=self.raw.id,
            direction="OUT",
            reason="仓管复核后确认线下已处理，补做特殊出库",
            lines=[
                SpecialWarehouseAdjustmentLineCreate(
                    item_id=self.raw_item.id,
                    lot_id=self.raw_lot.id,
                    adjust_weight_kg=Decimal("6"),
                )
            ],
        )
        create_special_inventory_adjustment(self.db, payload, user_id=9)

        row = self.db.execute(
            get_inventory_txn_ledger_query(warehouse_type="RAW", txn_type="SPECIAL_OUT")
        ).mappings().first()

        self.assertIsNotNone(row)
        self.assertEqual(row["direction"], "OUT")
        self.assertEqual(row["txn_type"], "SPECIAL_OUT")
        self.assertIn("特殊出库说明：仓管复核后确认线下已处理，补做特殊出库", row["remark"])
```

- [ ] **Step 2: Run route/ledger test before modifying ledger labels**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py::SpecialWarehouseAdjustmentTest::test_special_adjustment_ledger_direction_and_chinese_remark -v
```

Expected: PASS or FAIL only if `SPECIAL_OUT` is not included in ledger filtering/summary logic. If it already passes, still complete Step 3 to add explicit label coverage.

- [ ] **Step 3: Add route imports and endpoint**

Modify imports in `backend/app/api/routes/inventory.py`:

```python
from app.schemas.operations import (
    # existing imports...
    SpecialWarehouseAdjustmentCreate,
    SpecialWarehouseAdjustmentRead,
)
from app.services.special_inventory_adjustment import create_special_inventory_adjustment
```

Add this endpoint near the generic inbound/outbound endpoints:

```python
@router.post("/special-adjustments", response_model=SpecialWarehouseAdjustmentRead)
def create_inventory_special_adjustment(
    payload: SpecialWarehouseAdjustmentCreate,
    context: AuthContext = Depends(require_authenticated_user),
    db: Session = Depends(get_db),
) -> SpecialWarehouseAdjustmentRead:
    try:
        return create_special_inventory_adjustment(db, payload, user_id=context.user.id)
    except ValueError as exc:
        db.rollback()
        raise HTTPException(status_code=400, detail=str(exc)) from exc
```

- [ ] **Step 4: Add Chinese business labels for special types**

Modify `_INVENTORY_BIZ_TYPE_LABELS` in `backend/app/api/routes/inventory.py`:

```python
    "SPECIAL_IN": "特殊入库",
    "SPECIAL_OUT": "特殊出库",
```

Modify any front/back ledger display helper that maps raw `txn_type` to labels if one exists. If no helper exists, keep `txn_type` as internal code but make sure `remark` contains the Chinese label because current warehouse ledger UI searches and displays remarks.

- [ ] **Step 5: Run focused backend tests**

Run:

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

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend/app/api/routes/inventory.py backend/app/services/operations.py backend/tests/test_special_warehouse_adjustment.py
git commit -m "feat: expose special warehouse adjustment api"
```

---

## Task 5: Frontend Rules And Static Tests

**Files:**

- Create: `frontend/src/utils/specialAdjustmentRules.js`
- Create: `frontend/scripts/test-special-adjustment-ui.mjs`

- [ ] **Step 1: Add frontend measure rules**

Create `frontend/src/utils/specialAdjustmentRules.js`:

```javascript
export function normalizeWarehouseType(value) {
  return String(value || "").toUpperCase();
}

export function specialAdjustmentDirectionLabel(direction) {
  return String(direction || "").toUpperCase() === "OUT" ? "特殊出库" : "特殊入库";
}

export function specialAdjustmentMeasureMode(warehouseType, row = {}) {
  const normalizedType = normalizeWarehouseType(warehouseType || row.warehouse_type);
  if (["RAW", "SCRAP"].includes(normalizedType)) {
    return { mode: "WEIGHT", primaryLabel: "调整重量(kg)", showQty: false, showWeight: true };
  }
  if (normalizedType === "AUX") {
    return { mode: "QTY", primaryLabel: "调整数量", showQty: true, showWeight: false };
  }
  if (["FINISHED", "RETURN"].includes(normalizedType)) {
    return { mode: "QTY_WITH_WEIGHT", primaryLabel: "调整数量", showQty: true, showWeight: true };
  }
  const hasQty = Number(row?.remaining_qty || row?.qty_available || 0) > 0;
  const hasWeight = Number(row?.remaining_weight_kg || row?.weight_available_kg || 0) > 0;
  if (hasQty && !hasWeight) {
    return { mode: "QTY", primaryLabel: "调整数量", showQty: true, showWeight: false };
  }
  if (hasWeight && !hasQty) {
    return { mode: "WEIGHT", primaryLabel: "调整重量(kg)", showQty: false, showWeight: true };
  }
  return { mode: "SEMI_SELECT", primaryLabel: "调整口径", showQty: true, showWeight: true };
}

export function specialAdjustmentOperationKeys() {
  return {
    raw: ["raw-special-in", "raw-special-out"],
    semi: ["semi-special-in", "semi-special-out"],
    finished: ["finished-special-in", "finished-special-out"],
    auxiliary: ["aux-special-in", "aux-special-out"],
    scrap: ["scrap-special-in", "scrap-special-out"],
    return: ["return-special-in", "return-special-out"]
  };
}
```

- [ ] **Step 2: Add static UI test script**

Create `frontend/scripts/test-special-adjustment-ui.mjs`:

```javascript
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { specialAdjustmentMeasureMode, specialAdjustmentOperationKeys } from "../src/utils/specialAdjustmentRules.js";

const viewSource = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
const operationKeys = specialAdjustmentOperationKeys();

for (const [warehouseTab, keys] of Object.entries(operationKeys)) {
  for (const key of keys) {
    assert.ok(viewSource.includes(`makeOperation("${key}"`), `${warehouseTab} 缺少 ${key}`);
  }
}

assert.ok(viewSource.includes("特殊入库"), "仓库页面必须展示特殊入库");
assert.ok(viewSource.includes("特殊出库"), "仓库页面必须展示特殊出库");
assert.ok(viewSource.includes("/inventory/special-adjustments"), "仓库页面必须调用特殊调整接口");

assert.deepEqual(specialAdjustmentMeasureMode("RAW"), {
  mode: "WEIGHT",
  primaryLabel: "调整重量(kg)",
  showQty: false,
  showWeight: true
});

assert.deepEqual(specialAdjustmentMeasureMode("AUX"), {
  mode: "QTY",
  primaryLabel: "调整数量",
  showQty: true,
  showWeight: false
});

assert.equal(specialAdjustmentMeasureMode("FINISHED").mode, "QTY_WITH_WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SCRAP").mode, "WEIGHT");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 10, remaining_weight_kg: 0 }).mode, "QTY");
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 0, remaining_weight_kg: 10 }).mode, "WEIGHT");
```

- [ ] **Step 3: Run the failing frontend test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-special-adjustment-ui.mjs
```

Expected: FAIL because `InventoryLedgerView.vue` does not yet include special adjustment operation keys or API call.

- [ ] **Step 4: Commit failing frontend tests**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add frontend/src/utils/specialAdjustmentRules.js frontend/scripts/test-special-adjustment-ui.mjs
git commit -m "test: cover special adjustment warehouse ui"
```

---

## Task 6: Frontend Operation Buttons And Drawer

**Files:**

- Modify: `frontend/src/views/InventoryLedgerView.vue`

- [ ] **Step 1: Import frontend rules**

In `frontend/src/views/InventoryLedgerView.vue`, add:

```javascript
import { specialAdjustmentMeasureMode, specialAdjustmentDirectionLabel } from "../utils/specialAdjustmentRules.js";
```

- [ ] **Step 2: Add special operation buttons to all six warehouses**

Modify `operationMatrix` so each inbound list ends with `特殊入库` and each outbound list ends with `特殊出库`:

```javascript
makeOperation("raw-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后，直接调整原材料库库存明细。", "specialAdjustment")
makeOperation("raw-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后，直接扣减原材料库库存明细。", "specialAdjustment")
makeOperation("semi-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后，直接调整半成品库库存明细。", "specialAdjustment")
makeOperation("semi-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后，直接扣减半成品库库存明细。", "specialAdjustment")
makeOperation("finished-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后，直接调整成品库库存明细。", "specialAdjustment")
makeOperation("finished-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后，直接扣减成品库库存明细。", "specialAdjustment")
makeOperation("aux-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后，直接调整辅料库库存明细。", "specialAdjustment")
makeOperation("aux-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后，直接扣减辅料库库存明细。", "specialAdjustment")
makeOperation("scrap-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后，直接调整废料库库存明细。", "specialAdjustment")
makeOperation("scrap-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后，直接扣减废料库库存明细。", "specialAdjustment")
makeOperation("return-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后，直接调整退货库库存明细。", "specialAdjustment")
makeOperation("return-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后，直接扣减退货库库存明细。", "specialAdjustment")
```

Use the same visual style as existing operation cards. Do not introduce a separate visual language.

- [ ] **Step 3: Add drawer state**

Add near other drawer state:

```javascript
const specialAdjustmentDrawerOpen = ref(false);
const specialAdjustmentSubmitting = ref(false);
const specialAdjustmentForm = reactive({
  direction: "IN",
  warehouse_id: "",
  reason: "",
  lines: []
});
```

Add reset helpers:

```javascript
function resetSpecialAdjustmentForm() {
  specialAdjustmentForm.direction = activeOperation.value?.direction === "out" ? "OUT" : "IN";
  specialAdjustmentForm.warehouse_id = activeWarehouses.value[0]?.id || "";
  specialAdjustmentForm.reason = "";
  specialAdjustmentForm.lines = [];
}

function closeSpecialAdjustmentDrawer() {
  specialAdjustmentDrawerOpen.value = false;
}
```

- [ ] **Step 4: Route special operations to the drawer**

Modify `openOperationDrawer(operation)` before the generic drawer fallback:

```javascript
  if (operation.drawerType === "specialAdjustment") {
    resetSpecialAdjustmentForm();
    specialAdjustmentDrawerOpen.value = true;
    return;
  }
```

- [ ] **Step 5: Add computed lot options and preview helpers**

Add computed helpers:

```javascript
const specialAdjustmentDirectionText = computed(() => specialAdjustmentDirectionLabel(specialAdjustmentForm.direction));

const specialAdjustmentLotOptions = computed(() => {
  const warehouseId = Number(specialAdjustmentForm.warehouse_id || 0);
  return stockLots.value
    .filter((lot) => Number(lot.warehouse_id || 0) === warehouseId)
    .sort((left, right) => {
      const leftAvailable = Number(left.remaining_qty || 0) > 0 || Number(left.remaining_weight_kg || 0) > 0 ? 0 : 1;
      const rightAvailable = Number(right.remaining_qty || 0) > 0 || Number(right.remaining_weight_kg || 0) > 0 ? 0 : 1;
      return leftAvailable - rightAvailable || String(left.lot_no || "").localeCompare(String(right.lot_no || ""), "zh-CN", { numeric: true });
    });
});

function buildSpecialAdjustmentEmptyLine() {
  return {
    lot_id: "",
    item_id: "",
    item_name: "",
    lot_no: "",
    adjust_qty: "",
    adjust_weight_kg: "",
    unit_cost: "",
    line_reason: ""
  };
}

function addSpecialAdjustmentLine() {
  if (!specialAdjustmentForm.reason.trim()) {
    errorMessage.value = `${specialAdjustmentDirectionText.value}必须先填写说明`;
    return;
  }
  specialAdjustmentForm.lines.push(buildSpecialAdjustmentEmptyLine());
}

function removeSpecialAdjustmentLine(index) {
  specialAdjustmentForm.lines.splice(index, 1);
}

function applySpecialAdjustmentLot(line) {
  const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id));
  if (!lot) {
    return;
  }
  line.item_id = lot.item_id;
  line.item_name = lot.item_name;
  line.lot_no = lot.lot_no;
  line.unit_cost = lot.unit_cost || 0;
  const measure = specialAdjustmentMeasureMode(activeWarehouseType.value, lot);
  line.measure_mode = measure.mode;
}

function specialAdjustmentLinePreview(line) {
  const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id));
  if (!lot) {
    return "请选择库存明细";
  }
  const directionMultiplier = specialAdjustmentForm.direction === "OUT" ? -1 : 1;
  const qtyChange = Number(line.adjust_qty || 0) * directionMultiplier;
  const weightChange = Number(line.adjust_weight_kg || 0) * directionMultiplier;
  const afterQty = Number(lot.remaining_qty || 0) + qtyChange;
  const afterWeight = Number(lot.remaining_weight_kg || 0) + weightChange;
  return `调整前：${formatQty(lot.remaining_qty)} / ${formatWeight(lot.remaining_weight_kg)}；调整后：${formatQty(afterQty)} / ${formatWeight(afterWeight)}`;
}
```

- [ ] **Step 6: Add drawer template**

Add a `SideDrawer` after the generic drawer or near other operation drawers:

```vue
<SideDrawer
  :open="specialAdjustmentDrawerOpen"
  :eyebrow="`${activeInventoryLabel} · ${specialAdjustmentDirectionText}`"
  :title="specialAdjustmentDirectionText"
  tag="特殊调整"
  @close="closeSpecialAdjustmentDrawer"
>
  <form class="stack-form" @submit.prevent="submitSpecialAdjustment">
    <div class="warning-card">
      <strong>特殊调整会直接改变库存明细</strong>
      <span>请只在补录、纠错、线下已处理等特殊场景使用。保存后系统会写入库存流水和特殊调整记录。</span>
    </div>

    <label class="field">
      <span>调整说明</span>
      <textarea
        v-model.trim="specialAdjustmentForm.reason"
        required
        rows="3"
        placeholder="必须填写，例如：现场复核发现该批次已线下处理，补做特殊出库"
      />
    </label>

    <label class="field">
      <span>仓库</span>
      <select v-model.number="specialAdjustmentForm.warehouse_id" required>
        <option disabled value="">请选择仓库</option>
        <option v-for="warehouse in activeWarehouses" :key="warehouse.id" :value="warehouse.id">
          {{ warehouse.warehouse_name }}
        </option>
      </select>
    </label>

    <div class="drawer-table-actions">
      <button class="ghost-button" type="button" @click="addSpecialAdjustmentLine">添加调整明细</button>
    </div>

    <div class="drawer-table-wrap">
      <table class="drawer-data-table">
        <thead>
          <tr>
            <th>库存明细</th>
            <th>调整数量</th>
            <th>调整重量(kg)</th>
            <th>明细说明</th>
            <th>调整预览</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="(line, index) in specialAdjustmentForm.lines" :key="index">
            <td>
              <select v-model.number="line.lot_id" required @change="applySpecialAdjustmentLot(line)">
                <option disabled value="">请选择库存明细</option>
                <option v-for="lot in specialAdjustmentLotOptions" :key="lot.lot_id" :value="lot.lot_id">
                  {{ lot.item_name }} · {{ lot.lot_no }} · 数量 {{ formatQty(lot.remaining_qty) }} · 重量 {{ formatWeight(lot.remaining_weight_kg) }}
                </option>
              </select>
            </td>
            <td>
              <input v-model.number="line.adjust_qty" type="number" min="0" step="0.000001" />
            </td>
            <td>
              <input v-model.number="line.adjust_weight_kg" type="number" min="0" step="0.001" />
            </td>
            <td>
              <input v-model.trim="line.line_reason" type="text" placeholder="可选，补充单行说明" />
            </td>
            <td>{{ specialAdjustmentLinePreview(line) }}</td>
            <td>
              <button class="link-button danger" type="button" @click="removeSpecialAdjustmentLine(index)">删除</button>
            </td>
          </tr>
          <tr v-if="!specialAdjustmentForm.lines.length">
            <td colspan="6">请先填写调整说明，再添加调整明细。</td>
          </tr>
        </tbody>
      </table>
    </div>

    <div class="drawer-actions">
      <button class="ghost-button" type="button" @click="closeSpecialAdjustmentDrawer">取消</button>
      <button class="primary-button" type="submit" :disabled="specialAdjustmentSubmitting">
        {{ specialAdjustmentSubmitting ? "保存中..." : "确认保存" }}
      </button>
    </div>
  </form>
</SideDrawer>
```

- [ ] **Step 7: Add submit validation and API call**

Add:

```javascript
function validateSpecialAdjustmentBeforeSubmit() {
  if (!specialAdjustmentForm.reason.trim()) {
    return `${specialAdjustmentDirectionText.value}必须填写说明`;
  }
  if (!specialAdjustmentForm.lines.length) {
    return `${specialAdjustmentDirectionText.value}必须至少添加一条明细`;
  }
  for (const [index, line] of specialAdjustmentForm.lines.entries()) {
    if (!Number(line.lot_id || 0)) {
      return `第${index + 1}行必须选择库存明细`;
    }
    const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id));
    if (!lot) {
      return `第${index + 1}行库存明细不存在`;
    }
    const measure = specialAdjustmentMeasureMode(activeWarehouseType.value, lot);
    const qty = Number(line.adjust_qty || 0);
    const weight = Number(line.adjust_weight_kg || 0);
    if (measure.mode === "WEIGHT" && weight <= 0) {
      return `第${index + 1}行必须填写调整重量`;
    }
    if (["QTY", "QTY_WITH_WEIGHT"].includes(measure.mode) && qty <= 0) {
      return `第${index + 1}行必须填写调整数量`;
    }
    if (specialAdjustmentForm.direction === "OUT") {
      if (qty > Number(lot.remaining_qty || 0)) {
        return `第${index + 1}行调整数量不能超过当前可用数量`;
      }
      if (weight > Number(lot.remaining_weight_kg || 0)) {
        return `第${index + 1}行调整重量不能超过当前可用重量`;
      }
    }
  }
  return "";
}

async function submitSpecialAdjustment() {
  resetFeedback();
  const validationError = validateSpecialAdjustmentBeforeSubmit();
  if (validationError) {
    errorMessage.value = validationError;
    return;
  }
  const confirmed = window.confirm(`确认执行${specialAdjustmentDirectionText.value}？保存后会直接改变库存，并写入库存流水。`);
  if (!confirmed) {
    return;
  }
  specialAdjustmentSubmitting.value = true;
  try {
    const payload = {
      warehouse_id: Number(specialAdjustmentForm.warehouse_id),
      direction: specialAdjustmentForm.direction,
      reason: specialAdjustmentForm.reason.trim(),
      lines: specialAdjustmentForm.lines.map((line) => ({
        item_id: Number(line.item_id),
        lot_id: Number(line.lot_id),
        adjust_qty: Number(line.adjust_qty || 0),
        adjust_weight_kg: Number(line.adjust_weight_kg || 0),
        unit_cost: Number(line.unit_cost || 0),
        line_reason: line.line_reason || ""
      }))
    };
    const result = await postResource("/inventory/special-adjustments", payload);
    feedbackMessage.value = `${specialAdjustmentDirectionText.value} ${result.adjustment_no} 已保存`;
    closeSpecialAdjustmentDrawer();
    await loadAll();
  } catch (error) {
    errorMessage.value = error.message || `${specialAdjustmentDirectionText.value}保存失败`;
  } finally {
    specialAdjustmentSubmitting.value = false;
  }
}
```

- [ ] **Step 8: Run frontend static test**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-special-adjustment-ui.mjs
```

Expected: PASS.

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

Run:

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

Expected: PASS and Vite build completes.

- [ ] **Step 10: Commit**

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add frontend/src/views/InventoryLedgerView.vue frontend/src/utils/specialAdjustmentRules.js frontend/scripts/test-special-adjustment-ui.mjs
git commit -m "feat: add special adjustment warehouse UI"
```

---

## Task 7: Integration Verification And Manual Smoke Checks

**Files:**

- No new files expected.
- Verify changed files from prior tasks.

- [ ] **Step 1: Run focused backend suite**

Run:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest \
  tests/test_special_warehouse_adjustment.py \
  tests/test_warehouse_transaction_ledger.py \
  tests/test_stocktake_locking.py \
  tests/test_raw_material_weight_only_flow.py \
  tests/test_auxiliary_quantity_flow.py \
  tests/test_semi_finished_operation_inventory.py \
  tests/test_scrap_warehouse_flow.py \
  tests/test_return_warehouse_flow.py \
  -v
```

Expected: PASS.

- [ ] **Step 2: Run focused frontend scripts**

Run:

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

Expected: all scripts pass and build completes.

- [ ] **Step 3: Apply SQL patch to test database**

Run this only after confirming the target database is the intended test database:

```bash
mysql \
  -h <测试库IP> \
  -P <测试库端口> \
  -u <用户名> \
  -p \
  --default-character-set=utf8mb4 \
  jiaheng_erp < /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/add_special_warehouse_adjustment_patch.sql
```

Expected: no SQL error. Verify:

```sql
SHOW TABLES LIKE 'wh_special_adjustment%';
```

Expected:

```text
wh_special_adjustment
wh_special_adjustment_line
```

- [ ] **Step 4: Browser smoke check**

Start backend and frontend in the project’s normal local way, then open:

```text
http://127.0.0.1:5173/
```

Manual checks:

- 嘉恒仓库 6 大库的入库操作区都有“特殊入库”。
- 嘉恒仓库 6 大库的出库操作区都有“特殊出库”。
- 未填写说明时，不能添加/提交特殊调整明细。
- 原材料库特殊出库只能填写重量，提交后对应库存批次重量减少。
- 辅料库特殊入库只按数量增加，不写入重量。
- 成品库特殊出库按数量减少，重量作为辅助字段同步减少。
- 保存后打开当前库“流水”图标，能看到 `特殊入库` 或 `特殊出库` 对应流水，备注里包含完整说明。
- 盘库锁库状态下，特殊入库/特殊出库接口返回禁止操作提示。

- [ ] **Step 5: Final commit if smoke checks required small fixes**

If manual smoke checks required fixes:

```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git add backend frontend
git commit -m "fix: polish special warehouse adjustment flow"
```

If no fixes were needed, do not create an empty commit.

---

## Self-Review

Spec coverage:

- 6 大库入库/出库均添加特殊操作：Task 6 Step 2 and Task 5 static test.
- 可直接操作仓库数据并精确到每行明细：Task 3 service updates `StockLot` and `StockBalance` per selected `lot_id`.
- 操作前必须写说明：Task 1 mandatory reason test, Task 3 `_clean_required_reason`, Task 6 front-end validation.
- 说明体现在各变更记录中：Task 3 writes reason into `SpecialWarehouseAdjustment.reason`, `SpecialWarehouseAdjustmentLine.line_reason`, and `InventoryTxn.remark`.
- 现有 6 大库不同口径：Task 3 `_measure_for_warehouse`, Task 5 `specialAdjustmentMeasureMode`.
- 不能只有图标没有业务逻辑：Task 3 backend service, Task 4 API, Task 6 frontend submit, Task 7 smoke checks.

Placeholder scan:

- No unresolved placeholder markers or deferred implementation phrases.
- The only conditional note is an execution-time SQLite foreign key fallback in Task 3 Step 5; it gives the exact alternate implementation path.

Type consistency:

- `SpecialWarehouseAdjustmentCreate`, `SpecialWarehouseAdjustmentLineCreate`, and `SpecialWarehouseAdjustmentRead` are defined before service use.
- Service function is consistently named `create_special_inventory_adjustment`.
- Frontend API endpoint is consistently `/inventory/special-adjustments`.
- Internal transaction types are consistently `SPECIAL_IN` and `SPECIAL_OUT`; user-facing labels remain Chinese.
