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

```vue
            <select v-model.number="deliveryForm.product_item_id" required :disabled="isSalesOrderDelivery && Boolean(deliveryForm.sales_order_item_id)">
```

```vue
            <input
              v-model.number="deliveryForm.delivery_qty"
              type="number"
              min="1"
              step="1"
              required
              :max="isSalesOrderDelivery && selectedSalesOrderItem ? orderItemPendingQty(selectedSalesOrderItem) : undefined"
            />
```

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

```vue
              <tr>
                <th>成品库存批次号</th>
                <th>来源原材料库存批次号</th>
                <th>来源材料摘要</th>
                <th>可发数量</th>
                <th>本次发货</th>
                <th>重量</th>
              </tr>
```

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

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

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

Replace the `items` mapping with:

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

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

Expected after Task 3 only:

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

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

to:

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

Replace the right header actions:

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

with:

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

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

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

and row cell:

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

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

through its matching:

```vue
    </FormDrawer>
```

Do not remove the detail drawer that starts with:

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

The detail table must keep these cells:

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

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

to:

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

Remove these refs/state:

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

```text
selectedSalesOrder
selectedSalesOrderItem
openSalesOrders
shippingWarehouses
selectableSalesOrderItems
availableLots
totalAvailableQty
allocationRows
totalAllocatedQty
```

Remove these functions:

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

Keep these functions:

```js
joinUniqueNames
formatSourceMaterial
openLogisticsPhoto
formatFreight
openDetailDrawer
loadAll
```

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

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

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

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

Expected:

```text
delivery entry unification checks passed
```

- [ ] **Step 6: Run Vite build to catch SFC syntax errors**

Run:

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

Expected:

```text
✓ 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:

```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 2: Run new and existing frontend static checks**

Run:

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

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

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

Expected:

```text
✓ built
```

- [ ] **Step 4: Manual browser smoke test if local services are running**

Open the ERP frontend and verify:

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

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:

```text
发货与售后 -> 发货管理
```

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:

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

If the command prints `true`, run:

```bash
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.
