918 lines
29 KiB
Markdown
918 lines
29 KiB
Markdown
# 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.
|