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

44 KiB
Raw Blame History

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:

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.vue with 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.vue with 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.vue with 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.