1660 lines
44 KiB
Markdown
1660 lines
44 KiB
Markdown
# 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.
|