ForgeFlow-ERP/docs/superpowers/plans/2026-06-13-system-wide-paper-document-phase0-phase1.md
2026-06-14 16:23:27 +08:00

1660 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# System-Wide Paper Document Phase 0-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:** Build the reusable system-wide paper-document foundation and apply it to the highest-priority warehouse documents: special inbound/outbound, production material outbound, and production ledger inbound settlement.
**Architecture:** Add generic `Document*` form components under `frontend/src/components/documentForms`, then make existing warehouse wrappers delegate to them so current warehouse documents do not break. Convert only the P0 warehouse forms in `InventoryLedgerView.vue` to the shared paper control language, preserving all existing data models, computed values, validation, submit functions, archive behavior, and backend APIs.
**Tech Stack:** Vue 3 SFC, Vite, project CSS in `frontend/src/styles/main.css`, Node built-in test runner.
---
## Scope
This plan covers:
- Phase 0: system-wide paper document component foundation.
- Phase 1: warehouse P0 documents.
- P0 warehouse documents: `特殊入库 / 特殊出库`, `生产出库`, `生产台账入库结算单`.
This plan does not cover:
- Reworking sales, purchase, receipt, quality, delivery, return pages beyond ensuring the new generic foundation does not break them.
- Backend archive schema changes. Existing `document_archives` and document archive routes remain unchanged.
- Business logic, inventory calculation, production calculation, PDF generation rules, or database migrations.
## File Structure
- Create `frontend/src/components/documentForms/DocumentFormShell.vue`: generic form shell around `DocumentPaper`.
- Create `frontend/src/components/documentForms/DocumentSection.vue`: generic paper section wrapper.
- Create `frontend/src/components/documentForms/DocumentActionBar.vue`: generic action bar that teleports to the paper bottom.
- Create `frontend/src/components/documentForms/DocumentField.vue`: generic paper field wrapper for input/select/textarea/read-only slots.
- Create `frontend/src/components/documentForms/DocumentSelectorPanel.vue`: paper-native panel for searchable select/multi-select controls.
- Create `frontend/src/components/documentForms/DocumentInlineSummary.vue`: paper-native summary strip.
- Create `frontend/src/components/documentForms/DocumentSettlePanel.vue`: paper-native settle/exception panel.
- Modify `frontend/src/components/documentForms/DocumentPaper.vue`: add generic action host while keeping warehouse-compatible host.
- Modify `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`: delegate to `DocumentFormShell`.
- Modify `frontend/src/components/documentForms/WarehouseDocumentSection.vue`: delegate to `DocumentSection`.
- Modify `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue`: delegate to `DocumentActionBar`.
- Modify `frontend/src/views/InventoryLedgerView.vue`: convert P0 warehouse forms.
- Modify `frontend/src/styles/main.css`: add generic paper document styles and keep compatibility aliases.
- Modify `frontend/src/views/InventoryLedgerView.test.js`: add P0 regression checks.
- Create `frontend/src/components/documentForms/documentForms.test.js`: component foundation source-level regression checks.
## Task 1: Lock Generic Document Foundation Tests
**Files:**
- Create: `frontend/src/components/documentForms/documentForms.test.js`
- Read: `frontend/src/components/documentForms/DocumentPaper.vue`
- Read: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`
- Read: `frontend/src/styles/main.css`
- [ ] **Step 1: Write the failing test**
Create `frontend/src/components/documentForms/documentForms.test.js`:
```js
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/);
});
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd frontend
node --test src/components/documentForms/documentForms.test.js
```
Expected: FAIL because `DocumentFormShell.vue`, `DocumentSection.vue`, `DocumentActionBar.vue`, `DocumentField.vue`, `DocumentSelectorPanel.vue`, `DocumentInlineSummary.vue`, and `DocumentSettlePanel.vue` do not exist yet.
## Task 2: Add Generic Document Components
**Files:**
- Create: `frontend/src/components/documentForms/DocumentFormShell.vue`
- Create: `frontend/src/components/documentForms/DocumentSection.vue`
- Create: `frontend/src/components/documentForms/DocumentActionBar.vue`
- Create: `frontend/src/components/documentForms/DocumentField.vue`
- Create: `frontend/src/components/documentForms/DocumentSelectorPanel.vue`
- Create: `frontend/src/components/documentForms/DocumentInlineSummary.vue`
- Create: `frontend/src/components/documentForms/DocumentSettlePanel.vue`
- Modify: `frontend/src/components/documentForms/DocumentPaper.vue`
- [ ] **Step 1: Add `DocumentFormShell.vue`**
Create `frontend/src/components/documentForms/DocumentFormShell.vue`:
```vue
<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>
```
- [ ] **Step 2: Add `DocumentSection.vue`**
Create `frontend/src/components/documentForms/DocumentSection.vue`:
```vue
<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>
```
- [ ] **Step 3: Add `DocumentActionBar.vue`**
Create `frontend/src/components/documentForms/DocumentActionBar.vue`:
```vue
<template>
<span ref="anchorRef" class="document-action-anchor" aria-hidden="true"></span>
<Teleport :to="actionHost" :disabled="!actionHost">
<div
class="document-action-bar"
: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";
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>
```
- [ ] **Step 4: Add `DocumentField.vue`**
Create `frontend/src/components/documentForms/DocumentField.vue`:
```vue
<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>
```
- [ ] **Step 5: Add `DocumentSelectorPanel.vue`**
Create `frontend/src/components/documentForms/DocumentSelectorPanel.vue`:
```vue
<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>
```
- [ ] **Step 6: Add `DocumentInlineSummary.vue`**
Create `frontend/src/components/documentForms/DocumentInlineSummary.vue`:
```vue
<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>
```
- [ ] **Step 7: Add `DocumentSettlePanel.vue`**
Create `frontend/src/components/documentForms/DocumentSettlePanel.vue`:
```vue
<template>
<section class="document-settle-panel">
<slot />
</section>
</template>
```
- [ ] **Step 8: Add generic and legacy action hosts to `DocumentPaper.vue`**
Modify the action host near the end of the template:
```vue
<div
class="document-paper-action-host"
data-document-action-host
data-warehouse-document-action-host
></div>
```
- [ ] **Step 9: Run component test**
Run:
```bash
cd frontend
node --test src/components/documentForms/documentForms.test.js
```
Expected: FAIL on CSS assertions because the generic `document-*` styles are not defined yet.
## Task 3: Convert Warehouse Wrappers To Generic Adapters
**Files:**
- Modify: `frontend/src/components/documentForms/WarehouseDocumentFormShell.vue`
- Modify: `frontend/src/components/documentForms/WarehouseDocumentSection.vue`
- Modify: `frontend/src/components/documentForms/WarehouseDocumentActionBar.vue`
- [ ] **Step 1: Replace `WarehouseDocumentFormShell.vue` with adapter**
Use:
```vue
<template>
<DocumentFormShell
class="warehouse-document-form"
:title="title"
:document-no="documentNo"
:business-date="businessDate"
:tone="tone"
:company-name="companyName"
meta-label="仓库联单"
:signature-labels="signatureLabels"
@submit="emit('submit')"
>
<slot />
</DocumentFormShell>
</template>
<script setup>
import DocumentFormShell from "./DocumentFormShell.vue";
defineProps({
title: {
type: String,
required: true
},
documentNo: {
type: String,
default: ""
},
businessDate: {
type: [String, Date],
default: ""
},
tone: {
type: String,
default: "green"
},
companyName: {
type: String,
default: "嘉恒仓库"
},
signatureLabels: {
type: Array,
default: () => ["制单", "审核", "仓库", "财务"]
}
});
const emit = defineEmits(["submit"]);
</script>
```
- [ ] **Step 2: Replace `WarehouseDocumentSection.vue` with adapter**
Use:
```vue
<template>
<DocumentSection
class="warehouse-document-section"
:index="index"
:title="title"
>
<slot />
</DocumentSection>
</template>
<script setup>
import DocumentSection from "./DocumentSection.vue";
defineProps({
index: {
type: [Number, String],
required: true
},
title: {
type: String,
required: true
}
});
</script>
```
- [ ] **Step 3: Replace `WarehouseDocumentActionBar.vue` with adapter**
Use:
```vue
<template>
<DocumentActionBar class="warehouse-document-action-bar--shared">
<template v-if="$slots.state" #state>
<slot name="state" />
</template>
<slot />
</DocumentActionBar>
</template>
<script setup>
import DocumentActionBar from "./DocumentActionBar.vue";
</script>
```
- [ ] **Step 4: Run component test again**
Run:
```bash
cd frontend
node --test src/components/documentForms/documentForms.test.js
```
Expected: still FAIL on CSS assertions only.
## Task 4: Add Generic Paper Document CSS And Compatibility Aliases
**Files:**
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add generic style block near existing paper document styles**
Add this block before the existing warehouse-specific paper form overrides:
```css
/* System-wide paper document form primitives */
.document-form-shell {
display: grid;
gap: 14px;
width: 100%;
min-width: 0;
padding-bottom: 86px;
}
.document-form-shell .document-paper-shell {
max-width: 1080px;
padding: 18px 12px 26px;
}
.document-form-shell .document-paper-sheet {
min-height: 0;
padding-bottom: 34px;
}
.document-form-shell .document-paper-title {
font-size: clamp(34px, 4.2vw, 48px);
}
.document-form-shell .document-number-stamp {
min-width: 196px;
}
.document-form-meta {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 9px 12px;
color: rgba(23, 18, 10, 0.72);
border: 2px solid rgba(20, 14, 6, 0.82);
background:
linear-gradient(90deg, rgba(31, 122, 74, 0.12), transparent 42%),
rgba(255, 250, 240, 0.54);
font-family: "Songti SC", "SimSun", var(--ui-font-body);
font-size: 13px;
font-weight: 800;
letter-spacing: 0.08em;
}
.document-form-meta strong {
display: inline-flex;
align-items: center;
min-height: 26px;
padding: 0 10px;
color: #145c39;
border: 1.5px solid rgba(31, 122, 74, 0.64);
background: rgba(232, 246, 232, 0.62);
}
.document-form-body {
display: grid;
gap: 14px;
}
.document-section {
border: 2px solid rgba(20, 14, 6, 0.88);
background: rgba(255, 250, 240, 0.38);
}
.document-section-head {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 9px 12px;
border-bottom: 2px solid rgba(20, 14, 6, 0.88);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.26), transparent),
rgba(227, 207, 161, 0.72);
}
.document-section-index {
display: inline-grid;
place-items: center;
min-width: 30px;
height: 30px;
color: #145c39;
border: 1.5px solid rgba(31, 122, 74, 0.64);
background: rgba(232, 246, 232, 0.62);
font-family: var(--ui-font-display);
font-size: 12px;
font-weight: 900;
}
.document-section-head strong {
color: #17120a;
font-family: var(--ui-font-display);
font-size: 16px;
font-weight: 900;
letter-spacing: 0.06em;
}
.document-section-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
min-width: 0;
padding: 14px;
}
.document-section-grid > .document-control-grid,
.document-section-grid > .document-selector-panel,
.document-section-grid > .document-source-table,
.document-section-grid > .document-inline-summary,
.document-section-grid > .document-settle-panel,
.document-section-grid > .document-paper-field {
grid-column: 1 / -1;
width: 100%;
min-width: 0;
}
.document-control-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border-top: 2px solid rgba(20, 14, 6, 0.88);
border-left: 2px solid rgba(20, 14, 6, 0.88);
background: rgba(255, 250, 240, 0.34);
}
.document-paper-field {
display: grid;
grid-template-rows: auto minmax(54px, auto);
min-width: 0;
margin: 0;
border-right: 2px solid rgba(20, 14, 6, 0.88);
border-bottom: 2px solid rgba(20, 14, 6, 0.88);
background: rgba(255, 250, 240, 0.44);
}
.document-paper-field-wide {
grid-column: span 2;
}
.document-paper-field-full {
grid-column: 1 / -1;
}
.document-paper-field > span {
display: flex;
align-items: center;
min-height: 32px;
padding: 7px 10px;
color: rgba(23, 18, 10, 0.78);
border-bottom: 1px solid rgba(20, 14, 6, 0.56);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.24), transparent),
rgba(227, 207, 161, 0.62);
font-family: var(--ui-font-display);
font-size: 13px;
font-weight: 900;
letter-spacing: 0.08em;
}
.document-paper-field input,
.document-paper-field select,
.document-paper-field textarea {
width: 100%;
min-width: 0;
min-height: 48px;
padding: 10px 11px;
color: #17120a;
border: 0;
border-radius: 0;
outline: 0;
background: transparent;
box-shadow: none;
font-family: "Songti SC", "SimSun", var(--ui-font-body);
font-size: 15px;
font-weight: 800;
line-height: 1.45;
}
.document-paper-field textarea {
min-height: 104px;
resize: vertical;
}
.document-paper-field input:focus,
.document-paper-field select:focus,
.document-paper-field textarea:focus {
background:
linear-gradient(90deg, rgba(31, 122, 74, 0.12), transparent 42%),
rgba(255, 255, 255, 0.48);
box-shadow: inset 0 0 0 2px rgba(31, 122, 74, 0.42);
}
.document-paper-readonly-value {
display: flex;
align-items: center;
min-height: 48px;
margin: 0;
padding: 10px 11px;
color: #145c39;
background:
repeating-linear-gradient(-45deg, rgba(31, 122, 74, 0.06) 0 5px, transparent 5px 10px),
rgba(232, 246, 232, 0.32);
font-family: "Songti SC", "SimSun", var(--ui-font-body);
font-size: 14px;
font-weight: 900;
line-height: 1.5;
overflow-wrap: anywhere;
word-break: break-word;
}
.document-selector-panel {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
min-width: 0;
border-top: 2px solid rgba(20, 14, 6, 0.88);
border-left: 2px solid rgba(20, 14, 6, 0.88);
background: rgba(255, 250, 240, 0.44);
}
.document-selector-heading,
.document-selector-body {
min-width: 0;
border-right: 2px solid rgba(20, 14, 6, 0.88);
border-bottom: 2px solid rgba(20, 14, 6, 0.88);
}
.document-selector-heading {
display: grid;
grid-template-rows: auto 1fr;
}
.document-selector-heading span {
display: flex;
align-items: center;
min-height: 32px;
padding: 7px 10px;
color: rgba(23, 18, 10, 0.78);
border-bottom: 1px solid rgba(20, 14, 6, 0.56);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.24), transparent),
rgba(227, 207, 161, 0.62);
font-family: var(--ui-font-display);
font-size: 13px;
font-weight: 900;
letter-spacing: 0.08em;
}
.document-selector-heading strong,
.document-selector-body {
padding: 10px 11px;
font-family: "Songti SC", "SimSun", var(--ui-font-body);
font-weight: 900;
}
.document-inline-summary {
min-width: 0;
padding: 12px 14px;
color: #145c39;
border: 2px solid rgba(20, 14, 6, 0.88);
background:
linear-gradient(90deg, rgba(31, 122, 74, 0.12), transparent 42%),
rgba(232, 246, 232, 0.52);
font-family: "Songti SC", "SimSun", var(--ui-font-body);
font-size: 15px;
font-weight: 900;
}
.document-settle-panel {
display: grid;
gap: 0;
min-width: 0;
border-top: 2px solid rgba(20, 14, 6, 0.88);
border-left: 2px solid rgba(20, 14, 6, 0.88);
background:
linear-gradient(90deg, rgba(180, 83, 9, 0.08), transparent 48%),
rgba(255, 250, 240, 0.5);
}
.document-action-anchor {
display: none;
}
.document-action-bar {
position: relative;
z-index: 1;
display: flex;
justify-content: flex-end;
align-items: center;
width: fit-content;
max-width: 1080px;
margin: 0 0 0 auto;
padding: 10px;
border: 1px solid rgba(31, 122, 74, 0.24);
border-radius: 18px;
background:
linear-gradient(90deg, rgba(232, 246, 232, 0.92), rgba(255, 250, 240, 0.96)),
rgba(255, 255, 255, 0.92);
box-shadow:
0 -12px 28px rgba(5, 11, 18, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.68);
backdrop-filter: blur(14px);
}
.document-action-bar--with-state {
justify-content: space-between;
width: min(100%, 1080px);
}
.document-action-state {
min-width: 0;
color: var(--document-paper-muted, #6a5631);
font-size: 13px;
font-weight: 900;
line-height: 1.45;
}
.document-action-buttons {
display: inline-flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
@media (max-width: 960px) {
.document-form-meta,
.document-section-grid,
.document-control-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.document-selector-panel {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.document-form-shell .document-paper-shell {
padding-inline: 0;
}
.document-form-meta,
.document-section-grid,
.document-control-grid {
grid-template-columns: 1fr;
}
.document-paper-field,
.document-paper-field-wide,
.document-paper-field-full {
grid-column: 1 / -1;
}
.document-action-bar,
.document-action-bar--with-state,
.document-action-state,
.document-action-buttons {
width: 100%;
}
}
```
- [ ] **Step 2: Add warehouse compatibility aliases below generic styles**
Add:
```css
.warehouse-document-operation-form,
.warehouse-document-form {
display: grid;
gap: 14px;
width: 100%;
min-width: 0;
}
.warehouse-document-form-meta {
display: contents;
}
.warehouse-document-body {
display: contents;
}
.warehouse-document-section {
display: block;
}
.warehouse-document-section-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
min-width: 0;
padding: 14px;
}
.warehouse-paper-control-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
```
- [ ] **Step 3: Run component test to verify generic foundation passes**
Run:
```bash
cd frontend
node --test src/components/documentForms/documentForms.test.js
```
Expected: PASS.
## Task 5: Add P0 Warehouse Regression Tests
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.test.js`
- [ ] **Step 1: Add assertions for P0 document conversion**
Append these tests inside the existing `describe("InventoryLedgerView warehouse action layout", () => { })` block:
```js
it("keeps special adjustment documents inside the paper document language", () => {
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(source, /<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", () => {
assert.match(source, /production-ledger-inbound-paper-grid/);
assert.match(source, /production-ledger-inbound-summary/);
assert.match(source, /production-ledger-inbound-metrics/);
assert.doesNotMatch(source, /<div class="triple-field">[\s\S]*?productionWorkOrderInboundForm\.finished_qty/);
assert.match(mainCss, /\.production-ledger-inbound-metrics/);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run:
```bash
cd frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: FAIL because the P0 class names are not in `InventoryLedgerView.vue` or CSS yet.
## Task 6: Convert Special Inbound / Special Outbound Paper Form
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Replace the special adjustment warning and header fields**
In the `specialAdjustmentDrawerOpen` drawer, replace:
```vue
<div class="special-adjustment-warning">
<strong>特殊调整会直接改变库存明细</strong>
<span>保存后写入库存流水和特殊调整记录请仅在补录纠错线下已处理等特殊场景使用</span>
</div>
<div class="double-field">
<label class="form-field">
<span>调整说明</span>
<textarea
v-model.trim="specialAdjustmentForm.reason"
rows="3"
required
placeholder="必须填写现场原因、审批依据或补录说明"
></textarea>
</label>
<label class="form-field">
<span>仓库</span>
<select v-model.number="specialAdjustmentForm.warehouse_id" required @change="handleSpecialAdjustmentWarehouseChange">
<option disabled value="">请选择仓库</option>
<option v-for="warehouse in activeWarehouses" :key="warehouse.id" :value="warehouse.id">
{{ warehouse.warehouse_name }}
</option>
</select>
</label>
</div>
```
with:
```vue
<div class="document-inline-summary special-adjustment-paper-warning">
<strong>特殊调整会直接改变库存明细</strong>
<span>保存后写入库存流水和特殊调整记录请仅在补录纠错线下已处理等特殊场景使用</span>
</div>
<div class="document-control-grid special-adjustment-paper-grid">
<label class="document-paper-field document-paper-field-wide">
<span>调整说明</span>
<textarea
v-model.trim="specialAdjustmentForm.reason"
rows="3"
required
placeholder="必须填写现场原因、审批依据或补录说明"
></textarea>
</label>
<label class="document-paper-field">
<span>仓库</span>
<select v-model.number="specialAdjustmentForm.warehouse_id" required @change="handleSpecialAdjustmentWarehouseChange">
<option disabled value="">请选择仓库</option>
<option v-for="warehouse in activeWarehouses" :key="warehouse.id" :value="warehouse.id">
{{ warehouse.warehouse_name }}
</option>
</select>
</label>
</div>
```
- [ ] **Step 2: Replace add-line action area**
Replace:
```vue
<div class="drawer-table-actions">
<button class="ghost-button" type="button" :disabled="!specialAdjustmentForm.reason.trim()" @click="addSpecialAdjustmentLine">
添加调整明细
</button>
<span v-if="!specialAdjustmentForm.reason.trim()" class="special-adjustment-tip">请先填写调整说明</span>
</div>
```
with:
```vue
<div class="document-inline-summary special-adjustment-paper-toolbar">
<button class="ghost-button" type="button" :disabled="!specialAdjustmentForm.reason.trim()" @click="addSpecialAdjustmentLine">
添加调整明细
</button>
<span v-if="!specialAdjustmentForm.reason.trim()" class="special-adjustment-tip">请先填写调整说明</span>
<span v-else>每条明细都会写入库存流水调整说明会同步进入变更记录</span>
</div>
```
- [ ] **Step 3: Rename special adjustment line containers**
Replace class names:
```vue
class="special-adjustment-lines"
class="special-adjustment-line-card"
```
with:
```vue
class="special-adjustment-paper-lines special-adjustment-lines"
class="special-adjustment-paper-line-card special-adjustment-line-card"
```
Keep existing child classes such as `special-adjustment-card-grid`, `special-adjustment-card-field`, and `special-adjustment-card-preview` for now because they already use a paper-grid look.
- [ ] **Step 4: Add CSS aliases**
Add near the existing special adjustment styles:
```css
.special-adjustment-paper-warning {
display: grid;
gap: 5px;
color: #78350f;
background:
linear-gradient(90deg, rgba(180, 83, 9, 0.13), transparent 50%),
rgba(255, 250, 240, 0.64);
}
.special-adjustment-paper-warning strong {
color: #78350f;
font-size: 15px;
}
.special-adjustment-paper-warning span,
.special-adjustment-paper-toolbar span {
font-size: 12px;
font-weight: 900;
}
.special-adjustment-paper-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.special-adjustment-paper-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.special-adjustment-paper-lines {
display: grid;
grid-column: 1 / -1;
gap: 14px;
min-width: 0;
}
.special-adjustment-paper-line-card {
position: relative;
overflow: hidden;
border: 1.5px solid rgba(20, 14, 6, 0.72);
background:
linear-gradient(90deg, rgba(31, 122, 74, 0.06), transparent 44%),
rgba(255, 250, 240, 0.72);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.5),
0 8px 18px rgba(52, 39, 15, 0.08);
}
```
- [ ] **Step 5: Run focused test**
Run:
```bash
cd frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: special adjustment test passes; production out and production ledger inbound tests still fail.
## Task 7: Convert Production Out Paper Form
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Replace production out tip and product selector**
In the `productionDrawerOpen` drawer, replace:
```vue
<div class="workflow-tip">选择原材料库存批次出库后续小程序报工按库存批次号推进</div>
<label class="form-field">
<span>产品</span>
<select v-model.number="productionForm.product_item_id" required>
<option disabled value="">请选择产品</option>
<option v-for="product in products" :key="product.item_id" :value="product.item_id">
{{ formatProductLabel(product) }}
</option>
</select>
</label>
```
with:
```vue
<div class="document-inline-summary production-out-paper-tip">
选择原材料库存批次出库后续小程序报工按库存批次号推进一次生产出库只能选择一个材料库存批次
</div>
<div class="document-control-grid production-out-paper-grid">
<label class="document-paper-field document-paper-field-wide">
<span>产品</span>
<select v-model.number="productionForm.product_item_id" required>
<option disabled value="">请选择产品</option>
<option v-for="product in products" :key="product.item_id" :value="product.item_id">
{{ formatProductLabel(product) }}
</option>
</select>
</label>
</div>
```
- [ ] **Step 2: Replace production stock lot selector**
Replace:
```vue
<label class="form-field">
<span>库存批次号</span>
<StockLotTagSelect
v-model="selectedProductionStockLots"
:options="productionStockLotOptions"
:max-selections="1"
placeholder="搜索并选择一个材料库存批次号"
/>
</label>
```
with:
```vue
<div class="document-selector-panel production-out-source-lot-panel">
<div class="document-selector-heading">
<span>库存批次号</span>
<strong>搜索并选择一个材料库存批次号</strong>
</div>
<div class="document-selector-body">
<StockLotTagSelect
v-model="selectedProductionStockLots"
:options="productionStockLotOptions"
:max-selections="1"
placeholder="搜索并选择一个材料库存批次号"
/>
</div>
</div>
```
- [ ] **Step 3: Replace selected lot table wrapper**
Change:
```vue
<div v-if="selectedProductionStockLots.length" class="table-wrap">
```
to:
```vue
<div v-if="selectedProductionStockLots.length" class="table-wrap document-source-table production-out-source-lot-table">
```
- [ ] **Step 4: Replace issue weight/reference quantity group**
Replace the production out `double-field` group with:
```vue
<div class="document-control-grid production-out-paper-grid">
<label class="document-paper-field">
<span>本次生产出库重量(kg)</span>
<input
v-model.number="productionForm.issue_weight_kg"
type="number"
min="0.001"
step="0.001"
required
:readonly="selectedProductionStockLots.length > 0"
/>
</label>
<label class="document-paper-field">
<span>参考生产数量</span>
<input :value="formatQty(referenceProductionQty)" type="text" readonly />
</label>
</div>
```
- [ ] **Step 5: Add CSS**
Add:
```css
.production-out-paper-tip {
color: #145c39;
}
.production-out-paper-grid,
.production-out-source-lot-panel,
.production-out-source-lot-table {
grid-column: 1 / -1;
}
.production-out-source-lot-panel .stock-lot-control {
min-height: 50px;
border: 0;
border-radius: 0;
background: rgba(255, 255, 255, 0.32);
box-shadow: inset 0 0 0 1.5px rgba(31, 122, 74, 0.28);
}
.production-out-source-lot-table {
margin: 0;
overflow-x: auto;
border-top: 2px solid rgba(20, 14, 6, 0.88);
border-left: 2px solid rgba(20, 14, 6, 0.88);
border-radius: 0;
background: rgba(255, 250, 240, 0.46);
box-shadow: none;
}
```
- [ ] **Step 6: Run focused test**
Run:
```bash
cd frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: special adjustment and production out tests pass; production ledger inbound test still fails.
## Task 8: Convert Production Ledger Inbound Settlement Paper Form
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Replace settlement workflow tip and ledger select**
In `productionWorkOrderInboundDrawerOpen`, replace:
```vue
<div class="workflow-tip">
选择生产台账后系统按领料重量单件毛重和已入库成品数量填入理论余料理论废料理论值只是辅助参考实际称重可修改偏差超过上下15%时需要填写说明
</div>
<label class="form-field">
<span>生产台账</span>
<select v-model.number="selectedInboundWorkOrderId" required @change="loadProductionWorkOrderInboundPreview">
<option disabled value="">请选择在生产的生产台账</option>
<option v-for="workOrder in openProductionWorkOrders" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
{{ formatProductionWorkOrderInboundOption(workOrder) }}
</option>
</select>
</label>
```
with:
```vue
<div class="document-inline-summary production-ledger-inbound-tip">
选择生产台账后系统按领料重量单件毛重和已入库成品数量填入理论余料理论废料理论值只是辅助参考实际称重可修改偏差超过上下15%时需要填写说明
</div>
<div class="document-control-grid production-ledger-inbound-paper-grid">
<label class="document-paper-field document-paper-field-wide">
<span>生产台账</span>
<select v-model.number="selectedInboundWorkOrderId" required @change="loadProductionWorkOrderInboundPreview">
<option disabled value="">请选择在生产的生产台账</option>
<option v-for="workOrder in openProductionWorkOrders" :key="workOrder.work_order_id" :value="workOrder.work_order_id">
{{ formatProductionWorkOrderInboundOption(workOrder) }}
</option>
</select>
</label>
</div>
```
- [ ] **Step 2: Add settlement summary class to preview grid**
Change:
```vue
<div v-if="productionWorkOrderInboundPreview" class="warehouse-paper-summary-grid">
```
to:
```vue
<div v-if="productionWorkOrderInboundPreview" class="warehouse-paper-summary-grid production-ledger-inbound-summary">
```
- [ ] **Step 3: Replace the triple-field input group**
Replace:
```vue
<div class="triple-field">
<label class="form-field">
<span>成品入库数量</span>
<input
v-model.number="productionWorkOrderInboundForm.finished_qty"
type="number"
min="0"
step="1"
@input="handleProductionWorkOrderInboundFinishedQtyInput"
/>
</label>
<label class="form-field">
<span>余料入库重量(kg)</span>
<input v-model.number="productionWorkOrderInboundForm.surplus_weight_kg" type="number" min="0" step="0.001" />
</label>
<label class="form-field">
<span>废料重量(kg)</span>
<input v-model.number="productionWorkOrderInboundForm.scrap_weight_kg" type="number" min="0" step="0.001" />
</label>
</div>
```
with:
```vue
<div class="document-control-grid production-ledger-inbound-paper-grid">
<label class="document-paper-field">
<span>成品入库数量</span>
<input
v-model.number="productionWorkOrderInboundForm.finished_qty"
type="number"
min="0"
step="1"
@input="handleProductionWorkOrderInboundFinishedQtyInput"
/>
</label>
<label class="document-paper-field">
<span>余料入库重量(kg)</span>
<input v-model.number="productionWorkOrderInboundForm.surplus_weight_kg" type="number" min="0" step="0.001" />
</label>
<label class="document-paper-field">
<span>废料重量(kg)</span>
<input v-model.number="productionWorkOrderInboundForm.scrap_weight_kg" type="number" min="0" step="0.001" />
</label>
</div>
```
- [ ] **Step 4: Replace theoretical metric grid class**
Change:
```vue
<div v-if="productionWorkOrderInboundPreview" class="drawer-row-detail-grid">
```
to:
```vue
<div v-if="productionWorkOrderInboundPreview" class="warehouse-paper-summary-grid production-ledger-inbound-metrics">
```
Then change each nested `<article>` to use:
```vue
<article class="warehouse-paper-summary-card">
```
Keep the labels and computed values unchanged.
- [ ] **Step 5: Add CSS**
Add:
```css
.production-ledger-inbound-tip,
.production-ledger-inbound-paper-grid,
.production-ledger-inbound-summary,
.production-ledger-inbound-metrics {
grid-column: 1 / -1;
}
.production-ledger-inbound-tip {
color: #145c39;
}
.production-ledger-inbound-metrics {
border-top: 2px solid rgba(20, 14, 6, 0.88);
border-left: 2px solid rgba(20, 14, 6, 0.88);
}
```
- [ ] **Step 6: Run focused test**
Run:
```bash
cd frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: all `InventoryLedgerView` tests pass.
## Task 9: Full Verification
**Files:**
- Test: `frontend/src/components/documentForms/documentForms.test.js`
- Test: `frontend/src/views/InventoryLedgerView.test.js`
- Build: `frontend/package.json`
- [ ] **Step 1: Run document component tests**
Run:
```bash
cd frontend
node --test src/components/documentForms/documentForms.test.js
```
Expected: PASS.
- [ ] **Step 2: Run warehouse view tests**
Run:
```bash
cd frontend
node --test src/views/InventoryLedgerView.test.js
```
Expected: PASS.
- [ ] **Step 3: Run frontend build**
Run:
```bash
cd frontend
npm run build
```
Expected: build succeeds. Existing Vite chunk-size warning is acceptable.
- [ ] **Step 4: Browser verify P0 documents**
Using the in-app browser at `http://localhost:5173/inventory-ledger`, verify:
- `原材料库 -> 特殊入库`: warning, reason, warehouse, add-line toolbar, and line cards visually belong to the paper document.
- `原材料库 -> 特殊出库`: same as special inbound.
- `原材料库 -> 生产出库`: product selector, source lot selector, selected lot table, and issue weight/reference quantity are paper-native.
- `工具 -> 生产台账入库`: ledger selector, preview summary, finished/surplus/scrap inputs, theoretical metrics, settle checkbox, and abnormal explanation area are paper-native.
- `原材料库 -> 客料入库`: approved template does not regress.
- `采购订单` and `销售订单`: existing paper documents still render.
## Task 10: Completion Review
**Files:**
- Inspect: `git diff --stat`
- Inspect: `git status --short`
- [ ] **Step 1: Check whitespace**
Run:
```bash
git diff --check
```
Expected: no output.
- [ ] **Step 2: Check changed files**
Run:
```bash
git status --short
```
Expected changed files should be limited to:
```text
frontend/src/components/documentForms/DocumentActionBar.vue
frontend/src/components/documentForms/DocumentField.vue
frontend/src/components/documentForms/DocumentFormShell.vue
frontend/src/components/documentForms/DocumentInlineSummary.vue
frontend/src/components/documentForms/DocumentPaper.vue
frontend/src/components/documentForms/DocumentSection.vue
frontend/src/components/documentForms/DocumentSelectorPanel.vue
frontend/src/components/documentForms/DocumentSettlePanel.vue
frontend/src/components/documentForms/WarehouseDocumentActionBar.vue
frontend/src/components/documentForms/WarehouseDocumentFormShell.vue
frontend/src/components/documentForms/WarehouseDocumentSection.vue
frontend/src/components/documentForms/documentForms.test.js
frontend/src/styles/main.css
frontend/src/views/InventoryLedgerView.test.js
frontend/src/views/InventoryLedgerView.vue
```
Plus any already-existing dirty files that predated execution.
- [ ] **Step 3: Summarize residual risks**
Report:
- Which P0 documents were browser-verified.
- Whether build passed.
- Whether any browser verification was limited by narrow viewport.
- That backend APIs and business logic were intentionally not changed.
## Self-Review
- Spec coverage: covers Phase 0 generic foundation and Phase 1 P0 warehouse documents from `2026-06-13-system-wide-paper-document-unification-design.md`.
- Placeholder scan: no placeholder markers or fill-later wording.
- Type consistency: component names are consistently `DocumentFormShell`, `DocumentSection`, `DocumentActionBar`, `DocumentField`, `DocumentSelectorPanel`, `DocumentInlineSummary`, `DocumentSettlePanel`.
- Scope guard: does not alter backend archive routes, inventory calculation, production calculation, or database schema.