# 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:

```js
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:

```bash
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:

```js
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:

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

Expected output includes:

```text
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:

```js
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:

```js
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`:

```js
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:

```js
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:

```js
const activeStep = ref("select");
```

with:

```js
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:

```bash
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([]);`:

```js
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:

```js
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`:

```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:

```js
function diffClass(line) {
  return `diff-${diffTone(line)}`;
}
```

- [ ] **Step 7: Run helper self-test and Vue build**

Run:

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

Expected:

```text
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:

```vue
<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:

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

with:

```vue
<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`:

```js
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:

```vue
<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:

```vue
<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:

```bash
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:

```js
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:

```vue
<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:

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

with:

```vue
<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:

```css
.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:

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

Expected:

```text
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`:

```js
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:

```js
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:

```js
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:

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

with:

```js
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:

```js
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:

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

with:

```js
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:

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

Expected:

```text
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:

```css
.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:

```bash
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`:

```python
    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:

```bash
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:

```bash
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:

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

Expected output:

```text
stocktake workflow tests passed
```

- [ ] **Step 2: Run frontend production build**

Run:

```bash
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:

```bash
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:

```bash
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:

```text
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.
