# Delivery Entry Unification 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:** Make `百华仓库 -> 成品库 -> 销售出库` the only place that creates delivery outbound records, while `发货与售后 -> 发货管理` becomes a read-only delivery ledger with traceability. **Architecture:** Keep the existing `/sales/deliveries` API as the single write path for delivery creation. Extend the warehouse sales-out drawer to support both sales-order delivery and direct customer delivery, and remove the create path from the delivery management page. Preserve traceability through existing `DeliveryItem.lot_id -> StockLot.source_material_lot_id/source_material_sub_batch_no/source_material_summary` fields. **Tech Stack:** FastAPI + SQLAlchemy backend, Vue 3 SFC frontend, Node static regression scripts, pytest backend tests, Vite build. --- ## File Structure - Modify: `backend/app/api/routes/sales.py` - Enforce that delivery creation uses a `FINISHED` warehouse. - Keep support for direct customer delivery with `sales_order_id = null` and `sales_order_item_id = null`. - Modify: `backend/tests/test_sales_order_delivery_trace.py` - Add regression coverage for direct customer delivery and non-finished warehouse rejection. - Create: `frontend/scripts/test-delivery-entry-unification.mjs` - Static regression checks for the unified entry contract. - Modify: `frontend/src/views/InventoryLedgerView.vue` - Add delivery mode selection to the成品库销售出库 drawer. - Allow direct customer delivery. - Show source material trace fields in the pre-submit allocation table. - Modify: `frontend/src/views/DeliveryManagementView.vue` - Remove create delivery UI and write-side script. - Rename page to发货台账 and keep query/detail trace UI only. - No DB schema changes expected. --- ### Task 1: Backend Delivery Contract **Files:** - Modify: `backend/tests/test_sales_order_delivery_trace.py` - Modify: `backend/app/api/routes/sales.py` - [ ] **Step 1: Add failing backend tests for direct delivery and finished-warehouse validation** Append the following two tests inside `SalesOrderDeliveryTraceTest` before the `if __name__ == "__main__":` block in `backend/tests/test_sales_order_delivery_trace.py`: ```python def test_direct_customer_delivery_without_sales_order_preserves_trace_and_inventory(self) -> None: delivery_result = create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=self.warehouse.id, consignee_name=self.customer.contact_name, consignee_phone=self.customer.contact_phone, delivery_address=self.customer.address, waybill_no="YD-DIRECT-001", freight_amount=18, items=[ DeliveryItemCreate( sales_order_item_id=None, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=3, delivery_weight_kg=6, unit_price=0, ) ], ), context=self._auth_context(), db=self.db, ) delivery = self.db.get(Delivery, delivery_result.delivery_id) delivery_item = self.db.scalar(select(DeliveryItem).where(DeliveryItem.delivery_id == delivery.id)) trace_row = self.db.execute(get_delivery_items_query(limit=10, delivery_id=delivery.id)).mappings().first() stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id)) sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) self.assertIsNone(delivery.sales_order_id) self.assertEqual(delivery.customer_id, self.customer.id) self.assertIsNone(delivery_item.sales_order_item_id) self.assertEqual(trace_row["source_material_lot_no"], "RML-001") self.assertEqual(trace_row["source_material_code"], "MAT-001") self.assertEqual(trace_row["source_material_name"], "冷轧钢板") self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001") self.assertEqual(trace_row["source_material_summary"], "RML-001 / 冷轧钢板 / 40kg / ¥200") self.assertEqual(float(stock_balance.qty_on_hand), 17) self.assertEqual(float(stock_balance.qty_available), 17) self.assertEqual(sales_out_txn.source_doc_type, "DELIVERY") self.assertEqual(sales_out_txn.source_doc_id, delivery.id) def test_sales_delivery_rejects_non_finished_warehouse(self) -> None: raw_warehouse = Warehouse( id=2, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE", ) self.db.add(raw_warehouse) self.db.commit() with self.assertRaises(HTTPException) as exc: create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=raw_warehouse.id, waybill_no="YD-WRONG-WH", freight_amount=12, items=[ DeliveryItemCreate( product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=1, delivery_weight_kg=2, unit_price=0, ) ], ), context=self._auth_context(), db=self.db, ) self.assertEqual(exc.exception.status_code, 400) self.assertIn("销售出库必须选择成品库", exc.exception.detail) ``` - [ ] **Step 2: Run backend test and verify the expected failure** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q ``` Expected before implementation: ```text FAILED backend/tests/test_sales_order_delivery_trace.py::SalesOrderDeliveryTraceTest::test_sales_delivery_rejects_non_finished_warehouse ``` The direct customer delivery test may already pass because the backend mostly supports it. The warehouse-type test must fail until validation is added. - [ ] **Step 3: Add explicit finished warehouse validation** In `backend/app/api/routes/sales.py`, inside `create_delivery`, immediately after: ```python warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise HTTPException(status_code=404, detail="仓库不存在") ``` add: ```python if str(warehouse.warehouse_type or "").upper() != "FINISHED": raise HTTPException(status_code=400, detail="销售出库必须选择成品库") ``` The surrounding code should become: ```python warehouse = db.get(Warehouse, payload.warehouse_id) if not warehouse: raise HTTPException(status_code=404, detail="仓库不存在") if str(warehouse.warehouse_type or "").upper() != "FINISHED": raise HTTPException(status_code=400, detail="销售出库必须选择成品库") ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库") ``` - [ ] **Step 4: Run backend test and verify it passes** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend .venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q ``` Expected: ```text passed ``` - [ ] **Step 5: Commit backend contract changes if git is available** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP git rev-parse --is-inside-work-tree ``` If the command prints `true`, run: ```bash git add backend/app/api/routes/sales.py backend/tests/test_sales_order_delivery_trace.py git commit -m "test: protect unified delivery creation contract" ``` If it exits nonzero because this checkout is not a git repository, record that and continue without committing. --- ### Task 2: Frontend Regression Guard **Files:** - Create: `frontend/scripts/test-delivery-entry-unification.mjs` - [ ] **Step 1: Write the failing static regression script** Create `frontend/scripts/test-delivery-entry-unification.mjs` with this content: ```js import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import path from "node:path"; const root = process.cwd(); function readSource(relativePath) { return readFileSync(path.join(root, relativePath), "utf8"); } const inventory = readSource("src/views/InventoryLedgerView.vue"); const delivery = readSource("src/views/DeliveryManagementView.vue"); assert.match(inventory, /delivery_mode:\s*"SALES_ORDER"/, "sales-out form should default to sales-order delivery mode"); assert.match(inventory, /直接客户发货/, "warehouse sales-out drawer should support direct customer delivery"); assert.match(inventory, /销售订单发货/, "warehouse sales-out drawer should keep sales-order delivery mode"); assert.match(inventory, /isSalesOrderDelivery/, "sales-out drawer should centralize mode-dependent behavior"); assert.match(inventory, /setDeliveryMode\("DIRECT_CUSTOMER"\)/, "direct customer mode should be selectable"); assert.match(inventory, /deliveryForm\.delivery_mode === "SALES_ORDER"/, "sales order fields should be conditional"); assert.match(inventory, /source_material_summary/, "pre-submit allocation rows should expose source material summary"); assert.match(inventory, />来源材料摘要, "pre-submit allocation table should show source material summary column"); assert.match(inventory, />成品库存批次号, "pre-submit allocation table should show finished stock lot column"); assert.match(inventory, />来源原材料库存批次号, "pre-submit allocation table should show source raw-material lot column"); assert.match(inventory, /sales_order_id:\s*isSalesOrderDelivery\.value \? Number\(deliveryForm\.sales_order_id\) : null/, "direct customer delivery should send null sales_order_id"); assert.match(inventory, /sales_order_item_id:\s*isSalesOrderDelivery\.value \? Number\(deliveryForm\.sales_order_item_id\) : null/, "direct customer delivery lines should send null sales_order_item_id"); assert.match(delivery, /发货台账/, "delivery management should be renamed to delivery ledger"); assert.match(delivery, /交付查询/, "delivery ledger should describe query/trace use"); assert.match(delivery, />销售订单号, "delivery ledger should show sales order number"); assert.match(delivery, /source_material_summary/, "delivery ledger detail should keep material trace summary"); assert.doesNotMatch(delivery, /新增发货/, "delivery ledger must not expose create-delivery button"); assert.doesNotMatch(delivery, /openCreateDrawer/, "delivery ledger must not keep create drawer action"); assert.doesNotMatch(delivery, /submitDelivery/, "delivery ledger must not keep create submit handler"); assert.doesNotMatch(delivery, /postResource/, "delivery ledger must not import write API helpers"); assert.doesNotMatch(delivery, /uploadResource/, "delivery ledger must not upload logistics photos"); assert.doesNotMatch(delivery, /title="新增发货"/, "delivery ledger must not contain create drawer"); console.log("delivery entry unification checks passed"); ``` - [ ] **Step 2: Run static script and verify it fails** Run: ```bash cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend node scripts/test-delivery-entry-unification.mjs ``` Expected before implementation: ```text AssertionError ``` The failure should point to missing direct-customer mode in `InventoryLedgerView.vue` or create UI still existing in `DeliveryManagementView.vue`. --- ### Task 3: Warehouse Sales-Out Drawer Supports Both Delivery Modes **Files:** - Modify: `frontend/src/views/InventoryLedgerView.vue` - Test: `frontend/scripts/test-delivery-entry-unification.mjs` - [ ] **Step 1: Add delivery mode state and mode helpers** In `buildEmptyDeliveryForm()` add `delivery_mode`: ```js function buildEmptyDeliveryForm() { return { delivery_mode: "SALES_ORDER", sales_order_id: "", sales_order_item_id: "", product_item_id: "", warehouse_id: "", customer_id: "", shipper_employee_id: "", consignee_name: "", consignee_phone: "", delivery_address: "", delivery_qty: 0, waybill_no: "", order_photo_url: "", freight_amount: "" }; } ``` Near the existing `selectedSalesOrder` computed, add: ```js const isSalesOrderDelivery = computed(() => deliveryForm.delivery_mode === "SALES_ORDER"); const isDirectCustomerDelivery = computed(() => deliveryForm.delivery_mode === "DIRECT_CUSTOMER"); ``` Update `selectableSalesOrderItems` to return no rows in direct mode: ```js const selectableSalesOrderItems = computed(() => { if (!isSalesOrderDelivery.value) { return []; } return salesOrderItems.value.filter( (item) => Number(item.sales_order_id) === Number(deliveryForm.sales_order_id) && orderItemPendingQty(item) > 0 ); }); ``` Add this helper near `resetDeliveryForm()`: ```js function setDeliveryMode(mode) { deliveryForm.delivery_mode = mode === "DIRECT_CUSTOMER" ? "DIRECT_CUSTOMER" : "SALES_ORDER"; deliveryForm.sales_order_id = ""; deliveryForm.sales_order_item_id = ""; deliveryForm.product_item_id = ""; deliveryForm.customer_id = ""; deliveryForm.consignee_name = ""; deliveryForm.consignee_phone = ""; deliveryForm.delivery_address = ""; deliveryForm.delivery_qty = 0; } ``` - [ ] **Step 2: Make sales-order defaults no-op in direct mode** Replace `applySalesOrderDefaults()` with: ```js function applySalesOrderDefaults() { deliveryForm.sales_order_item_id = ""; if (!isSalesOrderDelivery.value) { return; } const order = selectedSalesOrder.value; if (!order) { return; } deliveryForm.customer_id = order.customer_id; deliveryForm.delivery_address = order.delivery_address || ""; const customer = customers.value.find((item) => item.id === Number(order.customer_id)); deliveryForm.consignee_name = customer?.contact_name || ""; deliveryForm.consignee_phone = customer?.contact_phone || ""; deliveryForm.delivery_address = order.delivery_address || customer?.address || ""; const firstOpenItem = selectableSalesOrderItems.value[0]; if (firstOpenItem) { deliveryForm.sales_order_item_id = firstOpenItem.sales_order_item_id; applySalesOrderItemDefaults(); } } ``` Replace `applySalesOrderItemDefaults()` with: ```js function applySalesOrderItemDefaults() { const item = selectedSalesOrderItem.value; if (!item || !isSalesOrderDelivery.value) { return; } deliveryForm.product_item_id = item.product_item_id; deliveryForm.delivery_qty = orderItemPendingQty(item); } ``` Add a customer default helper if it does not already exist in this file: ```js function applyDeliveryCustomerDefaults() { if (isSalesOrderDelivery.value) { return; } const customer = customers.value.find((item) => item.id === Number(deliveryForm.customer_id)); deliveryForm.consignee_name = customer?.contact_name || ""; deliveryForm.consignee_phone = customer?.contact_phone || ""; deliveryForm.delivery_address = customer?.address || ""; } ``` - [ ] **Step 3: Update sales-out drawer template** Inside the `FormDrawer` block whose open prop is `deliveryDrawerOpen`, replace the first sales-order field group with this mode section and conditional sales-order fields: ```vue