36 KiB
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, andwh_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-ERPis 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.
- Filter stocktake history by current warehouse with
-
Modify:
backend/app/schemas/operations.py- Tighten
StocktakeStartCreateto one warehouse in request validation if supported by current Pydantic.
- Tighten
-
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:
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:
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:
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:
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:
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:
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:
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:
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:
STOCKTAKE_EXCEL_HEADERS = [
"盘库单号",
"盘库校验码",
"仓库ID",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源材料分批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
]
- Step 2: Add validation token helper
Add below stocktake_warehouse_type_label:
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:
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:
class StocktakeStartCreate(BaseModel):
warehouse_ids: list[int] = Field(min_length=1)
remark: str | None = None
to:
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:
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:
[
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:
[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:
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:
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:
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:
stocktake_warehouse = _single_stocktake_warehouse(db, stocktake_id)
Then replace existing_lines = {...} with:
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:
_validate_stocktake_row_marker(row, stocktake, stocktake_warehouse)
For new rows without 快照行ID, remove lookup by arbitrary warehouse name and force the stocktake warehouse:
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:
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:
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:
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:
def _stocktake_read(db: Session, stocktake_id: int, warehouse_id: int | None = None) -> StocktakeRead:
Replace:
lines = stocktake_lines_for_read(db, stocktake_id)
with:
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/stocktakesby warehouse ID
Change route signature:
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:
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:
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:
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:
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:
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:
filterStocktakesForWarehouse,
stocktakeBelongsToWarehouse,
Add assertions:
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:
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:
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:
findOnlyActiveStocktake,
warehouseStocktakeStatus
Add:
filterStocktakesForWarehouse,
- Step 2: Replace multi-step labels
Change flowSteps to:
const flowSteps = [
{ key: "export", no: "01", label: "确认盘库并导出" },
{ key: "import", no: "02", label: "导入清单" }
];
- Step 3: Replace selection state with current warehouse state
Remove:
const selectedWarehouseIds = ref([]);
const activeWarehouseStatuses = computed(...);
const selectedStartableWarehouseIds = computed(...);
const activeWarehouseNames = computed(...);
Add:
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:
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:
const diffDialogOpen = ref(false);
In applyLoadedStocktake, set diffDialogOpen.value = false before loading state.
- Step 6: Replace
loadHistorywith warehouse-filtered API call
Replace loadHistory with:
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:
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:
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:
const fallbackRow = findActiveStocktakeForWarehouse(historyRows.value, currentWarehouseId.value);
- Step 10: Open diff modal immediately after import success
In importStocktake, after setting activePanel.value = "diff";, add:
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:
<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:
<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:
function closeDiffDialog() {
if (busy.value) return;
diffDialogOpen.value = false;
}
- Step 13: Filter history display to current warehouse
In history rows template, replace:
v-for="row in historyRows"
with:
v-for="row in currentWarehouseHistoryRows"
Replace empty state condition:
v-if="!currentWarehouseHistoryRows.length"
- Step 14: Update confirm success behavior
In confirmStocktake, after successful confirm, replace emit("close") with:
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:
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:
.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:
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:
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:
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:
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-stocktake-workflow.mjs
Expected output:
stocktake workflow tests passed
- Step 4: Run frontend build
Run:
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:
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 consistentlycurrentWarehouseId. - Scope risk: existing
wh_stocktakecan 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 bywarehouse_id. - Verification: final plan includes backend unit tests, backend compile, frontend workflow script, frontend build, and browser smoke test.