Initial commit

This commit is contained in:
汤学会
2026-06-12 16:00:56 +08:00
commit c927886888
337 changed files with 160396 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,917 @@
# 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.
@@ -0,0 +1,54 @@
# Dictionary Localization Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Convert visible English enum/dictionary values in ERP business screens to Chinese without changing stored enum codes.
**Architecture:** Centralize translations in `frontend/src/utils/dictionaries.js`, then update high-risk screens to use formatter helpers instead of raw enum output. Add a static regression script to prevent common untranslated enum leaks from returning.
**Tech Stack:** Vue 3 SFC, frontend utility modules, Node static checks, Vite build.
---
### Task 1: Expand Frontend Dictionary Utilities
**Files:**
- Modify: `frontend/src/utils/dictionaries.js`
- [ ] Add formatter maps for missing statuses, inventory transaction types, source document types, warehouse types, item types, purchase source types, maintenance types, miniapp roles, and stocktake row statuses.
- [ ] Make dictionary lookup normalize string keys to uppercase.
- [ ] Add Chinese fallback for unknown enum-like values so raw English enum codes are not displayed.
### Task 2: Replace Direct Enum Rendering In High-Risk Views
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/views/EquipmentManagementView.vue`
- Modify: `frontend/src/views/EmployeeManagementView.vue`
- Modify: `frontend/src/components/StocktakeDialog.vue`
- Modify: `frontend/src/views/PurchaseOrderView.vue`
- [ ] Inventory: translate stock lot quality/source/item displays and ledger type/source values.
- [ ] Equipment: translate maintenance type values.
- [ ] Employee: translate miniapp role codes even if backend returns raw role values.
- [ ] Stocktake: translate row status and keep existing diff/status translation.
- [ ] Purchase order: translate purchase source type in search and sort-facing display helpers.
### Task 3: Add Static Regression Check
**Files:**
- Create: `frontend/scripts/test-dictionary-localization.mjs`
- [ ] Verify dictionary maps include common English codes that previously leaked.
- [ ] Verify high-risk views call the new formatter helpers.
- [ ] Verify dictionary fallback does not return raw enum-like English.
### Task 4: Verify
**Files:**
- No source edits expected.
- [ ] Run `node scripts/test-dictionary-localization.mjs`.
- [ ] Run existing inventory static scripts.
- [ ] Run `npm run build`.
@@ -0,0 +1,44 @@
# Operation Report Date Filter 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 05-2 工序报工 default to recent 15 days and allow user-selected report-date ranges.
**Architecture:** Backend filters miniapp report rows by `production_reports.report_date` using optional `start_date` and `end_date` parameters. Frontend initializes a 15-day range and sends it whenever 05-2 loads or refreshes.
**Tech Stack:** FastAPI, SQLAlchemy, Vue 3 SFC, Node static regression scripts, pytest.
---
### Task 1: Backend Date Filtering
**Files:**
- Modify: `backend/app/api/routes/production.py`
- Create: `backend/tests/test_miniapp_operation_report_date_filter.py`
- [ ] Add failing tests for date range filtering and invalid ranges.
- [ ] Add `start_date` and `end_date` parameters to `_miniapp_report_item_rows`.
- [ ] Add query parameters to `/production/miniapp-operation-reports`.
- [ ] Validate `start_date <= end_date`.
- [ ] Run `backend/.venv/bin/python -m pytest tests/test_miniapp_operation_report_date_filter.py -q`.
### Task 2: Frontend Date Filter
**Files:**
- Modify: `frontend/src/views/OperationReportView.vue`
- Create: `frontend/scripts/test-operation-report-date-filter.mjs`
- [ ] Add failing static regression script.
- [ ] Add default 15-day date range helpers.
- [ ] Add right-side date inputs and refresh button.
- [ ] Build request URL with `start_date` and `end_date`.
- [ ] Run `node scripts/test-operation-report-date-filter.mjs`.
### Task 3: Verification
**Files:**
- No new files.
- [ ] Run backend date-filter test.
- [ ] Run frontend date-filter script.
- [ ] Run `npm run build`.
@@ -0,0 +1,290 @@
# Production Surplus Lightweight Source-Lot Return Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make 原材料库「生产余料入库」hide unnecessary free-text fields and return surplus weight against one or more selected source stock lots.
**Architecture:** Keep the existing generic `/inventory/inbound` API and existing `PRODUCTION_SURPLUS` business type. The frontend special-cases only the generic drawer when `activeOperation.bizType === "PRODUCTION_SURPLUS"`: hide batch/provider fields, replace free-text source lot input with a searchable multi-select, show per-source-lot return weight rows, and submit one inbound record per selected source lot.
**Tech Stack:** Vue 3 Composition API, existing `StockLotTagSelect.vue`, existing `fetchResource`/`postResource`, FastAPI existing generic inbound endpoint.
---
## File Structure
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Add source-lot option state for generic production surplus inbound.
- Hide `批次号` and `来源/提供方` for `PRODUCTION_SURPLUS`.
- Replace the single text input for `来源库存批次号` with `StockLotTagSelect` for `PRODUCTION_SURPLUS`.
- Add a per-source-lot return weight table and submit one inbound request per source lot.
- No backend model/table changes.
- No new SQL.
## Task 1: Frontend State And Option Loading
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- [ ] **Step 1: Add generic source lot state**
Add refs near the existing `productionStockLotOptions` and `selectedProductionStockLots` declarations:
```js
const genericSourceLotOptions = ref([]);
const selectedGenericSourceLots = ref([]);
let genericSourceLotOptionsRequestSeq = 0;
```
- [ ] **Step 2: Add computed helpers**
Add computed values near `genericRequiresLogistics`:
```js
const isProductionSurplusInbound = computed(() =>
activeOperation.value?.direction === "in" && activeOperation.value?.bizType === "PRODUCTION_SURPLUS"
);
const genericSourceLotReturnWeight = computed(() =>
selectedGenericSourceLots.value.reduce((total, lot) => total + Number(lot.return_weight_kg || 0), 0)
);
```
- [ ] **Step 3: Reset selected source lots when generic form resets**
Update `resetGenericForm()` so it clears the selected lots and option list:
```js
genericSourceLotOptionsRequestSeq += 1;
selectedGenericSourceLots.value = [];
genericSourceLotOptions.value = [];
```
- [ ] **Step 4: Load source lot options by material**
Add this function near `loadProductionStockLotOptions`:
```js
async function loadGenericSourceLotOptions(materialItemId) {
const requestSeq = ++genericSourceLotOptionsRequestSeq;
const id = Number(materialItemId || 0);
if (!id || !isProductionSurplusInbound.value) {
genericSourceLotOptions.value = [];
selectedGenericSourceLots.value = [];
return;
}
try {
const rows = await fetchResource(`/inventory/stock-lot-options?material_item_id=${id}&limit=1000`, []);
if (requestSeq !== genericSourceLotOptionsRequestSeq) {
return;
}
genericSourceLotOptions.value = Array.isArray(rows) ? rows : [];
const optionMap = new Map(genericSourceLotOptions.value.map((lot) => [Number(lot.lot_id), lot]));
selectedGenericSourceLots.value = selectedGenericSourceLots.value
.filter((lot) => optionMap.has(Number(lot.lot_id)))
.map((lot) => ({
...optionMap.get(Number(lot.lot_id)),
return_weight_kg: Number(lot.return_weight_kg || 0)
}));
} catch (error) {
if (requestSeq === genericSourceLotOptionsRequestSeq) {
genericSourceLotOptions.value = [];
selectedGenericSourceLots.value = [];
errorMessage.value = error.message || "来源库存批次号加载失败";
}
}
}
```
- [ ] **Step 5: Watch material and operation changes**
Add watchers near the existing Vue watchers:
```js
watch(
() => [genericForm.item_id, activeOperation.value?.bizType, genericDrawerOpen.value],
async () => {
if (!genericDrawerOpen.value || !isProductionSurplusInbound.value) {
return;
}
selectedGenericSourceLots.value = [];
await loadGenericSourceLotOptions(genericForm.item_id);
}
);
watch(
selectedGenericSourceLots,
() => {
if (isProductionSurplusInbound.value) {
genericForm.weight_kg = Number(genericSourceLotReturnWeight.value.toFixed(3));
}
},
{ deep: true }
);
```
## Task 2: Frontend Template
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- [ ] **Step 1: Hide batch/provider fields for production surplus inbound**
Change this condition:
```vue
<div v-if="activeOperation?.direction !== 'out'" class="double-field">
```
to:
```vue
<div v-if="activeOperation?.direction !== 'out' && !isProductionSurplusInbound" class="double-field">
```
- [ ] **Step 2: Hide top-level inbound weight for production surplus inbound**
Add `v-if="!isProductionSurplusInbound"` to the 入库/出库 weight label:
```vue
<label v-if="!isProductionSurplusInbound" class="form-field">
<span>{{ activeOperation?.direction === "out" ? "出库重量(kg)" : "入库重量(kg)" }}</span>
<input v-model.number="genericForm.weight_kg" type="number" min="0.001" step="0.001" required />
</label>
```
- [ ] **Step 3: Replace source-lot text input with multi-select for production surplus inbound**
Replace the existing `来源库存批次号` label with two branches:
```vue
<label v-if="!isProductionSurplusInbound" class="form-field">
<span>来源库存批次号</span>
<input v-model.trim="genericForm.source_material_sub_batch_no" type="text" placeholder="有来源库存批次时填写" />
</label>
<label v-else class="form-field">
<span>来源库存批次号</span>
<StockLotTagSelect v-model="selectedGenericSourceLots" :options="genericSourceLotOptions" placeholder="搜索并选择归还到的来源库存批次号,可多选" />
</label>
```
- [ ] **Step 4: Add return-weight table**
Add this block after the production-surplus `StockLotTagSelect` label:
```vue
<div v-if="isProductionSurplusInbound && selectedGenericSourceLots.length" class="table-wrap">
<table class="data-table compact-table">
<thead>
<tr>
<th>来源库存批次号</th>
<th>原材料</th>
<th>剩余重量</th>
<th>归还重量(kg)</th>
</tr>
</thead>
<tbody>
<tr v-for="lot in selectedGenericSourceLots" :key="lot.lot_id">
<td>{{ lot.lot_no }}</td>
<td>{{ lot.item_code }} · {{ lot.item_name }}</td>
<td>{{ formatWeight(lot.remaining_weight_kg) }}</td>
<td>
<input v-model.number="lot.return_weight_kg" type="number" min="0.001" step="0.001" required />
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="isProductionSurplusInbound" class="feedback-banner">
本次归还总重量:{{ formatWeight(genericSourceLotReturnWeight) }}
</div>
```
## Task 3: Submission Logic
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- [ ] **Step 1: Add validation for production surplus source lots**
Add this function before `submitGenericOperation()`:
```js
function validateProductionSurplusSourceLots() {
if (!isProductionSurplusInbound.value) {
return "";
}
if (!selectedGenericSourceLots.value.length) {
return "请选择至少一个来源库存批次号";
}
for (const lot of selectedGenericSourceLots.value) {
const weight = Number(lot.return_weight_kg || 0);
if (!Number.isFinite(weight) || weight <= 0) {
return `来源库存批次号 ${lot.lot_no || "-"} 的归还重量必须大于0`;
}
}
return "";
}
```
- [ ] **Step 2: Submit one inbound record per selected source lot**
At the start of the inbound branch inside `submitGenericOperation()`, add a special case:
```js
if (isProductionSurplusInbound.value) {
const validationError = validateProductionSurplusSourceLots();
if (validationError) {
errorMessage.value = validationError;
submittingOperation.value = false;
return;
}
for (const lot of selectedGenericSourceLots.value) {
await postResource("/inventory/inbound", {
biz_type: activeOperation.value.bizType,
item_id: Number(genericForm.item_id),
warehouse_id: Number(genericForm.warehouse_id),
location_id: genericForm.location_id ? Number(genericForm.location_id) : null,
inbound_weight_kg: Number(lot.return_weight_kg || 0),
unit_cost: Number(genericForm.unit_cost || 0),
lot_no: null,
source_material_sub_batch_no: lot.lot_no || null,
source_material_summary: lot.lot_no || null,
provider_name: null,
waybill_no: null,
order_photo_url: null,
freight_amount: null,
remark: genericForm.remark || null
});
}
feedbackMessage.value = `${activeOperation.value.label}已登记`;
genericDrawerOpen.value = false;
await loadAll();
return;
}
```
## Task 4: Verification
**Files:**
- Verify existing frontend/backend.
- [ ] **Step 1: Build frontend**
Run:
```bash
cd frontend && npm run build
```
Expected: build succeeds. Vite chunk size warning is acceptable if present.
- [ ] **Step 2: Run backend tests**
Run:
```bash
cd backend && .venv/bin/python -m pytest tests -q
```
Expected: all existing backend tests pass. No new backend behavior is introduced.
@@ -0,0 +1,127 @@
# Sales Order Item Promised Date Default 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 sales order line promised dates default to the sales order promised date while preserving user-edited line dates.
**Architecture:** This is a frontend-only interaction change because the backend already falls back from line promised date to order promised date on save. `SalesPlanningView.vue` will track whether a line date was manually edited, auto-fill empty/unmodified rows when the header date changes, and default newly added rows from the current header date.
**Tech Stack:** Vue 3 Composition API, existing Vite frontend, lightweight Node source-regression script.
---
### Task 1: Add Regression Coverage
**Files:**
- Create: `frontend/scripts/test-sales-order-item-promised-date-default.mjs`
- Read: `frontend/src/views/SalesPlanningView.vue`
- [ ] **Step 1: Write source-regression checks**
```js
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
const viewPath = path.resolve("src/views/SalesPlanningView.vue");
const source = fs.readFileSync(viewPath, "utf8");
assert.match(source, /promised_date_touched:\s*false/, "new sales order lines should track whether line promised date was manually edited");
assert.match(source, /@input="markSalesOrderItemPromisedDateTouched\(item\)"/, "line promised date input should mark the row as user-edited");
assert.match(source, /syncSalesOrderItemPromisedDatesFromHeader/, "header promised date changes should synchronize eligible line dates");
assert.match(source, /!item\.promised_date_touched/, "sync should only update untouched line promised dates");
assert.match(source, /promised_date:\s*salesOrderForm\.promised_date\s*\|\|\s*null/, "submit should fall back empty line dates to the sales order promised date");
console.log("sales order line promised date default checks passed");
```
- [ ] **Step 2: Run test to verify it fails**
Run: `cd frontend && node scripts/test-sales-order-item-promised-date-default.mjs`
Expected: FAIL before implementation because the source does not yet track `promised_date_touched`.
### Task 2: Implement Frontend Behavior
**Files:**
- Modify: `frontend/src/views/SalesPlanningView.vue`
- [ ] **Step 1: Add row touch tracking and defaulting**
Change `buildSalesOrderItemRow()` so each new row defaults from the current order promised date and tracks manual edits:
```js
function buildSalesOrderItemRow() {
return {
row_uid: `sales-order-item-${salesOrderItemSerial++}`,
product_item_id: "",
order_qty: 0,
unit_price: 0,
promised_date: salesOrderForm.promised_date || "",
promised_date_touched: false,
customer_part_no: ""
};
}
```
- [ ] **Step 2: Add header-date synchronization helpers**
Add these helpers near `addSalesOrderItem()`:
```js
function syncSalesOrderItemPromisedDatesFromHeader() {
salesOrderForm.items.forEach((item) => {
if (!item.promised_date_touched || !item.promised_date) {
item.promised_date = salesOrderForm.promised_date || "";
}
});
}
function markSalesOrderItemPromisedDateTouched(item) {
item.promised_date_touched = true;
}
```
- [ ] **Step 3: Wire the header date watcher and line input**
Add a watcher:
```js
watch(
() => salesOrderForm.promised_date,
() => syncSalesOrderItemPromisedDatesFromHeader()
);
```
Update the line promised date input:
```vue
<input v-model="item.promised_date" type="date" @input="markSalesOrderItemPromisedDateTouched(item)" />
```
- [ ] **Step 4: Add submit fallback**
Change the submitted line payload:
```js
promised_date: item.promised_date || salesOrderForm.promised_date || null,
```
### Task 3: Verify
**Files:**
- Test: `frontend/scripts/test-sales-order-item-promised-date-default.mjs`
- Test: frontend production build
- [ ] **Step 1: Run source-regression script**
Run: `cd frontend && node scripts/test-sales-order-item-promised-date-default.mjs`
Expected: PASS with `sales order line promised date default checks passed`.
- [ ] **Step 2: Run frontend build**
Run: `cd frontend && npm run build`
Expected: build exits 0. The existing Vite chunk-size warning may remain.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,728 @@
# Drawer Table Readability 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 all ERP drawers easier to read by eliminating ugly horizontal scrolling, vertical table headers, random wrapping, and overloaded drawer tables.
**Architecture:** First add global drawer/table readability guardrails so existing drawers immediately stop collapsing columns. Then introduce a reusable `DrawerDataTable` component for drawer tables with fixed layout, compact labels, ellipsis cells, optional expanded detail rows, and consistent column types. Finally migrate the highest-impact warehouse product overview and inventory ledger drawer tables, leaving the component ready for the rest of the ERP.
**Tech Stack:** Vue 3 single-file components, existing `FormDrawer`, existing `TableControls`, existing `PaginationBar`, global CSS in `frontend/src/styles/main.css`, Vite build.
---
## File Structure
- Modify: `frontend/src/components/FormDrawer.vue`
- Add a `size` prop while keeping the old `wide` prop working.
- Supported sizes: `normal`, `wide`, `xl`, `fullscreen`.
- Create: `frontend/src/components/DrawerDataTable.vue`
- Reusable drawer table renderer.
- Accepts rows, columns, row key, empty text, and optional expandable row details.
- Provides named cell slots like `cell-lot_no` and `cell-status`.
- Modify: `frontend/src/styles/main.css`
- Add drawer width classes.
- Add drawer table readability CSS.
- Prevent vertical Chinese headers.
- Add cell helpers for ellipsis, numeric alignment, chip-like long codes, and detail grids.
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Use `size="fullscreen"` or `size="xl"` for complex inventory detail drawers.
- Convert the product overview lot table, inventory batch detail table, and inventory transaction table to `DrawerDataTable`.
- Reduce visible columns to the core fields and move secondary fields to expandable detail.
- Test: `frontend/scripts/test-drawer-data-table.mjs`
- Static test that confirms the new component exists and includes required slot/column/detail behavior.
- Test: existing `frontend/scripts/test-inventory-ledger-options.mjs`
- Must still pass.
---
### Task 1: Add Drawer Size Levels
**Files:**
- Modify: `frontend/src/components/FormDrawer.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Update `FormDrawer.vue` props and class binding**
Replace the current aside class binding:
```vue
<aside class="workflow-drawer form-drawer" :class="{ 'form-drawer-wide': wide }">
```
with:
```vue
<aside class="workflow-drawer form-drawer" :class="drawerClass">
```
Add this script code after `const emit = defineEmits(["close"]);`:
```js
const drawerClass = computed(() => {
const normalizedSize = ["normal", "wide", "xl", "fullscreen"].includes(props.size) ? props.size : "normal";
return {
"form-drawer-wide": props.wide || normalizedSize === "wide",
"form-drawer-xl": normalizedSize === "xl",
"form-drawer-fullscreen": normalizedSize === "fullscreen"
};
});
```
Update the import:
```js
import { computed, toRef } from "vue";
```
Add this prop:
```js
size: {
type: String,
default: "normal"
}
```
- [ ] **Step 2: Add drawer width CSS**
Append near the existing drawer/form-drawer CSS in `frontend/src/styles/main.css`:
```css
.form-drawer-xl {
width: min(1280px, calc(100vw - 40px));
max-width: calc(100vw - 40px);
}
.form-drawer-fullscreen {
width: min(1680px, calc(100vw - 28px));
max-width: calc(100vw - 28px);
height: min(940px, calc(100vh - 28px));
}
.form-drawer-fullscreen .form-drawer-body,
.form-drawer-xl .form-drawer-body {
min-width: 0;
}
```
- [ ] **Step 3: Build check**
Run:
```bash
cd /Volumes/ForgeFlow-ERP/frontend
npm run build
```
Expected: build succeeds.
---
### Task 2: Add Global Drawer Table Guardrails
**Files:**
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add drawer table readability CSS**
Append after the existing `.data-table` styles:
```css
.workflow-drawer .table-wrap {
max-width: 100%;
overflow-x: hidden;
}
.workflow-drawer .data-table {
min-width: 0;
table-layout: fixed;
}
.workflow-drawer .data-table th {
writing-mode: horizontal-tb;
white-space: normal;
word-break: keep-all;
overflow-wrap: normal;
line-height: 1.35;
vertical-align: middle;
}
.workflow-drawer .data-table td {
min-width: 0;
vertical-align: middle;
word-break: normal;
overflow-wrap: anywhere;
}
.drawer-cell-ellipsis {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-cell-code {
display: inline-flex;
max-width: 100%;
padding: 4px 8px;
border-radius: 999px;
background: #eef6ff;
color: #1f5fa8;
font-weight: 650;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-cell-number {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
```
- [ ] **Step 2: Verify screenshots manually**
Run the frontend, open an existing drawer, and confirm:
```bash
cd /Volumes/ForgeFlow-ERP/frontend
npm run dev
```
Expected:
- Headers no longer display one Chinese character per line.
- Tables inside drawers no longer force immediate horizontal scroll for normal-width content.
- Long batch numbers are allowed to truncate rather than deform the whole table.
---
### Task 3: Create `DrawerDataTable`
**Files:**
- Create: `frontend/src/components/DrawerDataTable.vue`
- Create: `frontend/scripts/test-drawer-data-table.mjs`
- [ ] **Step 1: Write the static component test**
Create `frontend/scripts/test-drawer-data-table.mjs`:
```js
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const componentPath = resolve(process.cwd(), "src/components/DrawerDataTable.vue");
const source = readFileSync(componentPath, "utf8");
const requiredSnippets = [
"defineProps",
"columns",
"rows",
"rowKey",
"expandedRows",
"cellClass",
"slot :name=\"`cell-${column.key}`\"",
"drawer-data-table",
"drawer-row-detail"
];
for (const snippet of requiredSnippets) {
if (!source.includes(snippet)) {
throw new Error(`DrawerDataTable missing required snippet: ${snippet}`);
}
}
console.log("drawer data table static tests passed");
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs
```
Expected: FAIL because `src/components/DrawerDataTable.vue` does not exist.
- [ ] **Step 3: Create the component**
Create `frontend/src/components/DrawerDataTable.vue`:
```vue
<template>
<div class="drawer-data-table-wrap">
<table class="data-table compact-table drawer-data-table">
<colgroup>
<col v-for="column in visibleColumns" :key="column.key" :style="{ width: column.width || 'auto' }" />
<col v-if="expandable" style="width: 72px" />
</colgroup>
<thead>
<tr>
<th v-for="column in visibleColumns" :key="column.key" :title="column.title || column.label">
<span class="drawer-table-head-label">{{ column.compactLabel || column.label }}</span>
</th>
<th v-if="expandable">详情</th>
</tr>
</thead>
<tbody>
<template v-for="row in rows" :key="getRowKey(row)">
<tr>
<td v-for="column in visibleColumns" :key="column.key" :class="cellClass(column)">
<slot :name="`cell-${column.key}`" :row="row" :column="column" :value="row[column.key]">
<span :class="fallbackClass(column)" :title="formatFallback(row, column)">
{{ formatFallback(row, column) }}
</span>
</slot>
</td>
<td v-if="expandable" class="drawer-cell-actions">
<button class="mini-icon-button" type="button" @click="toggleRow(row)">
{{ isExpanded(row) ? "收起" : "展开" }}
</button>
</td>
</tr>
<tr v-if="expandable && isExpanded(row)" class="drawer-row-detail">
<td :colspan="visibleColumns.length + 1">
<slot name="detail" :row="row">
<div class="drawer-row-detail-grid">
<article v-for="column in detailColumns" :key="column.key">
<span>{{ column.label }}</span>
<strong>{{ formatFallback(row, column) }}</strong>
</article>
</div>
</slot>
</td>
</tr>
</template>
<tr v-if="!rows.length">
<td :colspan="visibleColumns.length + (expandable ? 1 : 0)" class="empty-row">{{ emptyText }}</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
rows: {
type: Array,
default: () => []
},
columns: {
type: Array,
default: () => []
},
rowKey: {
type: [String, Function],
default: "id"
},
expandedRows: {
type: Array,
default: () => []
},
emptyText: {
type: String,
default: "暂无数据"
}
});
const emit = defineEmits(["update:expandedRows"]);
const visibleColumns = computed(() => props.columns.filter((column) => !column.hidden && !column.detailOnly));
const detailColumns = computed(() => props.columns.filter((column) => column.detailOnly));
const expandable = computed(() => detailColumns.value.length > 0);
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
function isExpanded(row) {
return props.expandedRows.includes(getRowKey(row));
}
function toggleRow(row) {
const key = getRowKey(row);
const nextRows = isExpanded(row) ? props.expandedRows.filter((item) => item !== key) : [...props.expandedRows, key];
emit("update:expandedRows", nextRows);
}
function cellClass(column) {
return {
"drawer-cell-number": column.type === "number" || column.align === "right",
"drawer-cell-status": column.type === "status",
"drawer-cell-long": column.type === "code" || column.type === "longText"
};
}
function fallbackClass(column) {
return {
"drawer-cell-code": column.type === "code",
"drawer-cell-ellipsis": column.type === "longText" || column.type === "code"
};
}
function formatFallback(row, column) {
if (typeof column.format === "function") {
return column.format(row[column.key], row);
}
const value = row?.[column.key];
return value === null || value === undefined || value === "" ? "-" : String(value);
}
</script>
```
- [ ] **Step 4: Add component CSS**
Append to `frontend/src/styles/main.css`:
```css
.drawer-data-table-wrap {
width: 100%;
max-width: 100%;
overflow: hidden;
}
.drawer-data-table {
width: 100%;
min-width: 0;
table-layout: fixed;
}
.drawer-data-table th,
.drawer-data-table td {
min-width: 0;
}
.drawer-table-head-label {
display: inline-block;
max-width: 100%;
line-height: 1.35;
white-space: normal;
word-break: keep-all;
}
.drawer-cell-actions {
text-align: center;
}
.mini-icon-button {
border: 1px solid #d9e6f5;
border-radius: 999px;
background: #f5f9ff;
color: #2f74c8;
font-size: 12px;
padding: 4px 9px;
cursor: pointer;
}
.drawer-row-detail td {
background: #f8fbff;
}
.drawer-row-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.drawer-row-detail-grid article {
display: grid;
gap: 4px;
padding: 10px 12px;
border: 1px solid #e4edf7;
border-radius: 12px;
background: #ffffff;
}
.drawer-row-detail-grid span {
color: #7a8699;
font-size: 12px;
}
.drawer-row-detail-grid strong {
color: #1f2d3d;
font-size: 13px;
overflow-wrap: anywhere;
}
```
- [ ] **Step 5: Run static component test**
Run:
```bash
cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs
```
Expected: PASS with `drawer data table static tests passed`.
---
### Task 4: Migrate Warehouse Drawer Tables
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- [ ] **Step 1: Import `DrawerDataTable` and add expansion state**
Add import:
```js
import DrawerDataTable from "../components/DrawerDataTable.vue";
```
Add refs near the other table page refs:
```js
const expandedLotRows = ref([]);
const expandedTxnRows = ref([]);
```
- [ ] **Step 2: Add column definitions**
Add computed columns near the table-related computed values:
```js
const lotDrawerColumns = computed(() => [
{ key: "lot_no", label: "库存批次号", compactLabel: "批次号", type: "code", width: "220px" },
...(isSelectedFinishedWarehouse.value ? [{ key: "inbound_qty", label: "入库数量", compactLabel: "入库数", type: "number", width: "96px", format: (_value, row) => formatQty(row.inbound_qty) }] : []),
...(selectedWeightOnly.value ? [] : [{ key: "remaining_qty", label: isSelectedFinishedWarehouse.value ? "剩余数量" : "变动数量", compactLabel: isSelectedFinishedWarehouse.value ? "剩余数" : "变动数", type: "number", width: "96px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.remaining_qty, "PIECE") : formatQty(row.remaining_qty) }]),
{ key: "inbound_weight_kg", label: "入库重量(kg)", compactLabel: "入库重量", type: "number", width: "110px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.inbound_weight_kg, "WEIGHT") : formatWeight(row.inbound_weight_kg) },
{ key: "remaining_weight_kg", label: "剩余重量(kg)", compactLabel: "剩余重量", type: "number", width: "110px", format: (_value, row) => selectedWarehouseType.value === "SEMI" ? formatSemiMeasureValue(row, row.remaining_weight_kg, "WEIGHT") : formatWeight(row.remaining_weight_kg) },
{ key: "unit_cost", label: selectedUnitCostColumnLabel.value, compactLabel: isSelectedFinishedWarehouse.value ? "材料成本" : "单价", type: "number", width: "116px", format: (_value, row) => formatSelectedUnitCost(row.unit_cost) },
{ key: "status", label: "状态", type: "status", width: "90px" },
{ key: "warehouse_name", label: "仓库", detailOnly: true },
{ key: "source_purchase_order_no", label: "采购单号", detailOnly: true },
{ key: "source_receipt_no", label: "入库单号", detailOnly: true },
{ key: "source_material_summary", label: "来源库存批次", detailOnly: true },
{ key: "logistics_waybill_no", label: "运单号", detailOnly: true },
{ key: "logistics_freight_amount", label: "运费", detailOnly: true, format: (_value, row) => formatFreight(row.logistics_freight_amount) },
{ key: "quality_release", label: "质检放行人", detailOnly: true, format: (_value, row) => row.item_type === "RAW_MATERIAL" ? formatLotQualityRelease(row) : "-" }
]);
const txnDrawerColumns = computed(() => [
{ key: "biz_time", label: "时间", type: "longText", width: "128px", format: (_value, row) => formatDateTime(row.biz_time) },
{ key: "txn_type", label: "类型", width: "94px", format: (_value, row) => formatInventoryTxnTypeLabel(row.txn_type) },
{ key: "lot_no", label: "库存批次号", compactLabel: "批次号", type: "code", width: "220px" },
...(selectedWeightOnly.value ? [] : [{ key: "qty_change", label: "变动数量", compactLabel: "变动数", type: "number", width: "96px", format: (_value, row) => formatQty(row.qty_change) }]),
{ key: "weight_change_kg", label: "变动重量(kg)", compactLabel: "变动重量", type: "number", width: "110px", format: (_value, row) => formatWeight(row.weight_change_kg) },
{ key: "unit_cost", label: isSelectedFinishedWarehouse.value ? "材料成本" : "单价", type: "number", width: "116px", format: (_value, row) => formatSelectedUnitCost(row.unit_cost) },
{ key: "amount", label: "金额", type: "number", width: "104px", format: (_value, row) => `¥${formatAmount(row.amount)}` },
{ key: "source", label: "来源", detailOnly: true, format: (_value, row) => `${formatSourceDocTypeLabel(row.source_doc_type)} #${row.source_doc_id || "-"}` },
{ key: "remark", label: "备注", detailOnly: true }
]);
```
- [ ] **Step 3: Replace the lot detail table**
Replace the current `库存批次明细` table markup with:
```vue
<DrawerDataTable
v-model:expanded-rows="expandedLotRows"
:rows="paginatedLots"
:columns="lotDrawerColumns"
row-key="lot_id"
:empty-text="`暂无该${selectedDetailEntityLabel}库存批次`"
>
<template #cell-status="{ row }">
<span class="status-pill" :class="lotStatusClass(row)">{{ formatStatusLabel(row.status) }}</span>
</template>
<template #cell-lot_no="{ row }">
<span class="drawer-cell-code" :title="row.lot_no">{{ row.lot_no || "-" }}</span>
</template>
<template #detail="{ row }">
<div class="drawer-row-detail-grid">
<article><span>仓库</span><strong>{{ row.warehouse_name || "-" }}</strong></article>
<article><span>采购单号</span><strong>{{ row.source_purchase_order_no || "-" }}</strong></article>
<article><span>入库单号</span><strong>{{ row.source_receipt_no || "-" }}</strong></article>
<article><span>来源库存批次</span><strong>{{ row.source_material_summary || formatLotSourceLots(row) }}</strong></article>
<article><span>运单号</span><strong>{{ row.logistics_waybill_no || "-" }}</strong></article>
<article><span>运费</span><strong>{{ formatFreight(row.logistics_freight_amount) }}</strong></article>
<article><span>质检放行人</span><strong>{{ row.item_type === "RAW_MATERIAL" ? formatLotQualityRelease(row) : "-" }}</strong></article>
</div>
</template>
</DrawerDataTable>
```
- [ ] **Step 4: Replace the transaction table**
Replace the current `库存流水` table markup with:
```vue
<DrawerDataTable
v-model:expanded-rows="expandedTxnRows"
:rows="paginatedTransactions"
:columns="txnDrawerColumns"
row-key="inventory_txn_id"
:empty-text="`暂无该${selectedDetailEntityLabel}库存流水`"
>
<template #cell-lot_no="{ row }">
<span class="drawer-cell-code" :title="row.lot_no || ''">{{ row.lot_no || "-" }}</span>
</template>
<template #detail="{ row }">
<div class="drawer-row-detail-grid">
<article><span>来源</span><strong>{{ formatSourceDocTypeLabel(row.source_doc_type) }} #{{ row.source_doc_id || "-" }}</strong></article>
<article><span>备注</span><strong>{{ row.remark || "-" }}</strong></article>
</div>
</template>
</DrawerDataTable>
```
- [ ] **Step 5: Use a larger drawer size for complex inventory detail**
Find the warehouse detail drawer container in `InventoryLedgerView.vue`. If it uses a custom dual drawer mask, add a class that enables fullscreen width:
```vue
<div v-if="detailDrawerOpen" class="workflow-drawer-mask dual-drawer-mask inventory-ledger-detail-mask" @click.self="closeDetailDrawer">
```
Add CSS:
```css
.inventory-ledger-detail-mask .dual-drawer-layout {
width: min(1760px, calc(100vw - 28px));
max-width: calc(100vw - 28px);
}
.inventory-ledger-detail-mask .workflow-drawer {
min-width: 0;
}
```
- [ ] **Step 6: Run focused frontend tests**
Run:
```bash
cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs
node scripts/test-inventory-ledger-options.mjs
npm run build
```
Expected:
- Static drawer table test passes.
- Existing inventory option test passes.
- Vite build passes.
---
### Task 5: Apply the Same Pattern to Other Drawers
**Files:**
- Modify high-impact drawer pages after warehouse migration:
- `frontend/src/views/PurchaseOrderView.vue`
- `frontend/src/views/DeliveryManagementView.vue`
- `frontend/src/views/WorkOrderManagementView.vue`
- `frontend/src/views/CompletionReceiptView.vue`
- `frontend/src/components/ProductWorkflowDrawer.vue`
- [ ] **Step 1: Identify drawer tables that exceed 7 columns**
Run:
```bash
cd /Volumes/ForgeFlow-ERP
rg -n "<table class=\"data-table|<table class=\"data-table compact-table" frontend/src/views frontend/src/components
```
Expected: list all drawer tables. Prioritize tables inside `FormDrawer`, `.workflow-drawer`, or custom drawer masks.
- [ ] **Step 2: For each drawer table, classify columns**
Use this rule:
```text
Main table: 5-7 business-critical columns.
Detail row: long text, source documents, remarks, logistics, photos, secondary IDs.
Status: always visible as colored pill.
Money/quantity/weight: visible only when it is part of the user's immediate decision.
```
- [ ] **Step 3: Migrate one page at a time**
For each target page:
1. Import `DrawerDataTable`.
2. Add `expandedRows` ref.
3. Add `columns` computed.
4. Replace raw drawer table with `DrawerDataTable`.
5. Keep existing `TableControls` and `PaginationBar`.
6. Run `npm run build`.
Example import and state:
```js
import DrawerDataTable from "../components/DrawerDataTable.vue";
const expandedRows = ref([]);
```
- [ ] **Step 4: Stop when all common drawers follow the new pattern**
Acceptance criteria:
- No drawer table header displays vertically.
- No drawer table requires horizontal scrolling for the default visible columns.
- Long codes are shown as chips or ellipsis with `title`.
- Secondary details are visible by expanding the row.
---
### Task 6: Verification
**Files:**
- Test: `frontend/scripts/test-drawer-data-table.mjs`
- Test: `frontend/scripts/test-inventory-ledger-options.mjs`
- [ ] **Step 1: Run static and build checks**
Run:
```bash
cd /Volumes/ForgeFlow-ERP/frontend
node scripts/test-drawer-data-table.mjs
node scripts/test-inventory-ledger-options.mjs
npm run build
```
Expected: all pass.
- [ ] **Step 2: Manual browser verification**
Open the ERP in the browser and check:
```text
1. 嘉恒仓库 -> 成品库 -> 产品明细抽屉
2. 库存批次明细表
3. 库存流水表
4. 采购订单详情抽屉
5. 发货/销售出库详情抽屉
```
Expected:
- No vertical table headers.
- No ugly horizontal scrollbar for normal data.
- Long batch numbers do not deform the table.
- Row expand details reveal hidden fields clearly.
- Pagination bar remains visually consistent.
---
## Self-Review
- Spec coverage: The plan covers drawer width, table header readability, horizontal-scroll reduction, long-code handling, detail expansion, and migration of warehouse plus other high-impact drawers.
- Placeholder scan: No `TBD`, `TODO`, or unspecified “handle later” steps remain.
- Type consistency: `DrawerDataTable` props are consistently named `rows`, `columns`, `rowKey`, `expandedRows`, and `emptyText`; slot names use `cell-${column.key}` and `detail`.
- Risk note: The current project directory is mounted at `/Volumes/ForgeFlow-ERP` and is not detected as a git repository, so commit steps are intentionally omitted.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,107 @@
# Semi-Finished Measure Basis 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 semi-finished inventory use one locked measure basis per semi-finished item-operation in a warehouse: by piece or by weight.
**Architecture:** Do not add a database column unless tests prove derivation is insufficient. Derive the measure basis from semi-finished stock lots: `remaining_qty/inbound_qty` means by piece, `remaining_weight_kg/inbound_weight_kg` means by weight, both means legacy mixed. Backend enforces exactly one inbound basis and blocks mismatched new inbound for existing semi-finished stock; frontend presents only the correct input and availability wording.
**Tech Stack:** FastAPI, SQLAlchemy, pytest, Vue 3, Vite, Node assertion script.
---
### Task 1: Backend Measure Basis Enforcement
**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Modify: `backend/tests/test_semi_finished_operation_inventory.py`
- [ ] **Step 1: Write failing backend tests**
Add tests that prove:
- semi-finished inbound rejects filling both quantity and weight;
- semi-finished inbound rejects filling neither quantity nor weight;
- once a semi-finished item-operation has by-piece stock, later by-weight inbound is rejected;
- once a semi-finished item-operation has by-weight stock, later by-piece inbound is rejected.
- [ ] **Step 2: Run red tests**
Run: `.venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`
Expected before implementation: at least the mismatch/both-filled tests fail because current backend accepts both fields and does not enforce existing basis.
- [ ] **Step 3: Implement minimal backend helper**
Add helper logic in `create_warehouse_inbound`:
- determine requested basis: `PIECE` when `inbound_qty > 0 and inbound_weight == 0`, `WEIGHT` when `inbound_weight > 0 and inbound_qty == 0`;
- reject both-filled and neither-filled for `WIP_IN` / `OUTSOURCING_IN`;
- query existing semi-finished lots for same `item_id`, `warehouse_id`, `source_line_id` with remaining quantity or weight;
- derive existing basis; if it is `MIXED`, reject with a Chinese message asking to clean historical stock first;
- if existing basis exists and differs from requested basis, reject with a Chinese message that the semi-finished item must follow existing measure basis.
- [ ] **Step 4: Run backend tests**
Run: `.venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`
Expected: all semi-finished tests pass.
### Task 2: Frontend Semi-Finished UI Basis
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/utils/inventoryOperationOptions.js`
- Modify: `frontend/scripts/test-inventory-ledger-options.mjs`
- [ ] **Step 1: Write/update frontend assertions**
Assert the source contains:
- user-facing label `半成品` instead of semi warehouse `产品工序`;
- `入库类型`, `按件入库`, `按重入库`;
- helper logic that formats semi availability by basis instead of always `formatWeight(...)`;
- payload sends only `inbound_qty` for piece basis and only `inbound_weight_kg` for weight basis.
- [ ] **Step 2: Run red frontend assertion**
Run: `node scripts/test-inventory-ledger-options.mjs`
Expected before implementation: fails on missing measure-basis UI or payload logic.
- [ ] **Step 3: Implement frontend UI**
In `InventoryLedgerView.vue`:
- rename semi item field labels from `产品工序` to `半成品`;
- add `genericForm.measure_basis`;
- when semi inbound item changes, derive existing basis from `semiFinishedBalances` and auto-fill/lock `measure_basis`;
- show only `入库数量` for `PIECE`, only `入库重量(kg)` for `WEIGHT`;
- for semi outbound, derive basis from selected semi option and show only the matching outbound input;
- display `-` for irrelevant quantity/weight values in semi balances and lots.
In `inventoryOperationOptions.js`:
- carry `measure_basis` through semi outbound options.
- [ ] **Step 4: Run frontend checks**
Run: `node scripts/test-inventory-ledger-options.mjs`
Expected: assertion script passes.
Run: `npm run build`
Expected: Vite build completes successfully.
### Task 3: Final Verification
**Files:**
- Verify only.
- [ ] **Step 1: Run backend full test suite**
Run: `.venv/bin/python -m pytest tests -q`
Expected: all backend tests pass.
- [ ] **Step 2: Browser smoke**
Run frontend dev server and open `http://127.0.0.1:5173/` in the in-app browser.
Expected: page loads with title `嘉恒智能五金 ERP` and no console errors.
@@ -0,0 +1,593 @@
# 半成品产品工序库存 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:** 把嘉恒仓库的半成品库从“通用物料/产品库存”改为“产品 + 已完成工序”的半成品库存模型。
**Architecture:** 后端继续使用 `md_item.id` 表示产品,但用 `StockLot.source_line_id` 和 `InventoryTxn.source_line_id` 记录产品需规清单中的 `route_operation_id`,从而区分同一产品不同工序阶段的半成品。前端半成品入库从 `/master-data/process-routes` 展开产品工序选项,半成品出库和库存明细按 `item_id + source_line_id` 过滤与展示,避免跨工序混扣库存。
**Tech Stack:** FastAPI + SQLAlchemy + Pytest;Vue 3 + Vite + 现有静态 Node 测试脚本。
---
### Task 1: 前端选项工具增加产品工序选项
**Files:**
- Modify: `frontend/src/utils/inventoryOperationOptions.js`
- Test: `frontend/scripts/test-inventory-ledger-options.mjs`
- [ ] **Step 1: Write the failing test**
Add assertions to `frontend/scripts/test-inventory-ledger-options.mjs` after `productMasterRows`:
```js
const productOperationRows = [
{
option_key: "601::7001",
item_id: 601,
item_code: "产品需规00003",
item_name: "委外成品",
route_id: 8001,
route_operation_id: 7001,
seq_no: 1,
operation_name: "冲压"
},
{
option_key: "601::7002",
item_id: 601,
item_code: "产品需规00003",
item_name: "委外成品",
route_id: 8001,
route_operation_id: 7002,
seq_no: 2,
operation_name: "折弯"
}
];
```
Change the semi inbound test to pass `productOperations: productOperationRows` and expect both operation keys:
```js
assert.deepEqual(semiInboundOptions.map((item) => item.option_key), ["601::7001", "601::7002"]);
assert.deepEqual(semiInboundOptions.map((item) => item.route_operation_id), [7001, 7002]);
assert.deepEqual(finishedInboundOptions.map((item) => item.item_id), [601]);
```
- [ ] **Step 2: Run test to verify it fails**
Run: `node frontend/scripts/test-inventory-ledger-options.mjs`
Expected: FAIL because `buildGenericItemOptions` ignores `productOperations`.
- [ ] **Step 3: Implement minimal option behavior**
In `frontend/src/utils/inventoryOperationOptions.js`, add `productOperations = []` to the function parameters and return `productOperations` for semi inbound:
```js
export function buildGenericItemOptions({
direction = "in",
activeInventoryTab = "raw",
activeWarehouseType = "",
warehouseId = "",
balances = [],
materials = [],
products = [],
productOperations = []
} = {}) {
if (String(direction || "").toLowerCase() === "out") {
return buildOutboundItemOptions({
balances,
warehouseId,
warehouseType: activeWarehouseType,
activeInventoryTab
});
}
if (activeInventoryTab === "semi" || String(activeWarehouseType || "").toUpperCase() === "SEMI") {
return productOperations;
}
if (activeInventoryTab === "scrap" || String(activeWarehouseType || "").toUpperCase() === "SCRAP") {
return [...materials, ...products];
}
if (["raw", "auxiliary"].includes(activeInventoryTab)) {
return materials;
}
if (activeInventoryTab === "return" || String(activeWarehouseType || "").toUpperCase() === "RETURN") {
return products;
}
return products;
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `node frontend/scripts/test-inventory-ledger-options.mjs`
Expected: PASS.
---
### Task 2: 后端保存半成品工序维度并按工序出库
**Files:**
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/services/operations.py`
- Modify: `backend/app/api/routes/inventory.py`
- Test: `backend/tests/test_semi_finished_operation_inventory.py`
- [ ] **Step 1: Write failing backend tests**
Create `backend/tests/test_semi_finished_operation_inventory.py` with two tests:
```python
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import select
from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound
from app.models.master_data import Item, Process, ProcessRoute, ProcessRouteOperation, Warehouse, WarehouseLocation, WorkCenter
from app.models.operations import InventoryTxn, StockLot
from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate
class _User:
id = 1
username = "admin"
role = "ADMIN"
class _Context:
user = _User()
def _seed_route(db):
product = Item(item_code="产品需规00001", item_name="五金支架", item_type="FINISHED_GOOD", status="ACTIVE")
process = Process(process_code="GX01", process_name="冲压", process_type="INHOUSE", status="ACTIVE")
center = WorkCenter(work_center_code="WC01", work_center_name="一号工位", status="ACTIVE")
warehouse = Warehouse(warehouse_code="WH-SEMI-T", warehouse_name="半成品测试仓", warehouse_type="SEMI", status="ACTIVE")
db.add_all([product, process, center, warehouse])
db.flush()
location = WarehouseLocation(
warehouse_id=warehouse.id,
location_code="SEMI-A",
location_name="半成品默认库位",
is_default=True,
status="ACTIVE",
)
route = ProcessRoute(
route_code="R-001",
route_name="五金支架工艺",
product_item_id=product.id,
version_no="V1.0",
is_default=True,
status="ACTIVE",
)
db.add_all([location, route])
db.flush()
op1 = ProcessRouteOperation(
route_id=route.id,
seq_no=1,
process_id=process.id,
work_center_id=center.id,
operation_name="冲压",
status="ACTIVE",
)
op2 = ProcessRouteOperation(
route_id=route.id,
seq_no=2,
process_id=process.id,
work_center_id=center.id,
operation_name="折弯",
status="ACTIVE",
)
db.add_all([op1, op2])
db.commit()
return product, warehouse, route, op1, op2
def test_semi_inbound_records_product_operation_source_line(db_session):
product, warehouse, route, op1, _ = _seed_route(db_session)
row = create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="WIP_IN",
item_id=product.id,
warehouse_id=warehouse.id,
inbound_weight_kg=120,
unit_cost=8,
source_doc_type="PRODUCT_ROUTE_OPERATION",
source_doc_id=route.id,
source_line_id=op1.id,
source_material_summary="产品:五金支架;已完成工序:第1道 冲压",
),
context=_Context(),
db=db_session,
)
lot = db_session.get(StockLot, row.lot_id)
assert lot.source_line_id == op1.id
assert lot.source_doc_id == route.id
assert "第1道" in lot.source_material_summary
txn = db_session.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))
assert txn.source_line_id == op1.id
def test_semi_outbound_deducts_only_selected_product_operation(db_session):
product, warehouse, route, op1, op2 = _seed_route(db_session)
context = _Context()
first = create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="WIP_IN",
item_id=product.id,
warehouse_id=warehouse.id,
inbound_weight_kg=50,
unit_cost=8,
source_doc_type="PRODUCT_ROUTE_OPERATION",
source_doc_id=route.id,
source_line_id=op1.id,
source_material_summary="产品:五金支架;已完成工序:第1道 冲压",
),
context=context,
db=db_session,
)
second = create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="WIP_IN",
item_id=product.id,
warehouse_id=warehouse.id,
inbound_weight_kg=80,
unit_cost=9,
source_doc_type="PRODUCT_ROUTE_OPERATION",
source_doc_id=route.id,
source_line_id=op2.id,
source_material_summary="产品:五金支架;已完成工序:第2道 折弯",
),
context=context,
db=db_session,
)
create_warehouse_outbound(
WarehouseOutboundCreate(
biz_type="WIP_OUT",
item_id=product.id,
warehouse_id=warehouse.id,
outbound_weight_kg=30,
target_doc_type="PRODUCT_ROUTE_OPERATION",
target_doc_id=route.id,
target_doc_line_id=op2.id,
target_material_sub_batch_no="第2道 折弯",
),
context=context,
db=db_session,
)
first_lot = db_session.get(StockLot, first.lot_id)
second_lot = db_session.get(StockLot, second.lot_id)
assert first_lot.remaining_weight_kg == Decimal("50.000")
assert second_lot.remaining_weight_kg == Decimal("50.000")
```
- [ ] **Step 2: Run backend test to verify it fails**
Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`
Expected: FAIL because `WarehouseInboundCreate` has no `source_line_id` and inbound ignores operation.
- [ ] **Step 3: Implement schema and query fields**
Modify `backend/app/schemas/operations.py`:
```python
class StockLotRead(BaseModel):
...
source_doc_id: int
source_line_id: int | None = None
...
class WarehouseInboundCreate(BaseModel):
...
source_doc_id: int | None = None
source_line_id: int | None = None
...
```
Modify `backend/app/services/operations.py` inside `get_stock_lots_query` select list:
```python
StockLot.source_doc_id.label("source_doc_id"),
StockLot.source_line_id.label("source_line_id"),
```
- [ ] **Step 4: Implement inbound validation and persistence**
In `backend/app/api/routes/inventory.py`, import `ProcessRoute` and `ProcessRouteOperation` if missing. Add helper:
```python
def _validate_semi_product_operation(db: Session, item_id: int, source_line_id: int | None) -> ProcessRouteOperation:
if not source_line_id:
raise HTTPException(status_code=400, detail="半成品入出库必须选择产品工序")
operation = db.get(ProcessRouteOperation, source_line_id)
if not operation:
raise HTTPException(status_code=404, detail="产品工序不存在")
route = db.get(ProcessRoute, operation.route_id)
if not route or route.product_item_id != item_id:
raise HTTPException(status_code=400, detail="产品工序与所选产品不一致")
if str(operation.status or "").upper() != "ACTIVE":
raise HTTPException(status_code=400, detail="停用的产品工序不能用于半成品入出库")
return operation
```
Before creating a new half-finished inbound lot:
```python
if warehouse_type == "SEMI" and biz_type in {"WIP_IN", "OUTSOURCING_IN"}:
operation = _validate_semi_product_operation(db, payload.item_id, payload.source_line_id)
source_doc_type = payload.source_doc_type or "PRODUCT_ROUTE_OPERATION"
source_doc_id = payload.source_doc_id or operation.route_id
source_line_id = operation.id
else:
source_doc_id = payload.source_doc_id or 0
source_line_id = payload.source_line_id
```
Then use `source_doc_id` and `source_line_id` when creating `StockLot` and `InventoryTxn`.
- [ ] **Step 5: Implement semi outbound operation filter**
In `create_warehouse_outbound`, before FIFO query for non-selected lots:
```python
if warehouse_type == "SEMI" and biz_type in {"WIP_OUT", "OUTSOURCING_OUT"}:
_validate_semi_product_operation(db, payload.item_id, payload.target_doc_line_id)
```
Add this filter to `lot_filters`:
```python
if warehouse_type == "SEMI" and payload.target_doc_line_id:
lot_filters.append(StockLot.source_line_id == payload.target_doc_line_id)
```
- [ ] **Step 6: Run backend test to verify it passes**
Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`
Expected: PASS.
---
### Task 3: 前端半成品页面按产品工序展示、提交和过滤
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Test: `frontend/scripts/test-inventory-ledger-options.mjs`
- [ ] **Step 1: Add process route loading state**
Add:
```js
const processRoutes = ref([]);
```
Update `loadAll()` to fetch:
```js
fetchResource("/master-data/process-routes?limit=500", [])
```
and assign:
```js
processRoutes.value = processRouteRows;
```
- [ ] **Step 2: Build active product operation options**
Add computed:
```js
const productOperationOptions = computed(() =>
processRoutes.value
.filter((route) => String(route.status || "").toUpperCase() === "ACTIVE")
.flatMap((route) =>
(route.operations || [])
.filter((operation) => String(operation.status || "").toUpperCase() === "ACTIVE")
.map((operation) => ({
option_key: `${route.product_item_id}::${operation.route_operation_id}`,
item_id: Number(route.product_item_id),
item_code: route.product_code,
item_name: route.product_name,
version_no: route.version_no,
route_id: Number(route.route_id),
route_operation_id: Number(operation.route_operation_id),
seq_no: Number(operation.seq_no || 0),
operation_name: operation.operation_name || operation.process_name || "",
source_material_summary: `产品:${route.product_name};已完成工序:第${operation.seq_no}道 ${operation.operation_name || operation.process_name || ""}`
}))
)
.sort((left, right) =>
String(left.item_code || "").localeCompare(String(right.item_code || ""), "zh-CN", { numeric: true }) ||
Number(left.seq_no || 0) - Number(right.seq_no || 0)
)
);
```
- [ ] **Step 3: Change generic form selection to option key**
Extend `buildEmptyGenericForm()`:
```js
item_option_key: "",
source_doc_id: null,
source_line_id: null,
```
Add:
```js
const isSemiWarehouseOperation = computed(() => activeWarehouseType.value === "SEMI");
const genericItemFieldLabel = computed(() => (isSemiWarehouseOperation.value ? "产品工序" : activeInventoryTab.value === "finished" || activeInventoryTab.value === "return" ? "产品" : "物料"));
const genericItemPlaceholder = computed(() => `请选择${genericItemFieldLabel.value}`);
const selectedGenericItemOption = computed(() =>
genericItemOptions.value.find((item) => String(item.option_key || item.item_id) === String(genericForm.item_option_key)) || null
);
```
Change the generic item `<select>` to bind `genericForm.item_option_key` and show `genericItemFieldLabel`.
- [ ] **Step 4: Submit semi operation metadata**
Before POST in `submitGenericOperation()`, derive:
```js
const selectedOption = selectedGenericItemOption.value;
const selectedItemId = Number(selectedOption?.item_id || genericForm.item_id);
const selectedSourceDocId = selectedOption?.route_id || genericForm.source_doc_id || null;
const selectedSourceLineId = selectedOption?.route_operation_id || genericForm.source_line_id || null;
const selectedSourceSummary = selectedOption?.source_material_summary || genericForm.provider_name || genericForm.source_material_sub_batch_no || null;
```
Use `selectedItemId` for `item_id`. For semi inbound send:
```js
source_doc_type: "PRODUCT_ROUTE_OPERATION",
source_doc_id: selectedSourceDocId,
source_line_id: selectedSourceLineId,
source_material_summary: selectedSourceSummary,
```
For semi outbound send:
```js
target_doc_type: "PRODUCT_ROUTE_OPERATION",
target_doc_id: selectedSourceDocId,
target_doc_line_id: selectedSourceLineId,
target_material_sub_batch_no: selectedOption ? `第${selectedOption.seq_no}道 ${selectedOption.operation_name}` : null,
```
- [ ] **Step 5: Display semi balances by product operation**
Create `semiFinishedBalances` from `lots` instead of `balances`, grouped by `item_id + source_line_id`:
```js
const semiFinishedBalances = computed(() => aggregateSemiOperationBalances(lots.value.filter((item) => normalizeWarehouseType(item) === "SEMI")));
```
Implement:
```js
function aggregateSemiOperationBalances(rows) {
const map = new Map();
rows.forEach((lot) => {
const key = `${Number(lot.item_id)}::${Number(lot.source_line_id || 0)}`;
if (!map.has(key)) {
map.set(key, {
...lot,
stock_balance_id: `SEMI-${key}`,
source_line_id: lot.source_line_id || null,
operation_label: lot.source_line_id ? lot.source_material_summary || "产品工序未说明" : "未绑定工序",
qty_on_hand: 0,
weight_on_hand_kg: 0,
qty_available: 0,
weight_available_kg: 0,
qty_allocated: 0,
weight_allocated_kg: 0,
avg_unit_cost: 0,
_cost_amount: 0,
_weight_for_cost: 0,
_warehouses: new Set(),
_locations: new Set()
});
}
const row = map.get(key);
row.weight_on_hand_kg += Number(lot.remaining_weight_kg || 0);
row.weight_available_kg += String(lot.status || "").toUpperCase() === "AVAILABLE" ? Number(lot.remaining_weight_kg || 0) : 0;
row._cost_amount += Number(lot.unit_cost || 0) * Number(lot.remaining_weight_kg || 0);
row._weight_for_cost += Number(lot.remaining_weight_kg || 0);
row._warehouses.add(lot.warehouse_name);
if (lot.location_name) {
row._locations.add(lot.location_name);
}
});
return Array.from(map.values()).map((item) => ({
...item,
warehouse_name: Array.from(item._warehouses).join(" / ") || "-",
location_name: Array.from(item._locations).join(" / ") || "-",
avg_unit_cost: item._weight_for_cost > 0 ? item._cost_amount / item._weight_for_cost : 0
}));
}
```
- [ ] **Step 6: Filter detail drawer by operation**
In `selectedLots`, add:
```js
if (selectedWarehouseType.value === "SEMI" && selectedBalance.value?.source_line_id !== undefined) {
return rows.filter((lot) => Number(lot.source_line_id || 0) === Number(selectedBalance.value.source_line_id || 0));
}
```
In `selectedTransactions`, add the same `source_line_id` filter for `SEMI`.
- [ ] **Step 7: Run frontend tests**
Run:
```bash
node frontend/scripts/test-inventory-ledger-options.mjs
npm run build
```
Expected: both PASS.
---
### Task 4: 全量验证
**Files:**
- No direct file changes; verification only.
- [ ] **Step 1: Run focused backend tests**
Run: `cd backend && .venv/bin/python -m pytest tests/test_semi_finished_operation_inventory.py -q`
Expected: PASS.
- [ ] **Step 2: Run full backend suite**
Run: `cd backend && .venv/bin/python -m pytest tests -q`
Expected: PASS.
- [ ] **Step 3: Run frontend option test and build**
Run:
```bash
node frontend/scripts/test-inventory-ledger-options.mjs
npm run build
```
Expected: PASS.
- [ ] **Step 4: Manual UI sanity check if local server is already running**
Open the inventory page, select `嘉恒仓库 -> 半成品库` and verify:
```text
半成品入库字段显示“产品工序”
下拉项显示“产品编码 · 产品名 · 第N道工序 · 工序名”
库存总览按“同产品不同工序”拆成多行
点开明细只看到该产品工序对应的批次与流水
```
---
### Self-Review
- Spec coverage: 计划覆盖用户确认的方案 A,包括产品工序作为半成品库存对象、入库选产品工序、出库按工序扣减、明细和流水按工序过滤。
- Placeholder scan: 无 `TBD`、`TODO` 或“后续实现”占位。
- Type consistency: 前端统一使用 `route_operation_id/source_line_id`,后端统一使用 `source_line_id` 保存产品工序,`source_doc_id` 保存工艺路线。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,810 @@
# 系统权限管理脑图拖动画布与维护页表格化 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:** 让系统权限管理中的组织脑图支持拖动画布和刷新后自动适配一屏,同时把“人员与角色维护”改成“系统人员 / 角色维护”子页签加全局表格分页样式。
**Architecture:** `SystemPermissionView.vue` 继续负责页面状态、页签、分页和工具栏;`OrgMindMap.vue` 只负责脑图布局、画布视口拖动和根据父页面请求计算适配缩放。测试沿用现有静态断言脚本,先新增失败断言,再逐步实现。
**Tech Stack:** Vue 3 `<script setup>`、现有 `PaginationBar`、现有 `usePagination`、Vite、Node 静态断言测试。
---
## File Structure
- Modify: `frontend/scripts/test-system-permission-management-ui.mjs`
- 增加脑图画布拖动、刷新自适应、维护页子页签、表格分页的静态断言。
- Modify: `frontend/src/components/OrgMindMap.vue`
- 增加画布视口 ref、鼠标拖动画布滚动、适配一屏计算、向父页面回传缩放比例。
- Modify: `frontend/src/views/SystemPermissionView.vue`
- 传入脑图适配请求 key,刷新后触发适配;把维护页左右卡片改为子页签、表格、分页。
- No SQL changes.
- No backend changes.
---
### Task 1: Write Failing Static Assertions
**Files:**
- Modify: `frontend/scripts/test-system-permission-management-ui.mjs`
- [ ] **Step 1: Add failing assertions for the new interaction and layout requirements**
Add the following assertions after the existing `mindMapScale` / `refreshMindMap` assertions:
```js
assert.match(systemPermissionSource, /mindMapFitRequestKey/, "page should request one-screen mind-map fitting");
assert.match(
systemPermissionSource,
/:fit-request-key="mindMapFitRequestKey"/,
"SystemPermissionView should pass fit request key to OrgMindMap"
);
assert.match(
systemPermissionSource,
/@fit-scale="applyMindMapFitScale"/,
"SystemPermissionView should accept fit scale from OrgMindMap"
);
assert.match(
systemPermissionSource,
/function applyMindMapFitScale/,
"SystemPermissionView should apply one-screen fit scale"
);
assert.match(
systemPermissionSource,
/mindMapFitRequestKey\.value \+= 1/,
"refresh should request a one-screen fit after reloading mind-map data"
);
```
Add the following assertions after the existing `人员与角色维护` assertions:
```js
assert.match(systemPermissionSource, /maintenanceSubTab/, "maintenance page should have nested personnel and role tabs");
assert.match(systemPermissionSource, /system-permission-sub-tabs/, "maintenance page should render nested sub tabs");
assert.match(
systemPermissionSource,
/v-if="maintenanceSubTab === 'users'"/,
"system users should render in their own maintenance sub tab"
);
assert.match(
systemPermissionSource,
/v-if="maintenanceSubTab === 'roles'"/,
"roles should render in their own maintenance sub tab"
);
assert.match(systemPermissionSource, /import PaginationBar from "\.\.\/components\/PaginationBar\.vue";/, "page should use global PaginationBar");
assert.match(systemPermissionSource, /import \{ usePagination \} from "\.\.\/utils\/pagination";/, "page should use global pagination helper");
assert.match(systemPermissionSource, /paginatedUsers/, "system users table should use paginated rows");
assert.match(systemPermissionSource, /paginatedRoles/, "roles table should use paginated rows");
assert.match(systemPermissionSource, /<table class="data-table compact-table">/, "maintenance sub tabs should use global table styling");
assert.doesNotMatch(systemPermissionSource, /class="maintenance-grid"/, "maintenance page should not use the old side-by-side card grid");
assert.doesNotMatch(systemPermissionSource, /<ul class="permission-list">[\s\S]*v-for="role in roles"/, "roles should not render as old card list");
assert.doesNotMatch(systemPermissionSource, /<ul class="permission-list">[\s\S]*v-for="user in users"/, "users should not render as old card list");
```
Add the following assertions near the existing `OrgMindMap` assertions:
```js
assert.match(orgMindMapSource, /fitRequestKey/, "OrgMindMap should accept fit request key");
assert.match(orgMindMapSource, /fit-scale/, "OrgMindMap should emit fit scale");
assert.match(orgMindMapSource, /mindMapViewport/, "OrgMindMap should keep a viewport ref for canvas panning");
assert.match(orgMindMapSource, /isCanvasPanning/, "OrgMindMap should track canvas panning state");
assert.match(orgMindMapSource, /startCanvasPan/, "OrgMindMap should start canvas panning from empty canvas space");
assert.match(orgMindMapSource, /moveCanvasPan/, "OrgMindMap should move the viewport while panning");
assert.match(orgMindMapSource, /endCanvasPan/, "OrgMindMap should stop canvas panning");
assert.match(orgMindMapSource, /fitCanvasToViewport/, "OrgMindMap should fit canvas to visible viewport");
assert.match(orgMindMapSource, /window\.addEventListener\("mousemove", moveCanvasPan\)/, "OrgMindMap should pan canvas through window mousemove listener");
assert.match(orgMindMapSource, /window\.removeEventListener\("mousemove", moveCanvasPan\)/, "OrgMindMap should clean up canvas pan listener");
assert.match(orgMindMapSource, /closest\?\.\("\.org-mind-map-node"\)/, "canvas panning should not start from node cards");
assert.match(orgMindMapSource, /defineExpose/, "OrgMindMap should expose fit helper for explicit refresh handling if needed");
```
- [ ] **Step 2: Run the static test and confirm it fails for missing behavior**
Run:
```bash
node frontend/scripts/test-system-permission-management-ui.mjs
```
Expected result:
```text
AssertionError [ERR_ASSERTION]
```
The first failure should reference one of the new missing symbols, such as `mindMapFitRequestKey`, `maintenanceSubTab`, or `fitRequestKey`.
---
### Task 2: Add Canvas Panning and One-Screen Fit to OrgMindMap
**Files:**
- Modify: `frontend/src/components/OrgMindMap.vue`
- [ ] **Step 1: Extend the template with a draggable viewport**
Replace the opening wrapper:
```vue
<div class="org-mind-map">
```
with:
```vue
<div
ref="mindMapViewport"
class="org-mind-map"
:class="{ 'org-mind-map--panning': isCanvasPanning }"
@mousedown="startCanvasPan"
>
```
Keep the SVG and node event handlers as they are. Do not add `draggable="true"` to any node.
- [ ] **Step 2: Extend script imports and props/emits**
Replace:
```js
import { computed, ref } from "vue";
```
with:
```js
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
```
Extend props:
```js
const props = defineProps({
nodes: {
type: Array,
default: () => []
},
zoomScale: {
type: Number,
default: 1
},
fitRequestKey: {
type: Number,
default: 0
}
});
```
Replace emits:
```js
const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves"]);
```
with:
```js
const emit = defineEmits(["select-node", "node-contextmenu", "toggle-employee-leaves", "fit-scale"]);
```
- [ ] **Step 3: Add viewport panning state**
Add below `const ROOT_GROUP_GAP = 1;`:
```js
const FIT_SCALE_MIN = 0.12;
const FIT_SCALE_MAX = 1;
const mindMapViewport = ref(null);
const isCanvasPanning = ref(false);
const canvasPan = ref({
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0
});
```
- [ ] **Step 4: Add panning functions**
Add below `toggleEmployeeLeaves`:
```js
function shouldIgnoreCanvasPan(event) {
const target = event.target;
return Boolean(
target?.closest?.(".org-mind-map-node") ||
target?.closest?.(".org-mind-map-employee-toggle")
);
}
function startCanvasPan(event) {
if (event.button !== 0 || shouldIgnoreCanvasPan(event)) {
return;
}
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
isCanvasPanning.value = true;
canvasPan.value = {
startX: event.clientX,
startY: event.clientY,
scrollLeft: viewport.scrollLeft,
scrollTop: viewport.scrollTop
};
window.addEventListener("mousemove", moveCanvasPan);
window.addEventListener("mouseup", endCanvasPan);
}
function moveCanvasPan(event) {
if (!isCanvasPanning.value) {
return;
}
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
const deltaX = event.clientX - canvasPan.value.startX;
const deltaY = event.clientY - canvasPan.value.startY;
viewport.scrollLeft = canvasPan.value.scrollLeft - deltaX;
viewport.scrollTop = canvasPan.value.scrollTop - deltaY;
}
function endCanvasPan() {
isCanvasPanning.value = false;
window.removeEventListener("mousemove", moveCanvasPan);
window.removeEventListener("mouseup", endCanvasPan);
}
```
- [ ] **Step 5: Add one-screen fit helper**
Add below `scaledCanvasStyle`:
```js
function centerCanvasInViewport() {
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
viewport.scrollLeft = Math.max(0, (viewport.scrollWidth - viewport.clientWidth) / 2);
viewport.scrollTop = Math.max(0, (viewport.scrollHeight - viewport.clientHeight) / 2);
}
async function fitCanvasToViewport() {
await nextTick();
const viewport = mindMapViewport.value;
if (!viewport) {
return;
}
const availableWidth = Math.max(1, viewport.clientWidth - 24);
const availableHeight = Math.max(1, viewport.clientHeight - 24);
const widthScale = availableWidth / canvasWidth.value;
const heightScale = availableHeight / canvasHeight.value;
const nextScale = clamp(Math.min(widthScale, heightScale), FIT_SCALE_MIN, FIT_SCALE_MAX);
emit("fit-scale", Number(nextScale.toFixed(2)));
requestAnimationFrame(centerCanvasInViewport);
}
watch(
() => props.fitRequestKey,
() => {
if (props.fitRequestKey > 0) {
fitCanvasToViewport();
}
},
{ immediate: true }
);
onBeforeUnmount(() => {
endCanvasPan();
});
defineExpose({
fitCanvasToViewport
});
```
- [ ] **Step 6: Update cursor styles**
Replace the `.org-mind-map` style block with:
```css
.org-mind-map {
width: 100%;
min-height: 460px;
overflow: auto;
border: 1px solid rgba(37, 99, 235, 0.14);
border-radius: 24px;
background:
linear-gradient(135deg, rgba(248, 250, 252, 0.96), rgba(219, 234, 254, 0.46)),
radial-gradient(circle at 1px 1px, rgba(37, 99, 235, 0.16) 1px, transparent 0);
background-size:
auto,
22px 22px;
cursor: grab;
user-select: none;
}
.org-mind-map--panning {
cursor: grabbing;
}
```
Keep `.org-mind-map-node { cursor: pointer; }` so node cards do not visually look draggable.
- [ ] **Step 7: Run the static test and confirm Task 2 assertions pass or fail only on parent-page assertions**
Run:
```bash
node frontend/scripts/test-system-permission-management-ui.mjs
```
Expected result:
```text
AssertionError [ERR_ASSERTION]
```
The remaining failure should be in `SystemPermissionView.vue`, because parent refresh and maintenance tabs are not implemented yet.
---
### Task 3: Wire Refresh to One-Screen Fit in SystemPermissionView
**Files:**
- Modify: `frontend/src/views/SystemPermissionView.vue`
- [ ] **Step 1: Pass fit request key and fit-scale handler to OrgMindMap**
Replace:
```vue
<OrgMindMap
:key="mindMapRenderKey"
:nodes="orgNodes"
:zoom-scale="mindMapScale"
@select-node="selectOrgNode"
@node-contextmenu="openContextMenu"
@toggle-employee-leaves="selectOrgNode"
/>
```
with:
```vue
<OrgMindMap
:key="mindMapRenderKey"
:nodes="orgNodes"
:zoom-scale="mindMapScale"
:fit-request-key="mindMapFitRequestKey"
@select-node="selectOrgNode"
@node-contextmenu="openContextMenu"
@toggle-employee-leaves="selectOrgNode"
@fit-scale="applyMindMapFitScale"
/>
```
- [ ] **Step 2: Add fit request state**
Add below:
```js
const mindMapScale = ref(1);
const mindMapRenderKey = ref(0);
```
this line:
```js
const mindMapFitRequestKey = ref(0);
```
- [ ] **Step 3: Relax manual zoom minimum**
Replace:
```js
mindMapScale.value = Math.min(1.8, Math.max(0.6, nextScale));
```
with:
```js
mindMapScale.value = Math.min(1.8, Math.max(0.12, nextScale));
```
- [ ] **Step 4: Apply fit scale from child component**
Add below `zoomMindMap`:
```js
function applyMindMapFitScale(scale) {
const numericScale = Number(scale || 1);
mindMapScale.value = Math.min(1, Math.max(0.12, numericScale));
}
```
- [ ] **Step 5: Trigger one-screen fit after refresh**
Replace `refreshMindMap` with:
```js
async function refreshMindMap() {
closeContextMenu();
selectedNode.value = null;
mindMapRenderKey.value += 1;
await loadAll();
mindMapFitRequestKey.value += 1;
notifySuccess("脑图已刷新并适配一屏");
}
```
- [ ] **Step 6: Run the static test and confirm remaining failures only relate to maintenance tables**
Run:
```bash
node frontend/scripts/test-system-permission-management-ui.mjs
```
Expected result:
```text
AssertionError [ERR_ASSERTION]
```
The remaining failure should reference `maintenanceSubTab`, `PaginationBar`, `paginatedUsers`, `paginatedRoles`, `maintenance-grid`, or old `permission-list` rendering.
---
### Task 4: Convert Maintenance Page to Nested Tabs, Tables, and Pagination
**Files:**
- Modify: `frontend/src/views/SystemPermissionView.vue`
- [ ] **Step 1: Add imports**
Replace:
```js
import OrgMindMap from "../components/OrgMindMap.vue";
import { deleteResource, fetchResource, postResource, putResource } from "../services/api";
```
with:
```js
import OrgMindMap from "../components/OrgMindMap.vue";
import PaginationBar from "../components/PaginationBar.vue";
import { deleteResource, fetchResource, postResource, putResource } from "../services/api";
import { usePagination } from "../utils/pagination";
```
- [ ] **Step 2: Add maintenance sub-tab state and paginated rows**
Add below:
```js
const permissionMainTab = ref("org");
```
this line:
```js
const maintenanceSubTab = ref("users");
```
Add below `filteredManagerOptions`:
```js
const userRowsForTable = computed(() => users.value);
const roleRowsForTable = computed(() => roles.value);
const {
page: userPage,
pageSize: userPageSize,
rows: paginatedUsers
} = usePagination(userRowsForTable);
const {
page: rolePage,
pageSize: rolePageSize,
rows: paginatedRoles
} = usePagination(roleRowsForTable);
```
- [ ] **Step 3: Replace the old side-by-side maintenance section**
Replace the entire block starting with:
```vue
<section v-if="permissionMainTab === 'maintenance'" class="maintenance-grid">
```
and ending at its matching closing `</section>` before the context menu block with:
```vue
<section v-if="permissionMainTab === 'maintenance'" class="system-permission-maintenance-card full-width-card">
<div class="section-head">
<div>
<p class="eyebrow">账号角色</p>
<h3>人员与角色维护</h3>
<p class="permission-note">系统人员用于登录;角色维护用于配置菜单权限和业务身份。</p>
</div>
<div class="row-actions">
<button
v-if="maintenanceSubTab === 'users'"
class="primary-button"
type="button"
@click="openUserDrawer"
>
新增系统使用人员
</button>
<button
v-else
class="primary-button"
type="button"
@click="openRoleDrawer()"
>
新增角色
</button>
</div>
</div>
<div class="system-permission-sub-tabs" role="tablist" aria-label="人员与角色维护子页签">
<button
type="button"
:class="{ active: maintenanceSubTab === 'users' }"
@click="maintenanceSubTab = 'users'"
>
系统人员
</button>
<button
type="button"
:class="{ active: maintenanceSubTab === 'roles' }"
@click="maintenanceSubTab = 'roles'"
>
角色维护
</button>
</div>
<div v-if="maintenanceSubTab === 'users'" class="maintenance-table-panel">
<p class="permission-note">新增系统使用人员时,请从现有人员中选择,电话号码作为登录账号;密码必填,最少6位。</p>
<div class="table-wrap">
<table class="data-table compact-table">
<thead>
<tr>
<th>姓名</th>
<th>电话号码 / 账号</th>
<th>角色</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="user in paginatedUsers" :key="user.user_id || user.username">
<td>{{ user.display_name || user.employee_name || user.username }}</td>
<td>{{ user.mobile || user.username || "未设置电话号码" }}</td>
<td>{{ formatRoleNames(user) }}</td>
<td>{{ user.status === "ACTIVE" ? "启用" : "停用" }}</td>
<td class="action-row">
<button class="ghost-button" type="button" @click="resetPassword(user)">重置密码</button>
<button class="ghost-button" type="button" @click="toggleUserStatus(user)">
{{ user.status === "ACTIVE" ? "停用" : "启用" }}
</button>
</td>
</tr>
<tr v-if="!users.length">
<td colspan="5" class="empty-row">暂无系统人员数据</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar v-model:page="userPage" v-model:page-size="userPageSize" :total="users.length" />
</div>
<div v-if="maintenanceSubTab === 'roles'" class="maintenance-table-panel">
<div class="table-wrap">
<table class="data-table compact-table">
<thead>
<tr>
<th>角色名称</th>
<th>角色编码</th>
<th>系统人员数</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="role in paginatedRoles" :key="role.role_id || role.role_code || role.role_name">
<td>{{ role.role_name || role.name || "未命名角色" }}</td>
<td>{{ role.role_code || "未设置编码" }}</td>
<td>{{ role.user_count || 0 }} 人</td>
<td>{{ role.status === "INACTIVE" ? "停用" : "启用" }}</td>
<td class="action-row">
<button class="ghost-button" type="button" @click="openRoleDrawer(role)">编辑</button>
</td>
</tr>
<tr v-if="!roles.length">
<td colspan="5" class="empty-row">暂无角色数据</td>
</tr>
</tbody>
</table>
</div>
<PaginationBar v-model:page="rolePage" v-model:page-size="rolePageSize" :total="roles.length" />
</div>
</section>
```
- [ ] **Step 4: Replace maintenance CSS**
Remove the `.maintenance-grid` style block:
```css
.maintenance-grid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 20px;
}
```
Add below `.system-permission-main-tabs button.active`:
```css
.system-permission-maintenance-card {
display: flex;
flex-direction: column;
gap: 14px;
}
.system-permission-sub-tabs {
display: inline-grid;
grid-template-columns: repeat(2, minmax(150px, 1fr));
gap: 8px;
align-self: flex-start;
padding: 6px;
border: 1px solid rgba(37, 99, 235, 0.12);
border-radius: 18px;
background: #eef6ff;
}
.system-permission-sub-tabs button {
border: 0;
border-radius: 14px;
padding: 10px 16px;
background: transparent;
color: #475569;
font-weight: 800;
cursor: pointer;
}
.system-permission-sub-tabs button.active {
background: #ffffff;
color: #1d4ed8;
box-shadow: 0 12px 24px rgba(37, 99, 235, 0.14);
}
.maintenance-table-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
```
In the `@media (max-width: 980px)` block, remove:
```css
.maintenance-grid {
grid-template-columns: 1fr;
}
```
Add this replacement inside the same media block:
```css
.system-permission-sub-tabs {
width: 100%;
grid-template-columns: 1fr;
}
```
- [ ] **Step 5: Run static test and confirm it passes**
Run:
```bash
node frontend/scripts/test-system-permission-management-ui.mjs
```
Expected result:
```text
system permission management UI static assertions passed
```
---
### Task 5: Build and Runtime Verification
**Files:**
- Verify: `frontend/src/components/OrgMindMap.vue`
- Verify: `frontend/src/views/SystemPermissionView.vue`
- Verify: `frontend/scripts/test-system-permission-management-ui.mjs`
- [ ] **Step 1: Run the focused static test**
Run:
```bash
node frontend/scripts/test-system-permission-management-ui.mjs
```
Expected result:
```text
system permission management UI static assertions passed
```
- [ ] **Step 2: Run frontend build**
Run:
```bash
npm run build
```
from:
```bash
/Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
```
Expected result:
```text
✓ built
```
The existing Vite chunk-size warning can remain.
- [ ] **Step 3: Browser smoke test if the local Vite server is running**
If `http://127.0.0.1:5173` is already running, open the in-app browser to:
```text
http://127.0.0.1:5173/system-permissions
```
Verify these UI behaviors:
```text
组织脑图:
- Right-click on a node still opens the node context menu.
- Left-click a node still selects it.
- Dragging from empty canvas space moves the viewport.
- Dragging from a node card does not move that node.
- Clicking 刷新 reloads and fits the whole mind map into one screen when the tree size allows it.
人员与角色维护:
- Top-level tab remains 组织脑图 / 人员与角色维护.
- 人员与角色维护 contains 系统人员 / 角色维护 child tabs.
- 系统人员 uses a data table plus PaginationBar.
- 角色维护 uses a data table plus PaginationBar.
- 新增 button changes with the active child tab.
```
- [ ] **Step 4: Report verification evidence**
Report the exact commands run and their outcomes. If the browser smoke test is skipped because the local server is not running, state that it was skipped and that build/static verification passed.
---
## Self-Review
- Spec coverage: The plan covers canvas panning, node non-drag behavior, refresh-to-fit behavior, nested maintenance tabs, table layout, and global pagination.
- Placeholder scan: This plan contains no unfinished placeholder markers and no unspecified future work.
- Type consistency: `mindMapFitRequestKey`, `fitRequestKey`, `fit-scale`, `applyMindMapFitScale`, `maintenanceSubTab`, `paginatedUsers`, and `paginatedRoles` are defined before use in the plan.
- Scope check: All changes are frontend-only and isolated to the system permission page, mind-map component, and the existing static test.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,63 @@
# System Permission Split Tabs And Empty Org Mind Map 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:** Rework 系统权限管理 into two independent tabs, make 组织脑图 initialize as a single 总公司 root, and support right-click organization/person maintenance without polluting miniapp personnel master data.
**Architecture:** Keep `sys_department` as the organization-node table and add a small `sys_org_employee_binding` table for explicit mind-map personnel leaves. Backend owns root creation, level rules, deletion safety, manager binding, and employee binding. Frontend uses a full-width mind-map tab plus a separate personnel/role tab, with a deterministic non-draggable SVG mind map and a context menu.
**Tech Stack:** FastAPI, SQLAlchemy 2, Pydantic, MySQL 8 / SQLite tests, Vue 3, Vite, plain SVG/CSS.
---
### Task 1: Backend Data Model And Service Rules
**Files:**
- Modify: `backend/app/models/org.py`
- Modify: `backend/app/schemas/system_permissions.py`
- Modify: `backend/app/services/system_permissions.py`
- Modify: `backend/app/api/routes/system_permissions.py`
- Test: `backend/tests/test_system_permission_management.py`
- [ ] Add failing tests for empty-root org tree, explicit employee binding, delete guards, and valid child node types.
- [ ] Implement `OrgEmployeeBinding` model and schema fields.
- [ ] Build org tree from the managed root only, not legacy root departments.
- [ ] Add create/update/delete org node service rules.
- [ ] Add bind/unbind employee leaf service rules.
- [ ] Run backend focused tests.
### Task 2: SQL Patch
**Files:**
- Create: `backend/sql/add_system_permission_org_mindmap_patch.sql`
- [ ] Create `sys_org_employee_binding` safely.
- [ ] Ensure one `ORG_ROOT` company root named `总公司` exists.
- [ ] Support `GROUP` node type in comments/default expectations.
- [ ] Execute the patch against current test DB.
- [ ] Verify table and root exist.
### Task 3: Frontend Page Split And Mind Map Interactions
**Files:**
- Modify: `frontend/src/views/SystemPermissionView.vue`
- Modify: `frontend/src/components/OrgMindMap.vue`
- Modify: `frontend/scripts/test-system-permission-management-ui.mjs`
- [ ] Add static UI assertions for two top-level tabs, personnel/role tab separation, right-click menu, default collapsed employee leaves, and no free dragging.
- [ ] Rework `SystemPermissionView.vue` into `组织脑图` and `人员与角色维护` tabs.
- [ ] Move role/user cards and new-user action into `人员与角色维护`.
- [ ] Add right-click context menu commands by node type.
- [ ] Rework org drawer modes: rename, add child, set manager, add employee.
- [ ] Rework `OrgMindMap.vue` to show organization nodes first and collapse employee leaves behind a plus toggle.
- [ ] Run frontend static UI test and production build.
### Task 4: Final Verification
**Files:**
- Backend and frontend touched above.
- [ ] Run backend focused test.
- [ ] Run frontend static UI test.
- [ ] Run frontend build.
- [ ] Summarize SQL execution and verification output.
@@ -0,0 +1,95 @@
# Button System Upgrade 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:** Replace demo-like ERP buttons with a semantic, icon-backed, high-craft global button system without changing business logic.
**Architecture:** Add a small pure semantic mapper for button labels, test it first, then wire it into a browser-side enhancer that decorates existing plain buttons. Keep existing page templates and API calls intact; upgrade the shared CSS and existing `ActionButton`/`AppIcon` path so new and old buttons share one visual language.
**Tech Stack:** Vue 3, Vite, plain CSS, Node test runner, existing SVG icon map.
---
### Task 1: Button Semantics
**Files:**
- Create: `frontend/src/utils/buttonSemantics.js`
- Test: `frontend/src/utils/buttonSemantics.test.js`
- [ ] **Step 1: Write failing tests for label semantics**
Create tests that assert common terse labels become explicit business actions, including `刷新` -> `刷新数据`, `查看` -> `查看详情`, `编辑` -> `编辑资料`, `删除` -> `删除记录`, `增加一行` -> `添加明细行`, `保存` -> `保存信息`.
- [ ] **Step 2: Run the semantic tests and confirm failure**
Run: `node --test frontend/src/utils/buttonSemantics.test.js`
Expected: failure because the semantic mapper does not exist yet.
- [ ] **Step 3: Implement the mapper**
Implement `resolveButtonSemantics(label, className)` returning `{ label, icon, tone, kind }` with deterministic matching by label and risk class.
- [ ] **Step 4: Run semantic tests and confirm pass**
Run: `node --test frontend/src/utils/buttonSemantics.test.js`
Expected: all tests pass.
### Task 2: Runtime Button Enhancer
**Files:**
- Create: `frontend/src/utils/buttonEnhancer.js`
- Modify: `frontend/src/App.vue`
- [ ] **Step 1: Add DOM enhancer**
Create `installButtonEnhancer()` that scans ordinary buttons after route changes and DOM mutations. It skips buttons with `no-auto-icon`, existing SVG/icon content, table mode buttons, pagination arrows, tag close buttons, and menu items where decoration would hurt usability.
- [ ] **Step 2: Decorate compatible buttons**
For compatible buttons, update terse labels, set `data-button-kind`, `data-button-tone`, and prepend an SVG icon from the semantic mapper. Preserve click handlers, disabled state, type, and existing classes.
- [ ] **Step 3: Install once in `App.vue`**
Call the enhancer from `onMounted` and cleanup on unmount. Re-run after route navigation using the enhancer's MutationObserver instead of touching every page.
### Task 3: Visual System
**Files:**
- Modify: `frontend/src/utils/iconMap.js`
- Modify: `frontend/src/components/ActionButton.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add action icons**
Add linear Iconsax-style paths for `save`, `edit`, `delete`, `view`, `import`, `export`, `filter`, `authorize`, `check`, `reject`, `upload`, `download`, and `rowAdd`.
- [ ] **Step 2: Upgrade `ActionButton`**
Expose semantic `data-button-kind` and `data-button-tone` attributes so component buttons and auto-enhanced buttons share CSS.
- [ ] **Step 3: Rewrite button styling**
Use industrial control-console styling: tactile shadows, clear tone colors, icon chip, press motion, compact table pills, and accessible focus rings. Keep motion short and non-distracting.
### Task 4: Verification
**Files:**
- Verify: `frontend`
- [ ] **Step 1: Run semantic tests**
Run: `node --test frontend/src/utils/buttonSemantics.test.js`
- [ ] **Step 2: Run production build**
Run: `npm run build`
- [ ] **Step 3: Browser verify current page**
Open `http://127.0.0.1:5173/operation-reports` and verify `刷新` becomes `刷新数据` with an icon and tactile styling. Check the table mode switch remains unchanged.
- [ ] **Step 4: Browser verify a CRUD page**
Open a typical master-data page and verify create/edit/delete/detail buttons receive explicit labels and appropriate tone.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,668 @@
# Miniapp Operation Report Sync 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 工序报工 page refresh fast by separating read-only list refresh from explicit miniapp-to-ERP synchronization, with sync range fixed to automatic catch-up from the last successful sync time.
**Architecture:** Keep the existing list endpoint for display, but make the frontend call it with `sync_to_erp=false`. Add a dedicated backend sync endpoint that reads and locks `MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT` in `sys_system_config`, syncs all miniapp report items changed since that time, and only updates the timestamp after the transaction succeeds. The frontend adds a disabled loading state for the sync button and refreshes the list after sync.
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, MySQL-compatible row locking, Vue 3 Composition API, Vite, Python `unittest`, Node static UI checks.
---
## File Structure
- Modify: `backend/app/services/system_config.py`
- Add constants and helpers for `MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT`.
- Add parse/format helpers for sync timestamps.
- Add helpers to initialize and lock the sync config row, so first sync also has a database lock target.
- Modify: `backend/app/schemas/operations.py`
- Add `MiniAppOperationReportSyncRead`.
- Modify: `backend/app/api/routes/production.py`
- Add helper query for miniapp rows changed since the last successful sync.
- Add `POST /production/miniapp-operation-reports/sync`.
- Keep list endpoint behavior backward-compatible, but frontend will call it as read-only.
- Modify: `backend/tests/test_miniapp_operation_report_date_filter.py`
- Add tests for read-only list refresh and explicit automatic catch-up sync.
- Modify: `frontend/src/views/OperationReportView.vue`
- Change list refresh to `sync_to_erp=false`.
- Add sync button, loading state, last-sync display, and result feedback.
- Modify: `frontend/scripts/test-operation-report-date-filter.mjs`
- Update static checks for read-only refresh.
- Create: `frontend/scripts/test-operation-report-sync.mjs`
- Static checks for sync button, dedicated endpoint, disabled loading state, and auto-refresh after sync.
---
## Task 1: Backend Sync Timestamp Helpers
**Files:**
- Modify: `backend/app/services/system_config.py`
- Test: `backend/tests/test_miniapp_operation_report_date_filter.py`
- [ ] **Step 1: Add failing helper tests**
Append these imports in `backend/tests/test_miniapp_operation_report_date_filter.py`:
```python
from app.services.system_config import ( # noqa: E402
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
format_miniapp_operation_report_last_sync,
get_miniapp_operation_report_last_sync,
parse_miniapp_operation_report_last_sync,
upsert_miniapp_operation_report_last_sync,
)
from app.models.org import SystemConfig # noqa: E402
from sqlalchemy import select # noqa: E402
```
Add this test method to `MiniAppOperationReportDateFilterTest`:
```python
def test_miniapp_operation_report_last_sync_config_round_trips_datetime(self) -> None:
timestamp = datetime(2026, 6, 11, 10, 30, 15)
row = upsert_miniapp_operation_report_last_sync(self.db, timestamp, updated_by=1)
self.db.commit()
persisted = self.db.scalar(
select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE)
)
self.assertIsNotNone(persisted)
self.assertEqual(row.config_value, "2026-06-11 10:30:15")
self.assertEqual(format_miniapp_operation_report_last_sync(timestamp), "2026-06-11 10:30:15")
self.assertEqual(parse_miniapp_operation_report_last_sync("2026-06-11 10:30:15"), timestamp)
self.assertEqual(get_miniapp_operation_report_last_sync(self.db), timestamp)
```
- [ ] **Step 2: Run the failing backend test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py::MiniAppOperationReportDateFilterTest::test_miniapp_operation_report_last_sync_config_round_trips_datetime -q
```
Expected: FAIL because the sync config helper functions do not exist.
- [ ] **Step 3: Implement timestamp helpers**
In `backend/app/services/system_config.py`, add the import:
```python
from datetime import datetime
```
The file already imports `datetime`; keep one import only.
Add these constants near existing config constants:
```python
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE = "MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT"
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME = "小程序报工上次同步成功时间"
MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT = "%Y-%m-%d %H:%M:%S"
```
Add these helpers after `get_smart_operation_report_enabled`:
```python
def format_miniapp_operation_report_last_sync(value: datetime) -> str:
return value.strftime(MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)
def parse_miniapp_operation_report_last_sync(value: str | None) -> datetime | None:
normalized = str(value or "").strip()
if not normalized:
return None
return datetime.strptime(normalized, MINIAPP_OPERATION_REPORT_LAST_SYNC_FORMAT)
def get_miniapp_operation_report_last_sync(db: Session) -> datetime | None:
value = get_system_config_value(db, MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE, "")
return parse_miniapp_operation_report_last_sync(value)
def upsert_miniapp_operation_report_last_sync(
db: Session,
timestamp: datetime,
*,
updated_by: int | None = None,
) -> SystemConfig:
return upsert_system_config(
db,
config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
config_value=format_miniapp_operation_report_last_sync(timestamp),
remark="工序报工小程序同步 ERP 的上次成功截止时间",
updated_by=updated_by,
now=timestamp,
)
def ensure_miniapp_operation_report_last_sync_config(
db: Session,
*,
updated_by: int | None = None,
) -> SystemConfig:
row = db.scalar(select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE))
if row:
return row
timestamp = datetime.now()
row = SystemConfig(
config_code=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
config_name=MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_NAME,
config_value="",
remark="工序报工小程序同步 ERP 的上次成功截止时间",
status="ACTIVE",
created_by=updated_by,
updated_by=updated_by,
created_at=timestamp,
updated_at=timestamp,
)
db.add(row)
db.flush()
return row
def lock_miniapp_operation_report_last_sync(db: Session) -> SystemConfig | None:
ensure_miniapp_operation_report_last_sync_config(db)
return db.scalar(
select(SystemConfig)
.where(
SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
SystemConfig.status == "ACTIVE",
)
.with_for_update()
)
```
- [ ] **Step 4: Run the helper test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py::MiniAppOperationReportDateFilterTest::test_miniapp_operation_report_last_sync_config_round_trips_datetime -q
```
Expected: PASS.
---
## Task 2: Backend Read-Only List And Explicit Sync Endpoint
**Files:**
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/tests/test_miniapp_operation_report_date_filter.py`
- [ ] **Step 1: Add failing tests for read-only refresh and automatic catch-up sync**
Add imports in `backend/tests/test_miniapp_operation_report_date_filter.py`:
```python
from app.api.routes import production as production_routes # noqa: E402
from app.api.routes.production import sync_miniapp_operation_reports # noqa: E402
from app.models.operations import OperationReport # noqa: E402
```
Add this test method:
```python
def test_list_reports_with_sync_disabled_does_not_call_sync(self) -> None:
calls: list[int] = []
original_sync = production_routes._sync_miniapp_item_to_erp
def fake_sync(db, source_item_id, **kwargs):
calls.append(source_item_id)
return None
production_routes._sync_miniapp_item_to_erp = fake_sync
try:
rows = list_miniapp_operation_reports(
limit=100,
sync_to_erp=False,
start_date=date(2026, 5, 18),
end_date=date(2026, 6, 1),
db=self.db,
)
finally:
production_routes._sync_miniapp_item_to_erp = original_sync
self.assertEqual(len(rows), 2)
self.assertEqual(calls, [])
self.assertEqual(self.db.query(OperationReport).count(), 0)
```
Add this test method:
```python
def test_sync_reports_uses_last_success_time_not_page_filter(self) -> None:
upsert_miniapp_operation_report_last_sync(self.db, datetime(2026, 5, 19, 0, 0, 0), updated_by=1)
self.db.commit()
calls: list[int] = []
original_sync = production_routes._sync_miniapp_item_to_erp
original_now = production_routes.datetime
class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 6, 11, 12, 0, 0)
def fake_sync(db, source_item_id, **kwargs):
calls.append(source_item_id)
return None
production_routes._sync_miniapp_item_to_erp = fake_sync
production_routes.datetime = FixedDateTime
try:
result = sync_miniapp_operation_reports(db=self.db)
finally:
production_routes._sync_miniapp_item_to_erp = original_sync
production_routes.datetime = original_now
self.assertEqual(calls, [2, 3])
self.assertEqual(result.total, 2)
self.assertEqual(result.sync_from, datetime(2026, 5, 19, 0, 0, 0))
self.assertEqual(result.sync_to, datetime(2026, 6, 11, 12, 0, 0))
self.assertEqual(get_miniapp_operation_report_last_sync(self.db), datetime(2026, 6, 11, 12, 0, 0))
```
- [ ] **Step 2: Run the failing tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py -q
```
Expected: FAIL because `sync_miniapp_operation_reports` and response schema do not exist yet.
- [ ] **Step 3: Add sync response schema**
In `backend/app/schemas/operations.py`, add after `MiniAppOperationReportRead`:
```python
class MiniAppOperationReportSyncRead(BaseModel):
sync_from: datetime | None = None
sync_to: datetime
last_synced_at: datetime
total: int = 0
synced: int = 0
skipped: int = 0
failed: int = 0
```
- [ ] **Step 4: Import sync helpers and schema**
In `backend/app/api/routes/production.py`, extend the operations schema import to include:
```python
MiniAppOperationReportSyncRead,
```
Extend the system config imports to include:
```python
get_miniapp_operation_report_last_sync,
lock_miniapp_operation_report_last_sync,
upsert_miniapp_operation_report_last_sync,
```
- [ ] **Step 5: Add changed-since miniapp row helper**
In `backend/app/api/routes/production.py`, add this helper near `_miniapp_report_item_rows`:
```python
def _miniapp_report_item_rows_changed_since(
db: Session,
*,
sync_from: datetime | None,
sync_to: datetime,
limit: int = 800,
) -> list[dict]:
stmt = (
select(
MiniAppProductionReport.id.label("source_report_id"),
MiniAppProductionReportItem.id.label("source_item_id"),
func.coalesce(
MiniAppProductionReportItem.attendance_point_name,
MiniAppProductionReport.attendance_point_name,
).label("attendance_point_name"),
MiniAppProductionReport.employee_phone.label("employee_phone"),
MiniAppPersonnel.name.label("employee_name"),
MiniAppProductionReport.report_date.label("report_date"),
MiniAppProductionReport.start_at.label("start_time"),
MiniAppProductionReport.end_at.label("end_time"),
MiniAppProductionReport.duration_minutes.label("duration_minutes"),
MiniAppProductionReport.effective_minutes.label("effective_minutes"),
MiniAppProductionReport.status.label("status"),
MiniAppProductionReport.reviewer_phone.label("reviewer_phone"),
MiniAppProductionReport.reviewed_at.label("reviewed_at"),
MiniAppProductionReport.reject_reason.label("reject_reason"),
MiniAppProductionReportItem.device_no.label("device_no"),
MiniAppProductionReportItem.project_no.label("project_no"),
MiniAppProductionReportItem.product_name.label("product_name"),
MiniAppProductionReportItem.material_code.label("material_code"),
MiniAppProductionReportItem.material_name.label("material_name"),
MiniAppProductionReportItem.raw_material_batch_no.label("raw_material_batch_no"),
MiniAppProductionReportItem.process_name.label("process_name"),
MiniAppProductionReportItem.stamping_method.label("stamping_method"),
MiniAppProductionReportItem.standard_beat.label("standard_beat"),
MiniAppProductionReportItem.standard_workload.label("standard_workload"),
MiniAppProductionReportItem.good_qty.label("good_qty"),
MiniAppProductionReportItem.defect_qty.label("defect_qty"),
MiniAppProductionReportItem.scrap_qty.label("miniapp_scrap_qty"),
MiniAppProductionReportItem.allocated_minutes.label("allocated_minutes"),
MiniAppProductionReportItem.started_at.label("started_at"),
)
.join(MiniAppProductionReport, MiniAppProductionReport.id == MiniAppProductionReportItem.report_id)
.outerjoin(MiniAppPersonnel, MiniAppPersonnel.phone == MiniAppProductionReport.employee_phone)
.where(MiniAppProductionReport.updated_at <= sync_to)
.order_by(MiniAppProductionReport.updated_at.asc(), MiniAppProductionReportItem.id.asc())
.limit(limit)
)
if sync_from is not None:
stmt = stmt.where(MiniAppProductionReport.updated_at > sync_from)
rows = db.execute(stmt).mappings().all()
return [dict(row) for row in rows]
```
- [ ] **Step 6: Add explicit sync endpoint**
In `backend/app/api/routes/production.py`, add before the existing `@router.get("/miniapp-operation-reports"` route:
```python
@router.post("/miniapp-operation-reports/sync", response_model=MiniAppOperationReportSyncRead)
def sync_miniapp_operation_reports(
limit: int = Query(default=800, ge=1, le=800),
db: Session = Depends(get_db),
) -> MiniAppOperationReportSyncRead:
lock_miniapp_operation_report_last_sync(db)
sync_from = get_miniapp_operation_report_last_sync(db)
sync_to = datetime.now()
rows = _miniapp_report_item_rows_changed_since(db, sync_from=sync_from, sync_to=sync_to, limit=limit)
cache = _new_miniapp_resolve_cache()
report_nos = [_miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"])) for row in rows]
if report_nos:
existing_reports = db.scalars(select(OperationReport).where(OperationReport.report_no.in_(report_nos))).all()
cache["report"].update({report.report_no: report for report in existing_reports})
touched_work_order_ids: set[int] = set()
synced = 0
skipped = 0
for row in rows:
synced_report = _sync_miniapp_item_to_erp(
db,
int(row["source_item_id"]),
required=False,
row=row,
cache=cache,
sync_status=False,
)
if synced_report:
synced += 1
if synced_report.work_order_id:
touched_work_order_ids.add(int(synced_report.work_order_id))
else:
skipped += 1
for work_order_id in touched_work_order_ids:
sync_work_order_status(db, work_order_id)
row = upsert_miniapp_operation_report_last_sync(db, sync_to)
db.commit()
db.refresh(row)
return MiniAppOperationReportSyncRead(
sync_from=sync_from,
sync_to=sync_to,
last_synced_at=sync_to,
total=len(rows),
synced=synced,
skipped=skipped,
failed=0,
)
```
- [ ] **Step 7: Run backend tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py -q
```
Expected: PASS.
---
## Task 3: Frontend Read-Only Refresh And Sync Button
**Files:**
- Modify: `frontend/src/views/OperationReportView.vue`
- Modify: `frontend/scripts/test-operation-report-date-filter.mjs`
- Create: `frontend/scripts/test-operation-report-sync.mjs`
- [ ] **Step 1: Add failing frontend static test**
Create `frontend/scripts/test-operation-report-sync.mjs`:
```javascript
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
const root = process.cwd();
const source = readFileSync(path.join(root, "src/views/OperationReportView.vue"), "utf8");
assert.match(source, /syncReportsToErp/, "operation report page should define an explicit ERP sync action");
assert.match(source, /postResource\("\/production\/miniapp-operation-reports\/sync"/, "sync action should call the dedicated sync endpoint");
assert.match(source, /syncingReports\.value\s*=\s*true/, "sync action should enable loading state");
assert.match(source, /:disabled="syncingReports"/, "sync button should be disabled while syncing");
assert.match(source, /同步中/, "sync button should show a syncing label");
assert.match(source, /lastSyncedAt/, "page should display the last successful sync time");
assert.match(source, /await loadReports\(\)/, "page should refresh the list after sync succeeds");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "list refresh should not sync ERP implicitly");
assert.match(source, /sync_to_erp:\s*"false"/, "list refresh should explicitly request read-only data");
console.log("operation report sync checks passed");
```
- [ ] **Step 2: Run failing frontend test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-operation-report-sync.mjs
```
Expected: FAIL because the sync action and button do not exist.
- [ ] **Step 3: Update imports and state**
In `frontend/src/views/OperationReportView.vue`, change:
```javascript
import { fetchResource } from "../services/api";
```
to:
```javascript
import { fetchResource, postResource } from "../services/api";
```
Add after `const reports = ref([]);`:
```javascript
const syncingReports = ref(false);
const lastSyncedAt = ref("");
```
- [ ] **Step 4: Make list refresh read-only**
In `loadReports()`, change:
```javascript
const params = new URLSearchParams({
limit: "800",
sync_to_erp: "true"
});
```
to:
```javascript
const params = new URLSearchParams({
limit: "800",
sync_to_erp: "false"
});
```
Change success text from:
```javascript
feedbackMessage.value = "小程序报工数据已刷新并同步 ERP 生产核算";
```
to:
```javascript
feedbackMessage.value = "小程序报工列表已刷新";
```
- [ ] **Step 5: Add explicit sync action**
Add after `loadReports()`:
```javascript
async function syncReportsToErp() {
if (syncingReports.value) {
return;
}
feedbackMessage.value = "";
errorMessage.value = "";
syncingReports.value = true;
try {
const result = await postResource("/production/miniapp-operation-reports/sync", {});
lastSyncedAt.value = result.last_synced_at || result.sync_to || "";
feedbackMessage.value = [
`同步完成:共 ${result.total || 0} 条`,
`成功 ${result.synced || 0} 条`,
`跳过 ${result.skipped || 0} 条`,
`失败 ${result.failed || 0} 条`,
`同步范围:${result.sync_from || "首次同步"} 至 ${result.sync_to || "-"}`
].join(",");
await loadReports();
} catch (error) {
errorMessage.value = error.message || "同步小程序报工到 ERP 失败,上次同步成功时间未更新,请稍后重试";
} finally {
syncingReports.value = false;
}
}
```
- [ ] **Step 6: Add sync UI**
In the template, inside `.operation-filter-ribbon` after the refresh button, add:
```vue
<button class="primary-button" type="button" :disabled="syncingReports" @click="syncReportsToErp">
{{ syncingReports ? "同步中..." : "同步到 ERP" }}
</button>
<span class="operation-sync-hint">上次同步:{{ lastSyncedAt || "暂无" }}</span>
```
- [ ] **Step 7: Update existing date-filter static test**
In `frontend/scripts/test-operation-report-date-filter.mjs`, replace the old negative check:
```javascript
assert.doesNotMatch(
source,
/fetchResource\("\/production\/miniapp-operation-reports\?limit=800&sync_to_erp=true"/,
"operation report page must not use the old all-row request"
);
```
with:
```javascript
assert.match(source, /sync_to_erp:\s*"false"/, "operation report refresh should be read-only by default");
assert.doesNotMatch(source, /sync_to_erp:\s*"true"/, "operation report refresh must not sync ERP implicitly");
```
- [ ] **Step 8: Run frontend static tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-operation-report-date-filter.mjs
node scripts/test-operation-report-sync.mjs
```
Expected: both PASS.
---
## Task 4: Final Verification
**Files:**
- Backend and frontend files from prior tasks.
- [ ] **Step 1: Run backend focused tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m pytest tests/test_miniapp_operation_report_date_filter.py -q
```
Expected: PASS.
- [ ] **Step 2: Run frontend static checks**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-operation-report-date-filter.mjs
node scripts/test-operation-report-sync.mjs
```
Expected: PASS.
- [ ] **Step 3: Run frontend build**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected: Vite build succeeds. Chunk-size warning is acceptable if it matches the existing project warning.
- [ ] **Step 4: Manual browser verification**
Open `http://127.0.0.1:5173/operation-reports` and verify:
- Page loads the list without waiting for ERP sync.
- `刷新` only refreshes the current visible list.
- `同步到 ERP` changes to `同步中...` and cannot be clicked again while syncing.
- Sync success message shows total, success, skipped, failed, and sync range.
- The list refreshes after sync completes.
- Page date filters do not affect the backend sync range.
---
## Self-Review
- Spec coverage: The plan covers read-only refresh, automatic catch-up sync, no user sync date range, loading button, last-sync persistence, idempotent existing sync reuse, and verification.
- Placeholder scan: No unfinished placeholders or vague “add tests” instructions remain.
- Type consistency: The plan consistently uses `MiniAppOperationReportSyncRead`, `sync_miniapp_operation_reports`, `MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT`, `syncingReports`, and `lastSyncedAt`.
@@ -0,0 +1,852 @@
# Production Inbound Archive Entrypoints 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:** Expose preview/download/regenerate entrypoints for `生产入库结算单` in the places users naturally look after production ledger inbound creates multiple warehouse transactions.
**Architecture:** Keep `生产台账入库` as one business settlement action and keep all generated warehouse transactions linked to the same `生产入库结算单` archive. The backend warehouse transaction ledger will return archive metadata for production settlement transactions; the frontend will reuse `DocumentArchiveActions` in the warehouse ledger and show a post-save shortcut when `生产台账入库` succeeds.
**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, existing `document_archives` service/routes, Vue 3, existing `DocumentArchiveActions`, existing `openResource`/`downloadResource`/`postResource`.
---
## File Structure
### Backend
- Modify: `backend/app/schemas/operations.py`
- Add archive metadata fields to `InventoryTxnRead`, inherited by `InventoryTxnLedgerRowRead`.
- Modify: `backend/app/services/operations.py`
- Add `DocumentArchive` and production archive constants imports if missing.
- Add helper functions for production settlement archive grouping on warehouse inventory transactions.
- Extend `get_inventory_txn_ledger_query` to select archive metadata for production settlement rows.
- Test: `backend/tests/test_inventory_transaction_ledger_archive_fields.py`
- Add a focused test that proves finished, surplus, and scrap rows from the same production settlement batch all expose the same archive business ID and status.
### Frontend
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Import `DocumentArchiveActions`.
- Import `downloadResource` and `openResource` if not already imported.
- Add a `单据留档` column to the warehouse流水 drawer.
- Render `DocumentArchiveActions` for rows with `archive_business_id`.
- Add archive preview/download/regenerate handlers.
- Add a post-save shortcut panel after `生产台账入库` succeeds.
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
- Add static assertions for the new warehouse ledger archive column, document type key, and post-save shortcut state.
- Modify: `frontend/src/styles/main.css`
- Add compact archive action styling for table cells and feedback shortcut panels.
---
## Current Reality To Preserve
- `生产台账入库` already calls `POST /production/production-ledger-inbounds`.
- That endpoint already returns:
- `archive_status`
- `archive_business_id`
- `archive_document_type`
- `archive_error_message`
- `ProductionLedgerView.vue` already uses `DocumentArchiveActions` for `production-material-out` and `production-inbound-settlement`.
- `InventoryLedgerView.vue` warehouse ledger currently has no archive column.
- `GET /inventory/transaction-ledger` currently returns `InventoryTxnLedgerRowRead`, which does not yet include archive metadata.
---
### Task 1: Backend Test For Warehouse Ledger Archive Metadata
**Files:**
- Create: `backend/tests/test_inventory_transaction_ledger_archive_fields.py`
- [ ] **Step 1: Write the failing test**
Create `backend/tests/test_inventory_transaction_ledger_archive_fields.py`:
```python
from __future__ import annotations
import unittest
from datetime import datetime
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, Warehouse # noqa: E402
from app.models.operations import InventoryTxn, StockLot # noqa: E402
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT # noqa: E402
from app.services.operations import get_inventory_txn_ledger_query # noqa: E402
class InventoryTransactionLedgerArchiveFieldsTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self.now = datetime(2026, 6, 11, 14, 0, 0)
self._seed_data()
def tearDown(self) -> None:
self.db.close()
def _seed_data(self) -> None:
finished_wh = Warehouse(
id=1,
warehouse_code="WH-FIN",
warehouse_name="成品库",
warehouse_type="FINISHED",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
raw_wh = Warehouse(
id=2,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
scrap_wh = Warehouse(
id=3,
warehouse_code="WH-SCRAP",
warehouse_name="废料库",
warehouse_type="SCRAP",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
material = Item(
id=10,
item_code="RM-010",
item_name="测试原材料",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("0"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
product = Item(
id=20,
item_code="FG-020",
item_name="测试成品",
item_type="FINISHED",
unit_weight_kg=Decimal("0.5"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
source_lot = StockLot(
id=100,
lot_no="YL0001",
item_id=material.id,
warehouse_id=raw_wh.id,
location_id=None,
inbound_qty=0,
inbound_weight_kg=500,
remaining_qty=0,
remaining_weight_kg=200,
locked_qty=0,
locked_weight_kg=0,
unit_cost=Decimal("6.5"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
finished_lot = StockLot(
id=101,
lot_no="FGL0001",
parent_lot_id=source_lot.id,
item_id=product.id,
warehouse_id=finished_wh.id,
location_id=None,
source_material_lot_id=source_lot.id,
source_material_sub_batch_no="YL0001",
inbound_qty=100,
inbound_weight_kg=50,
remaining_qty=100,
remaining_weight_kg=50,
locked_qty=0,
locked_weight_kg=0,
unit_cost=Decimal("3.25"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
scrap_lot = StockLot(
id=102,
lot_no="SCRAPI0001",
parent_lot_id=source_lot.id,
item_id=material.id,
warehouse_id=scrap_wh.id,
location_id=None,
source_material_lot_id=source_lot.id,
source_material_sub_batch_no="YL0001",
inbound_qty=0,
inbound_weight_kg=5,
remaining_qty=0,
remaining_weight_kg=5,
locked_qty=0,
locked_weight_kg=0,
unit_cost=Decimal("6.5"),
quality_status="SCRAP",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
batch_no = "生产结算20260611140000000001"
self.finished_txn = InventoryTxn(
id=1000,
txn_no="TXN-FIN-001",
txn_type="FG_IN",
item_id=product.id,
warehouse_id=finished_wh.id,
location_id=None,
lot_id=finished_lot.id,
qty_change=Decimal("100"),
weight_change_kg=Decimal("50"),
unit_cost=Decimal("3.25"),
amount=Decimal("325"),
source_doc_type="生产台账",
source_doc_id=2000,
source_line_id=finished_lot.id,
biz_time=self.now,
remark=f"生产台账入库;成品入库;单据批次号:{batch_no}",
created_at=self.now,
updated_at=self.now,
)
self.surplus_txn = InventoryTxn(
id=1001,
txn_no="TXN-SURPLUS-001",
txn_type="PRODUCTION_SURPLUS_IN",
item_id=material.id,
warehouse_id=raw_wh.id,
location_id=None,
lot_id=source_lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("20"),
unit_cost=Decimal("6.5"),
amount=Decimal("130"),
source_doc_type="生产台账",
source_doc_id=2000,
source_line_id=source_lot.id,
biz_time=self.now,
remark=f"生产台账入库;生产余料入库;单据批次号:{batch_no}",
created_at=self.now,
updated_at=self.now,
)
self.scrap_txn = InventoryTxn(
id=1002,
txn_no="TXN-SCRAP-001",
txn_type="PRODUCTION_SCRAP_IN",
item_id=material.id,
warehouse_id=scrap_wh.id,
location_id=None,
lot_id=scrap_lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("5"),
unit_cost=Decimal("6.5"),
amount=Decimal("32.5"),
source_doc_type="生产台账",
source_doc_id=2000,
source_line_id=scrap_lot.id,
biz_time=self.now,
remark=f"生产台账入库;生产废料入库;单据批次号:{batch_no}",
created_at=self.now,
updated_at=self.now,
)
archive = DocumentArchive(
document_type=DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
business_id=self.finished_txn.id,
document_no="生产入库结算-TXN-FIN-001",
archive_version=1,
file_format="PDF",
file_name="生产入库结算-TXN-FIN-001_V1.pdf",
file_path="/tmp/生产入库结算-TXN-FIN-001_V1.pdf",
status="已归档",
error_message=None,
created_by=None,
created_at=self.now,
)
self.db.add_all([
finished_wh,
raw_wh,
scrap_wh,
material,
product,
source_lot,
finished_lot,
scrap_lot,
self.finished_txn,
self.surplus_txn,
self.scrap_txn,
archive,
])
self.db.commit()
def test_finished_warehouse_row_exposes_own_settlement_archive(self) -> None:
rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="FINISHED")).mappings().all()
self.assertEqual(len(rows), 1)
row = dict(rows[0])
self.assertEqual(row["inventory_txn_id"], self.finished_txn.id)
self.assertEqual(row["archive_business_id"], self.finished_txn.id)
self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
self.assertEqual(row["archive_status"], "已归档")
def test_surplus_warehouse_row_exposes_same_settlement_archive(self) -> None:
rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().all()
self.assertEqual(len(rows), 1)
row = dict(rows[0])
self.assertEqual(row["inventory_txn_id"], self.surplus_txn.id)
self.assertEqual(row["archive_business_id"], self.finished_txn.id)
self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
self.assertEqual(row["archive_status"], "已归档")
def test_scrap_warehouse_row_exposes_same_settlement_archive(self) -> None:
rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="SCRAP")).mappings().all()
self.assertEqual(len(rows), 1)
row = dict(rows[0])
self.assertEqual(row["inventory_txn_id"], self.scrap_txn.id)
self.assertEqual(row["archive_business_id"], self.finished_txn.id)
self.assertEqual(row["archive_document_type"], DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
self.assertEqual(row["archive_status"], "已归档")
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run the test and verify it fails**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v
```
Expected: fail with missing `archive_business_id`/`archive_document_type`/`archive_status` columns on the warehouse transaction ledger query.
---
### Task 2: Add Archive Metadata To Warehouse Transaction Ledger
**Files:**
- Modify: `backend/app/schemas/operations.py`
- Modify: `backend/app/services/operations.py`
- [ ] **Step 1: Extend the response schema**
In `backend/app/schemas/operations.py`, add these fields to `InventoryTxnRead` after `remark`:
```python
archive_status: str | None = None
archive_business_id: int | None = None
archive_document_type: str | None = None
archive_error_message: str | None = None
```
- [ ] **Step 2: Import production archive constants**
In `backend/app/services/operations.py`, add this import near the other service imports:
```python
from app.services.document_archives import DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, production_document_batch_no_from_text
```
- [ ] **Step 3: Add production settlement helper constants**
In `backend/app/services/operations.py`, near `WAREHOUSE_LEDGER_SORT_COLUMNS`, add:
```python
PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES = {
"FG_IN",
"PRODUCTION_SURPLUS_IN",
"PRODUCTION_SCRAP_IN",
}
PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY = {
"FG_IN": 1,
"PRODUCTION_SURPLUS_IN": 2,
"PRODUCTION_SCRAP_IN": 3,
}
```
- [ ] **Step 4: Add a settlement main transaction subquery helper**
In `backend/app/services/operations.py`, add this helper before `get_inventory_txn_ledger_query`:
```python
def _production_settlement_archive_subquery():
settlement_txn = aliased(InventoryTxn)
settlement_batch_no = func.coalesce(
production_document_batch_no_from_text(settlement_txn.remark),
func.date_format(settlement_txn.biz_time, "%Y-%m-%d %H:%i:%s.%f"),
).label("settlement_group_key")
priority_expr = case(
*[
(settlement_txn.txn_type == txn_type, priority)
for txn_type, priority in PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY.items()
],
else_=99,
)
grouped = (
select(
settlement_txn.source_doc_id.label("production_ledger_id"),
settlement_batch_no,
func.min(
case(
*[
(settlement_txn.txn_type == txn_type, priority)
for txn_type, priority in PRODUCTION_SETTLEMENT_MAIN_TXN_PRIORITY.items()
],
else_=99,
)
* 1_000_000_000
+ settlement_txn.id
).label("main_rank"),
)
.where(
settlement_txn.source_doc_type == "生产台账",
settlement_txn.source_doc_id.is_not(None),
settlement_txn.txn_type.in_(PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES),
)
.group_by(settlement_txn.source_doc_id, settlement_batch_no)
.subquery()
)
return grouped
```
If MySQL-specific `date_format` breaks SQLite tests, replace the fallback group key with `func.coalesce(production_document_batch_no_from_text(settlement_txn.remark), settlement_txn.biz_time)`. The seed data uses `单据批次号` in all remarks, so the SQLite tests still exercise the intended path.
- [ ] **Step 5: Join archive metadata in `get_inventory_txn_ledger_query`**
In `get_inventory_txn_ledger_query`, add aliases before building `stmt`:
```python
settlement_grouped = _production_settlement_archive_subquery()
settlement_main_txn = aliased(InventoryTxn)
settlement_archive = aliased(DocumentArchive)
current_batch_no = func.coalesce(
production_document_batch_no_from_text(InventoryTxn.remark),
func.date_format(InventoryTxn.biz_time, "%Y-%m-%d %H:%i:%s.%f"),
)
```
Add these selected columns after `InventoryTxn.remark.label("remark")`:
```python
settlement_archive.status.label("archive_status"),
settlement_main_txn.id.label("archive_business_id"),
literal(DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT).label("archive_document_type"),
settlement_archive.error_message.label("archive_error_message"),
```
Add these joins after the operator join:
```python
.outerjoin(
settlement_grouped,
and_(
InventoryTxn.source_doc_type == "生产台账",
InventoryTxn.source_doc_id == settlement_grouped.c.production_ledger_id,
current_batch_no == settlement_grouped.c.settlement_group_key,
InventoryTxn.txn_type.in_(PRODUCTION_SETTLEMENT_INVENTORY_TXN_TYPES),
),
)
.outerjoin(
settlement_main_txn,
settlement_main_txn.id == settlement_grouped.c.main_rank % 1_000_000_000,
)
.outerjoin(
settlement_archive,
and_(
settlement_archive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
settlement_archive.business_id == settlement_main_txn.id,
settlement_archive.file_format == "PDF",
settlement_archive.archive_version
== select(func.max(DocumentArchive.archive_version))
.where(
DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DocumentArchive.business_id == settlement_main_txn.id,
DocumentArchive.file_format == "PDF",
)
.scalar_subquery(),
),
)
```
- [ ] **Step 6: Run the backend test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v
```
Expected: all 3 tests pass.
---
### Task 3: Frontend Static Tests For Warehouse Ledger Archive Entrypoints
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
- [ ] **Step 1: Add static assertions**
Append this `describe` block to `frontend/src/views/InventoryLedgerView.test.js`:
```javascript
describe("InventoryLedgerView document archive entrypoints", () => {
it("renders a warehouse ledger archive column using production settlement archive actions", () => {
assert.match(source, /<th>单据留档<\/th>/);
assert.match(source, /<DocumentArchiveActions/);
assert.match(source, /row\.archive_business_id/);
assert.match(source, /production-inbound-settlement/);
});
it("stores the latest production ledger inbound archive shortcut after saving", () => {
assert.match(source, /lastArchiveAction/);
assert.match(source, /buildProductionInboundArchiveAction/);
assert.match(source, /预览结算单/);
assert.match(source, /下载PDF/);
});
});
```
- [ ] **Step 2: Run the static test and verify it fails**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: fail because archive entrypoint code has not been added yet.
---
### Task 4: Add Warehouse Ledger Archive Actions And Post-Save Shortcut
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Import archive helpers**
In `frontend/src/views/InventoryLedgerView.vue`, add the component import:
```javascript
import DocumentArchiveActions from "../components/documentForms/DocumentArchiveActions.vue";
```
Update the API import to include download/open helpers:
```javascript
import { downloadResource, fetchResource, openResource, postResource, requestJson, uploadResource } from "../services/api";
```
- [ ] **Step 2: Add latest archive shortcut state**
Near existing refs, add:
```javascript
const lastArchiveAction = ref(null);
```
Add this helper near `formatArchiveFeedback`:
```javascript
function buildProductionInboundArchiveAction(result) {
if (!result?.archive_business_id) {
return null;
}
return {
typeKey: "production-inbound-settlement",
businessId: Number(result.archive_business_id),
status: result.archive_status || "未生成",
label: "生产入库结算单",
filename: `${result.material_lot_no || "生产入库结算单"}归档.pdf`
};
}
```
Clear stale shortcut in `resetFeedback`:
```javascript
function resetFeedback() {
feedbackMessage.value = "";
errorMessage.value = "";
lastArchiveAction.value = null;
}
```
- [ ] **Step 3: Render post-save shortcut near feedback banners**
In the main page area, after the existing `feedbackMessage` banner if present, render:
```vue
<div v-if="lastArchiveAction" class="document-archive-feedback-panel">
<span>{{ lastArchiveAction.label }}</span>
<button class="document-archive-button document-archive-button-preview" type="button" @click="previewDocumentArchive(lastArchiveAction.typeKey, lastArchiveAction.businessId)">预览结算单</button>
<button class="document-archive-button document-archive-button-download" type="button" @click="downloadDocumentArchive(lastArchiveAction.typeKey, lastArchiveAction.businessId, lastArchiveAction.filename)">下载PDF</button>
</div>
```
Also render the same panel inside the `productionWorkOrderInboundDrawerOpen` form under its `feedbackMessage` banner, so the shortcut works if a future change keeps the drawer open after save.
- [ ] **Step 4: Add `单据留档` column to warehouse流水**
In the warehouse ledger drawer table header, add a column before `备注`:
```vue
<th>单据留档</th>
```
In the row body, add the matching cell before `备注`:
```vue
<td class="warehouse-ledger-archive-cell">
<DocumentArchiveActions
v-if="row.archive_business_id"
class="document-archive-actions-compact"
:status="row.archive_status"
@preview="previewDocumentArchive('production-inbound-settlement', row.archive_business_id)"
@download="downloadDocumentArchive('production-inbound-settlement', row.archive_business_id, buildWarehouseLedgerArchiveFilename(row))"
@regenerate="regenerateDocumentArchive('production-inbound-settlement', row.archive_business_id)"
/>
<span v-else class="muted-cell">-</span>
</td>
```
Update the empty-row colspan expression by adding 1:
```vue
:colspan="activeWeightOnly || activeQuantityOnly ? 13 : 14"
```
- [ ] **Step 5: Add archive action handlers**
Add these functions near `openLogisticsPhoto`:
```javascript
function buildWarehouseLedgerArchiveFilename(row) {
const lotNo = row.source_material_lot_no || row.lot_no || activeInventoryLabel.value || "生产入库结算单";
const typeLabel = formatInventoryTxnTypeLabel(row.txn_type) || "生产入库结算单";
return `${lotNo}-${typeLabel}归档.pdf`;
}
async function previewDocumentArchive(typeKey, businessId) {
resetFeedback();
if (!businessId) {
errorMessage.value = "缺少归档业务ID,无法预览";
return;
}
try {
await openResource(`/document-archives/${typeKey}/${businessId}/latest/preview`);
} catch (error) {
errorMessage.value = error.message || "PDF归档预览失败";
}
}
async function downloadDocumentArchive(typeKey, businessId, fallbackFilename) {
resetFeedback();
if (!businessId) {
errorMessage.value = "缺少归档业务ID,无法下载";
return;
}
try {
await downloadResource(`/document-archives/${typeKey}/${businessId}/latest/download`, fallbackFilename);
feedbackMessage.value = "PDF归档已下载";
} catch (error) {
errorMessage.value = error.message || "PDF归档下载失败";
}
}
async function regenerateDocumentArchive(typeKey, businessId) {
resetFeedback();
if (!businessId) {
errorMessage.value = "缺少归档业务ID,无法重新生成";
return;
}
try {
const result = await postResource(`/document-archives/${typeKey}/${businessId}/generate`, {});
feedbackMessage.value = result.message || "PDF归档已重新生成";
await loadWarehouseLedger();
await loadAll();
} catch (error) {
errorMessage.value = error.message || "PDF归档重新生成失败";
}
}
```
- [ ] **Step 6: Store shortcut after `生产台账入库` save**
In `submitProductionWorkOrderInbound`, after the API result:
```javascript
lastArchiveAction.value = buildProductionInboundArchiveAction(result);
feedbackMessage.value = `生产台账入库 ${result.material_lot_no || ""} 已保存${formatArchiveFeedback(result)}`;
```
Keep the drawer close behavior unchanged.
- [ ] **Step 7: Add compact styles**
Append to `frontend/src/styles/main.css`:
```css
.warehouse-ledger-archive-cell {
min-width: 260px;
}
.document-archive-actions-compact {
align-items: stretch;
display: grid;
gap: 6px;
}
.document-archive-actions-compact .document-archive-status {
justify-content: center;
min-height: 26px;
}
.document-archive-actions-compact .document-archive-buttons {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
}
.document-archive-actions-compact .document-archive-button {
min-height: 28px;
padding: 0 7px;
font-size: 11px;
}
.document-archive-feedback-panel {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin: 10px 0 14px;
padding: 10px;
border: 1px solid rgba(31, 122, 74, 0.26);
border-radius: 14px;
background: rgba(232, 246, 232, 0.76);
}
.document-archive-feedback-panel > span {
color: #145c39;
font-weight: 900;
}
```
- [ ] **Step 8: Run the frontend static test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: pass.
---
### Task 5: Verification
**Files:**
- No code files.
- [ ] **Step 1: Run backend archive metadata test**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
python -m unittest backend.tests.test_inventory_transaction_ledger_archive_fields -v
```
Expected: all tests pass.
- [ ] **Step 2: Run frontend static tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node --test src/views/InventoryLedgerView.test.js src/views/ProductionLedgerView.test.js
```
Expected: all tests pass.
- [ ] **Step 3: Build frontend**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected: build exits 0. The existing Vite chunk-size warning is acceptable.
- [ ] **Step 4: Browser verify warehouse ledger**
Use the in-app browser:
1. Open `http://127.0.0.1:5173/inventory-ledger`.
2. Click `工具 -> 流水`.
3. Find a `生产台账` related `成品入库` / `生产余料入库` / `生产废料入库` row.
4. Confirm the `单据留档` column shows `已归档` plus `预览PDF` and `下载PDF`.
5. Click `预览PDF`.
6. Confirm the opened PDF is `生产入库结算单` and includes 成品、余料、废料明细 when the settlement action created multiple warehouse transactions.
- [ ] **Step 5: Browser verify post-save shortcut**
Use the in-app browser:
1. Open `http://127.0.0.1:5173/inventory-ledger`.
2. Click `工具 -> 生产台账入库`.
3. Select an open production ledger with safe test values.
4. Save.
5. Confirm the success area shows `预览结算单` and `下载PDF`.
6. Click `预览结算单`.
7. Confirm it opens the same `生产入库结算单` archive as the warehouse ledger row.
---
## Self-Review
- Spec coverage: The plan covers the approved design: a single `生产入库结算单` shared by the multiple warehouse流水 rows from one production ledger inbound action, warehouse ledger buttons, production ledger detail already existing, and post-save shortcut.
- Placeholder scan: No `TBD`, `TODO`, or vague “add appropriate handling” steps remain.
- Type consistency: The plan consistently uses `archive_status`, `archive_business_id`, `archive_document_type`, `archive_error_message`, and route key `production-inbound-settlement`.
- Scope control: This plan does not implement all warehouse branch PDFs. It only exposes production settlement archives already generated by the production main chain.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff