1791 lines
66 KiB
Markdown
1791 lines
66 KiB
Markdown
# Special Warehouse Adjustment Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** 为嘉恒仓库 6 大库的入库与出库操作区分别增加“特殊入库”“特殊出库”,允许在强制填写说明后,精确到库存明细行进行受控库存调整,并把说明写入所有变更记录。
|
||
|
||
**Architecture:** 新增独立的“特殊调整”后端服务与审计主/明细表,库存变动仍复用现有 `wh_stock_lot`、`wh_stock_balance`、`wh_inventory_txn` 三层账本。前端在现有 `InventoryLedgerView.vue` 的仓库出入库操作矩阵中增加特殊操作入口,打开专用抽屉完成说明填写、明细选择、调整前后预览和提交。
|
||
|
||
**Tech Stack:** FastAPI, SQLAlchemy ORM, MySQL 8, SQLite unit tests, Vue 3, Vite, Node.js static UI scripts.
|
||
|
||
---
|
||
|
||
## Scope And Invariants
|
||
|
||
本计划只实现“特殊入库/特殊出库”,不改变正常采购、销售、生产、盘库链路。
|
||
|
||
必须保持这些业务不变量:
|
||
|
||
- 原材料库:只按重量调整,精确到原材料库存批次号。
|
||
- 半成品库:按该半成品库存既有口径调整,按件库存只能调数量,按重库存只能调重量。
|
||
- 成品库:主维度按数量调整,重量作为辅助字段随同记录。
|
||
- 辅料库:只按数量调整。
|
||
- 废料库:只按重量调整,并保留来源原材料库存批次追溯展示。
|
||
- 退货库:按退货品库存明细调整,保留发货来源、来源原材料库存批次追溯。
|
||
- 特殊出库不能超过所选明细当前可用量。
|
||
- 盘库锁库期间禁止特殊入库和特殊出库。
|
||
- 操作说明必填,且必须写入特殊调整主单、明细、库存流水 `remark`。
|
||
- 特殊调整不联动采购订单、销售订单、生产工单状态,避免污染正常业务状态。
|
||
- 用户界面展示必须使用中文业务名,不显示英文状态或英文标志。
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
Create:
|
||
|
||
- `backend/sql/add_special_warehouse_adjustment_patch.sql`
|
||
- MySQL 生产/测试库补丁,新增特殊调整主表与明细表。
|
||
- `backend/app/services/special_inventory_adjustment.py`
|
||
- 特殊调整核心服务,负责校验、明细变更、汇总库存更新、流水生成、审计记录。
|
||
- `backend/tests/test_special_warehouse_adjustment.py`
|
||
- 后端 SQLite 单元测试,覆盖强制说明、原材料重量调整、辅料数量调整、成品数量调整、锁库拦截。
|
||
- `frontend/src/utils/specialAdjustmentRules.js`
|
||
- 前端特殊调整口径与展示规则,避免把口径判断全部堆进 Vue 单文件。
|
||
- `frontend/scripts/test-special-adjustment-ui.mjs`
|
||
- 前端静态脚本测试,检查 6 大库都出现特殊入库/特殊出库入口,检查口径规则。
|
||
|
||
Modify:
|
||
|
||
- `backend/app/models/operations.py`
|
||
- 新增 `SpecialWarehouseAdjustment`、`SpecialWarehouseAdjustmentLine` ORM 模型。
|
||
- `backend/app/schemas/operations.py`
|
||
- 新增特殊调整请求/响应 schema。
|
||
- `backend/app/api/routes/inventory.py`
|
||
- 新增 `/inventory/special-adjustments` 接口,接入服务。
|
||
- `backend/app/services/operations.py`
|
||
- 扩展仓库流水方向与类型中文展示能力,确保特殊调整可在流水中查到。
|
||
- `frontend/src/views/InventoryLedgerView.vue`
|
||
- 增加 6 大库操作按钮、专用抽屉、提交逻辑、调整前后预览。
|
||
|
||
---
|
||
|
||
## Task 1: Backend Failing Tests For Special Adjustment
|
||
|
||
**Files:**
|
||
|
||
- Create: `backend/tests/test_special_warehouse_adjustment.py`
|
||
- Future dependency: `backend/app/services/special_inventory_adjustment.py`
|
||
- Future dependency: `backend/app/schemas/operations.py`
|
||
|
||
- [ ] **Step 1: Write the failing backend test file**
|
||
|
||
Create `backend/tests/test_special_warehouse_adjustment.py` with this content:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import unittest
|
||
from datetime import UTC, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import BigInteger, create_engine, select
|
||
from sqlalchemy.ext.compiler import compiles
|
||
from sqlalchemy.orm import Session, sessionmaker
|
||
|
||
|
||
@compiles(BigInteger, "sqlite")
|
||
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
|
||
_ = type_, compiler, kw
|
||
return "INTEGER"
|
||
|
||
|
||
import app.models.master_data # noqa: E402,F401
|
||
import app.models.operations # noqa: E402,F401
|
||
from app.models.base import Base # noqa: E402
|
||
from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402
|
||
from app.models.operations import InventoryTxn, StockLot, WarehouseLocation # noqa: E402
|
||
from app.schemas.operations import SpecialWarehouseAdjustmentCreate, SpecialWarehouseAdjustmentLineCreate # noqa: E402
|
||
from app.services.special_inventory_adjustment import create_special_inventory_adjustment # noqa: E402
|
||
|
||
|
||
class SpecialWarehouseAdjustmentTest(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||
Base.metadata.create_all(engine)
|
||
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||
self.db: Session = self.SessionLocal()
|
||
self._seed()
|
||
|
||
def tearDown(self) -> None:
|
||
self.db.close()
|
||
|
||
def _seed(self) -> None:
|
||
now = datetime.now(UTC)
|
||
self.raw = Warehouse(
|
||
id=1,
|
||
warehouse_code="WH-RAW",
|
||
warehouse_name="原材料库",
|
||
warehouse_type="RAW",
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.aux = Warehouse(
|
||
id=2,
|
||
warehouse_code="WH-AUX",
|
||
warehouse_name="辅料库",
|
||
warehouse_type="AUX",
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.finished = Warehouse(
|
||
id=3,
|
||
warehouse_code="WH-FG",
|
||
warehouse_name="成品库",
|
||
warehouse_type="FINISHED",
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.raw_loc = WarehouseLocation(
|
||
id=1,
|
||
warehouse_id=1,
|
||
location_code="RAW-A",
|
||
location_name="原材料默认库位",
|
||
is_default=1,
|
||
is_locked=0,
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.aux_loc = WarehouseLocation(
|
||
id=2,
|
||
warehouse_id=2,
|
||
location_code="AUX-A",
|
||
location_name="辅料默认库位",
|
||
is_default=1,
|
||
is_locked=0,
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.finished_loc = WarehouseLocation(
|
||
id=3,
|
||
warehouse_id=3,
|
||
location_code="FG-A",
|
||
location_name="成品默认库位",
|
||
is_default=1,
|
||
is_locked=0,
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.raw_item = Item(
|
||
id=1,
|
||
item_code="原材料00001",
|
||
item_name="冷轧钢板",
|
||
item_type="RAW_MATERIAL",
|
||
unit_weight_kg=Decimal("1"),
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.aux_item = Item(
|
||
id=2,
|
||
item_code="原材料00002",
|
||
item_name="劳保手套",
|
||
item_type="RAW_MATERIAL",
|
||
unit_weight_kg=Decimal("0"),
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.finished_item = Item(
|
||
id=3,
|
||
item_code="产品需规00001",
|
||
item_name="钢制碗",
|
||
item_type="FINISHED_GOOD",
|
||
unit_weight_kg=Decimal("0.6"),
|
||
status="ACTIVE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.raw_lot = StockLot(
|
||
id=1,
|
||
lot_no="JH_冷轧钢板00001_0001",
|
||
lot_role="OPENING_STOCK",
|
||
item_id=1,
|
||
warehouse_id=1,
|
||
location_id=1,
|
||
source_doc_type="OPENING_STOCK",
|
||
source_doc_id=1,
|
||
inbound_qty=Decimal("0"),
|
||
inbound_weight_kg=Decimal("500"),
|
||
remaining_qty=Decimal("0"),
|
||
remaining_weight_kg=Decimal("300"),
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=Decimal("4.5"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.aux_lot = StockLot(
|
||
id=2,
|
||
lot_no="AUX-20260606-001",
|
||
lot_role="OPENING_STOCK",
|
||
item_id=2,
|
||
warehouse_id=2,
|
||
location_id=2,
|
||
source_doc_type="OPENING_STOCK",
|
||
source_doc_id=2,
|
||
inbound_qty=Decimal("120"),
|
||
inbound_weight_kg=Decimal("0"),
|
||
remaining_qty=Decimal("80"),
|
||
remaining_weight_kg=Decimal("0"),
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=Decimal("2"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.finished_lot = StockLot(
|
||
id=3,
|
||
lot_no="FG-20260606-001",
|
||
lot_role="INBOUND_FINISHED",
|
||
item_id=3,
|
||
warehouse_id=3,
|
||
location_id=3,
|
||
source_doc_type="FG_RECEIPT",
|
||
source_doc_id=3,
|
||
inbound_qty=Decimal("100"),
|
||
inbound_weight_kg=Decimal("60"),
|
||
remaining_qty=Decimal("70"),
|
||
remaining_weight_kg=Decimal("42"),
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=Decimal("2.7"),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.raw_balance = StockBalance(
|
||
item_id=1,
|
||
warehouse_id=1,
|
||
location_id=1,
|
||
qty_on_hand=Decimal("0"),
|
||
weight_on_hand_kg=Decimal("300"),
|
||
qty_available=Decimal("0"),
|
||
weight_available_kg=Decimal("300"),
|
||
qty_allocated=Decimal("0"),
|
||
weight_allocated_kg=Decimal("0"),
|
||
avg_unit_cost=Decimal("4.5"),
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.aux_balance = StockBalance(
|
||
item_id=2,
|
||
warehouse_id=2,
|
||
location_id=2,
|
||
qty_on_hand=Decimal("80"),
|
||
weight_on_hand_kg=Decimal("0"),
|
||
qty_available=Decimal("80"),
|
||
weight_available_kg=Decimal("0"),
|
||
qty_allocated=Decimal("0"),
|
||
weight_allocated_kg=Decimal("0"),
|
||
avg_unit_cost=Decimal("2"),
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.finished_balance = StockBalance(
|
||
item_id=3,
|
||
warehouse_id=3,
|
||
location_id=3,
|
||
qty_on_hand=Decimal("70"),
|
||
weight_on_hand_kg=Decimal("42"),
|
||
qty_available=Decimal("70"),
|
||
weight_available_kg=Decimal("42"),
|
||
qty_allocated=Decimal("0"),
|
||
weight_allocated_kg=Decimal("0"),
|
||
avg_unit_cost=Decimal("2.7"),
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
self.db.add_all([
|
||
self.raw,
|
||
self.aux,
|
||
self.finished,
|
||
self.raw_loc,
|
||
self.aux_loc,
|
||
self.finished_loc,
|
||
self.raw_item,
|
||
self.aux_item,
|
||
self.finished_item,
|
||
self.raw_lot,
|
||
self.aux_lot,
|
||
self.finished_lot,
|
||
self.raw_balance,
|
||
self.aux_balance,
|
||
self.finished_balance,
|
||
])
|
||
self.db.commit()
|
||
|
||
def test_special_outbound_requires_reason_before_stock_change(self) -> None:
|
||
payload = SpecialWarehouseAdjustmentCreate(
|
||
warehouse_id=self.raw.id,
|
||
direction="OUT",
|
||
reason="",
|
||
lines=[
|
||
SpecialWarehouseAdjustmentLineCreate(
|
||
item_id=self.raw_item.id,
|
||
lot_id=self.raw_lot.id,
|
||
adjust_weight_kg=Decimal("10"),
|
||
)
|
||
],
|
||
)
|
||
|
||
with self.assertRaisesRegex(ValueError, "特殊出库必须填写说明"):
|
||
create_special_inventory_adjustment(self.db, payload, user_id=9)
|
||
|
||
unchanged_lot = self.db.get(StockLot, self.raw_lot.id)
|
||
self.assertEqual(Decimal(str(unchanged_lot.remaining_weight_kg)), Decimal("300.000000"))
|
||
|
||
def test_raw_special_outbound_updates_lot_balance_and_inventory_txn(self) -> None:
|
||
payload = SpecialWarehouseAdjustmentCreate(
|
||
warehouse_id=self.raw.id,
|
||
direction="OUT",
|
||
reason="现场发现边角料已线下报废,补做特殊出库",
|
||
lines=[
|
||
SpecialWarehouseAdjustmentLineCreate(
|
||
item_id=self.raw_item.id,
|
||
lot_id=self.raw_lot.id,
|
||
adjust_weight_kg=Decimal("25.5"),
|
||
)
|
||
],
|
||
)
|
||
|
||
result = create_special_inventory_adjustment(self.db, payload, user_id=9)
|
||
|
||
adjusted_lot = self.db.get(StockLot, self.raw_lot.id)
|
||
balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.raw_item.id))
|
||
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_OUT"))
|
||
|
||
self.assertEqual(result.direction, "OUT")
|
||
self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("274.500000"))
|
||
self.assertEqual(Decimal(str(balance.weight_on_hand_kg)), Decimal("274.500000"))
|
||
self.assertEqual(Decimal(str(txn.weight_change_kg)), Decimal("-25.500000"))
|
||
self.assertIn("特殊出库说明:现场发现边角料已线下报废,补做特殊出库", txn.remark)
|
||
self.assertIn("调整前重量 300", txn.remark)
|
||
self.assertIn("调整后重量 274.5", txn.remark)
|
||
|
||
def test_special_outbound_cannot_exceed_available_lot_quantity_or_weight(self) -> None:
|
||
payload = SpecialWarehouseAdjustmentCreate(
|
||
warehouse_id=self.aux.id,
|
||
direction="OUT",
|
||
reason="盘点前发现辅料账实差异,补做出库",
|
||
lines=[
|
||
SpecialWarehouseAdjustmentLineCreate(
|
||
item_id=self.aux_item.id,
|
||
lot_id=self.aux_lot.id,
|
||
adjust_qty=Decimal("81"),
|
||
)
|
||
],
|
||
)
|
||
|
||
with self.assertRaisesRegex(ValueError, "特殊出库数量不能超过当前可用数量"):
|
||
create_special_inventory_adjustment(self.db, payload, user_id=9)
|
||
|
||
def test_aux_special_inbound_uses_quantity_only(self) -> None:
|
||
payload = SpecialWarehouseAdjustmentCreate(
|
||
warehouse_id=self.aux.id,
|
||
direction="IN",
|
||
reason="供应商赠送辅料漏入账,补做特殊入库",
|
||
lines=[
|
||
SpecialWarehouseAdjustmentLineCreate(
|
||
item_id=self.aux_item.id,
|
||
lot_id=self.aux_lot.id,
|
||
adjust_qty=Decimal("12"),
|
||
adjust_weight_kg=Decimal("99"),
|
||
)
|
||
],
|
||
)
|
||
|
||
create_special_inventory_adjustment(self.db, payload, user_id=9)
|
||
|
||
adjusted_lot = self.db.get(StockLot, self.aux_lot.id)
|
||
balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.aux_item.id))
|
||
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_IN"))
|
||
|
||
self.assertEqual(Decimal(str(adjusted_lot.remaining_qty)), Decimal("92.000000"))
|
||
self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("0.000000"))
|
||
self.assertEqual(Decimal(str(balance.qty_on_hand)), Decimal("92.000000"))
|
||
self.assertEqual(Decimal(str(txn.qty_change)), Decimal("12.000000"))
|
||
self.assertEqual(Decimal(str(txn.weight_change_kg)), Decimal("0.000000"))
|
||
|
||
def test_finished_special_outbound_uses_qty_and_keeps_weight_auxiliary(self) -> None:
|
||
payload = SpecialWarehouseAdjustmentCreate(
|
||
warehouse_id=self.finished.id,
|
||
direction="OUT",
|
||
reason="客户样品领用,补做成品特殊出库",
|
||
lines=[
|
||
SpecialWarehouseAdjustmentLineCreate(
|
||
item_id=self.finished_item.id,
|
||
lot_id=self.finished_lot.id,
|
||
adjust_qty=Decimal("5"),
|
||
adjust_weight_kg=Decimal("3"),
|
||
)
|
||
],
|
||
)
|
||
|
||
create_special_inventory_adjustment(self.db, payload, user_id=9)
|
||
|
||
adjusted_lot = self.db.get(StockLot, self.finished_lot.id)
|
||
balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.finished_item.id))
|
||
|
||
self.assertEqual(Decimal(str(adjusted_lot.remaining_qty)), Decimal("65.000000"))
|
||
self.assertEqual(Decimal(str(adjusted_lot.remaining_weight_kg)), Decimal("39.000000"))
|
||
self.assertEqual(Decimal(str(balance.qty_on_hand)), Decimal("65.000000"))
|
||
self.assertEqual(Decimal(str(balance.weight_on_hand_kg)), Decimal("39.000000"))
|
||
```
|
||
|
||
- [ ] **Step 2: Run the failing backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py -v
|
||
```
|
||
|
||
Expected: FAIL with `ModuleNotFoundError: No module named 'app.services.special_inventory_adjustment'` or import errors for `SpecialWarehouseAdjustmentCreate`.
|
||
|
||
- [ ] **Step 3: Commit the failing test**
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||
git add backend/tests/test_special_warehouse_adjustment.py
|
||
git commit -m "test: cover special warehouse adjustments"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Database And ORM Audit Tables
|
||
|
||
**Files:**
|
||
|
||
- Create: `backend/sql/add_special_warehouse_adjustment_patch.sql`
|
||
- Modify: `backend/app/models/operations.py`
|
||
|
||
- [ ] **Step 1: Add MySQL migration patch**
|
||
|
||
Create `backend/sql/add_special_warehouse_adjustment_patch.sql`:
|
||
|
||
```sql
|
||
-- 特殊出入库调整主表和明细表
|
||
-- 说明:
|
||
-- 1. 特殊调整不替代库存流水,所有库存变动仍写入 wh_inventory_txn。
|
||
-- 2. 该表用于把一次特殊操作中涉及的多行明细聚合成一张审计单。
|
||
|
||
CREATE TABLE IF NOT EXISTS wh_special_adjustment (
|
||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||
adjustment_no VARCHAR(50) NOT NULL,
|
||
warehouse_id BIGINT NOT NULL,
|
||
warehouse_type VARCHAR(32) NOT NULL,
|
||
direction VARCHAR(16) NOT NULL,
|
||
reason VARCHAR(1000) NOT NULL,
|
||
status VARCHAR(32) NOT NULL DEFAULT 'CONFIRMED',
|
||
operator_user_id BIGINT NULL,
|
||
adjusted_at DATETIME NOT NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uk_wh_special_adjustment_no (adjustment_no),
|
||
KEY idx_wh_special_adjustment_warehouse (warehouse_id, adjusted_at),
|
||
KEY idx_wh_special_adjustment_direction (direction, adjusted_at),
|
||
CONSTRAINT fk_wh_special_adjustment_warehouse
|
||
FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库特殊出入库调整主表';
|
||
|
||
CREATE TABLE IF NOT EXISTS wh_special_adjustment_line (
|
||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||
adjustment_id BIGINT NOT NULL,
|
||
line_no INT NOT NULL,
|
||
item_id BIGINT NOT NULL,
|
||
warehouse_id BIGINT NOT NULL,
|
||
location_id BIGINT NULL,
|
||
lot_id BIGINT NOT NULL,
|
||
inventory_txn_id BIGINT NULL,
|
||
before_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
|
||
change_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
|
||
after_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
|
||
before_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
|
||
change_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
|
||
after_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
|
||
unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0,
|
||
line_reason VARCHAR(1000) NULL,
|
||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||
PRIMARY KEY (id),
|
||
KEY idx_wh_special_adjustment_line_adjustment (adjustment_id, line_no),
|
||
KEY idx_wh_special_adjustment_line_lot (lot_id),
|
||
KEY idx_wh_special_adjustment_line_txn (inventory_txn_id),
|
||
CONSTRAINT fk_wh_special_adjustment_line_adjustment
|
||
FOREIGN KEY (adjustment_id) REFERENCES wh_special_adjustment(id),
|
||
CONSTRAINT fk_wh_special_adjustment_line_item
|
||
FOREIGN KEY (item_id) REFERENCES md_item(id),
|
||
CONSTRAINT fk_wh_special_adjustment_line_warehouse
|
||
FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id),
|
||
CONSTRAINT fk_wh_special_adjustment_line_lot
|
||
FOREIGN KEY (lot_id) REFERENCES wh_stock_lot(id),
|
||
CONSTRAINT fk_wh_special_adjustment_line_txn
|
||
FOREIGN KEY (inventory_txn_id) REFERENCES wh_inventory_txn(id)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓库特殊出入库调整明细表';
|
||
```
|
||
|
||
- [ ] **Step 2: Add ORM models**
|
||
|
||
Modify `backend/app/models/operations.py` after `InventoryTxn`:
|
||
|
||
```python
|
||
class SpecialWarehouseAdjustment(Base):
|
||
__tablename__ = "wh_special_adjustment"
|
||
|
||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||
adjustment_no: Mapped[str] = mapped_column(String(50))
|
||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||
warehouse_type: Mapped[str] = mapped_column(String(32))
|
||
direction: Mapped[str] = mapped_column(String(16))
|
||
reason: Mapped[str] = mapped_column(String(1000))
|
||
status: Mapped[str] = mapped_column(String(32), server_default=text("'CONFIRMED'"))
|
||
operator_user_id: Mapped[int | None] = mapped_column(ForeignKey("sys_user.id"), nullable=True)
|
||
adjusted_at: Mapped[object] = mapped_column(DateTime)
|
||
created_at: Mapped[object] = mapped_column(DateTime)
|
||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||
|
||
|
||
class SpecialWarehouseAdjustmentLine(Base):
|
||
__tablename__ = "wh_special_adjustment_line"
|
||
|
||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||
adjustment_id: Mapped[int] = mapped_column(ForeignKey("wh_special_adjustment.id"))
|
||
line_no: Mapped[int] = mapped_column(Integer)
|
||
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
|
||
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
|
||
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
|
||
lot_id: Mapped[int] = mapped_column(ForeignKey("wh_stock_lot.id"))
|
||
inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True)
|
||
before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||
change_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||
after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||
before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||
change_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||
after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
|
||
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
|
||
line_reason: Mapped[str | None] = mapped_column(String(1000), nullable=True)
|
||
created_at: Mapped[object] = mapped_column(DateTime)
|
||
updated_at: Mapped[object] = mapped_column(DateTime)
|
||
```
|
||
|
||
- [ ] **Step 3: Run model import check**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python - <<'PY'
|
||
import app.models.operations as operations
|
||
print(operations.SpecialWarehouseAdjustment.__tablename__)
|
||
print(operations.SpecialWarehouseAdjustmentLine.__tablename__)
|
||
PY
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
wh_special_adjustment
|
||
wh_special_adjustment_line
|
||
```
|
||
|
||
- [ ] **Step 4: Re-run backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py -v
|
||
```
|
||
|
||
Expected: still FAIL because schemas and service are not implemented yet.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||
git add backend/sql/add_special_warehouse_adjustment_patch.sql backend/app/models/operations.py
|
||
git commit -m "feat: add special adjustment audit tables"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Backend Schemas And Core Service
|
||
|
||
**Files:**
|
||
|
||
- Modify: `backend/app/schemas/operations.py`
|
||
- Create: `backend/app/services/special_inventory_adjustment.py`
|
||
|
||
- [ ] **Step 1: Add request and response schemas**
|
||
|
||
Modify `backend/app/schemas/operations.py` near the warehouse operation schemas:
|
||
|
||
```python
|
||
class SpecialWarehouseAdjustmentLineCreate(BaseModel):
|
||
item_id: int
|
||
lot_id: int | None = None
|
||
location_id: int | None = None
|
||
adjust_qty: float | None = 0
|
||
adjust_weight_kg: float | None = 0
|
||
unit_cost: float | None = None
|
||
line_reason: str | None = None
|
||
|
||
|
||
class SpecialWarehouseAdjustmentCreate(BaseModel):
|
||
warehouse_id: int
|
||
direction: str
|
||
reason: str
|
||
lines: list[SpecialWarehouseAdjustmentLineCreate] = Field(min_length=1)
|
||
|
||
|
||
class SpecialWarehouseAdjustmentLineRead(BaseModel):
|
||
line_id: int
|
||
line_no: int
|
||
item_id: int
|
||
lot_id: int
|
||
inventory_txn_id: int | None = None
|
||
before_qty: float
|
||
change_qty: float
|
||
after_qty: float
|
||
before_weight_kg: float
|
||
change_weight_kg: float
|
||
after_weight_kg: float
|
||
unit_cost: float
|
||
line_reason: str | None = None
|
||
|
||
|
||
class SpecialWarehouseAdjustmentRead(BaseModel):
|
||
adjustment_id: int
|
||
adjustment_no: str
|
||
warehouse_id: int
|
||
warehouse_type: str
|
||
direction: str
|
||
reason: str
|
||
status: str
|
||
adjusted_at: datetime
|
||
lines: list[SpecialWarehouseAdjustmentLineRead] = Field(default_factory=list)
|
||
```
|
||
|
||
- [ ] **Step 2: Implement service constants and helpers**
|
||
|
||
Create `backend/app/services/special_inventory_adjustment.py` with these imports and helpers:
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models.master_data import Item, StockBalance, Warehouse
|
||
from app.models.operations import (
|
||
InventoryTxn,
|
||
SpecialWarehouseAdjustment,
|
||
SpecialWarehouseAdjustmentLine,
|
||
StockLot,
|
||
)
|
||
from app.schemas.operations import (
|
||
SpecialWarehouseAdjustmentCreate,
|
||
SpecialWarehouseAdjustmentLineRead,
|
||
SpecialWarehouseAdjustmentRead,
|
||
)
|
||
from app.services.operations import create_inventory_txn, get_location_or_default, next_doc_no, to_decimal, upsert_stock_balance
|
||
from app.services.stocktake import ensure_warehouses_unlocked
|
||
|
||
SPECIAL_SOURCE_DOC_TYPE = "SPECIAL_ADJUSTMENT"
|
||
SPECIAL_IN_TXN_TYPE = "SPECIAL_IN"
|
||
SPECIAL_OUT_TXN_TYPE = "SPECIAL_OUT"
|
||
WEIGHT_ONLY_WAREHOUSE_TYPES = {"RAW", "SCRAP"}
|
||
QTY_ONLY_WAREHOUSE_TYPES = {"AUX"}
|
||
QTY_PRIMARY_WAREHOUSE_TYPES = {"FINISHED", "RETURN"}
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AdjustmentMeasure:
|
||
qty: Decimal
|
||
weight: Decimal
|
||
amount_basis: str
|
||
|
||
|
||
def _clean_required_reason(direction: str, reason: str | None) -> str:
|
||
label = "特殊入库" if direction == "IN" else "特殊出库"
|
||
cleaned = str(reason or "").strip()
|
||
if not cleaned:
|
||
raise ValueError(f"{label}必须填写说明")
|
||
if len(cleaned) < 4:
|
||
raise ValueError(f"{label}说明不能少于4个字")
|
||
return cleaned
|
||
|
||
|
||
def _direction_label(direction: str) -> str:
|
||
return "特殊入库" if direction == "IN" else "特殊出库"
|
||
|
||
|
||
def _normalize_direction(value: str | None) -> str:
|
||
direction = str(value or "").upper()
|
||
if direction not in {"IN", "OUT"}:
|
||
raise ValueError("特殊调整方向只能是特殊入库或特殊出库")
|
||
return direction
|
||
|
||
|
||
def _measure_for_warehouse(warehouse_type: str, raw_qty: Decimal, raw_weight: Decimal) -> AdjustmentMeasure:
|
||
if warehouse_type in WEIGHT_ONLY_WAREHOUSE_TYPES:
|
||
if raw_weight <= 0:
|
||
raise ValueError("该仓库特殊调整必须填写重量")
|
||
return AdjustmentMeasure(qty=Decimal("0"), weight=raw_weight, amount_basis="WEIGHT")
|
||
if warehouse_type in QTY_ONLY_WAREHOUSE_TYPES:
|
||
if raw_qty <= 0:
|
||
raise ValueError("该仓库特殊调整必须填写数量")
|
||
return AdjustmentMeasure(qty=raw_qty, weight=Decimal("0"), amount_basis="QTY")
|
||
if warehouse_type in QTY_PRIMARY_WAREHOUSE_TYPES:
|
||
if raw_qty <= 0:
|
||
raise ValueError("该仓库特殊调整必须填写数量")
|
||
return AdjustmentMeasure(qty=raw_qty, weight=max(raw_weight, Decimal("0")), amount_basis="QTY")
|
||
if raw_qty > 0 and raw_weight > 0:
|
||
raise ValueError("半成品特殊调整只能按件或按重选择一种口径")
|
||
if raw_qty > 0:
|
||
return AdjustmentMeasure(qty=raw_qty, weight=Decimal("0"), amount_basis="QTY")
|
||
if raw_weight > 0:
|
||
return AdjustmentMeasure(qty=Decimal("0"), weight=raw_weight, amount_basis="WEIGHT")
|
||
raise ValueError("半成品特殊调整必须填写数量或重量")
|
||
|
||
|
||
def _format_decimal(value: Decimal) -> str:
|
||
text = f"{value.normalize():f}"
|
||
return text.rstrip("0").rstrip(".") if "." in text else text
|
||
```
|
||
|
||
- [ ] **Step 3: Implement new lot creation for special inbound**
|
||
|
||
Append this helper to `backend/app/services/special_inventory_adjustment.py`:
|
||
|
||
```python
|
||
def _build_special_lot_no(db: Session, item: Item, warehouse: Warehouse, now: datetime) -> str:
|
||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||
if warehouse_type == "RAW":
|
||
from app.services.operations import build_inventory_lot_no
|
||
|
||
return build_inventory_lot_no(db, item.id)
|
||
prefix = {
|
||
"SEMI": "半成品特殊",
|
||
"FINISHED": "成品特殊",
|
||
"AUX": "辅料特殊",
|
||
"SCRAP": "废料特殊",
|
||
"RETURN": "退货特殊",
|
||
}.get(warehouse_type, "库存特殊")
|
||
base = f"{prefix}{now.strftime('%Y%m%d')}"
|
||
existing_count = db.scalar(
|
||
select(SpecialWarehouseAdjustmentLine.id)
|
||
.join(SpecialWarehouseAdjustment, SpecialWarehouseAdjustment.id == SpecialWarehouseAdjustmentLine.adjustment_id)
|
||
.where(SpecialWarehouseAdjustment.warehouse_id == warehouse.id)
|
||
.order_by(SpecialWarehouseAdjustmentLine.id.desc())
|
||
.limit(1)
|
||
)
|
||
next_seq = int(existing_count or 0) + 1
|
||
candidate = f"{base}-{next_seq:04d}"
|
||
while db.scalar(select(StockLot.id).where(StockLot.lot_no == candidate)):
|
||
next_seq += 1
|
||
candidate = f"{base}-{next_seq:04d}"
|
||
return candidate
|
||
|
||
|
||
def _create_special_inbound_lot(
|
||
db: Session,
|
||
*,
|
||
item: Item,
|
||
warehouse: Warehouse,
|
||
location_id: int | None,
|
||
measure: AdjustmentMeasure,
|
||
unit_cost: Decimal,
|
||
adjustment_id: int,
|
||
line_id: int,
|
||
reason: str,
|
||
now: datetime,
|
||
) -> StockLot:
|
||
location = get_location_or_default(db, warehouse.id, location_id)
|
||
lot = StockLot(
|
||
lot_no=_build_special_lot_no(db, item, warehouse, now),
|
||
lot_role="SPECIAL_ADJUSTMENT",
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=location.id if location else None,
|
||
source_doc_type=SPECIAL_SOURCE_DOC_TYPE,
|
||
source_doc_id=adjustment_id,
|
||
source_line_id=line_id,
|
||
source_material_summary=f"特殊入库说明:{reason}",
|
||
inbound_qty=measure.qty,
|
||
inbound_weight_kg=measure.weight,
|
||
remaining_qty=measure.qty,
|
||
remaining_weight_kg=measure.weight,
|
||
locked_qty=Decimal("0"),
|
||
locked_weight_kg=Decimal("0"),
|
||
unit_cost=unit_cost,
|
||
production_date=now.date(),
|
||
quality_status="PASS",
|
||
status="AVAILABLE",
|
||
remark=f"特殊入库说明:{reason}"[:255],
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
db.add(lot)
|
||
db.flush()
|
||
return lot
|
||
```
|
||
|
||
- [ ] **Step 4: Implement main service**
|
||
|
||
Append this function to `backend/app/services/special_inventory_adjustment.py`:
|
||
|
||
```python
|
||
def create_special_inventory_adjustment(
|
||
db: Session,
|
||
payload: SpecialWarehouseAdjustmentCreate,
|
||
*,
|
||
user_id: int | None,
|
||
) -> SpecialWarehouseAdjustmentRead:
|
||
direction = _normalize_direction(payload.direction)
|
||
reason = _clean_required_reason(direction, payload.reason)
|
||
warehouse = db.get(Warehouse, payload.warehouse_id)
|
||
if not warehouse:
|
||
raise ValueError("仓库不存在")
|
||
ensure_warehouses_unlocked(db, [warehouse.id], _direction_label(direction))
|
||
warehouse_type = str(warehouse.warehouse_type or "").upper()
|
||
now = datetime.now()
|
||
adjustment = SpecialWarehouseAdjustment(
|
||
adjustment_no=next_doc_no(db, SpecialWarehouseAdjustment, "adjustment_no", f"特殊调整{now.strftime('%Y%m%d')}"),
|
||
warehouse_id=warehouse.id,
|
||
warehouse_type=warehouse_type,
|
||
direction=direction,
|
||
reason=reason,
|
||
status="CONFIRMED",
|
||
operator_user_id=user_id,
|
||
adjusted_at=now,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
db.add(adjustment)
|
||
db.flush()
|
||
|
||
read_lines: list[SpecialWarehouseAdjustmentLineRead] = []
|
||
for index, payload_line in enumerate(payload.lines, start=1):
|
||
item = db.get(Item, payload_line.item_id)
|
||
if not item:
|
||
raise ValueError(f"第 {index} 行物料不存在")
|
||
|
||
raw_qty = to_decimal(payload_line.adjust_qty)
|
||
raw_weight = to_decimal(payload_line.adjust_weight_kg)
|
||
if raw_qty < 0 or raw_weight < 0:
|
||
raise ValueError("特殊调整数量和重量不能小于0")
|
||
measure = _measure_for_warehouse(warehouse_type, raw_qty, raw_weight)
|
||
|
||
lot = db.get(StockLot, payload_line.lot_id) if payload_line.lot_id else None
|
||
if direction == "OUT" and lot is None:
|
||
raise ValueError("特殊出库必须选择库存明细")
|
||
if lot is not None:
|
||
if lot.item_id != item.id or lot.warehouse_id != warehouse.id:
|
||
raise ValueError("库存明细与所选物料或仓库不一致")
|
||
if payload_line.location_id and lot.location_id != payload_line.location_id:
|
||
raise ValueError("库存明细不属于所选库位")
|
||
|
||
if direction == "IN" and lot is None:
|
||
unit_cost = to_decimal(payload_line.unit_cost, "0.0001")
|
||
placeholder_line = SpecialWarehouseAdjustmentLine(
|
||
adjustment_id=adjustment.id,
|
||
line_no=index,
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=payload_line.location_id,
|
||
lot_id=0,
|
||
before_qty=Decimal("0"),
|
||
change_qty=measure.qty,
|
||
after_qty=measure.qty,
|
||
before_weight_kg=Decimal("0"),
|
||
change_weight_kg=measure.weight,
|
||
after_weight_kg=measure.weight,
|
||
unit_cost=unit_cost,
|
||
line_reason=payload_line.line_reason,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
db.add(placeholder_line)
|
||
db.flush()
|
||
lot = _create_special_inbound_lot(
|
||
db,
|
||
item=item,
|
||
warehouse=warehouse,
|
||
location_id=payload_line.location_id,
|
||
measure=measure,
|
||
unit_cost=unit_cost,
|
||
adjustment_id=adjustment.id,
|
||
line_id=placeholder_line.id,
|
||
reason=reason,
|
||
now=now,
|
||
)
|
||
placeholder_line.lot_id = lot.id
|
||
db.add(placeholder_line)
|
||
line = placeholder_line
|
||
before_qty = Decimal("0")
|
||
before_weight = Decimal("0")
|
||
else:
|
||
assert lot is not None
|
||
before_qty = to_decimal(lot.remaining_qty)
|
||
before_weight = to_decimal(lot.remaining_weight_kg)
|
||
unit_cost = to_decimal(payload_line.unit_cost, "0.0001") if payload_line.unit_cost is not None else to_decimal(lot.unit_cost, "0.0001")
|
||
if direction == "OUT":
|
||
available_qty = before_qty - to_decimal(lot.locked_qty)
|
||
available_weight = before_weight - to_decimal(lot.locked_weight_kg)
|
||
if measure.qty > 0 and measure.qty > available_qty:
|
||
raise ValueError("特殊出库数量不能超过当前可用数量")
|
||
if measure.weight > 0 and measure.weight > available_weight:
|
||
raise ValueError("特殊出库重量不能超过当前可用重量")
|
||
change_qty = -measure.qty
|
||
change_weight = -measure.weight
|
||
else:
|
||
change_qty = measure.qty
|
||
change_weight = measure.weight
|
||
line = SpecialWarehouseAdjustmentLine(
|
||
adjustment_id=adjustment.id,
|
||
line_no=index,
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=lot.location_id,
|
||
lot_id=lot.id,
|
||
before_qty=before_qty,
|
||
change_qty=change_qty,
|
||
after_qty=before_qty + change_qty,
|
||
before_weight_kg=before_weight,
|
||
change_weight_kg=change_weight,
|
||
after_weight_kg=before_weight + change_weight,
|
||
unit_cost=unit_cost,
|
||
line_reason=payload_line.line_reason,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
db.add(line)
|
||
db.flush()
|
||
lot.remaining_qty = line.after_qty
|
||
lot.remaining_weight_kg = line.after_weight_kg
|
||
lot.status = "AVAILABLE" if line.after_qty > 0 or line.after_weight_kg > 0 else "DEPLETED"
|
||
lot.updated_at = now
|
||
db.add(lot)
|
||
|
||
line_reason = str(payload_line.line_reason or "").strip()
|
||
direction_text = _direction_label(direction)
|
||
remark_parts = [
|
||
f"{direction_text}说明:{reason}",
|
||
f"调整前数量 {_format_decimal(to_decimal(line.before_qty))}",
|
||
f"调整数量 {_format_decimal(to_decimal(line.change_qty))}",
|
||
f"调整后数量 {_format_decimal(to_decimal(line.after_qty))}",
|
||
f"调整前重量 {_format_decimal(to_decimal(line.before_weight_kg))}",
|
||
f"调整重量 {_format_decimal(to_decimal(line.change_weight_kg))}",
|
||
f"调整后重量 {_format_decimal(to_decimal(line.after_weight_kg))}",
|
||
]
|
||
if line_reason:
|
||
remark_parts.append(f"明细说明:{line_reason}")
|
||
txn = create_inventory_txn(
|
||
db,
|
||
txn_type=SPECIAL_IN_TXN_TYPE if direction == "IN" else SPECIAL_OUT_TXN_TYPE,
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=lot.location_id,
|
||
lot_id=lot.id,
|
||
qty_change=to_decimal(line.change_qty),
|
||
weight_change=to_decimal(line.change_weight_kg),
|
||
unit_cost=unit_cost,
|
||
source_doc_type=SPECIAL_SOURCE_DOC_TYPE,
|
||
source_doc_id=adjustment.id,
|
||
source_line_id=line.id,
|
||
biz_time=now,
|
||
operator_user_id=user_id,
|
||
remark=";".join(remark_parts)[:255],
|
||
amount_basis=measure.amount_basis,
|
||
)
|
||
line.inventory_txn_id = txn.id
|
||
db.add(line)
|
||
upsert_stock_balance(
|
||
db,
|
||
item_id=item.id,
|
||
warehouse_id=warehouse.id,
|
||
location_id=lot.location_id,
|
||
qty_delta=to_decimal(line.change_qty),
|
||
weight_delta=to_decimal(line.change_weight_kg),
|
||
available_qty_delta=to_decimal(line.change_qty),
|
||
available_weight_delta=to_decimal(line.change_weight_kg),
|
||
unit_cost=unit_cost,
|
||
)
|
||
read_lines.append(
|
||
SpecialWarehouseAdjustmentLineRead(
|
||
line_id=line.id,
|
||
line_no=line.line_no,
|
||
item_id=line.item_id,
|
||
lot_id=line.lot_id,
|
||
inventory_txn_id=txn.id,
|
||
before_qty=float(line.before_qty),
|
||
change_qty=float(line.change_qty),
|
||
after_qty=float(line.after_qty),
|
||
before_weight_kg=float(line.before_weight_kg),
|
||
change_weight_kg=float(line.change_weight_kg),
|
||
after_weight_kg=float(line.after_weight_kg),
|
||
unit_cost=float(line.unit_cost),
|
||
line_reason=line.line_reason,
|
||
)
|
||
)
|
||
|
||
db.commit()
|
||
return SpecialWarehouseAdjustmentRead(
|
||
adjustment_id=adjustment.id,
|
||
adjustment_no=adjustment.adjustment_no,
|
||
warehouse_id=adjustment.warehouse_id,
|
||
warehouse_type=adjustment.warehouse_type,
|
||
direction=adjustment.direction,
|
||
reason=adjustment.reason,
|
||
status=adjustment.status,
|
||
adjusted_at=adjustment.adjusted_at,
|
||
lines=read_lines,
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Run the backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py -v
|
||
```
|
||
|
||
Expected: PASS for the new test file. If SQLite rejects `lot_id=0` because of a foreign key in the placeholder line, adjust the implementation by creating the special inbound `StockLot` first with `source_line_id=0`, then create `SpecialWarehouseAdjustmentLine`, then update `StockLot.source_line_id` to the line id before commit.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||
git add backend/app/schemas/operations.py backend/app/services/special_inventory_adjustment.py backend/tests/test_special_warehouse_adjustment.py
|
||
git commit -m "feat: implement special warehouse adjustment service"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: API Route And Ledger Visibility
|
||
|
||
**Files:**
|
||
|
||
- Modify: `backend/app/api/routes/inventory.py`
|
||
- Modify: `backend/app/services/operations.py`
|
||
- Modify: `backend/tests/test_special_warehouse_adjustment.py`
|
||
|
||
- [ ] **Step 1: Extend tests with API-style route behavior**
|
||
|
||
Append to `SpecialWarehouseAdjustmentTest`:
|
||
|
||
```python
|
||
def test_special_adjustment_ledger_direction_and_chinese_remark(self) -> None:
|
||
from app.services.operations import get_inventory_txn_ledger_query
|
||
|
||
payload = SpecialWarehouseAdjustmentCreate(
|
||
warehouse_id=self.raw.id,
|
||
direction="OUT",
|
||
reason="仓管复核后确认线下已处理,补做特殊出库",
|
||
lines=[
|
||
SpecialWarehouseAdjustmentLineCreate(
|
||
item_id=self.raw_item.id,
|
||
lot_id=self.raw_lot.id,
|
||
adjust_weight_kg=Decimal("6"),
|
||
)
|
||
],
|
||
)
|
||
create_special_inventory_adjustment(self.db, payload, user_id=9)
|
||
|
||
row = self.db.execute(
|
||
get_inventory_txn_ledger_query(warehouse_type="RAW", txn_type="SPECIAL_OUT")
|
||
).mappings().first()
|
||
|
||
self.assertIsNotNone(row)
|
||
self.assertEqual(row["direction"], "OUT")
|
||
self.assertEqual(row["txn_type"], "SPECIAL_OUT")
|
||
self.assertIn("特殊出库说明:仓管复核后确认线下已处理,补做特殊出库", row["remark"])
|
||
```
|
||
|
||
- [ ] **Step 2: Run route/ledger test before modifying ledger labels**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py::SpecialWarehouseAdjustmentTest::test_special_adjustment_ledger_direction_and_chinese_remark -v
|
||
```
|
||
|
||
Expected: PASS or FAIL only if `SPECIAL_OUT` is not included in ledger filtering/summary logic. If it already passes, still complete Step 3 to add explicit label coverage.
|
||
|
||
- [ ] **Step 3: Add route imports and endpoint**
|
||
|
||
Modify imports in `backend/app/api/routes/inventory.py`:
|
||
|
||
```python
|
||
from app.schemas.operations import (
|
||
# existing imports...
|
||
SpecialWarehouseAdjustmentCreate,
|
||
SpecialWarehouseAdjustmentRead,
|
||
)
|
||
from app.services.special_inventory_adjustment import create_special_inventory_adjustment
|
||
```
|
||
|
||
Add this endpoint near the generic inbound/outbound endpoints:
|
||
|
||
```python
|
||
@router.post("/special-adjustments", response_model=SpecialWarehouseAdjustmentRead)
|
||
def create_inventory_special_adjustment(
|
||
payload: SpecialWarehouseAdjustmentCreate,
|
||
context: AuthContext = Depends(require_authenticated_user),
|
||
db: Session = Depends(get_db),
|
||
) -> SpecialWarehouseAdjustmentRead:
|
||
try:
|
||
return create_special_inventory_adjustment(db, payload, user_id=context.user.id)
|
||
except ValueError as exc:
|
||
db.rollback()
|
||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||
```
|
||
|
||
- [ ] **Step 4: Add Chinese business labels for special types**
|
||
|
||
Modify `_INVENTORY_BIZ_TYPE_LABELS` in `backend/app/api/routes/inventory.py`:
|
||
|
||
```python
|
||
"SPECIAL_IN": "特殊入库",
|
||
"SPECIAL_OUT": "特殊出库",
|
||
```
|
||
|
||
Modify any front/back ledger display helper that maps raw `txn_type` to labels if one exists. If no helper exists, keep `txn_type` as internal code but make sure `remark` contains the Chinese label because current warehouse ledger UI searches and displays remarks.
|
||
|
||
- [ ] **Step 5: Run focused backend tests**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest tests/test_special_warehouse_adjustment.py tests/test_warehouse_transaction_ledger.py -v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||
git add backend/app/api/routes/inventory.py backend/app/services/operations.py backend/tests/test_special_warehouse_adjustment.py
|
||
git commit -m "feat: expose special warehouse adjustment api"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Frontend Rules And Static Tests
|
||
|
||
**Files:**
|
||
|
||
- Create: `frontend/src/utils/specialAdjustmentRules.js`
|
||
- Create: `frontend/scripts/test-special-adjustment-ui.mjs`
|
||
|
||
- [ ] **Step 1: Add frontend measure rules**
|
||
|
||
Create `frontend/src/utils/specialAdjustmentRules.js`:
|
||
|
||
```javascript
|
||
export function normalizeWarehouseType(value) {
|
||
return String(value || "").toUpperCase();
|
||
}
|
||
|
||
export function specialAdjustmentDirectionLabel(direction) {
|
||
return String(direction || "").toUpperCase() === "OUT" ? "特殊出库" : "特殊入库";
|
||
}
|
||
|
||
export function specialAdjustmentMeasureMode(warehouseType, row = {}) {
|
||
const normalizedType = normalizeWarehouseType(warehouseType || row.warehouse_type);
|
||
if (["RAW", "SCRAP"].includes(normalizedType)) {
|
||
return { mode: "WEIGHT", primaryLabel: "调整重量(kg)", showQty: false, showWeight: true };
|
||
}
|
||
if (normalizedType === "AUX") {
|
||
return { mode: "QTY", primaryLabel: "调整数量", showQty: true, showWeight: false };
|
||
}
|
||
if (["FINISHED", "RETURN"].includes(normalizedType)) {
|
||
return { mode: "QTY_WITH_WEIGHT", primaryLabel: "调整数量", showQty: true, showWeight: true };
|
||
}
|
||
const hasQty = Number(row?.remaining_qty || row?.qty_available || 0) > 0;
|
||
const hasWeight = Number(row?.remaining_weight_kg || row?.weight_available_kg || 0) > 0;
|
||
if (hasQty && !hasWeight) {
|
||
return { mode: "QTY", primaryLabel: "调整数量", showQty: true, showWeight: false };
|
||
}
|
||
if (hasWeight && !hasQty) {
|
||
return { mode: "WEIGHT", primaryLabel: "调整重量(kg)", showQty: false, showWeight: true };
|
||
}
|
||
return { mode: "SEMI_SELECT", primaryLabel: "调整口径", showQty: true, showWeight: true };
|
||
}
|
||
|
||
export function specialAdjustmentOperationKeys() {
|
||
return {
|
||
raw: ["raw-special-in", "raw-special-out"],
|
||
semi: ["semi-special-in", "semi-special-out"],
|
||
finished: ["finished-special-in", "finished-special-out"],
|
||
auxiliary: ["aux-special-in", "aux-special-out"],
|
||
scrap: ["scrap-special-in", "scrap-special-out"],
|
||
return: ["return-special-in", "return-special-out"]
|
||
};
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add static UI test script**
|
||
|
||
Create `frontend/scripts/test-special-adjustment-ui.mjs`:
|
||
|
||
```javascript
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import { specialAdjustmentMeasureMode, specialAdjustmentOperationKeys } from "../src/utils/specialAdjustmentRules.js";
|
||
|
||
const viewSource = readFileSync(new URL("../src/views/InventoryLedgerView.vue", import.meta.url), "utf8");
|
||
const operationKeys = specialAdjustmentOperationKeys();
|
||
|
||
for (const [warehouseTab, keys] of Object.entries(operationKeys)) {
|
||
for (const key of keys) {
|
||
assert.ok(viewSource.includes(`makeOperation("${key}"`), `${warehouseTab} 缺少 ${key}`);
|
||
}
|
||
}
|
||
|
||
assert.ok(viewSource.includes("特殊入库"), "仓库页面必须展示特殊入库");
|
||
assert.ok(viewSource.includes("特殊出库"), "仓库页面必须展示特殊出库");
|
||
assert.ok(viewSource.includes("/inventory/special-adjustments"), "仓库页面必须调用特殊调整接口");
|
||
|
||
assert.deepEqual(specialAdjustmentMeasureMode("RAW"), {
|
||
mode: "WEIGHT",
|
||
primaryLabel: "调整重量(kg)",
|
||
showQty: false,
|
||
showWeight: true
|
||
});
|
||
|
||
assert.deepEqual(specialAdjustmentMeasureMode("AUX"), {
|
||
mode: "QTY",
|
||
primaryLabel: "调整数量",
|
||
showQty: true,
|
||
showWeight: false
|
||
});
|
||
|
||
assert.equal(specialAdjustmentMeasureMode("FINISHED").mode, "QTY_WITH_WEIGHT");
|
||
assert.equal(specialAdjustmentMeasureMode("SCRAP").mode, "WEIGHT");
|
||
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 10, remaining_weight_kg: 0 }).mode, "QTY");
|
||
assert.equal(specialAdjustmentMeasureMode("SEMI", { remaining_qty: 0, remaining_weight_kg: 10 }).mode, "WEIGHT");
|
||
```
|
||
|
||
- [ ] **Step 3: Run the failing frontend test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
node scripts/test-special-adjustment-ui.mjs
|
||
```
|
||
|
||
Expected: FAIL because `InventoryLedgerView.vue` does not yet include special adjustment operation keys or API call.
|
||
|
||
- [ ] **Step 4: Commit failing frontend tests**
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||
git add frontend/src/utils/specialAdjustmentRules.js frontend/scripts/test-special-adjustment-ui.mjs
|
||
git commit -m "test: cover special adjustment warehouse ui"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Frontend Operation Buttons And Drawer
|
||
|
||
**Files:**
|
||
|
||
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||
|
||
- [ ] **Step 1: Import frontend rules**
|
||
|
||
In `frontend/src/views/InventoryLedgerView.vue`, add:
|
||
|
||
```javascript
|
||
import { specialAdjustmentMeasureMode, specialAdjustmentDirectionLabel } from "../utils/specialAdjustmentRules.js";
|
||
```
|
||
|
||
- [ ] **Step 2: Add special operation buttons to all six warehouses**
|
||
|
||
Modify `operationMatrix` so each inbound list ends with `特殊入库` and each outbound list ends with `特殊出库`:
|
||
|
||
```javascript
|
||
makeOperation("raw-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整原材料库库存明细。", "specialAdjustment")
|
||
makeOperation("raw-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减原材料库库存明细。", "specialAdjustment")
|
||
makeOperation("semi-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整半成品库库存明细。", "specialAdjustment")
|
||
makeOperation("semi-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减半成品库库存明细。", "specialAdjustment")
|
||
makeOperation("finished-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整成品库库存明细。", "specialAdjustment")
|
||
makeOperation("finished-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减成品库库存明细。", "specialAdjustment")
|
||
makeOperation("aux-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整辅料库库存明细。", "specialAdjustment")
|
||
makeOperation("aux-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减辅料库库存明细。", "specialAdjustment")
|
||
makeOperation("scrap-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整废料库库存明细。", "specialAdjustment")
|
||
makeOperation("scrap-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减废料库库存明细。", "specialAdjustment")
|
||
makeOperation("return-special-in", "in", "SPECIAL_IN", "特殊入库", "强制填写说明后,直接调整退货库库存明细。", "specialAdjustment")
|
||
makeOperation("return-special-out", "out", "SPECIAL_OUT", "特殊出库", "强制填写说明后,直接扣减退货库库存明细。", "specialAdjustment")
|
||
```
|
||
|
||
Use the same visual style as existing operation cards. Do not introduce a separate visual language.
|
||
|
||
- [ ] **Step 3: Add drawer state**
|
||
|
||
Add near other drawer state:
|
||
|
||
```javascript
|
||
const specialAdjustmentDrawerOpen = ref(false);
|
||
const specialAdjustmentSubmitting = ref(false);
|
||
const specialAdjustmentForm = reactive({
|
||
direction: "IN",
|
||
warehouse_id: "",
|
||
reason: "",
|
||
lines: []
|
||
});
|
||
```
|
||
|
||
Add reset helpers:
|
||
|
||
```javascript
|
||
function resetSpecialAdjustmentForm() {
|
||
specialAdjustmentForm.direction = activeOperation.value?.direction === "out" ? "OUT" : "IN";
|
||
specialAdjustmentForm.warehouse_id = activeWarehouses.value[0]?.id || "";
|
||
specialAdjustmentForm.reason = "";
|
||
specialAdjustmentForm.lines = [];
|
||
}
|
||
|
||
function closeSpecialAdjustmentDrawer() {
|
||
specialAdjustmentDrawerOpen.value = false;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Route special operations to the drawer**
|
||
|
||
Modify `openOperationDrawer(operation)` before the generic drawer fallback:
|
||
|
||
```javascript
|
||
if (operation.drawerType === "specialAdjustment") {
|
||
resetSpecialAdjustmentForm();
|
||
specialAdjustmentDrawerOpen.value = true;
|
||
return;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Add computed lot options and preview helpers**
|
||
|
||
Add computed helpers:
|
||
|
||
```javascript
|
||
const specialAdjustmentDirectionText = computed(() => specialAdjustmentDirectionLabel(specialAdjustmentForm.direction));
|
||
|
||
const specialAdjustmentLotOptions = computed(() => {
|
||
const warehouseId = Number(specialAdjustmentForm.warehouse_id || 0);
|
||
return stockLots.value
|
||
.filter((lot) => Number(lot.warehouse_id || 0) === warehouseId)
|
||
.sort((left, right) => {
|
||
const leftAvailable = Number(left.remaining_qty || 0) > 0 || Number(left.remaining_weight_kg || 0) > 0 ? 0 : 1;
|
||
const rightAvailable = Number(right.remaining_qty || 0) > 0 || Number(right.remaining_weight_kg || 0) > 0 ? 0 : 1;
|
||
return leftAvailable - rightAvailable || String(left.lot_no || "").localeCompare(String(right.lot_no || ""), "zh-CN", { numeric: true });
|
||
});
|
||
});
|
||
|
||
function buildSpecialAdjustmentEmptyLine() {
|
||
return {
|
||
lot_id: "",
|
||
item_id: "",
|
||
item_name: "",
|
||
lot_no: "",
|
||
adjust_qty: "",
|
||
adjust_weight_kg: "",
|
||
unit_cost: "",
|
||
line_reason: ""
|
||
};
|
||
}
|
||
|
||
function addSpecialAdjustmentLine() {
|
||
if (!specialAdjustmentForm.reason.trim()) {
|
||
errorMessage.value = `${specialAdjustmentDirectionText.value}必须先填写说明`;
|
||
return;
|
||
}
|
||
specialAdjustmentForm.lines.push(buildSpecialAdjustmentEmptyLine());
|
||
}
|
||
|
||
function removeSpecialAdjustmentLine(index) {
|
||
specialAdjustmentForm.lines.splice(index, 1);
|
||
}
|
||
|
||
function applySpecialAdjustmentLot(line) {
|
||
const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id));
|
||
if (!lot) {
|
||
return;
|
||
}
|
||
line.item_id = lot.item_id;
|
||
line.item_name = lot.item_name;
|
||
line.lot_no = lot.lot_no;
|
||
line.unit_cost = lot.unit_cost || 0;
|
||
const measure = specialAdjustmentMeasureMode(activeWarehouseType.value, lot);
|
||
line.measure_mode = measure.mode;
|
||
}
|
||
|
||
function specialAdjustmentLinePreview(line) {
|
||
const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id));
|
||
if (!lot) {
|
||
return "请选择库存明细";
|
||
}
|
||
const directionMultiplier = specialAdjustmentForm.direction === "OUT" ? -1 : 1;
|
||
const qtyChange = Number(line.adjust_qty || 0) * directionMultiplier;
|
||
const weightChange = Number(line.adjust_weight_kg || 0) * directionMultiplier;
|
||
const afterQty = Number(lot.remaining_qty || 0) + qtyChange;
|
||
const afterWeight = Number(lot.remaining_weight_kg || 0) + weightChange;
|
||
return `调整前:${formatQty(lot.remaining_qty)} / ${formatWeight(lot.remaining_weight_kg)};调整后:${formatQty(afterQty)} / ${formatWeight(afterWeight)}`;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Add drawer template**
|
||
|
||
Add a `SideDrawer` after the generic drawer or near other operation drawers:
|
||
|
||
```vue
|
||
<SideDrawer
|
||
:open="specialAdjustmentDrawerOpen"
|
||
:eyebrow="`${activeInventoryLabel} · ${specialAdjustmentDirectionText}`"
|
||
:title="specialAdjustmentDirectionText"
|
||
tag="特殊调整"
|
||
@close="closeSpecialAdjustmentDrawer"
|
||
>
|
||
<form class="stack-form" @submit.prevent="submitSpecialAdjustment">
|
||
<div class="warning-card">
|
||
<strong>特殊调整会直接改变库存明细</strong>
|
||
<span>请只在补录、纠错、线下已处理等特殊场景使用。保存后系统会写入库存流水和特殊调整记录。</span>
|
||
</div>
|
||
|
||
<label class="field">
|
||
<span>调整说明</span>
|
||
<textarea
|
||
v-model.trim="specialAdjustmentForm.reason"
|
||
required
|
||
rows="3"
|
||
placeholder="必须填写,例如:现场复核发现该批次已线下处理,补做特殊出库"
|
||
/>
|
||
</label>
|
||
|
||
<label class="field">
|
||
<span>仓库</span>
|
||
<select v-model.number="specialAdjustmentForm.warehouse_id" required>
|
||
<option disabled value="">请选择仓库</option>
|
||
<option v-for="warehouse in activeWarehouses" :key="warehouse.id" :value="warehouse.id">
|
||
{{ warehouse.warehouse_name }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="drawer-table-actions">
|
||
<button class="ghost-button" type="button" @click="addSpecialAdjustmentLine">添加调整明细</button>
|
||
</div>
|
||
|
||
<div class="drawer-table-wrap">
|
||
<table class="drawer-data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>库存明细</th>
|
||
<th>调整数量</th>
|
||
<th>调整重量(kg)</th>
|
||
<th>明细说明</th>
|
||
<th>调整预览</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(line, index) in specialAdjustmentForm.lines" :key="index">
|
||
<td>
|
||
<select v-model.number="line.lot_id" required @change="applySpecialAdjustmentLot(line)">
|
||
<option disabled value="">请选择库存明细</option>
|
||
<option v-for="lot in specialAdjustmentLotOptions" :key="lot.lot_id" :value="lot.lot_id">
|
||
{{ lot.item_name }} · {{ lot.lot_no }} · 数量 {{ formatQty(lot.remaining_qty) }} · 重量 {{ formatWeight(lot.remaining_weight_kg) }}
|
||
</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<input v-model.number="line.adjust_qty" type="number" min="0" step="0.000001" />
|
||
</td>
|
||
<td>
|
||
<input v-model.number="line.adjust_weight_kg" type="number" min="0" step="0.001" />
|
||
</td>
|
||
<td>
|
||
<input v-model.trim="line.line_reason" type="text" placeholder="可选,补充单行说明" />
|
||
</td>
|
||
<td>{{ specialAdjustmentLinePreview(line) }}</td>
|
||
<td>
|
||
<button class="link-button danger" type="button" @click="removeSpecialAdjustmentLine(index)">删除</button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!specialAdjustmentForm.lines.length">
|
||
<td colspan="6">请先填写调整说明,再添加调整明细。</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="drawer-actions">
|
||
<button class="ghost-button" type="button" @click="closeSpecialAdjustmentDrawer">取消</button>
|
||
<button class="primary-button" type="submit" :disabled="specialAdjustmentSubmitting">
|
||
{{ specialAdjustmentSubmitting ? "保存中..." : "确认保存" }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</SideDrawer>
|
||
```
|
||
|
||
- [ ] **Step 7: Add submit validation and API call**
|
||
|
||
Add:
|
||
|
||
```javascript
|
||
function validateSpecialAdjustmentBeforeSubmit() {
|
||
if (!specialAdjustmentForm.reason.trim()) {
|
||
return `${specialAdjustmentDirectionText.value}必须填写说明`;
|
||
}
|
||
if (!specialAdjustmentForm.lines.length) {
|
||
return `${specialAdjustmentDirectionText.value}必须至少添加一条明细`;
|
||
}
|
||
for (const [index, line] of specialAdjustmentForm.lines.entries()) {
|
||
if (!Number(line.lot_id || 0)) {
|
||
return `第${index + 1}行必须选择库存明细`;
|
||
}
|
||
const lot = specialAdjustmentLotOptions.value.find((item) => Number(item.lot_id) === Number(line.lot_id));
|
||
if (!lot) {
|
||
return `第${index + 1}行库存明细不存在`;
|
||
}
|
||
const measure = specialAdjustmentMeasureMode(activeWarehouseType.value, lot);
|
||
const qty = Number(line.adjust_qty || 0);
|
||
const weight = Number(line.adjust_weight_kg || 0);
|
||
if (measure.mode === "WEIGHT" && weight <= 0) {
|
||
return `第${index + 1}行必须填写调整重量`;
|
||
}
|
||
if (["QTY", "QTY_WITH_WEIGHT"].includes(measure.mode) && qty <= 0) {
|
||
return `第${index + 1}行必须填写调整数量`;
|
||
}
|
||
if (specialAdjustmentForm.direction === "OUT") {
|
||
if (qty > Number(lot.remaining_qty || 0)) {
|
||
return `第${index + 1}行调整数量不能超过当前可用数量`;
|
||
}
|
||
if (weight > Number(lot.remaining_weight_kg || 0)) {
|
||
return `第${index + 1}行调整重量不能超过当前可用重量`;
|
||
}
|
||
}
|
||
}
|
||
return "";
|
||
}
|
||
|
||
async function submitSpecialAdjustment() {
|
||
resetFeedback();
|
||
const validationError = validateSpecialAdjustmentBeforeSubmit();
|
||
if (validationError) {
|
||
errorMessage.value = validationError;
|
||
return;
|
||
}
|
||
const confirmed = window.confirm(`确认执行${specialAdjustmentDirectionText.value}?保存后会直接改变库存,并写入库存流水。`);
|
||
if (!confirmed) {
|
||
return;
|
||
}
|
||
specialAdjustmentSubmitting.value = true;
|
||
try {
|
||
const payload = {
|
||
warehouse_id: Number(specialAdjustmentForm.warehouse_id),
|
||
direction: specialAdjustmentForm.direction,
|
||
reason: specialAdjustmentForm.reason.trim(),
|
||
lines: specialAdjustmentForm.lines.map((line) => ({
|
||
item_id: Number(line.item_id),
|
||
lot_id: Number(line.lot_id),
|
||
adjust_qty: Number(line.adjust_qty || 0),
|
||
adjust_weight_kg: Number(line.adjust_weight_kg || 0),
|
||
unit_cost: Number(line.unit_cost || 0),
|
||
line_reason: line.line_reason || ""
|
||
}))
|
||
};
|
||
const result = await postResource("/inventory/special-adjustments", payload);
|
||
feedbackMessage.value = `${specialAdjustmentDirectionText.value} ${result.adjustment_no} 已保存`;
|
||
closeSpecialAdjustmentDrawer();
|
||
await loadAll();
|
||
} catch (error) {
|
||
errorMessage.value = error.message || `${specialAdjustmentDirectionText.value}保存失败`;
|
||
} finally {
|
||
specialAdjustmentSubmitting.value = false;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run frontend static test**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
node scripts/test-special-adjustment-ui.mjs
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 9: Run frontend build**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
npm run build
|
||
```
|
||
|
||
Expected: PASS and Vite build completes.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||
git add frontend/src/views/InventoryLedgerView.vue frontend/src/utils/specialAdjustmentRules.js frontend/scripts/test-special-adjustment-ui.mjs
|
||
git commit -m "feat: add special adjustment warehouse UI"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Integration Verification And Manual Smoke Checks
|
||
|
||
**Files:**
|
||
|
||
- No new files expected.
|
||
- Verify changed files from prior tasks.
|
||
|
||
- [ ] **Step 1: Run focused backend suite**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
|
||
.venv/bin/python -m pytest \
|
||
tests/test_special_warehouse_adjustment.py \
|
||
tests/test_warehouse_transaction_ledger.py \
|
||
tests/test_stocktake_locking.py \
|
||
tests/test_raw_material_weight_only_flow.py \
|
||
tests/test_auxiliary_quantity_flow.py \
|
||
tests/test_semi_finished_operation_inventory.py \
|
||
tests/test_scrap_warehouse_flow.py \
|
||
tests/test_return_warehouse_flow.py \
|
||
-v
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 2: Run focused frontend scripts**
|
||
|
||
Run:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
|
||
node scripts/test-special-adjustment-ui.mjs
|
||
node scripts/test-inventory-ledger-options.mjs
|
||
node scripts/test-warehouse-drawer-table-overflow.mjs
|
||
npm run build
|
||
```
|
||
|
||
Expected: all scripts pass and build completes.
|
||
|
||
- [ ] **Step 3: Apply SQL patch to test database**
|
||
|
||
Run this only after confirming the target database is the intended test database:
|
||
|
||
```bash
|
||
mysql \
|
||
-h <测试库IP> \
|
||
-P <测试库端口> \
|
||
-u <用户名> \
|
||
-p \
|
||
--default-character-set=utf8mb4 \
|
||
jiaheng_erp < /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend/sql/add_special_warehouse_adjustment_patch.sql
|
||
```
|
||
|
||
Expected: no SQL error. Verify:
|
||
|
||
```sql
|
||
SHOW TABLES LIKE 'wh_special_adjustment%';
|
||
```
|
||
|
||
Expected:
|
||
|
||
```text
|
||
wh_special_adjustment
|
||
wh_special_adjustment_line
|
||
```
|
||
|
||
- [ ] **Step 4: Browser smoke check**
|
||
|
||
Start backend and frontend in the project’s normal local way, then open:
|
||
|
||
```text
|
||
http://127.0.0.1:5173/
|
||
```
|
||
|
||
Manual checks:
|
||
|
||
- 嘉恒仓库 6 大库的入库操作区都有“特殊入库”。
|
||
- 嘉恒仓库 6 大库的出库操作区都有“特殊出库”。
|
||
- 未填写说明时,不能添加/提交特殊调整明细。
|
||
- 原材料库特殊出库只能填写重量,提交后对应库存批次重量减少。
|
||
- 辅料库特殊入库只按数量增加,不写入重量。
|
||
- 成品库特殊出库按数量减少,重量作为辅助字段同步减少。
|
||
- 保存后打开当前库“流水”图标,能看到 `特殊入库` 或 `特殊出库` 对应流水,备注里包含完整说明。
|
||
- 盘库锁库状态下,特殊入库/特殊出库接口返回禁止操作提示。
|
||
|
||
- [ ] **Step 5: Final commit if smoke checks required small fixes**
|
||
|
||
If manual smoke checks required fixes:
|
||
|
||
```bash
|
||
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
|
||
git add backend frontend
|
||
git commit -m "fix: polish special warehouse adjustment flow"
|
||
```
|
||
|
||
If no fixes were needed, do not create an empty commit.
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
Spec coverage:
|
||
|
||
- 6 大库入库/出库均添加特殊操作:Task 6 Step 2 and Task 5 static test.
|
||
- 可直接操作仓库数据并精确到每行明细:Task 3 service updates `StockLot` and `StockBalance` per selected `lot_id`.
|
||
- 操作前必须写说明:Task 1 mandatory reason test, Task 3 `_clean_required_reason`, Task 6 front-end validation.
|
||
- 说明体现在各变更记录中:Task 3 writes reason into `SpecialWarehouseAdjustment.reason`, `SpecialWarehouseAdjustmentLine.line_reason`, and `InventoryTxn.remark`.
|
||
- 现有 6 大库不同口径:Task 3 `_measure_for_warehouse`, Task 5 `specialAdjustmentMeasureMode`.
|
||
- 不能只有图标没有业务逻辑:Task 3 backend service, Task 4 API, Task 6 frontend submit, Task 7 smoke checks.
|
||
|
||
Placeholder scan:
|
||
|
||
- No unresolved placeholder markers or deferred implementation phrases.
|
||
- The only conditional note is an execution-time SQLite foreign key fallback in Task 3 Step 5; it gives the exact alternate implementation path.
|
||
|
||
Type consistency:
|
||
|
||
- `SpecialWarehouseAdjustmentCreate`, `SpecialWarehouseAdjustmentLineCreate`, and `SpecialWarehouseAdjustmentRead` are defined before service use.
|
||
- Service function is consistently named `create_special_inventory_adjustment`.
|
||
- Frontend API endpoint is consistently `/inventory/special-adjustments`.
|
||
- Internal transaction types are consistently `SPECIAL_IN` and `SPECIAL_OUT`; user-facing labels remain Chinese.
|