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
@@ -0,0 +1,192 @@
# 发货入口统一设计
## 背景
当前系统同时存在两个发货相关入口:
- `嘉恒仓库 -> 成品库 -> 销售出库`
- `发货与售后 -> 发货管理`
两个入口都会影响发货单、成品库存和后续退货追溯,容易让用户不知道应该在哪里执行发货,也容易造成业务口径重复。
## 目标
采用方案 A:将发货执行入口统一到 `嘉恒仓库 -> 成品库 -> 销售出库`,将 `发货与售后 -> 发货管理` 调整为发货台账/交付查询。
目标效果:
- 新增发货只允许从成品库的销售出库入口办理。
- 发货与售后模块不再新增发货,只查看、筛选、追溯发货记录。
- 销售订单发货和直接客户发货都能扣减成品库存。
- 任意发货明细都能追溯到成品库存批次和来源原材料库存批次号。
- 客户退货继续以发货批次为来源,保持退货库、废料库的追溯能力。
## 业务入口
### 成品库销售出库
`嘉恒仓库 -> 成品库 -> 销售出库` 是唯一新增发货入口。
该入口支持两种模式:
- `销售订单发货`:选择销售订单和销售订单明细,系统带出客户、产品、未发数量、收货人、电话、地址,并按成品库存批次自动拆分出库。
- `直接客户发货`:不绑定销售订单,选择客户、产品和发货数量,系统同样按成品库存批次自动拆分出库。
销售出库需要填写或维护:
- 发货模式
- 销售订单和订单明细,仅销售订单发货时必填
- 收货客户
- 发货人
- 收货人、联系电话、送货地址
- 发货产品
- 发货数量
- 运单号或订单照片
- 运费
- 自动拆分出的成品库存批次明细
- 自动拆分明细中必须直接展示成品批次、来源原材料库存批次号、来源材料摘要,方便发货前确认这批货来自哪批材料
### 发货管理
`发货与售后 -> 发货管理` 调整为发货台账/交付查询。
该页面保留:
- 发货列表
- 发货明细抽屉
- 发货单号、客户、产品、销售订单号、发货时间、发货数量、状态
- 运单号、运费、订单照片
- 成品批次、来源原材料库存批次号、来源材料信息
- 搜索、排序、分页
该页面去除:
- 新增发货按钮
- 新增发货抽屉
- 任何直接创建发货单的入口
## 数据流
成品库销售出库创建成功后统一写入:
- 发货单
- 发货明细
- 成品库存批次扣减
- 成品库库存余额扣减
- 库存流水,类型为 `SALES_OUT`
- 销售订单已发数量,仅销售订单发货时回写
- 发货批次追溯数据,供退货库和废料库使用
销售订单发货链路:
```text
销售订单
-> 销售订单明细
-> 成品库销售出库
-> 发货单
-> 发货明细
-> 成品库存批次
-> 来源原材料库存批次号
```
直接客户发货链路:
```text
客户
-> 成品库销售出库
-> 发货单
-> 发货明细
-> 成品库存批次
-> 来源原材料库存批次号
```
## 追溯规则
每条发货明细必须绑定实际扣减的成品库存批次。
成品库销售出库的自动拆分明细,在提交前就要直接展示追溯字段,而不是只在提交后的台账里展示。
成品库存批次应保留以下来源信息:
- 成品库存批次号
- 来源原材料库存批次号
- 来源材料摘要
- 完工入库或委外入库来源
- 成本相关信息
因此无论是否绑定销售订单,都可以从发货明细倒查:
```text
发货单
-> 发货明细
-> 成品库存批次
-> 来源原材料库存批次号
-> 原材料物料
-> 原材料入库批次
-> 入库成本或采购来源
```
## 退货联动
客户退货继续从发货批次选择来源。
- 可返工的客户退货进入 `退货库 -> 退货入库`。
- 判定报废的客户退货进入 `废料库 -> 退货废料入库`。
- 两种退货都应能通过发货明细追溯到成品批次和来源原材料库存批次号。
直接客户发货也必须支持退货,因为直接客户发货同样生成发货单和发货明细。
## 校验规则
成品库销售出库需要校验:
- 必须选择成品库仓库。
- 必须选择客户。
- 必须选择产品。
- 发货数量必须大于 0。
- 自动拆分出的成品批次数量合计必须等于本次发货数量。
- 成品库存不足时禁止提交。
- 销售订单发货时,发货数量不能超过订单明细未发数量。
- 运单号和订单照片至少填写或上传一个。
- 运费字段保留,允许为 0。
发货管理台账需要保证:
- 不产生库存变更。
- 不产生发货单。
- 仅展示已有发货单、明细、追溯链路和物流信息。
## UI 调整
成品库销售出库抽屉增加发货模式选择:
- 销售订单发货
- 直接客户发货
销售订单发货时展示销售订单和订单明细选择。
直接客户发货时隐藏销售订单相关字段,允许手动选择客户和产品。
自动销售出库明细表建议展示以下列:
- 成品库存批次号
- 来源原材料库存批次号
- 来源材料摘要
- 可发数量
- 本次发货数量
- 本次发货重量
发货管理页面标题建议改为 `发货台账`,页面说明调整为交付查询和批次追溯,不再出现新增发货入口。
## 测试重点
- 成品库销售出库可以创建销售订单发货。
- 成品库销售出库可以创建直接客户发货。
- 发货后成品库存正确扣减。
- 发货后生成 `SALES_OUT` 库存流水。
- 销售订单发货后订单已发数量正确回写。
- 直接客户发货不影响销售订单数量。
- 成品库销售出库自动拆分明细能在提交前展示来源原材料库存批次号。
- 发货管理页面没有新增发货入口。
- 发货管理明细能展示来源原材料库存批次号。
- 退货库和废料库仍能从发货批次选择来源。
@@ -0,0 +1,27 @@
# Dictionary Localization Design
## Goal
Eliminate visible English enum/dictionary values from ERP business screens by translating status, type, source, role, warehouse, inventory, stocktake, and maintenance codes to Chinese at the display layer.
## Scope
- Keep database and API enum values unchanged, such as `ACTIVE`, `RAW`, `PURCHASE_IN`, and `STOCKTAKE_LOSS`.
- Centralize frontend translations in `frontend/src/utils/dictionaries.js`.
- Update pages that currently render enum values directly, especially inventory, equipment, employee, purchase, stocktake, and dashboard-style views.
- Unknown enum codes should not be shown raw in business UI. They should display a Chinese fallback while retaining the raw code in `title` where useful for diagnosis.
## Design
- Expand dictionary maps for common business enum groups: status, quality status, inventory transaction type, source document type, warehouse type, item type, purchase source, maintenance type, miniapp role, and stocktake row status.
- Add formatter functions such as `formatWarehouseTypeLabel`, `formatItemTypeLabel`, `formatPurchaseSourceTypeLabel`, `formatMaintenanceTypeLabel`, `formatMiniappRoleLabel`, and `formatStocktakeRowStatusLabel`.
- Replace direct template output like `{{ order.maintenance_type }}` and `{{ person.role_names.join(" / ") }}` where values may be English enum codes.
- Keep user-entered free text unchanged. Only known internal enum/code fields are localized.
## Verification
- Add a frontend static check to verify key enum maps and high-risk views use formatter functions.
- Run existing inventory static checks.
- Run frontend build.
- Run targeted backend compile or tests only if backend files are changed.
@@ -0,0 +1,19 @@
# Operation Report Date Filter Design
## Goal
05-2 工序报工不再默认从小程序全量拉取报工数据,而是默认拉取最近 15 天,并允许用户在页面右上角按报工日期筛选。
## Design
- 后端 `/production/miniapp-operation-reports` 增加 `start_date`、`end_date` 查询参数,按 `production_reports.report_date` 闭区间过滤。
- 如果 `start_date > end_date`,接口返回 400,提示开始日期不能晚于结束日期。
- 前端 `OperationReportView.vue` 初始化日期范围为最近 15 天,含今天,例如 2026-06-01 默认 `2026-05-18` 到 `2026-06-01`。
- 05-2 页面右上角显示开始日期、结束日期和刷新按钮;刷新请求带上日期参数。
- 05-3 报废处理暂不套默认半个月筛选,避免隐藏历史未处理报废。
## Verification
- 后端测试覆盖日期范围过滤和非法日期范围。
- 前端静态回归覆盖默认 15 天、日期输入控件和请求参数。
- 运行相关后端测试、前端静态脚本和生产构建。
@@ -0,0 +1,270 @@
# 嘉恒仓库库级出入库流水设计
## 背景
嘉恒仓库已经承载原材料库、半成品库、成品库、辅料库、废料库、退货库六类库存。当前页面支持点击某个物料查看该物料的批次台账和库存流水,但缺少“站在某一个仓库整体视角查看所有出入库流水”的入口。
本设计在仓库页新增库级流水入口。入口跟随当前选中的仓库 tab,用于查看当前仓库下所有物料、批次和业务单据产生的出入库流水。
## 目标
- 在嘉恒仓库页面的出入库操作区右侧新增一个“流水”图标按钮,位置对应用户截图红框区域。
- 当前选中哪个仓库 tab,点击按钮就查看该仓库的出入库流水。
- 六大仓库都支持库级流水:原材料库、半成品库、成品库、辅料库、废料库、退货库。
- 流水数据来自统一库存流水表 `wh_inventory_txn`,不新增另一套流水记录体系。
- 库级流水和现有物料级“库存流水”并存,形成“仓库总览 -> 物料明细 -> 批次追溯”的层级。
## 非目标
- 本次不新增复杂趋势图、库存金额图表或 BI 看板。
- 本次不替代物料详情抽屉中的批次台账和物料流水。
- 本次不改变现有出入库业务的入账逻辑。
- 本次不新增导出功能;可在后续版本增加“导出当前筛选流水”。
## 页面入口
在嘉恒仓库页面的出入库操作区右侧新增一个图标按钮。
按钮要求:
- 图标使用符合当前 Ruoyi 风格的“流水/列表/票据”类图标。
- 鼠标悬停显示提示:`查看当前仓库出入库流水`。
- 按钮文本可显示为 `流水`,避免只有图标导致用户不理解。
- 按钮始终跟随当前选中仓库 tab,不在每个仓库 tab 内重复放置。
示例:
```text
仓库 tab:原材料库 / 半成品库 / 成品库 / 辅料库 / 废料库 / 退货库
出入库操作区:
入库 [+ 期初入库] [+ 客料入库] ...
出库 [- 生产出库] [- 委外出库] ...
[流水]
```
## 抽屉交互
点击“流水”按钮后,从右侧弹出大抽屉。
抽屉标题格式:
```text
{当前仓库名} · 出入库流水
```
例如:
```text
原材料库 · 出入库流水
成品库 · 出入库流水
退货库 · 出入库流水
```
抽屉内容分为三块:
1. 顶部汇总卡片
2. 筛选区
3. 流水明细表
抽屉滚动遵循全局抽屉规则:鼠标滚动只控制抽屉,不滚动底层主页面;抽屉必须完整显示在屏幕可视范围内。
## 顶部汇总
汇总数据按当前仓库和当前筛选条件计算。
建议展示:
- 入库笔数
- 出库笔数
- 入库重量(kg)
- 出库重量(kg)
- 最近一笔流水时间
数量口径:
- 原材料库、辅料库、废料库以重量为主,数量不作为核心汇总。
- 半成品库、成品库、退货库同时展示数量和重量。
如果汇总接口暂不单独返回,可前端基于当前分页结果做轻量展示,但最终推荐由后端返回全量筛选条件下的汇总,避免分页导致汇总不完整。
## 筛选与排序
筛选区保留系统全局业务列表筛选规则:
- 搜索框支持任意文字模糊搜索。
- 搜索范围包括物料编码、物料名称、批次号、流水类型、来源单据、运单号、备注。
- 重要业务字段支持排序,字段名旁显示上下箭头。
筛选项:
- 日期范围:按 `biz_time` 过滤。
- 出入库方向:全部、入库、出库、调整。
- 业务类型:采购入库、销售出库、退货入库、返工出库等。
- 物料:可选当前仓库内出现过的物料。
- 批次号:支持输入模糊匹配。
排序字段:
- 流水时间
- 业务类型
- 物料编码
- 批次号
- 变动重量
- 变动数量
- 金额
默认排序:
```text
流水时间倒序,最新流水在最上方。
```
## 流水表字段
通用字段:
- 流水时间
- 方向
- 业务类型
- 物料编码 / 物料名称
- 批次号
- 变动数量
- 变动重量(kg)
- 单价
- 金额
- 来源单据
- 经办人
- 运单号 / 订单照片
- 备注
不同仓库展示口径:
- 原材料库:隐藏或置空数量列,突出重量。
- 辅料库:隐藏或置空数量列,突出重量。
- 废料库:隐藏或置空数量列,突出重量和金额。
- 半成品库:展示数量和重量。
- 成品库:展示数量和重量。
- 退货库:展示数量和重量,并突出发货批次、退货单、返工出库来源。
方向判断:
- `weight_change_kg > 0` 或 `qty_change > 0` 为入库。
- `weight_change_kg < 0` 或 `qty_change < 0` 为出库。
- 盘库造成的正负调整显示为调整,也保留原始正负数。
## 数据接口
新增后端分页接口:
```http
GET /inventory/transaction-ledger
```
参数:
- `warehouse_type`: 必填,枚举 `RAW | SEMI | FINISHED | AUX | SCRAP | RETURN`
- `warehouse_id`: 可选,指定具体仓库 ID
- `keyword`: 可选,模糊搜索
- `direction`: 可选,`IN | OUT | ADJUST | ALL`
- `txn_type`: 可选,业务类型
- `item_id`: 可选,物料 ID
- `lot_no`: 可选,批次号模糊搜索
- `start_date`: 可选
- `end_date`: 可选
- `page`: 默认 1
- `page_size`: 默认 10
- `sort_key`: 默认 `biz_time`
- `sort_direction`: 默认 `desc`
返回:
```json
{
"items": [],
"total": 0,
"summary": {
"in_count": 0,
"out_count": 0,
"adjust_count": 0,
"in_qty": 0,
"out_qty": 0,
"in_weight_kg": 0,
"out_weight_kg": 0,
"latest_biz_time": null
}
}
```
接口内部使用 `wh_inventory_txn` 作为主表,关联:
- `md_item`
- `wh_warehouse`
- `wh_location`
- `wh_stock_lot`
- 操作人表,如果当前流水已有 `operator_user_id`
## 与现有功能关系
库级流水:
- 入口位于出入库操作区。
- 查询当前仓库所有物料和批次的流水。
- 用于回答“这个仓库最近发生了哪些出入库”。
物料级流水:
- 入口是点击库存列表中的某个物料。
- 查询该物料在当前仓库下的流水。
- 用于回答“这个物料为什么变成当前库存”。
批次台账:
- 入口同样是点击库存列表中的物料。
- 查询该物料下面每个批次的现存状态。
- 用于回答“当前还剩哪些批次,批次来源是什么”。
三者保留并互补,不互相替代。
## 错误与空状态
- 当前仓库没有流水时显示空状态:`暂无{仓库名}出入库流水`。
- 接口失败时使用右上角全局提示,几秒后自动收回,鼠标悬停时保持不收。
- 筛选结果为空时显示:`没有符合条件的流水`。
- 如果仓库正在盘库锁库,流水仍可查看,但出入库动作不可操作。
## 测试方案
后端测试:
- 按仓库类型过滤只返回对应仓库流水。
- 方向过滤能区分入库、出库、调整。
- 关键词搜索覆盖物料、批次、来源、备注。
- 分页返回 `items`、`total` 和 `summary`。
- 原材料库流水数量口径保持重量优先,不恢复原材料数量概念。
前端静态测试:
- 嘉恒仓库页面存在“流水”入口。
- 六大仓库 tab 都可打开同一流水抽屉。
- 请求使用 `/inventory/transaction-ledger`。
- 旧物料级库存流水仍保留。
前端构建:
- `npm run build` 通过。
浏览器冒烟:
- 登录后进入嘉恒仓库。
- 切换任意仓库 tab。
- 点击“流水”按钮。
- 抽屉标题和当前仓库一致。
- 搜索、日期筛选、分页能正常工作。
## 后续可选增强
- 增加“导出当前筛选流水”。
- 增加流水趋势图。
- 增加来源单据点击跳转。
- 增加按业务类型聚合的迷你统计。
@@ -0,0 +1,290 @@
# 生产日结与材料闭环设计
## 背景
真实生产不是一次领料后必须按参考生产数量一次性全部做完。常见场景是:今天领料生产一部分,下班前把已完成成品入成品库,把剩余原材料还回原材料库,第二天再次领料或继续生产。现有逻辑偏向“参考生产数量未做完则生产出库任务未结束”,会让 DAG 看起来像领料一直悬挂,无法表达阶段性生产和阶段性结算。
本设计目标是把生产出库从“一次性完工工单”改造为“可多次日结的生产任务”,用材料去向闭环替代计划数量闭环。
## 核心业务原则
1. 生产出库代表一次领料任务,不代表必须一次性完成全部参考生产数量。
2. 每次生产结束可以做一次“生产日结”,记录本次报工、成品入库、余料归还、废料/报废和差异原因。
3. 任务是否结束,不再只看 `完工数 >= 参考生产数量`,而是看领出的材料是否已经有明确去向。
4. 成品入库、生产余料入库、生产废料入库都要能挂到同一次生产日结下。
5. DAG 要展示“领料任务 -> 多次生产日结 -> 各类入库/报废 -> 任务结清”的真实链路。
## 新增概念
### 生产出库任务
沿用现有生产出库/工单主体,语义调整为“领料任务”。它记录:
- 生产跟踪号
- 产品
- 领用原材料库存批次
- 领用重量
- 参考生产数量
- 当前材料闭环状态
参考生产数量仍保留,但只作为参考值,不再是唯一闭环标准。
### 生产日结单
新增一个业务对象,建议表名为 `pp_production_settlement`,用于记录某个生产出库任务的一次阶段性结算。
建议字段:
- `settlement_no`:日结单号
- `work_order_id`:生产出库任务 ID
- `settlement_date`:结算日期
- `reported_qty`:本次报工数量
- `finished_qty`:本次合格成品数量
- `scrap_qty`:本次报废数量
- `standard_consumed_weight_kg`:标准消耗重量
- `expected_surplus_weight_kg`:标准应归还余料重量
- `actual_surplus_weight_kg`:实际余料归还重量
- `site_carryover_weight_kg`:现场留存重量,用于记录当天不还仓、继续留在生产现场的材料重量
- `scrap_weight_kg`:废料或报废折算重量
- `variance_weight_kg`:差异重量
- `variance_rate`:差异率
- `variance_reason`:偏差原因
- `status`:草稿、已确认、异常确认、已撤销
标准消耗公式:
```text
标准消耗重量 = 本次报工数量 × 产品毛重
```
标准应归还余料:
```text
标准应归还余料 = 结算前未闭环现场余额 - 标准消耗重量 - 废料/报废折算重量 - 现场留存重量
```
偏差率:
```text
偏差率 = abs(实际余料归还重量 - 标准应归还余料) / 标准应归还余料
```
当标准应归还余料为 0 时,使用差异重量是否为 0 判断;如存在差异,要求填写原因。
## 状态规则
生产出库任务建议状态:
- `已领料`:已经完成生产出库,还没有日结。
- `生产中`:已有小程序报工或工序进度,但尚未结算。
- `部分结算`:至少存在一张已确认生产日结单,但材料尚未全部闭环。
- `待结清`:理论上仍有未处理材料、未入库成品或未解释差异。
- `已结清`:领料重量已经通过成品、余料、废料、允许损耗和差异原因完成闭环。
- `异常结清`:偏差超过阈值,用户填写原因后确认结清。
偏差阈值默认使用 10%。超过 10% 时,必须填写偏差原因才能确认日结。
## 材料闭环判断
一个生产出库任务的材料闭环按重量判断:
```text
领料总重量
= 已结算标准消耗重量
+ 已归还余料重量
+ 已入废料/报废重量
+ 允许损耗重量
+ 已解释差异重量
+ 未结清现场余额
```
系统展示时要明确区分:
- 已生产消耗
- 已余料归还
- 已报废/废料入库
- 已解释差异
- 未结清现场余额
只有未结清现场余额为 0,且所有超过阈值的差异都有原因时,任务才能进入已结清或异常结清。
如果用户当天不把剩余材料全部还仓,而是留在现场第二天继续生产,则在生产日结中填写“现场留存重量”。这部分重量不会形成余料入库流水,也不会让任务结清;它会作为下一次生产日结的结算前未闭环现场余额。
## 出入库联动
### 成品入库
成品入库从生产日结单发起或关联生产日结单。每张生产日结单可以对应一张或多张成品入库记录,但推荐前端默认合并为一次成品入库操作。
成品批次要记录:
- 生产出库任务
- 生产日结单
- 原材料库存批次来源
- 入库数量
- 入库重量
- 入库材料成本
### 生产余料入库
生产余料入库必须关联生产日结单和来源库存批次。归还逻辑仍然是回到原库存批次,不新建库存批次。
页面展示:
- 来源库存批次号,多选
- 每个来源库存批次的归还重量
- 标准应归还重量
- 实际归还重量
- 现场留存重量
- 偏差率
- 超过 10% 时填写原因
### 生产废料入库
生产废料入库可以关联生产日结单。用于记录边角料、废料和报废折算进入废料库。
如果废料入库重量来自报废数量折算,系统应显示计算依据;如果用户直接称重录入,则以称重为准。
## DAG 展示设计
DAG 从当前“生产出库后直接挂工序、报工、成品入库”调整为分层展示:
```mermaid
flowchart LR
A["原材料库存批次"] --> B["生产出库任务"]
B --> C1["生产日结 2026-06-02"]
B --> C2["生产日结 2026-06-03"]
C1 --> D1["成品入库"]
C1 --> E1["生产余料入库"]
C1 --> F1["生产废料入库"]
C2 --> D2["成品入库"]
C2 --> E2["生产余料入库"]
C2 --> F2["生产废料入库"]
B --> G["任务结清状态"]
```
节点层级:
- Level 0:原材料库存批次
- Level 1:生产出库任务
- Level 2:生产日结单
- Level 3:工序报工汇总、成品入库、余料入库、废料入库
- Level 4:销售出库或后续追溯节点
DAG 节点指标:
- 生产出库任务节点:领料重量、参考生产数量、累计日结数量、未结清现场余额、状态。
- 生产日结节点:本次报工、合格品、报废、标准消耗、标准余料、实际余料、偏差率。
- 未结清现场余额节点或指标:展示仍留在现场、尚未还仓或尚未解释去向的材料重量。
- 成品入库节点:入库数量、来源库存批次、材料成本。
- 余料入库节点:归还库存批次、归还重量、偏差说明。
- 废料入库节点:废料重量、来源日结、处理状态。
DAG 视觉规则:
- 未结清任务用黄色状态。
- 偏差超过阈值且未写原因用红色状态。
- 已结清任务用绿色状态。
- 生产日结节点按日期从上到下排列。
- 点击生产出库任务时,高亮所有关联日结和出入库链路。
- 点击某个生产日结时,只高亮该日结对应的成品、余料和废料链路。
## 前端交互
### 生产出库列表
新增或调整列:
- 生产跟踪号
- 产品
- 领料重量
- 参考生产数量
- 累计报工数量
- 累计成品入库
- 累计余料归还
- 未结清现场余额
- 状态
操作:
- 查看 DAG
- 新增生产日结
- 查看日结记录
- 结清任务
### 新增生产日结抽屉
用户选择生产出库任务后,系统自动带出:
- 产品
- 产品毛重
- 领料来源库存批次
- 当前未结清重量
- 小程序报工汇总
- 可入库成品数量
用户填写或确认:
- 本次报工数量
- 本次成品入库数量
- 本次报废数量
- 本次余料归还重量
- 本次现场留存重量
- 本次废料入库重量
- 偏差原因
系统实时计算:
- 标准消耗重量
- 标准应归还重量
- 结算后未结清现场余额
- 实际归还偏差
- 偏差率
- 是否必须填写原因
## 后端接口
建议新增接口:
- `GET /production/work-orders/{id}/settlement-preview`
- `POST /production/work-orders/{id}/settlements`
- `GET /production/work-orders/{id}/settlements`
- `POST /production/settlements/{id}/confirm`
- `POST /production/work-orders/{id}/close`
现有接口调整:
- 成品入库接口支持传入 `settlement_id`。
- 生产余料入库接口支持传入 `settlement_id`。
- 生产废料入库接口支持传入 `settlement_id`。
- DAG 生命周期接口返回生产日结节点和日结关联的入库/报废节点。
## 数据迁移
已有历史生产出库可以不强制补日结单。迁移策略:
- 已完工且已有成品入库的生产出库,DAG 可继续按旧链路展示。
- 新增生产日结功能上线后,新生产出库默认走日结链路。
- 对历史未完成生产出库,可允许用户手工创建一张“历史补录日结单”进行结清。
## 测试要点
后端测试:
- 领料 200kg,报工 200 件,毛重 0.6kg,标准应归还 80kg。
- 偏差不超过 10% 时允许确认日结。
- 偏差超过 10% 且无原因时禁止确认。
- 多次日结累计后,未结清现场余额正确减少。
- 成品入库、余料入库、废料入库均能关联同一日结单。
- 任务结清不再依赖 `完工数 >= 参考生产数量`。
前端测试:
- 生产日结抽屉实时计算标准余料和偏差率。
- 超过 10% 时偏差原因必填。
- DAG 展示生产日结节点和多条出入库链路。
- 点击生产出库任务和点击日结节点的高亮范围不同。
## 非目标
本次设计不引入复杂 APS 排产,不管理员工排班,不强制要求每道工序都每日结算。小程序报工仍作为生产数量和报废数量来源,ERP 负责生产日结和库存闭环。
@@ -0,0 +1,177 @@
# 成品库返工入库设计
> 状态:已暂停,后续版本再做。
>
> 2026-06-03 业务决策:本版本不实现 `成品库 -> 返工入库`,也不切换为普通返工入库方案。本文仅保留为后续版本讨论参考,当前不可作为实施依据。
## 背景
当前 `退货库 -> 返工出库` 已经能把客户退回且可返工的产品从退货库出库,并生成退货处置记录和返工工单。但 `成品库` 还没有对应的 `返工入库` 入口,返工完成后的成品无法在仓库端形成明确闭环。
本设计采用方案 A:`成品库 -> 返工入库` 必须关联一条已经完成 `退货库 -> 返工出库` 的返工记录。这样能把客户退货、返工出库、返工完成回库、成品库存和原材料库存批次追溯串成一条完整链路。
## 目标
- 在 `嘉恒仓库 -> 成品库` 的入库区新增 `返工入库`。
- `返工入库` 必须选择产品,并关联该产品未完成回库的返工出库记录。
- 选择产品后,系统带出该产品对应的原材料库存批次号多选框,用 `JH...` 原材料库存批次号做追溯。
- 成品库仍以数量为主维度,重量只作为辅助信息或追溯信息,不作为主要库存口径。
- 入库后如果成品库已有该产品,则更新该产品库存余额;同时新增本次返工入库的库存明细和库存流水。
- 避免展示每个仓库自己的无业务意义内部批次号,前端展示时优先展示原材料库存批次号。
## 业务入口
`嘉恒仓库 -> 成品库 -> 入库 -> 返工入库`
该入口建议复用当前普通仓库入库抽屉样式,不另起一个完全不同的页面。字段建议为:
- 产品,必填。
- 返工出库记录,必填,只展示当前产品已返工出库且尚未完成返工入库的记录。
- 原材料库存批次号,可多选,选择范围按产品需规清单中该产品使用的原材料匹配库存批次。
- 入库数量,必填,必须大于 0。
- 入库材料成本,默认可按选中原材料库存批次成本和产品毛重计算,也允许用户按现有成品入库规则调整。
- 入库仓库和库位,默认成品库默认库位。
- 来源/提供方,可选,用于记录返工执行方或内部返工班组。
- 备注,可选。
## 数据流
返工闭环链路:
```text
发货明细
-> 退货库退货入库
-> 退货库返工出库
-> 退货返工处置记录
-> 成品库返工入库
-> 成品库存余额
-> 成品库存流水
-> 原材料库存批次号追溯
```
`返工入库` 创建成功后写入:
- 成品库库存批次明细,业务类型为 `返工入库`。
- 成品库库存余额,按产品数量累加。
- 成品库库存流水,流水类型为 `REWORK_IN` 或对应中文展示 `返工入库`。
- 返工处置记录关联的回库数量派生数据,来源于本次成品库库存批次和库存流水。
- 来源原材料库存批次号字段,用于后续成品库明细、销售出库、发货台账、退货追溯展示。
## 返工记录状态
返工出库记录不新增独立状态字段。本次通过 `成品库返工入库` 关联的库存批次和库存流水汇总已回库数量,并派生以下展示状态:
- `待回库`:已从退货库返工出库,但尚未做成品库返工入库。
- `部分回库`:返工入库数量小于返工出库数量。
- `已回库`:返工入库数量达到返工出库数量。
返工入库的库存批次和库存流水必须记录:
- `source_doc_type = RETURN_DISPOSITION`
- `source_doc_id = 返工处置记录ID`
- `source_line_id = 退货明细ID或返工处置记录ID`
待回库和部分回库列表按 `返工出库数量 - 已返工入库数量` 计算,避免额外维护一套容易不同步的状态字段。
## 数量规则
成品库返工入库以数量为主。
校验规则:
- 必须选择成品库。
- 必须选择产品。
- 必须选择返工出库记录。
- 返工出库记录的产品必须和入库产品一致。
- 入库数量必须大于 0。
- 入库数量不能超过该返工出库记录的剩余可回库数量。
- 原材料库存批次号可以多选;如果用户未选择,允许保存,但追溯信息会显示为空或未指定。
- 如果选择了原材料库存批次号,只记录追溯关系,不扣减原材料库存。
## 原材料库存批次追溯
返工入库的原材料库存批次号用于说明该返工成品来源于哪些原材料库存批次,不代表本次重新消耗原材料。
选择逻辑:
- 用户先选择产品。
- 系统根据产品需规清单找到该产品使用的原材料。
- 下拉多选框列出这些原材料在原材料库中的库存批次号。
- 可用批次优先展示,不可用批次置灰排后;是否允许勾选不可用批次按现有追溯选择组件规则执行。
- 保存后写入 `source_material_lot_id`、`source_material_sub_batch_no`、`source_material_summary` 等现有追溯字段。
展示规则:
- 成品库库存明细和流水中,`库存批次号` 展示应优先使用来源原材料库存批次号。
- 如果多个原材料库存批次号参与追溯,显示为 `JH...、JH...`。
- 不把成品库内部批次号作为主要业务展示字段。
## 成本规则
成品库页面中的成本字段沿用当前成品库口径:成品按件管理,成本按每件辅助展示。
返工入库建议默认成本:
```text
入库材料成本(毛重;元/件) = 选中原材料库存批次成本加权单价 × 产品毛重
```
如果没有选择原材料库存批次,或无法取到产品毛重,则默认成本为 0,并允许用户按现有成品入库规则手动填写或后续补录。
返工产生的额外人工成本仍保留在退货处置记录中,不在本次返工入库中重复计算,避免成本重复归集。
## UI 设计
成品库入库区新增一个 `返工入库` 操作按钮,和 `生产入库`、`委外入库`、`期初入库` 保持同一套风格。
抽屉交互:
1. 用户点击 `返工入库`。
2. 选择产品。
3. 返工出库记录下拉框刷新,只显示该产品待回库或部分回库记录。
4. 系统展示返工摘要,例如退货单、客户、返工出库数量、已回库数量、剩余可回库数量。
5. 原材料库存批次号使用标签多选框。
6. 用户填写入库数量、来源/提供方、备注。
7. 保存后关闭抽屉并刷新成品库库存、流水和返工记录状态。
## 接口与字典
建议新增入库业务类型:
- 内部值:`REWORK_IN`
- 中文展示:`返工入库`
- 允许仓库:`FINISHED`
- 库存流水类型:`REWORK_IN`
前端字典、状态展示和库存流水展示必须使用中文,不展示英文内部值。
## 错误提示
错误提示使用明确中文:
- `请选择返工出库记录`
- `该返工记录不属于当前产品`
- `返工入库数量必须大于0`
- `返工入库数量不能超过剩余可回库数量`
- `成品库不允许执行该入库业务`
## 测试重点
- 成品库入库区出现 `返工入库` 操作。
- 返工入库必须选择返工出库记录。
- 产品选择后,只列出该产品的待回库返工记录。
- 入库数量大于 0 才能提交。
- 入库数量超过剩余可回库数量时后端拒绝。
- 保存后成品库库存余额按数量增加。
- 保存后生成 `返工入库` 库存流水。
- 保存后返工记录状态更新为部分回库或已回库。
- 成品库库存明细和流水展示原材料库存批次号,而不是内部批次号。
- 原材料库存批次号只做追溯,不扣减原材料库存。
- 前端字典和值展示不出现英文 `REWORK_IN`。
## 非目标
- 本次不改小程序报工流程。
- 本次不把返工入库纳入普通生产工单完工入库结单逻辑。
- 本次不重做退货库返工出库,只补齐其在成品库的回库闭环。
- 本次不引入新的仓库维度或新的独立批次展示体系。
@@ -0,0 +1,480 @@
# 原材料库存批次驱动的生产工单与入库闭环设计
## 背景
当前 ERP 已经保留“工单台账”,小程序报工也已经能按生产相关标识回写 ERP。用户希望现场员工不要再理解复杂的“生产工单号”概念,而是围绕“材料库存批次号”报工:员工只要知道这批产品用的是哪一批材料,系统就能追溯到材料入库成本、成品入库、发货和利润。
本设计保留 ERP 内部的生产工单台账,但把生产执行入口和小程序选择语义调整为“材料库存批次号驱动”。ERP 工单继续承担生产过程容器的职责,负责承接领料、报工、成品入库、余料入库、废料入库和结单。
## 目标
- 原材料库存批次号改为原材料库全库统一递增,不再按物料分别递增。
- 原材料库存批次号默认格式为 `YL0001`,前缀从“系统拓展”配置,不配置默认 `YL`。
- 批次数字不足 4 位补 0,超过 `9999` 后自然扩展为 5 位及以上,例如 `YL9999 -> YL10000`。
- 原材料库“生产出库”强制一次只能选择一个原材料库存批次。
- 生产工单号改为 `YYYYMMDD-库存批次号-产品名称`,例如 `20260607-YL0001-钢制碗`。
- 同一天、同一个库存批次号、同一个产品的生产出库合并为同一张未结单工单。
- 小程序原“工单号”业务语义调整为“材料库存批次号”,员工按材料库存批次号报工。
- 嘉恒仓库“流水”按钮旁新增“生产工单入库”入口,统一处理成品入库、生产余料入库、生产废料入库。
- “生产工单入库”中系统计算理论成品、理论余料、理论废料作为辅助默认值,用户可修改。
- 当用户修改余料或废料后,与理论值偏差超过上下 15%,必须弹窗填写说明,但不禁止入库。
- “生产工单入库”不默认结单。只有用户主动勾选“本次入库后结单”才结单,并且提交前必须二次确认。
## 非目标
- 不取消工单台账。
- 不把 ERP 内部所有生产关系改成只靠字符串批次号追溯,核心追溯仍依赖数据库主键和外键关系。
- 不在初版强制限制理论成品、理论余料、理论废料的入库上限,只做偏差说明。
- 不在初版重构所有历史成品批次号、废料批次号、半成品批次号的编号规则。
- 不在初版做复杂的多原材料批次混合生产。生产出库阶段先强制单原材料库存批次。
## 编号规则
### 原材料库存批次号
新增系统拓展配置项:
```text
配置项名称:原材料库存批次号前缀
配置项编码:RAW_MATERIAL_LOT_PREFIX
默认值:YL
```
生成规则:
```text
批次号 = 前缀 + 序号
```
序号规则:
- 在原材料库范围内统一递增。
- 只统计符合“当前前缀 + 纯数字”的原材料库存批次号。
- 数字不足 4 位时左侧补 0。
- 数字达到 10000 后不截断、不回卷,直接显示完整数字。
示例:
```text
YL0001
YL0002
YL9999
YL10000
YL10001
```
如果后续把前缀从 `YL` 改成 `RM`,则新批次按 `RM0001` 重新从该前缀的最大序号递增。历史 `YL` 批次不改号。
### 生产工单号
生产工单号格式:
```text
YYYYMMDD-库存批次号-产品名称
```
示例:
```text
20260607-YL0001-钢制碗
```
日期按服务器业务时间所在日期生成,使用当前项目部署时区。产品名称做长度截断,保证不超过数据库字段长度。
## 生产出库规则
原材料库“生产出库”从多选库存批次改为单选库存批次。
提交校验:
- 必须选择产品。
- 必须选择且只能选择一个原材料库存批次。
- 选择的库存批次必须属于该产品需规清单默认用料。
- 选择的库存批次必须属于原材料库。
- 选择的库存批次必须可用且质检合格。
- 本次出库重量必须大于 0,且不能超过该批次可用重量。
### 同日同批同产品合并
提交生产出库时,系统按以下条件查找工单:
```text
工单日期 = 当天
产品 = 当前产品
来源原材料库存批次 = 当前选择批次
状态 != 已结单
```
如果找到,则本次生产出库合并到已有工单:
- 追加一条工单领料明细。
- 扣减该库存批次剩余重量。
- 增加工单领料重量。
- 按 BOM 单件毛重重新计算参考计划生产数。
- 工单号不变。
如果未找到,则新建工单:
- 工单号按 `YYYYMMDD-库存批次号-产品名称` 生成。
- 新建工单物料、工序路线。
- 写入工单领料明细。
- 扣减原材料库存批次。
如果同日同批同产品的工单已结单,本次不合并旧工单,直接拦截:
```text
该库存批次今日对应产品的生产工单已结单,不能继续合并生产出库。
```
这样避免结单后再次追加领料,造成闭环记录不可信。
## 小程序报工语义
小程序界面原“工单号”业务语义调整为:
```text
材料库存批次号
```
为降低上线风险,接口字段名可以先保持原字段,例如继续使用小程序现有的 `raw_material_batch_no` 或原“工单号”字段承载值,但页面展示文案和业务解释改为“材料库存批次号”。
小程序报工传回 ERP 时,ERP 后端按以下信息匹配生产工单:
```text
产品
工序
材料库存批次号
未结单状态
```
匹配逻辑:
- 先通过材料库存批次号找到原材料库存批次。
- 再找该产品、该批次关联的未结单生产工单。
- 再按小程序扫码上下文中的工序名称匹配工单工序。
- 匹配成功后报工写入该工单和该工序。
- 匹配失败时返回清晰错误,提示 ERP 还没有为该产品和该材料批次创建生产出库工单。
结单后的工单不再参与小程序待选项,也不再接收小程序报工。
## 生产工单入库入口
在嘉恒仓库页面,“流水”按钮旁新增:
```text
生产工单入库
```
入口跟随嘉恒仓库整体,不放在某一个具体仓库的入库/出库行里。因为这个操作一次会同时影响成品库、原材料库和废料库。
点击后打开较宽抽屉,避免表格和计算字段拥挤。该入口不使用小弹窗承载主流程。
## 生产工单入库交互
第一步选择生产工单:
- 下拉只显示未结单生产工单。
- 显示格式建议为 `工单号 · 产品 · 领料重量kg · 已入库成品数`。
- 支持搜索工单号、产品、材料库存批次号。
选择工单后展示工单摘要:
- 工单号
- 产品名称
- 材料库存批次号
- 领料重量
- 单件毛重
- 单件净重
- 参考计划生产数
- 当前已报工成品数
- 当前已报工废品数
- 已入库成品数
- 已归还余料重量
- 已入库废料重量
用户填写:
- 成品入库数量
- 余料入库重量
- 废料入库重量
- 备注
- 本次入库后结单,默认不勾选
当用户输入“成品入库数量”后,系统自动计算并填入:
- 理论余料入库重量
- 理论废料重量
用户可以直接修改系统填入的值。
## 理论值计算
设:
```text
领料重量 = L
成品入库数量 = Q
产品单件毛重 = G
产品单件净重 = N
当前工单累计报废数量 = S
已归还余料重量 = R
已入库废料重量 = W
```
理论已用材料重量:
```text
Q * G
```
理论本次余料入库重量:
```text
max(L - R - Q * G, 0)
```
理论本次废料重量:
```text
max(Q * (G - N) + S * G - W, 0)
```
说明:
- 初版理论值只作为辅助默认值。
- 不作为硬性库存上限。
- 用户可以自由修改。
- 如果理论值为 0,而用户填写了大于 0 的值,应视为超过 15% 偏差,需要填写说明。
## 15% 偏差说明
当用户提交时,系统比较实际填写值和理论值。
需要校验的字段:
- 余料入库重量
- 废料入库重量
偏差公式:
```text
偏差率 = abs(实际值 - 理论值) / 理论值
```
规则:
- 理论值大于 0,偏差率超过 15%,必须填写说明。
- 理论值等于 0,实际值大于 0,必须填写说明。
- 理论值等于 0,实际值等于 0,不需要说明。
弹窗文案示例:
```text
本次入库数据与理论值存在较大偏差
余料入库:
理论值:80kg
实际值:100kg
偏差:+25%
废料入库:
理论值:12kg
实际值:10kg
偏差:-16.67%
请填写偏差说明后继续入库。
```
用户填写说明后,可以继续提交。说明必须写入相关库存流水和工单备注/扩展记录中,保证后续可查。
## 结单确认
“本次入库后结单”默认不勾选。
只有用户主动勾选后才结单。勾选后提交,必须弹出二次确认框。
确认框展示:
```text
请确认本次生产工单入库
工单:20260607-YL0001-钢制碗
成品入库:xxx 件
余料入库:xxx kg
废料入库:xxx kg
偏差说明:xxx
勾选结单后:
1. 该工单状态将变为已结单
2. 小程序不再显示该工单对应的材料库存批次号
3. 后续不能再对该工单做生产工单入库
4. 该工单对应的成品、余料、废料流水将作为最终闭环记录
```
用户确认后才提交。
## 入库落账
生产工单入库提交后,根据用户填写值分别落账。
### 成品库
如果成品入库数量大于 0:
- 生成成品库生产入库流水。
- 增加成品库存数量。
- 重量作为辅助信息,可按成品数量和单件毛重/净重计算或记录为辅助重量。
- 成品批次必须记录来源原材料库存批次号。
- 成品入库关联生产工单。
### 原材料库
如果余料入库重量大于 0:
- 生成原材料库生产余料入库流水。
- 余料归还到原材料原库存批次,不新建独立余料批次。
- 增加该库存批次剩余重量。
- 流水备注写明“生产余料入库”和偏差说明。
- 更新工单已归还余料重量。
### 废料库
如果废料重量大于 0:
- 生成废料库生产废料入库流水。
- 废料明细记录来源原材料库存批次号。
- 增加废料库存重量。
- 流水备注写明“生产废料入库”和偏差说明。
- 更新工单已入库废料重量。
## 工单台账展示
工单台账保留,并强化以下字段:
- 生产工单号
- 产品名称
- 材料库存批次号
- 领料重量
- 参考计划生产数
- 各工序累计成品报工
- 各工序累计废品报工
- 已入库最终成品数
- 已归还余料重量
- 已入库废料重量
- 状态
状态至少包含:
- 进行中
- 已结单
工单台账中的材料库存批次号应显示短批次号,例如 `YL0001`,并在详情里展示对应原材料名称、规格、单价、来源入库单等追溯信息。
## 数据结构影响
初步涉及以下数据:
- `wh_stock_lot.lot_no`:原材料库存批次号生成规则改变。
- `pp_work_order.work_order_no`:生产工单号生成规则改变。
- `pp_work_order_material_issue.source_lot_id`:继续作为工单和原材料库存批次的核心关联。
- `pp_work_order_material_issue.material_sub_batch_no`:后续语义应等同于来源原材料库存批次号,值保存短批次号。
- `wh_inventory_txn`:继续记录所有库存流水。
- 系统拓展配置表:新增原材料库存批次号前缀配置。
如果现有配置表不适合存储该项,可沿用项目当前“系统拓展”模块已有配置结构,不新增独立配置系统。
## 兼容与历史数据
历史原材料库存批次号不强制立即改号,除非用户明确要求清理测试环境或迁移展示效果。
新入库批次使用新规则。
历史工单号不强制改号。新生产出库产生或合并的工单使用新规则。
小程序同步报工在过渡期应兼容旧值:
- 如果传入值是新材料库存批次号,按新逻辑匹配工单。
- 如果传入值是旧工单短码或旧工单号,可保留兼容一段时间,避免线上历史报工数据无法同步。
正式切换后,小程序展示文案应统一为“材料库存批次号”。
## 错误处理
生产出库常见错误:
- 未选择库存批次:提示“生产出库必须选择一个材料库存批次号”。
- 选择多个库存批次:提示“一次生产出库只能选择一个材料库存批次号”。
- 批次不可用:提示具体批次号和不可用原因。
- 批次不属于产品需规用料:提示“该材料库存批次不属于当前产品需规默认用料”。
- 同日同批同产品工单已结单:提示不能继续合并。
生产工单入库常见错误:
- 未选择工单:提示“请选择生产工单”。
- 工单已结单:提示“该生产工单已结单,不能继续入库”。
- 成品、余料、废料均为 0:提示“本次至少填写一项入库数据”。
- 偏差超过 15% 且无说明:弹窗要求填写说明。
小程序报工常见错误:
- 材料库存批次号不存在。
- 当前产品和材料库存批次没有未结单生产工单。
- 当前工序不属于匹配到的生产工单。
错误提示必须使用中文,不允许暴露英文状态码或内部字段名。
## 测试策略
后端测试:
- 原材料库存批次号默认从 `YL0001` 开始。
- 自定义前缀生效。
- `YL9999` 后生成 `YL10000`。
- 不同物料共用同一个原材料库递增序列。
- 生产出库选择多个库存批次被拒绝。
- 同日同批同产品重复生产出库合并到同一工单。
- 同日同批同产品已结单后再次生产出库被拒绝。
- 生产工单入库能同时写成品库、原材料库、废料库流水。
- 余料/废料偏差超过 15% 时必须填写说明。
- 勾选结单后工单状态变为已结单。
- 未勾选结单时工单保持进行中。
前端测试:
- 原材料库生产出库 UI 只能选择一个材料库存批次号。
- 生产出库提交 payload 只包含一个库存批次。
- 嘉恒仓库流水按钮旁显示“生产工单入库”。
- 生产工单入库选择工单后自动填理论值。
- 修改成品数量后自动重算余料和废料理论值。
- 偏差超过 15% 时弹出说明框。
- 未勾选结单时不弹结单影响确认。
- 勾选结单时弹出结单影响确认。
集成测试:
- 原材料入库生成 `YL0001`。
- 原材料生产出库生成 `20260607-YL0001-产品名称`。
- 小程序报工传 `YL0001` 能同步到对应工单和工序。
- 生产工单入库后,成品批次能追溯到 `YL0001`。
- 销售出库后,发货台账能看到来源原材料库存批次号和材料成本。
## 实施顺序建议
1. 新增系统拓展配置和原材料库存批次号生成规则。
2. 调整采购入库、客料入库、特殊入库等原材料入库路径,统一使用新批次号生成。
3. 调整生产出库为单库存批次,并实现同日同批同产品工单合并。
4. 调整小程序报工同步逻辑,按材料库存批次号匹配未结单工单。
5. 新增生产工单入库后端接口。
6. 新增嘉恒仓库“生产工单入库”前端入口和交互。
7. 联调工单台账、成品入库、余料入库、废料入库、发货追溯。
8. 补齐自动化测试和启动检查。
## 待用户确认后进入计划
本设计已按用户确认规则固定:
- 原材料库存批次号默认格式为 `YL0001`。
- “生产工单入库”不默认勾选“本次入库后结单”。
- 只有用户主动勾选结单,并确认结单影响后,才执行结单。
用户确认本设计文档后,再拆分具体实现计划并进入代码实现。
@@ -0,0 +1,108 @@
# 系统权限管理组织视图与人员授权设计
## 背景
系统权限管理当前已经具备组织脑图、人员绑定、系统人员、角色维护等能力,但“人员与角色维护”把账号维护和角色维护放在同一页签内,页面职责混杂。用户要求把系统账号创建与角色授权回收到组织架构里的人员节点上完成,同时在组织脑图旁增加“脑图 / 组织树”两种组织展示方式。
## 目标
- `组织脑图`页签调整为更准确的`组织架构`页签。
- `组织架构`页签内增加视图切换:`脑图模式`与`组织树模式`。
- `人员与角色维护`重命名为`角色维护`,只保留角色新增、编辑、菜单权限配置。
- 系统账号创建与人员角色授权不再通过单独“系统人员”表维护,而是在组织架构中的人员节点上完成。
- 两种组织视图共用同一套组织数据、人员绑定数据、角色数据、系统账号接口。
## 组织架构视图
### 脑图模式
脑图模式沿用现有 `OrgMindMap` 组件。组织节点继续通过右键菜单维护:
- 总公司:重命名、新增分公司节点、添加该节点的主管。
- 分公司:重命名、新增部门节点、添加该节点的主管、删除节点。
- 部门:重命名、新增小组节点、新增人员、添加该节点的主管、删除节点。
- 小组:重命名、新增人员、添加该节点的主管、删除节点。
人员叶子节点右键菜单只保留两项:
- `授权`:打开人员授权抽屉。
- `移除`:从当前组织节点移除该人员,不删除基础资料人员主数据,不删除已有系统账号。
### 组织树模式
组织树模式采用左树右表布局:
- 左侧为组织节点树,显示总公司、分公司、部门、小组,节点支持展开、收起、选中。
- 左侧组织节点同样支持右键菜单,与脑图模式的组织节点菜单一致。
- 右侧显示当前选中组织节点下直接绑定的人员列表。
- 人员列表使用项目全局表格风格和 `PaginationBar` 分页。
- 人员列表行操作提供`授权`、`移除`。
- 右侧提供`新增人员`入口,将基础资料人员绑定到当前组织节点。
## 授权抽屉
人员授权抽屉由脑图人员节点右键`授权`和组织树人员行`授权`共同打开。抽屉展示:
- 人员姓名。
- 电话号码 / 账号。
- 所属组织。
- 当前系统账号状态。
- 角色多选。
- 密码输入框。
保存规则:
- 如果人员没有电话号码,禁止创建系统账号,提示“该人员没有电话号码,不能作为账号”。
- 如果人员已有系统账号,密码不必填;保存时只调用系统账号角色更新接口。
- 如果人员没有系统账号,密码必填且最少 6 位;保存时先创建系统账号,账号为人员电话号码,再绑定所选角色。
- 角色必须至少选择一个。
- 保存成功后刷新组织树、角色、系统人员缓存,使人员节点展示的角色信息同步更新。
## 角色维护页签
`人员与角色维护`改为`角色维护`,移除内层子页签。该页只展示角色表格、角色分页和新增角色按钮。角色抽屉继续负责:
- 角色编码。
- 角色名称。
- 角色范围。
- 状态。
- 备注。
- 菜单权限树。
系统人员列表、重置密码、账号启停不再作为主页面入口。本次只移除独立维护入口;已有后端接口保留,供授权抽屉复用。
## 数据与接口
本次优先复用现有接口,不新增表结构:
- `GET /system-permissions/org-tree` 获取组织节点与人员叶子。
- `POST /system-permissions/org-nodes/{node_id}/employees` 绑定人员。
- `DELETE /system-permissions/org-nodes/{node_id}/employees/{employee_id}` 移除人员。
- `GET /system-permissions/employee-options` 获取基础资料人员。
- `GET /system-permissions/users` 获取系统账号,用于判断人员是否已有账号。
- `POST /system-permissions/users` 创建系统账号。
- `PUT /system-permissions/users/{user_id}/roles` 更新系统账号角色。
- `GET /system-permissions/roles` 获取角色。
- `POST /system-permissions/roles` 创建角色。
- `PUT /system-permissions/roles/{role_id}` 更新角色与菜单权限。
## 测试与验收
- 静态 UI 测试应覆盖:顶层页签重命名、组织视图切换、组织树模式、人员授权入口、角色维护独立化、系统人员独立表移除。
- 前端构建必须通过 `npm run build`。
- 现有后端权限测试必须继续通过,证明复用接口没有被破坏。
- 浏览器检查应覆盖脑图模式右键授权、组织树模式人员行授权、角色维护页签三个主路径。
## 非目标
- 不新增复杂的部门独立维护页面。
- 不删除系统账号后端接口。
- 不改变基础资料人员管理和小程序人员主数据。
- 不做权限登录体系的大改造,本次只调整权限管理页的组织授权交互。
## 自检
- 无占位符。
- 两种组织视图共用同一套接口,没有产生双业务流。
- 账号创建入口已经从独立系统人员表迁移到人员节点授权。
- 角色维护保留原有菜单权限能力。
@@ -0,0 +1,439 @@
# 嘉恒仓库全出入库单据纸质化与归档设计
## 背景
嘉恒仓库已经有 6 大库:原材料库、半成品库、成品库、辅料库、废料库、退货库。当前出入库业务主要通过抽屉表单完成,保存后形成库存流水。用户已经认可 `原材料库 · 客料入库` 的纸质填单样板,但该样板目前只完成填写 UI,没有生成 PDF 归档,也没有预览和下载入口。
现有归档体系已覆盖销售订单、采购订单、到货入库单、质量校验单、生产领料出库单、生产入库结算单。仓库里大量非生产分支仍未纳入正式单据化:客料、委外、销售、退货、返工、特殊出入库、期初等。为避免后续继续碎片化,本方案把“所有仓库出入库填单纸质化、保存后 PDF 归档、流水入口预览下载、流水 Excel 导出”作为统一仓库单据化工程。
## 目标
1. 6 大库所有入库、出库填单页面改造成正式纸质化单据风格。
2. 每次出入库保存后生成 PDF 归档,业务保存成功但 PDF 失败不回滚业务。
3. 保存成功提示区提供 `预览PDF`、`下载PDF` 快捷入口。
4. 各库流水抽屉中每条流水展示 `单据留档`,支持预览、下载、重新生成。
5. 物料/库存批次明细中的库存流水也能看到对应 PDF 留档入口。
6. `生产台账入库` 这类一次生成多个仓库流水的动作,使用一张 `生产入库结算单`,多条流水共用同一归档。
7. 各库流水模块支持按当前筛选条件,尤其是时间范围,导出 Excel 清单。
8. PDF 和 Excel 中不使用前端省略号逻辑,长字段完整换行或完整输出。
## 范围
### 覆盖仓库
- 原材料库
- 半成品库
- 成品库
- 辅料库
- 废料库
- 退货库
### 覆盖入口
原材料库:
- 期初入库
- 客料入库
- 生产余料入库
- 委外余料入库
- 特殊入库
- 生产出库
- 委外出库
- 退货出库
- 报废出库
- 特殊出库
半成品库:
- 产中入库
- 期初入库
- 委外入库
- 特殊入库
- 产中出库
- 委外出库
- 特殊出库
成品库:
- 生产入库
- 返工入库
- 委外入库
- 期初入库
- 特殊入库
- 销售出库
- 特殊出库
辅料库:
- 期初入库
- 特殊入库
- 生产出库
- 特殊出库
废料库:
- 生产废料入库
- 返工废料入库
- 期初入库
- 委外废料入库
- 退货废料入库
- 特殊入库
- 售卖出库
- 特殊出库
退货库:
- 期初入库
- 退货入库
- 特殊入库
- 返工出库
- 特殊出库
跨库工具:
- 生产台账入库
## 单据归档类型
### 生产专用单据
保持现有设计:
- `生产领料出库单`
- `生产入库结算单`
这些单据用于生产主链路,不再重复映射到通用仓库作业单,避免一笔生产业务出现两套 PDF。
### 通用仓库作业单
新增通用归档类型:
- `仓库出入库单`
该类型覆盖除生产专用单据外的所有仓库出入库按钮。PDF 标题根据仓库和业务类型动态生成,例如:
- `客料入库单`
- `委外余料入库单`
- `委外出库单`
- `销售出库单`
- `退货入库单`
- `返工出库单`
- `特殊入库单`
- `售卖出库单`
通用类型的好处是后端只需一个归档收集器和一个路由 key,所有仓库业务通过配置映射生成不同标题与字段。
## 归档业务主键
### 单条库存流水动作
以本次业务动作形成的主 `wh_inventory_txn.id` 作为 `business_id`。
适用:
- 客料入库
- 委外出库
- 报废出库
- 售卖出库
- 期初入库
- 大多数普通入库/出库
### 多条库存流水动作
以主库存流水 ID 作为 `business_id`,同一动作产生的其它库存流水通过 `document_batch_no` 或关联表映射到同一归档。
适用:
- `生产台账入库`:使用现有 `生产入库结算单`,主流水优先级为成品入库、余料入库、废料入库。
- 后续若某个普通仓库动作一次形成多条流水,也沿用同样主流水规则。
### 特殊出入库
特殊出入库要保留“必须填写说明”的业务约束。若特殊调整有主表,则优先以特殊调整主表 ID 作为归档业务主键;若当前实现只有库存流水,则以本次特殊调整的主库存流水 ID 作为归档业务主键。PDF 必须完整展示调整说明和每条明细的调整前后数量/重量。
### 已有关联业务单据
如果某个仓库动作本质已经有更上游单据:
- 采购到货入库保留 `到货入库单`
- 销售订单保留 `销售订单`
- 生产主链路保留生产专用单据
但仓库内实际发生出库/入库时,仍应能从仓库流水看到该次仓库作业的留档。如果该仓库动作已有专用归档,则流水指向专用归档;否则指向 `仓库出入库单`。
## 纸质填单 UI 规则
### 通用结构
所有仓库出入库抽屉采用统一纸质单据底座:
- 抬头:嘉恒仓库、单据标题、单据编号或提交后生成提示。
- 基础信息区:仓库、库位、业务类型、来源/去向、经办人、时间。
- 明细区:物料/产品/半成品/废料/退货品、库存批次号、来源库存批次号、数量、重量、单价、金额。
- 辅助信息区:运单号、运费、辅助照片、用途、包装备注、退货/返工/委外方、说明。
- 备注区:用户备注和系统生成说明。
- 签核区:制单、仓库确认、业务确认、复核。
- 底部固定操作区:保存按钮、保存中状态。
### 业务字段不删减
纸质化只是表现层升级,不改变原有业务字段和校验。已有字段必须保留,只调整布局、标题、分区和视觉样式。此前用户明确要求去掉的字段除外。
### 保存后反馈
保存成功后显示:
- 业务保存结果
- PDF 归档状态
- `预览PDF`
- `下载PDF`
如果 PDF 失败:
- 显示 `PDF归档失败`
- 显示中文失败原因
- 提供 `重新生成`
- 不回滚库存流水
## PDF 内容规则
PDF 是业务快照,以保存后的数据库事实为准,不从前端表单重新计算。
PDF 必须包含:
- 单据标题
- 单据编号
- 业务时间
- 仓库和库位
- 业务类型
- 经办人
- 物料/产品/半成品/废料/退货品信息
- 库存批次号
- 来源库存批次号
- 数量/重量
- 单价/金额
- 运单号、运费、辅助照片链接或照片存在标记
- 业务说明和备注
- 关联采购单、销售单、生产台账、发货批次、退货处置等追溯信息
PDF 不得出现:
- 英文状态值
- 前端省略号
- 超出单元格的长文本
- 无业务意义的每库内部批次号
## 流水预览与下载入口
### 各库流水抽屉
在 `嘉恒仓库 -> 工具 -> 流水` 中新增 `单据留档` 列。
每条流水根据后端返回的归档字段展示:
- `未生成`
- `已归档`
- `归档失败`
- `预览PDF`
- `下载PDF`
- `重新生成`
### 库存明细右侧流水
物料/批次详情抽屉中的库存流水也新增 `单据留档`,行为与仓库流水一致。
### 生产台账入库
`生产台账入库` 保存后若一次生成成品、余料、废料多条流水,这几条流水的 `单据留档` 指向同一张 `生产入库结算单`。
## 流水 Excel 导出
### 入口位置
在各库流水抽屉顶部筛选区增加 `导出Excel` 按钮,放在 `查询` 按钮旁边。
### 导出范围
导出使用当前流水筛选条件:
- 当前仓库类型
- 当前仓库 ID
- 关键词
- 流水方向
- 流水类型
- 物料
- 库存批次号
- 开始日期
- 结束日期
- 排序字段
- 排序方向
如果用户没有选择时间范围,允许导出当前筛选条件下所有流水,但前端需要轻提示“当前未选择时间范围,将导出全部匹配流水”。第一版不强制时间范围,避免阻塞真实查账场景。
### Excel 字段
导出的 Excel 至少包含:
- 业务时间
- 仓库
- 库位
- 方向
- 流水类型
- 物料/产品编码
- 物料/产品名称
- 库存批次号
- 来源库存批次号
- 数量变化
- 重量变化
- 单价
- 金额
- 来源单据
- 经办人
- 运单号
- 运费
- 辅助照片
- 备注
- PDF归档状态
- PDF归档业务ID
Excel 需使用中文表头,冻结表头,自动筛选,合理列宽。
## 后端设计
### 归档服务
在 `document_archives.py` 中新增:
- `DOCUMENT_TYPE_WAREHOUSE_OPERATION = "仓库出入库单"`
- `collect_warehouse_operation_archive_context`
通用收集器根据主库存流水读取:
- 主库存流水
- 相关库存流水
- 库存批次
- 来源库存批次
- 仓库和库位
- 关联采购/销售/退货/发货/生产台账信息
- 物流字段
- 特殊调整说明
### 路由
`/document-archives/{document_type_key}/{business_id}/...` 增加:
- `warehouse-operation`
### 仓库保存接口
所有普通仓库出入库保存成功后:
1. 完成库存业务事务。
2. 提交业务数据。
3. 生成 PDF 归档。
4. 返回归档状态。
生产专用动作保持现有生产归档类型。
### 流水接口
`GET /inventory/transaction-ledger` 返回每行归档字段:
- `archive_status`
- `archive_business_id`
- `archive_document_type`
- `archive_error_message`
### Excel 导出接口
新增:
- `GET /inventory/transaction-ledger/export`
该接口接收与流水查询一致的筛选参数,返回 `.xlsx` 文件。
## 前端设计
### 单据化表单
抽出通用仓库纸质表单组件:
- `WarehouseDocumentFormShell`
- `WarehouseDocumentGrid`
- `WarehouseDocumentActionBar`
- `WarehouseArchiveShortcut`
`原材料库 · 客料入库` 样板迁移到通用组件上,其它表单逐步替换。
### PDF 操作组件
复用现有 `DocumentArchiveActions`,不再做第二套预览/下载按钮。
### Excel 导出
仓库流水抽屉筛选区新增 `导出Excel`。
导出时:
- 按钮显示 `导出中...`
- 禁止重复点击
- 使用 `downloadResource`
- 文件名格式:`{仓库名}出入库流水_{开始日期或全部}_{结束日期或全部}.xlsx`
## 实施分期
### Phase 1:底座和入口
- 新增通用 `仓库出入库单` 归档类型。
- 仓库流水接口返回归档字段。
- 仓库流水新增预览/下载/重新生成。
- 流水 Excel 导出。
- 客料入库补齐 PDF 归档。
### Phase 2:通用表单纸质化
- 期初入库
- 客料入库
- 委外余料入库
- 委外出库
- 报废出库
- 售卖出库
- 辅料生产出库
- 半成品产中/委外入出库
### Phase 3:专用业务表单纸质化
- 销售出库
- 退货入库
- 退货废料入库
- 返工出库
- 返工入库
- 返工废料入库
- 特殊出入库
### Phase 4:生产主链路统一入口
- 生产出库、生产台账入库、生产入库、生产余料入库、生产废料入库沿用生产专用 PDF。
- 仓库流水和生产台账详情统一指向同一份归档。
## 验收标准
1. 所有仓库出入库入口打开后都是纸质化单据风格。
2. 客料入库保存后能生成 `仓库出入库单` PDF,并可预览和下载。
3. 任意仓库流水行能看到归档状态;已归档可预览和下载。
4. `生产台账入库` 一次产生多条流水时,多条流水打开同一张 `生产入库结算单`。
5. PDF 失败不影响库存保存,页面显示失败并允许重新生成。
6. 各库流水可按时间范围导出 Excel。
7. Excel 内容符合当前筛选范围,中文表头完整,数值和备注不丢失。
8. PDF 和 Excel 均不出现英文状态值或截断省略号。
## 设计结论
全仓库单据化采用“通用仓库作业单 + 生产专用单据”的双轨方案:
- 普通仓库出入库统一归档为 `仓库出入库单`。
- 生产领料和生产入库结算继续使用生产专用单据。
- 所有单据都通过仓库流水、库存明细流水、保存成功反馈提供预览和下载入口。
- 各库流水导出 Excel 与当前筛选条件保持一致。
该方案能覆盖 6 大库所有出入库入口,又避免为每个按钮单独造一套 PDF 逻辑。
@@ -0,0 +1,93 @@
# Miniapp Operation Report Sync Design
## Goal
解决“生产执行 · 工序报工”每次刷新都要等待十几秒的问题。页面刷新只负责查看小程序报工列表,ERP 同步改成显式按钮触发;同步范围由系统固定为“从上次同步成功时间起自动补齐”,不允许用户手工选择同步时间,避免漏同步。
## Problem
当前前端进入页面或点击刷新时,请求 `/production/miniapp-operation-reports` 会带上 `sync_to_erp=true` 和 `limit=800`。后端接口默认也会执行同步逻辑,对每条小程序报工逐条匹配生产台账或旧工单、工序、员工、ERP 报工记录,并更新累计值。数据库在远程 MySQL 上,逐条查询和写入会被网络往返延迟放大,所以一次刷新可能需要十几秒。
## Business Rules
- `刷新列表` 只读取小程序报工数据,使用页面日期筛选范围,不能创建或更新 ERP 报工。
- `同步到 ERP` 不使用页面日期筛选范围。
- 同步范围固定为“上次同步成功时间之后,到本次同步开始时间止”。
- 如果没有上次同步成功时间,首次同步从现有小程序报工数据中最早的记录开始。
- 同步成功后更新上次同步成功时间。
- 同步失败时不更新上次同步成功时间,下次点击同步继续从旧时间补齐。
- 同步按钮在同步过程中显示加载状态并禁用,防止用户连续点击造成重复请求。
- 同一条小程序报工重复同步必须幂等,不能重复累计 ERP 报工数量。
- 页面日期筛选只影响“看哪些数据”,绝不影响“同步哪些数据”。
## Backend Design
新增一个明确的同步接口:
```text
POST /production/miniapp-operation-reports/sync
```
该接口不接受用户传入的开始日期和结束日期。服务端读取系统配置中的上次同步成功时间,计算本次同步窗口:
```text
sync_from = 上次同步成功时间,如果不存在则为空
sync_to = 当前服务端时间
```
查询范围使用小程序报工的更新时间或创建时间,保证历史日期的补报、修改、驳回也能被纳入同步。接口循环调用现有 `_sync_miniapp_item_to_erp()`,复用当前 ERP 报工幂等差额逻辑。同步全部成功后,更新上次同步成功时间为 `sync_to`。
同步状态存储复用 `sys_system_config`:
```text
config_code: MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT
config_name: 小程序报工上次同步成功时间
config_value: 2026-06-11 10:30:00
```
为了降低重复请求风险,同步接口在更新配置前对该配置行加数据库锁。前端禁用按钮解决正常用户重复点击,后端锁解决异常并发请求。
## Frontend Design
工序报工页面顶部保留日期筛选和刷新按钮,并新增同步区:
- `刷新列表`:请求 `/production/miniapp-operation-reports?sync_to_erp=false`,只读加载当前日期范围内的小程序报工。
- `同步到 ERP`:请求 `POST /production/miniapp-operation-reports/sync`,按钮进入 `同步中...` 状态并禁用。
- 同步完成后自动调用 `刷新列表`。
- 页面显示上次同步成功时间。
- 成功提示展示本次同步范围和结果数量。
- 失败提示说明“同步失败,上次同步成功时间未更新,可稍后重试”。
## Response Shape
同步接口返回:
```json
{
"sync_from": "2026-06-01 10:00:00",
"sync_to": "2026-06-11 15:30:00",
"last_synced_at": "2026-06-11 15:30:00",
"total": 120,
"synced": 115,
"skipped": 5,
"failed": 0
}
```
`skipped` 表示小程序报工无法匹配有效生产台账、旧工单、有效工序,或没有有效数量。`failed` 初版只在单条同步被捕获为可恢复失败时使用;如果发生数据库级错误,接口直接失败并不更新同步时间。
## Error Handling
- 同步过程中出现数据库异常时,事务回滚,不更新 `MINIAPP_OPERATION_REPORT_LAST_SYNCED_AT`。
- 如果没有需要同步的数据,接口返回 `total=0`,并仍可更新上次同步成功时间为本次 `sync_to`,表示系统已经确认这一段时间没有新变化。
- 如果小程序报工被驳回,沿用现有 `_sync_miniapp_item_to_erp()` 对已同步报工的解绑逻辑。
- 前端同步失败时解除按钮禁用,并展示错误提示。
## Verification
- 后端测试覆盖列表接口 `sync_to_erp=false` 时不写 ERP。
- 后端测试覆盖同步接口从上次同步成功时间自动补齐,且不接受前端筛选范围。
- 后端测试覆盖同步成功后更新上次同步成功时间,失败时不更新。
- 前端静态测试覆盖列表刷新传 `sync_to_erp=false`。
- 前端静态测试覆盖同步按钮调用独立同步接口、同步中禁用、同步完成后刷新列表。
- 运行后端相关测试、前端静态脚本和前端构建。
@@ -0,0 +1,370 @@
# 生产主链路仓库单据化与 PDF 归档设计
## 背景
系统已经完成销售订单、采购订单、到货入库单、质量校验单的正式单据化与 PDF 归档。嘉恒仓库仍以业务抽屉和库存流水为主,用户在办理生产出库、生产入库、生产余料入库、生产废料入库时,缺少一张可预览、可下载、可留档的正式业务单据。
仓库业务入口很多,6 大库中存在采购、生产、委外、退货、销售、特殊调整等多条链路。如果一次性把所有出入库按钮都接入 PDF,容易形成重复模板和重复归档逻辑。本次先以生产主链路作为第一期样板,抽出仓库作业单通用底座,后续再扩展到委外、退货、销售出库、特殊出入库。
## 本期目标
1. 将生产主链路的仓库作业改造成“保存即归档”的正式业务单据。
2. 不改变现有库存、生产台账、生产工单的核心业务计算逻辑。
3. 后端基于业务保存后的真实数据生成 PDF 归档快照。
4. PDF 失败不阻断业务保存,但必须能在界面看到归档失败并重新生成。
5. 前端保持现有操作入口,只把相关弹窗和归档操作升级为正式单据体验。
6. 第一版先不做批量下载,待仓库单据类型稳定后再统一扩展。
## 本期范围
本期只覆盖生产主链路:
- 原材料库 · 生产出库
- 生产台账入口 · 生产工单入库
- 成品库 · 生产入库
- 原材料库 · 生产余料入库
- 废料库 · 生产废料入库
本期不覆盖:
- 委外入库、委外出库、委外余料入库、委外废料入库
- 销售出库
- 客料入库
- 退货出库、退货入库、退货废料入库、返工出库
- 特殊入库、特殊出库
- 盘库单
- 批量归档下载
## 单据类型
后端归档类型先新增两类:
1. `生产领料出库单`
2. `生产入库结算单`
### 生产领料出库单
对应原材料库的生产出库。
业务含义:把某个原材料库存批次号中的材料领出,用于生产某个产品。由于系统已经限制生产出库一次只能选择一个原材料库存批次,本单据天然对应“材料库存批次号 + 产品”的生产台账维度。
### 生产入库结算单
对应生产过程中的入库结算动作。
业务含义:围绕某个在生产的材料库存批次号和产品,登记本次成品入库、余料入库、废料入库。无论用户从“生产工单入库”合并入口办理,还是从成品库、原材料库、废料库的分支入口单独办理,都归档为同一种结算单。
这样可以避免同一件生产结算业务因为入口不同而生成不同结构的单据。
## 归档业务主键
现有 `document_archives` 表通过 `document_type + business_id + archive_version` 管理归档。生产主链路需要明确 `business_id` 的来源。
### 生产领料出库单
推荐以生产台账记录 ID 作为 `business_id`。
原因:
- 生产出库会形成或更新“材料库存批次号 + 产品”的生产台账。
- 同一天同一库存批次同一产品允许多次生产出库,但业务上累计到同一条生产台账。
- 生产领料出库单应该表达这条生产台账当前的领料出库快照,而不是某一条孤立库存流水。
归档版本规则:
- 每次生产出库后,对同一生产台账生成新版本 `生产领料出库单`。
- 列表和详情默认展示最新版本。
- 历史版本保留,用于追溯每次领料变化后的快照。
### 生产入库结算单
推荐以本次入库动作形成的主库存流水 ID 作为 `business_id`。
原因:
- 生产工单入库合并入口一次可同时产生成品入库、余料入库、废料入库三类库存流水。
- 分支入口按入口类型只产生对应的一类库存流水。
- 归档单要代表“本次保存动作”,而不是覆盖整条生产台账。
对于合并入口,如果一次保存产生多条库存流水,需要在后端确定一条“主流水”:
- 优先使用成品入库流水。
- 如果没有成品入库流水,使用余料入库流水。
- 如果没有余料入库流水,使用废料入库流水。
归档内容中仍要列出本次保存产生的全部相关流水。
## 单据内容
### 生产领料出库单字段
单据抬头:
- 单据编号
- 单据日期
- 仓库:原材料库
- 业务类型:生产出库
- 生产台账号
- 生产工单号或材料生产编号
- 材料库存批次号
- 产品名称
- 产品编码
- 状态
- 操作人
明细字段:
- 序号
- 原材料编码
- 原材料名称
- 规格
- 库存批次号
- 本次领料重量
- 累计领料重量
- 单位成本
- 本次材料成本
- 来源仓库
- 备注
汇总字段:
- 本次领料重量合计
- 累计领料重量
- 本次材料成本合计
- 生产台账状态
签字栏:
- 领料人
- 仓库复核
- 生产确认
- 制单人
### 生产入库结算单字段
单据抬头:
- 单据编号
- 单据日期
- 生产台账号
- 生产工单号或材料生产编号
- 材料库存批次号
- 产品名称
- 产品编码
- 业务入口:生产工单入库、生产入库、生产余料入库、生产废料入库
- 是否结单
- 生产台账状态
- 操作人
明细字段:
- 序号
- 入库类型:成品入库、生产余料入库、生产废料入库
- 入库仓库
- 入库物料或产品
- 原材料库存批次号
- 本次数量
- 本次重量
- 理论数量或理论重量
- 偏差百分比
- 偏差说明
- 备注
汇总字段:
- 本次成品入库数量合计
- 本次余料入库重量合计
- 本次废料入库重量合计
- 该生产台账累计成品入库数量
- 该生产台账累计余料入库重量
- 该生产台账累计废料入库重量
签字栏:
- 入库确认
- 仓库复核
- 生产确认
- 制单人
## 前端交互
### 操作弹窗
生产主链路现有入口保留,不改变用户从嘉恒仓库进入业务的习惯。
本期只做两类升级:
1. 表单内部逐步向 `DocumentPaper`、`DocumentGrid`、`DocumentLineTable` 的正式单据形态靠拢。
2. 保存成功后明确提示 PDF 归档结果。
如果业务保存成功但 PDF 失败,提示示例:
`生产入库已保存,但 PDF 归档失败,可在归档操作中重新生成。`
### 列表与详情归档操作
本期优先在生产台账或相关仓库流水明细中展示归档操作。
展示内容:
- 归档状态:未生成、已归档、归档失败
- 预览 PDF
- 下载 PDF
- 重新生成
第一版不做批量下载,避免在仓库单据类型尚未全部稳定时扩大复杂度。
## 后端设计
### 文档归档服务扩展
在现有 `document_archives.py` 基础上新增:
- `DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT = "生产领料出库单"`
- `DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT = "生产入库结算单"`
- `collect_production_material_out_archive_context`
- `collect_production_inbound_settlement_archive_context`
现有 `collect_archive_context` 增加两类分支。
现有 `/document-archives/{document_type_key}/{business_id}/...` 通用路由增加 key:
- `production-material-out`
- `production-inbound-settlement`
### 仓库接口触发
生产出库保存成功后:
- 完成库存扣减。
- 完成生产台账更新。
- 提交业务事务。
- 调用归档生成。
- 如果归档失败,不回滚生产出库。
- 返回结果携带归档状态。
生产工单入库、生产入库、生产余料入库、生产废料入库保存成功后:
- 完成库存变更。
- 完成生产台账或生产工单联动。
- 提交业务事务。
- 以本次动作主流水 ID 生成 `生产入库结算单`。
- 如果归档失败,不回滚入库业务。
- 返回结果携带归档状态。
### PDF 模板
第一版沿用现有纸面归档 PDF 风格:
- 米白纸张底色
- 黑色表格线
- 单据标题居中
- 右上显示 `ERP留存联` 和 `PDF归档`
- 表格内容完整换行,不使用省略号
- 状态、备注、偏差说明全部使用中文
生产主链路字段比销售/采购更偏业务追溯,PDF 表格需要允许多行换行,不能因为库存批次号或备注过长而超格。
## 数据流
生产出库:
```mermaid
flowchart LR
A["原材料库 · 生产出库"] --> B["扣减原材料库存批次"]
B --> C["创建库存流水"]
C --> D["创建或更新生产台账"]
D --> E["生成生产领料出库单 PDF"]
E --> F["写入 document_archives"]
```
生产入库结算:
```mermaid
flowchart LR
A["生产工单入库或分支入库"] --> B["创建成品/余料/废料库存流水"]
B --> C["联动生产台账累计量"]
C --> D["确定本次动作主流水"]
D --> E["生成生产入库结算单 PDF"]
E --> F["写入 document_archives"]
```
## 错误处理
业务保存失败:
- 不生成 PDF。
- 返回原有业务错误。
PDF 生成失败:
- 不回滚业务保存。
- 写入或返回 `归档失败` 状态。
- 前端提示用户可重新生成。
重新生成失败:
- 不影响已有归档版本。
- 保留错误信息。
- 前端显示失败原因。
历史数据:
- 本期不强制自动补档。
- 已存在的生产台账和库存流水在列表中显示 `未生成`。
- 用户可以点 `重新生成` 补 PDF。
## 测试策略
后端测试:
- 生产出库后生成 `生产领料出库单` 归档记录。
- 同一生产台账多次生产出库后归档版本递增。
- 生产工单入库后生成 `生产入库结算单` 归档记录。
- 分支成品入库、余料入库、废料入库后生成 `生产入库结算单`。
- PDF 生成失败不回滚库存和生产台账。
- 归档服务能通过 `production-material-out` 和 `production-inbound-settlement` key 预览、下载、重新生成。
前端测试:
- 生产主链路保存成功后显示归档状态反馈。
- 相关列表或详情出现归档操作。
- 归档操作调用正确 document type key。
- PDF 相关文案不出现英文状态。
人工验证:
- 原材料库生产出库后可预览生产领料出库单。
- 生产工单入库后可预览生产入库结算单。
- 分支入口入库后可预览生产入库结算单。
- PDF 中长库存批次号、备注、偏差说明完整显示且不超格。
## 风险与边界
1. 生产台账和旧生产工单逻辑仍处在并存状态,实施时要按当前代码真实入口分别接入,不能只接其中一条。
2. 合并入口一次产生多条库存流水时,必须稳定确定主流水,否则归档 business_id 会不稳定。
3. 第一版不做批量下载,避免过早扩大接口和 UI 范围。
4. 本次只做生产主链路,不顺手改委外、退货、特殊调整。
5. PDF 是业务快照,不是重新计算入口;归档时以保存后的数据库事实为准。
## 验收标准
1. 原材料库生产出库保存后生成 `生产领料出库单` PDF 归档。
2. 生产工单入库保存后生成 `生产入库结算单` PDF 归档。
3. 成品库生产入库、原材料库生产余料入库、废料库生产废料入库保存后生成 `生产入库结算单` PDF 归档。
4. 业务保存成功但归档失败时,业务数据仍保留,页面能看到归档失败并重新生成。
5. 生产台账或相关流水详情能预览、下载、重新生成 PDF。
6. PDF 内容不使用省略号,长字段自动换行。
7. 用户可通过 PDF 看清材料库存批次号、产品、领料、成品入库、余料、废料、偏差说明、结单状态。
## 设计结论
第一期采用“生产主链路优先”的仓库单据化方案:
- 用 `生产领料出库单` 覆盖原材料库生产出库。
- 用 `生产入库结算单` 覆盖生产工单入库和成品/余料/废料分支入库。
- 后端复用现有 `document_archives` 通用归档表和路由。
- PDF 失败不阻断业务保存。
- 第一版先不做批量下载和全仓库入口覆盖。
该方案能先把最核心的生产闭环打造成正式单据样板,同时为后续 6 大库所有出入库单据化留下统一扩展路径。
@@ -0,0 +1,236 @@
# 销售/采购订单单据化与 PDF 归档设计
## 背景
当前销售订单、采购订单新增页面主要是抽屉内的卡片式数据录入表单。它能完成业务数据提交,但视觉上更像后台管理系统,不像正式业务单据。
本次先按方案 B 试点:只改造“销售订单”和“采购订单”。目标是验证“真实单据填写 + 保存即归档”的产品方向,跑通后再扩展到到货入库单、仓库出入库单、质检单、盘库单等其他单据。
## 设计目标
1. 销售订单、采购订单新增表单改为真实“单据”形态,用户感觉是在填写正式业务单。
2. 外层仍沿用当前系统抽屉,不改变用户从列表点击新增的入口习惯。
3. 内层采用 v3 效果图方向:工厂联单/归档单风格,而不是普通网页卡片拟纸。
4. 单据保存成功时,后端基于当时业务数据生成不可变 PDF 归档快照。
5. PDF 作为主归档文件,Word 只作为按需辅助导出,不作为原始留档依据。
6. 列表支持单据归档状态展示、单份预览、单份下载、多选批量下载。
## 视觉方向
采用“工厂联单/归档单嵌入系统抽屉”的风格。
已确认的 v3 效果图文件:
- `outputs/document-form-mockups/sales-order-document-form-mockup-v3-real-paper.png`
- `outputs/document-form-mockups/purchase-order-document-form-mockup-v3-real-paper.png`
关键视觉特征:
- 真实纸张底色,偏米白而不是冷白网页卡片。
- 纸张纹理轻量保留,避免影响阅读。
- 外层有档案板/叠纸阴影,体现这是一张正式归档单。
- 顶部使用金属夹/装订元素,增强真实“夹在文件板上”的感觉。
- 单据标题使用黑色居中大字,例如“销售订单”“采购订单”。
- 单据编号使用右上红/绿编号章,不再放在普通卡片角标里。
- 表单字段使用真实单据表格格线,减少圆角输入框感。
- 明细使用表格行,表现为“纸上填写格”,不再使用多张卡片堆叠。
- 底部保留签字/确认栏,例如销售确认、客户确认、财务复核、制单人。
- PDF 归档章弱化为纸面印章,不能遮挡核心数据。
## 交互范围
### 销售订单新增
保持现有字段和业务逻辑,不删字段、不改变提交语义。
单据结构:
1. 单据头部:公司名、销售订单标题、销售订单号、归档联标记。
2. 抬头信息:客户、销售人员、下单日期、交期、税率、送货地址。
3. 订单明细:产品、客户料号、订单数量、销售单价、明细交期、金额。
4. 汇总信息:订单总数、未税金额、含税金额、归档状态。
5. 备注与签字:备注、销售确认、客户确认、财务复核、制单人。
6. 操作区:保存草稿、保存并生成 PDF 归档。
### 采购订单新增
保持现有字段和业务逻辑,不删字段、不改变提交语义。
单据结构:
1. 单据头部:公司名、采购订单标题、采购单号、归档联标记。
2. 采购路径:到货库、采购方式、归档策略。
3. 抬头信息:供应商、采购人员、预计到货、提前预警、税率、关联销售订单。
4. 采购明细:原材料、性能要求、采购重量、辅助吨数、单价、元/吨、明细交期。
5. 汇总信息:采购总重量、折合吨数、含税金额、归档状态。
6. 备注与签字:采购确认、供应商确认、仓库接收、制单人。
7. 操作区:保存草稿、保存并生成 PDF 归档。
## 归档规则
### 主档格式
PDF 是主归档文件。
原因:
- PDF 更适合固定原始单据快照。
- PDF 不易被用户误编辑。
- 后续预览、下载、批量下载更稳定。
Word 只做辅助导出。
原因:
- Word 可编辑,不适合作为原始留档依据。
- 如果后续客户需要对外沟通版本,可以导出 Word,但系统内部追溯以 PDF 为准。
### 生成时机
第一版采用“保存成功后自动生成 PDF 归档”。
具体规则:
- 新增销售订单保存成功后,立即生成销售订单 PDF 归档。
- 新增采购订单保存成功后,立即生成采购订单 PDF 归档。
- 如果 PDF 生成失败,业务单据仍可保存,但列表显示“归档失败”,允许用户重新生成。
- 归档生成时要记录模板版本、生成时间、生成用户、文件路径、文件哈希。
### 编辑后的处理
第一版建议采用版本化归档。
规则:
- 每次关键字段编辑后重新生成新的 PDF 归档版本。
- 历史归档版本保留。
- 列表默认展示最新归档。
- 详情中可查看历史归档版本。
这样不会出现“数据改了,但原来 PDF 覆盖掉了”的追溯风险。
## 数据模型建议
新增通用单据归档表,例如 `document_archives`。
建议字段:
- `id`
- `document_type`:销售订单、采购订单等。
- `business_id`:业务主表 ID。
- `document_no`:业务单号。
- `archive_version`:归档版本号。
- `template_version`:模板版本。
- `file_format`:PDF 或 DOCX。
- `file_name`
- `file_path`
- `file_hash`
- `status`:已生成、生成失败。
- `error_message`
- `created_by`
- `created_at`
第一版只要求销售订单、采购订单接入。
## 前端列表调整
销售订单列表、采购订单列表增加归档相关操作。
列表列建议:
- 归档状态:未生成、已归档、归档失败。
- 操作:预览、下载、重新生成。
批量操作:
- 勾选多张销售订单后可批量下载 PDF。
- 勾选多张采购订单后可批量下载 PDF。
- 批量下载由后端打包 zip。
## 后端接口建议
销售订单:
- `POST /sales/orders/{id}/archive`
- `GET /sales/orders/{id}/archive/latest/preview`
- `GET /sales/orders/{id}/archive/latest/download`
- `POST /sales/orders/archives/batch-download`
采购订单:
- `POST /purchase/orders/{id}/archive`
- `GET /purchase/orders/{id}/archive/latest/preview`
- `GET /purchase/orders/{id}/archive/latest/download`
- `POST /purchase/orders/archives/batch-download`
也可以抽象成通用接口:
- `POST /document-archives/{document_type}/{business_id}/generate`
- `GET /document-archives/{document_type}/{business_id}/latest`
- `POST /document-archives/batch-download`
第一版建议后端服务层通用,路由层可以先按销售/采购分别暴露,便于前端调用和权限控制。
## 技术实现建议
### 前端
新增通用单据表单组件:
- `DocumentFormDrawer`
- `DocumentPaper`
- `DocumentGrid`
- `DocumentLineTable`
- `DocumentArchiveActions`
销售订单和采购订单分别提供字段配置,不建议完全硬编码到一个巨大组件里。
### 后端
新增归档服务:
- 收集业务数据。
- 渲染 HTML 模板。
- 生成 PDF。
- 写入归档记录。
- 提供预览、下载、批量 zip。
PDF 生成建议后端完成,不能只依赖浏览器 `window.print()`。
### 文件存储
第一版可以先存在服务器本地目录,例如:
- `outputs/document_archives/sales_order/`
- `outputs/document_archives/purchase_order/`
后续正式环境再迁移到对象存储也可以。
## 风险与边界
1. 这次不是全系统单据化,只试点销售订单和采购订单。
2. 不改变销售订单、采购订单现有业务字段和计算逻辑。
3. 不把合同生成逻辑和单据归档混在一起。合同是对外合同,订单归档是内部业务原始单据。
4. PDF 归档失败不能阻断核心业务保存,但必须明确提醒并允许补生成。
5. 真实纸张效果要服务阅读,不能为了拟物牺牲表格清晰度。
## 验收标准
1. 新增销售订单页面不再是卡片堆叠,而是 v3 风格单据填写。
2. 新增采购订单页面不再是卡片堆叠,而是 v3 风格单据填写。
3. 保存销售订单后生成 PDF 归档记录。
4. 保存采购订单后生成 PDF 归档记录。
5. 销售订单列表可预览、下载、批量下载归档 PDF。
6. 采购订单列表可预览、下载、批量下载归档 PDF。
7. PDF 归档中显示单据编号、公司名、关键抬头、明细、汇总、制单信息。
8. PDF 主归档、Word 辅助导出的规则在代码和界面文案中保持一致。
## 设计结论
采用方案 B 作为第一版试点:
- 销售订单、采购订单先单据化。
- 使用 v3 工厂联单/归档单视觉方向。
- PDF 作为主归档。
- Word 作为后续按需辅助导出。
- 归档能力做成通用底座,后续再推广到其他业务单据。