ForgeFlow-ERP/docs/superpowers/plans/2026-05-30-stocktake-ux-refactor.md
2026-06-14 21:05:49 +08:00

39 KiB
Raw Blame History

Stocktake UX Refactor 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: Rebuild the 百华仓库盘库 interaction so users can clearly start locked stocktakes, export/import sheets, review side-by-side differences, confirm adjustments, and continue unfinished stocktakes without confusing tab jumps or silent states.

Architecture: Keep the existing backend stocktake tables and core service flow. Add a small frontend workflow helper to centralize stocktake state decisions, then refactor StocktakeDialog.vue into a status-driven panel instead of a freely clickable five-tab wizard. Backend changes are limited to regression tests unless implementation finds an API defect.

Tech Stack: FastAPI, SQLAlchemy ORM, openpyxl, Python unittest, Vue 3 Composition API, Node built-in assert, Vite.


Scope Rules

  • Do not replace wh_stocktake, wh_stocktake_warehouse, wh_stocktake_line, or wh_stocktake_adjustment.
  • Do not redesign Excel import/export columns in this iteration.
  • Do not allow users to jump to invalid states: import requires an active stocktake, confirm requires imported or clean empty stocktake data, and active locked stocktakes must be surfaced first.
  • Keep the current lock behavior: selected warehouses stay locked from stocktake creation until confirm or cancel.
  • The current workspace at /Users/souplearn/Gitlab/py/ForgeFlow-ERP is not a git repository, so this plan does not include commit steps. If execution is later moved into a git worktree, commit after each task.

File Structure

  • Create: frontend/src/utils/stocktakeWorkflow.js

    • Pure functions for stocktake status normalization, next-panel decisions, active stocktake lookup, diff filtering, diff tone mapping, and warehouse card status.
  • Create: frontend/scripts/test-stocktake-workflow.mjs

    • Node-based self-test for the pure workflow helper using node:assert/strict.
  • Modify: frontend/src/components/StocktakeDialog.vue

    • Replace arbitrary step navigation with a status-driven flow.
    • Add warehouse overview cards, active stocktake resume behavior, explicit empty-warehouse handling, clear upload feedback, and confirmation gating.
  • Modify: frontend/src/styles/main.css

    • Replace the current stocktake wizard styling with overview-card, progress-rail, diff-grid, and history-card styles.
  • Modify: backend/tests/test_stocktake_excel_diff_confirm.py

    • Add a focused regression test that confirms empty exported stocktakes and imported stocktakes produce stable API states for the new UI assumptions.

Task 1: Add Stocktake Workflow Helper and Self-Test

Files:

  • Create: frontend/src/utils/stocktakeWorkflow.js

  • Create: frontend/scripts/test-stocktake-workflow.mjs

  • Step 1: Write the failing workflow self-test

Create frontend/scripts/test-stocktake-workflow.mjs with this content:

import assert from "node:assert/strict";

import {
  canContinueStocktake,
  diffTone,
  findActiveStocktakeForWarehouse,
  hasBlockingDiffErrors,
  isEmptyStocktakeRead,
  nextStocktakePanel,
  splitDiffLines,
  stocktakeSummaryLineCount,
  warehouseStocktakeStatus
} from "../src/utils/stocktakeWorkflow.js";

const emptyExported = {
  id: 10,
  status: "EXPORTED",
  summary: {
    match_count: 0,
    gain_count: 0,
    loss_count: 0,
    new_lot_count: 0,
    missing_count: 0,
    error_count: 0
  },
  warehouses: [{ warehouse_id: 4, warehouse_name: "辅料库" }]
};

const importedWithDiff = {
  id: 11,
  status: "IMPORTED",
  summary: {
    match_count: 2,
    gain_count: 1,
    loss_count: 1,
    new_lot_count: 0,
    missing_count: 0,
    error_count: 0
  },
  warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }]
};

const lockedRaw = {
  id: 12,
  status: "LOCKED",
  stocktake_no: "PK-20260530-012",
  warehouses: [{ warehouse_id: 1, warehouse_name: "原材料库" }]
};

assert.equal(stocktakeSummaryLineCount(emptyExported.summary), 0);
assert.equal(isEmptyStocktakeRead(emptyExported), true);
assert.equal(isEmptyStocktakeRead(importedWithDiff), false);

assert.equal(nextStocktakePanel(null), "overview");
assert.equal(nextStocktakePanel({ status: "LOCKED" }), "export");
assert.equal(nextStocktakePanel(emptyExported), "diff");
assert.equal(nextStocktakePanel({ ...emptyExported, summary: { match_count: 1 } }), "import");
assert.equal(nextStocktakePanel(importedWithDiff), "diff");
assert.equal(nextStocktakePanel({ status: "CONFIRMED" }), "history");

assert.equal(canContinueStocktake({ status: "LOCKED" }), true);
assert.equal(canContinueStocktake({ status: "EXPORTED" }), true);
assert.equal(canContinueStocktake({ status: "IMPORTED" }), true);
assert.equal(canContinueStocktake({ status: "CONFIRMED" }), false);

assert.equal(findActiveStocktakeForWarehouse([importedWithDiff, lockedRaw], 1).id, 11);
assert.equal(findActiveStocktakeForWarehouse([emptyExported], 4).id, 10);
assert.equal(findActiveStocktakeForWarehouse([emptyExported], 999), null);

const lines = [
  { id: 1, diff_type: "MATCH", row_status: "READY" },
  { id: 2, diff_type: "LOSS", row_status: "READY" },
  { id: 3, diff_type: "GAIN", row_status: "READY" },
  { id: 4, diff_type: "MATCH", row_status: "ERROR" }
];

assert.deepEqual(splitDiffLines(lines, "ALL").map((line) => line.id), [4, 2, 3, 1]);
assert.deepEqual(splitDiffLines(lines, "DIFF").map((line) => line.id), [4, 2, 3]);
assert.deepEqual(splitDiffLines(lines, "ERROR").map((line) => line.id), [4]);
assert.equal(hasBlockingDiffErrors(lines), true);
assert.equal(diffTone({ diff_type: "GAIN", row_status: "READY" }), "gain");
assert.equal(diffTone({ diff_type: "LOSS", row_status: "READY" }), "loss");
assert.equal(diffTone({ diff_type: "NEW_LOT", row_status: "READY" }), "new");
assert.equal(diffTone({ diff_type: "MISSING", row_status: "READY" }), "missing");
assert.equal(diffTone({ diff_type: "MATCH", row_status: "ERROR" }), "error");
assert.equal(diffTone({ diff_type: "MATCH", row_status: "READY" }), "match");

const warehouseStatus = warehouseStocktakeStatus(
  { id: 1, warehouse_name: "原材料库", warehouse_type: "RAW" },
  [lockedRaw]
);

assert.equal(warehouseStatus.locked, true);
assert.equal(warehouseStatus.activeStocktake.stocktake_no, "PK-20260530-012");
assert.equal(warehouseStatus.label, "盘库中");

console.log("stocktake workflow tests passed");
  • Step 2: Run the self-test and confirm it fails

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs

Expected: fail with Cannot find module .../src/utils/stocktakeWorkflow.js.

  • Step 3: Add the workflow helper

Create frontend/src/utils/stocktakeWorkflow.js with this content:

const ACTIVE_STOCKTAKE_STATUSES = new Set(["LOCKED", "EXPORTED", "IMPORTED"]);

export function normalizeStocktakeStatus(status) {
  return String(status || "").trim().toUpperCase();
}

export function stocktakeSummaryLineCount(summary = {}) {
  return ["match_count", "gain_count", "loss_count", "new_lot_count", "missing_count", "error_count"].reduce(
    (total, key) => total + Number(summary?.[key] || 0),
    0
  );
}

export function isEmptyStocktakeRead(stocktake) {
  return Boolean(stocktake) && stocktakeSummaryLineCount(stocktake.summary || {}) === 0;
}

export function canContinueStocktake(row) {
  return ACTIVE_STOCKTAKE_STATUSES.has(normalizeStocktakeStatus(row?.status));
}

export function nextStocktakePanel(stocktake) {
  const status = normalizeStocktakeStatus(stocktake?.status);
  if (!stocktake?.id) return "overview";
  if (status === "LOCKED") return "export";
  if (status === "EXPORTED") return isEmptyStocktakeRead(stocktake) ? "diff" : "import";
  if (status === "IMPORTED") return "diff";
  return "history";
}

export function findActiveStocktakeForWarehouse(historyRows = [], warehouseId) {
  const id = Number(warehouseId || 0);
  if (!id) return null;
  return (
    historyRows.find(
      (row) =>
        canContinueStocktake(row) &&
        Array.isArray(row.warehouses) &&
        row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === id)
    ) || null
  );
}

export function findOnlyActiveStocktake(historyRows = []) {
  const rows = historyRows.filter((row) => canContinueStocktake(row));
  return rows.length === 1 ? rows[0] : null;
}

export function hasBlockingDiffErrors(lines = []) {
  return lines.some((line) => String(line?.row_status || "").toUpperCase() === "ERROR");
}

export function hasStocktakeDiff(line) {
  const diffType = normalizeStocktakeStatus(line?.diff_type || "MATCH");
  return diffType !== "MATCH" || String(line?.row_status || "").toUpperCase() === "ERROR";
}

export function splitDiffLines(lines = [], filter = "ALL") {
  const normalizedFilter = normalizeStocktakeStatus(filter || "ALL");
  return [...lines]
    .filter((line) => {
      if (normalizedFilter === "ERROR") return String(line?.row_status || "").toUpperCase() === "ERROR";
      if (normalizedFilter === "DIFF") return hasStocktakeDiff(line);
      return true;
    })
    .sort((left, right) => {
      const leftError = String(left?.row_status || "").toUpperCase() === "ERROR" ? 0 : 1;
      const rightError = String(right?.row_status || "").toUpperCase() === "ERROR" ? 0 : 1;
      if (leftError !== rightError) return leftError - rightError;
      const leftMatch = normalizeStocktakeStatus(left?.diff_type) === "MATCH" ? 1 : 0;
      const rightMatch = normalizeStocktakeStatus(right?.diff_type) === "MATCH" ? 1 : 0;
      if (leftMatch !== rightMatch) return leftMatch - rightMatch;
      return Number(left?.line_no || left?.id || 0) - Number(right?.line_no || right?.id || 0);
    });
}

export function diffTone(line) {
  if (String(line?.row_status || "").toUpperCase() === "ERROR") return "error";
  const diffType = normalizeStocktakeStatus(line?.diff_type || "MATCH");
  if (diffType === "GAIN") return "gain";
  if (diffType === "LOSS") return "loss";
  if (diffType === "NEW_LOT") return "new";
  if (diffType === "MISSING") return "missing";
  return "match";
}

export function warehouseStocktakeStatus(warehouse, historyRows = []) {
  const activeStocktake = findActiveStocktakeForWarehouse(historyRows, warehouse?.id);
  const status = normalizeStocktakeStatus(activeStocktake?.status);
  return {
    locked: Boolean(activeStocktake),
    activeStocktake,
    status,
    label:
      {
        LOCKED: "盘库中",
        EXPORTED: "已导出待导入",
        IMPORTED: "已导入待确认"
      }[status] || "可盘库"
  };
}
  • Step 4: Run the workflow self-test and confirm it passes

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs

Expected output includes:

stocktake workflow tests passed

Task 2: Refactor Stocktake Dialog State Model

Files:

  • Modify: frontend/src/components/StocktakeDialog.vue

  • Test: frontend/scripts/test-stocktake-workflow.mjs

  • Step 1: Replace local state helpers with shared workflow imports

In frontend/src/components/StocktakeDialog.vue, replace the current dictionary import block:

import { computed, ref, watch } from "vue";

import { downloadResource, fetchResource, postResource, uploadResource } from "../services/api";
import { formatStocktakeDiffTypeLabel, formatStocktakeStatusLabel } from "../utils/dictionaries";
import { formatAmount, formatDateTime, formatQty, formatWeight } from "../utils/formatters";

with:

import { computed, ref, watch } from "vue";

import { downloadResource, fetchResource, postResource, uploadResource } from "../services/api";
import { formatStocktakeDiffTypeLabel, formatStocktakeStatusLabel } from "../utils/dictionaries";
import { formatAmount, formatDateTime, formatQty, formatWeight } from "../utils/formatters";
import {
  canContinueStocktake,
  diffTone,
  findActiveStocktakeForWarehouse,
  findOnlyActiveStocktake,
  hasBlockingDiffErrors,
  isEmptyStocktakeRead,
  nextStocktakePanel,
  splitDiffLines,
  warehouseStocktakeStatus
} from "../utils/stocktakeWorkflow";
  • Step 2: Remove the clickable steps array

Delete this block from StocktakeDialog.vue:

const steps = [
  { key: "select", no: "01", label: "选择仓库" },
  { key: "export", no: "02", label: "导出清单" },
  { key: "import", no: "03", label: "导入盘点" },
  { key: "diff", no: "04", label: "差异确认" },
  { key: "history", no: "05", label: "盘库记录" }
];

Add this block in the same location:

const flowSteps = [
  { key: "overview", no: "01", label: "选择仓库" },
  { key: "export", no: "02", label: "导出清单" },
  { key: "import", no: "03", label: "导入盘点" },
  { key: "diff", no: "04", label: "差异确认" },
  { key: "history", no: "05", label: "盘库记录" }
];
  • Step 3: Rename the active panel state

Replace:

const activeStep = ref("select");

with:

const activePanel = ref("overview");

Replace all script references to activeStep.value with activePanel.value.

Replace all template references to activeStep with activePanel.

After replacement, there must be no activeStep reference:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
rg -n "activeStep|steps" frontend/src/components/StocktakeDialog.vue

Expected: no output.

  • Step 4: Add computed workflow state

Add this block after const historyRows = ref([]);:

const currentFlowIndex = computed(() => Math.max(0, flowSteps.findIndex((step) => step.key === activePanel.value)));

const activeWarehouseStatuses = computed(() =>
  props.warehouses.map((warehouse) => ({
    ...warehouse,
    stocktakeStatus: warehouseStocktakeStatus(warehouse, historyRows.value)
  }))
);

const activeWarehouseNames = computed(() =>
  activeStocktake.value?.warehouses?.map((warehouse) => warehouse.warehouse_name).filter(Boolean).join("、") || "-"
);

const canConfirmCurrentStocktake = computed(
  () => Boolean(activeStocktake.value?.id) && !hasBlockingDiffErrors(previewLines.value)
);
  • Step 5: Update open watcher to use status-driven routing

Replace the current watch(() => props.open, ...) body with:

watch(
  () => props.open,
  async (open) => {
    if (!open) return;
    activePanel.value = "overview";
    selectedWarehouseIds.value = props.defaultWarehouseId ? [props.defaultWarehouseId] : [];
    activeStocktake.value = null;
    previewLines.value = [];
    previewSummary.value = {};
    diffFilter.value = "ALL";
    confirmDialogOpen.value = false;
    dialogMessage.value = "";
    dialogError.value = "";
    await loadHistory();
    const activeRow = findActiveStocktakeForWarehouse(historyRows.value, props.defaultWarehouseId) || findOnlyActiveStocktake(historyRows.value);
    if (activeRow) {
      await continueStocktake(activeRow, { silent: true });
    }
  }
);
  • Step 6: Remove duplicate helper functions from the component

Delete these component-local functions because they are now imported from stocktakeWorkflow.js:

function stocktakeSummaryLineCount(summary = {}) {
  return ["match_count", "gain_count", "loss_count", "new_lot_count", "missing_count", "error_count"].reduce(
    (total, key) => total + Number(summary?.[key] || 0),
    0
  );
}

function isEmptyStocktakeRead(stocktake) {
  return stocktake && stocktakeSummaryLineCount(stocktake.summary || {}) === 0;
}

function diffClass(line) {
  return `diff-${String(line.diff_type || "MATCH").toLowerCase()} ${line.row_status === "ERROR" ? "diff-error" : ""}`;
}

function canContinueStocktake(row) {
  return ["LOCKED", "EXPORTED", "IMPORTED"].includes(String(row?.status || "").toUpperCase());
}

function activeHistoryRows() {
  return historyRows.value.filter((row) => canContinueStocktake(row));
}

function findOnlyActiveRow() {
  const rows = activeHistoryRows();
  return rows.length === 1 ? rows[0] : null;
}

function findActiveRowForDefaultWarehouse() {
  if (!props.defaultWarehouseId) return null;
  return historyRows.value.find(
    (row) =>
      canContinueStocktake(row) &&
      row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === Number(props.defaultWarehouseId))
  );
}

Then add this smaller display helper:

function diffClass(line) {
  return `diff-${diffTone(line)}`;
}
  • Step 7: Run helper self-test and Vue build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs
npm run build

Expected:

stocktake workflow tests passed

and Vite exits with code 0. Chunk-size warnings are acceptable.


Task 3: Rebuild Dialog Template Into Status-Driven Panels

Files:

  • Modify: frontend/src/components/StocktakeDialog.vue

  • Modify: frontend/src/styles/main.css

  • Step 1: Replace the clickable navigation with a progress rail

In StocktakeDialog.vue, replace the current <nav class="stocktake-steps" ...> block with:

<nav class="stocktake-steps stocktake-progress-rail" aria-label="盘库流程">
  <span
    v-for="(step, index) in flowSteps"
    :key="step.key"
    class="stocktake-step"
    :class="{
      active: activePanel === step.key,
      done: index < currentFlowIndex
    }"
  >
    <span>{{ step.no }}</span>
    <strong>{{ step.label }}</strong>
  </span>
</nav>
  • Step 2: Replace the select panel with warehouse overview cards

Replace the section beginning with:

<section v-if="activePanel === 'select'" class="stocktake-panel stocktake-panel-select">

with:

<section v-if="activePanel === 'overview'" class="stocktake-panel stocktake-panel-overview">
  <div class="stocktake-overview-grid">
    <article
      v-for="warehouse in activeWarehouseStatuses"
      :key="warehouse.id"
      class="stocktake-overview-card"
      :class="{ locked: warehouse.stocktakeStatus.locked, selected: selectedWarehouseIds.includes(warehouse.id) }"
    >
      <div class="stocktake-overview-card-head">
        <div>
          <strong>{{ warehouse.warehouse_name }}</strong>
          <span>{{ warehouseTypeText(warehouse.warehouse_type) }}</span>
        </div>
        <mark>{{ warehouse.stocktakeStatus.label }}</mark>
      </div>
      <p v-if="warehouse.stocktakeStatus.activeStocktake">
        当前盘库单:{{ warehouse.stocktakeStatus.activeStocktake.stocktake_no }}
      </p>
      <p v-else>选择该仓库后开始盘库,系统会立即锁库。</p>
      <div class="stocktake-card-actions">
        <button
          v-if="warehouse.stocktakeStatus.activeStocktake"
          class="primary-button"
          type="button"
          :disabled="busy"
          @click="continueStocktake(warehouse.stocktakeStatus.activeStocktake)"
        >
          继续处理
        </button>
        <button
          v-else
          class="ghost-button"
          type="button"
          :disabled="busy"
          @click="toggleWarehouseSelection(warehouse.id)"
        >
          {{ selectedWarehouseIds.includes(warehouse.id) ? "已选择" : "选择盘库" }}
        </button>
      </div>
    </article>
  </div>
  <div class="stocktake-footer">
    <button class="primary-button" type="button" :disabled="!selectedWarehouseIds.length || busy" @click="startStocktake">
      {{ busy ? "锁库中..." : "开始盘库并锁库" }}
    </button>
  </div>
</section>
  • Step 3: Add the selection toggle function

Add this function near warehouseTypeText:

function toggleWarehouseSelection(warehouseId) {
  const id = Number(warehouseId);
  if (!id) return;
  if (selectedWarehouseIds.value.includes(id)) {
    selectedWarehouseIds.value = selectedWarehouseIds.value.filter((item) => Number(item) !== id);
    return;
  }
  selectedWarehouseIds.value = [...selectedWarehouseIds.value, id];
}
  • Step 4: Make export/import panels show current stocktake context

Replace the export panel content with:

<section v-if="activePanel === 'export'" class="stocktake-panel stocktake-panel-export">
  <div class="stocktake-action-card stocktake-action-card-wide">
    <span class="stocktake-stage-tag">当前仓库:{{ activeWarehouseNames }}</span>
    <strong>盘库单已创建,仓库已锁定</strong>
    <p>请导出盘库清单给仓库人员清点。确认或取消盘库前,这些仓库不能进行其他出入库。</p>
    <div class="stocktake-inline-actions">
      <button class="ghost-button" type="button" :disabled="busy" @click="activePanel = 'overview'">返回仓库</button>
      <button class="primary-button" type="button" :disabled="!activeStocktake || busy" @click="exportStocktake">
        {{ busy ? "导出中..." : "导出盘库清单" }}
      </button>
    </div>
  </div>
</section>

Replace the import panel content with:

<section v-if="activePanel === 'import'" class="stocktake-panel stocktake-panel-import">
  <div class="stocktake-action-card stocktake-action-card-wide">
    <span class="stocktake-stage-tag">当前盘库单:{{ activeStocktake?.stocktake_no || "-" }}</span>
    <strong>导入清点后的 Excel</strong>
    <p>导入后只生成差异预览,不会直接改库存。确认盘库后才会重置库存并解锁仓库。</p>
    <label class="import-dropzone">
      <input ref="fileInputRef" type="file" accept=".xlsx,.xlsm" @change="importStocktake" />
      <strong>{{ busy ? "导入中..." : "选择盘点后的 Excel" }}</strong>
      <span>支持系统导出的盘库清单文件</span>
    </label>
  </div>
</section>
  • Step 5: Run Vue build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: Vite exits with code 0. Chunk-size warnings are acceptable.


Task 4: Rebuild Difference Review as Code-Diff-Style Table

Files:

  • Modify: frontend/src/components/StocktakeDialog.vue

  • Modify: frontend/src/styles/main.css

  • Step 1: Replace filteredLines computed with the shared sorter

Replace the existing filteredLines computed block with:

const filteredLines = computed(() => splitDiffLines(previewLines.value, diffFilter.value));
  • Step 2: Replace the diff panel table

Replace the current <div class="stocktake-diff-table">...</div> block with:

<div class="stocktake-diff-table stocktake-diff-table-v2">
  <div class="stocktake-diff-header">系统快照</div>
  <div class="stocktake-diff-change-header">变化</div>
  <div class="stocktake-diff-header">实盘结果</div>

  <template v-for="line in filteredLines" :key="line.id">
    <div class="stocktake-diff-cell stocktake-diff-cell-left" :class="diffClass(line)">
      <strong>{{ line.item_code }} · {{ line.item_name }}</strong>
      <span>批次:{{ line.lot_no || "-" }}</span>
      <span>来源分批次:{{ line.source_material_sub_batch_no || "-" }}</span>
      <span>重量:{{ formatWeight(line.snapshot_weight_kg) }} kg</span>
      <span v-if="line.item_type !== 'RAW_MATERIAL'">数量:{{ formatQty(line.snapshot_qty) }}</span>
      <span>单价:¥{{ formatAmount(line.snapshot_unit_cost) }}</span>
    </div>
    <div class="stocktake-diff-change" :class="diffClass(line)">
      <strong>{{ formatStocktakeDiffTypeLabel(line.diff_type) }}</strong>
      <span>重量差:{{ formatWeight(line.diff_weight_kg) }} kg</span>
      <span v-if="line.item_type !== 'RAW_MATERIAL'">数量差:{{ formatQty(line.diff_qty) }}</span>
      <span>金额影响:¥{{ formatAmount(line.diff_amount) }}</span>
      <em v-if="line.error_message">{{ line.error_message }}</em>
    </div>
    <div class="stocktake-diff-cell stocktake-diff-cell-right" :class="diffClass(line)">
      <strong>{{ line.item_code }} · {{ line.item_name }}</strong>
      <span>批次:{{ line.lot_no || "-" }}</span>
      <span>盘点重量:{{ formatWeight(line.counted_weight_kg) }} kg</span>
      <span v-if="line.item_type !== 'RAW_MATERIAL'">盘点数量:{{ formatQty(line.counted_qty) }}</span>
      <span>备注:{{ line.remark || "-" }}</span>
    </div>
  </template>

  <div v-if="!filteredLines.length" class="stocktake-diff-empty-v2">
    <strong>没有需要展示的差异行</strong>
    <span v-if="isCleanEmptyStocktake">这是空库盘库确认后只解锁仓库不生成库存调整流水</span>
    <span v-else>当前筛选条件下没有明细</span>
  </div>
</div>
  • Step 3: Disable confirm when errors exist

Replace the confirm button disabled binding in the diff panel:

<button class="primary-button" type="button" :disabled="busy" @click="openConfirmDialog">

with:

<button class="primary-button" type="button" :disabled="busy || !canConfirmCurrentStocktake" @click="openConfirmDialog">
  • Step 4: Add diff styles

Append this CSS to frontend/src/styles/main.css near the existing stocktake diff styles:

.stocktake-diff-table-v2 {
  display: grid;
  grid-template-columns: minmax(280px, 1fr) 180px minmax(280px, 1fr);
  gap: 0;
  overflow: auto;
  border: 1px solid rgba(148, 163, 184, 0.42);
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.9);
}

.stocktake-diff-header,
.stocktake-diff-change-header {
  position: sticky;
  top: 0;
  z-index: 1;
  padding: 12px 14px;
  background: rgba(241, 245, 249, 0.96);
  color: #334155;
  font-weight: 800;
  border-bottom: 1px solid rgba(148, 163, 184, 0.36);
}

.stocktake-diff-change-header {
  text-align: center;
  border-left: 1px solid rgba(148, 163, 184, 0.28);
  border-right: 1px solid rgba(148, 163, 184, 0.28);
}

.stocktake-diff-cell,
.stocktake-diff-change {
  display: grid;
  gap: 5px;
  min-height: 116px;
  padding: 12px 14px;
  color: #1f2937;
  border-bottom: 1px solid rgba(226, 232, 240, 0.92);
}

.stocktake-diff-change {
  align-content: center;
  text-align: center;
  border-left: 1px solid rgba(148, 163, 184, 0.22);
  border-right: 1px solid rgba(148, 163, 184, 0.22);
}

.stocktake-diff-cell span,
.stocktake-diff-change span {
  color: #64748b;
  font-size: 13px;
}

.stocktake-diff-change em {
  color: #991b1b;
  font-style: normal;
  font-weight: 700;
}

.stocktake-diff-cell.diff-gain,
.stocktake-diff-change.diff-gain {
  background: rgba(220, 252, 231, 0.86);
}

.stocktake-diff-cell.diff-loss,
.stocktake-diff-change.diff-loss {
  background: rgba(254, 226, 226, 0.9);
}

.stocktake-diff-cell.diff-new,
.stocktake-diff-change.diff-new {
  background: rgba(219, 234, 254, 0.9);
}

.stocktake-diff-cell.diff-missing,
.stocktake-diff-change.diff-missing {
  background: rgba(255, 237, 213, 0.9);
}

.stocktake-diff-cell.diff-error,
.stocktake-diff-change.diff-error {
  background: rgba(127, 29, 29, 0.94);
  color: #fff;
}

.stocktake-diff-cell.diff-error span,
.stocktake-diff-change.diff-error span,
.stocktake-diff-change.diff-error em {
  color: #fee2e2;
}

.stocktake-diff-empty-v2 {
  grid-column: 1 / -1;
  display: grid;
  gap: 6px;
  padding: 24px;
  text-align: center;
  color: #475569;
}
  • Step 5: Run workflow test and build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs
npm run build

Expected:

stocktake workflow tests passed

and Vite exits with code 0. Chunk-size warnings are acceptable.


Task 5: Fix Continue, Import, Empty-Stocktake, and Confirm Interactions

Files:

  • Modify: frontend/src/components/StocktakeDialog.vue

  • Test: frontend/scripts/test-stocktake-workflow.mjs

  • Step 1: Add a single function for applying loaded stocktake state

Add this function before continueStocktake:

async function applyLoadedStocktake(stocktake, options = {}) {
  activeStocktake.value = stocktake;
  previewSummary.value = stocktake?.summary || {};
  confirmDialogOpen.value = false;
  const nextPanel = nextStocktakePanel(stocktake);
  if (nextPanel === "diff" && String(stocktake?.status || "").toUpperCase() === "IMPORTED") {
    previewLines.value = await fetchResource(`/inventory/stocktakes/${stocktake.id}/lines`, []);
  } else {
    previewLines.value = [];
  }
  activePanel.value = nextPanel;

  if (options.silent) return;
  if (nextPanel === "export") {
    setDialogMessage(`${stocktake.stocktake_no} 已锁库,请导出盘库清单`);
  } else if (nextPanel === "import") {
    setDialogMessage(`${stocktake.stocktake_no} 已导出,请导入盘点清单`);
  } else if (nextPanel === "diff" && isEmptyStocktakeRead(stocktake)) {
    setDialogMessage(`${stocktake.stocktake_no} 是空库盘库单,可确认后解锁`);
  } else if (nextPanel === "diff") {
    setDialogMessage(`${stocktake.stocktake_no} 已导入,请核对差异后确认`);
  }
}
  • Step 2: Replace continueStocktake body

Replace the existing continueStocktake function with:

async function continueStocktake(row, options = {}) {
  if (!row?.id) return;
  clearDialogFeedback();
  busy.value = true;
  try {
    const stocktake = await fetchResource(`/inventory/stocktakes/${row.id}`, row);
    await applyLoadedStocktake(stocktake, options);
  } catch (error) {
    setDialogError(error.message || "继续处理盘库单失败");
  } finally {
    busy.value = false;
  }
}
  • Step 3: Replace exportStocktake body

Replace the existing exportStocktake function with:

async function exportStocktake() {
  if (!activeStocktake.value?.id) return;
  clearDialogFeedback();
  busy.value = true;
  try {
    await downloadResource(`/inventory/stocktakes/${activeStocktake.value.id}/export`, `${activeStocktake.value.stocktake_no}_盘库清单.xlsx`);
    const exportedStocktake = await fetchResource(`/inventory/stocktakes/${activeStocktake.value.id}`, null);
    if (!exportedStocktake) {
      throw new Error("导出后读取盘库单失败,请刷新后继续处理");
    }
    await applyLoadedStocktake(exportedStocktake, { silent: true });
    if (isEmptyStocktakeRead(exportedStocktake)) {
      setDialogMessage(`空盘库单 ${exportedStocktake.stocktake_no} 已导出,可直接确认盘库后解锁仓库`);
    } else {
      setDialogMessage(`盘库清单 ${exportedStocktake.stocktake_no} 已导出,请清点后导入`);
    }
  } catch (error) {
    setDialogError(error.message || "导出盘库清单失败");
  } finally {
    busy.value = false;
  }
}
  • Step 4: Replace active-stocktake fallback in importStocktake

Inside importStocktake, replace:

const fallbackRow = findOnlyActiveRow();
if (fallbackRow) {
  await continueStocktake(fallbackRow, { silent: true });
}

with:

const fallbackRow = findOnlyActiveStocktake(historyRows.value);
if (fallbackRow) {
  await continueStocktake(fallbackRow, { silent: true });
}
  • Step 5: Make import success always visible

Inside importStocktake, replace the success branch after await loadHistory(); with:

if (!previewLines.value.length) {
  setDialogMessage(`空盘库单 ${result.stocktake.stocktake_no} 已导入:无库存差异,确认盘库后解锁仓库`);
} else if (hasBlockingDiffErrors(previewLines.value)) {
  setDialogError(`盘点清单已导入,但存在 ${previewSummary.value.error_count || 0} 行异常,请处理后再确认`);
} else {
  setDialogMessage(`盘点清单已导入:发现 ${previewSummary.value.gain_count || 0} 行盘盈、${previewSummary.value.loss_count || 0} 行盘亏、${previewSummary.value.new_lot_count || 0} 行新增批次、${previewSummary.value.missing_count || 0} 行缺失批次`);
}
  • Step 6: Replace confirm guard with computed

In openConfirmDialog, replace:

if (Number(previewSummary.value.error_count || 0) > 0) {
  setDialogError("存在异常行,不能确认盘库");
  return;
}

with:

if (!canConfirmCurrentStocktake.value) {
  setDialogError("存在异常行或没有可确认的盘库单,不能确认盘库");
  return;
}

In confirmStocktake, replace the same error-count guard with the same computed guard.

  • Step 7: Run workflow test and build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs
npm run build

Expected:

stocktake workflow tests passed

and Vite exits with code 0. Chunk-size warnings are acceptable.


Task 6: Add Visual Styles for Overview, Progress, and Actions

Files:

  • Modify: frontend/src/styles/main.css

  • Step 1: Add overview and progress styles

Append this CSS near the existing stocktake styles:

.stocktake-progress-rail {
  align-items: stretch;
}

.stocktake-progress-rail .stocktake-step {
  cursor: default;
}

.stocktake-progress-rail .stocktake-step.done {
  border-color: rgba(34, 197, 94, 0.58);
  color: #15803d;
  background: rgba(240, 253, 244, 0.92);
}

.stocktake-panel-overview {
  display: grid;
  gap: 16px;
}

.stocktake-overview-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 14px;
}

.stocktake-overview-card {
  display: grid;
  gap: 12px;
  min-height: 178px;
  padding: 18px;
  border: 1px solid rgba(148, 163, 184, 0.38);
  border-radius: 20px;
  background: rgba(255, 255, 255, 0.9);
  color: #1f2937;
  box-shadow: 0 14px 34px rgba(15, 23, 42, 0.1);
}

.stocktake-overview-card.selected {
  border-color: rgba(37, 99, 235, 0.65);
  box-shadow: 0 18px 40px rgba(37, 99, 235, 0.18);
}

.stocktake-overview-card.locked {
  border-color: rgba(245, 158, 11, 0.62);
  background: linear-gradient(135deg, rgba(255, 251, 235, 0.96), rgba(255, 255, 255, 0.92));
}

.stocktake-overview-card-head {
  display: flex;
  justify-content: space-between;
  gap: 12px;
}

.stocktake-overview-card-head div {
  display: grid;
  gap: 4px;
}

.stocktake-overview-card-head span,
.stocktake-overview-card p {
  color: #64748b;
}

.stocktake-overview-card mark {
  height: fit-content;
  border-radius: 999px;
  padding: 4px 10px;
  background: rgba(219, 234, 254, 0.9);
  color: #1d4ed8;
  font-weight: 800;
}

.stocktake-overview-card.locked mark {
  background: rgba(254, 243, 199, 0.92);
  color: #92400e;
}

.stocktake-card-actions,
.stocktake-inline-actions {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  margin-top: auto;
}

.stocktake-action-card-wide {
  max-width: 760px;
}

.stocktake-stage-tag {
  width: fit-content;
  border-radius: 999px;
  padding: 5px 11px;
  background: rgba(219, 234, 254, 0.9);
  color: #1d4ed8;
  font-weight: 800;
}
  • Step 2: Run Vue build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: Vite exits with code 0. Chunk-size warnings are acceptable.


Task 7: Backend Regression Tests for UI Assumptions

Files:

  • Modify: backend/tests/test_stocktake_excel_diff_confirm.py

  • Step 1: Add test for status sequence consumed by the new UI

Append this test method to StocktakeExcelDiffConfirmTest:

    def test_stocktake_status_sequence_supports_status_driven_ui(self) -> None:
        from app.services.stocktake import confirm_stocktake, import_stocktake_workbook

        stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
        self.assertEqual(stocktake.status, "LOCKED")

        workbook = build_stocktake_workbook(self.db, stocktake.id)
        self.assertEqual(self.db.get(type(stocktake), stocktake.id).status, "EXPORTED")

        output = BytesIO()
        workbook.save(output)
        preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
        self.assertEqual(preview["stocktake"].status, "IMPORTED")
        self.assertEqual(preview["summary"]["match_count"], 1)

        confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="确认盘库")
        self.assertEqual(confirmed.status, "CONFIRMED")
        stocktake_warehouse = self.db.scalar(
            select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake.id)
        )
        self.assertEqual(stocktake_warehouse.status, "UNLOCKED")
  • Step 2: Run the new backend test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm.StocktakeExcelDiffConfirmTest.test_stocktake_status_sequence_supports_status_driven_ui

Expected: pass with OK.

  • Step 3: Run all stocktake tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm

Expected: pass with OK.


Task 8: Full Verification and Manual Smoke Checklist

Files:

  • Verify only.

  • Step 1: Run frontend workflow self-test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs

Expected output:

stocktake workflow tests passed
  • Step 2: Run frontend production build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: Vite exits with code 0. Chunk-size warnings are acceptable.

  • Step 3: Run backend stocktake tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm

Expected: pass with OK.

  • Step 4: Run backend compile check

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m compileall app

Expected: command exits with code 0.

  • Step 5: Manual smoke test the UI

Start backend and frontend using the project's normal local commands, then verify these paths in the browser:

1. Open 百华仓库 and click 盘库.
2. Warehouse cards show 可盘库 or active stocktake status.
3. Start a stocktake for one warehouse.
4. Dialog moves to 导出清单 and shows the selected warehouse name.
5. Export the workbook.
6. If the warehouse is empty, dialog moves to 差异确认 and says no adjustment will be created.
7. If the warehouse has stock, dialog moves to 导入盘点.
8. Import the exported workbook without changes.
9. Diff page shows rows as 一致 and confirm button is enabled.
10. Modify one counted weight in the workbook and import again.
11. Diff page shows red/green difference rows with system snapshot on the left and counted result on the right.
12. Confirm opens a confirmation dialog.
13. Confirming writes the stocktake, closes the dialog, refreshes warehouse data, and unlocks the warehouse.
14. Reopen 盘库 and confirm the history row shows 已确认.

Self-Review

  • Spec coverage: the plan covers the chosen option 2 by keeping backend tables/services, replacing the arbitrary tab wizard with a status-driven UI, improving empty-stocktake feedback, rebuilding side-by-side diff display, and preserving history continuation.
  • Placeholder scan: no TBD, TODO, or unspecified “handle later” items are used.
  • Type consistency: frontend helper names used in StocktakeDialog.vue match the helper exports in Task 1. Backend test names and imports match existing test module patterns.
  • Risk noted: this plan intentionally does not redesign Excel columns or database schema. If user dissatisfaction includes Excel format itself, that should be a separate plan.