ForgeFlow-ERP/docs/superpowers/plans/2026-05-30-stocktake-locking.md
2026-06-12 16:00:56 +08:00

93 KiB
Raw Blame History

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:

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:

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:

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:

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:

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:

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:

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:

    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:

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:

from app.services.stocktake import ensure_warehouses_unlocked

Add these calls:

# 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:

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:

    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:

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:

from app.services.stocktake import ensure_warehouses_unlocked

Inside create_purchase_receipt, after determining warehouse_id and before creating PurchaseReceipt, add:

ensure_warehouses_unlocked(db, [warehouse_id], "到货入库")

Use the existing variable name in that function. If the function uses receipt_warehouse_id, call:

ensure_warehouses_unlocked(db, [receipt_warehouse_id], "到货入库")
  • Step 3: Guard production work-order material issue

In backend/app/api/routes/production.py, import:

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:

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:

ensure_warehouses_unlocked(db, [payload.warehouse_id], "成品入库")
  • Step 5: Guard sales deliveries

In backend/app/api/routes/sales.py, import:

from app.services.stocktake import ensure_warehouses_unlocked

Inside create_delivery, after validating the warehouse and before creating Delivery, add:

ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库")
  • Step 6: Run backend compile and existing targeted tests

Run:

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:

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:

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:

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:

STOCKTAKE_EXCEL_HEADERS = [
    "盘库单号",
    "快照行ID",
    "仓库",
    "库位",
    "物料编码",
    "物料名称",
    "批次号",
    "来源材料分批次号",
    "系统数量",
    "系统重量(kg)",
    "系统单价",
    "盘点数量",
    "盘点重量(kg)",
    "盘点备注",
]

Add start_stocktake:

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:

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:

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:

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:

@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:

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:

    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:

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:

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:

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:

@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:

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:

    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:

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:

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:

@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:

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:

@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:

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:

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:

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:

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:

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:

@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:

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:

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:

<template>
  <Teleport to="body">
    <div v-if="open" class="workflow-drawer-mask stocktake-mask" @click.self="$emit('close')">
      <section class="stocktake-shell">
        <header class="stocktake-head">
          <div>
            <p class="eyebrow">嘉恒仓库 · 盘库</p>
            <h3>{{ activeStocktake ? activeStocktake.stocktake_no : "新建盘库" }}</h3>
          </div>
          <button class="ghost-button" type="button" @click="$emit('close')">关闭</button>
        </header>

        <nav class="stocktake-steps" aria-label="盘库步骤">
          <button v-for="step in steps" :key="step.key" type="button" class="stocktake-step" :class="{ active: activeStep === step.key }" @click="activeStep = step.key">
            <span>{{ step.no }}</span>
            <strong>{{ step.label }}</strong>
          </button>
        </nav>

        <section v-if="activeStep === 'select'" class="stocktake-panel">
          <div class="stocktake-warehouse-grid">
            <label v-for="warehouse in warehouses" :key="warehouse.id" class="stocktake-warehouse-card" :class="{ active: selectedWarehouseIds.includes(warehouse.id) }">
              <input v-model="selectedWarehouseIds" type="checkbox" :value="warehouse.id" />
              <strong>{{ warehouse.warehouse_name }}</strong>
              <span>{{ warehouseTypeText(warehouse.warehouse_type) }}</span>
            </label>
          </div>
          <div class="stocktake-footer">
            <button class="primary-button" type="button" :disabled="!selectedWarehouseIds.length || busy" @click="startStocktake">
              {{ busy ? "锁库中..." : "开始盘库并锁库" }}
            </button>
          </div>
        </section>

        <section v-if="activeStep === 'export'" class="stocktake-panel">
          <div class="stocktake-action-card">
            <strong>导出盘库清单</strong>
            <p>导出的 Excel 会按仓库分 sheet。锁库期间选中仓库不能执行出入库。</p>
            <button class="primary-button" type="button" :disabled="!activeStocktake || busy" @click="exportStocktake">导出盘库清单</button>
          </div>
        </section>

        <section v-if="activeStep === 'import'" class="stocktake-panel">
          <label class="import-dropzone">
            <input ref="fileInputRef" type="file" accept=".xlsx,.xlsm" @change="importStocktake" />
            <strong>{{ busy ? "导入中..." : "上传盘点后的 Excel" }}</strong>
            <span>系统会生成左右差异对比,不会直接改库存。</span>
          </label>
        </section>

        <section v-if="activeStep === 'diff'" class="stocktake-panel stocktake-diff-panel">
          <div class="stocktake-summary-grid">
            <article v-for="card in summaryCards" :key="card.key" class="stocktake-summary-card" :class="card.tone">
              <span>{{ card.label }}</span>
              <strong>{{ card.value }}</strong>
            </article>
          </div>
          <div class="stocktake-diff-toolbar">
            <button class="ghost-button" type="button" :class="{ active: diffFilter === 'ALL' }" @click="diffFilter = 'ALL'">全部</button>
            <button class="ghost-button" type="button" :class="{ active: diffFilter === 'DIFF' }" @click="diffFilter = 'DIFF'">只看差异</button>
            <button class="ghost-button" type="button" :class="{ active: diffFilter === 'ERROR' }" @click="diffFilter = 'ERROR'">只看异常</button>
          </div>
          <div class="stocktake-diff-table">
            <div class="stocktake-diff-column">
              <h4>系统库存</h4>
              <div v-for="line in filteredLines" :key="`sys-${line.id}`" class="stocktake-diff-row" :class="diffClass(line)">
                <strong>{{ line.item_code }} · {{ line.item_name }}</strong>
                <span>{{ line.lot_no || "-" }}</span>
                <span>数量 {{ formatQty(line.snapshot_qty) }} / 重量 {{ formatWeight(line.snapshot_weight_kg) }}</span>
              </div>
            </div>
            <div class="stocktake-diff-column">
              <h4>盘点结果</h4>
              <div v-for="line in filteredLines" :key="`count-${line.id}`" class="stocktake-diff-row" :class="diffClass(line)">
                <strong>{{ formatStocktakeDiffTypeLabel(line.diff_type) }}</strong>
                <span>{{ line.error_message || line.remark || "-" }}</span>
                <span>数量 {{ formatQty(line.counted_qty) }} / 重量 {{ formatWeight(line.counted_weight_kg) }}</span>
              </div>
            </div>
          </div>
          <div class="stocktake-footer">
            <input v-model.trim="confirmText" class="stocktake-confirm-input" placeholder="输入:确认盘库" />
            <button class="primary-button" type="button" :disabled="!canConfirm || busy" @click="confirmStocktake">
              {{ busy ? "确认中..." : "确认盘库并解锁" }}
            </button>
          </div>
        </section>
      </section>
    </div>
  </Teleport>
</template>

Add script:

<script setup>
import { computed, ref, watch } from "vue";

import { downloadResource, fetchResource, postResource, uploadResource } from "../services/api";
import { formatStocktakeDiffTypeLabel, formatStocktakeStatusLabel } from "../utils/dictionaries";
import { formatAmount, formatQty, formatWeight } from "../utils/formatters";

const props = defineProps({
  open: { type: Boolean, default: false },
  warehouses: { type: Array, default: () => [] },
  defaultWarehouseId: { type: Number, default: 0 }
});
const emit = defineEmits(["close", "changed", "message", "error"]);

const steps = [
  { key: "select", no: "01", label: "选择仓库" },
  { key: "export", no: "02", label: "导出清单" },
  { key: "import", no: "03", label: "导入盘点" },
  { key: "diff", no: "04", label: "差异确认" }
];

const activeStep = ref("select");
const selectedWarehouseIds = ref([]);
const activeStocktake = ref(null);
const previewLines = ref([]);
const previewSummary = ref({});
const busy = ref(false);
const fileInputRef = ref(null);
const diffFilter = ref("ALL");
const confirmText = ref("");

watch(
  () => props.open,
  (open) => {
    if (!open) return;
    activeStep.value = "select";
    selectedWarehouseIds.value = props.defaultWarehouseId ? [props.defaultWarehouseId] : [];
    activeStocktake.value = null;
    previewLines.value = [];
    previewSummary.value = {};
    diffFilter.value = "ALL";
    confirmText.value = "";
  }
);

const summaryCards = computed(() => [
  { key: "match", label: "一致", value: previewSummary.value.match_count || 0, tone: "neutral" },
  { key: "gain", label: "盘盈", value: previewSummary.value.gain_count || 0, tone: "gain" },
  { key: "loss", label: "盘亏", value: previewSummary.value.loss_count || 0, tone: "loss" },
  { key: "new", label: "新增批次", value: previewSummary.value.new_lot_count || 0, tone: "new" },
  { key: "missing", label: "缺失批次", value: previewSummary.value.missing_count || 0, tone: "missing" },
  { key: "error", label: "异常", value: previewSummary.value.error_count || 0, tone: "error" },
  { key: "amount", label: "影响金额", value: ${formatAmount(previewSummary.value.diff_amount || 0)}`, tone: "amount" }
]);

const filteredLines = computed(() => {
  if (diffFilter.value === "ERROR") {
    return previewLines.value.filter((line) => line.row_status === "ERROR");
  }
  if (diffFilter.value === "DIFF") {
    return previewLines.value.filter((line) => line.diff_type !== "MATCH");
  }
  return previewLines.value;
});

const canConfirm = computed(() => activeStocktake.value?.id && confirmText.value === "确认盘库" && Number(previewSummary.value.error_count || 0) === 0);

function warehouseTypeText(type) {
  return { RAW: "原材料库", SEMI: "半成品库", FINISHED: "成品库", AUX: "辅料库" }[String(type || "").toUpperCase()] || type || "-";
}

function diffClass(line) {
  return `diff-${String(line.diff_type || "MATCH").toLowerCase()} ${line.row_status === "ERROR" ? "diff-error" : ""}`;
}

async function startStocktake() {
  busy.value = true;
  try {
    activeStocktake.value = await postResource("/inventory/stocktakes", {
      warehouse_ids: selectedWarehouseIds.value,
      remark: "嘉恒仓库盘库"
    });
    activeStep.value = "export";
    emit("message", `盘库单 ${activeStocktake.value.stocktake_no} 已创建并锁库`);
    emit("changed");
  } catch (error) {
    emit("error", error.message || "开始盘库失败");
  } finally {
    busy.value = false;
  }
}

async function exportStocktake() {
  if (!activeStocktake.value?.id) return;
  busy.value = true;
  try {
    await downloadResource(`/inventory/stocktakes/${activeStocktake.value.id}/export`, `${activeStocktake.value.stocktake_no}_盘库清单.xlsx`);
    activeStep.value = "import";
    emit("message", "盘库清单已导出");
  } catch (error) {
    emit("error", error.message || "导出盘库清单失败");
  } finally {
    busy.value = false;
  }
}

async function importStocktake(event) {
  const file = event.target.files?.[0];
  if (!file || !activeStocktake.value?.id) return;
  busy.value = true;
  try {
    const formData = new FormData();
    formData.append("file", file);
    const result = await uploadResource(`/inventory/stocktakes/${activeStocktake.value.id}/import`, formData);
    activeStocktake.value = result.stocktake;
    previewLines.value = result.lines;
    previewSummary.value = result.summary;
    activeStep.value = "diff";
    emit("message", "盘点清单已导入,请核对差异");
  } catch (error) {
    emit("error", error.message || "导入盘点清单失败");
  } finally {
    busy.value = false;
    if (fileInputRef.value) fileInputRef.value.value = "";
  }
}

async function confirmStocktake() {
  if (!canConfirm.value) return;
  busy.value = true;
  try {
    activeStocktake.value = await postResource(`/inventory/stocktakes/${activeStocktake.value.id}/confirm`, {
      confirm_text: confirmText.value,
      remark: "盘库确认"
    });
    emit("message", `盘库单 ${activeStocktake.value.stocktake_no} 已确认并解锁`);
    emit("changed");
    emit("close");
  } catch (error) {
    emit("error", error.message || "确认盘库失败");
  } finally {
    busy.value = false;
  }
}
</script>
  • Step 3: Verify component compiles after wiring in next task

Do not run build yet; StocktakeDialog.vue is not imported until Task 10.


Task 10: Add Stocktake Entry Above Warehouse Bar

Files:

  • Modify: frontend/src/views/InventoryLedgerView.vue

  • Modify: frontend/src/styles/main.css

  • Step 1: Import dialog and API helpers

Modify imports in frontend/src/views/InventoryLedgerView.vue:

import StocktakeDialog from "../components/StocktakeDialog.vue";

Add state:

const stocktakeDialogOpen = ref(false);

Update useBodyScrollLock condition:

useBodyScrollLock(
  computed(
    () =>
      detailDrawerOpen.value ||
      genericDrawerOpen.value ||
      productionDrawerOpen.value ||
      completionDrawerOpen.value ||
      deliveryDrawerOpen.value ||
      stocktakeDialogOpen.value
  )
);

Add computed:

const activeWarehouseId = computed(() => activeWarehouses.value[0]?.id || 0);

Add handlers:

function openStocktakeDialog() {
  resetFeedback();
  stocktakeDialogOpen.value = true;
}

function closeStocktakeDialog() {
  stocktakeDialogOpen.value = false;
}

function handleStocktakeMessage(message) {
  feedbackMessage.value = message;
  errorMessage.value = "";
}

function handleStocktakeError(message) {
  errorMessage.value = message;
  feedbackMessage.value = "";
}
  • Step 2: Add stocktake button above warehouse strip

In the template, insert between panel header and <div class="inventory-warehouse-strip"...>:

      <div class="inventory-stocktake-bar">
        <button class="stocktake-launch-button no-auto-icon" type="button" @click="openStocktakeDialog">
          <span class="stocktake-launch-icon" aria-hidden="true">
            <svg viewBox="0 0 24 24">
              <path d="M5 4h14v16H5z" />
              <path d="M8 8h8" />
              <path d="M8 12h8" />
              <path d="M8 16h5" />
            </svg>
          </span>
          <strong>盘库</strong>
        </button>
      </div>

Before closing </section> root, add:

    <StocktakeDialog
      :open="stocktakeDialogOpen"
      :warehouses="warehouses"
      :default-warehouse-id="activeWarehouseId"
      @close="closeStocktakeDialog"
      @changed="loadAll"
      @message="handleStocktakeMessage"
      @error="handleStocktakeError"
    />
  • Step 3: Add styles

Append to frontend/src/styles/main.css:

.inventory-stocktake-bar {
  display: flex;
  justify-content: flex-end;
  margin: 4px 0 12px;
}

.stocktake-launch-button {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  border: 1px solid rgba(37, 99, 235, 0.38);
  border-radius: 16px;
  padding: 10px 18px;
  background: linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(219, 234, 254, 0.82));
  color: #1d4ed8;
  box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16);
  cursor: pointer;
}

.stocktake-launch-button:hover {
  transform: translateY(-2px);
  box-shadow: 0 18px 38px rgba(37, 99, 235, 0.22);
}

.stocktake-launch-icon {
  width: 26px;
  height: 26px;
  display: inline-grid;
  place-items: center;
}

.stocktake-launch-icon svg,
.stocktake-summary-card svg {
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.stocktake-mask {
  align-items: center;
  justify-content: center;
}

.stocktake-shell {
  width: min(1500px, 92vw);
  height: min(860px, 90vh);
  display: grid;
  grid-template-rows: auto auto 1fr;
  border: 1px solid rgba(148, 163, 184, 0.55);
  border-radius: 26px;
  background: rgba(248, 250, 252, 0.94);
  box-shadow: 0 30px 90px rgba(15, 23, 42, 0.28);
  overflow: hidden;
}

.stocktake-head,
.stocktake-footer {
  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-steps {
  display: flex;
  gap: 10px;
  padding: 14px 20px;
  background: rgba(241, 245, 249, 0.78);
}

.stocktake-step,
.stocktake-warehouse-card,
.stocktake-summary-card,
.stocktake-action-card {
  border: 1px solid rgba(148, 163, 184, 0.42);
  border-radius: 18px;
  background: rgba(255, 255, 255, 0.86);
  box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
}

.stocktake-step {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  cursor: pointer;
}

.stocktake-step.active {
  border-color: rgba(37, 99, 235, 0.7);
  color: #1d4ed8;
  box-shadow: 0 16px 36px rgba(37, 99, 235, 0.16);
}

.stocktake-panel {
  overflow: auto;
  padding: 20px;
}

.stocktake-warehouse-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 14px;
}

.stocktake-warehouse-card {
  display: grid;
  gap: 8px;
  padding: 16px;
  cursor: pointer;
}

.stocktake-warehouse-card.active {
  border-color: rgba(22, 163, 74, 0.72);
  background: rgba(240, 253, 244, 0.9);
}

.stocktake-summary-grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(120px, 1fr));
  gap: 12px;
  margin-bottom: 14px;
}

.stocktake-summary-card {
  padding: 12px 14px;
}

.stocktake-summary-card strong {
  display: block;
  margin-top: 6px;
  font-size: 22px;
}

.stocktake-summary-card.gain,
.stocktake-summary-card.new {
  background: rgba(240, 253, 244, 0.92);
  color: #166534;
}

.stocktake-summary-card.loss,
.stocktake-summary-card.missing {
  background: rgba(254, 242, 242, 0.92);
  color: #991b1b;
}

.stocktake-summary-card.error {
  background: rgba(127, 29, 29, 0.92);
  color: #fff;
}

.stocktake-diff-toolbar {
  display: flex;
  gap: 10px;
  margin-bottom: 12px;
}

.stocktake-diff-table {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.stocktake-diff-column {
  border: 1px solid rgba(148, 163, 184, 0.42);
  border-radius: 18px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.86);
}

.stocktake-diff-column h4 {
  margin: 0;
  padding: 12px 14px;
  background: rgba(226, 232, 240, 0.8);
}

.stocktake-diff-row {
  display: grid;
  gap: 4px;
  min-height: 76px;
  padding: 12px 14px;
  border-top: 1px solid rgba(226, 232, 240, 0.9);
}

.stocktake-diff-row.diff-gain,
.stocktake-diff-row.diff-new_lot {
  background: rgba(220, 252, 231, 0.86);
}

.stocktake-diff-row.diff-loss,
.stocktake-diff-row.diff-missing {
  background: rgba(254, 226, 226, 0.9);
}

.stocktake-diff-row.diff-error {
  background: #7f1d1d;
  color: #fff;
}

.stocktake-confirm-input {
  min-width: 220px;
  border: 1px solid rgba(148, 163, 184, 0.7);
  border-radius: 12px;
  padding: 10px 12px;
}
  • Step 4: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npx vite build --mode development

Expected: build PASS.


Task 11: Add Stocktake History View Inside Dialog

Files:

  • Modify: frontend/src/components/StocktakeDialog.vue

  • Step 1: Add history step

Change steps in StocktakeDialog.vue:

const steps = [
  { key: "select", no: "01", label: "选择仓库" },
  { key: "export", no: "02", label: "导出清单" },
  { key: "import", no: "03", label: "导入盘点" },
  { key: "diff", no: "04", label: "差异确认" },
  { key: "history", no: "05", label: "盘库记录" }
];

Add state:

const historyRows = ref([]);

Add loader:

async function loadHistory() {
  try {
    historyRows.value = await fetchResource("/inventory/stocktakes?limit=100", []);
  } catch (error) {
    emit("error", error.message || "读取盘库记录失败");
  }
}

Update watcher when opened:

watch(
  () => props.open,
  async (open) => {
    if (!open) return;
    activeStep.value = "select";
    selectedWarehouseIds.value = props.defaultWarehouseId ? [props.defaultWarehouseId] : [];
    activeStocktake.value = null;
    previewLines.value = [];
    previewSummary.value = {};
    diffFilter.value = "ALL";
    confirmText.value = "";
    await loadHistory();
  }
);
  • Step 2: Add history template

Add after diff section:

        <section v-if="activeStep === 'history'" class="stocktake-panel">
          <div class="table-wrap">
            <table class="data-table compact-table">
              <thead>
                <tr>
                  <th>盘库单号</th>
                  <th>仓库</th>
                  <th>状态</th>
                  <th>开始时间</th>
                  <th>确认时间</th>
                  <th>盘盈</th>
                  <th>盘亏</th>
                  <th>新增批次</th>
                  <th>缺失批次</th>
                  <th>影响金额</th>
                </tr>
              </thead>
              <tbody>
                <tr v-for="row in historyRows" :key="row.id">
                  <td>{{ row.stocktake_no }}</td>
                  <td>{{ row.warehouses.map((item) => item.warehouse_name).join("、") }}</td>
                  <td>{{ formatStocktakeStatusLabel(row.status) }}</td>
                  <td>{{ row.started_at }}</td>
                  <td>{{ row.confirmed_at || "-" }}</td>
                  <td>{{ row.summary.gain_count || 0 }}</td>
                  <td>{{ row.summary.loss_count || 0 }}</td>
                  <td>{{ row.summary.new_lot_count || 0 }}</td>
                  <td>{{ row.summary.missing_count || 0 }}</td>
                  <td>¥{{ formatAmount(row.summary.diff_amount || 0) }}</td>
                </tr>
                <tr v-if="!historyRows.length">
                  <td colspan="10" class="empty-row">暂无盘库记录</td>
                </tr>
              </tbody>
            </table>
          </div>
        </section>
  • Step 3: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npx vite build --mode development

Expected: build PASS.


Task 12: Final Verification and Regression Sweep

Files:

  • Verify all touched backend and frontend files.

  • Step 1: Run stocktake unit tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest tests.test_stocktake_locking tests.test_stocktake_excel_diff_confirm

Expected: PASS.

  • Step 2: Run backend compile

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m compileall app

Expected: PASS.

  • Step 3: Run frontend build

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npx vite build --mode development

Expected: PASS.

  • Step 4: Manual browser workflow check

Run the app using the project's normal backend/frontend commands, then verify:

1. 打开 采购与库存 -> 嘉恒仓库。
2. 点击仓库 bar 上方“盘库”。
3. 默认勾选当前仓库。
4. 点击“开始盘库并锁库”。
5. 在锁库期间尝试对该仓库做入库或出库,页面应收到“正在盘库”错误提示。
6. 导出 Excel确认 sheet 名按仓库分开。
7. 修改“盘点重量(kg)”或“盘点数量”后导入。
8. 差异页面左右两表对齐显示。
9. 盘盈、盘亏、新增批次、缺失批次颜色明显。
10. 输入“确认盘库”并确认。
11. 库存列表刷新后显示调整后的库存。
12. 再次进行入库或出库,锁已解除。
13. 打开盘库记录,能看到本次盘库和差异汇总。
  • Step 5: Run broader backend tests and document known unrelated failures

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m unittest discover -s tests

Expected: stocktake tests PASS. If existing purchase receipt or purchase lock tests fail with previously known failures, record exact test names in the final response and do not change unrelated purchase status behavior unless the user asks for that fix.

  • Step 6: Commit if this workspace is a git repository

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git rev-parse --is-inside-work-tree

If output is true, commit:

git add backend/app/models/operations.py backend/app/schemas/operations.py backend/app/services/stocktake.py backend/app/api/routes/inventory.py backend/app/api/routes/purchase.py backend/app/api/routes/production.py backend/app/api/routes/sales.py backend/sql/add_stocktake_tables.sql backend/tests/test_stocktake_locking.py backend/tests/test_stocktake_excel_diff_confirm.py frontend/src/views/InventoryLedgerView.vue frontend/src/components/StocktakeDialog.vue frontend/src/styles/main.css frontend/src/utils/dictionaries.js
git commit -m "feat: add locked warehouse stocktake"

If output says this is not a git repository, skip the commit and mention that the workspace has no .git directory.


Self-Review

  • Spec coverage: The plan implements lock-based stocktake, multi-warehouse Excel export with sheets, import, side-by-side diff, confirmation adjustment, audit records, history display, and lock guards on warehouse-changing flows.
  • Placeholder scan: The plan contains concrete function names, file paths, validation behavior, commands, and expected outputs.
  • Type consistency: Stocktake, StocktakeWarehouse, StocktakeLine, StocktakeAdjustment, start_stocktake, build_stocktake_workbook, import_stocktake_workbook, confirm_stocktake, and ensure_warehouses_unlocked are introduced before dependent tasks use them.