统一仓库纸质单据体验
This commit is contained in:
parent
fc39d81564
commit
453b19399b
33
PRODUCT.md
Normal file
33
PRODUCT.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Product
|
||||||
|
|
||||||
|
## Register
|
||||||
|
|
||||||
|
product
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
嘉恒五金 ERP 面向采购、销售、仓库、生产、财务、售后和管理人员。用户通常在真实业务现场或办公室中处理订单、入库、出库、生产台账、发货、权限和经营数据,需要快速、准确、少解释地完成工作。
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
系统用于把五金制造企业的采购、销售、仓储、生产、报工、发货、售后、财务和系统管理流程串联起来。成功的界面应该让业务人员感觉是在填写正式单据、查看可信台账、执行明确动作,而不是在操作一个临时 demo。
|
||||||
|
|
||||||
|
## Brand Personality
|
||||||
|
|
||||||
|
可靠、利落、工业质感。界面可以有五金制造业的材料感和秩序感,但不能为了装饰牺牲效率、阅读清晰度和业务可信度。
|
||||||
|
|
||||||
|
## Anti-references
|
||||||
|
|
||||||
|
不要 demo 感强的卡片拼贴、解释大框、老式 Web 表单混入正式单据、按钮文案词不达意、状态无颜色区分、表格列名错位、值把表格撑到难以操作、抽屉内部滚动条阻断整页滚动。
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. 业务动作要像正式单据,不像随手填卡片。
|
||||||
|
2. 表格和台账优先服务核对效率,字段名清楚,长值可查看全量但不破坏布局。
|
||||||
|
3. 状态、风险和动作要有明确视觉区分,避免所有按钮和状态一个颜色。
|
||||||
|
4. 系统解释应收敛到需要时出现,不能常驻占用主要工作空间。
|
||||||
|
5. 视觉升级必须保留原有业务字段和流程语义,装修不改业务骨架。
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
默认按生产型后台工具处理:文字对比度要足够,交互目标不能过小,键盘焦点可见,动画只用于状态反馈并尊重 reduced-motion。中文业务字段、系统状态和备注优先使用中文。
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,212 @@
|
|||||||
|
# Warehouse Paper Form Unification Phase 1 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 warehouse operation documents use one cohesive paper-form visual language, starting with the production-ledger controlled inbound forms that currently mix modern web controls into the paper sheet.
|
||||||
|
|
||||||
|
**Architecture:** Keep all business state, computed values, validation, and submit payloads in `InventoryLedgerView.vue` unchanged. Introduce paper-specific wrappers/classes around the existing controls so selectors, selected-lot tables, live summaries, remarks, and settle controls visually belong to the same document grid as the approved “客料入库单”. Avoid global restyling of `.form-field`, `.double-field`, `.table-wrap`, or shared controls used outside paper documents.
|
||||||
|
|
||||||
|
**Tech Stack:** Vue 3 SFC, Vite, project CSS in `frontend/src/styles/main.css`, Node built-in test runner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `frontend/src/views/InventoryLedgerView.vue`: convert the generic warehouse drawer’s production-ledger controlled inbound branch to paper-specific markup while preserving existing bindings and branch conditions.
|
||||||
|
- Modify `frontend/src/styles/main.css`: add reusable paper document classes for control grids, inline summary rows, source-lot selector/table panels, and settle confirmation rows.
|
||||||
|
- Modify `frontend/src/views/InventoryLedgerView.test.js`: add regression coverage that production surplus/scrap inbound forms use the paper-specific classes and that the old naked modern classes are not the only visual contract.
|
||||||
|
|
||||||
|
## Design Direction
|
||||||
|
|
||||||
|
- Aesthetic: “工业纸质单据”.
|
||||||
|
- Purpose: Support fast warehouse entry while making each operation feel like a formal business document, not a demo web form.
|
||||||
|
- Differentiation anchor: Thick ledger grid lines, label/value cells, stamped meta strips, and paper-native control panels.
|
||||||
|
- DFII: 13. Impact 4, fit 5, feasibility 5, performance 4, consistency risk 5. This is strong because it reuses the approved客料入库 visual system, but must be scoped to avoid touching unrelated app forms.
|
||||||
|
|
||||||
|
## Task 1: Lock The Regression Tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
|
||||||
|
- Read: `frontend/src/views/InventoryLedgerView.vue`
|
||||||
|
- Read: `frontend/src/styles/main.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add paper-form assertions for production-ledger controlled inbound**
|
||||||
|
|
||||||
|
Add a test that checks the generic drawer contains production-ledger paper classes and keeps the old branch visually wrapped:
|
||||||
|
|
||||||
|
```js
|
||||||
|
it("wraps production-ledger controlled inbound forms in the shared paper control language", () => {
|
||||||
|
assert.match(source, /warehouse-paper-control-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(source, /warehouse-paper-inline-summary/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-toggle/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-remark/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-control-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-control-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-settle-toggle/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test and confirm it fails before implementation**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
node --test src/views/InventoryLedgerView.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected before implementation: failure mentioning one or more missing `warehouse-paper-*` classes.
|
||||||
|
|
||||||
|
## Task 2: Convert Production Controlled Inbound Markup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/views/InventoryLedgerView.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the naked production work-order block**
|
||||||
|
|
||||||
|
Replace the `isProductionWorkOrderControlledInbound` `stack-form` block with paper-grid fields:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="isProductionWorkOrderControlledInbound" class="warehouse-paper-control-grid warehouse-paper-control-grid-compact">
|
||||||
|
<label class="warehouse-paper-field warehouse-paper-field-wide">
|
||||||
|
<span>{{ isReworkScrapInbound ? "返工工单" : "生产台账" }}</span>
|
||||||
|
<select v-model.number="genericForm.work_order_id" required @change="applyGenericWorkOrder">
|
||||||
|
<option disabled value="">{{ isReworkScrapInbound ? "请选择返工工单" : "请选择生产台账" }}</option>
|
||||||
|
<option v-for="workOrder in genericWorkOrderOptions" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
|
||||||
|
{{ formatWorkOrderLedgerOption(workOrder, activeOperation?.bizType) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<template v-if="selectedGenericWorkOrder">
|
||||||
|
<article class="warehouse-paper-summary-card warehouse-paper-summary-card-wide">
|
||||||
|
<span>产品</span>
|
||||||
|
<strong>{{ selectedGenericWorkOrder.product_code }} · {{ selectedGenericWorkOrder.product_name }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="warehouse-paper-summary-card">
|
||||||
|
<span>材料库存批次号</span>
|
||||||
|
<strong>{{ selectedGenericWorkOrder.source_lot_summary || selectedGenericWorkOrder.material_lot_no || "-" }}</strong>
|
||||||
|
</article>
|
||||||
|
<article v-if="isProductionScrapInbound" class="warehouse-paper-summary-card">
|
||||||
|
<span>库外未闭环重量</span>
|
||||||
|
<strong>{{ formatWeight(selectedGenericWorkOrder.outside_weight_kg || selectedGenericWorkOrder.limits?.max_scrap_inbound_weight_kg) }}</strong>
|
||||||
|
</article>
|
||||||
|
<article v-if="isProductionSurplusInbound" class="warehouse-paper-summary-card">
|
||||||
|
<span>库外未闭环重量</span>
|
||||||
|
<strong>{{ formatWeight(selectedGenericWorkOrder.outside_weight_kg || selectedGenericWorkOrder.limits?.max_surplus_inbound_weight_kg) }}</strong>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Convert the core fields to a paper grid**
|
||||||
|
|
||||||
|
Replace the main `double-field` and `triple-field` groups in the generic drawer with `warehouse-paper-control-grid`, using the existing exact conditions and v-models. Keep field order: item, warehouse, location, measure basis, weight, quantity, unit cost.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wrap source-lot selector and tables in paper panels**
|
||||||
|
|
||||||
|
Keep `StockLotTagSelect` unchanged, but place it inside:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<div v-if="usesGenericSourceLotSelector" class="warehouse-paper-source-lot-panel">
|
||||||
|
<div class="warehouse-paper-source-lot-heading">
|
||||||
|
<span>来源库存批次号</span>
|
||||||
|
<strong>{{ genericSourceLotSelectPlaceholder }}</strong>
|
||||||
|
</div>
|
||||||
|
<StockLotTagSelect ... />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
For selected lot tables, keep the same columns and inputs but add `warehouse-paper-source-lot-table` to the wrapping table container so CSS can remove the ordinary web-card feel.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Convert summaries and settle controls**
|
||||||
|
|
||||||
|
Replace `inventory-live-summary` display with `warehouse-paper-inline-summary`, and replace `work-order-settle-panel` / `work-order-settle-toggle` with paper-specific classes. Keep `genericForm.settle_work_order`, deviation values, and `genericForm.settle_remark` unchanged.
|
||||||
|
|
||||||
|
## Task 3: Add Paper-Specific CSS
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/styles/main.css`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add grid spanning rules**
|
||||||
|
|
||||||
|
Add the new paper containers beside existing `.warehouse-paper-grid` rules:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-control-grid,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-source-lot-panel,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-source-lot-table,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-inline-summary,
|
||||||
|
.warehouse-document-section-grid > .warehouse-paper-settle-panel {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reuse approved paper field and summary cells**
|
||||||
|
|
||||||
|
Make `.warehouse-paper-control-grid` visually match `.warehouse-paper-grid`, with four columns on desktop, two on tablet, and one on mobile. Make `.warehouse-paper-control-grid-compact` allow summary cards to live in the same grid.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Style selector/table/settle panels as document rows**
|
||||||
|
|
||||||
|
Style:
|
||||||
|
|
||||||
|
```css
|
||||||
|
.warehouse-paper-source-lot-panel
|
||||||
|
.warehouse-paper-source-lot-heading
|
||||||
|
.warehouse-paper-source-lot-table
|
||||||
|
.warehouse-paper-inline-summary
|
||||||
|
.warehouse-paper-settle-panel
|
||||||
|
.warehouse-paper-settle-toggle
|
||||||
|
.warehouse-paper-settle-grid
|
||||||
|
.warehouse-paper-settle-remark
|
||||||
|
```
|
||||||
|
|
||||||
|
They should use document borders, paper backgrounds, Songti/Simsun value text where appropriate, and no floating modern-card shadows.
|
||||||
|
|
||||||
|
## Task 4: Verify
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `frontend/src/views/InventoryLedgerView.test.js`
|
||||||
|
- Build: `frontend/package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused view tests**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
node --test src/views/InventoryLedgerView.test.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run production build**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: Vite build succeeds. Existing chunk-size warning is acceptable if it remains the same non-blocking warning.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Browser verification**
|
||||||
|
|
||||||
|
Open `http://localhost:5173/inventory-ledger`, then inspect:
|
||||||
|
|
||||||
|
- 原材料库 · 客料入库 remains unchanged and approved.
|
||||||
|
- 原材料库 · 生产余料入库 uses the same paper-grid visual language.
|
||||||
|
- The source-lot multi-select, selected-lot table, remark field, and “该批材料余料结单” no longer look like separate modern web cards pasted onto paper.
|
||||||
|
- Existing submit button and drawer behavior still work.
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- Spec coverage: covers the user’s requested recommended approach by converting the most visible mixed-UI warehouse document first and creating reusable paper classes for subsequent warehouse forms.
|
||||||
|
- Placeholder scan: no TBD/TODO/implement-later placeholders.
|
||||||
|
- Type consistency: class names in tests, markup, and CSS use the same `warehouse-paper-*` prefix.
|
||||||
@ -0,0 +1,341 @@
|
|||||||
|
# 全系统纸质单据统一改造设计
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
系统已经开始从普通后台表单向正式业务单据升级:销售订单、采购订单已经接入 `DocumentPaper` 纸质单据和 PDF 归档;仓库中的 `原材料库 · 客料入库单` 已被用户认可为“模板级别”;`生产余料入库单 / 生产废料入库单` 第一阶段也已经开始改成同一套纸质单据语言。
|
||||||
|
|
||||||
|
用户明确补充:这不是仓库专属改造,而是全系统所有“单据型业务”都要采用统一纸质单据体系。为了避免仓库、采购、销售、生产各自长出不同样式,本设计把目标升级为“全系统业务单据纸质化 + PDF 归档统一体系”。
|
||||||
|
|
||||||
|
## 单据边界
|
||||||
|
|
||||||
|
### 纳入纸质化的对象
|
||||||
|
|
||||||
|
满足任一条件就纳入:
|
||||||
|
|
||||||
|
- 会创建正式业务凭证:销售订单、采购订单、到货入库单、质量校验单、发货单、退货/返工单。
|
||||||
|
- 会改变库存或形成库存流水:各库入库、出库、特殊调整、盘库。
|
||||||
|
- 会形成生产追溯闭环:生产出库、生产台账入库、生产入库结算、生产领料出库。
|
||||||
|
- 用户后续需要预览、下载、批量下载或审计留档。
|
||||||
|
|
||||||
|
### 不纳入纸质化的对象
|
||||||
|
|
||||||
|
这些仍按系统管理/主数据维护表单处理,不强行改成纸质单据:
|
||||||
|
|
||||||
|
- 供应商名录、客户名录、人员管理、设备管理。
|
||||||
|
- 原材料名录、产品管理、产品需规清单、BOM、工艺路线。
|
||||||
|
- 系统权限、系统拓展、导入工具、基础配置。
|
||||||
|
|
||||||
|
原因:这些是主数据或配置记录,不是业务凭证。强行纸质化会降低维护效率,也会把“单据”的正式感稀释掉。
|
||||||
|
|
||||||
|
## 现状清单
|
||||||
|
|
||||||
|
### 已基本纸质化且已有 PDF 归档
|
||||||
|
|
||||||
|
| 模块 | 页面 | 单据 | 前端状态 | 后端归档类型 | 备注 |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| 订单与计划 | `SalesPlanningView.vue` | 销售订单 | 使用 `DocumentPaper`、`DocumentLineTable`、`DocumentArchiveActions` | `销售订单` | 已有预览、下载、重新生成、批量下载 |
|
||||||
|
| 采购与库存 | `PurchaseOrderView.vue` | 采购订单 | 使用 `DocumentPaper`、`DocumentArchiveActions` | `采购订单` | 已有预览、下载、重新生成、批量下载 |
|
||||||
|
| 采购与库存 | `PurchaseReceiptView.vue` | 到货入库单 | 使用 `DocumentPaper`、`DocumentArchiveActions` | `到货入库单` | 新增到货入库已纸质化 |
|
||||||
|
| 采购与库存 | `QualityInspectionView.vue` | 质量校验单 | 使用 `DocumentPaper`、`DocumentLineTable`、`DocumentArchiveActions` | `质量校验单` | 质检放行弹窗仍需继续统一交互 |
|
||||||
|
| 生产执行 | `ProductionLedgerView.vue` | 生产领料出库单 | 列表详情有 `DocumentArchiveActions` | `生产领料出库单` | 主要是归档入口,填单发生在仓库生产出库 |
|
||||||
|
| 生产执行 | `ProductionLedgerView.vue` | 生产入库结算单 | 列表详情有 `DocumentArchiveActions` | `生产入库结算单` | 对应生产台账入库 |
|
||||||
|
|
||||||
|
### 已套纸质外壳但仍有混用 UI
|
||||||
|
|
||||||
|
| 模块 | 页面 | 单据 | 当前问题 | 优先级 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 特殊入库单 / 特殊出库单 | 明细编辑区仍像现代卡片控件,和纸质单据割裂 | P0 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产出库单 | 已使用纸质外壳,但来源批次选择、产品/BOM信息仍需统一纸质表格语言 | P0 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产台账入库结算单 | 已有纸质外壳和摘要格,但三库联动字段、异常说明区域仍需与纸质单据模板对齐 | P0 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 生产入库单 / 返工入库单 | 结单区和摘要区仍有旧卡片感 | P1 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 销售出库单 | 已套纸质外壳,但销售订单关联、包装备注、发货明细区域需统一 | P1 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 原材料退货出库单、退货入库单、退货废料入库单、返工出库单 | 已套纸质外壳,但字段区仍有普通表单/表格痕迹 | P1 |
|
||||||
|
| 嘉恒仓库 | `InventoryLedgerView.vue` | 期初入库、委外入库/出库、报废出库、售卖出库等通用分支 | 通用分支第一阶段只统一了生产余料/废料一部分,仍需逐个验收 | P1 |
|
||||||
|
|
||||||
|
### 需要补充纸质化或确认是否作为单据
|
||||||
|
|
||||||
|
| 模块 | 页面 | 业务 | 判断 | 优先级 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 发货概览 | `DeliveryManagementView.vue` | 发货台账/发货明细 | 如果存在独立发货动作,应形成发货单;若发货统一走成品库销售出库,则只保留流水归档入口 | P1 |
|
||||||
|
| 发货概览 | `ReturnManagementView.vue` | 退货/返工/废料处置 | 属于业务凭证,需要与退货库单据闭环统一 | P1 |
|
||||||
|
| 发货概览 | `ScrapReworkView.vue` | 报废返工处理 | 若仍在使用,应按退货/返工处置单统一;若已被仓库/退货库替代,应清理或降级为台账 | P2 |
|
||||||
|
| 生产执行 | `CompletionReceiptView.vue` | 成品入库 | 旧入口如果仍可用,需要和仓库生产入库/生产台账入库统一;如果已被新流程替代,应标记为旧入口 | P2 |
|
||||||
|
| 嘉恒仓库 | `StocktakeDialog.vue` | 盘库单 | 盘库会改库存,应纳入纸质盘库单和 PDF 归档,但可排在特殊出入库之后 | P2 |
|
||||||
|
|
||||||
|
## 推荐方案
|
||||||
|
|
||||||
|
### 方案 A:继续逐页局部改造
|
||||||
|
|
||||||
|
每个页面自己套 `DocumentPaper` 和单独 CSS。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 单次改动小。
|
||||||
|
- 速度快。
|
||||||
|
|
||||||
|
缺点:
|
||||||
|
|
||||||
|
- 很容易再次出现“仓库一套、采购一套、销售一套”。
|
||||||
|
- 样式复制越来越多。
|
||||||
|
- 后续 PDF、预览、下载、异常说明、签核栏会重复实现。
|
||||||
|
|
||||||
|
结论:不推荐作为长期方案,只适合救急。
|
||||||
|
|
||||||
|
### 方案 B:沉淀全系统单据底座,分批迁移
|
||||||
|
|
||||||
|
把现在的 `DocumentPaper`、`DocumentGrid`、`DocumentLineTable`、`WarehouseDocumentFormShell` 抽象成全系统单据组件。仓库继续推进,但所有新样式都进入通用单据层。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 风格统一。
|
||||||
|
- 后续新增单据成本低。
|
||||||
|
- 能保留现有业务逻辑,不需要一次性大重构。
|
||||||
|
- 适合当前系统已经上线、仍在快速迭代的状态。
|
||||||
|
|
||||||
|
缺点:
|
||||||
|
|
||||||
|
- 第一批需要先做组件抽象和兼容层。
|
||||||
|
- 每个单据仍要逐个检查字段和业务交互。
|
||||||
|
|
||||||
|
结论:推荐。
|
||||||
|
|
||||||
|
### 方案 C:一次性全系统重构
|
||||||
|
|
||||||
|
一次把所有单据型页面、归档、PDF 重新设计和重写。
|
||||||
|
|
||||||
|
优点:
|
||||||
|
|
||||||
|
- 最终一致性最高。
|
||||||
|
|
||||||
|
缺点:
|
||||||
|
|
||||||
|
- 风险大。
|
||||||
|
- 容易影响已经可用的采购、销售、仓库主流程。
|
||||||
|
- 验收压力大。
|
||||||
|
|
||||||
|
结论:当前不推荐。
|
||||||
|
|
||||||
|
## 目标架构
|
||||||
|
|
||||||
|
### 前端组件层
|
||||||
|
|
||||||
|
保留现有组件并向通用命名演进:
|
||||||
|
|
||||||
|
- `DocumentPaper`:纸张、标题、编号章、签核栏、留存联。
|
||||||
|
- `DocumentGrid`:只读/展示型字段格。
|
||||||
|
- `DocumentLineTable`:明细表。
|
||||||
|
- `DocumentArchiveActions`:预览、下载、重新生成。
|
||||||
|
|
||||||
|
新增或演进:
|
||||||
|
|
||||||
|
- `DocumentFormShell`:全系统通用填单外壳,替代仓库专属 `WarehouseDocumentFormShell`。
|
||||||
|
- `DocumentSection`:通用单据分区,替代仓库专属 `WarehouseDocumentSection`。
|
||||||
|
- `DocumentActionBar`:通用底部操作区,替代仓库专属 `WarehouseDocumentActionBar`。
|
||||||
|
- `DocumentField`:纸质输入格,承载 input、select、textarea、readonly。
|
||||||
|
- `DocumentSelectorPanel`:纸质风格的下拉、多选、来源批次选择、关联订单选择。
|
||||||
|
- `DocumentInlineSummary`:纸质风格的金额/数量/重量汇总条。
|
||||||
|
- `DocumentSettlePanel`:结单、异常说明、偏差确认等正式确认区。
|
||||||
|
|
||||||
|
兼容规则:
|
||||||
|
|
||||||
|
- 第一阶段不删除 `WarehouseDocument*` 组件。
|
||||||
|
- 新通用组件先复用当前纸质 CSS 变量和视觉语言。
|
||||||
|
- 仓库页面逐步从 `warehouse-paper-*` 类迁移到 `document-paper-*` 类。
|
||||||
|
- 老类名保留一段时间作为兼容,避免正在使用的仓库单据被打断。
|
||||||
|
|
||||||
|
### 前端页面层
|
||||||
|
|
||||||
|
每个单据页面遵循同一结构:
|
||||||
|
|
||||||
|
1. `DocumentFormShell`
|
||||||
|
2. 单据头:标题、单据编号、业务日期、公司/模块名。
|
||||||
|
3. 基础信息区:客户/供应商/仓库/经办人/业务日期/关联单据。
|
||||||
|
4. 明细区:物料/产品/批次/数量/重量/单价/金额。
|
||||||
|
5. 辅助信息区:照片、运单号、运费、包装备注、用途、说明。
|
||||||
|
6. 汇总区:数量、重量、金额、差异、异常说明。
|
||||||
|
7. 签核区:制单、经办、审核、仓库/财务。
|
||||||
|
8. 操作区:保存、保存并归档、归档状态动作。
|
||||||
|
|
||||||
|
### 后端归档层
|
||||||
|
|
||||||
|
继续使用现有 `document_archives` 表和统一路由:
|
||||||
|
|
||||||
|
- `POST /document-archives/{document_type_key}/{business_id}/generate`
|
||||||
|
- `GET /document-archives/{document_type_key}/{business_id}/latest/preview`
|
||||||
|
- `GET /document-archives/{document_type_key}/{business_id}/latest/download`
|
||||||
|
- `POST /document-archives/batch-download`
|
||||||
|
|
||||||
|
现有支持的归档类型:
|
||||||
|
|
||||||
|
- `销售订单`
|
||||||
|
- `采购订单`
|
||||||
|
- `到货入库单`
|
||||||
|
- `质量校验单`
|
||||||
|
- `生产领料出库单`
|
||||||
|
- `生产入库结算单`
|
||||||
|
- `仓库出入库单`
|
||||||
|
|
||||||
|
新增归档类型只在确实不是现有类型能表达时添加。优先复用:
|
||||||
|
|
||||||
|
- 普通仓库动作统一走 `仓库出入库单`。
|
||||||
|
- 生产出库走 `生产领料出库单`。
|
||||||
|
- 生产台账入库走 `生产入库结算单`。
|
||||||
|
- 采购流程走 `采购订单 / 到货入库单 / 质量校验单`。
|
||||||
|
- 销售下单走 `销售订单`。
|
||||||
|
|
||||||
|
### PDF 规则
|
||||||
|
|
||||||
|
PDF 是保存后的数据库事实快照,不从前端表单二次推导。
|
||||||
|
|
||||||
|
PDF 必须遵守:
|
||||||
|
|
||||||
|
- 不显示英文状态。
|
||||||
|
- 不显示前端省略号。
|
||||||
|
- 长文本完整换行,不超格。
|
||||||
|
- 制单、经办人默认使用当前登录账号或后端记录的操作人。
|
||||||
|
- 库存批次号使用业务认可的原材料库存批次号,不使用无业务意义的每库内部批次号。
|
||||||
|
- PDF 样式和前端纸质单据属于同一视觉体系,但不要求像素完全一致。
|
||||||
|
|
||||||
|
## 分期执行
|
||||||
|
|
||||||
|
### 第 0 期:全系统清单和统一底座
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 固化本文档的单据清单。
|
||||||
|
- 新增通用 `DocumentFormShell / DocumentSection / DocumentActionBar / DocumentField / DocumentSelectorPanel`。
|
||||||
|
- 把仓库当前纸质样式抽象为通用 `document-paper-*`,保留 `warehouse-paper-*` 兼容层。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 销售订单、采购订单、客料入库单视觉不退化。
|
||||||
|
- 新通用组件有基础测试或源码断言。
|
||||||
|
|
||||||
|
### 第 1 期:仓库 P0 单据
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 特殊入库 / 特殊出库。
|
||||||
|
- 生产出库。
|
||||||
|
- 生产台账入库结算单。
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 这些单据业务风险最高,且当前混用 UI 最明显。
|
||||||
|
- 改完能沉淀“多行明细类单据”“生产闭环类单据”“异常说明类单据”三类模板。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 不再出现普通卡片/普通表格突兀嵌在纸里。
|
||||||
|
- 特殊出入库说明、明细行、调整前后数据完整显示。
|
||||||
|
- 生产台账入库三库联动显示为一张结算单,不误写成只属于成品库。
|
||||||
|
|
||||||
|
### 第 2 期:仓库 P1 单据
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 生产入库 / 返工入库。
|
||||||
|
- 销售出库。
|
||||||
|
- 原材料退货出库、退货入库、退货废料入库、返工出库。
|
||||||
|
- 期初、委外、报废、售卖等通用分支。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 6 大库所有出入库抽屉都统一纸质化。
|
||||||
|
- 流水里 PDF 预览、下载、重新生成入口完整。
|
||||||
|
|
||||||
|
### 第 3 期:采购、销售、质检细节统一
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 销售订单、采购订单、到货入库、质量校验页面继续消除残留普通控件。
|
||||||
|
- 关联销售订单、采购明细、质检备注弹窗等改成统一纸质选择/说明面板。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 已有 PDF 归档能力不回退。
|
||||||
|
- 纸质字段、明细表、按钮区和仓库单据保持同一套视觉语言。
|
||||||
|
|
||||||
|
### 第 4 期:发货、退货、返工闭环
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 发货台账按销售订单聚合后的明细留档入口统一。
|
||||||
|
- 退货、返工、废料处置相关页面和退货库单据闭环。
|
||||||
|
- 若旧页面被新仓库流程替代,则标记为只读台账或移除入口,避免双流程。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 用户能从销售订单、发货台账、退货库流水追溯到同一套 PDF 单据。
|
||||||
|
|
||||||
|
### 第 5 期:盘库与批量导出
|
||||||
|
|
||||||
|
目标:
|
||||||
|
|
||||||
|
- 盘库单纸质化和 PDF 归档。
|
||||||
|
- 各业务台账按时间/筛选条件批量导出单据或流水 Excel。
|
||||||
|
|
||||||
|
验收:
|
||||||
|
|
||||||
|
- 盘库调整说明进入 PDF。
|
||||||
|
- Excel 导出不使用省略号,长字段完整输出。
|
||||||
|
|
||||||
|
## 测试策略
|
||||||
|
|
||||||
|
### 前端测试
|
||||||
|
|
||||||
|
每批至少增加源码断言或组件测试:
|
||||||
|
|
||||||
|
- 单据页面使用通用 `DocumentFormShell` 或兼容的纸质单据组件。
|
||||||
|
- 不在纸质单据主体中裸露旧 `form-field / double-field / triple-field / workflow-tip` 作为主要布局。
|
||||||
|
- 归档按钮使用 `DocumentArchiveActions`。
|
||||||
|
- 保存按钮位于单据底部操作区,不悬在单据中间。
|
||||||
|
|
||||||
|
### 后端测试
|
||||||
|
|
||||||
|
继续沿用现有归档测试风格:
|
||||||
|
|
||||||
|
- 归档生成成功写入 `document_archives`。
|
||||||
|
- PDF 文件以 `%PDF` 开头。
|
||||||
|
- 归档失败不阻断业务保存。
|
||||||
|
- 列表返回最新 PDF 归档状态。
|
||||||
|
- 不同业务类型映射到正确 `document_type`。
|
||||||
|
|
||||||
|
### 浏览器验收
|
||||||
|
|
||||||
|
每批至少手动/浏览器验证:
|
||||||
|
|
||||||
|
- 典型桌面宽度。
|
||||||
|
- 当前内置浏览器窄视口。
|
||||||
|
- 单据可滚动、按钮位置合理。
|
||||||
|
- 长文本不挤坏单据。
|
||||||
|
- PDF 预览/下载入口可见。
|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
- 纸质化是表现层和归档层升级,不借机删字段。
|
||||||
|
- 不改变现有业务计算和提交语义。
|
||||||
|
- 不把主数据维护表单改成纸质单据。
|
||||||
|
- 不把 PDF 归档失败作为库存/订单保存失败。
|
||||||
|
- 不新增英文状态、英文备注或英文系统标志。
|
||||||
|
- 不在 PDF 中使用省略号。
|
||||||
|
- 不把仓库特有样式继续扩散到全系统,新增能力进入通用 `documentForms`。
|
||||||
|
|
||||||
|
## 下一步推荐
|
||||||
|
|
||||||
|
推荐进入第 0 期 + 第 1 期合并执行:
|
||||||
|
|
||||||
|
1. 先抽象通用单据底座,保留现有仓库兼容层。
|
||||||
|
2. 接着改 `特殊入库 / 特殊出库`。
|
||||||
|
3. 再改 `生产出库`。
|
||||||
|
4. 再改 `生产台账入库结算单`。
|
||||||
|
|
||||||
|
这样能最快解决用户当前肉眼最不舒服的仓库混 UI 问题,同时保证做出来的能力可以继续给采购、销售、质检、发货复用。
|
||||||
|
|
||||||
|
## 自检
|
||||||
|
|
||||||
|
- 无占位项。
|
||||||
|
- 已明确哪些页面纳入,哪些页面不纳入。
|
||||||
|
- 已避免把仓库纸质化做成孤岛。
|
||||||
|
- 已给出分期和优先级。
|
||||||
|
- 已保持“先视觉/交互统一,不改变业务逻辑”的边界。
|
||||||
53
frontend/src/components/documentForms/DocumentActionBar.vue
Normal file
53
frontend/src/components/documentForms/DocumentActionBar.vue
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<template>
|
||||||
|
<span ref="anchorRef" class="document-action-anchor" aria-hidden="true"></span>
|
||||||
|
|
||||||
|
<Teleport :to="actionHost" :disabled="!actionHost">
|
||||||
|
<div
|
||||||
|
class="document-action-bar"
|
||||||
|
:class="[
|
||||||
|
$attrs.class,
|
||||||
|
{ 'document-action-bar--with-state': $slots.state }
|
||||||
|
]"
|
||||||
|
@click.capture="handleActionClick"
|
||||||
|
>
|
||||||
|
<div v-if="$slots.state" class="document-action-state">
|
||||||
|
<slot name="state" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="document-action-buttons">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { nextTick, onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const anchorRef = ref(null);
|
||||||
|
const actionHost = ref(null);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
const form = anchorRef.value?.closest?.("form");
|
||||||
|
actionHost.value = form?.querySelector?.("[data-document-action-host]") || form?.querySelector?.("[data-warehouse-document-action-host]") || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleActionClick(event) {
|
||||||
|
const target = event.target;
|
||||||
|
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
|
||||||
|
if (!submitter || submitter.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const form = anchorRef.value?.closest?.("form");
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
55
frontend/src/components/documentForms/DocumentField.vue
Normal file
55
frontend/src/components/documentForms/DocumentField.vue
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<component
|
||||||
|
:is="as"
|
||||||
|
class="document-paper-field"
|
||||||
|
:class="[
|
||||||
|
spanClass,
|
||||||
|
{
|
||||||
|
'document-paper-field-readonly': readonly,
|
||||||
|
'document-paper-field-upload': upload
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<slot>
|
||||||
|
<p class="document-paper-readonly-value">{{ value || "-" }}</p>
|
||||||
|
</slot>
|
||||||
|
</component>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
span: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 1
|
||||||
|
},
|
||||||
|
as: {
|
||||||
|
type: String,
|
||||||
|
default: "label"
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
upload: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const spanClass = computed(() => {
|
||||||
|
if (String(props.span) === "full") return "document-paper-field-full";
|
||||||
|
if (Number(props.span) === 2) return "document-paper-field-wide";
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
69
frontend/src/components/documentForms/DocumentFormShell.vue
Normal file
69
frontend/src/components/documentForms/DocumentFormShell.vue
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
<template>
|
||||||
|
<form class="document-form-shell" @submit.prevent="emit('submit')">
|
||||||
|
<DocumentPaper
|
||||||
|
:title="title"
|
||||||
|
:document-no="documentNo"
|
||||||
|
:company-name="companyName"
|
||||||
|
:tone="tone"
|
||||||
|
:signature-labels="signatureLabels"
|
||||||
|
>
|
||||||
|
<div class="document-form-meta" :aria-label="metaAriaLabel">
|
||||||
|
<span>{{ metaLabel }}</span>
|
||||||
|
<strong>{{ companyName }}</strong>
|
||||||
|
<span>{{ dateLabel }}</span>
|
||||||
|
<strong>{{ displayBusinessDate }}</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="document-form-body">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</DocumentPaper>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
import DocumentPaper from "./DocumentPaper.vue";
|
||||||
|
import { formatChineseDate } from "../../utils/formatters";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
documentNo: {
|
||||||
|
type: String,
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
businessDate: {
|
||||||
|
type: [String, Date],
|
||||||
|
default: ""
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
type: String,
|
||||||
|
default: "green"
|
||||||
|
},
|
||||||
|
companyName: {
|
||||||
|
type: String,
|
||||||
|
default: "宁波嘉恒智能科技有限公司"
|
||||||
|
},
|
||||||
|
metaLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "业务联单"
|
||||||
|
},
|
||||||
|
dateLabel: {
|
||||||
|
type: String,
|
||||||
|
default: "业务日期"
|
||||||
|
},
|
||||||
|
signatureLabels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ["制单", "经办", "审核", "财务"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["submit"]);
|
||||||
|
|
||||||
|
const displayBusinessDate = computed(() => formatChineseDate(props.businessDate));
|
||||||
|
const metaAriaLabel = computed(() => `${props.metaLabel}基础信息`);
|
||||||
|
</script>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div class="document-inline-summary" :class="toneClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
tone: {
|
||||||
|
type: String,
|
||||||
|
default: "green"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const toneClass = computed(() => `document-inline-summary-${props.tone}`);
|
||||||
|
</script>
|
||||||
@ -43,7 +43,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<div class="document-paper-action-host" data-warehouse-document-action-host></div>
|
<div
|
||||||
|
class="document-paper-action-host"
|
||||||
|
data-document-action-host
|
||||||
|
data-warehouse-document-action-host
|
||||||
|
></div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
25
frontend/src/components/documentForms/DocumentSection.vue
Normal file
25
frontend/src/components/documentForms/DocumentSection.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<template>
|
||||||
|
<section class="document-section">
|
||||||
|
<header class="document-section-head">
|
||||||
|
<span class="document-section-index">{{ index }}</span>
|
||||||
|
<strong>{{ title }}</strong>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="document-section-grid">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
index: {
|
||||||
|
type: [Number, String],
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
<template>
|
||||||
|
<section class="document-selector-panel">
|
||||||
|
<div class="document-selector-heading">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<strong>{{ hint }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="document-selector-body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
type: String,
|
||||||
|
default: "请选择"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<section class="document-settle-panel">
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@ -1,47 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<span ref="anchorRef" class="warehouse-document-action-anchor" aria-hidden="true"></span>
|
<DocumentActionBar class="warehouse-document-action-bar warehouse-document-action-bar--shared">
|
||||||
|
<template v-if="$slots.state" #state>
|
||||||
<Teleport :to="actionHost" :disabled="!actionHost">
|
<slot name="state" />
|
||||||
<div
|
</template>
|
||||||
class="warehouse-document-action-bar warehouse-document-action-bar--shared"
|
<slot />
|
||||||
:class="{ 'warehouse-document-action-bar--with-state': $slots.state }"
|
</DocumentActionBar>
|
||||||
@click.capture="handleActionClick"
|
|
||||||
>
|
|
||||||
<div v-if="$slots.state" class="warehouse-document-action-state">
|
|
||||||
<slot name="state" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="warehouse-document-action-buttons">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, ref } from "vue";
|
import DocumentActionBar from "./DocumentActionBar.vue";
|
||||||
|
|
||||||
const anchorRef = ref(null);
|
|
||||||
const actionHost = ref(null);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick();
|
|
||||||
actionHost.value = anchorRef.value
|
|
||||||
?.closest?.("form")
|
|
||||||
?.querySelector?.("[data-warehouse-document-action-host]") || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleActionClick(event) {
|
|
||||||
const target = event.target;
|
|
||||||
const submitter = target?.closest?.("button[type='submit'], input[type='submit']");
|
|
||||||
if (!submitter || submitter.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const form = anchorRef.value?.closest?.("form");
|
|
||||||
if (!form) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,32 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<form class="warehouse-document-form" @submit.prevent="emit('submit')">
|
<DocumentFormShell
|
||||||
<DocumentPaper
|
class="warehouse-document-form"
|
||||||
:title="title"
|
:title="title"
|
||||||
:document-no="documentNo"
|
:document-no="documentNo"
|
||||||
:company-name="companyName"
|
:business-date="businessDate"
|
||||||
:tone="tone"
|
:tone="tone"
|
||||||
>
|
:company-name="companyName"
|
||||||
<div class="warehouse-document-form-meta" aria-label="仓库单据基础信息">
|
meta-label="仓库联单"
|
||||||
<span>仓库联单</span>
|
:signature-labels="signatureLabels"
|
||||||
<strong>{{ companyName }}</strong>
|
@submit="emit('submit')"
|
||||||
<span>业务日期</span>
|
>
|
||||||
<strong>{{ displayBusinessDate }}</strong>
|
<slot />
|
||||||
</div>
|
</DocumentFormShell>
|
||||||
|
|
||||||
<main class="warehouse-document-body">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</DocumentPaper>
|
|
||||||
</form>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import DocumentFormShell from "./DocumentFormShell.vue";
|
||||||
|
|
||||||
import DocumentPaper from "./DocumentPaper.vue";
|
defineProps({
|
||||||
import { formatChineseDate } from "../../utils/formatters";
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
@ -46,9 +37,12 @@ const props = defineProps({
|
|||||||
companyName: {
|
companyName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "嘉恒仓库"
|
default: "嘉恒仓库"
|
||||||
|
},
|
||||||
|
signatureLabels: {
|
||||||
|
type: Array,
|
||||||
|
default: () => ["制单", "审核", "仓库", "财务"]
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(["submit"]);
|
const emit = defineEmits(["submit"]);
|
||||||
const displayBusinessDate = computed(() => formatChineseDate(props.businessDate));
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -1,17 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="warehouse-document-section">
|
<DocumentSection
|
||||||
<header class="warehouse-document-section-head">
|
class="warehouse-document-section"
|
||||||
<span class="warehouse-document-section-index">{{ index }}</span>
|
:index="index"
|
||||||
<strong>{{ title }}</strong>
|
:title="title"
|
||||||
</header>
|
>
|
||||||
|
<slot />
|
||||||
<div class="warehouse-document-section-grid">
|
</DocumentSection>
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import DocumentSection from "./DocumentSection.vue";
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
index: {
|
index: {
|
||||||
type: [Number, String],
|
type: [Number, String],
|
||||||
|
|||||||
52
frontend/src/components/documentForms/documentForms.test.js
Normal file
52
frontend/src/components/documentForms/documentForms.test.js
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
function readComponent(name) {
|
||||||
|
return readFileSync(join(__dirname, name), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainCss = readFileSync(resolve(__dirname, "../../styles/main.css"), "utf8");
|
||||||
|
|
||||||
|
describe("document form foundation", () => {
|
||||||
|
it("provides generic system-wide paper form components", () => {
|
||||||
|
[
|
||||||
|
"DocumentFormShell.vue",
|
||||||
|
"DocumentSection.vue",
|
||||||
|
"DocumentActionBar.vue",
|
||||||
|
"DocumentField.vue",
|
||||||
|
"DocumentSelectorPanel.vue",
|
||||||
|
"DocumentInlineSummary.vue",
|
||||||
|
"DocumentSettlePanel.vue"
|
||||||
|
].forEach((name) => {
|
||||||
|
const source = readComponent(name);
|
||||||
|
assert.match(source, /document-/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps warehouse wrappers as compatibility adapters over generic document components", () => {
|
||||||
|
assert.match(readComponent("WarehouseDocumentFormShell.vue"), /import DocumentFormShell/);
|
||||||
|
assert.match(readComponent("WarehouseDocumentSection.vue"), /import DocumentSection/);
|
||||||
|
assert.match(readComponent("WarehouseDocumentActionBar.vue"), /import DocumentActionBar/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes a generic action host without removing the existing warehouse host", () => {
|
||||||
|
const paper = readComponent("DocumentPaper.vue");
|
||||||
|
assert.match(paper, /data-document-action-host/);
|
||||||
|
assert.match(paper, /data-warehouse-document-action-host/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defines generic document paper controls and keeps warehouse aliases compatible", () => {
|
||||||
|
assert.match(mainCss, /\.document-form-shell/);
|
||||||
|
assert.match(mainCss, /\.document-section-grid/);
|
||||||
|
assert.match(mainCss, /\.document-paper-field/);
|
||||||
|
assert.match(mainCss, /\.document-selector-panel/);
|
||||||
|
assert.match(mainCss, /\.document-inline-summary/);
|
||||||
|
assert.match(mainCss, /\.document-settle-panel/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-control-grid/);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,14 @@ function functionBody(name) {
|
|||||||
return match[0];
|
return match[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawerBlock(openBinding, nextMarker) {
|
||||||
|
const start = source.indexOf(`:open="${openBinding}"`);
|
||||||
|
assert.ok(start >= 0, `expected drawer ${openBinding} to exist`);
|
||||||
|
const end = source.indexOf(nextMarker, start + openBinding.length);
|
||||||
|
assert.ok(end > start, `expected drawer ${openBinding} to end before ${nextMarker}`);
|
||||||
|
return source.slice(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
describe("InventoryLedgerView warehouse action layout", () => {
|
describe("InventoryLedgerView warehouse action layout", () => {
|
||||||
it("keeps stocktake inside the tool action group instead of a standalone rack", () => {
|
it("keeps stocktake inside the tool action group instead of a standalone rack", () => {
|
||||||
assert.equal(source.includes("warehouse-stocktake-rack"), false);
|
assert.equal(source.includes("warehouse-stocktake-rack"), false);
|
||||||
@ -77,6 +85,134 @@ describe("InventoryLedgerView warehouse action layout", () => {
|
|||||||
assert.match(mainCss, /\.warehouse-paper-summary-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
assert.match(mainCss, /\.warehouse-paper-summary-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps customer supplied inbound paper fields full width instead of squeezed into a side column", () => {
|
||||||
|
assert.match(source, /title="客料入库单"/);
|
||||||
|
assert.match(source, /class="warehouse-document-meta-strip"/);
|
||||||
|
assert.match(source, /class="warehouse-paper-grid"/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-operation-form\s*\{[\s\S]*width: 100%;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-document-meta-strip\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-field > span\s*\{[\s\S]*font-size: 13px;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-field input,[\s\S]*?\.warehouse-paper-field textarea\s*\{[\s\S]*font-size: 15px;/);
|
||||||
|
assert.match(mainCss, /@media \(max-width: 520px\)[\s\S]*\.document-paper-binder,[\s\S]*\.document-retained-mark\s*\{[\s\S]*display: none;/);
|
||||||
|
assert.match(mainCss, /@media \(max-width: 520px\)[\s\S]*\.document-paper-sheet\s*\{[\s\S]*padding: 18px 10px 22px;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\)\s*\{[\s\S]*width: min\(1280px, calc\(100vw - 24px\)\) !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.form-drawer-body\s*\{[\s\S]*overflow-x: hidden !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*font-size: clamp\(22px, 7\.2vw, 28px\) !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*white-space: normal !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-paper-title\s*\{[\s\S]*overflow-wrap: anywhere !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.document-number-stamp\s*\{[\s\S]*width: min\(176px, 100%\) !important;/);
|
||||||
|
assert.match(mainCss, /body \.workflow-drawer\.form-drawer:has\(\.warehouse-document-operation-form\) \.form-drawer-body\s*\{[\s\S]*padding: 10px !important;/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("wraps production-ledger controlled inbound forms in the shared paper control language", () => {
|
||||||
|
assert.match(source, /warehouse-paper-control-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(source, /warehouse-paper-inline-summary/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-panel/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-toggle/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-grid/);
|
||||||
|
assert.match(source, /warehouse-paper-settle-remark/);
|
||||||
|
assert.match(mainCss, /\.warehouse-document-section-grid > \.warehouse-paper-control-grid\s*\{[\s\S]*grid-column: 1 \/ -1;/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-control-grid\s*\{[\s\S]*grid-template-columns: repeat\(4, minmax\(0, 1fr\)\);/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-source-lot-table/);
|
||||||
|
assert.match(mainCss, /\.warehouse-paper-settle-toggle/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps special adjustment documents inside the paper document language", () => {
|
||||||
|
const specialAdjustmentBlock = source.slice(
|
||||||
|
source.indexOf(':open="specialAdjustmentDrawerOpen"'),
|
||||||
|
source.indexOf(':open="productionDrawerOpen"')
|
||||||
|
);
|
||||||
|
assert.match(source, /special-adjustment-paper-grid/);
|
||||||
|
assert.match(source, /special-adjustment-paper-warning/);
|
||||||
|
assert.match(source, /special-adjustment-paper-lines/);
|
||||||
|
assert.match(source, /special-adjustment-paper-line-card/);
|
||||||
|
assert.doesNotMatch(specialAdjustmentBlock, /<div class="double-field">[\s\S]*?specialAdjustmentForm\.reason/);
|
||||||
|
assert.match(mainCss, /\.special-adjustment-paper-grid/);
|
||||||
|
assert.match(mainCss, /\.special-adjustment-paper-line-card/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps production outbound document controls paper-native", () => {
|
||||||
|
assert.match(source, /production-out-paper-grid/);
|
||||||
|
assert.match(source, /production-out-source-lot-panel/);
|
||||||
|
assert.match(source, /production-out-source-lot-table/);
|
||||||
|
assert.doesNotMatch(source, /<div class="workflow-tip">选择原材料库存批次出库/);
|
||||||
|
assert.match(mainCss, /\.production-out-source-lot-panel/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps production ledger inbound settlement controls paper-native", () => {
|
||||||
|
const productionLedgerInboundBlock = source.slice(
|
||||||
|
source.indexOf(':open="productionWorkOrderInboundDrawerOpen"'),
|
||||||
|
source.indexOf("<StocktakeDialog")
|
||||||
|
);
|
||||||
|
assert.match(source, /production-ledger-inbound-paper-grid/);
|
||||||
|
assert.match(source, /production-ledger-inbound-summary/);
|
||||||
|
assert.match(source, /production-ledger-inbound-metrics/);
|
||||||
|
assert.doesNotMatch(productionLedgerInboundBlock, /<div class="triple-field">[\s\S]*?productionWorkOrderInboundForm\.finished_qty/);
|
||||||
|
assert.match(mainCss, /\.production-ledger-inbound-metrics/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps raw material return outbound documents paper-native", () => {
|
||||||
|
const block = drawerBlock("rawReturnDrawerOpen", ':open="returnInboundDrawerOpen"');
|
||||||
|
assert.match(block, /raw-return-paper-grid/);
|
||||||
|
assert.match(block, /raw-return-logistics-grid/);
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
assert.doesNotMatch(block, /modern-upload/);
|
||||||
|
assert.match(mainCss, /\.raw-return-paper-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps return inbound and return scrap inbound documents paper-native", () => {
|
||||||
|
const returnBlock = drawerBlock("returnInboundDrawerOpen", ':open="returnScrapInboundDrawerOpen"');
|
||||||
|
const scrapBlock = drawerBlock("returnScrapInboundDrawerOpen", ':open="openingImportDrawerOpen"');
|
||||||
|
[returnBlock, scrapBlock].forEach((block) => {
|
||||||
|
assert.match(block, /return-paper-grid/);
|
||||||
|
assert.match(block, /return-trace-summary/);
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
});
|
||||||
|
assert.match(mainCss, /\.return-paper-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps finished inbound and sales outbound documents paper-native", () => {
|
||||||
|
const completionBlock = drawerBlock("completionDrawerOpen", ':open="deliveryDrawerOpen"');
|
||||||
|
const deliveryBlock = drawerBlock("deliveryDrawerOpen", ':open="returnReworkDrawerOpen"');
|
||||||
|
assert.match(completionBlock, /completion-paper-grid/);
|
||||||
|
assert.match(completionBlock, /completion-settle-panel/);
|
||||||
|
assert.match(deliveryBlock, /sales-delivery-paper-grid/);
|
||||||
|
assert.match(deliveryBlock, /sales-delivery-allocation-table/);
|
||||||
|
[completionBlock, deliveryBlock].forEach((block) => {
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
assert.doesNotMatch(block, /modern-upload/);
|
||||||
|
});
|
||||||
|
assert.match(mainCss, /\.completion-paper-grid/);
|
||||||
|
assert.match(mainCss, /\.sales-delivery-paper-grid/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps return rework and production ledger closeout tails paper-native", () => {
|
||||||
|
const reworkBlock = drawerBlock("returnReworkDrawerOpen", ':open="warehouseLedgerDrawerOpen"');
|
||||||
|
const productionBlock = drawerBlock("productionDrawerOpen", ':open="completionDrawerOpen"');
|
||||||
|
const ledgerInboundBlock = drawerBlock("productionWorkOrderInboundDrawerOpen", "<StocktakeDialog");
|
||||||
|
assert.match(reworkBlock, /return-rework-paper-grid/);
|
||||||
|
assert.match(reworkBlock, /return-rework-trace-summary/);
|
||||||
|
assert.match(productionBlock, /production-out-paper-remark/);
|
||||||
|
assert.match(ledgerInboundBlock, /production-ledger-inbound-settle-panel/);
|
||||||
|
assert.match(ledgerInboundBlock, /production-ledger-inbound-remark/);
|
||||||
|
[reworkBlock, productionBlock, ledgerInboundBlock].forEach((block) => {
|
||||||
|
assert.doesNotMatch(block, /class="double-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="triple-field"/);
|
||||||
|
assert.doesNotMatch(block, /class="form-field/);
|
||||||
|
});
|
||||||
|
assert.match(mainCss, /\.return-rework-paper-grid/);
|
||||||
|
assert.match(mainCss, /\.production-ledger-inbound-settle-panel/);
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces generated PDF archive feedback after independent warehouse saves", () => {
|
it("surfaces generated PDF archive feedback after independent warehouse saves", () => {
|
||||||
[
|
[
|
||||||
["submitRawReturnOutbound", "退货出库"],
|
["submitRawReturnOutbound", "退货出库"],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user