ForgeFlow-ERP/docs/superpowers/plans/2026-06-01-delivery-entry-unification.md
2026-06-12 16:00:56 +08:00

29 KiB

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:

    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:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q

Expected before implementation:

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:

    warehouse = db.get(Warehouse, payload.warehouse_id)
    if not warehouse:
        raise HTTPException(status_code=404, detail="仓库不存在")

add:

    if str(warehouse.warehouse_type or "").upper() != "FINISHED":
        raise HTTPException(status_code=400, detail="销售出库必须选择成品库")

The surrounding code should become:

    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:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q

Expected:

passed
  • Step 5: Commit backend contract changes if git is available

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git rev-parse --is-inside-work-tree

If the command prints true, run:

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:

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:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-delivery-entry-unification.mjs

Expected before implementation:

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:

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:

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:

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

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:

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:

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:

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:

        <div class="purchase-choice-grid">
          <button
            class="purchase-choice-card mode-choice"
            :class="{ active: isSalesOrderDelivery }"
            type="button"
            title="选择已有销售订单,发货后回写订单已发数量。"
            @click="setDeliveryMode('SALES_ORDER')"
          >
            <span class="choice-check"></span>
            <span class="choice-main">
              <strong>销售订单发货</strong>
            </span>
          </button>
          <button
            class="purchase-choice-card mode-choice"
            :class="{ active: isDirectCustomerDelivery }"
            type="button"
            title="不绑定销售订单,直接按客户和产品从成品库出库。"
            @click="setDeliveryMode('DIRECT_CUSTOMER')"
          >
            <span class="choice-check"></span>
            <span class="choice-main">
              <strong>直接客户发货</strong>
            </span>
          </button>
        </div>

        <div v-if="deliveryForm.delivery_mode === 'SALES_ORDER'" class="double-field">
          <label class="form-field">
            <span>关联销售订单</span>
            <select v-model.number="deliveryForm.sales_order_id" required @change="applySalesOrderDefaults">
              <option disabled value="">请选择销售订单</option>
              <option v-for="order in openSalesOrders" :key="order.order_id" :value="order.order_id">
                {{ order.order_no }} · {{ order.customer_name }} · 未发 {{ formatQty(orderPendingQty(order.order_id)) }}
              </option>
            </select>
          </label>
          <label class="form-field">
            <span>销售订单明细</span>
            <select v-model.number="deliveryForm.sales_order_item_id" required @change="applySalesOrderItemDefaults">
              <option disabled value="">请选择订单明细</option>
              <option v-for="item in selectableSalesOrderItems" :key="item.sales_order_item_id" :value="item.sales_order_item_id">
                {{ item.product_code }} · {{ item.product_name }} · 未发 {{ formatQty(orderItemPendingQty(item)) }}
              </option>
            </select>
          </label>
        </div>

In the product/customer area:

  • Change the product select to :disabled="isSalesOrderDelivery && Boolean(deliveryForm.sales_order_item_id)".
  • Change the customer select to :disabled="isSalesOrderDelivery" and add @change="applyDeliveryCustomerDefaults".
  • Change the quantity input max to :max="isSalesOrderDelivery && selectedSalesOrderItem ? orderItemPendingQty(selectedSalesOrderItem) : undefined".

The relevant fields should look like:

            <select v-model.number="deliveryForm.product_item_id" required :disabled="isSalesOrderDelivery && Boolean(deliveryForm.sales_order_item_id)">
            <input
              v-model.number="deliveryForm.delivery_qty"
              type="number"
              min="1"
              step="1"
              required
              :max="isSalesOrderDelivery && selectedSalesOrderItem ? orderItemPendingQty(selectedSalesOrderItem) : undefined"
            />
            <select v-model.number="deliveryForm.customer_id" required :disabled="isSalesOrderDelivery" @change="applyDeliveryCustomerDefaults">
  • Step 4: Show trace fields before submit

In the automatic sales-out detail table, replace the headers and rows with:

              <tr>
                <th>成品库存批次号</th>
                <th>来源原材料库存批次号</th>
                <th>来源材料摘要</th>
                <th>可发数量</th>
                <th>本次发货</th>
                <th>重量</th>
              </tr>
              <tr v-for="item in allocationRows" :key="item.lot_id">
                <td>{{ item.lot_no }}</td>
                <td>{{ item.source_material_sub_batch_no || item.source_material_lot_no || "-" }}</td>
                <td :title="item.source_material_summary || ''">{{ item.source_material_summary || "-" }}</td>
                <td>{{ formatQty(item.remaining_qty) }}</td>
                <td>{{ formatQty(item.delivery_qty) }}</td>
                <td>{{ formatWeight(item.delivery_weight_kg) }}</td>
              </tr>
              <tr v-if="!allocationRows.length">
                <td colspan="6" class="empty-row">{{ isSalesOrderDelivery ? "选择销售订单和发货数量后显示拆分明细" : "选择客户产品和发货数量后显示拆分明细" }}</td>
              </tr>
  • Step 5: Submit null sales-order fields for direct customer delivery

In submitSalesOut(), before calling postResource, add explicit validations:

  if (isSalesOrderDelivery.value && !Number(deliveryForm.sales_order_id)) {
    errorMessage.value = "请选择销售订单";
    return;
  }
  if (isSalesOrderDelivery.value && !Number(deliveryForm.sales_order_item_id)) {
    errorMessage.value = "请选择销售订单明细";
    return;
  }
  if (!Number(deliveryForm.customer_id)) {
    errorMessage.value = "请选择收货客户";
    return;
  }
  if (!Number(deliveryForm.product_item_id)) {
    errorMessage.value = "请选择发货产品";
    return;
  }

Inside the postResource("/sales/deliveries", payload) call, replace the payload sales-order lines with:

      sales_order_id: isSalesOrderDelivery.value ? Number(deliveryForm.sales_order_id) : null,
      customer_id: Number(deliveryForm.customer_id),

Replace the items mapping with:

      items: allocationRows.value.map((item) => ({
        sales_order_item_id: isSalesOrderDelivery.value ? Number(deliveryForm.sales_order_item_id) : null,
        product_item_id: Number(deliveryForm.product_item_id),
        lot_id: Number(item.lot_id),
        delivery_qty: Number(item.delivery_qty || 0),
        delivery_weight_kg: Number(item.delivery_weight_kg || 0),
        unit_price: isSalesOrderDelivery.value ? Number(selectedSalesOrderItem.value?.unit_price || 0) : 0
      }))
  • Step 6: Run frontend static test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-delivery-entry-unification.mjs

Expected after Task 3 only:

AssertionError

The remaining failure should be about DeliveryManagementView.vue still exposing create delivery UI.


Task 4: Convert Delivery Management To Read-Only Ledger

Files:

  • Modify: frontend/src/views/DeliveryManagementView.vue

  • Test: frontend/scripts/test-delivery-entry-unification.mjs

  • Step 1: Remove create button and rename page

In frontend/src/views/DeliveryManagementView.vue, update the panel header from:

          <p class="eyebrow">发货列表</p>
          <h3>销售订单履约发货与直接发货</h3>

to:

          <p class="eyebrow">发货台账</p>
          <h3>交付查询物流信息与批次追溯</h3>

Replace the right header actions:

        <div class="workflow-inline-actions">
          <span class="panel-tag">成品出库</span>
          <button class="primary-button" type="button" @click="openCreateDrawer">新增发货</button>
        </div>

with:

        <div class="workflow-inline-actions">
          <span class="panel-tag">只读台账</span>
        </div>

Add a sales order number column after 发货单号:

              <th>销售订单号</th>

and row cell:

              <td>{{ delivery.order_no || "直接客户发货" }}</td>

Update the empty row colspan from 11 to 12.

  • Step 2: Delete the create drawer template

Remove the entire create drawer block that starts with:

    <FormDrawer
      :open="drawerOpen"
      eyebrow="发货管理"
      title="新增发货"

through its matching:

    </FormDrawer>

Do not remove the detail drawer that starts with:

    <FormDrawer
      :open="detailDrawerOpen"
      eyebrow="发货明细"

The detail table must keep these cells:

              <td>{{ item.product_name }}</td>
              <td :title="item.source_material_summary || ''">{{ formatSourceMaterial(item) }}</td>
              <td>{{ item.source_material_lot_no || "-" }}</td>
              <td>{{ item.source_material_sub_batch_no || "-" }}</td>
              <td>{{ item.lot_no || "-" }}</td>
  • Step 3: Remove write-side imports, state, computed values, and functions

Change imports from:

import FormDrawer from "../components/FormDrawer.vue";
import { fetchResource, openResource, postResource, uploadResource } from "../services/api";

to:

import FormDrawer from "../components/FormDrawer.vue";
import { fetchResource, openResource } from "../services/api";

Remove these refs/state:

const salesOrders = ref([]);
const salesOrderItems = ref([]);
const warehouses = ref([]);
const employees = ref([]);
const lots = ref([]);
const submitting = ref(false);
const logisticsPhotoUploading = ref(false);
const drawerOpen = ref(false);
const form = reactive(buildEmptyForm());

Remove the computed declarations named:

selectedSalesOrder
selectedSalesOrderItem
openSalesOrders
shippingWarehouses
selectableSalesOrderItems
availableLots
totalAvailableQty
allocationRows
totalAllocatedQty

Remove these functions:

buildEmptyForm
isFreightBlank
validateLogisticsForm
handleLogisticsPhotoUpload
resetForm
normalizeWarehouseType
openCreateDrawer
applyCustomerDefaults
orderItemPendingQty
orderPendingQty
applySalesOrderDefaults
applySalesOrderItemDefaults
submitDelivery

Keep these functions:

joinUniqueNames
formatSourceMaterial
openLogisticsPhoto
formatFreight
openDetailDrawer
loadAll

Update loadAll() to fetch only read-side resources:

async function loadAll() {
  const [productRows, customerRows, deliveryRows, deliveryItemRows] = await Promise.all([
    fetchResource("/master-data/products?limit=200", []),
    fetchResource("/sales/customers?limit=200", []),
    fetchResource("/sales/deliveries?limit=500", []),
    fetchResource("/sales/delivery-items?limit=500", [])
  ]);
  products.value = productRows;
  customers.value = customerRows;
  deliveries.value = deliveryRows;
  deliveryItems.value = deliveryItemRows;
}
  • Step 4: Ensure delivery ledger search includes direct/customer and order number

Update deliveryControls search fields to:

const deliveryControls = useTableControls(deliveryRows, {
  searchFields: [
    "delivery_no",
    "order_no",
    "customer_name",
    "product_names",
    "consignee_name",
    "consignee_phone",
    "logistics_waybill_no",
    "status",
    (row) => row.order_no || "直接客户发货"
  ],
  sortOptions: [
    { key: "delivery_no", label: "发货单号" },
    { key: "order_no", label: "销售订单号" },
    { key: "customer_name", label: "客户" },
    { key: "product_names", label: "产品" },
    { key: "delivery_date", label: "发货时间" },
    { key: "total_delivery_qty", label: "发货数量" },
    { key: "status", label: "状态" }
  ],
  defaultSortKey: "delivery_date",
  defaultSortDirection: "desc"
});
  • Step 5: Run delivery static test and verify it passes

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-delivery-entry-unification.mjs

Expected:

delivery entry unification checks passed
  • Step 6: Run Vite build to catch SFC syntax errors

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected:

✓ built

The existing Vite chunk-size warning may appear and is acceptable.


Task 5: End-To-End Verification

Files:

  • No source edits expected.

  • Step 1: Run backend delivery tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_sales_order_delivery_trace.py -q

Expected:

passed
  • Step 2: Run new and existing frontend static checks

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-delivery-entry-unification.mjs
node scripts/test-inventory-ledger-transaction-ledger.mjs
node scripts/test-inventory-ledger-return-warehouse.mjs
node scripts/test-inventory-ledger-options.mjs
node scripts/test-inventory-ledger-raw-return.mjs
node scripts/test-stocktake-workflow.mjs

Expected:

delivery entry unification checks passed
Warehouse transaction ledger UI checks passed
Return warehouse UI checks passed
inventory ledger option tests passed
Raw return UI checks passed
stocktake workflow tests passed
  • Step 3: Run frontend production build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected:

✓ built
  • Step 4: Manual browser smoke test if local services are running

Open the ERP frontend and verify:

嘉恒仓库 -> 成品库 -> 销售出库

Expected UI behavior:

  • Sales-out drawer shows 销售订单发货 and 直接客户发货.
  • In 销售订单发货, sales order and order item fields are visible and customer/product are derived.
  • In 直接客户发货, sales order fields are hidden and customer/product can be selected directly.
  • Allocation table shows 成品库存批次号, 来源原材料库存批次号, and 来源材料摘要.

Open:

发货与售后 -> 发货管理

Expected UI behavior:

  • Page title is 发货台账.

  • No 新增发货 button exists.

  • Clicking a delivery number opens read-only details.

  • Details include source material and source raw-material stock lot fields.

  • Step 5: Commit frontend changes if git is available

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git rev-parse --is-inside-work-tree

If the command prints true, run:

git add \
  frontend/scripts/test-delivery-entry-unification.mjs \
  frontend/src/views/InventoryLedgerView.vue \
  frontend/src/views/DeliveryManagementView.vue
git commit -m "feat: unify delivery creation entry"

If it exits nonzero because this checkout is not a git repository, record that and finish without committing.