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