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

45 KiB
Raw Blame History

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:

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:

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:

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:

    "PRODUCTION_SCRAP_IN",  # 生产废料入库(废料库)
    "OUTSOURCING_SCRAP_IN", # 委外废料入库(废料库)
    "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:

WAREHOUSE_TYPE_LABELS = {
    "RAW": "原材料库",
    "SEMI": "半成品库",
    "FINISHED": "成品库",
    "AUX": "辅料库",
    "SCRAP": "废料库",
}

Add this row group to OPENING_INVENTORY_SAMPLE_ROWS:

    "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:

raw_weight_only = warehouse_type in {"RAW", "AUX"} or item.item_type == "RAW_MATERIAL"

to:

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:

    "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:

    "SCRAP": {"PRODUCTION_SCRAP_IN", "OPENING", "OUTSOURCING_SCRAP_IN"},

In _OUTBOUND_BIZ_MAP, add:

    "SCRAP_SALE_OUT":     ("SCRAP_SALE_OUT",    "SCRAP_SALE_OUT"),

In _ALLOWED_OUTBOUND_BY_WAREHOUSE_TYPE, add:

    "SCRAP": {"SCRAP_SALE_OUT"},
  • Step 5: Add scrap labels and item validation

In _warehouse_type_label(), add:

        "SCRAP": "废料库",

In _validate_warehouse_item(), add this branch between the RAW/AUX branch and the SEMI/FINISHED branch:

    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:

        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:

    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:

        unit_cost = to_decimal(available_lot.unit_cost, "0.0001")

with:

        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():

            unit_cost=lot_unit_cost,

Build the remark before create_inventory_txn() like this:

        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():

            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():

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:

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:

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:

    {
        "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:

select(Warehouse).where(Warehouse.warehouse_type.in_(["RAW", "SEMI", "FINISHED", "AUX"]))

to:

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:

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:

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:

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:

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:

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:

function isWeightOnlyTab(tab) {
  return ["raw", "auxiliary"].includes(String(tab || ""));
}

to:

function isWeightOnlyTab(tab) {
  return ["raw", "auxiliary", "scrap"].includes(String(tab || ""));
}

Change:

const weightOnly = isWeightOnlyTab(activeInventoryTab) || ["RAW", "AUX"].includes(normalizedWarehouseType);

to:

const weightOnly = isWeightOnlyTab(activeInventoryTab) || ["RAW", "AUX", "SCRAP"].includes(normalizedWarehouseType);

Change inbound option selection:

  if (["raw", "auxiliary"].includes(activeInventoryTab)) {
    return materials;
  }
  return products;

to:

  if (String(activeInventoryTab || "") === "scrap") {
    return [...materials, ...products];
  }
  if (["raw", "auxiliary"].includes(activeInventoryTab)) {
    return materials;
  }
  return products;
  • Step 4: Re-run frontend option test

Run:

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:

const WAREHOUSE_TYPE_BY_TAB = {
  raw: "RAW",
  semi: "SEMI",
  finished: "FINISHED",
  auxiliary: "AUX",
  scrap: "SCRAP"
};

Update WAREHOUSE_LABEL_BY_TAB:

const WAREHOUSE_LABEL_BY_TAB = {
  raw: "原材料库",
  semi: "半成品库",
  finished: "成品库",
  auxiliary: "辅料库",
  scrap: "废料库"
};

Add scrap balances after auxiliaryBalances:

const scrapBalances = computed(() => balances.value.filter((item) => normalizeWarehouseType(item) === "SCRAP"));
  • Step 2: Add fifth inventory tab

In inventoryTabs, append:

  {
    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:

const filteredScrapCount = computed(() => scrapBalances.value.length);
  • Step 3: Add labels, title, active balance mapping, and weight-only logic

In activeInventoryTitle, add:

    scrap: "废料库存与售卖出库"

In activeInventoryTag, add:

    scrap: "废料库"

Change:

const activeWeightOnly = computed(() => ["raw", "auxiliary"].includes(activeInventoryTab.value));
const selectedWeightOnly = computed(() => ["RAW", "AUX"].includes(selectedWarehouseType.value));

to:

const activeWeightOnly = computed(() => ["raw", "auxiliary", "scrap"].includes(activeInventoryTab.value));
const selectedWeightOnly = computed(() => ["RAW", "AUX", "SCRAP"].includes(selectedWarehouseType.value));

In activeBalances, add:

    scrap: scrapBalances.value
  • Step 4: Add scrap operation matrix

In operationMatrix, append:

  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:

            <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:

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:

    sale_unit_price: "",
    buyer_name: "",
    sale_remark: ""

In resetGenericForm() or the function that clears generic form values, clear the same fields:

  genericForm.sale_unit_price = "";
  genericForm.buyer_name = "";
  genericForm.sale_remark = "";

In submitGenericOperation(), in the outbound payload sent to /inventory/outbound, add:

        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:

    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:

  PRODUCTION_SCRAP_IN: "生产废料入库",
  OUTSOURCING_SCRAP_IN: "委外废料入库",
  SCRAP_SALE_OUT: "废料售卖出库",

Update SOURCE_DOC_TYPE_LABELS:

  PRODUCTION_SCRAP_IN: "生产废料入库",
  OUTSOURCING_SCRAP_IN: "委外废料入库",
  SCRAP_SALE_OUT: "废料售卖出库",
  • Step 8: Run frontend option test

Run:

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:

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:

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:

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:

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:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m uvicorn app.main:app --reload
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.