1099 lines
36 KiB
Markdown
1099 lines
36 KiB
Markdown
# 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.
|