ForgeFlow-ERP/docs/superpowers/plans/2026-06-06-special-warehouse-adjustment.md
2026-06-12 16:00:56 +08:00

1791 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 projects 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.