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

36 KiB
Raw Blame History

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:

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/stocktakes by 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 loadHistory with 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 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.