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