# 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
特殊调整会直接改变库存明细 请只在补录、纠错、线下已处理等特殊场景使用。保存后系统会写入库存流水和特殊调整记录。