# Locked Warehouse Stocktake 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:** Build a "盘库" feature in 嘉恒仓库 that locks selected warehouses, exports stocktake sheets, imports counted results, shows a side-by-side diff, posts adjustments, records audit logs, and unlocks warehouses after confirmation.
**Architecture:** Backend owns stocktake locking, snapshots, Excel import/export, diff calculation, and inventory adjustment transactions. Frontend adds a stocktake entry above the warehouse bar, drives a wizard-style dialog, and renders a code-diff-like two-table comparison before confirmation. Inventory mutation endpoints must call a shared lock guard so locked warehouses cannot be changed outside stocktake.
**Tech Stack:** FastAPI, SQLAlchemy ORM, openpyxl, unittest, Vue 3 Composition API, existing `FormDrawer`, `TableControls`, `PaginationBar`, `downloadResource`, `uploadResource`, `postResource`, Vite.
---
## File Structure
- Create `backend/app/services/stocktake.py`
- Builds stocktake numbers.
- Checks active warehouse locks.
- Creates stocktake snapshots.
- Builds and parses Excel workbooks.
- Calculates diff rows.
- Applies confirmed adjustments.
- Modify `backend/app/models/operations.py`
- Add `Stocktake`, `StocktakeWarehouse`, `StocktakeLine`, and `StocktakeAdjustment` SQLAlchemy models.
- Modify `backend/app/schemas/operations.py`
- Add request/response schemas for stocktake start, import preview, diff rows, confirm, cancel, and history.
- Modify `backend/app/api/routes/inventory.py`
- Add `/inventory/stocktakes` routes.
- Add lock guard calls to `/inventory/inbound`, `/inventory/outbound`, and `/inventory/customer-supplied-inbound`.
- Modify `backend/app/api/routes/purchase.py`
- Guard purchase receipt creation when target warehouse is locked.
- Modify `backend/app/api/routes/production.py`
- Guard production work order auto issue and completion receipt posting when affected warehouse is locked.
- Modify `backend/app/api/routes/sales.py`
- Guard delivery creation when finished warehouse is locked.
- Create `backend/sql/add_stocktake_tables.sql`
- MySQL patch for production databases.
- Create `backend/tests/test_stocktake_locking.py`
- Unit tests for lock guard and route-level blocking.
- Create `backend/tests/test_stocktake_excel_diff_confirm.py`
- Unit tests for snapshot export, import diff, and confirmation adjustments.
- Modify `frontend/src/views/InventoryLedgerView.vue`
- Add stocktake action above warehouse bar.
- Add stocktake dialog state and API calls.
- Include stocktake dialog component.
- Create `frontend/src/components/StocktakeDialog.vue`
- Wizard UI: choose warehouses, export, import, diff preview, confirm, history.
- Modify `frontend/src/styles/main.css`
- Add stocktake button, wizard, and side-by-side diff styles.
- Modify `frontend/src/utils/dictionaries.js`
- Add stocktake status and diff type labels if central dictionary is already used for labels.
---
## Task 1: Backend Model and Lock Guard Tests
**Files:**
- Create: `backend/tests/test_stocktake_locking.py`
- Modify: `backend/app/models/operations.py`
- Create: `backend/app/services/stocktake.py`
- [ ] **Step 1: Write failing tests for active warehouse locks**
Create `backend/tests/test_stocktake_locking.py` with this content:
```python
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from fastapi import HTTPException
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.operations # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.master_data import Warehouse # noqa: E402
from app.models.operations import Stocktake, StocktakeWarehouse # noqa: E402
from app.services.stocktake import ensure_warehouses_unlocked, get_locked_warehouse_ids # noqa: E402
class StocktakeLockingTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed()
def tearDown(self) -> None:
self.db.close()
def _seed(self) -> None:
now = datetime.now(UTC)
self.raw = Warehouse(
id=1,
warehouse_code="WH-RAW-01",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished = Warehouse(
id=2,
warehouse_code="WH-FG-01",
warehouse_name="成品库",
warehouse_type="FINISHED",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([self.raw, self.finished])
self.db.flush()
stocktake = Stocktake(
stocktake_no="PK-20260530-001",
status="LOCKED",
started_by_user_id=1,
started_at=now,
remark="测试锁库",
)
self.db.add(stocktake)
self.db.flush()
self.db.add(
StocktakeWarehouse(
stocktake_id=stocktake.id,
warehouse_id=self.raw.id,
warehouse_type="RAW",
warehouse_name="原材料库",
status="LOCKED",
)
)
self.db.commit()
def test_get_locked_warehouse_ids_returns_only_active_stocktakes(self) -> None:
locked = get_locked_warehouse_ids(self.db, [self.raw.id, self.finished.id])
self.assertEqual(locked, {self.raw.id})
def test_ensure_warehouses_unlocked_raises_for_locked_warehouse(self) -> None:
with self.assertRaises(HTTPException) as exc:
ensure_warehouses_unlocked(self.db, [self.raw.id], "入库")
self.assertEqual(exc.exception.status_code, 423)
self.assertIn("原材料库正在盘库", exc.exception.detail)
def test_ensure_warehouses_unlocked_allows_unlocked_warehouse(self) -> None:
ensure_warehouses_unlocked(self.db, [self.finished.id], "销售出库")
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run the lock tests and confirm they fail because models/service are missing**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking
```
Expected: FAIL with import errors for `Stocktake`, `StocktakeWarehouse`, or `app.services.stocktake`.
---
## Task 2: Add Stocktake Models, Schemas, SQL Patch, and Lock Service
**Files:**
- Modify: `backend/app/models/operations.py`
- Modify: `backend/app/schemas/operations.py`
- Create: `backend/app/services/stocktake.py`
- Create: `backend/sql/add_stocktake_tables.sql`
- Test: `backend/tests/test_stocktake_locking.py`
- [ ] **Step 1: Add stocktake ORM models**
Modify `backend/app/models/operations.py` by adding these classes after `InventoryTxn`:
```python
class Stocktake(Base):
__tablename__ = "wh_stocktake"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
stocktake_no: Mapped[str] = mapped_column(String(50))
status: Mapped[str] = mapped_column(String(32))
started_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
imported_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
confirmed_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
canceled_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
started_at: Mapped[object] = mapped_column(DateTime)
exported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
imported_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
confirmed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
canceled_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class StocktakeWarehouse(Base):
__tablename__ = "wh_stocktake_warehouse"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id"))
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
warehouse_type: Mapped[str] = mapped_column(String(32))
warehouse_name: Mapped[str] = mapped_column(String(100))
status: Mapped[str] = mapped_column(String(32))
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class StocktakeLine(Base):
__tablename__ = "wh_stocktake_line"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id"))
stocktake_warehouse_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stocktake_warehouse.id"), nullable=True)
line_no: Mapped[int] = mapped_column(Integer)
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
warehouse_name: Mapped[str] = mapped_column(String(100))
warehouse_type: Mapped[str] = mapped_column(String(32))
location_id: Mapped[int | None] = mapped_column(ForeignKey("wh_location.id"), nullable=True)
location_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
item_code: Mapped[str] = mapped_column(String(50))
item_name: Mapped[str] = mapped_column(String(200))
item_type: Mapped[str] = mapped_column(String(32))
lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
lot_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
source_material_sub_batch_no: Mapped[str | None] = mapped_column(String(80), nullable=True)
snapshot_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
snapshot_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
snapshot_unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
counted_qty: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True)
counted_weight_kg: Mapped[float | None] = mapped_column(DECIMAL(18, 6), nullable=True)
diff_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
diff_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
diff_amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
diff_type: Mapped[str] = mapped_column(String(32))
row_status: Mapped[str] = mapped_column(String(32))
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
remark: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
class StocktakeAdjustment(Base):
__tablename__ = "wh_stocktake_adjustment"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
stocktake_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake.id"))
stocktake_line_id: Mapped[int] = mapped_column(ForeignKey("wh_stocktake_line.id"))
warehouse_id: Mapped[int] = mapped_column(ForeignKey("wh_warehouse.id"))
item_id: Mapped[int] = mapped_column(ForeignKey("md_item.id"))
lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
adjustment_lot_id: Mapped[int | None] = mapped_column(ForeignKey("wh_stock_lot.id"), nullable=True)
inventory_txn_id: Mapped[int | None] = mapped_column(ForeignKey("wh_inventory_txn.id"), nullable=True)
adjustment_type: Mapped[str] = mapped_column(String(32))
before_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
after_qty: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
before_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
after_weight_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
qty_change: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
weight_change_kg: Mapped[float] = mapped_column(DECIMAL(18, 6), server_default=text("0"))
unit_cost: Mapped[float] = mapped_column(DECIMAL(18, 4), server_default=text("0"))
amount: Mapped[float] = mapped_column(DECIMAL(18, 2), server_default=text("0"))
created_at: Mapped[object] = mapped_column(DateTime)
updated_at: Mapped[object] = mapped_column(DateTime)
```
- [ ] **Step 2: Add stocktake schemas**
Modify `backend/app/schemas/operations.py` by adding:
```python
class StocktakeStartCreate(BaseModel):
warehouse_ids: list[int] = Field(min_length=1)
remark: str | None = None
class StocktakeWarehouseRead(BaseModel):
id: int
warehouse_id: int
warehouse_name: str
warehouse_type: str
status: str
class StocktakeLineRead(BaseModel):
id: int
stocktake_id: int
line_no: int
warehouse_id: int
warehouse_name: str
warehouse_type: str
location_id: int | None = None
location_name: str | None = None
item_id: int
item_code: str
item_name: str
item_type: str
lot_id: int | None = None
lot_no: str | None = None
source_material_sub_batch_no: str | None = None
snapshot_qty: float
snapshot_weight_kg: float
snapshot_unit_cost: float
counted_qty: float | None = None
counted_weight_kg: float | None = None
diff_qty: float
diff_weight_kg: float
diff_amount: float
diff_type: str
row_status: str
error_message: str | None = None
remark: str | None = None
class StocktakeRead(BaseModel):
id: int
stocktake_no: str
status: str
started_by_user_id: int | None = None
imported_by_user_id: int | None = None
confirmed_by_user_id: int | None = None
canceled_by_user_id: int | None = None
started_at: datetime
exported_at: datetime | None = None
imported_at: datetime | None = None
confirmed_at: datetime | None = None
canceled_at: datetime | None = None
remark: str | None = None
warehouses: list[StocktakeWarehouseRead] = Field(default_factory=list)
summary: dict[str, float | int] = Field(default_factory=dict)
class StocktakeImportPreviewRead(BaseModel):
stocktake: StocktakeRead
lines: list[StocktakeLineRead]
summary: dict[str, float | int]
class StocktakeConfirmCreate(BaseModel):
confirm_text: str
remark: str | None = None
```
If `Field` is not imported in `backend/app/schemas/operations.py`, add it to the existing Pydantic import.
- [ ] **Step 3: Create SQL patch for production MySQL**
Create `backend/sql/add_stocktake_tables.sql`:
```sql
CREATE TABLE IF NOT EXISTS wh_stocktake (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stocktake_no VARCHAR(50) NOT NULL UNIQUE,
status VARCHAR(32) NOT NULL,
started_by_user_id BIGINT NULL,
imported_by_user_id BIGINT NULL,
confirmed_by_user_id BIGINT NULL,
canceled_by_user_id BIGINT NULL,
started_at DATETIME NOT NULL,
exported_at DATETIME NULL,
imported_at DATETIME NULL,
confirmed_at DATETIME NULL,
canceled_at DATETIME NULL,
remark VARCHAR(255) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_wh_stocktake_status (status),
INDEX idx_wh_stocktake_started_at (started_at)
);
CREATE TABLE IF NOT EXISTS wh_stocktake_warehouse (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stocktake_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
warehouse_type VARCHAR(32) NOT NULL,
warehouse_name VARCHAR(100) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_wh_stocktake_warehouse_stocktake (stocktake_id),
INDEX idx_wh_stocktake_warehouse_lock (warehouse_id, status),
CONSTRAINT fk_stocktake_warehouse_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id),
CONSTRAINT fk_stocktake_warehouse_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id)
);
CREATE TABLE IF NOT EXISTS wh_stocktake_line (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stocktake_id BIGINT NOT NULL,
stocktake_warehouse_id BIGINT NULL,
line_no INT NOT NULL,
warehouse_id BIGINT NOT NULL,
warehouse_name VARCHAR(100) NOT NULL,
warehouse_type VARCHAR(32) NOT NULL,
location_id BIGINT NULL,
location_name VARCHAR(100) NULL,
item_id BIGINT NOT NULL,
item_code VARCHAR(50) NOT NULL,
item_name VARCHAR(200) NOT NULL,
item_type VARCHAR(32) NOT NULL,
lot_id BIGINT NULL,
lot_no VARCHAR(80) NULL,
source_material_sub_batch_no VARCHAR(80) NULL,
snapshot_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
snapshot_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
snapshot_unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0,
counted_qty DECIMAL(18,6) NULL,
counted_weight_kg DECIMAL(18,6) NULL,
diff_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
diff_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
diff_amount DECIMAL(18,2) NOT NULL DEFAULT 0,
diff_type VARCHAR(32) NOT NULL,
row_status VARCHAR(32) NOT NULL,
error_message VARCHAR(500) NULL,
remark VARCHAR(255) NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_wh_stocktake_line_stocktake (stocktake_id),
INDEX idx_wh_stocktake_line_lot (lot_id),
INDEX idx_wh_stocktake_line_diff (diff_type, row_status),
CONSTRAINT fk_stocktake_line_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id),
CONSTRAINT fk_stocktake_line_warehouse FOREIGN KEY (warehouse_id) REFERENCES wh_warehouse(id),
CONSTRAINT fk_stocktake_line_item FOREIGN KEY (item_id) REFERENCES md_item(id)
);
CREATE TABLE IF NOT EXISTS wh_stocktake_adjustment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stocktake_id BIGINT NOT NULL,
stocktake_line_id BIGINT NOT NULL,
warehouse_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
lot_id BIGINT NULL,
adjustment_lot_id BIGINT NULL,
inventory_txn_id BIGINT NULL,
adjustment_type VARCHAR(32) NOT NULL,
before_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
after_qty DECIMAL(18,6) NOT NULL DEFAULT 0,
before_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
after_weight_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
qty_change DECIMAL(18,6) NOT NULL DEFAULT 0,
weight_change_kg DECIMAL(18,6) NOT NULL DEFAULT 0,
unit_cost DECIMAL(18,4) NOT NULL DEFAULT 0,
amount DECIMAL(18,2) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_wh_stocktake_adjustment_stocktake (stocktake_id),
CONSTRAINT fk_stocktake_adjustment_stocktake FOREIGN KEY (stocktake_id) REFERENCES wh_stocktake(id),
CONSTRAINT fk_stocktake_adjustment_line FOREIGN KEY (stocktake_line_id) REFERENCES wh_stocktake_line(id)
);
```
- [ ] **Step 4: Create the stocktake lock service**
Create `backend/app/services/stocktake.py` with the lock constants and guard functions:
```python
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.models.master_data import Warehouse
from app.models.operations import Stocktake, StocktakeWarehouse
ACTIVE_STOCKTAKE_STATUSES = {"LOCKED", "EXPORTED", "IMPORTED"}
def build_stocktake_no(db: Session) -> str:
date_part = date.today().strftime("%Y%m%d")
prefix = f"PK-{date_part}"
count = db.scalar(select(func.count(Stocktake.id)).where(Stocktake.stocktake_no.like(f"{prefix}-%"))) or 0
return f"{prefix}-{count + 1:03d}"
def stocktake_warehouse_type_label(value: str | None) -> str:
return {
"RAW": "原材料库",
"SEMI": "半成品库",
"FINISHED": "成品库",
"AUX": "辅料库",
}.get(str(value or "").upper(), str(value or "未知仓库"))
def get_locked_warehouse_ids(db: Session, warehouse_ids: list[int] | set[int]) -> set[int]:
ids = {int(item) for item in warehouse_ids if item}
if not ids:
return set()
rows = db.scalars(
select(StocktakeWarehouse.warehouse_id)
.join(Stocktake, Stocktake.id == StocktakeWarehouse.stocktake_id)
.where(
StocktakeWarehouse.warehouse_id.in_(ids),
StocktakeWarehouse.status == "LOCKED",
Stocktake.status.in_(ACTIVE_STOCKTAKE_STATUSES),
)
).all()
return {int(row) for row in rows}
def ensure_warehouses_unlocked(db: Session, warehouse_ids: list[int] | set[int], action_label: str) -> None:
locked_ids = get_locked_warehouse_ids(db, warehouse_ids)
if not locked_ids:
return
rows = db.scalars(select(Warehouse).where(Warehouse.id.in_(locked_ids)).order_by(Warehouse.id.asc())).all()
labels = "、".join(row.warehouse_name for row in rows)
raise HTTPException(status_code=423, detail=f"{labels}正在盘库,暂不能执行{action_label}")
def decimal_value(value: object, places: str = "0.000001") -> Decimal:
return Decimal(str(value or 0)).quantize(Decimal(places))
def current_time() -> datetime:
return datetime.now()
```
- [ ] **Step 5: Run lock tests and compile backend**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking
.venv/bin/python -m compileall app
```
Expected: lock tests PASS and compileall PASS.
---
## Task 3: Add Lock Guards to Inventory Mutation Routes
**Files:**
- Modify: `backend/app/api/routes/inventory.py`
- Test: `backend/tests/test_stocktake_locking.py`
- [ ] **Step 1: Add failing route-level test for generic inbound**
Append this test method to `StocktakeLockingTest` in `backend/tests/test_stocktake_locking.py`:
```python
def test_inventory_inbound_is_blocked_when_warehouse_is_locked(self) -> None:
from decimal import Decimal
from app.api.routes.inventory import create_warehouse_inbound
from app.models.master_data import Item
from app.schemas.operations import WarehouseInboundCreate
now = datetime.now(UTC)
item = Item(
id=1,
item_code="原材料00001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(item)
self.db.commit()
class User:
id = 1
class Context:
user = User()
with self.assertRaises(HTTPException) as exc:
create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="OPENING",
item_id=item.id,
warehouse_id=self.raw.id,
inbound_weight_kg=10,
unit_cost=1,
),
context=Context(),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 423)
self.assertIn("正在盘库", exc.exception.detail)
```
- [ ] **Step 2: Run test and confirm failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking.StocktakeLockingTest.test_inventory_inbound_is_blocked_when_warehouse_is_locked
```
Expected: FAIL because `create_warehouse_inbound` does not yet check locks.
- [ ] **Step 3: Add lock checks to inventory routes**
In `backend/app/api/routes/inventory.py`, import the guard:
```python
from app.services.stocktake import ensure_warehouses_unlocked
```
Add these calls:
```python
# create_customer_supplied_inbound, after warehouse exists
ensure_warehouses_unlocked(db, [payload.warehouse_id], "客料入库")
# create_warehouse_inbound, after warehouse exists
ensure_warehouses_unlocked(db, [payload.warehouse_id], "入库")
# create_warehouse_outbound, after warehouse exists
ensure_warehouses_unlocked(db, [payload.warehouse_id], "出库")
```
- [ ] **Step 4: Run lock tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking
```
Expected: PASS.
---
## Task 4: Add Lock Guards to Purchase, Production, and Sales Posting Routes
**Files:**
- Modify: `backend/app/api/routes/purchase.py`
- Modify: `backend/app/api/routes/production.py`
- Modify: `backend/app/api/routes/sales.py`
- Test: `backend/tests/test_stocktake_locking.py`
- [ ] **Step 1: Add failing tests for posting-route lock helper calls**
Append a focused service-level test to `backend/tests/test_stocktake_locking.py` so the route imports are exercised without building every domain payload:
```python
def test_lock_guard_message_names_action(self) -> None:
with self.assertRaises(HTTPException) as exc:
ensure_warehouses_unlocked(self.db, [self.raw.id], "到货入库")
self.assertEqual(exc.exception.status_code, 423)
self.assertIn("到货入库", exc.exception.detail)
```
Run it:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking.StocktakeLockingTest.test_lock_guard_message_names_action
```
Expected: PASS. This test locks the expected user-facing message before adding guard calls to larger routes.
- [ ] **Step 2: Guard purchase receipts**
In `backend/app/api/routes/purchase.py`, import:
```python
from app.services.stocktake import ensure_warehouses_unlocked
```
Inside `create_purchase_receipt`, after determining `warehouse_id` and before creating `PurchaseReceipt`, add:
```python
ensure_warehouses_unlocked(db, [warehouse_id], "到货入库")
```
Use the existing variable name in that function. If the function uses `receipt_warehouse_id`, call:
```python
ensure_warehouses_unlocked(db, [receipt_warehouse_id], "到货入库")
```
- [ ] **Step 3: Guard production work-order material issue**
In `backend/app/api/routes/production.py`, import:
```python
from app.services.stocktake import ensure_warehouses_unlocked
```
Inside `create_work_order`, before auto issue changes raw material lots, collect the warehouse ids of lots that would be consumed:
```python
locked_check_warehouse_ids = {lot.warehouse_id for lot in available_lots}
ensure_warehouses_unlocked(db, locked_check_warehouse_ids, "生产出库")
```
Place this after `available_lots` is loaded and before any `remaining_weight_kg` mutation.
- [ ] **Step 4: Guard completion receipts**
Inside `create_completion_receipt`, after validating the target completion warehouse and before creating `CompletionReceipt`, add:
```python
ensure_warehouses_unlocked(db, [payload.warehouse_id], "成品入库")
```
- [ ] **Step 5: Guard sales deliveries**
In `backend/app/api/routes/sales.py`, import:
```python
from app.services.stocktake import ensure_warehouses_unlocked
```
Inside `create_delivery`, after validating the warehouse and before creating `Delivery`, add:
```python
ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库")
```
- [ ] **Step 6: Run backend compile and existing targeted tests**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m compileall app
.venv/bin/python -m unittest tests.test_stocktake_locking
.venv/bin/python -m unittest tests.test_sales_order_purchase_lock_flow
```
Expected: compileall PASS, lock tests PASS. Existing `test_sales_order_purchase_lock_flow` may expose unrelated status behavior already present in the repo; do not change purchase status semantics in this task.
---
## Task 5: Start Stocktake, Create Snapshot, and Export Workbook
**Files:**
- Modify: `backend/app/services/stocktake.py`
- Modify: `backend/app/api/routes/inventory.py`
- Test: `backend/tests/test_stocktake_excel_diff_confirm.py`
- [ ] **Step 1: Write failing snapshot/export tests**
Create `backend/tests/test_stocktake_excel_diff_confirm.py`:
```python
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from io import BytesIO
from openpyxl import load_workbook
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.operations # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402
from app.models.operations import StockLot, StocktakeLine # noqa: E402
from app.services.stocktake import build_stocktake_workbook, start_stocktake # noqa: E402
class StocktakeExcelDiffConfirmTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed()
def tearDown(self) -> None:
self.db.close()
def _seed(self) -> None:
now = datetime.now(UTC)
self.raw = Warehouse(
id=1,
warehouse_code="WH-RAW-01",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.item = Item(
id=1,
item_code="原材料00001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([self.raw, self.item])
self.db.flush()
self.lot = StockLot(
id=1,
lot_no="LOT-RAW-001",
lot_role="INVENTORY",
item_id=self.item.id,
warehouse_id=self.raw.id,
source_doc_type="OPENING",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("100"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("100"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("2.5"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
self.balance = StockBalance(
item_id=self.item.id,
warehouse_id=self.raw.id,
qty_on_hand=Decimal("0"),
weight_on_hand_kg=Decimal("100"),
qty_available=Decimal("0"),
weight_available_kg=Decimal("100"),
qty_allocated=Decimal("0"),
weight_allocated_kg=Decimal("0"),
avg_unit_cost=Decimal("2.5"),
created_at=now,
updated_at=now,
)
self.db.add_all([self.lot, self.balance])
self.db.commit()
def test_start_stocktake_locks_warehouse_and_snapshots_lots(self) -> None:
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
self.assertEqual(stocktake.status, "LOCKED")
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
self.assertIsNotNone(line)
self.assertEqual(line.lot_no, "LOT-RAW-001")
self.assertEqual(float(line.snapshot_weight_kg), 100)
self.assertEqual(line.diff_type, "MATCH")
def test_export_workbook_has_one_sheet_per_selected_warehouse(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))
self.assertIn("快照行ID", rows[0])
self.assertIn("盘点重量(kg)", rows[0])
self.assertEqual(rows[1][rows[0].index("批次号")], "LOT-RAW-001")
if __name__ == "__main__":
unittest.main()
```
- [ ] **Step 2: Run tests and confirm failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm
```
Expected: FAIL because `start_stocktake` and `build_stocktake_workbook` do not exist.
- [ ] **Step 3: Implement `start_stocktake` in `backend/app/services/stocktake.py`**
Add imports:
```python
from openpyxl import Workbook, load_workbook
from sqlalchemy import delete
from app.models.master_data import Item, StockBalance
from app.models.operations import InventoryTxn, StockLot, StocktakeAdjustment, StocktakeLine
from app.services.operations import create_inventory_txn, get_location_or_default, to_decimal, upsert_stock_balance
```
Add constants:
```python
STOCKTAKE_EXCEL_HEADERS = [
"盘库单号",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源材料分批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
]
```
Add `start_stocktake`:
```python
def start_stocktake(db: Session, *, warehouse_ids: list[int], user_id: int | None, remark: str | None = None) -> Stocktake:
ids = [int(item) for item in warehouse_ids if item]
if not ids:
raise HTTPException(status_code=400, detail="请选择需要盘库的仓库")
ensure_warehouses_unlocked(db, ids, "开始盘库")
warehouses = db.scalars(select(Warehouse).where(Warehouse.id.in_(ids)).order_by(Warehouse.id.asc())).all()
if len(warehouses) != len(set(ids)):
raise HTTPException(status_code=404, detail="存在无效仓库,无法开始盘库")
now = current_time()
stocktake = Stocktake(
stocktake_no=build_stocktake_no(db),
status="LOCKED",
started_by_user_id=user_id,
started_at=now,
remark=remark,
)
db.add(stocktake)
db.flush()
stocktake_warehouses_by_id: dict[int, StocktakeWarehouse] = {}
for warehouse in warehouses:
row = StocktakeWarehouse(
stocktake_id=stocktake.id,
warehouse_id=warehouse.id,
warehouse_type=str(warehouse.warehouse_type or "").upper(),
warehouse_name=warehouse.warehouse_name,
status="LOCKED",
)
db.add(row)
db.flush()
stocktake_warehouses_by_id[warehouse.id] = row
lot_rows = db.execute(
select(
StockLot,
Item,
)
.join(Item, Item.id == StockLot.item_id)
.where(
StockLot.warehouse_id.in_(ids),
StockLot.status == "AVAILABLE",
StockLot.remaining_weight_kg > 0,
)
.order_by(StockLot.warehouse_id.asc(), Item.item_code.asc(), StockLot.lot_no.asc(), StockLot.id.asc())
).all()
line_no = 1
for lot, item in lot_rows:
warehouse = next(row for row in warehouses if row.id == lot.warehouse_id)
location_name = None
if lot.location_id:
from app.models.operations import WarehouseLocation
location_name = db.scalar(select(WarehouseLocation.location_name).where(WarehouseLocation.id == lot.location_id))
db.add(
StocktakeLine(
stocktake_id=stocktake.id,
stocktake_warehouse_id=stocktake_warehouses_by_id[warehouse.id].id,
line_no=line_no,
warehouse_id=warehouse.id,
warehouse_name=warehouse.warehouse_name,
warehouse_type=str(warehouse.warehouse_type or "").upper(),
location_id=lot.location_id,
location_name=location_name,
item_id=item.id,
item_code=item.item_code,
item_name=item.item_name,
item_type=item.item_type,
lot_id=lot.id,
lot_no=lot.lot_no,
source_material_sub_batch_no=lot.source_material_sub_batch_no or lot.material_sub_batch_no,
snapshot_qty=Decimal("0") if item.item_type == "RAW_MATERIAL" else to_decimal(lot.remaining_qty),
snapshot_weight_kg=to_decimal(lot.remaining_weight_kg),
snapshot_unit_cost=to_decimal(lot.unit_cost, "0.0001"),
counted_qty=None,
counted_weight_kg=None,
diff_qty=Decimal("0"),
diff_weight_kg=Decimal("0"),
diff_amount=Decimal("0"),
diff_type="MATCH",
row_status="SNAPSHOT",
remark=None,
)
)
line_no += 1
db.commit()
db.refresh(stocktake)
return stocktake
```
- [ ] **Step 4: Implement workbook export**
Add:
```python
def _stocktake_lines(db: Session, stocktake_id: int) -> list[StocktakeLine]:
return db.scalars(
select(StocktakeLine)
.where(StocktakeLine.stocktake_id == stocktake_id)
.order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc())
).all()
def build_stocktake_workbook(db: Session, stocktake_id: int) -> Workbook:
stocktake = db.get(Stocktake, stocktake_id)
if not stocktake:
raise HTTPException(status_code=404, detail="盘库单不存在")
lines = _stocktake_lines(db, stocktake_id)
workbook = Workbook()
default_sheet = workbook.active
workbook.remove(default_sheet)
grouped: dict[str, list[StocktakeLine]] = {}
for line in lines:
grouped.setdefault(stocktake_warehouse_type_label(line.warehouse_type), []).append(line)
if not grouped:
for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all():
grouped.setdefault(stocktake_warehouse_type_label(warehouse.warehouse_type), [])
for sheet_name, sheet_lines in grouped.items():
worksheet = workbook.create_sheet(title=sheet_name[:31])
worksheet.freeze_panes = "A2"
worksheet.append(STOCKTAKE_EXCEL_HEADERS)
for line in sheet_lines:
worksheet.append(
[
stocktake.stocktake_no,
line.id,
line.warehouse_name,
line.location_name or "",
line.item_code,
line.item_name,
line.lot_no or "",
line.source_material_sub_batch_no or "",
float(line.snapshot_qty or 0),
float(line.snapshot_weight_kg or 0),
float(line.snapshot_unit_cost or 0),
float(line.snapshot_qty or 0),
float(line.snapshot_weight_kg or 0),
"",
]
)
for index, width in enumerate([18, 12, 14, 14, 18, 28, 22, 24, 14, 16, 12, 14, 16, 24], start=1):
worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width
stocktake.exported_at = current_time()
stocktake.status = "EXPORTED"
db.add(stocktake)
db.commit()
return workbook
```
- [ ] **Step 5: Add start/export API routes**
In `backend/app/api/routes/inventory.py`, import:
```python
from io import BytesIO
from urllib.parse import quote
from fastapi import File, UploadFile
from fastapi.responses import Response
from app.schemas.operations import StocktakeConfirmCreate, StocktakeImportPreviewRead, StocktakeRead, StocktakeStartCreate
from app.services.stocktake import build_stocktake_workbook, start_stocktake
```
Add response helper:
```python
def _excel_response(workbook, filename: str) -> Response:
output = BytesIO()
workbook.save(output)
encoded_filename = quote(filename)
return Response(
content=output.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=\"stocktake.xlsx\"; filename*=UTF-8''{encoded_filename}"},
)
```
Add routes:
```python
@router.post("/stocktakes", response_model=StocktakeRead)
def start_inventory_stocktake(
payload: StocktakeStartCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> StocktakeRead:
stocktake = start_stocktake(db, warehouse_ids=payload.warehouse_ids, user_id=context.user.id, remark=payload.remark)
return _stocktake_read(db, stocktake.id)
@router.get("/stocktakes/{stocktake_id}/export")
def export_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> Response:
workbook = build_stocktake_workbook(db, stocktake_id)
stocktake = db.get(Stocktake, stocktake_id)
return _excel_response(workbook, f"{stocktake.stocktake_no}_盘库清单.xlsx")
```
Also add `_stocktake_read` in `inventory.py` or `stocktake.py`; keep it in `stocktake.py` if multiple routes need it.
- [ ] **Step 6: Run 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 compileall app
```
Expected: tests PASS and compileall PASS.
---
## Task 6: Import Counted Workbook and Calculate Diff Preview
**Files:**
- Modify: `backend/app/services/stocktake.py`
- Modify: `backend/app/api/routes/inventory.py`
- Test: `backend/tests/test_stocktake_excel_diff_confirm.py`
- [ ] **Step 1: Add failing import/diff test**
Append this test to `StocktakeExcelDiffConfirmTest`:
```python
def test_import_counted_workbook_marks_loss_gain_and_missing_rows(self) -> None:
from openpyxl import Workbook
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "原材料库"
worksheet.append([
"盘库单号",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源材料分批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
])
worksheet.append([
stocktake.stocktake_no,
line.id,
"原材料库",
"",
"原材料00001",
"冷轧钢板",
"LOT-RAW-001",
"",
0,
100,
2.5,
0,
90,
"实盘少10kg",
])
output = BytesIO()
workbook.save(output)
preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(preview["summary"]["loss_count"], 1)
loss_line = self.db.get(StocktakeLine, line.id)
self.assertEqual(loss_line.diff_type, "LOSS")
self.assertEqual(float(loss_line.diff_weight_kg), -10)
self.assertEqual(loss_line.row_status, "READY")
```
- [ ] **Step 2: Run the test and confirm failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm.StocktakeExcelDiffConfirmTest.test_import_counted_workbook_marks_loss_gain_and_missing_rows
```
Expected: FAIL because `import_stocktake_workbook` is missing.
- [ ] **Step 3: Implement import parser and diff calculation**
In `backend/app/services/stocktake.py`, add:
```python
def _clean_text(value: object) -> str:
return str(value or "").strip()
def _read_workbook_rows(content: bytes) -> list[dict[str, object]]:
try:
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
except Exception as exc:
raise ValueError("Excel 文件读取失败,请确认上传的是盘库清单 xlsx 文件") from exc
rows: list[dict[str, object]] = []
for worksheet in workbook.worksheets:
values = list(worksheet.iter_rows(values_only=True))
if not values:
continue
headers = [_clean_text(item) for item in values[0]]
missing = [header for header in STOCKTAKE_EXCEL_HEADERS if header not in headers]
if missing:
raise ValueError(f"{worksheet.title} 缺少必要列:{'、'.join(missing)}")
for row in values[1:]:
if not any(_clean_text(item) for item in row):
continue
payload = {headers[index]: value for index, value in enumerate(row) if index < len(headers)}
payload["_sheet_name"] = worksheet.title
rows.append(payload)
return rows
def _line_diff_type(qty_diff: Decimal, weight_diff: Decimal, *, is_new: bool = False, is_missing: bool = False) -> str:
if is_new:
return "NEW_LOT"
if is_missing:
return "MISSING"
basis = weight_diff if weight_diff != 0 else qty_diff
if basis > 0:
return "GAIN"
if basis < 0:
return "LOSS"
return "MATCH"
def _stocktake_summary(lines: list[StocktakeLine]) -> dict[str, int | float]:
summary = {
"match_count": 0,
"gain_count": 0,
"loss_count": 0,
"new_lot_count": 0,
"missing_count": 0,
"error_count": 0,
"diff_amount": 0.0,
}
for line in lines:
key = {
"MATCH": "match_count",
"GAIN": "gain_count",
"LOSS": "loss_count",
"NEW_LOT": "new_lot_count",
"MISSING": "missing_count",
}.get(line.diff_type)
if key:
summary[key] += 1
if line.row_status == "ERROR":
summary["error_count"] += 1
summary["diff_amount"] += float(line.diff_amount or 0)
return summary
```
Implement:
```python
def import_stocktake_workbook(db: Session, stocktake_id: int, content: bytes, user_id: int | None) -> dict[str, object]:
stocktake = db.get(Stocktake, stocktake_id)
if not stocktake:
raise HTTPException(status_code=404, detail="盘库单不存在")
if stocktake.status not in {"LOCKED", "EXPORTED", "IMPORTED"}:
raise HTTPException(status_code=400, detail="当前盘库单状态不允许导入")
rows = _read_workbook_rows(content)
if not rows:
raise ValueError("Excel 中没有可导入的盘点数据")
existing_lines = {
line.id: line
for line in db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id)).all()
}
seen_line_ids: set[int] = set()
next_line_no = max((line.line_no for line in existing_lines.values()), default=0) + 1
for row in rows:
row_stocktake_no = _clean_text(row.get("盘库单号"))
if row_stocktake_no != stocktake.stocktake_no:
raise ValueError(f"存在不属于当前盘库单的行:{row_stocktake_no or '空盘库单号'}")
line_id_text = _clean_text(row.get("快照行ID"))
counted_qty = decimal_value(row.get("盘点数量"))
counted_weight = decimal_value(row.get("盘点重量(kg)"))
remark = _clean_text(row.get("盘点备注")) or None
if line_id_text:
line_id = int(float(line_id_text))
line = existing_lines.get(line_id)
if not line:
raise ValueError(f"快照行ID {line_id} 不存在于当前盘库单")
if line_id in seen_line_ids:
line.row_status = "ERROR"
line.error_message = "导入清单中该快照行重复"
seen_line_ids.add(line_id)
snapshot_qty = decimal_value(line.snapshot_qty)
snapshot_weight = decimal_value(line.snapshot_weight_kg)
qty_diff = Decimal("0") if line.item_type == "RAW_MATERIAL" else counted_qty - snapshot_qty
weight_diff = counted_weight - snapshot_weight
line.counted_qty = Decimal("0") if line.item_type == "RAW_MATERIAL" else counted_qty
line.counted_weight_kg = counted_weight
line.diff_qty = qty_diff
line.diff_weight_kg = weight_diff
line.diff_amount = (weight_diff * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01"))
line.diff_type = _line_diff_type(qty_diff, weight_diff)
line.row_status = "ERROR" if line.error_message else "READY"
line.remark = remark
db.add(line)
continue
warehouse_name = _clean_text(row.get("仓库"))
item_code = _clean_text(row.get("物料编码"))
item = db.scalar(select(Item).where(Item.item_code == item_code).limit(1))
warehouse = db.scalar(select(Warehouse).where(Warehouse.warehouse_name == warehouse_name).limit(1))
if not item or not warehouse:
raise ValueError(f"新增批次行物料或仓库不存在:{warehouse_name} / {item_code}")
unit_cost = decimal_value(row.get("系统单价"), "0.0001")
db.add(
StocktakeLine(
stocktake_id=stocktake.id,
stocktake_warehouse_id=None,
line_no=next_line_no,
warehouse_id=warehouse.id,
warehouse_name=warehouse.warehouse_name,
warehouse_type=str(warehouse.warehouse_type or "").upper(),
location_id=None,
location_name=_clean_text(row.get("库位")) or None,
item_id=item.id,
item_code=item.item_code,
item_name=item.item_name,
item_type=item.item_type,
lot_id=None,
lot_no=_clean_text(row.get("批次号")) or None,
source_material_sub_batch_no=_clean_text(row.get("来源材料分批次号")) or None,
snapshot_qty=Decimal("0"),
snapshot_weight_kg=Decimal("0"),
snapshot_unit_cost=unit_cost,
counted_qty=Decimal("0") if item.item_type == "RAW_MATERIAL" else counted_qty,
counted_weight_kg=counted_weight,
diff_qty=Decimal("0") if item.item_type == "RAW_MATERIAL" else counted_qty,
diff_weight_kg=counted_weight,
diff_amount=(counted_weight * unit_cost).quantize(Decimal("0.01")),
diff_type="NEW_LOT",
row_status="READY",
remark=remark,
)
)
next_line_no += 1
for line_id, line in existing_lines.items():
if line_id in seen_line_ids:
continue
line.counted_qty = Decimal("0")
line.counted_weight_kg = Decimal("0")
line.diff_qty = -decimal_value(line.snapshot_qty)
line.diff_weight_kg = -decimal_value(line.snapshot_weight_kg)
line.diff_amount = (decimal_value(line.diff_weight_kg) * decimal_value(line.snapshot_unit_cost, "0.0001")).quantize(Decimal("0.01"))
line.diff_type = "MISSING"
line.row_status = "READY"
line.remark = "导入清单缺失,按盘点为0处理"
db.add(line)
stocktake.status = "IMPORTED"
stocktake.imported_by_user_id = user_id
stocktake.imported_at = current_time()
db.add(stocktake)
db.commit()
lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all()
return {"stocktake": stocktake, "lines": lines, "summary": _stocktake_summary(lines)}
```
- [ ] **Step 4: Add import API route**
In `backend/app/api/routes/inventory.py`, import `import_stocktake_workbook` and add:
```python
@router.post("/stocktakes/{stocktake_id}/import", response_model=StocktakeImportPreviewRead)
async def import_inventory_stocktake(
stocktake_id: int,
file: UploadFile = File(...),
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> StocktakeImportPreviewRead:
content = await file.read()
try:
result = import_stocktake_workbook(db, stocktake_id, content, user_id=context.user.id)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _stocktake_preview_read(db, result)
```
- [ ] **Step 5: Run 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 compileall app
```
Expected: tests PASS and compileall PASS.
---
## Task 7: Confirm Stocktake and Post Inventory Adjustments
**Files:**
- Modify: `backend/app/services/stocktake.py`
- Modify: `backend/app/api/routes/inventory.py`
- Test: `backend/tests/test_stocktake_excel_diff_confirm.py`
- [ ] **Step 1: Add failing confirmation test**
Append:
```python
def test_confirm_stocktake_adjusts_lot_balance_and_writes_txn(self) -> None:
from openpyxl import Workbook
from app.models.operations import InventoryTxn, StocktakeAdjustment
from app.services.stocktake import confirm_stocktake, import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "原材料库"
worksheet.append([
"盘库单号",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源材料分批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
])
worksheet.append([stocktake.stocktake_no, line.id, "原材料库", "", "原材料00001", "冷轧钢板", "LOT-RAW-001", "", 0, 100, 2.5, 0, 90, "实盘少10kg"])
output = BytesIO()
workbook.save(output)
import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="确认调整")
self.assertEqual(confirmed.status, "CONFIRMED")
self.db.refresh(self.lot)
self.db.refresh(self.balance)
self.assertEqual(float(self.lot.remaining_weight_kg), 90)
self.assertEqual(float(self.balance.weight_on_hand_kg), 90)
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "STOCKTAKE_LOSS"))
self.assertIsNotNone(txn)
adjustment = self.db.scalar(select(StocktakeAdjustment).where(StocktakeAdjustment.stocktake_id == stocktake.id))
self.assertIsNotNone(adjustment)
self.assertEqual(float(adjustment.weight_change_kg), -10)
```
- [ ] **Step 2: Run test and confirm failure**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm.StocktakeExcelDiffConfirmTest.test_confirm_stocktake_adjusts_lot_balance_and_writes_txn
```
Expected: FAIL because `confirm_stocktake` is missing.
- [ ] **Step 3: Implement confirmation**
In `backend/app/services/stocktake.py`, add:
```python
def _stocktake_txn_type(diff_type: str) -> str:
return {
"GAIN": "STOCKTAKE_GAIN",
"LOSS": "STOCKTAKE_LOSS",
"MISSING": "STOCKTAKE_LOSS",
"NEW_LOT": "STOCKTAKE_NEW_LOT",
}.get(diff_type, "STOCKTAKE_GAIN")
def confirm_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, confirm_text: str, remark: str | None = None) -> Stocktake:
if confirm_text != "确认盘库":
raise HTTPException(status_code=400, detail="请输入“确认盘库”后再确认")
stocktake = db.get(Stocktake, stocktake_id)
if not stocktake:
raise HTTPException(status_code=404, detail="盘库单不存在")
if stocktake.status != "IMPORTED":
raise HTTPException(status_code=400, detail="请先导入盘点清单并确认差异后再过账")
lines = db.scalars(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake_id).order_by(StocktakeLine.line_no.asc())).all()
error_lines = [line for line in lines if line.row_status == "ERROR"]
if error_lines:
raise HTTPException(status_code=400, detail=f"存在 {len(error_lines)} 行异常,不能确认盘库")
now = current_time()
for line in lines:
if line.diff_type == "MATCH":
continue
unit_cost = decimal_value(line.snapshot_unit_cost, "0.0001")
qty_change = decimal_value(line.diff_qty)
weight_change = decimal_value(line.diff_weight_kg)
if line.diff_type == "NEW_LOT":
lot = StockLot(
lot_no=line.lot_no or f"{stocktake.stocktake_no}-NEW-{line.line_no:04d}",
lot_role="STOCKTAKE_NEW",
item_id=line.item_id,
warehouse_id=line.warehouse_id,
location_id=line.location_id,
source_doc_type="STOCKTAKE",
source_doc_id=stocktake.id,
source_line_id=line.id,
source_material_sub_batch_no=line.source_material_sub_batch_no,
source_material_summary="盘库新增批次",
inbound_qty=max(Decimal("0"), decimal_value(line.counted_qty)),
inbound_weight_kg=max(Decimal("0"), decimal_value(line.counted_weight_kg)),
remaining_qty=max(Decimal("0"), decimal_value(line.counted_qty)),
remaining_weight_kg=max(Decimal("0"), decimal_value(line.counted_weight_kg)),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=unit_cost,
production_date=now.date(),
quality_status="PASS",
status="AVAILABLE",
remark=line.remark or "盘库新增",
)
db.add(lot)
db.flush()
lot_id = lot.id
before_qty = Decimal("0")
before_weight = Decimal("0")
after_qty = decimal_value(line.counted_qty)
after_weight = decimal_value(line.counted_weight_kg)
else:
lot = db.get(StockLot, line.lot_id)
if not lot:
raise HTTPException(status_code=400, detail=f"盘库行 {line.line_no} 对应批次不存在")
before_qty = Decimal("0") if line.item_type == "RAW_MATERIAL" else decimal_value(lot.remaining_qty)
before_weight = decimal_value(lot.remaining_weight_kg)
after_qty = Decimal("0") if line.item_type == "RAW_MATERIAL" else max(Decimal("0"), decimal_value(line.counted_qty))
after_weight = max(Decimal("0"), decimal_value(line.counted_weight_kg))
qty_change = after_qty - before_qty
weight_change = after_weight - before_weight
lot.remaining_qty = after_qty
lot.remaining_weight_kg = after_weight
lot.status = "AVAILABLE" if after_qty > 0 or after_weight > 0 else "DEPLETED"
db.add(lot)
lot_id = lot.id
upsert_stock_balance(
db,
item_id=line.item_id,
warehouse_id=line.warehouse_id,
location_id=line.location_id,
qty_delta=qty_change,
weight_delta=weight_change,
available_qty_delta=qty_change,
available_weight_delta=weight_change,
unit_cost=unit_cost,
)
txn = create_inventory_txn(
db,
txn_type=_stocktake_txn_type(line.diff_type),
item_id=line.item_id,
warehouse_id=line.warehouse_id,
location_id=line.location_id,
lot_id=lot_id,
qty_change=qty_change,
weight_change=weight_change,
unit_cost=unit_cost,
source_doc_type="STOCKTAKE",
source_doc_id=stocktake.id,
source_line_id=line.id,
biz_time=now,
operator_user_id=user_id,
remark=line.remark or remark or "盘库调整",
amount_basis="WEIGHT",
)
db.flush()
db.add(
StocktakeAdjustment(
stocktake_id=stocktake.id,
stocktake_line_id=line.id,
warehouse_id=line.warehouse_id,
item_id=line.item_id,
lot_id=line.lot_id,
adjustment_lot_id=lot_id,
inventory_txn_id=txn.id,
adjustment_type=line.diff_type,
before_qty=before_qty,
after_qty=after_qty,
before_weight_kg=before_weight,
after_weight_kg=after_weight,
qty_change=qty_change,
weight_change_kg=weight_change,
unit_cost=unit_cost,
amount=(abs(weight_change) * unit_cost).quantize(Decimal("0.01")),
)
)
for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all():
warehouse.status = "UNLOCKED"
db.add(warehouse)
stocktake.status = "CONFIRMED"
stocktake.confirmed_by_user_id = user_id
stocktake.confirmed_at = now
if remark:
stocktake.remark = remark
db.add(stocktake)
db.commit()
db.refresh(stocktake)
return stocktake
```
- [ ] **Step 4: Add confirm and cancel API routes**
In `backend/app/api/routes/inventory.py`, import `confirm_stocktake` and add:
```python
@router.post("/stocktakes/{stocktake_id}/confirm", response_model=StocktakeRead)
def confirm_inventory_stocktake(
stocktake_id: int,
payload: StocktakeConfirmCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> StocktakeRead:
stocktake = confirm_stocktake(
db,
stocktake_id,
user_id=context.user.id,
confirm_text=payload.confirm_text,
remark=payload.remark,
)
return _stocktake_read(db, stocktake.id)
```
Add `cancel_stocktake` in service:
```python
def cancel_stocktake(db: Session, stocktake_id: int, *, user_id: int | None, remark: str | None = None) -> Stocktake:
stocktake = db.get(Stocktake, stocktake_id)
if not stocktake:
raise HTTPException(status_code=404, detail="盘库单不存在")
if stocktake.status not in ACTIVE_STOCKTAKE_STATUSES:
raise HTTPException(status_code=400, detail="当前盘库单不能取消")
now = current_time()
for warehouse in db.scalars(select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake_id)).all():
warehouse.status = "UNLOCKED"
db.add(warehouse)
stocktake.status = "CANCELED"
stocktake.canceled_by_user_id = user_id
stocktake.canceled_at = now
stocktake.remark = remark or stocktake.remark
db.add(stocktake)
db.commit()
db.refresh(stocktake)
return stocktake
```
Add route:
```python
@router.post("/stocktakes/{stocktake_id}/cancel", response_model=StocktakeRead)
def cancel_inventory_stocktake(
stocktake_id: int,
payload: StocktakeConfirmCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> StocktakeRead:
stocktake = cancel_stocktake(db, stocktake_id, user_id=context.user.id, remark=payload.remark)
return _stocktake_read(db, stocktake.id)
```
- [ ] **Step 5: Run confirmation 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 compileall app
```
Expected: tests PASS and compileall PASS.
---
## Task 8: Stocktake History and Read APIs
**Files:**
- Modify: `backend/app/services/stocktake.py`
- Modify: `backend/app/api/routes/inventory.py`
- Test: `backend/tests/test_stocktake_excel_diff_confirm.py`
- [ ] **Step 1: Add read helpers**
In `backend/app/services/stocktake.py`, add:
```python
def stocktake_lines_for_read(db: Session, stocktake_id: int) -> list[StocktakeLine]:
return db.scalars(
select(StocktakeLine)
.where(StocktakeLine.stocktake_id == stocktake_id)
.order_by(StocktakeLine.warehouse_id.asc(), StocktakeLine.line_no.asc())
).all()
def stocktake_warehouses_for_read(db: Session, stocktake_id: int) -> list[StocktakeWarehouse]:
return db.scalars(
select(StocktakeWarehouse)
.where(StocktakeWarehouse.stocktake_id == stocktake_id)
.order_by(StocktakeWarehouse.id.asc())
).all()
```
- [ ] **Step 2: Add route read serializers**
In `backend/app/api/routes/inventory.py`, add:
```python
def _stocktake_line_read(line: StocktakeLine) -> dict[str, object]:
return {
"id": line.id,
"stocktake_id": line.stocktake_id,
"line_no": line.line_no,
"warehouse_id": line.warehouse_id,
"warehouse_name": line.warehouse_name,
"warehouse_type": line.warehouse_type,
"location_id": line.location_id,
"location_name": line.location_name,
"item_id": line.item_id,
"item_code": line.item_code,
"item_name": line.item_name,
"item_type": line.item_type,
"lot_id": line.lot_id,
"lot_no": line.lot_no,
"source_material_sub_batch_no": line.source_material_sub_batch_no,
"snapshot_qty": line.snapshot_qty,
"snapshot_weight_kg": line.snapshot_weight_kg,
"snapshot_unit_cost": line.snapshot_unit_cost,
"counted_qty": line.counted_qty,
"counted_weight_kg": line.counted_weight_kg,
"diff_qty": line.diff_qty,
"diff_weight_kg": line.diff_weight_kg,
"diff_amount": line.diff_amount,
"diff_type": line.diff_type,
"row_status": line.row_status,
"error_message": line.error_message,
"remark": line.remark,
}
```
Add `_stocktake_read`:
```python
def _stocktake_read(db: Session, stocktake_id: int) -> StocktakeRead:
stocktake = db.get(Stocktake, stocktake_id)
if not stocktake:
raise HTTPException(status_code=404, detail="盘库单不存在")
warehouses = stocktake_warehouses_for_read(db, stocktake_id)
lines = stocktake_lines_for_read(db, stocktake_id)
return StocktakeRead.model_validate(
{
"id": stocktake.id,
"stocktake_no": stocktake.stocktake_no,
"status": stocktake.status,
"started_by_user_id": stocktake.started_by_user_id,
"imported_by_user_id": stocktake.imported_by_user_id,
"confirmed_by_user_id": stocktake.confirmed_by_user_id,
"canceled_by_user_id": stocktake.canceled_by_user_id,
"started_at": stocktake.started_at,
"exported_at": stocktake.exported_at,
"imported_at": stocktake.imported_at,
"confirmed_at": stocktake.confirmed_at,
"canceled_at": stocktake.canceled_at,
"remark": stocktake.remark,
"warehouses": [
{
"id": row.id,
"warehouse_id": row.warehouse_id,
"warehouse_name": row.warehouse_name,
"warehouse_type": row.warehouse_type,
"status": row.status,
}
for row in warehouses
],
"summary": _stocktake_summary(lines),
}
)
```
Add `_stocktake_preview_read`:
```python
def _stocktake_preview_read(db: Session, result: dict[str, object]) -> StocktakeImportPreviewRead:
stocktake = result["stocktake"]
lines = result["lines"]
return StocktakeImportPreviewRead.model_validate(
{
"stocktake": _stocktake_read(db, stocktake.id),
"lines": [_stocktake_line_read(line) for line in lines],
"summary": result["summary"],
}
)
```
- [ ] **Step 3: Add list/detail/lines routes**
Add:
```python
@router.get("/stocktakes", response_model=list[StocktakeRead])
def list_inventory_stocktakes(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[StocktakeRead]:
rows = db.scalars(select(Stocktake).order_by(Stocktake.started_at.desc(), Stocktake.id.desc()).limit(limit)).all()
return [_stocktake_read(db, row.id) for row in rows]
@router.get("/stocktakes/{stocktake_id}", response_model=StocktakeRead)
def get_inventory_stocktake(stocktake_id: int, db: Session = Depends(get_db)) -> StocktakeRead:
return _stocktake_read(db, stocktake_id)
@router.get("/stocktakes/{stocktake_id}/lines", response_model=list[StocktakeLineRead])
def list_inventory_stocktake_lines(stocktake_id: int, db: Session = Depends(get_db)) -> list[StocktakeLineRead]:
return [StocktakeLineRead.model_validate(_stocktake_line_read(line)) for line in stocktake_lines_for_read(db, stocktake_id)]
```
- [ ] **Step 4: Run backend verification**
Run:
```bash
cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_excel_diff_confirm tests.test_stocktake_locking
.venv/bin/python -m compileall app
```
Expected: tests PASS and compileall PASS.
---
## Task 9: Frontend Stocktake Dialog API Wiring
**Files:**
- Modify: `frontend/src/views/InventoryLedgerView.vue`
- Create: `frontend/src/components/StocktakeDialog.vue`
- Modify: `frontend/src/utils/dictionaries.js`
- Modify: `frontend/src/styles/main.css`
- [ ] **Step 1: Add dictionary labels**
In `frontend/src/utils/dictionaries.js`, add exports:
```javascript
export function formatStocktakeStatusLabel(status) {
return {
LOCKED: "盘库中",
EXPORTED: "已导出",
IMPORTED: "已导入",
CONFIRMED: "已确认",
CANCELED: "已取消"
}[String(status || "").toUpperCase()] || status || "-";
}
export function formatStocktakeDiffTypeLabel(type) {
return {
MATCH: "一致",
GAIN: "盘盈",
LOSS: "盘亏",
NEW_LOT: "新增批次",
MISSING: "缺失批次"
}[String(type || "").toUpperCase()] || type || "-";
}
```
- [ ] **Step 2: Create dialog skeleton with props and events**
Create `frontend/src/components/StocktakeDialog.vue`:
```vue
嘉恒仓库 · 盘库 导出的 Excel 会按仓库分 sheet。锁库期间选中仓库不能执行出入库。{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}
系统库存
盘点结果
| 盘库单号 | 仓库 | 状态 | 开始时间 | 确认时间 | 盘盈 | 盘亏 | 新增批次 | 缺失批次 | 影响金额 |
|---|---|---|---|---|---|---|---|---|---|
| {{ row.stocktake_no }} | {{ row.warehouses.map((item) => item.warehouse_name).join("、") }} | {{ formatStocktakeStatusLabel(row.status) }} | {{ row.started_at }} | {{ row.confirmed_at || "-" }} | {{ row.summary.gain_count || 0 }} | {{ row.summary.loss_count || 0 }} | {{ row.summary.new_lot_count || 0 }} | {{ row.summary.missing_count || 0 }} | ¥{{ formatAmount(row.summary.diff_amount || 0) }} |
| 暂无盘库记录 | |||||||||