1270 lines
39 KiB
Markdown
1270 lines
39 KiB
Markdown
# 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.
|