Initial commit
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
# 真实账号与菜单权限
|
||||
|
||||
系统已经切换为数据库真实账号登录,账号来自以下表:
|
||||
|
||||
- `hr_employee`
|
||||
- `sys_user`
|
||||
- `sys_role`
|
||||
- `sys_user_role`
|
||||
- `sys_permission`
|
||||
- `sys_role_permission`
|
||||
|
||||
## 初始账号
|
||||
|
||||
- `admin / admin123`
|
||||
- `sales / sales123`
|
||||
- `planner / planner123`
|
||||
- `warehouse / warehouse123`
|
||||
- `supervisor / supervisor123`
|
||||
|
||||
以上密码已采用 PBKDF2-SHA256 存储到 `sys_user.password_hash`。
|
||||
|
||||
## 菜单权限
|
||||
|
||||
- `MENU_DASHBOARD`:经营驾驶舱
|
||||
- `MENU_SALES_PLANNING`:销售与计划中心
|
||||
- `MENU_PROCESS_CENTER`:流程与工序中心
|
||||
- `MENU_DATA_MODEL`:模块联动与数据设计
|
||||
|
||||
## 角色与菜单关系
|
||||
|
||||
- `ADMIN`:全部菜单
|
||||
- `SALES`:驾驶舱、销售与计划中心
|
||||
- `PLANNER`:驾驶舱、销售与计划中心、流程与工序中心
|
||||
- `WAREHOUSE`:驾驶舱、流程与工序中心
|
||||
- `SUPERVISOR`:驾驶舱、流程与工序中心
|
||||
|
||||
## 后续建议
|
||||
|
||||
如果你后面要进入正式使用阶段,建议继续做:
|
||||
|
||||
1. 修改初始密码
|
||||
2. 增加修改密码接口
|
||||
3. 增加用户停用与重置密码功能
|
||||
4. 把页面按钮权限也接到 `sys_permission`
|
||||
@@ -0,0 +1,94 @@
|
||||
# 本体样例主数据说明
|
||||
|
||||
本文件描述已为“本体”生成的样例主数据假设,目的是让 ERP 的销售、MRP、采购、仓储、工艺路线、设备管理和报工链路可以直接开始联调。
|
||||
|
||||
## 产品定义
|
||||
|
||||
- 产品编码:`FG-BT-001`
|
||||
- 产品名称:`本体`
|
||||
- 图号:`JH-BT-001`
|
||||
- 版本:`V1.0`
|
||||
- 产品系列:`结构件`
|
||||
- 成品单位:`件`
|
||||
- 单件成品重量:`5.2kg`
|
||||
- 产品允许总报废率:`3.5%`
|
||||
|
||||
## 原材料定义
|
||||
|
||||
- 原材料编码:`RM-SAP440-500600-2.5`
|
||||
- 名称:`SAP440钢板`
|
||||
- 规格:`500*600*2.5`
|
||||
- 材质牌号:`SAP440`
|
||||
- 密度:`7850kg/m3`
|
||||
- 理论单张重量:`5.8875kg`
|
||||
- 安全库存重量:`10000kg`
|
||||
- 默认供应商:`宁波嘉钢材料有限公司`
|
||||
- 采购方式:按重量采购
|
||||
- 最小采购重量:`2000kg`
|
||||
- 采购倍数重量:`2000kg`
|
||||
|
||||
## BOM 假设
|
||||
|
||||
- 单件净重:`5.2kg`
|
||||
- 单件毛重:`5.9kg`
|
||||
- 单件用料类型:按重量
|
||||
- 损耗率:`11.8%`
|
||||
- 领料超发容差率:`2%`
|
||||
|
||||
这一组参数用于支撑你前面提出的业务场景:
|
||||
|
||||
- 订单 `10000件`
|
||||
- 理论毛重需求约 `59000kg`
|
||||
- 按 `2000kg` 采购倍数向上取整后建议采购 `60000kg`
|
||||
|
||||
## 工艺路线
|
||||
|
||||
标准 6 道工序:
|
||||
|
||||
1. 开平
|
||||
2. 冲压
|
||||
3. 去毛刺
|
||||
4. 折弯
|
||||
5. 焊接
|
||||
6. 检验包装
|
||||
|
||||
各工序已配置:
|
||||
|
||||
- 标准准备时间
|
||||
- 标准节拍
|
||||
- 标准人工成本
|
||||
- 标准设备成本
|
||||
- 允许报废率
|
||||
- 是否关键工序
|
||||
|
||||
## 设备与工作中心
|
||||
|
||||
已同步生成:
|
||||
|
||||
- 6 个工作中心
|
||||
- 6 台样例设备
|
||||
- 6 条周期保养计划
|
||||
|
||||
这样后续你接设备管理、报工设备选择、设备保养预警时可以直接复用。
|
||||
|
||||
## 仓储主数据
|
||||
|
||||
已生成 5 个仓库:
|
||||
|
||||
- 原材料仓
|
||||
- 在制品暂存仓
|
||||
- 成品待发仓
|
||||
- 退货仓
|
||||
- 废料库
|
||||
|
||||
每个仓库已带一个默认库位,便于后续做入库、发料、退货和废料流转。
|
||||
|
||||
## 后续建议
|
||||
|
||||
下一步最适合继续补的数据:
|
||||
|
||||
1. 员工与账号
|
||||
2. 一个客户
|
||||
3. 一张销售订单
|
||||
4. 自动生成一张本体的 MRP 需求
|
||||
5. 自动生成一张本体工单
|
||||
@@ -0,0 +1,78 @@
|
||||
# 嘉恒智能五金 ERP 生产使用案例
|
||||
|
||||
## 案例背景
|
||||
|
||||
- 客户:宁波华远汽车零部件有限公司
|
||||
- 产品:本体
|
||||
- 产品编码:`FG-BT-001`
|
||||
- 订单数量:`10000` 件
|
||||
- 承诺交期:`2026-04-28`
|
||||
- 原材料:`SAP440 500*600*2.5` 钢板
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 1. 管理员先完成基础主数据
|
||||
|
||||
- 进入 `模块联动与数据设计`,确认主数据范围与流程顺序。
|
||||
- 在后台维护或核对产品、原材料、BOM、工艺路线、仓库、部门、角色账号。
|
||||
- 确认产品 `FG-BT-001` 已维护单重、允许报废率、BOM 用料和采购倍数。
|
||||
|
||||
### 2. 销售录入客户
|
||||
|
||||
- 使用销售账号登录系统。
|
||||
- 进入 `销售与计划中心`。
|
||||
- 在 `客户管理` 区域录入客户名称、联系人、电话、地址和账期。
|
||||
- 点击 `新增客户`,系统生成客户编码并建立客户主档。
|
||||
|
||||
### 3. 销售录入订单
|
||||
|
||||
- 在同页 `销售订单` 区域选择刚创建的客户。
|
||||
- 填写承诺交期、送货地址、税率和备注。
|
||||
- 在订单明细中选择产品 `FG-BT-001`。
|
||||
- 录入数量 `10000`、单价和客户料号。
|
||||
- 点击 `创建销售订单`,系统生成销售订单号。
|
||||
|
||||
### 4. 计划员生成 MRP
|
||||
|
||||
- 在下方 `销售订单列表` 找到刚生成的订单。
|
||||
- 点击 `生成 MRP`。
|
||||
- 系统按 BOM、材料毛重、当前库存和采购倍数自动生成物料需求。
|
||||
- 在 `MRP 需求结果` 中查看理论重量、缺口重量和建议采购重量。
|
||||
|
||||
### 5. 采购按 MRP 执行下单
|
||||
|
||||
- 根据 MRP 结果确认材料缺口。
|
||||
- 按建议采购重量发起采购,例如系统建议采购 `60000 kg`,则按采购倍数下单。
|
||||
- 到货后由仓库按批次入库,形成库存余额。
|
||||
|
||||
### 6. 车间按工序组织生产
|
||||
|
||||
- 计划员释放工单后,仓库按工单领料。
|
||||
- 工人通过小程序扫码报工,回传工单号、工序、员工、设备、开工时间、完工时间、报工数量、合格数、报废数等字段。
|
||||
- 管理员在 `流程与工序中心` 查看产品主档、MRP 需求和报工字段约束。
|
||||
|
||||
### 7. 成品入库与发货
|
||||
|
||||
- 工序全部完成并检验合格后,成品入库。
|
||||
- 仓库按销售订单组织发货。
|
||||
- 财务按订单、材料、工序、制造费用进行对账和成本核算。
|
||||
|
||||
### 8. 异常与退货处理
|
||||
|
||||
- 若现场出现异常报废,按工序、设备、员工追溯原因。
|
||||
- 若客户退货,按实际情况选择返工或报废处理。
|
||||
- 返工继续计入成本,报废则记录废料收益与损失。
|
||||
|
||||
## 推荐角色分工
|
||||
|
||||
- 管理员:维护主数据、账号权限、异常监控
|
||||
- 销售:客户建档、销售订单录入、交期跟进
|
||||
- 计划员:MRP 生成、工单释放、排产协调
|
||||
- 仓库:收料、入库、领料、成品发货
|
||||
- 车间主管:工序执行、报工审核、异常处置
|
||||
|
||||
## 上线建议
|
||||
|
||||
- 先跑通 `客户 → 订单 → MRP → 采购 → 入库`。
|
||||
- 再接入 `工单 → 报工 → 成品入库 → 发货`。
|
||||
- 最后完善 `财务核算、返工、退货、设备管理`。
|
||||
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
+810
@@ -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 作为后续按需辅助导出。
|
||||
- 归档能力做成通用底座,后续再推广到其他业务单据。
|
||||
@@ -0,0 +1,341 @@
|
||||
# 宁波嘉恒智能科技有限公司 ERP 设计方案
|
||||
|
||||
## 1. 项目定位
|
||||
|
||||
本系统定位为面向 50 人规模五金加工企业的流程化 ERP,覆盖从销售接单、材料测算、采购、入库、领料生产、工序报工、成品入库、发货、退货、财务对账到设备管理的完整闭环。
|
||||
|
||||
系统核心目标:
|
||||
|
||||
- 让订单、材料、工序、库存、成本、人员、设备形成一条链
|
||||
- 让管理员能在一个看板上快速看到异常和瓶颈
|
||||
- 让仓库、采购、生产、财务的数据相互校验,不出现信息孤岛
|
||||
- 为后续接入工人扫码报工小程序预留统一的数据结构
|
||||
|
||||
## 2. 核心设计原则
|
||||
|
||||
- 流程化:所有业务围绕“销售订单 -> MRP -> 采购/库存 -> 工单 -> 报工 -> 完工 -> 发货 -> 财务”的闭环展开
|
||||
- 工序化:产品先定义工艺路线,再生成工单工序,再采集工序报工
|
||||
- 批次化:材料入库、领料、完工、发货、退货都保留批次追溯
|
||||
- 角色化:管理员、销售、采购、仓库、计划、车间主管、工人、财务、设备管理员按角色分工
|
||||
- 异常前置:工序报废超阈值、设备保养到期、库存低于安全库存、采购延期、工单卡滞统一预警
|
||||
- 成本可还原:材料成本、工序人工成本、设备成本、制造费用、返工成本、废料收益都能回溯到工单和订单
|
||||
|
||||
## 3. 角色设计
|
||||
|
||||
- 管理员:全局主数据维护、参数维护、成本规则、异常监控
|
||||
- 销售:客户、销售订单、交期跟踪、发货协同
|
||||
- 计划员:根据订单生成物料需求和工单
|
||||
- 采购员:采购申请、采购订单、到货跟踪、供应商对账
|
||||
- 仓库员:入库、上架、移库、领料、退料、盘点、发货
|
||||
- 车间主管:工单排产、设备与人员调度、异常审批
|
||||
- 加工员工:扫码报工、报废登记、返工登记、停机上报
|
||||
- 财务:采购对账、库存核对、制造费用维护、成本报表、周报月报
|
||||
- 设备管理员:设备台账、点检、保养、维修、停机分析
|
||||
|
||||
## 4. 业务闭环设计
|
||||
|
||||
### 4.1 销售接单
|
||||
|
||||
- 销售录入订单头和订单明细
|
||||
- 每个订单明细绑定产品、数量、交期、含税单价
|
||||
- 系统读取产品 BOM 与工艺路线,自动判断需要哪些原材料、哪些工序、哪些设备能力
|
||||
|
||||
### 4.2 原材料需求计算
|
||||
|
||||
系统依据以下数据计算原材料需求:
|
||||
|
||||
- 产品单重
|
||||
- BOM 中该材料的单件毛重或定额
|
||||
- 原材料规格、密度、理论单张重量
|
||||
- 采购最小批量或采购倍数
|
||||
- 管理员维护的损耗率、允许报废率、经验备料系数
|
||||
|
||||
建议计算逻辑:
|
||||
|
||||
- 理论需求重量 = 订单数量 × BOM 单件毛重
|
||||
- 计划需求重量 = 理论需求重量 × 备料系数
|
||||
- 建议采购重量 = 按采购倍数或最小批量向上取整
|
||||
|
||||
例如:
|
||||
|
||||
- 产品“本体”订单数量 10000 件
|
||||
- BOM 维护“本体”使用 `SAP440 500*600*2.5` 钢板
|
||||
- 单件毛重 5.9kg
|
||||
- 理论需求 59000kg
|
||||
- 供应商按 2000kg 为一档出售
|
||||
- 建议采购 60000kg
|
||||
|
||||
### 4.3 采购与到货
|
||||
|
||||
- MRP 自动生成采购需求
|
||||
- 采购员可合并需求生成采购订单
|
||||
- 到货后按批次入库
|
||||
- 每批入库记录供应商、炉批号、重量、件数、单价、质检状态、仓位
|
||||
- 入库后即时更新库存余额和可用库存
|
||||
|
||||
### 4.4 生产工单与领料
|
||||
|
||||
- 计划员根据销售订单生成工单
|
||||
- 工单自动展开工序清单和工单用料清单
|
||||
- 领料支持整批领料、分批领料、超领审批、退料回库
|
||||
- 同一批 60t 材料可以分多次出库给多个工单或同一工单不同批次生产
|
||||
|
||||
### 4.5 工序报工
|
||||
|
||||
每张工单按产品工艺路线生成标准工序,例如:
|
||||
|
||||
1. 开平
|
||||
2. 冲压
|
||||
3. 去毛刺
|
||||
4. 折弯
|
||||
5. 焊接
|
||||
6. 检验包装
|
||||
|
||||
每道工序维护:
|
||||
|
||||
- 标准工时
|
||||
- 标准人工成本
|
||||
- 标准设备成本
|
||||
- 标准报废上限
|
||||
- 是否必须扫码报工
|
||||
|
||||
工人报工时提交:
|
||||
|
||||
- 工单号
|
||||
- 工序号
|
||||
- 员工
|
||||
- 设备
|
||||
- 班次
|
||||
- 开始时间、结束时间
|
||||
- 本次加工数量
|
||||
- 合格数量
|
||||
- 报废数量
|
||||
- 返工数量
|
||||
- 报废原因
|
||||
- 停机时长
|
||||
- 附图或备注
|
||||
|
||||
系统自动判断:
|
||||
|
||||
- 当前工序报废率是否高于阈值
|
||||
- 本工单累计报废率是否高于产品允许报废率
|
||||
- 某设备在某工序上的异常是否持续出现
|
||||
|
||||
异常规则建议:
|
||||
|
||||
- 工序报废率 > 工序允许报废率时标红
|
||||
- 工单累计报废率 > 产品允许报废率时升级预警
|
||||
- 同设备 3 次连续出现异常报废时推送设备检查任务
|
||||
|
||||
### 4.6 成品入库与发货
|
||||
|
||||
- 完工报工确认后生成完工入库单
|
||||
- 成品按工单批次入库
|
||||
- 发货单关联销售订单明细和成品库存批次
|
||||
- 支持待发货区、已锁定库存、出库复核
|
||||
|
||||
### 4.7 财务成本
|
||||
|
||||
成本核算分为四层:
|
||||
|
||||
- 材料成本:采购成本、运杂费、材料损耗
|
||||
- 工序成本:人工成本、设备成本、委外成本
|
||||
- 制造费用:房租、水电、后勤、管理、折旧
|
||||
- 异常成本:返工成本、报废成本、停机损失
|
||||
|
||||
建议月度流程:
|
||||
|
||||
- 财务录入期间制造费用
|
||||
- 系统按工时、设备工时或产量进行分摊
|
||||
- 自动计算工单完工成本、产品标准成本、库存结存成本
|
||||
- 输出周报、月报、库存对账表、工单成本表、废料收益表
|
||||
|
||||
### 4.8 退货流程
|
||||
|
||||
退货只有两种处置:
|
||||
|
||||
- 返工:生成返工工单,继续产生人工/设备/辅料成本
|
||||
- 报废:形成报废记录,可登记废料重量和废料出售金额
|
||||
|
||||
退货必须回溯:
|
||||
|
||||
- 原销售订单
|
||||
- 原发货批次
|
||||
- 原成品工单
|
||||
- 退货原因
|
||||
- 责任工序或责任批次
|
||||
|
||||
## 5. 模块联动设计
|
||||
|
||||
### 5.1 主数据中心
|
||||
|
||||
- 物料档案:原材料、半成品、成品、废料、备件统一编码
|
||||
- 产品档案:图号、单重、标准工艺路线、允许报废率
|
||||
- 材料档案:材质、规格、密度、理论单重、采购倍数、安全库存
|
||||
- 工艺路线:工序顺序、工位、成本参数、标准报废率
|
||||
- 仓库档案:仓库、库区、库位
|
||||
- 客户与供应商档案
|
||||
- 设备档案与保养计划
|
||||
- 员工、角色、账号
|
||||
|
||||
### 5.2 销售模块
|
||||
|
||||
- 客户管理
|
||||
- 销售订单
|
||||
- 交期预警
|
||||
- 发货计划
|
||||
- 回款字段预留
|
||||
|
||||
### 5.3 MRP 与计划模块
|
||||
|
||||
- 按订单展开 BOM
|
||||
- 计算材料需求重量和采购建议
|
||||
- 判断库存可用量与缺料量
|
||||
- 生成采购需求单和工单
|
||||
|
||||
### 5.4 采购模块
|
||||
|
||||
- 采购订单
|
||||
- 到货登记
|
||||
- 供应商交期与价格对比
|
||||
- 采购对账字段
|
||||
|
||||
### 5.5 仓储模块
|
||||
|
||||
仓储必须作为强化模块设计,建议包含:
|
||||
|
||||
- 原材料入库
|
||||
- 成品入库
|
||||
- 退货入库
|
||||
- 生产领料
|
||||
- 生产退料
|
||||
- 发货出库
|
||||
- 移库
|
||||
- 盘点
|
||||
- 批次冻结
|
||||
- 安全库存预警
|
||||
- 呆滞库存分析
|
||||
- 出入库流水追溯
|
||||
|
||||
### 5.6 生产模块
|
||||
|
||||
- 工单管理
|
||||
- 工单工序展开
|
||||
- 生产领料
|
||||
- 工序报工
|
||||
- 报废登记
|
||||
- 完工入库
|
||||
- 工单进度看板
|
||||
- 异常停机与异常报废分析
|
||||
|
||||
### 5.7 设备模块
|
||||
|
||||
- 设备台账
|
||||
- 所属工位/产线
|
||||
- 点检计划
|
||||
- 保养计划
|
||||
- 维修工单
|
||||
- 停机记录
|
||||
- 设备稼动率分析
|
||||
|
||||
### 5.8 财务模块
|
||||
|
||||
- 会计期间
|
||||
- 制造费用录入
|
||||
- 工单成本归集
|
||||
- 产品成本报表
|
||||
- 库存金额报表
|
||||
- 采购对账
|
||||
- 销售发货对账
|
||||
|
||||
## 6. 管理员首页设计重点
|
||||
|
||||
管理员首页必须“一针见血”,建议固定展示以下信息:
|
||||
|
||||
- 今日新增订单数、未交订单数
|
||||
- 今日缺料物料数
|
||||
- 待处理采购到货异常
|
||||
- 在制工单数、卡滞工单数
|
||||
- 异常报废工序数
|
||||
- 设备保养到期数
|
||||
- 待发货成品数量
|
||||
- 本周材料成本、人工成本、制造费用、废料收益
|
||||
|
||||
并提供三类重点面板:
|
||||
|
||||
- 订单到发货流程看板
|
||||
- 异常中心
|
||||
- 库存与成本总览
|
||||
|
||||
## 7. 报工小程序需要回传的数据
|
||||
|
||||
后续小程序接入时,建议至少上传以下字段:
|
||||
|
||||
- report_no
|
||||
- work_order_no
|
||||
- operation_seq
|
||||
- operation_name
|
||||
- employee_code
|
||||
- employee_name
|
||||
- equipment_code
|
||||
- shift_code
|
||||
- start_time
|
||||
- end_time
|
||||
- report_qty
|
||||
- good_qty
|
||||
- scrap_qty
|
||||
- rework_qty
|
||||
- scrap_reason
|
||||
- downtime_minutes
|
||||
- remark
|
||||
- source = `MINI_APP`
|
||||
|
||||
## 8. 数据库设计说明
|
||||
|
||||
数据库按业务域分层:
|
||||
|
||||
- `sys_`:组织、用户、角色、权限
|
||||
- `md_`:主数据
|
||||
- `so_`:销售
|
||||
- `mrp_`:物料需求
|
||||
- `po_`:采购
|
||||
- `wh_`:仓储
|
||||
- `pp_`:生产
|
||||
- `em_`:设备
|
||||
- `rt_`:退货
|
||||
- `fi_`:财务
|
||||
|
||||
核心设计思想:
|
||||
|
||||
- `md_item` 统一物料主档,避免库存、采购、生产各自维护一套编码
|
||||
- `wh_stock_lot` + `wh_inventory_txn` 实现批次追溯和库存流水
|
||||
- `pp_work_order_operation` + `pp_operation_report` 实现工序化和报工化
|
||||
- `fi_overhead_entry` + `fi_cost_allocation` 解决制造费用分摊
|
||||
|
||||
## 9. 推荐实施顺序
|
||||
|
||||
第一阶段:
|
||||
|
||||
- 主数据
|
||||
- 销售订单
|
||||
- BOM/工艺路线
|
||||
- MRP
|
||||
- 采购
|
||||
- 仓储
|
||||
- 工单
|
||||
- 报工
|
||||
|
||||
第二阶段:
|
||||
|
||||
- 完整财务核算
|
||||
- 设备保养与维修
|
||||
- 退货闭环
|
||||
- 小程序扫码报工
|
||||
|
||||
第三阶段:
|
||||
|
||||
- 经营驾驶舱
|
||||
- 异常预测
|
||||
- 供应商评分
|
||||
- 设备稼动率分析
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# 采购使用说明手册
|
||||
|
||||
适用模块:`采购与库存 -> 采购订单`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`采购与库存 -> 嘉恒仓库`。
|
||||
|
||||
本手册面向采购专员、仓库管理人员和管理人员,用于说明从采购下单、锁单、到货、质检、入库、退货到合同生成的完整操作方式。
|
||||
|
||||
## 1. 采购业务总览
|
||||
|
||||
采购模块负责把公司需要采购的原材料或辅料形成采购订单,并推动后续到货、质检和入库。
|
||||
|
||||
采购订单目前分两类仓库目标:
|
||||
|
||||
| 采购到货库 | 采购来源 | 计量口径 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 原材料库 | 备料采购、销售订单采购 | 重量,主单位 kg,辅助显示 t | 钢板、卷料等进入原材料库,后续用于生产出库。 |
|
||||
| 辅料库 | 备料采购 | 数量 | 手套、薄膜、纸箱、螺丝钉等生产消耗品。 |
|
||||
|
||||
采购单号由系统自动生成,规则为:
|
||||
|
||||
```text
|
||||
采购{年份}-00001
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
采购2026-00001
|
||||
```
|
||||
|
||||
## 2. 标准采购流程图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["维护基础资料"] --> B["新增采购订单"]
|
||||
B --> C{"选择采购到货库"}
|
||||
C --> D["原材料库"]
|
||||
C --> E["辅料库"]
|
||||
D --> F{"选择采购来源"}
|
||||
F --> G["备料采购"]
|
||||
F --> H["销售订单采购"]
|
||||
E --> I["辅料备料采购"]
|
||||
G --> J["填写供应商、预计到货、采购明细"]
|
||||
H --> K["选择一个或多个销售订单"]
|
||||
K --> L["系统按产品需规带出原材料需求"]
|
||||
I --> M["填写辅料、数量、用途"]
|
||||
J --> N["保存采购订单"]
|
||||
L --> N
|
||||
M --> N
|
||||
N --> O["锁单"]
|
||||
O --> P["到货入库"]
|
||||
P --> Q["质量检验"]
|
||||
Q --> R{"质检结果"}
|
||||
R --> S["放行入库"]
|
||||
R --> T["拒收/异常处理"]
|
||||
S --> U["形成库存批次或辅料库存"]
|
||||
```
|
||||
|
||||
## 3. 使用前准备
|
||||
|
||||
采购前建议先确认以下基础资料已经维护完整。
|
||||
|
||||
| 前置资料 | 所属模块 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 供应商名录 | 基础资料 -> 供应商名录 | 新增采购订单时选择供应商,导入导出供应商数据。 |
|
||||
| 原材料名录 | 基础资料 -> 原材料名录 | 原材料采购明细的选择来源,维护原材料编码、名称、材质、规格、性能要求等。 |
|
||||
| 产品需规清单 | 基础资料 -> 产品需规清单 | 销售订单采购时,系统按产品需规自动带出所需原材料。 |
|
||||
| 销售订单 | 订单与计划 -> 销售订单列表 | 销售订单采购时作为需求来源。 |
|
||||
|
||||
如果是辅料采购,需确认辅料主数据已经存在,且采购时按数量填写。
|
||||
|
||||
## 4. 新增采购订单
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 采购订单 -> 新增采购订单
|
||||
```
|
||||
|
||||
### 4.1 第一步:选择采购到货库
|
||||
|
||||
新增采购订单时,系统会先让用户选择采购到货库:
|
||||
|
||||
| 选择项 | 后续规则 |
|
||||
| --- | --- |
|
||||
| 原材料库 | 可以选择备料采购或销售订单采购。 |
|
||||
| 辅料库 | 只能选择备料采购,不能关联销售订单采购。 |
|
||||
|
||||
### 4.2 第二步:选择采购来源
|
||||
|
||||
原材料库采购时,有两种采购来源。
|
||||
|
||||
| 采购来源 | 适用场景 | 系统行为 |
|
||||
| --- | --- | --- |
|
||||
| 备料采购 | 不直接绑定某张销售订单,只是按库存或经验备料。 | 用户手动选择原材料,填写采购重量和单价。 |
|
||||
| 销售订单采购 | 为一个或多个销售订单采购原材料。 | 用户选择销售订单后,系统按产品需规清单自动带出原材料需求,用户可以删除不需要采购的明细。 |
|
||||
|
||||
辅料库采购只支持备料采购。
|
||||
|
||||
### 4.3 销售订单采购的自动带出逻辑
|
||||
|
||||
销售订单采购时,用户可以选择多个销售订单。系统会根据销售订单中的产品,到 `产品需规清单` 中查找该产品默认使用的原材料,并自动生成采购明细。
|
||||
|
||||
生成后用户仍然可以调整:
|
||||
|
||||
- 如果某个供应商不能供应某种材料,可以删除该行。
|
||||
- 如果本次采购只买一部分材料,可以调整采购重量。
|
||||
- 如果库存已足够,系统可能提示缺口为 0,用户可改为备料采购。
|
||||
|
||||
### 4.4 采购重量、吨和单价显示
|
||||
|
||||
原材料采购统一以 kg 为主单位。为了方便采购人员看吨位,系统会在涉及重量的地方辅助显示 t。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
采购重量:2500 kg
|
||||
辅助显示:2.5 t
|
||||
```
|
||||
|
||||
单价主口径为:
|
||||
|
||||
```text
|
||||
元/kg
|
||||
```
|
||||
|
||||
系统会辅助显示:
|
||||
|
||||
```text
|
||||
元/t
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
4.20 元/kg = 4200 元/t
|
||||
```
|
||||
|
||||
### 4.5 预计到货和提前预警
|
||||
|
||||
采购订单需要填写预计到货时间,并可填写提前预警时间,单位为天。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
预计到货:2026-06-10
|
||||
提前预警时间:3 天
|
||||
```
|
||||
|
||||
则从 `2026-06-07` 开始,采购订单会进入到货提醒状态,页面上会用提醒色提示用户关注。
|
||||
|
||||
## 5. 采购订单状态和锁单
|
||||
|
||||
采购订单保存后,并不代表已经通知供应商供货。正式通知供应商前,应点击 `锁单`。
|
||||
|
||||
| 状态/标记 | 含义 | 用户应做什么 |
|
||||
| --- | --- | --- |
|
||||
| 未锁单 | 采购订单仍可修改和删除。 | 继续检查供应商、交期、明细、单价。 |
|
||||
| 已锁单 | 表示已经通知供应商供货。 | 后续不能随意修改或删除,可进入到货入库。 |
|
||||
| 到货预警 | 预计到货日前进入提醒期。 | 联系供应商确认到货进度。 |
|
||||
| 部分收货 | 采购订单只有部分明细或部分重量到货。 | 等待后续到货。 |
|
||||
| 待质检 | 到货已登记,但还未完成质检放行。 | 质检人员处理质量检验。 |
|
||||
| 已放行/已收货 | 质检通过并进入库存。 | 仓库可以使用该库存。 |
|
||||
| 存在退货 | 该采购订单关联的库存批次发生过退货出库。 | 采购需跟进供应商补货、换货或扣款。 |
|
||||
|
||||
重要规则:
|
||||
|
||||
```text
|
||||
锁单是采购执行控制,不等于收货完成。
|
||||
质检放行前,材料不应被视为可用库存。
|
||||
```
|
||||
|
||||
## 6. 生成采购合同
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 采购订单
|
||||
```
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 在采购订单列表中勾选需要生成合同的采购订单。
|
||||
2. 点击列表上方的 `生成合同`。
|
||||
3. 系统按基础采购合同模板生成合同。
|
||||
4. 生成合同前请确认供应商、采购明细、单价、预计到货时间正确。
|
||||
|
||||
限制规则:
|
||||
|
||||
```text
|
||||
采购合同只能按同一供应商生成,不要混选不同供应商的采购订单。
|
||||
```
|
||||
|
||||
## 7. 到货入库
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 到货入库 -> 新增到货入库单
|
||||
```
|
||||
|
||||
到货入库用于登记供应商送到公司的货物。系统只允许选择已经锁单的采购订单。
|
||||
|
||||
操作流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["供应商到货"] --> B["新增到货入库单"]
|
||||
B --> C["选择已锁单采购订单"]
|
||||
C --> D["选择采购明细"]
|
||||
D --> E{"采购到货库"}
|
||||
E --> F["原材料库:填写到货重量 kg、单价"]
|
||||
E --> G["辅料库:填写到货数量、单位"]
|
||||
F --> H["保存入库单"]
|
||||
G --> H
|
||||
H --> I["进入质量检验"]
|
||||
I --> J["质检放行后进入库存"]
|
||||
```
|
||||
|
||||
原材料到货时,每一次到货都会生成新的库存批次号。即便同一个采购订单、同一种原材料分多次到货,每次也会形成新的库存批次。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
采购订单 A 采购原材料 a
|
||||
第一次到货 -> JH_材料名00001_0001
|
||||
第二次到货 -> JH_材料名00001_0002
|
||||
第三次到货 -> JH_材料名00001_0003
|
||||
```
|
||||
|
||||
入库单号由系统自动生成,规则为:
|
||||
|
||||
```text
|
||||
入库{年份}-00001
|
||||
```
|
||||
|
||||
## 8. 质量检验
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 质量检验
|
||||
```
|
||||
|
||||
质检用于判断到货材料或辅料是否可以进入库存。
|
||||
|
||||
常见处理结果:
|
||||
|
||||
| 质检结果 | 后续影响 |
|
||||
| --- | --- |
|
||||
| 已放行 | 进入对应仓库库存,原材料形成可用库存批次。 |
|
||||
| 已拒收 | 不进入可用库存,采购和仓库需要协同处理。 |
|
||||
| 待质检 | 暂时不可用,不能当作可生产库存。 |
|
||||
|
||||
如果材料入仓后才发现质量或性能问题,应在嘉恒仓库的 `原材料库 -> 退货出库` 中办理退货。
|
||||
|
||||
## 9. 原材料退货出库
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 嘉恒仓库 -> 原材料库 -> 出库 -> 退货出库
|
||||
```
|
||||
|
||||
适用场景:
|
||||
|
||||
```text
|
||||
原材料已经入仓,但后续发现质量、规格或性能不合格,需要退回供应商。
|
||||
```
|
||||
|
||||
操作要点:
|
||||
|
||||
- 选择要退货的原材料库存批次。
|
||||
- 填写退货重量。
|
||||
- 填写运单号或上传辅助照片。
|
||||
- 保存后系统会减少对应库存批次的可用重量。
|
||||
- 对应采购订单会显示 `存在退货` 警示标记。
|
||||
|
||||
## 10. 查询和核对
|
||||
|
||||
采购人员常用核对路径:
|
||||
|
||||
| 想核对的内容 | 推荐入口 |
|
||||
| --- | --- |
|
||||
| 某张采购订单是否锁单 | 采购与库存 -> 采购订单 |
|
||||
| 某采购订单到了多少 | 采购与库存 -> 到货入库 |
|
||||
| 到货是否质检放行 | 采购与库存 -> 质量检验 |
|
||||
| 材料是否形成库存批次 | 采购与库存 -> 嘉恒仓库 -> 原材料库 |
|
||||
| 某采购订单是否存在退货 | 采购与库存 -> 采购订单,查看警示标记 |
|
||||
| 某材料批次来源采购单 | 嘉恒仓库 -> 原材料库 -> 批次明细 |
|
||||
|
||||
## 11. 常见问题
|
||||
|
||||
### 11.1 为什么到货入库下拉框里看不到采购订单?
|
||||
|
||||
通常原因:
|
||||
|
||||
- 采购订单还没有锁单。
|
||||
- 采购订单已经全部收货完成。
|
||||
- 当前选择的到货仓库类型与采购订单仓库类型不一致。
|
||||
|
||||
### 11.2 为什么辅料库不能选择销售订单采购?
|
||||
|
||||
辅料通常不是按某个销售订单的产品需规直接计算出来的主材需求,而是生产过程中的消耗品,因此只允许备料采购。
|
||||
|
||||
### 11.3 为什么原材料采购页面既显示 kg 又显示 t?
|
||||
|
||||
系统主口径是 kg,方便库存、生产、成本统一计算;采购沟通时常用吨,因此辅助显示 t。
|
||||
|
||||
### 11.4 采购订单锁单后还能修改吗?
|
||||
|
||||
不能。锁单表示已经通知供应商供货,避免采购内容被随意改变。若确实要调整,需要走业务管理确认后再处理。
|
||||
|
||||
### 11.5 采购订单显示“存在退货”是什么意思?
|
||||
|
||||
说明该采购订单关联的某个原材料库存批次发生过退货出库。采购人员需要跟供应商确认补货、换货、退款或扣款。
|
||||
|
||||
## 12. 采购员每日建议检查清单
|
||||
|
||||
- 检查即将到货或已经到货预警的采购订单。
|
||||
- 检查已锁单但长时间未到货的采购订单。
|
||||
- 检查到货后仍处于待质检状态的记录。
|
||||
- 检查存在退货的采购订单,并跟进供应商处理。
|
||||
- 检查销售订单采购是否已覆盖相关销售订单的原材料需求。
|
||||
@@ -0,0 +1,301 @@
|
||||
# 销售使用说明手册
|
||||
|
||||
适用模块:`基础资料 -> 客户名录`、`订单与计划 -> 销售订单列表`、`采购与库存 -> 采购订单`、`采购与库存 -> 嘉恒仓库 -> 成品库 -> 销售出库`、`发货概览 -> 发货台账`。
|
||||
|
||||
本手册面向销售人员、管理人员和仓库发货人员,用于说明客户维护、销售订单创建、合同生成、采购联动、成品发货、发货追溯和客户退货处理。
|
||||
|
||||
## 1. 销售业务总览
|
||||
|
||||
销售模块的核心是销售订单。销售订单创建后,可以联动采购、生产、成品入库、销售出库和发货台账。
|
||||
|
||||
完整链路如下:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["客户名录"] --> B["新增销售订单"]
|
||||
B --> C["销售订单明细:产品、数量、单价、交期"]
|
||||
C --> D{"是否需要采购原材料"}
|
||||
D --> E["采购订单:销售订单采购"]
|
||||
D --> F["使用现有库存生产"]
|
||||
E --> G["到货入库与质检"]
|
||||
F --> H["生产出库生成工单"]
|
||||
G --> H
|
||||
H --> I["小程序工序报工"]
|
||||
I --> J["成品库:生产入库"]
|
||||
J --> K["成品库:销售出库"]
|
||||
K --> L["发货概览:按销售订单查看发货台账"]
|
||||
L --> M{"客户是否退货"}
|
||||
M --> N["退货库:退货入库,后续返工"]
|
||||
M --> O["废料库:退货废料入库,直接报废"]
|
||||
```
|
||||
|
||||
## 2. 客户名录
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
基础资料 -> 客户名录
|
||||
```
|
||||
|
||||
客户名录用于维护销售订单所需的客户基础信息。新增销售订单前,应先确认客户已经存在。
|
||||
|
||||
支持能力:
|
||||
|
||||
- 新增客户。
|
||||
- 编辑客户。
|
||||
- 导入客户。
|
||||
- 导出客户。
|
||||
- 每个客户允许维护多位联系人,最多 3 位。
|
||||
|
||||
建议维护字段:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| 客户名称 | 销售订单和合同的客户主体。 |
|
||||
| 联系人 | 发货、对账、合同沟通使用。 |
|
||||
| 电话 | 销售联系和发货联系。 |
|
||||
| 地址 | 发货地址或合同地址。 |
|
||||
| 备注 | 记录客户特殊要求。 |
|
||||
|
||||
## 3. 销售订单列表
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
订单与计划 -> 销售订单列表
|
||||
```
|
||||
|
||||
销售订单用于记录客户订购的产品、数量、单价和交期。
|
||||
|
||||
销售订单号由系统自动生成,规则为:
|
||||
|
||||
```text
|
||||
销售{年份}-00001
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
销售2026-00001
|
||||
```
|
||||
|
||||
## 4. 新增销售订单
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 进入 `订单与计划 -> 销售订单列表`。
|
||||
2. 点击 `新增销售订单`。
|
||||
3. 选择客户。
|
||||
4. 确认销售订单号,系统会自动生成。
|
||||
5. 填写销售订单主信息。
|
||||
6. 添加销售明细。
|
||||
7. 每条明细选择产品、填写销售数量、销售单价和明细交期。
|
||||
8. 保存销售订单。
|
||||
|
||||
明细交期规则:
|
||||
|
||||
```text
|
||||
销售订单明细的交期默认等于销售订单交期,用户可以单独修改每条明细交期。
|
||||
```
|
||||
|
||||
## 5. 销售订单删除规则
|
||||
|
||||
销售订单不是任何时候都可以删除。
|
||||
|
||||
| 状态 | 是否可删除 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 未关联采购、未发货 | 可以删除 | 说明订单还没有进入后续业务。 |
|
||||
| 已关联采购订单 | 不可删除 | 采购已经基于该订单产生需求。 |
|
||||
| 已发货或部分发货 | 不可删除 | 已经形成库存和发货流水。 |
|
||||
|
||||
如果订单已经进入采购或发货环节,应通过业务调整方式处理,而不是直接删除。
|
||||
|
||||
## 6. 生成销售合同
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
订单与计划 -> 销售订单列表
|
||||
```
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 在销售订单列表中勾选需要生成合同的销售订单。
|
||||
2. 点击列表上方的 `生成合同`。
|
||||
3. 系统按基础销售合同模板生成合同。
|
||||
4. 用户下载或打印后,与客户确认合同内容。
|
||||
|
||||
限制规则:
|
||||
|
||||
```text
|
||||
销售合同只能按同一客户生成,不要混选不同客户的销售订单。
|
||||
```
|
||||
|
||||
合同中会包含:
|
||||
|
||||
- 销售订单号。
|
||||
- 客户信息。
|
||||
- 产品明细。
|
||||
- 销售数量。
|
||||
- 销售单价。
|
||||
- 金额。
|
||||
- 约定交期。
|
||||
- 基础质量和交付条款。
|
||||
|
||||
## 7. 销售订单与采购联动
|
||||
|
||||
如果销售订单需要采购原材料,可以在采购订单中选择 `销售订单采购`。
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 采购订单 -> 新增采购订单 -> 原材料库 -> 销售订单采购
|
||||
```
|
||||
|
||||
联动逻辑:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["销售订单"] --> B["采购订单选择销售订单采购"]
|
||||
B --> C["选择一个或多个销售订单"]
|
||||
C --> D["系统读取销售订单产品"]
|
||||
D --> E["系统读取产品需规清单"]
|
||||
E --> F["自动生成原材料采购明细"]
|
||||
F --> G["采购人员删除供应商不能供应的材料"]
|
||||
G --> H["保存并锁单"]
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 一个采购订单可以关联多个销售订单。
|
||||
- 系统会按销售订单产品的需规清单自动带出原材料。
|
||||
- 采购人员可以删除本供应商不能供应的材料。
|
||||
- 采购订单锁单后,销售订单会被视为已经进入采购联动流程。
|
||||
|
||||
## 8. 销售出库
|
||||
|
||||
销售出库目前在嘉恒仓库的成品库中办理。
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
采购与库存 -> 嘉恒仓库 -> 成品库 -> 出库 -> 销售出库
|
||||
```
|
||||
|
||||
销售出库用于把成品库存发给客户,并回写销售订单已发数量。
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 进入嘉恒仓库。
|
||||
2. 选择 `成品库`。
|
||||
3. 点击出库区的 `销售出库`。
|
||||
4. 选择是否关联销售订单。
|
||||
5. 如果关联销售订单,选择销售订单和销售订单明细。
|
||||
6. 填写发货数量。
|
||||
7. 填写运单号或上传辅助照片。
|
||||
8. 填写包装备注,例如有无提供篮子等。
|
||||
9. 保存后系统自动从成品库存中扣减,并生成销售出库单。
|
||||
|
||||
销售出库时,系统会自动拆分可用成品库存批次,并追溯其来源原材料库存批次。
|
||||
|
||||
如果库存不足,系统应阻止创建销售出库单,并提示缺少数量。
|
||||
|
||||
## 9. 发货概览
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
发货概览 -> 发货台账
|
||||
```
|
||||
|
||||
发货台账按销售订单聚合展示,而不是只看一张张发货单。
|
||||
|
||||
推荐查看方式:
|
||||
|
||||
| 想查看 | 操作 |
|
||||
| --- | --- |
|
||||
| 某销售订单总共发了多少 | 搜索销售订单号,查看聚合行。 |
|
||||
| 发货单号和发货明细 | 点开销售订单后的流水信息。 |
|
||||
| 发货产品来自哪些成品批次 | 查看发货明细中的成品库存批次。 |
|
||||
| 成品批次用了哪些原材料 | 查看来源原材料库存批次号。 |
|
||||
| 客户、收货人、运单 | 查看发货流水详情。 |
|
||||
|
||||
发货概览的业务意义:
|
||||
|
||||
```text
|
||||
销售人员看客户订单履约;
|
||||
仓库人员看发货流水;
|
||||
管理人员看销售订单、成品库存和原材料来源追溯。
|
||||
```
|
||||
|
||||
## 10. 客户退货处理
|
||||
|
||||
客户退货不再放在旧的 `发货与售后 -> 退货处理` 中,而是在嘉恒仓库中按退回品去向处理。
|
||||
|
||||
| 客户退货判断 | 操作入口 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 可返工 | 嘉恒仓库 -> 退货库 -> 入库 -> 退货入库 | 退货品进入退货库,后续可返工出库。 |
|
||||
| 不可返工,直接报废 | 嘉恒仓库 -> 废料库 -> 入库 -> 退货废料入库 | 退货品直接进入废料库。 |
|
||||
|
||||
退货入库时,应选择原发货批次或发货明细,系统会追溯到原销售订单、产品和原材料库存批次。
|
||||
|
||||
退货流程图:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["客户退回产品"] --> B{"业务判断"}
|
||||
B --> C["可返工"]
|
||||
B --> D["不可返工"]
|
||||
C --> E["退货库:退货入库"]
|
||||
E --> F["退货库:返工出库"]
|
||||
F --> G["后续返工生产"]
|
||||
D --> H["废料库:退货废料入库"]
|
||||
H --> I["废料库:售卖出库"]
|
||||
```
|
||||
|
||||
## 11. 销售人员常用核对路径
|
||||
|
||||
| 目标 | 推荐入口 |
|
||||
| --- | --- |
|
||||
| 新增客户 | 基础资料 -> 客户名录 |
|
||||
| 创建销售订单 | 订单与计划 -> 销售订单列表 |
|
||||
| 生成销售合同 | 订单与计划 -> 销售订单列表 |
|
||||
| 看销售订单是否采购联动 | 订单与计划 -> 销售订单列表,或采购与库存 -> 采购订单 |
|
||||
| 看销售订单是否发货 | 发货概览 -> 发货台账 |
|
||||
| 办理销售发货 | 采购与库存 -> 嘉恒仓库 -> 成品库 -> 销售出库 |
|
||||
| 办理客户退货 | 采购与库存 -> 嘉恒仓库 -> 退货库或废料库 |
|
||||
|
||||
## 12. 常见问题
|
||||
|
||||
### 12.1 为什么销售订单不能删除?
|
||||
|
||||
通常是该销售订单已经关联采购订单,或已经发生销售出库。系统不允许删除已经影响后续业务的数据。
|
||||
|
||||
### 12.2 为什么生成销售合同失败?
|
||||
|
||||
常见原因:
|
||||
|
||||
- 没有勾选销售订单。
|
||||
- 混选了不同客户的销售订单。
|
||||
- 所选销售订单没有明细。
|
||||
|
||||
### 12.3 为什么销售出库时看不到库存?
|
||||
|
||||
可能原因:
|
||||
|
||||
- 成品还没有生产入库。
|
||||
- 成品库存已被发完。
|
||||
- 选择的销售订单明细产品与成品库存产品不一致。
|
||||
|
||||
### 12.4 发货台账为什么按销售订单聚合?
|
||||
|
||||
销售人员最关心的是某张销售订单履约了多少,而不是孤立的一张发货单。因此系统按销售订单聚合,发货单号和发货明细作为点开后的流水信息展示。
|
||||
|
||||
## 13. 销售每日建议检查清单
|
||||
|
||||
- 检查新增销售订单是否录入产品、数量、单价和交期。
|
||||
- 检查需要采购的销售订单是否已经被采购订单关联。
|
||||
- 检查即将到交期的销售订单是否已生产入库。
|
||||
- 检查可发货销售订单是否已在成品库办理销售出库。
|
||||
- 检查发货台账中的发货数量和客户要求是否一致。
|
||||
- 检查客户退货是否已进入退货库或废料库。
|
||||
@@ -0,0 +1,408 @@
|
||||
# 仓库使用说明手册
|
||||
|
||||
适用模块:`采购与库存 -> 嘉恒仓库`、`生产执行 -> 工单台账`、`采购与库存 -> 到货入库`、`采购与库存 -> 质量检验`、`发货概览 -> 发货台账`。
|
||||
|
||||
本手册面向仓库管理人员、生产管理人员和管理人员,用于说明嘉恒仓库六大库的库存口径、出入库操作、库存批次、流水、盘库和追溯方式。
|
||||
|
||||
## 1. 嘉恒仓库总览
|
||||
|
||||
嘉恒仓库目前包含六大库:
|
||||
|
||||
| 仓库 | 主要对象 | 主计量口径 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 原材料库 | 钢板、卷料等原材料 | 重量 kg | 原材料只有重量概念,不使用数量概念。 |
|
||||
| 半成品库 | 做完部分工序的产品 | 按件或按重二选一 | 半成品是产品加已完成工序,不是普通物料。 |
|
||||
| 成品库 | 完工产品 | 数量为主,重量辅助 | 销售出库推动业务的是数量。 |
|
||||
| 辅料库 | 手套、薄膜、纸箱、螺丝钉等 | 数量 | 辅料采购、入库、出库统一按数量。 |
|
||||
| 废料库 | 边角料、报废品、废料 | 重量 kg | 废料只有重量概念。 |
|
||||
| 退货库 | 客户退回且可返工的产品 | 数量/追溯信息 | 用于存放待返工退货品。 |
|
||||
|
||||
仓库页面由三块组成:
|
||||
|
||||
```text
|
||||
仓库选择区:选择原材料库、半成品库、成品库、辅料库、废料库、退货库。
|
||||
出入库操作区:根据当前仓库显示对应的入库和出库操作。
|
||||
库存展示区:查看库存总览、批次明细和流水。
|
||||
```
|
||||
|
||||
## 2. 仓库出入库总流程
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["进入嘉恒仓库"] --> B["选择仓库"]
|
||||
B --> C["查看库存总览"]
|
||||
B --> D["选择入库操作"]
|
||||
B --> E["选择出库操作"]
|
||||
D --> F["填写业务信息"]
|
||||
E --> G["选择库存或来源批次"]
|
||||
F --> H["保存"]
|
||||
G --> H
|
||||
H --> I["生成库存变化"]
|
||||
I --> J["写入库级流水"]
|
||||
J --> K["可在库存明细和流水中追溯"]
|
||||
```
|
||||
|
||||
## 3. 原材料库
|
||||
|
||||
### 3.1 原材料库业务口径
|
||||
|
||||
原材料库只使用重量口径。
|
||||
|
||||
```text
|
||||
原材料物料 -> 库存批次号
|
||||
```
|
||||
|
||||
系统不再使用“材料分批次号”这个业务概念。每个原材料库存批次号就是后续生产、余料归还、废料追溯、销售追溯的核心来源。
|
||||
|
||||
库存批次号规则:
|
||||
|
||||
```text
|
||||
JH_{原材料名称 + 原材料编码后5位}_0001
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
JH_冷轧板00001_0001
|
||||
JH_冷轧板00001_0002
|
||||
```
|
||||
|
||||
每一次采购到货入库都会产生新的库存批次号。即便是同一个采购订单、同一种原材料,第二次到货也会递增为新的库存批次。
|
||||
|
||||
### 3.2 原材料库入库操作
|
||||
|
||||
| 入库类型 | 用途 | 关键规则 |
|
||||
| --- | --- | --- |
|
||||
| 期初入库 | 仓库初始化时导入初始材料。 | 支持导出模板和导入初始数据。 |
|
||||
| 客料入库 | 客户提供的原材料直接入库。 | 成本按 0 处理;系统自动生成新库存批次号。 |
|
||||
| 生产余料入库 | 生产结束后剩余原材料归还。 | 归还到原来源库存批次,不另起新批次。 |
|
||||
| 委外余料入库 | 委外加工结束后剩余原材料退回。 | 归还到原来源库存批次,不另起新批次。 |
|
||||
|
||||
生产余料入库流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["选择生产工单"] --> B["系统显示可归还重量"]
|
||||
B --> C["选择来源库存批次号,可多选"]
|
||||
C --> D["按每个来源库存批次填写归还重量"]
|
||||
D --> E{"是否勾选该工单结单"}
|
||||
E --> F["未结单:保存本次余料归还"]
|
||||
E --> G["结单:校验累计归还是否在允许范围"]
|
||||
G --> H{"是否超出范围"}
|
||||
H --> I["未超出:正常结单"]
|
||||
H --> J["超出:填写说明后结单"]
|
||||
```
|
||||
|
||||
重要规则:
|
||||
|
||||
```text
|
||||
余料归还是还到原库存批次里,不生成新的 SCSY 之类独立批次。
|
||||
```
|
||||
|
||||
### 3.3 原材料库出库操作
|
||||
|
||||
| 出库类型 | 用途 | 关键规则 |
|
||||
| --- | --- | --- |
|
||||
| 生产出库 | 原材料出库给生产使用。 | 选择原材料库存批次,多选;每次生产出库自动生成生产工单。 |
|
||||
| 委外出库 | 原材料出库给第三方加工。 | 选择来源库存批次,多选;需填写运单号或上传辅助照片。 |
|
||||
| 退货出库 | 入仓后发现不合格,退回供应商。 | 选择来源库存批次;会联动采购订单显示存在退货。 |
|
||||
| 报废出库 | 原材料报废。 | 选择来源库存批次,多选;减少原材料库存。 |
|
||||
|
||||
生产出库与工单关系:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["原材料库:生产出库"] --> B["选择产品"]
|
||||
B --> C["选择一个或多个原材料库存批次"]
|
||||
C --> D["填写出库重量"]
|
||||
D --> E["系统生成生产工单"]
|
||||
E --> F["小程序按工单报工"]
|
||||
F --> G["成品库生产入库"]
|
||||
F --> H["废料库生产废料入库"]
|
||||
F --> I["原材料库生产余料入库"]
|
||||
```
|
||||
|
||||
## 4. 半成品库
|
||||
|
||||
### 4.1 半成品库业务口径
|
||||
|
||||
半成品不是普通物料,而是:
|
||||
|
||||
```text
|
||||
产品 + 已完成工序
|
||||
```
|
||||
|
||||
例如一个产品有 5 道工序,当前完成第 3 道工序后暂时入库,那么半成品对象应是:
|
||||
|
||||
```text
|
||||
产品名称 + 第3道已完成工序
|
||||
```
|
||||
|
||||
页面展示时,标题尽量简洁显示产品名称,已完成工序在明细卡片或字段中展示。
|
||||
|
||||
### 4.2 半成品入库计量规则
|
||||
|
||||
半成品入库时必须选择入库类型:
|
||||
|
||||
| 入库类型 | 填写字段 |
|
||||
| --- | --- |
|
||||
| 按件入库 | 填写件数 |
|
||||
| 按重入库 | 填写重量 |
|
||||
|
||||
二者只能选一个,不能同时填写。
|
||||
|
||||
如果某种半成品在库中已经存在,再次入库时沿用已有半成品的入库类型。
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
某半成品第一次按件入库,后续同种半成品继续按件入库。
|
||||
某半成品第一次按重入库,后续同种半成品继续按重入库。
|
||||
```
|
||||
|
||||
### 4.3 半成品库操作
|
||||
|
||||
| 类型 | 操作 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 入库 | 产中入库 | 生产过程中需要暂时入库的半成品。 |
|
||||
| 入库 | 期初入库 | 仓库初始化时导入历史半成品。 |
|
||||
| 入库 | 委外入库 | 委外某道工序完成后入库,等待后续继续加工。 |
|
||||
| 出库 | 产中出库 | 暂存半成品出库,继续完成后续生产。 |
|
||||
| 出库 | 委外出库 | 半成品出库委外加工后续工序,需填写委外方。 |
|
||||
|
||||
来源库存批次规则:
|
||||
|
||||
```text
|
||||
入库时,来源库存批次根据所选半成品对应产品使用的原材料,带出该原材料现有库存批次。
|
||||
出库时,来源库存批次由所选半成品库存自动确定,不让用户手填。
|
||||
```
|
||||
|
||||
## 5. 成品库
|
||||
|
||||
### 5.1 成品库业务口径
|
||||
|
||||
成品库以数量为主,重量只是辅助显示。
|
||||
|
||||
```text
|
||||
推动销售、发货和库存扣减的是成品数量。
|
||||
```
|
||||
|
||||
成品的入库材料成本可以按原材料库存批次成本和产品毛重估算,用于辅助成本参考,不等同于销售单价。
|
||||
|
||||
### 5.2 成品库操作
|
||||
|
||||
| 类型 | 操作 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 入库 | 生产入库 | 选择生产工单,把完工成品入库。 |
|
||||
| 入库 | 委外入库 | 委外加工完成后的成品入库。 |
|
||||
| 入库 | 期初入库 | 仓库初始化时导入初始成品。 |
|
||||
| 出库 | 销售出库 | 按销售订单或客户需求发货出库。 |
|
||||
|
||||
生产入库流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["选择生产工单"] --> B["系统计算可入库成品数量"]
|
||||
B --> C["填写本次入库数量"]
|
||||
C --> D{"是否勾选工单结单"}
|
||||
D --> E["不结单:保存本次成品入库"]
|
||||
D --> F["结单:生产工单进入成品结单状态"]
|
||||
E --> G["成品库增加库存"]
|
||||
F --> G
|
||||
```
|
||||
|
||||
销售出库流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["成品库:销售出库"] --> B{"是否关联销售订单"}
|
||||
B --> C["关联销售订单"]
|
||||
B --> D["不关联销售订单"]
|
||||
C --> E["选择销售订单和明细"]
|
||||
D --> F["选择客户和产品"]
|
||||
E --> G["填写发货数量、运单号或辅助照片、包装备注"]
|
||||
F --> G
|
||||
G --> H["系统检查成品库存是否足够"]
|
||||
H --> I{"库存是否足够"}
|
||||
I --> J["足够:创建销售出库单"]
|
||||
I --> K["不足:提示缺少数量,不创建"]
|
||||
J --> L["回写销售订单已发数量"]
|
||||
J --> M["写入发货台账"]
|
||||
```
|
||||
|
||||
## 6. 辅料库
|
||||
|
||||
辅料库用于存放生产过程中消耗的辅料,例如手套、薄膜、纸箱、螺丝钉等。
|
||||
|
||||
辅料库统一按数量管理。
|
||||
|
||||
| 类型 | 操作 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 入库 | 期初入库 | 仓库初始化时导入初始辅料。 |
|
||||
| 出库 | 生产出库 | 辅料用于生产消耗出库,需填写用途。 |
|
||||
|
||||
辅料通过采购入库时,也应统一按数量口径,不走原材料重量逻辑。
|
||||
|
||||
## 7. 废料库
|
||||
|
||||
废料库用于存放生产中的边角料、报废品、退货报废品和委外产生的废料。
|
||||
|
||||
废料库只使用重量口径。
|
||||
|
||||
| 类型 | 操作 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 入库 | 生产废料入库 | 生产过程产生的废料入库,选择生产工单。 |
|
||||
| 入库 | 期初入库 | 仓库初始化时导入初始废料。 |
|
||||
| 入库 | 委外废料入库 | 委外加工产生的废料入库。 |
|
||||
| 入库 | 退货废料入库 | 客户退回后判定报废的产品直接进入废料库。 |
|
||||
| 出库 | 售卖出库 | 废料称重售卖出库。 |
|
||||
|
||||
生产废料入库会根据生产工单计算可入库废料重量。废料由两部分组成:
|
||||
|
||||
```text
|
||||
边角废料 = 合格生产数量 * (毛重 - 净重)
|
||||
报废品废料 = 报废数量 * 毛重
|
||||
```
|
||||
|
||||
系统会按工单已报工和已入库废料计算本次可参考入库重量。
|
||||
|
||||
## 8. 退货库
|
||||
|
||||
退货库用于存放客户退回且判定可返工的产品。
|
||||
|
||||
| 类型 | 操作 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 入库 | 期初入库 | 仓库初始化时导入历史退货待返工品。 |
|
||||
| 入库 | 退货入库 | 客户退回且判定可返工的产品入退货库。 |
|
||||
| 出库 | 返工出库 | 退货库待返工品出库,后续返工处理。 |
|
||||
|
||||
如果客户退回品判定为不可返工,不进入退货库,应进入:
|
||||
|
||||
```text
|
||||
废料库 -> 退货废料入库
|
||||
```
|
||||
|
||||
退货库中的退货明细应能追溯:
|
||||
|
||||
- 原销售订单。
|
||||
- 原发货明细。
|
||||
- 产品。
|
||||
- 原材料库存批次号。
|
||||
|
||||
## 9. 运单号和辅助照片
|
||||
|
||||
以下业务需要填写运单号或上传辅助照片,二者至少提供一种即可:
|
||||
|
||||
| 仓库 | 操作 |
|
||||
| --- | --- |
|
||||
| 原材料库 | 客料入库、委外余料入库、委外出库、退货出库 |
|
||||
| 半成品库 | 委外入库、委外出库 |
|
||||
| 成品库 | 委外入库、销售出库 |
|
||||
| 废料库 | 委外废料入库、售卖出库 |
|
||||
|
||||
说明:
|
||||
|
||||
```text
|
||||
辅助照片可以是运单照片、现场照片、交接照片或其他能辅助说明业务事实的图片。
|
||||
```
|
||||
|
||||
## 10. 库存流水
|
||||
|
||||
每个仓库都应查看对应库的出入库流水。
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
嘉恒仓库 -> 选择仓库 -> 点击流水图标
|
||||
```
|
||||
|
||||
流水用于回答:
|
||||
|
||||
| 问题 | 通过流水查看 |
|
||||
| --- | --- |
|
||||
| 这批库存从哪里来? | 来源单据、来源库存批次、采购单、生产工单。 |
|
||||
| 这批库存去了哪里? | 出库类型、销售出库、生产出库、报废出库等。 |
|
||||
| 谁在什么时候操作? | 流水时间和操作记录。 |
|
||||
| 库存为什么增加或减少? | 流水类型和变动数量/重量。 |
|
||||
|
||||
## 11. 盘库
|
||||
|
||||
盘库用于把系统库存与真实仓库清点结果对齐。
|
||||
|
||||
入口:
|
||||
|
||||
```text
|
||||
嘉恒仓库 -> 选择仓库 -> 盘库
|
||||
```
|
||||
|
||||
盘库规则:
|
||||
|
||||
```text
|
||||
当前选中哪个仓库,就盘哪个仓库。
|
||||
盘库期间该仓库会锁库,不能继续出入库。
|
||||
```
|
||||
|
||||
盘库流程:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["选择仓库"] --> B["点击盘库"]
|
||||
B --> C["确认盘库并导出仓库清单"]
|
||||
C --> D["系统锁定当前仓库"]
|
||||
D --> E["用户按 Excel 清单清点真实仓库"]
|
||||
E --> F["用户修改 Excel 后导入"]
|
||||
F --> G["系统校验是否为本次盘库清单"]
|
||||
G --> H{"校验通过"}
|
||||
H --> I["弹窗展示左右差异对比"]
|
||||
H --> J["拒绝导入并提示原因"]
|
||||
I --> K{"用户确认盘库"}
|
||||
K --> L["按清单重置库存"]
|
||||
K --> M["取消,继续修改后重新导入"]
|
||||
L --> N["写入盘库记录和调整流水"]
|
||||
N --> O["解锁仓库"]
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- 只能导入本次盘库导出的清单。
|
||||
- 清单内有盘库单号校验标志。
|
||||
- 空库盘库导出为空、导入为空时,确认后只解锁仓库,不生成库存调整流水。
|
||||
- 差异对比确认前,不会重置库存。
|
||||
|
||||
## 12. 仓库常见问题
|
||||
|
||||
### 12.1 为什么原材料库没有数量?
|
||||
|
||||
当前业务规则是原材料一律按重量管理。采购、库存、生产出库、余料归还和报废都围绕 kg 进行。
|
||||
|
||||
### 12.2 为什么每次采购入库都生成新库存批次?
|
||||
|
||||
这样可以清楚追踪不同到货批次的单价、采购订单、入库时间和后续使用情况。即使同一个采购单分多批到货,也应形成不同库存批次。
|
||||
|
||||
### 12.3 为什么生产余料入库不是新批次?
|
||||
|
||||
余料是从原库存批次领出去后剩余归还,因此业务上应回到原来源库存批次,而不是生成新库存批次。
|
||||
|
||||
### 12.4 为什么半成品不是物料?
|
||||
|
||||
半成品是某个产品完成了部分工序后的状态。它必须带产品和已完成工序,否则无法判断后续该继续哪道工序。
|
||||
|
||||
### 12.5 为什么成品库以数量为主?
|
||||
|
||||
成品销售和发货是按件数履约,重量只是辅助显示或成本参考。销售出库扣减的是成品数量。
|
||||
|
||||
### 12.6 仓库被锁了怎么办?
|
||||
|
||||
先检查是否有正在进行的盘库单。盘库确认或取消后,仓库才会解锁。不要绕过盘库流程直接改库存。
|
||||
|
||||
## 13. 仓库每日建议检查清单
|
||||
|
||||
- 检查原材料库可用库存批次是否充足。
|
||||
- 检查是否有待质检到货未放行。
|
||||
- 检查生产出库是否已生成对应生产工单。
|
||||
- 检查生产余料是否及时归还到来源库存批次。
|
||||
- 检查成品生产入库是否与工单报工匹配。
|
||||
- 检查销售出库是否已填写运单号或辅助照片。
|
||||
- 检查废料入库和售卖出库是否有完整流水。
|
||||
- 检查退货库是否有长期未返工的退货品。
|
||||
- 定期进行盘库,确保系统库存和真实库存一致。
|
||||
Reference in New Issue
Block a user