ForgeFlow-ERP/docs/superpowers/plans/2026-05-31-scrap-warehouse.md
2026-06-14 21:05:49 +08:00

1346 lines
45 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.

# Scrap Warehouse 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:** Add a fifth "废料库" to 百华仓库 for production scrap, offcuts, defective goods, opening stock, outsourcing scrap return, and scrap sale outbound.
**Architecture:** Reuse the current warehouse ledger architecture: one new warehouse type `SCRAP`, one tab in `InventoryLedgerView.vue`, existing `wh_stock_lot` / `wh_stock_balance` / `wh_inventory_txn` tables, existing stocktake flow, and existing opening inventory import/export. Scrap inventory is weight-only and can reference existing material/product items rather than introducing a new scrap catalog.
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic schemas, MySQL-compatible SQL patches, Vue 3 Composition API, Vite, Node assert-based frontend checks, Python unittest backend checks.
---
## File Structure
All paths are relative to `/Users/souplearn/Gitlab/py/ForgeFlow-ERP`.
- Modify `backend/app/api/routes/inventory.py`
- Add `SCRAP` to warehouse labels and opening import sample rows.
- Add inbound business types `PRODUCTION_SCRAP_IN` and `OUTSOURCING_SCRAP_IN`.
- Add outbound business type `SCRAP_SALE_OUT`.
- Allow `SCRAP` warehouse to execute only scrap-specific operations.
- Treat `SCRAP` as weight-only.
- Use sale unit price for `SCRAP_SALE_OUT` transaction amount.
- Modify `backend/app/schemas/operations.py`
- Add new business type comments to inbound/outbound allowed value documentation.
- Extend `WarehouseOutboundCreate` with `sale_unit_price`, `buyer_name`, and `sale_remark`.
- Modify `backend/app/api/routes/master_data.py`
- Add `SCRAP` to `JIAHENG_WAREHOUSE_DEFINITIONS`.
- Update `_ensure_jiaheng_warehouses()` to include `SCRAP` when checking existing warehouse types.
- Modify `backend/app/services/stocktake.py`
- Add `SCRAP: 废料库` to stocktake sheet label mapping.
- Create `backend/sql/add_scrap_warehouse_patch.sql`
- Ensure existing deployments have active `WH-SCRAP-01` and default `SCR-E-01`.
- Create `backend/tests/test_scrap_warehouse_flow.py`
- Cover scrap opening template/import, production scrap inbound, outsourcing scrap inbound, sale outbound, validation blocks for wrong operations, and stocktake label/export.
- Modify `frontend/src/views/InventoryLedgerView.vue`
- Add `scrap` tab after `auxiliary`.
- Add warehouse type/label/title/tag/balances/operation matrix entries.
- Treat scrap as weight-only.
- Add sale-specific fields when active operation is `SCRAP_SALE_OUT`.
- Modify `frontend/src/utils/inventoryOperationOptions.js`
- Treat `scrap` / `SCRAP` as weight-only.
- For scrap inbound, return both material and product options.
- For scrap outbound, use current scrap stock balances.
- Modify `frontend/src/utils/dictionaries.js`
- Add Chinese labels for new inventory transaction and source document types.
- Modify `frontend/scripts/test-inventory-ledger-options.mjs`
- Add assertions for scrap inbound options, scrap outbound options, and source code checks for the new tab/operations.
---
### Task 1: Backend Tests For Scrap Warehouse Behavior
**Files:**
- Create: `backend/tests/test_scrap_warehouse_flow.py`
- [ ] **Step 1: Write the failing backend test file**
Create `backend/tests/test_scrap_warehouse_flow.py` with this content:
```python
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from io import BytesIO
from fastapi import HTTPException
from openpyxl import Workbook, load_workbook
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, StocktakeLine, WarehouseLocation # noqa: E402
from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402
class User:
id = 1
class Context:
user = User()
class ScrapWarehouseFlowTest(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.scrap_warehouse = Warehouse(
id=1,
warehouse_code="WH-SCRAP-01",
warehouse_name="废料库",
warehouse_type="SCRAP",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.raw_warehouse = Warehouse(
id=2,
warehouse_code="WH-RM-01",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.scrap_location = WarehouseLocation(
id=1,
warehouse_id=1,
location_code="SCR-E-01",
location_name="废料暂存位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.raw_location = WarehouseLocation(
id=2,
warehouse_id=2,
location_code="RM-A-01",
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="304不锈钢边角料",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("1.6"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.product_item = Item(
id=2,
item_code="产品需规00001",
item_name="冲压支架报废品",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("2.5"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("1.2"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all(
[
self.scrap_warehouse,
self.raw_warehouse,
self.scrap_location,
self.raw_location,
self.raw_item,
self.product_item,
]
)
self.db.commit()
def _opening_workbook(self, rows: list[list[object]]) -> bytes:
from app.api.routes.inventory import OPENING_INVENTORY_HEADERS
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "废料库期初导入"
worksheet.append(OPENING_INVENTORY_HEADERS)
for row in rows:
worksheet.append(row)
output = BytesIO()
workbook.save(output)
return output.getvalue()
def test_scrap_opening_template_exports_sample_rows(self) -> None:
from app.api.routes.inventory import build_opening_inventory_template
workbook = build_opening_inventory_template(self.db, "SCRAP")
output = BytesIO()
workbook.save(output)
loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True)
rows = list(loaded["废料库期初导入"].iter_rows(values_only=True))
headers = list(rows[0])
self.assertEqual(rows[1][headers.index("仓库类型")], "废料库")
self.assertEqual(rows[1][headers.index("仓库名称")], "废料库")
self.assertIn("示例-SCRAP-", rows[1][headers.index("批次号")])
self.assertGreater(rows[1][headers.index("重量(kg)")], 0)
def test_scrap_opening_import_is_weight_only(self) -> None:
from app.api.routes.inventory import import_opening_inventory_workbook
content = self._opening_workbook(
[[
"废料库",
"废料库",
"废料暂存位",
"原材料00001",
"304不锈钢边角料",
"OPEN-SCRAP-001",
99,
125,
1.6,
"初始化废料",
"期初废料",
]]
)
result = import_opening_inventory_workbook(self.db, "SCRAP", content, user_id=1)
lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "OPEN-SCRAP-001"))
balance = self.db.scalar(
select(StockBalance).where(
StockBalance.item_id == self.raw_item.id,
StockBalance.warehouse_id == self.scrap_warehouse.id,
)
)
self.assertIsNotNone(lot)
self.assertIsNotNone(balance)
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))
self.assertIsNotNone(txn)
self.assertEqual(result["warehouse_type"], "SCRAP")
self.assertEqual(result["imported"], 1)
self.assertEqual(float(lot.inbound_qty), 0)
self.assertEqual(float(lot.remaining_qty), 0)
self.assertEqual(float(lot.inbound_weight_kg), 125)
self.assertEqual(float(balance.qty_on_hand), 0)
self.assertEqual(float(balance.weight_on_hand_kg), 125)
self.assertEqual(txn.txn_type, "OPENING_IN")
self.assertEqual(float(txn.qty_change), 0)
self.assertEqual(float(txn.weight_change_kg), 125)
def test_production_scrap_inbound_creates_scrap_lot_balance_and_txn(self) -> None:
from app.api.routes.inventory import create_warehouse_inbound
row = create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="PRODUCTION_SCRAP_IN",
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
inbound_weight_kg=36.5,
unit_cost=1.6,
source_material_sub_batch_no="JH_110501000185_0001",
source_material_summary="LAC-51012170/71左右后纵梁后段 1序报废边角料",
remark="生产废料入库",
),
context=Context(),
db=self.db,
)
lot = self.db.get(StockLot, row.lot_id)
balance = self.db.scalar(
select(StockBalance).where(
StockBalance.item_id == self.raw_item.id,
StockBalance.warehouse_id == self.scrap_warehouse.id,
)
)
self.assertIsNotNone(lot)
self.assertIsNotNone(balance)
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))
self.assertIsNotNone(txn)
self.assertEqual(lot.lot_role, "PRODUCTION_SCRAP")
self.assertEqual(lot.source_doc_type, "PRODUCTION_SCRAP_IN")
self.assertEqual(lot.source_material_sub_batch_no, "JH_110501000185_0001")
self.assertEqual(float(balance.weight_on_hand_kg), 36.5)
self.assertEqual(txn.txn_type, "PRODUCTION_SCRAP_IN")
self.assertEqual(float(txn.amount), 58.40)
def test_outsourcing_scrap_inbound_accepts_product_item(self) -> None:
from app.api.routes.inventory import create_warehouse_inbound
row = create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="OUTSOURCING_SCRAP_IN",
item_id=self.product_item.id,
warehouse_id=self.scrap_warehouse.id,
inbound_weight_kg=12,
unit_cost=1.2,
provider_name="外协加工厂A",
remark="委外废料入库",
),
context=Context(),
db=self.db,
)
lot = self.db.get(StockLot, row.lot_id)
self.assertIsNotNone(lot)
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))
self.assertIsNotNone(txn)
self.assertEqual(lot.lot_role, "OUTSOURCING_SCRAP")
self.assertEqual(lot.source_doc_type, "OUTSOURCING_SCRAP_IN")
self.assertEqual(float(lot.inbound_qty), 0)
self.assertEqual(txn.txn_type, "OUTSOURCING_SCRAP_IN")
def test_scrap_sale_outbound_schema_preserves_sale_fields(self) -> None:
payload = WarehouseOutboundCreate(
biz_type="SCRAP_SALE_OUT",
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
outbound_weight_kg=40,
sale_unit_price=2.5,
buyer_name="废品回收商A",
sale_remark="按废不锈钢售卖",
)
self.assertEqual(float(payload.sale_unit_price), 2.5)
self.assertEqual(payload.buyer_name, "废品回收商A")
self.assertEqual(payload.sale_remark, "按废不锈钢售卖")
def test_scrap_sale_outbound_uses_sale_price_for_transaction_amount(self) -> None:
from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound
create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="PRODUCTION_SCRAP_IN",
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
inbound_weight_kg=100,
unit_cost=1.6,
),
context=Context(),
db=self.db,
)
row = create_warehouse_outbound(
WarehouseOutboundCreate(
biz_type="SCRAP_SALE_OUT",
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
outbound_weight_kg=40,
sale_unit_price=2.5,
buyer_name="废品回收商A",
sale_remark="按废不锈钢售卖",
),
context=Context(),
db=self.db,
)
txn = self.db.scalar(
select(InventoryTxn)
.where(InventoryTxn.txn_type == "SCRAP_SALE_OUT")
.order_by(InventoryTxn.id.desc())
.limit(1)
)
balance = self.db.scalar(
select(StockBalance).where(
StockBalance.item_id == self.raw_item.id,
StockBalance.warehouse_id == self.scrap_warehouse.id,
)
)
self.assertIsNotNone(txn)
self.assertIsNotNone(balance)
self.assertEqual(row.warehouse_type, "SCRAP")
self.assertEqual(float(balance.weight_on_hand_kg), 60)
self.assertEqual(float(txn.unit_cost), 2.5)
self.assertEqual(float(txn.amount), 100)
self.assertIn("废品回收商A", txn.remark)
def test_scrap_warehouse_rejects_non_scrap_operations(self) -> None:
from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound
with self.assertRaises(HTTPException) as inbound_exc:
create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="CUSTOMER_SUPPLIED",
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
inbound_weight_kg=5,
),
context=Context(),
db=self.db,
)
self.assertEqual(inbound_exc.exception.status_code, 400)
self.assertIn("废料库不允许执行该入库业务", inbound_exc.exception.detail)
with self.assertRaises(HTTPException) as outbound_exc:
create_warehouse_outbound(
WarehouseOutboundCreate(
biz_type="SCRAP_OUT",
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
outbound_weight_kg=5,
),
context=Context(),
db=self.db,
)
self.assertEqual(outbound_exc.exception.status_code, 400)
self.assertIn("废料库不允许执行该出库业务", outbound_exc.exception.detail)
def test_stocktake_export_uses_scrap_sheet_name(self) -> None:
from app.services.stocktake import build_stocktake_workbook, start_stocktake
self.db.add(
StockLot(
id=10,
lot_no="SCRAP-STOCKTAKE-001",
lot_role="PRODUCTION_SCRAP",
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
location_id=self.scrap_location.id,
source_doc_type="PRODUCTION_SCRAP_IN",
source_doc_id=10,
source_material_sub_batch_no="JH_110501000185_0001",
source_material_summary="盘库用废料",
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("66"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("66"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("1.6"),
quality_status="PASS",
status="AVAILABLE",
)
)
self.db.add(
StockBalance(
item_id=self.raw_item.id,
warehouse_id=self.scrap_warehouse.id,
location_id=self.scrap_location.id,
qty_on_hand=Decimal("0"),
weight_on_hand_kg=Decimal("66"),
qty_available=Decimal("0"),
weight_available_kg=Decimal("66"),
qty_allocated=Decimal("0"),
weight_allocated_kg=Decimal("0"),
avg_unit_cost=Decimal("1.6"),
)
)
self.db.commit()
stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="废料库盘库")
workbook = build_stocktake_workbook(self.db, stocktake.id)
output = BytesIO()
workbook.save(output)
loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True)
self.assertIn("废料库", loaded.sheetnames)
rows = list(loaded["废料库"].iter_rows(values_only=True))
headers = list(rows[0])
self.assertEqual(rows[1][headers.index("仓库")], "废料库")
self.assertEqual(rows[1][headers.index("库位")], "废料暂存位")
self.assertEqual(rows[1][headers.index("物料编码")], "原材料00001")
self.assertEqual(rows[1][headers.index("批次号")], "SCRAP-STOCKTAKE-001")
self.assertEqual(rows[1][headers.index("来源材料分批次号")], "JH_110501000185_0001")
self.assertEqual(rows[1][headers.index("系统数量")], 0)
self.assertEqual(rows[1][headers.index("系统重量(kg)")], 66)
self.assertEqual(rows[1][headers.index("系统单价")], 1.6)
def test_scrap_stocktake_keeps_product_scrap_weight_only_after_confirm(self) -> None:
from app.services.stocktake import build_stocktake_workbook, confirm_stocktake, import_stocktake_workbook, start_stocktake
self.db.add(
StockLot(
id=11,
lot_no="SCRAP-PRODUCT-001",
lot_role="OUTSOURCING_SCRAP",
item_id=self.product_item.id,
warehouse_id=self.scrap_warehouse.id,
location_id=self.scrap_location.id,
source_doc_type="OUTSOURCING_SCRAP_IN",
source_doc_id=11,
source_material_summary="产品报废件盘库",
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("30"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("30"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("1.2"),
quality_status="PASS",
status="AVAILABLE",
)
)
self.db.add(
StockBalance(
item_id=self.product_item.id,
warehouse_id=self.scrap_warehouse.id,
location_id=self.scrap_location.id,
qty_on_hand=Decimal("0"),
weight_on_hand_kg=Decimal("30"),
qty_available=Decimal("0"),
weight_available_kg=Decimal("30"),
qty_allocated=Decimal("0"),
weight_allocated_kg=Decimal("0"),
avg_unit_cost=Decimal("1.2"),
)
)
self.db.commit()
stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="产品废料盘库")
workbook = build_stocktake_workbook(self.db, stocktake.id)
output = BytesIO()
workbook.save(output)
loaded = load_workbook(BytesIO(output.getvalue()))
worksheet = loaded["废料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("盘点数量") + 1, value=88)
worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1, value=45)
imported_output = BytesIO()
loaded.save(imported_output)
import_stocktake_workbook(self.db, stocktake.id, imported_output.getvalue(), user_id=1)
confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="确认产品废料盘库")
self.assertEqual(confirmed.status, "CONFIRMED")
lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "SCRAP-PRODUCT-001"))
balance = self.db.scalar(
select(StockBalance).where(
StockBalance.item_id == self.product_item.id,
StockBalance.warehouse_id == self.scrap_warehouse.id,
)
)
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
self.assertIsNotNone(lot)
self.assertIsNotNone(balance)
self.assertIsNotNone(line)
self.assertEqual(float(line.counted_qty), 0)
self.assertEqual(float(line.diff_qty), 0)
self.assertEqual(float(lot.remaining_qty), 0)
self.assertEqual(float(lot.remaining_weight_kg), 45)
self.assertEqual(float(balance.qty_on_hand), 0)
self.assertEqual(float(balance.qty_available), 0)
self.assertEqual(float(balance.weight_on_hand_kg), 45)
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run the new test and verify it fails before implementation**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest tests.test_scrap_warehouse_flow -v
```
Expected before implementation: FAIL with errors mentioning unsupported `SCRAP`, missing `sale_unit_price`, unsupported `PRODUCTION_SCRAP_IN`, unsupported `OUTSOURCING_SCRAP_IN`, unsupported `SCRAP_SALE_OUT`, or missing `废料库` sheet label.
---
### Task 2: Backend Scrap Warehouse Types, Validation, And Ledger Logic
**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/services/stocktake.py`
- [ ] **Step 1: Extend outbound schema with sale fields**
In `backend/app/schemas/operations.py`, change `WarehouseOutboundCreate` to:
```python
class WarehouseOutboundCreate(BaseModel):
biz_type: str
item_id: int
warehouse_id: int
location_id: int | None = None
outbound_weight_kg: float
target_doc_type: str | None = None
target_doc_id: int | None = None
target_doc_line_id: int | None = None
target_material_sub_batch_no: str | None = None
sale_unit_price: float | None = None
buyer_name: str | None = None
sale_remark: str | None = None
remark: str | None = None
```
Also update the allowed business type comments near the inbound/outbound documentation:
```python
"PRODUCTION_SCRAP_IN", # 生产废料入库(废料库)
"OUTSOURCING_SCRAP_IN", # 委外废料入库(废料库)
```
```python
"SCRAP_SALE_OUT", # 售卖出库(废料库)
```
- [ ] **Step 2: Add `SCRAP` to warehouse labels and opening template samples**
In `backend/app/api/routes/inventory.py`, update `WAREHOUSE_TYPE_LABELS`:
```python
WAREHOUSE_TYPE_LABELS = {
"RAW": "原材料库",
"SEMI": "半成品库",
"FINISHED": "成品库",
"AUX": "辅料库",
"SCRAP": "废料库",
}
```
Add this row group to `OPENING_INVENTORY_SAMPLE_ROWS`:
```python
"SCRAP": [
["废料库", "废料库", "废料暂存位", "示例原材料001", "304不锈钢边角料", "示例-SCRAP-0001", 0, 120, 1.6, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
["废料库", "废料库", "废料暂存位", "示例原材料002", "冲压报废件", "示例-SCRAP-0002", 0, 80, 1.2, "仓库初始化清点", "示例行,导入时会自动跳过,请替换为真实数据"],
],
```
- [ ] **Step 3: Treat scrap opening import as weight-only**
In `_create_opening_inventory_row()` or the nearby opening import row handling in `backend/app/api/routes/inventory.py`, change the weight-only condition from:
```python
raw_weight_only = warehouse_type in {"RAW", "AUX"} or item.item_type == "RAW_MATERIAL"
```
to:
```python
raw_weight_only = warehouse_type in {"RAW", "AUX", "SCRAP"} or item.item_type == "RAW_MATERIAL"
```
- [ ] **Step 4: Add scrap inbound and outbound business maps**
In `_INBOUND_BIZ_MAP`, add:
```python
"PRODUCTION_SCRAP_IN": ("PRODUCTION_SCRAP", "PRODUCTION_SCRAP_IN", "PRODUCTION_SCRAP_IN", None),
"OUTSOURCING_SCRAP_IN": ("OUTSOURCING_SCRAP", "OUTSOURCING_SCRAP_IN", "OUTSOURCING_SCRAP_IN", None),
```
In `_ALLOWED_INBOUND_BY_WAREHOUSE_TYPE`, add:
```python
"SCRAP": {"PRODUCTION_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN"},
```
In `_OUTBOUND_BIZ_MAP`, add:
```python
"SCRAP_SALE_OUT": ("SCRAP_SALE_OUT", "SCRAP_SALE_OUT"),
```
In `_ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE`, add:
```python
"SCRAP": {"SCRAP_SALE_OUT"},
```
- [ ] **Step 5: Add scrap labels and item validation**
In `_warehouse_type_label()`, add:
```python
"SCRAP": "废料库",
```
In `_validate_warehouse_item()`, add this branch between the RAW/AUX branch and the SEMI/FINISHED branch:
```python
if warehouse_type == "SCRAP" and item.item_type not in {"RAW_MATERIAL", "PRODUCT", "FINISHED_GOOD"}:
raise HTTPException(status_code=400, detail="废料库只能选择原材料、辅料、产品或报废品类物料")
```
- [ ] **Step 6: Generate clear scrap lot numbers**
In the lot number generation block in `create_warehouse_inbound()`, add a scrap branch before the final `else`:
```python
elif biz_type in ("PRODUCTION_SCRAP_IN", "OUTSOURCING_SCRAP_IN"):
tag = "SCFL" if biz_type == "PRODUCTION_SCRAP_IN" else "WWFL"
lot_no = _build_surplus_lot_no(db, item, warehouse, tag)
```
This keeps the current lot-number helper and creates readable lot numbers with production scrap and outsourcing scrap tags.
- [ ] **Step 7: Use sale unit price for scrap sale outbound transaction amount**
In `create_warehouse_outbound()`, after `txn_type, source_doc_type = _OUTBOUND_BIZ_MAP[biz_type]`, add:
```python
sale_unit_price = to_decimal(payload.sale_unit_price, "0.0001") if payload.sale_unit_price is not None else None
if biz_type == "SCRAP_SALE_OUT" and (sale_unit_price is None or sale_unit_price <= 0):
raise HTTPException(status_code=400, detail="废料售卖出库必须填写大于0的售卖单价")
```
Inside the lot deduction loop, replace:
```python
unit_cost = to_decimal(available_lot.unit_cost, "0.0001")
```
with:
```python
lot_unit_cost = to_decimal(available_lot.unit_cost, "0.0001")
txn_unit_cost = sale_unit_price if biz_type == "SCRAP_SALE_OUT" and sale_unit_price is not None else lot_unit_cost
```
Use `lot_unit_cost` in `upsert_stock_balance()`:
```python
unit_cost=lot_unit_cost,
```
Build the remark before `create_inventory_txn()` like this:
```python
remark_parts = [payload.remark or biz_type]
if payload.target_material_sub_batch_no:
remark_parts.append(f"目标/来源分批号 {payload.target_material_sub_batch_no}")
if biz_type == "SCRAP_SALE_OUT":
if payload.buyer_name:
remark_parts.append(f"购买方 {payload.buyer_name}")
if payload.sale_remark:
remark_parts.append(payload.sale_remark)
remark = "".join(part for part in remark_parts if part)
```
Use `txn_unit_cost` in `create_inventory_txn()`:
```python
unit_cost=txn_unit_cost,
```
Keep `amount_basis="WEIGHT"` unchanged.
- [ ] **Step 8: Add scrap to stocktake label mapping**
In `backend/app/services/stocktake.py`, update `stocktake_warehouse_type_label()`:
```python
def stocktake_warehouse_type_label(value: str | None) -> str:
return {
"RAW": "原材料库",
"SEMI": "半成品库",
"FINISHED": "成品库",
"AUX": "辅料库",
"SCRAP": "废料库",
}.get(str(value or "").upper(), str(value or "未知仓库"))
```
- [ ] **Step 9: Run backend scrap tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest tests.test_scrap_warehouse_flow -v
```
Expected: all tests in `ScrapWarehouseFlowTest` pass.
- [ ] **Step 10: Run related backend regression tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest tests.test_opening_inventory_import tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm -v
```
Expected: all existing opening inventory and stocktake tests pass.
---
### Task 3: Default Warehouse Seed And Deployment SQL
**Files:**
- Modify: `backend/app/api/routes/master_data.py`
- Create: `backend/sql/add_scrap_warehouse_patch.sql`
- [ ] **Step 1: Add scrap warehouse to runtime default warehouse definitions**
In `backend/app/api/routes/master_data.py`, append this object to `JIAHENG_WAREHOUSE_DEFINITIONS`:
```python
{
"warehouse_code": "WH-SCRAP-01",
"warehouse_name": "废料库",
"warehouse_type": "SCRAP",
"location_code": "SCR-E-01",
"location_name": "废料暂存位",
"zone_name": "E区",
"remark": "生产废料、边角料、委外废料、报废品等废料库存",
},
```
Change `_ensure_jiaheng_warehouses()` existing type query from:
```python
select(Warehouse).where(Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX"]))
```
to:
```python
select(Warehouse).where(Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX", "SCRAP"]))
```
- [ ] **Step 2: Create the SQL patch**
Create `backend/sql/add_scrap_warehouse_patch.sql`:
```sql
INSERT INTO wh_warehouse (warehouse_code, warehouse_name, warehouse_type, manager_employee_id, status, remark)
SELECT 'WH-SCRAP-01', '废料库', 'SCRAP', NULL, 'ACTIVE', '生产废料、边角料、委外废料、报废品等废料库存'
WHERE NOT EXISTS (SELECT 1 FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01');
UPDATE wh_warehouse
SET warehouse_name = '废料库',
warehouse_type = 'SCRAP',
status = 'ACTIVE',
remark = '生产废料、边角料、委外废料、报废品等废料库存'
WHERE warehouse_code = 'WH-SCRAP-01';
INSERT INTO wh_warehouse_location (
warehouse_id,
location_code,
location_name,
zone_name,
is_default,
is_locked,
status,
remark
)
SELECT
(SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01'),
'SCR-E-01',
'废料暂存位',
'E区',
1,
0,
'ACTIVE',
'废料库默认库位'
WHERE NOT EXISTS (
SELECT 1
FROM wh_warehouse_location
WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01')
AND location_code = 'SCR-E-01'
);
UPDATE wh_warehouse_location
SET location_name = '废料暂存位',
zone_name = 'E区',
is_default = 1,
is_locked = 0,
status = 'ACTIVE',
remark = '废料库默认库位'
WHERE warehouse_id = (SELECT id FROM wh_warehouse WHERE warehouse_code = 'WH-SCRAP-01')
AND location_code = 'SCR-E-01';
```
- [ ] **Step 3: Verify SQL text is present and deterministic**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "WH-SCRAP-01|废料库|SCR-E-01" backend/sql/add_scrap_warehouse_patch.sql backend/app/api/routes/master_data.py
```
Expected: output contains the new warehouse code, label, type, location code, and definition.
---
### Task 4: Frontend Option Builder Tests And Logic
**Files:**
- Modify: `frontend/src/utils/inventoryOperationOptions.js`
- Modify: `frontend/scripts/test-inventory-ledger-options.mjs`
- [ ] **Step 1: Add failing frontend option tests for scrap warehouse**
In `frontend/scripts/test-inventory-ledger-options.mjs`, after the auxiliary option assertions, add:
```javascript
const scrapInboundOptions = buildGenericItemOptions({
direction: "in",
activeInventoryTab: "scrap",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [],
materials: [{ item_id: 801, item_code: "原材料00001", item_name: "304边角料" }],
products: [{ item_id: 901, item_code: "产品需规00001", item_name: "报废冲压件" }]
});
assert.deepEqual(scrapInboundOptions.map((item) => item.item_id), [801, 901]);
const scrapOutboundOptions = buildGenericItemOptions({
direction: "out",
activeInventoryTab: "scrap",
activeWarehouseType: "SCRAP",
warehouseId: 11,
balances: [
{
item_id: 801,
item_code: "原材料00001",
item_name: "304边角料",
warehouse_id: 11,
warehouse_type: "SCRAP",
qty_available: 0,
weight_available_kg: 18
},
{
item_id: 802,
item_code: "原材料00002",
item_name: "废铁",
warehouse_id: 11,
warehouse_type: "SCRAP",
qty_available: 99,
weight_available_kg: 0
}
],
materials: [],
products: []
});
assert.equal(scrapOutboundOptions.length, 1);
assert.equal(scrapOutboundOptions[0].item_id, 801);
```
Add source checks near the bottom:
```javascript
assert.match(inventoryLedgerSource, /key: "scrap"/);
assert.match(inventoryLedgerSource, /label: "废料库"/);
assert.match(inventoryLedgerSource, /PRODUCTION_SCRAP_IN/);
assert.match(inventoryLedgerSource, /OUTSOURCING_SCRAP_IN/);
assert.match(inventoryLedgerSource, /SCRAP_SALE_OUT/);
```
- [ ] **Step 2: Run frontend option test and verify it fails before logic changes**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-options.mjs
```
Expected before implementation: FAIL because `scrap` inbound does not return both materials and products, `SCRAP` is not weight-only, or the component source does not contain the new tab/operations.
- [ ] **Step 3: Update option builder for scrap**
In `frontend/src/utils/inventoryOperationOptions.js`, change:
```javascript
function isWeightOnlyTab(tab) {
return ["raw", "auxiliary"].includes(String(tab || ""));
}
```
to:
```javascript
function isWeightOnlyTab(tab) {
return ["raw", "auxiliary", "scrap"].includes(String(tab || ""));
}
```
Change:
```javascript
const weightOnly = isWeightOnlyTab(activeInventoryTab) || ["RAW", "AUX"].includes(normalizedWarehouseType);
```
to:
```javascript
const weightOnly = isWeightOnlyTab(activeInventoryTab) || ["RAW", "AUX", "SCRAP"].includes(normalizedWarehouseType);
```
Change inbound option selection:
```javascript
if (["raw", "auxiliary"].includes(activeInventoryTab)) {
return materials;
}
return products;
```
to:
```javascript
if (String(activeInventoryTab || "") === "scrap") {
return [...materials, ...products];
}
if (["raw", "auxiliary"].includes(activeInventoryTab)) {
return materials;
}
return products;
```
- [ ] **Step 4: Re-run frontend option test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-options.mjs
```
Expected at this point: scrap option assertions pass, source checks still fail until `InventoryLedgerView.vue` is updated in Task 5.
---
### Task 5: Frontend Scrap Tab, Operations, And Sale Fields
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/utils/dictionaries.js`
- [ ] **Step 1: Add scrap tab constants and computed balances**
In `frontend/src/views/InventoryLedgerView.vue`, update `WAREHOUSE_TYPE_BY_TAB`:
```javascript
const WAREHOUSE_TYPE_BY_TAB = {
raw: "RAW",
semi: "SEMI",
finished: "FINISHED",
auxiliary: "AUX",
scrap: "SCRAP"
};
```
Update `WAREHOUSE_LABEL_BY_TAB`:
```javascript
const WAREHOUSE_LABEL_BY_TAB = {
raw: "原材料库",
semi: "半成品库",
finished: "成品库",
auxiliary: "辅料库",
scrap: "废料库"
};
```
Add scrap balances after `auxiliaryBalances`:
```javascript
const scrapBalances = computed(() => balances.value.filter((item) => normalizeWarehouseType(item) === "SCRAP"));
```
- [ ] **Step 2: Add fifth inventory tab**
In `inventoryTabs`, append:
```javascript
{
key: "scrap",
label: "废料库",
count: filteredScrapCount.value,
iconClass: "inventory-tab-icon-scrap",
iconPaths: ["M7 7.5 10 4h4l3 3.5", "M17 7.5h-3.2l1.6-2.2", "M17.5 11.5l2 3.5-2 3.5h-4", "M13.5 18.5l1.7-2.6", "M8.5 18.5h-4l-2-3.5 2-3.5", "M4.5 11.5 6.2 14"]
}
```
Add this after `filteredAuxiliaryCount`:
```javascript
const filteredScrapCount = computed(() => scrapBalances.value.length);
```
- [ ] **Step 3: Add labels, title, active balance mapping, and weight-only logic**
In `activeInventoryTitle`, add:
```javascript
scrap: "废料库存与售卖出库"
```
In `activeInventoryTag`, add:
```javascript
scrap: "废料库"
```
Change:
```javascript
const activeWeightOnly = computed(() => ["raw", "auxiliary"].includes(activeInventoryTab.value));
const selectedWeightOnly = computed(() => ["RAW", "AUX"].includes(selectedWarehouseType.value));
```
to:
```javascript
const activeWeightOnly = computed(() => ["raw", "auxiliary", "scrap"].includes(activeInventoryTab.value));
const selectedWeightOnly = computed(() => ["RAW", "AUX", "SCRAP"].includes(selectedWarehouseType.value));
```
In `activeBalances`, add:
```javascript
scrap: scrapBalances.value
```
- [ ] **Step 4: Add scrap operation matrix**
In `operationMatrix`, append:
```javascript
scrap: {
inbound: [
makeOperation("scrap-production-in", "in", "PRODUCTION_SCRAP_IN", "生产废料入库", "生产过程中产生的废料、边角料、报废品入废料库。"),
makeOperation("scrap-opening-in", "in", "OPENING", "期初入库", "仓库初始化时导入的初始废料。"),
makeOperation("scrap-outsourcing-in", "in", "OUTSOURCING_SCRAP_IN", "委外废料入库", "委外加工返回的废料、边角料、报废品入废料库。")
],
outbound: [
makeOperation("scrap-sale-out", "out", "SCRAP_SALE_OUT", "售卖出库", "废料售卖给回收商或客户后出库。")
]
}
```
- [ ] **Step 5: Add sale-specific fields to the generic drawer**
In the generic drawer template, below the outbound weight input and before remark, add:
```vue
<label v-if="activeOperation?.bizType === 'SCRAP_SALE_OUT'">
<span>购买方</span>
<input v-model.trim="genericForm.buyer_name" type="text" placeholder="例如废品回收商A" />
</label>
<label v-if="activeOperation?.bizType === 'SCRAP_SALE_OUT'">
<span>售卖单价(元/kg)</span>
<input v-model.number="genericForm.sale_unit_price" type="number" min="0" step="0.0001" required />
</label>
<label v-if="activeOperation?.bizType === 'SCRAP_SALE_OUT'">
<span>预计售卖金额</span>
<input :value="formatAmount(scrapSaleAmount)" type="text" disabled />
</label>
```
Add computed amount:
```javascript
const scrapSaleAmount = computed(() => {
const weight = Number(genericForm.weight_kg || 0);
const price = Number(genericForm.sale_unit_price || 0);
return Number.isFinite(weight * price) ? weight * price : 0;
});
```
- [ ] **Step 6: Extend generic form state and submit payload**
In `buildEmptyGenericForm()`, add:
```javascript
sale_unit_price: "",
buyer_name: "",
sale_remark: ""
```
In `resetGenericForm()` or the function that clears generic form values, clear the same fields:
```javascript
genericForm.sale_unit_price = "";
genericForm.buyer_name = "";
genericForm.sale_remark = "";
```
In `submitGenericOperation()`, in the outbound payload sent to `/inventory/outbound`, add:
```javascript
sale_unit_price: activeOperation.value?.bizType === "SCRAP_SALE_OUT" ? Number(genericForm.sale_unit_price || 0) : null,
buyer_name: activeOperation.value?.bizType === "SCRAP_SALE_OUT" ? genericForm.buyer_name || "" : null,
sale_remark: activeOperation.value?.bizType === "SCRAP_SALE_OUT" ? genericForm.remark || "" : null,
```
Before posting outbound, add frontend validation:
```javascript
if (activeOperation.value?.bizType === "SCRAP_SALE_OUT" && Number(genericForm.sale_unit_price || 0) <= 0) {
errorMessage.value = "废料售卖出库必须填写大于0的售卖单价";
return;
}
```
- [ ] **Step 7: Add dictionary labels**
In `frontend/src/utils/dictionaries.js`, update `INVENTORY_TXN_TYPE_LABELS`:
```javascript
PRODUCTION_SCRAP_IN: "生产废料入库",
OUTSOURCING_SCRAP_IN: "委外废料入库",
SCRAP_SALE_OUT: "废料售卖出库",
```
Update `SOURCE_DOC_TYPE_LABELS`:
```javascript
PRODUCTION_SCRAP_IN: "生产废料入库",
OUTSOURCING_SCRAP_IN: "委外废料入库",
SCRAP_SALE_OUT: "废料售卖出库",
```
- [ ] **Step 8: Run frontend option test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-options.mjs
```
Expected: `inventory ledger option tests passed`.
- [ ] **Step 9: Run frontend build**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected: Vite build completes without Vue compile errors.
---
### Task 6: End-To-End Verification Checklist
**Files:**
- No new files.
- Verify changed files from Tasks 1 through 5.
- [ ] **Step 1: Backend full targeted verification**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest tests.test_scrap_warehouse_flow tests.test_opening_inventory_import tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm -v
```
Expected: all tests pass.
- [ ] **Step 2: Frontend targeted verification**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-options.mjs
node scripts/test-stocktake-workflow.mjs
npm run build
```
Expected:
- `inventory ledger option tests passed`
- `stocktake workflow tests passed`
- `npm run build` exits successfully
- [ ] **Step 3: Static search verification**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "SCRAP|废料库|PRODUCTION_SCRAP_IN|OUTSOURCING_SCRAP_IN|SCRAP_SALE_OUT" backend/app frontend/src backend/sql
```
Expected:
- Backend labels include `SCRAP: 废料库`.
- Backend allowed inbound map includes `PRODUCTION_SCRAP_IN`, `OPENING`, `OUTSOURCING_SCRAP_IN` for `SCRAP`.
- Backend allowed outbound map includes only `SCRAP_SALE_OUT` for `SCRAP`.
- Frontend tab and operation matrix include `废料库`.
- SQL patch includes `WH-SCRAP-01` and `SCR-E-01`.
- [ ] **Step 4: Manual browser verification**
Start the app using the projects normal local commands:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m uvicorn app.main:app --reload
```
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run dev
```
Expected manual checks:
- 百华仓库 shows five warehouse tabs: 原材料库、半成品库、成品库、辅料库、废料库.
- 废料库 tab uses the same card style and selected style as the other four warehouses.
- 废料库 list is weight-only and does not show quantity columns.
- 入库 bar shows 生产废料入库、期初入库、委外废料入库.
- 出库 bar shows 售卖出库.
- 期初入库 exports `废料库期初导入模版.xlsx` with two sample rows.
- 生产废料入库 creates a new scrap lot and stock balance.
- 售卖出库 only offers items that have available scrap stock.
- 售卖出库 requires sale unit price and records buyer information in the inventory transaction remark.
- 盘库 can lock/export/import/confirm/unlock 废料库.
---
## Self-Review
- Spec coverage: The plan covers the fifth warehouse tab, three inbound operations, one outbound operation, backend validation, stock ledger writes, opening import/export, stocktake support, runtime default warehouse creation, SQL patch, Chinese labels, and targeted tests.
- Placeholder scan: No placeholder implementation steps remain. Every task names concrete files, snippets, commands, and expected outcomes.
- Type consistency: The plan uses `SCRAP`, `PRODUCTION_SCRAP_IN`, `OUTSOURCING_SCRAP_IN`, `SCRAP_SALE_OUT`, `sale_unit_price`, `buyer_name`, and `sale_remark` consistently across schemas, backend routes, frontend payloads, and tests.
- Scope check: This is one coherent warehouse-ledger extension. It does not add an independent scrap catalog, independent finance module, or unrelated warehouse UI refactor.