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

1099 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Single Warehouse Stocktake Flow 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:** Change 百华仓库盘库 into a current-selected-warehouse-only flow: select a warehouse bar first, click 盘库, confirm/export one locked warehouse sheet, import only that same stocktake workbook, show a modal diff immediately, and confirm to reset that warehouse inventory and unlock it.
**Architecture:** Backend becomes the source of truth for single-warehouse stocktake rules, workbook validation token checks, import overwrite behavior, and current-warehouse history filtering. Frontend removes multi-warehouse selection from the stocktake dialog and renders two blocks: a stocktake flow block for the currently selected warehouse and a stocktake records block filtered to that warehouse. The diff review is opened as a modal immediately after each import; cancel closes the modal and leaves the warehouse locked for re-import.
**Tech Stack:** FastAPI, SQLAlchemy ORM, openpyxl, Python unittest, Vue 3 Composition API, Node built-in `assert`, Vite.
---
## Scope Rules
- Keep existing stocktake tables: `wh_stocktake`, `wh_stocktake_warehouse`, `wh_stocktake_line`, and `wh_stocktake_adjustment`.
- Do not add a migration unless tests prove existing columns cannot represent the requested flow.
- Backend must reject multi-warehouse stocktake creation even if a caller bypasses the UI.
- Backend must reject import files that are not for the current stocktake number and current warehouse.
- Re-importing a corrected workbook must replace the previous imported diff preview for that same stocktake before confirmation.
- Frontend must not let the user choose warehouses inside the stocktake dialog. The active warehouse comes from the warehouse bar selection.
- The workspace root `/Users/souplearn/Gitlab/py/ForgeFlow-ERP` is not a git repository in this environment, so plan steps do not include commits.
## File Structure
- Modify: `backend/app/services/stocktake.py`
- Enforce one warehouse per stocktake.
- Build and validate workbook check tokens.
- Export one warehouse workbook with a validation marker.
- Import only matching current-stocktake/current-warehouse workbooks.
- Reset previous imported preview rows before re-import.
- Modify: `backend/app/api/routes/inventory.py`
- Filter stocktake history by current warehouse with `warehouse_id`.
- Keep import/export route shapes stable.
- Modify: `backend/app/schemas/operations.py`
- Tighten `StocktakeStartCreate` to one warehouse in request validation if supported by current Pydantic.
- Modify: `backend/tests/test_stocktake_excel_diff_confirm.py`
- Add backend regression tests for single-warehouse start, validation marker export, wrong stocktake import rejection, wrong warehouse import rejection, and re-import overwrite.
- Modify: `frontend/src/components/StocktakeDialog.vue`
- Replace multi-warehouse overview cards with current warehouse flow + current warehouse records.
- Start and export in one user action.
- Open diff as modal immediately after import.
- Cancel diff modal without unlocking; allow re-import.
- Modify: `frontend/src/utils/stocktakeWorkflow.js`
- Add current-warehouse-only helpers for active stocktake lookup and panel routing.
- Modify: `frontend/scripts/test-stocktake-workflow.mjs`
- Update workflow tests to cover current-selected-warehouse behavior and no global fallback.
- Modify: `frontend/src/styles/main.css`
- Style the two-block stocktake dialog, current warehouse badge, and diff modal.
---
## Task 1: Backend Failing Tests for Single-Warehouse Rules
**Files:**
- Modify: `backend/tests/test_stocktake_excel_diff_confirm.py`
- [ ] **Step 1: Add imports used by new tests**
Ensure this import line includes `Workbook`:
```python
from openpyxl import Workbook, load_workbook
```
If the file currently imports `Workbook` inside a test method, leave local imports alone unless duplicate imports become confusing.
- [ ] **Step 2: Add a test that multi-warehouse stocktake creation is rejected**
Append this method to `StocktakeExcelDiffConfirmTest`:
```python
def test_start_stocktake_rejects_multiple_warehouses_for_current_bar_flow(self) -> None:
from fastapi import HTTPException
now = datetime.now(UTC)
aux = Warehouse(
id=2,
warehouse_code="WH-AUX-01",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(aux)
self.db.commit()
with self.assertRaises(HTTPException) as exc:
start_stocktake(self.db, warehouse_ids=[self.raw.id, aux.id], user_id=1, remark="多库盘库")
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("一次只能盘一个仓库", exc.exception.detail)
```
- [ ] **Step 3: Add a test that export includes a validation marker and one warehouse sheet**
Append this method:
```python
def test_single_warehouse_export_contains_validation_marker(self) -> None:
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库")
workbook = build_stocktake_workbook(self.db, stocktake.id)
output = BytesIO()
workbook.save(output)
loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True)
self.assertEqual(loaded.sheetnames, ["原材料库"])
rows = list(loaded["原材料库"].iter_rows(values_only=True))
headers = rows[0]
self.assertIn("盘库校验码", headers)
self.assertIn("仓库ID", headers)
self.assertEqual(rows[1][headers.index("盘库单号")], stocktake.stocktake_no)
self.assertEqual(rows[1][headers.index("仓库ID")], self.raw.id)
self.assertEqual(
rows[1][headers.index("盘库校验码")],
f"{stocktake.stocktake_no}|{self.raw.id}|RAW",
)
```
- [ ] **Step 4: Add a helper for workbook mutation in tests**
Append this private helper method to the test class:
```python
def _exported_workbook_bytes(self, stocktake) -> bytes:
workbook = build_stocktake_workbook(self.db, stocktake.id)
output = BytesIO()
workbook.save(output)
return output.getvalue()
```
- [ ] **Step 5: Add a test that wrong stocktake number is rejected**
Append this method:
```python
def test_import_rejects_workbook_from_other_stocktake_number(self) -> None:
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库")
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("盘库单号") + 1).value = "PK-OTHER-001"
worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = "PK-OTHER-001|1|RAW"
output = BytesIO()
workbook.save(output)
with self.assertRaises(ValueError) as exc:
import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertIn("不属于当前盘库单", str(exc.exception))
```
- [ ] **Step 6: Add a test that wrong warehouse marker is rejected**
Append this method:
```python
def test_import_rejects_workbook_from_other_warehouse(self) -> None:
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库")
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("仓库") + 1).value = "成品库"
worksheet.cell(row=2, column=headers.index("仓库ID") + 1).value = 999
worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = f"{stocktake.stocktake_no}|999|FINISHED"
output = BytesIO()
workbook.save(output)
with self.assertRaises(ValueError) as exc:
import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertIn("不属于当前仓库", str(exc.exception))
```
- [ ] **Step 7: Add a test that re-import overwrites previous diff preview**
Append this method:
```python
def test_reimport_overwrites_previous_stocktake_preview(self) -> None:
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库")
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 90
output = BytesIO()
workbook.save(output)
first_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(first_preview["summary"]["loss_count"], 1)
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 100
output = BytesIO()
workbook.save(output)
second_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(second_preview["summary"]["loss_count"], 0)
self.assertEqual(second_preview["summary"]["match_count"], 1)
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
self.assertEqual(line.diff_type, "MATCH")
self.assertEqual(float(line.counted_weight_kg), 100)
```
- [ ] **Step 8: Run tests and confirm they fail for missing implementation**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm
```
Expected: fail because current backend still allows multi-warehouse stocktakes and does not export/validate `盘库校验码` and `仓库ID`.
---
## Task 2: Backend Single-Warehouse Service Implementation
**Files:**
- Modify: `backend/app/services/stocktake.py`
- Modify: `backend/app/schemas/operations.py`
- [ ] **Step 1: Add workbook validation headers**
In `backend/app/services/stocktake.py`, extend `STOCKTAKE_EXCEL_HEADERS` so the first columns are:
```python
STOCKTAKE_EXCEL_HEADERS = [
"盘库单号",
"盘库校验码",
"仓库ID",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源材料分批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
]
```
- [ ] **Step 2: Add validation token helper**
Add below `stocktake_warehouse_type_label`:
```python
def stocktake_validation_token(stocktake_no: str, warehouse_id: int, warehouse_type: str | None) -> str:
return f"{stocktake_no}|{int(warehouse_id)}|{str(warehouse_type or '').upper()}"
```
- [ ] **Step 3: Enforce one warehouse in service**
In `start_stocktake`, after `ids = ...`, add:
```python
if len(set(ids)) != 1:
raise HTTPException(status_code=400, detail="一次只能盘一个仓库,请先在仓库栏选中单个仓库")
```
Keep the existing empty and invalid warehouse checks.
- [ ] **Step 4: Tighten schema request validation**
In `backend/app/schemas/operations.py`, change:
```python
class StocktakeStartCreate(BaseModel):
warehouse_ids: list[int] = Field(min_length=1)
remark: str | None = None
```
to:
```python
class StocktakeStartCreate(BaseModel):
warehouse_ids: list[int] = Field(min_length=1, max_length=1)
remark: str | None = None
```
- [ ] **Step 5: Update export rows to include token and warehouse ID**
In `build_stocktake_workbook`, compute token per stocktake warehouse. Replace worksheet append rows so empty marker rows start with:
```python
token = stocktake_validation_token(stocktake.stocktake_no, warehouse.id, warehouse.warehouse_type)
worksheet.append(
[
stocktake.stocktake_no,
token,
warehouse.id,
"",
sheet_name,
"",
"",
"",
"",
"",
0,
0,
0,
0,
0,
EMPTY_STOCKTAKE_MARKER_REMARK,
]
)
```
For normal stocktake lines, row values must start with:
```python
[
stocktake.stocktake_no,
stocktake_validation_token(stocktake.stocktake_no, line.warehouse_id, line.warehouse_type),
line.warehouse_id,
line.id,
line.warehouse_name,
...
]
```
Update column widths list to 16 columns:
```python
[18, 30, 10, 12, 14, 14, 18, 28, 22, 24, 14, 16, 12, 14, 16, 24]
```
- [ ] **Step 6: Add helper to get the single stocktake warehouse**
Add below `_stocktake_lines`:
```python
def _single_stocktake_warehouse(db: Session, stocktake_id: int) -> StocktakeWarehouse:
rows = db.scalars(
select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id).order_by(StocktakeWarehouse.id.asc())
).all()
if len(rows) != 1:
raise HTTPException(status_code=400, detail="当前盘库单不是单仓库盘库,无法继续处理")
return rows[0]
```
- [ ] **Step 7: Update empty marker detection for shifted columns**
No logic change is needed if `_is_empty_stocktake_marker_row` uses header labels. Verify it still checks `快照行ID`, material fields, and numeric columns by name.
- [ ] **Step 8: Add workbook validation helper**
Add below `_is_empty_stocktake_marker_row`:
```python
def _validate_stocktake_row_marker(row: dict[str, object], stocktake: Stocktake, warehouse: StocktakeWarehouse) -> None:
row_stocktake_no = _clean_text(row.get("盘库单号"))
if row_stocktake_no != stocktake.stocktake_no:
raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}")
row_warehouse_id = _clean_text(row.get("仓库ID"))
if row_warehouse_id and int(float(row_warehouse_id)) != int(warehouse.warehouse_id):
raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致")
row_warehouse_name = _clean_text(row.get("仓库"))
if row_warehouse_name and row_warehouse_name != warehouse.warehouse_name:
raise ValueError("导入清单不属于当前仓库,请确认仓库栏选中项和盘库清单一致")
expected_token = stocktake_validation_token(stocktake.stocktake_no, warehouse.warehouse_id, warehouse.warehouse_type)
row_token = _clean_text(row.get("盘库校验码"))
if row_token != expected_token:
raise ValueError("盘库校验码不匹配,请导入本次盘库导出的仓库清单")
```
- [ ] **Step 9: Add re-import reset helper**
Add below `_validate_stocktake_row_marker`:
```python
def _reset_stocktake_preview_for_reimport(db: Session, stocktake_id: int, warehouse_id: int) -> dict[int, StocktakeLine]:
lines = db.scalars(
select(StocktakeLine)
.where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == warehouse_id)
.order_by(StocktakeLine.line_no.asc())
).all()
existing_lines: dict[int, StocktakeLine] = {}
for line in lines:
if line.lot_id is None and line.diff_type == "NEW_LOT":
db.delete(line)
continue
line.counted_qty = None
line.counted_weight_kg = None
line.diff_qty = Decimal("0")
line.diff_weight_kg = Decimal("0")
line.diff_amount = Decimal("0")
line.diff_type = "MATCH"
line.row_status = "SNAPSHOT"
line.error_message = None
line.remark = None
db.add(line)
existing_lines[line.id] = line
db.flush()
return existing_lines
```
- [ ] **Step 10: Rewrite import to validate current warehouse and overwrite previous preview**
In `import_stocktake_workbook`, after stocktake status check, add:
```python
stocktake_warehouse = _single_stocktake_warehouse(db, stocktake_id)
```
Then replace `existing_lines = {...}` with:
```python
existing_lines = _reset_stocktake_preview_for_reimport(db, stocktake_id, stocktake_warehouse.warehouse_id)
```
Inside `for row in rows`, replace the current stocktake number validation with:
```python
_validate_stocktake_row_marker(row, stocktake, stocktake_warehouse)
```
For new rows without `快照行ID`, remove lookup by arbitrary warehouse name and force the stocktake warehouse:
```python
warehouse = db.get(Warehouse, stocktake_warehouse.warehouse_id)
if not item or not warehouse:
raise ValueError(f"新增批次行物料或仓库不存在:{stocktake_warehouse.warehouse_name} / {item_code}")
```
When creating `StocktakeLine` for new rows, set:
```python
stocktake_warehouse_id=stocktake_warehouse.id,
warehouse_id=warehouse.id,
warehouse_name=warehouse.warehouse_name,
warehouse_type=str(warehouse.warehouse_type or "").upper(),
```
In the missing-line loop, only iterate current `existing_lines`.
- [ ] **Step 11: Return only current warehouse lines after import**
At the end of `import_stocktake_workbook`, replace final `lines = ...` query with:
```python
lines = db.scalars(
select(StocktakeLine)
.where(StocktakeLine.stocktake_id == stocktake_id, StocktakeLine.warehouse_id == stocktake_warehouse.warehouse_id)
.order_by(StocktakeLine.line_no.asc())
).all()
```
- [ ] **Step 12: Run backend stocktake tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm
.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm
```
Expected: both commands pass with `OK`.
---
## Task 3: Backend Routes for Current-Warehouse History and Lines
**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- [ ] **Step 1: Add warehouse filter to stocktake read summary**
Modify `_stocktake_read` signature:
```python
def _stocktake_read(db: Session, stocktake_id: int, warehouse_id: int | None = None) -> StocktakeRead:
```
Replace:
```python
lines = stocktake_lines_for_read(db, stocktake_id)
```
with:
```python
lines = stocktake_lines_for_read(db, stocktake_id)
if warehouse_id:
lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)]
```
- [ ] **Step 2: Filter `/inventory/stocktakes` by warehouse ID**
Change route signature:
```python
def list_inventory_stocktakes(
limit: int = Query(default=100, ge=1, le=500),
warehouse_id: int | None = Query(default=None),
db: Session = Depends(get_db),
) -> list[StocktakeRead]:
```
Replace the query with:
```python
stmt = select(Stocktake).order_by(Stocktake.started_at.desc(), Stocktake.id.desc()).limit(limit)
if warehouse_id:
stmt = (
select(Stocktake)
.join(StocktakeWarehouse, StocktakeWarehouse.stocktake_id == Stocktake.id)
.where(StocktakeWarehouse.warehouse_id == warehouse_id)
.order_by(Stocktake.started_at.desc(), Stocktake.id.desc())
.limit(limit)
)
rows = db.scalars(stmt).all()
return [_stocktake_read(db, row.id, warehouse_id=warehouse_id) for row in rows]
```
- [ ] **Step 3: Filter lines endpoint by warehouse ID**
Change route signature:
```python
def list_inventory_stocktake_lines(
stocktake_id: int,
warehouse_id: int | None = Query(default=None),
db: Session = Depends(get_db),
) -> list[StocktakeLineRead]:
```
Replace return with:
```python
lines = stocktake_lines_for_read(db, stocktake_id)
if warehouse_id:
lines = [line for line in lines if int(line.warehouse_id) == int(warehouse_id)]
return [StocktakeLineRead.model_validate(_stocktake_line_read(line)) for line in lines]
```
- [ ] **Step 4: Preserve import preview response**
In `_stocktake_preview_read`, do not filter because backend import is now single-warehouse. Confirm it still returns `result["lines"]`.
- [ ] **Step 5: Run route-adjacent 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 4: Frontend Workflow Helper for Current Warehouse Only
**Files:**
- Modify: `frontend/src/utils/stocktakeWorkflow.js`
- Modify: `frontend/scripts/test-stocktake-workflow.mjs`
- [ ] **Step 1: Add helper for current warehouse history filtering**
In `frontend/src/utils/stocktakeWorkflow.js`, add:
```js
export function stocktakeBelongsToWarehouse(row, warehouseId) {
const id = Number(warehouseId || 0);
if (!id || !Array.isArray(row?.warehouses)) return false;
return row.warehouses.some((warehouse) => Number(warehouse.warehouse_id) === id);
}
export function filterStocktakesForWarehouse(historyRows = [], warehouseId) {
return historyRows.filter((row) => stocktakeBelongsToWarehouse(row, warehouseId));
}
```
- [ ] **Step 2: Replace global fallback helper usage policy in tests**
In `frontend/scripts/test-stocktake-workflow.mjs`, add imports:
```js
filterStocktakesForWarehouse,
stocktakeBelongsToWarehouse,
```
Add assertions:
```js
assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 1), true);
assert.equal(stocktakeBelongsToWarehouse(lockedRaw, 4), false);
assert.deepEqual(filterStocktakesForWarehouse([lockedRaw, emptyExported], 1).map((row) => row.id), [12]);
```
- [ ] **Step 3: Add source assertions that global fallback is not used by dialog**
Add after existing component source assertions:
```js
assert.doesNotMatch(componentSource, /findOnlyActiveStocktake\\(historyRows\\.value\\)/);
assert.match(componentSource, /findActiveStocktakeForWarehouse\\(historyRows\\.value, currentWarehouseId\\.value\\)/);
assert.match(componentSource, /\\/inventory\\/stocktakes\\?warehouse_id=\\$\\{currentWarehouseId\\.value\\}/);
```
- [ ] **Step 4: Run frontend helper test and confirm it fails before dialog implementation**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs
```
Expected: fail because `StocktakeDialog.vue` still uses `findOnlyActiveStocktake(historyRows.value)` and does not request `warehouse_id`.
---
## Task 5: Frontend Single-Warehouse Dialog Flow
**Files:**
- Modify: `frontend/src/components/StocktakeDialog.vue`
- [ ] **Step 1: Update imports**
Remove unused imports if no longer needed:
```js
findOnlyActiveStocktake,
warehouseStocktakeStatus
```
Add:
```js
filterStocktakesForWarehouse,
```
- [ ] **Step 2: Replace multi-step labels**
Change `flowSteps` to:
```js
const flowSteps = [
{ key: "export", no: "01", label: "确认盘库并导出" },
{ key: "import", no: "02", label: "导入清单" }
];
```
- [ ] **Step 3: Replace selection state with current warehouse state**
Remove:
```js
const selectedWarehouseIds = ref([]);
const activeWarehouseStatuses = computed(...);
const selectedStartableWarehouseIds = computed(...);
const activeWarehouseNames = computed(...);
```
Add:
```js
const currentWarehouseId = computed(() => Number(props.defaultWarehouseId || 0));
const currentWarehouse = computed(
() => props.warehouses.find((warehouse) => Number(warehouse.id) === currentWarehouseId.value) || null
);
const currentWarehouseName = computed(() => currentWarehouse.value?.warehouse_name || "未选择仓库");
const currentWarehouseHistoryRows = computed(() => filterStocktakesForWarehouse(historyRows.value, currentWarehouseId.value));
```
- [ ] **Step 4: Replace open watcher**
Replace watcher body with:
```js
watch(
() => props.open,
async (open) => {
if (!open) return;
activePanel.value = "export";
activeStocktake.value = null;
previewLines.value = [];
previewSummary.value = {};
diffFilter.value = "ALL";
confirmDialogOpen.value = false;
diffDialogOpen.value = false;
dialogMessage.value = "";
dialogError.value = "";
if (!currentWarehouseId.value) {
setDialogError("请先在仓库栏选中一个仓库,再进行盘库");
return;
}
await loadHistory();
const activeRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value);
if (activeRow) {
await continueStocktake(activeRow, { silent: true });
}
}
);
```
- [ ] **Step 5: Add diff modal state**
Add near `confirmDialogOpen`:
```js
const diffDialogOpen = ref(false);
```
In `applyLoadedStocktake`, set `diffDialogOpen.value = false` before loading state.
- [ ] **Step 6: Replace `loadHistory` with warehouse-filtered API call**
Replace `loadHistory` with:
```js
async function loadHistory() {
try {
if (!currentWarehouseId.value) {
historyRows.value = [];
return;
}
historyRows.value = await fetchResource(`/inventory/stocktakes?warehouse_id=${currentWarehouseId.value}&limit=100`, []);
} catch (error) {
setDialogError(error.message || "读取盘库记录失败");
}
}
```
- [ ] **Step 7: Replace start flow with start-and-export action**
Replace `startStocktake` with:
```js
async function startAndExportStocktake() {
clearDialogFeedback();
if (!currentWarehouseId.value) {
setDialogError("请先在仓库栏选中一个仓库");
return;
}
busy.value = true;
try {
const stocktake = await postResource("/inventory/stocktakes", {
warehouse_ids: [currentWarehouseId.value],
remark: `${currentWarehouseName.value}盘库`
});
activeStocktake.value = stocktake;
await downloadResource(`/inventory/stocktakes/${stocktake.id}/export`, `${stocktake.stocktake_no}_${currentWarehouseName.value}_盘库清单.xlsx`);
const exportedStocktake = await fetchResource(`/inventory/stocktakes/${stocktake.id}`, null);
if (!exportedStocktake) {
throw new Error("导出后读取盘库单失败,请重新打开盘库");
}
await loadHistory();
await applyLoadedStocktake(exportedStocktake, { silent: true });
setDialogMessage(`盘库单 ${exportedStocktake.stocktake_no} 已导出,${currentWarehouseName.value} 已锁库`);
} catch (error) {
setDialogError(error.message || "开始盘库并导出失败");
} finally {
busy.value = false;
}
}
```
Keep `exportStocktake` only if used for continuing a `LOCKED` stocktake that has not exported. If kept, make it use `currentWarehouseName`.
- [ ] **Step 8: Update import stocktake line loading to current warehouse**
In `applyLoadedStocktake`, change line fetch:
```js
const lineRows = await fetchResource(`/inventory/stocktakes/${stocktake.id}/lines?warehouse_id=${currentWarehouseId.value}`, null);
```
- [ ] **Step 9: Replace import fallback**
In `importStocktake`, replace fallback lookup with:
```js
const fallbackRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value);
```
- [ ] **Step 10: Open diff modal immediately after import success**
In `importStocktake`, after setting `activePanel.value = "diff";`, add:
```js
diffDialogOpen.value = true;
activePanel.value = "import";
```
This keeps the flow block on the import step while the modal shows differences.
- [ ] **Step 11: Replace template overview/export/import/history sections**
Replace the current overview/export/import blocks with this two-block flow:
```vue
<section v-if="activePanel === 'export'" class="stocktake-panel stocktake-single-panel">
<div class="stocktake-action-card stocktake-action-card-wide">
<span class="stocktake-stage-tag">当前仓库:{{ currentWarehouseName }}</span>
<strong>01 确认盘库并导出仓库清单</strong>
<p>点击后系统会锁定当前仓库,并导出本次盘库 Excel 清单。盘库结束前该仓库不能出入库。</p>
<div class="stocktake-inline-actions">
<button class="primary-button" type="button" :disabled="busy || !currentWarehouseId" @click="startAndExportStocktake">
{{ busy ? "处理中..." : "确认盘库并导出仓库清单" }}
</button>
<button v-if="activeStocktake?.status === 'LOCKED'" class="ghost-button" type="button" :disabled="busy" @click="exportStocktake">
重新导出清单
</button>
</div>
</div>
</section>
<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>02 导入盘点后的 Excel</strong>
<p>只允许导入当前仓库、本次盘库单导出的清单。导入成功后会立刻弹出差异对比。</p>
<label class="import-dropzone">
<input ref="fileInputRef" type="file" accept=".xlsx,.xlsm" :disabled="busy" @change="importStocktake" />
<strong>{{ busy ? "导入中..." : "选择盘点后的 Excel" }}</strong>
<span>如果差异确认时取消可以修改 Excel 后重新导入覆盖旧差异</span>
</label>
</div>
</section>
```
- [ ] **Step 12: Move diff table into modal**
Wrap the existing diff panel content in a modal:
```vue
<div v-if="diffDialogOpen" class="stocktake-diff-modal-mask" @click.self="closeDiffDialog">
<section class="stocktake-diff-modal">
<header class="stocktake-diff-modal-head">
<div>
<p class="eyebrow">盘库差异对比</p>
<h3>{{ activeStocktake?.stocktake_no }} · {{ currentWarehouseName }}</h3>
</div>
<button class="ghost-button" type="button" :disabled="busy" @click="closeDiffDialog">取消,重新导入</button>
</header>
<!-- keep current summary cards, toolbar, diff table here -->
<div class="stocktake-footer">
<button class="primary-button" type="button" :disabled="busy || !canConfirmCurrentStocktake" @click="openConfirmDialog">
{{ busy ? "确认中..." : "确认更新库存并解锁" }}
</button>
</div>
</section>
</div>
```
Add:
```js
function closeDiffDialog() {
if (busy.value) return;
diffDialogOpen.value = false;
}
```
- [ ] **Step 13: Filter history display to current warehouse**
In history rows template, replace:
```vue
v-for="row in historyRows"
```
with:
```vue
v-for="row in currentWarehouseHistoryRows"
```
Replace empty state condition:
```vue
v-if="!currentWarehouseHistoryRows.length"
```
- [ ] **Step 14: Update confirm success behavior**
In `confirmStocktake`, after successful confirm, replace `emit("close")` with:
```js
diffDialogOpen.value = false;
activePanel.value = "export";
activeStocktake.value = null;
previewLines.value = [];
previewSummary.value = {};
emit("changed");
```
Do not close the whole stocktake dialog automatically unless the user explicitly closes it.
- [ ] **Step 15: Run frontend checks**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs
npm run build
```
Expected: workflow test passes and Vite exits code 0.
---
## Task 6: Frontend Styles for Single-Warehouse Flow and Diff Modal
**Files:**
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add single warehouse flow styles**
Append near existing stocktake styles:
```css
.stocktake-single-panel {
display: grid;
gap: 16px;
align-content: start;
}
.stocktake-diff-modal-mask {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
place-items: center;
padding: 22px;
background: rgba(15, 23, 42, 0.34);
}
.stocktake-diff-modal {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
width: min(1320px, calc(100vw - 64px));
height: min(820px, calc(100vh - 64px));
min-height: 0;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.5);
border-radius: 24px;
background: rgba(248, 250, 252, 0.98);
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.3);
}
.stocktake-diff-modal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 22px;
border-bottom: 1px solid rgba(148, 163, 184, 0.35);
}
.stocktake-diff-modal .stocktake-summary-grid,
.stocktake-diff-modal .stocktake-empty-state,
.stocktake-diff-modal .stocktake-diff-toolbar {
margin: 14px 18px 0;
}
.stocktake-diff-modal .stocktake-diff-table-v2 {
margin: 14px 18px;
min-height: 0;
}
.stocktake-diff-modal .stocktake-footer {
padding: 14px 18px 18px;
border-top: 1px solid rgba(148, 163, 184, 0.35);
}
```
- [ ] **Step 2: Remove or neutralize obsolete overview card styles only if no longer used**
If `.stocktake-overview-card` styles remain unused, leave them in place for now to avoid broad CSS churn. Do not delete large CSS blocks in this task.
- [ ] **Step 3: Run frontend build**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected: Vite exits code 0.
---
## Task 7: End-to-End Backend and Frontend Verification
**Files:**
- Verify only.
- [ ] **Step 1: 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 2: Run backend compile check**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m compileall app
```
Expected: exit code 0.
- [ ] **Step 3: 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 4: Run frontend build**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build
```
Expected: Vite exits code 0. Existing chunk warning is acceptable.
- [ ] **Step 5: Manual browser smoke test**
Use the running ERP UI and verify:
```text
1. Select 原材料库 in 百华仓库 warehouse bar.
2. Click 盘库.
3. Dialog shows 当前仓库:原材料库 and no multi-warehouse checkbox cards.
4. Click 确认盘库并导出仓库清单.
5. Exported Excel has one sheet and includes 盘库单号、盘库校验码、仓库ID.
6. Warehouse is locked and stocktake dialog reopens to 导入清单 for the same warehouse.
7. Import the same Excel. Diff modal appears immediately.
8. Click 取消,重新导入. Modal closes, warehouse remains locked, import control remains available.
9. Modify counted weight and import again. New diff replaces old diff.
10. Try importing an Excel from a different stocktake number. System rejects it.
11. Try importing an Excel with wrong warehouse marker. System rejects it.
12. Click 确认更新库存并解锁. Stock is reset to imported workbook values, stocktake is completed, warehouse unlocks.
13. Select another warehouse and click 盘库. Records shown are only for that selected warehouse.
```
---
## Self-Review
- Spec coverage: covers all six user rules: warehouse-bar selected warehouse determines stocktake, flow has confirm/export first step, user can edit sheet offline, reopening same warehouse resumes import step, import opens diff modal with confirm/cancel, and workbook validation rejects wrong stocktake/warehouse files.
- Placeholder scan: no `TBD`, `TODO`, or unspecified edge handling remains.
- Type consistency: backend token format is consistently `"{stocktake_no}|{warehouse_id}|{warehouse_type}"`; frontend current warehouse ID is consistently `currentWarehouseId`.
- Scope risk: existing `wh_stocktake` can technically contain historical multi-warehouse rows from older tests/data. New service rejects new multi-warehouse stocktakes; old records can remain visible in history unless filtered by `warehouse_id`.
- Verification: final plan includes backend unit tests, backend compile, frontend workflow script, frontend build, and browser smoke test.