44 KiB
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_archivesand 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 aroundDocumentPaper. - 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 toDocumentFormShell. - Modify
frontend/src/components/documentForms/WarehouseDocumentSection.vue: delegate toDocumentSection. - Modify
frontend/src/components/documentForms/WarehouseDocumentActionBar.vue: delegate toDocumentActionBar. - 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:
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:
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:
<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:
<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:
<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:
<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:
<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:
<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:
<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:
<div
class="document-paper-action-host"
data-document-action-host
data-warehouse-document-action-host
></div>
- Step 9: Run component test
Run:
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.vuewith adapter
Use:
<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.vuewith adapter
Use:
<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.vuewith adapter
Use:
<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:
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:
/* 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:
.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:
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:
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:
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:
<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:
<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:
<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:
<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:
class="special-adjustment-lines"
class="special-adjustment-line-card"
with:
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:
.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:
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:
<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:
<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:
<label class="form-field">
<span>库存批次号</span>
<StockLotTagSelect
v-model="selectedProductionStockLots"
:options="productionStockLotOptions"
:max-selections="1"
placeholder="搜索并选择一个材料库存批次号"
/>
</label>
with:
<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:
<div v-if="selectedProductionStockLots.length" class="table-wrap">
to:
<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:
<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:
.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:
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:
<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:
<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:
<div v-if="productionWorkOrderInboundPreview" class="warehouse-paper-summary-grid">
to:
<div v-if="productionWorkOrderInboundPreview" class="warehouse-paper-summary-grid production-ledger-inbound-summary">
- Step 3: Replace the triple-field input group
Replace:
<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:
<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:
<div v-if="productionWorkOrderInboundPreview" class="drawer-row-detail-grid">
to:
<div v-if="productionWorkOrderInboundPreview" class="warehouse-paper-summary-grid production-ledger-inbound-metrics">
Then change each nested <article> to use:
<article class="warehouse-paper-summary-card">
Keep the labels and computed values unchanged.
- Step 5: Add CSS
Add:
.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:
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:
cd frontend
node --test src/components/documentForms/documentForms.test.js
Expected: PASS.
- Step 2: Run warehouse view tests
Run:
cd frontend
node --test src/views/InventoryLedgerView.test.js
Expected: PASS.
- Step 3: Run frontend build
Run:
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:
git diff --check
Expected: no output.
- Step 2: Check changed files
Run:
git status --short
Expected changed files should be limited to:
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.