# 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 ``` 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 project’s 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.