ForgeFlow-ERP/docs/superpowers/plans/2026-06-01-warehouse-transaction-ledger.md
2026-06-14 21:05:49 +08:00

43 KiB

Warehouse Transaction Ledger 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: Add a warehouse-level “流水” entry in 百华仓库 so each of the six warehouses can view paginated, searchable, sortable inbound/outbound inventory transactions.

Architecture: Keep wh_inventory_txn as the single source of truth. Add a new backend pagination endpoint for warehouse-level transaction ledger queries, then add a right-side drawer in InventoryLedgerView.vue that follows the active warehouse tab and displays summary cards, filters, and transaction rows. Keep the existing material-level stock detail drawer unchanged.

Tech Stack: FastAPI, SQLAlchemy ORM, Pydantic schemas, SQLite backend unit tests, Vue 3 SFC, Vite, existing FormDrawer, PaginationBar, TableControls, and notification conventions.


Confirmed Requirements

  • Add a “流水” icon/text button in 百华仓库, positioned on the right side of the existing inbound/outbound operation bar.
  • The button follows the active warehouse tab: 原材料库、半成品库、成品库、辅料库、废料库、退货库.
  • Clicking opens a right-side drawer titled {当前仓库名} · 出入库流水.
  • The drawer shows summary cards, filters, and a paginated table.
  • Data comes from wh_inventory_txn; do not create a second ledger table.
  • Warehouse-level ledger does not replace the existing material-level stock detail drawer.
  • 原材料库、辅料库、废料库 use weight-first display and must not revive raw material quantity concepts.

File Structure

  • Create backend/tests/test_warehouse_transaction_ledger.py
    • Owns backend regression coverage for warehouse filtering, direction, keyword, pagination, summary, and raw-material quantity display.
  • Modify backend/app/schemas/operations.py
    • Add response schemas for ledger rows, summary, and paginated response.
  • Modify backend/app/services/operations.py
    • Add reusable ledger query helpers and summary calculation.
  • Modify backend/app/api/routes/inventory.py
    • Add GET /inventory/transaction-ledger.
  • Create frontend/scripts/test-inventory-ledger-transaction-ledger.mjs
    • Static checks for the new drawer, endpoint, and preservation of existing material-level flow.
  • Modify frontend/src/views/InventoryLedgerView.vue
    • Add the “流水” action button, drawer, filters, server-side loading state, and table rendering.

Task 1: Add Backend Failing Tests For Warehouse Transaction Ledger

Files:

  • Create: backend/tests/test_warehouse_transaction_ledger.py

  • Step 1: Create the backend test file scaffold

Create backend/tests/test_warehouse_transaction_ledger.py with this content:

from __future__ import annotations

import unittest
from datetime import UTC, datetime, timedelta
from decimal import Decimal

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
import app.models.org  # noqa: E402,F401
from app.models.base import Base  # noqa: E402


class WarehouseTransactionLedgerTest(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()

    def tearDown(self) -> None:
        self.db.close()

    def seed_ledger_data(self) -> None:
        from app.models.master_data import Item, Warehouse
        from app.models.operations import InventoryTxn, StockLot, WarehouseLocation
        from app.models.org import Department, Employee, User

        now = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
        department = Department(
            id=1,
            dept_code="D001",
            dept_name="仓储部",
            dept_type="WAREHOUSE",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        employee = Employee(
            id=1,
            employee_code="EMP001",
            employee_name="张仓管",
            dept_id=1,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        user = User(
            id=1,
            username="keeper",
            password_hash="x",
            employee_id=1,
            dept_id=1,
            nickname="张仓管",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        raw_wh = Warehouse(
            id=1,
            warehouse_code="WH-RAW-01",
            warehouse_name="原材料库",
            warehouse_type="RAW",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        finished_wh = Warehouse(
            id=2,
            warehouse_code="WH-FG-01",
            warehouse_name="成品库",
            warehouse_type="FINISHED",
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        raw_location = WarehouseLocation(
            id=1,
            warehouse_id=1,
            location_code="RAW-A",
            location_name="原料主库位",
            is_default=1,
            is_locked=0,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        finished_location = WarehouseLocation(
            id=2,
            warehouse_id=2,
            location_code="FG-A",
            location_name="成品主库位",
            is_default=1,
            is_locked=0,
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        raw_item = Item(
            id=1,
            item_code="原材料00001",
            item_name="304不锈钢",
            item_type="RAW_MATERIAL",
            unit_weight_kg=Decimal("1"),
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        finished_item = Item(
            id=2,
            item_code="产品需规00001",
            item_name="冲压支架",
            item_type="FINISHED_GOOD",
            unit_weight_kg=Decimal("2.5"),
            status="ACTIVE",
            created_at=now,
            updated_at=now,
        )
        raw_lot = StockLot(
            id=1,
            lot_no="JH_原材料00001_0001",
            lot_role="INBOUND_RAW",
            item_id=1,
            warehouse_id=1,
            location_id=1,
            source_doc_type="OPENING_STOCK",
            source_doc_id=1,
            inbound_qty=0,
            inbound_weight_kg=1000,
            remaining_qty=0,
            remaining_weight_kg=850,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("10"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=now,
            updated_at=now,
        )
        finished_lot = StockLot(
            id=2,
            lot_no="FG-20260601-001",
            lot_role="INBOUND_FINISHED",
            item_id=2,
            warehouse_id=2,
            location_id=2,
            source_doc_type="FG_RECEIPT",
            source_doc_id=9,
            inbound_qty=100,
            inbound_weight_kg=250,
            remaining_qty=80,
            remaining_weight_kg=200,
            locked_qty=0,
            locked_weight_kg=0,
            unit_cost=Decimal("18"),
            quality_status="PASS",
            status="AVAILABLE",
            created_at=now,
            updated_at=now,
        )
        transactions = [
            InventoryTxn(
                id=1,
                txn_no="TXN-RAW-IN",
                txn_type="OPENING_IN",
                item_id=1,
                warehouse_id=1,
                location_id=1,
                lot_id=1,
                qty_change=0,
                weight_change_kg=1000,
                unit_cost=10,
                amount=10000,
                source_doc_type="OPENING_STOCK",
                source_doc_id=1,
                biz_time=now,
                operator_user_id=1,
                remark="期初入库 304",
                created_at=now,
                updated_at=now,
            ),
            InventoryTxn(
                id=2,
                txn_no="TXN-RAW-OUT",
                txn_type="MATERIAL_ISSUE",
                item_id=1,
                warehouse_id=1,
                location_id=1,
                lot_id=1,
                qty_change=0,
                weight_change_kg=-150,
                unit_cost=10,
                amount=1500,
                source_doc_type="WORK_ORDER",
                source_doc_id=2,
                biz_time=now + timedelta(hours=1),
                operator_user_id=1,
                remark="生产出库",
                created_at=now,
                updated_at=now,
            ),
            InventoryTxn(
                id=3,
                txn_no="TXN-FG-IN",
                txn_type="FG_IN",
                item_id=2,
                warehouse_id=2,
                location_id=2,
                lot_id=2,
                qty_change=100,
                weight_change_kg=250,
                unit_cost=18,
                amount=1800,
                source_doc_type="FG_RECEIPT",
                source_doc_id=9,
                biz_time=now + timedelta(hours=2),
                operator_user_id=1,
                remark="成品入库",
                created_at=now,
                updated_at=now,
            ),
        ]
        self.db.add_all([department, employee, user, raw_wh, finished_wh, raw_location, finished_location, raw_item, finished_item, raw_lot, finished_lot, *transactions])
        self.db.commit()
  • Step 2: Add the failing warehouse filter and summary test

Append this test to WarehouseTransactionLedgerTest:

    def test_transaction_ledger_filters_by_warehouse_type_and_returns_summary(self) -> None:
        from app.api.routes.inventory import list_inventory_transaction_ledger

        self.seed_ledger_data()

        result = list_inventory_transaction_ledger(
            warehouse_type="RAW",
            page=1,
            page_size=10,
            db=self.db,
        )

        self.assertEqual(result.total, 2)
        self.assertEqual([row.warehouse_type for row in result.items], ["RAW", "RAW"])
        self.assertEqual(result.summary.in_count, 1)
        self.assertEqual(result.summary.out_count, 1)
        self.assertEqual(Decimal(str(result.summary.in_weight_kg)), Decimal("1000.0"))
        self.assertEqual(Decimal(str(result.summary.out_weight_kg)), Decimal("150.0"))
        self.assertEqual(result.items[0].txn_type, "MATERIAL_ISSUE")
        self.assertEqual(result.items[0].direction, "OUT")
  • Step 3: Add the failing keyword, direction, pagination, and raw quantity test

Append this test to WarehouseTransactionLedgerTest:

    def test_transaction_ledger_supports_keyword_direction_pagination_and_raw_qty_zero(self) -> None:
        from app.api.routes.inventory import list_inventory_transaction_ledger

        self.seed_ledger_data()

        result = list_inventory_transaction_ledger(
            warehouse_type="RAW",
            keyword="304",
            direction="OUT",
            page=1,
            page_size=1,
            db=self.db,
        )

        self.assertEqual(result.total, 1)
        self.assertEqual(len(result.items), 1)
        row = result.items[0]
        self.assertEqual(row.txn_no, "TXN-RAW-OUT")
        self.assertEqual(row.item_code, "原材料00001")
        self.assertEqual(row.lot_no, "JH_原材料00001_0001")
        self.assertEqual(row.qty_change, 0)
        self.assertEqual(row.operator_name, "张仓管")
        self.assertEqual(result.summary.out_count, 1)
  • Step 4: Add the failing invalid warehouse type test

Append this test to WarehouseTransactionLedgerTest:

    def test_transaction_ledger_rejects_unknown_warehouse_type(self) -> None:
        from app.api.routes.inventory import list_inventory_transaction_ledger
        from fastapi import HTTPException

        self.seed_ledger_data()

        with self.assertRaises(HTTPException) as exc:
            list_inventory_transaction_ledger(
                warehouse_type="UNKNOWN",
                page=1,
                page_size=10,
                db=self.db,
            )

        self.assertEqual(exc.exception.status_code, 400)
        self.assertIn("未知仓库类型", exc.exception.detail)
  • Step 5: Run tests and verify they fail for missing API

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_warehouse_transaction_ledger.py -q

Expected: FAIL with an import or attribute error for list_inventory_transaction_ledger.


Task 2: Add Backend Schemas, Query Helper, And API Route

Files:

  • Modify: backend/app/schemas/operations.py

  • Modify: backend/app/services/operations.py

  • Modify: backend/app/api/routes/inventory.py

  • Test: backend/tests/test_warehouse_transaction_ledger.py

  • Step 1: Add response schemas

In backend/app/schemas/operations.py, after InventoryTxnRead, add:

class InventoryTxnLedgerRowRead(InventoryTxnRead):
    direction: str
    source_line_id: int | None = None
    operator_user_id: int | None = None
    operator_name: str | None = None


class InventoryTxnLedgerSummaryRead(BaseModel):
    in_count: int = 0
    out_count: int = 0
    adjust_count: int = 0
    in_qty: float = 0
    out_qty: float = 0
    in_weight_kg: float = 0
    out_weight_kg: float = 0
    latest_biz_time: datetime | None = None


class InventoryTxnLedgerResponse(BaseModel):
    items: list[InventoryTxnLedgerRowRead]
    total: int
    summary: InventoryTxnLedgerSummaryRead
  • Step 2: Import the schemas in inventory route

In backend/app/api/routes/inventory.py, extend the schema import block:

from app.schemas.operations import (
    ...
    InventoryTxnLedgerResponse,
)

Keep existing imports and add only the new name.

  • Step 3: Add service constants and helpers

In backend/app/services/operations.py, near get_inventory_txn_query, add:

WAREHOUSE_LEDGER_TYPES = {"RAW", "SEMI", "FINISHED", "AUX", "SCRAP", "RETURN"}

WAREHOUSE_LEDGER_SORT_COLUMNS = {
    "biz_time": InventoryTxn.biz_time,
    "txn_type": InventoryTxn.txn_type,
    "item_code": Item.item_code,
    "item_name": Item.item_name,
    "lot_no": StockLot.lot_no,
    "qty_change": InventoryTxn.qty_change,
    "weight_change_kg": InventoryTxn.weight_change_kg,
    "amount": InventoryTxn.amount,
}


def inventory_txn_direction_expression():
    return case(
        (or_(InventoryTxn.txn_type.like("STOCKTAKE_%"), InventoryTxn.source_doc_type == "STOCKTAKE"), "ADJUST"),
        (or_(InventoryTxn.weight_change_kg > 0, InventoryTxn.qty_change > 0), "IN"),
        (or_(InventoryTxn.weight_change_kg < 0, InventoryTxn.qty_change < 0), "OUT"),
        else_="ADJUST",
    )
  • Step 4: Add the ledger row query helper

In backend/app/services/operations.py, below the helper above, add:

def get_inventory_txn_ledger_query(
    *,
    warehouse_type: str,
    warehouse_id: int | None = None,
    keyword: str | None = None,
    direction: str = "ALL",
    txn_type: str | None = None,
    item_id: int | None = None,
    lot_no: str | None = None,
    start_date: date | None = None,
    end_date: date | None = None,
    sort_key: str = "biz_time",
    sort_direction: str = "desc",
) -> Select:
    normalized_warehouse_type = str(warehouse_type or "").upper()
    direction_expr = inventory_txn_direction_expression().label("direction")
    operator_employee = aliased(Employee)
    stmt = (
        select(
            InventoryTxn.id.label("inventory_txn_id"),
            InventoryTxn.txn_no.label("txn_no"),
            InventoryTxn.txn_type.label("txn_type"),
            InventoryTxn.item_id.label("item_id"),
            Item.item_code.label("item_code"),
            Item.item_name.label("item_name"),
            InventoryTxn.warehouse_id.label("warehouse_id"),
            Warehouse.warehouse_name.label("warehouse_name"),
            Warehouse.warehouse_type.label("warehouse_type"),
            InventoryTxn.location_id.label("location_id"),
            WarehouseLocation.location_name.label("location_name"),
            InventoryTxn.lot_id.label("lot_id"),
            StockLot.lot_no.label("lot_no"),
            case((Item.item_type == "RAW_MATERIAL", literal(0)), else_=InventoryTxn.qty_change).label("qty_change"),
            InventoryTxn.weight_change_kg.label("weight_change_kg"),
            InventoryTxn.unit_cost.label("unit_cost"),
            InventoryTxn.amount.label("amount"),
            InventoryTxn.source_doc_type.label("source_doc_type"),
            InventoryTxn.source_doc_id.label("source_doc_id"),
            InventoryTxn.source_line_id.label("source_line_id"),
            InventoryTxn.biz_time.label("biz_time"),
            InventoryTxn.operator_user_id.label("operator_user_id"),
            func.coalesce(operator_employee.employee_name, User.nickname, User.username).label("operator_name"),
            InventoryTxn.logistics_waybill_no.label("logistics_waybill_no"),
            InventoryTxn.logistics_freight_amount.label("logistics_freight_amount"),
            InventoryTxn.logistics_photo_url.label("logistics_photo_url"),
            InventoryTxn.remark.label("remark"),
            direction_expr,
        )
        .join(Item, Item.id == InventoryTxn.item_id)
        .join(Warehouse, Warehouse.id == InventoryTxn.warehouse_id)
        .outerjoin(WarehouseLocation, WarehouseLocation.id == InventoryTxn.location_id)
        .outerjoin(StockLot, StockLot.id == InventoryTxn.lot_id)
        .outerjoin(User, User.id == InventoryTxn.operator_user_id)
        .outerjoin(operator_employee, operator_employee.id == User.employee_id)
        .where(Warehouse.warehouse_type == normalized_warehouse_type)
    )
    if warehouse_id:
        stmt = stmt.where(InventoryTxn.warehouse_id == warehouse_id)
    if txn_type:
        stmt = stmt.where(InventoryTxn.txn_type == str(txn_type).upper())
    if item_id:
        stmt = stmt.where(InventoryTxn.item_id == item_id)
    if lot_no:
        stmt = stmt.where(StockLot.lot_no.like(f"%{lot_no.strip()}%"))
    if start_date:
        stmt = stmt.where(InventoryTxn.biz_time >= start_of_day(start_date))
    if end_date:
        stmt = stmt.where(InventoryTxn.biz_time <= end_of_day(end_date))
    if keyword and keyword.strip():
        pattern = f"%{keyword.strip()}%"
        stmt = stmt.where(
            or_(
                Item.item_code.like(pattern),
                Item.item_name.like(pattern),
                InventoryTxn.txn_type.like(pattern),
                InventoryTxn.source_doc_type.like(pattern),
                InventoryTxn.txn_no.like(pattern),
                InventoryTxn.logistics_waybill_no.like(pattern),
                InventoryTxn.remark.like(pattern),
                StockLot.lot_no.like(pattern),
            )
        )
    normalized_direction = str(direction or "ALL").upper()
    if normalized_direction in {"IN", "OUT", "ADJUST"}:
        stmt = stmt.where(direction_expr == normalized_direction)

    sort_column = WAREHOUSE_LEDGER_SORT_COLUMNS.get(sort_key, InventoryTxn.biz_time)
    if str(sort_direction or "desc").lower() == "asc":
        stmt = stmt.order_by(sort_column.asc(), InventoryTxn.id.asc())
    else:
        stmt = stmt.order_by(sort_column.desc(), InventoryTxn.id.desc())
    return stmt
  • Step 5: Add the summary helper

In backend/app/services/operations.py, below get_inventory_txn_ledger_query, add:

def summarize_inventory_txn_ledger(db: Session, ledger_stmt: Select) -> dict[str, object]:
    rows = db.execute(ledger_stmt).mappings().all()
    summary = {
        "in_count": 0,
        "out_count": 0,
        "adjust_count": 0,
        "in_qty": Decimal("0"),
        "out_qty": Decimal("0"),
        "in_weight_kg": Decimal("0"),
        "out_weight_kg": Decimal("0"),
        "latest_biz_time": None,
    }
    for row in rows:
        direction = str(row["direction"] or "ADJUST").upper()
        qty = abs(to_decimal(row["qty_change"]))
        weight = abs(to_decimal(row["weight_change_kg"]))
        if direction == "IN":
            summary["in_count"] += 1
            summary["in_qty"] += qty
            summary["in_weight_kg"] += weight
        elif direction == "OUT":
            summary["out_count"] += 1
            summary["out_qty"] += qty
            summary["out_weight_kg"] += weight
        else:
            summary["adjust_count"] += 1
        biz_time = row["biz_time"]
        if biz_time and (summary["latest_biz_time"] is None or biz_time > summary["latest_biz_time"]):
            summary["latest_biz_time"] = biz_time
    return summary
  • Step 6: Import helpers into inventory route

In backend/app/api/routes/inventory.py, add these service imports to the existing import list from app.services.operations:

    WAREHOUSE_LEDGER_TYPES,
    get_inventory_txn_ledger_query,
    summarize_inventory_txn_ledger,
  • Step 7: Add the API endpoint

In backend/app/api/routes/inventory.py, after list_inventory_transactions, add:

@router.get("/transaction-ledger", response_model=InventoryTxnLedgerResponse)
def list_inventory_transaction_ledger(
    warehouse_type: str = Query(...),
    warehouse_id: int | None = Query(default=None, ge=1),
    keyword: str | None = Query(default=None),
    direction: str = Query(default="ALL"),
    txn_type: str | None = Query(default=None),
    item_id: int | None = Query(default=None, ge=1),
    lot_no: str | None = Query(default=None),
    start_date: date | None = Query(default=None),
    end_date: date | None = Query(default=None),
    page: int = Query(default=1, ge=1),
    page_size: int = Query(default=10, ge=1, le=100),
    sort_key: str = Query(default="biz_time"),
    sort_direction: str = Query(default="desc"),
    db: Session = Depends(get_db),
) -> InventoryTxnLedgerResponse:
    normalized_warehouse_type = str(warehouse_type or "").upper()
    if normalized_warehouse_type not in WAREHOUSE_LEDGER_TYPES:
        raise HTTPException(status_code=400, detail="未知仓库类型,无法查询出入库流水")
    normalized_direction = str(direction or "ALL").upper()
    if normalized_direction not in {"ALL", "IN", "OUT", "ADJUST"}:
        raise HTTPException(status_code=400, detail="流水方向只能是 ALL、IN、OUT、ADJUST")

    stmt = get_inventory_txn_ledger_query(
        warehouse_type=normalized_warehouse_type,
        warehouse_id=warehouse_id,
        keyword=keyword,
        direction=normalized_direction,
        txn_type=txn_type,
        item_id=item_id,
        lot_no=lot_no,
        start_date=start_date,
        end_date=end_date,
        sort_key=sort_key,
        sort_direction=sort_direction,
    )
    total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
    summary = summarize_inventory_txn_ledger(db, stmt)
    offset = (page - 1) * page_size
    rows = db.execute(stmt.offset(offset).limit(page_size)).mappings().all()
    return InventoryTxnLedgerResponse(
        items=[dict(row) for row in rows],
        total=total,
        summary=summary,
    )
  • Step 8: Run backend tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_warehouse_transaction_ledger.py -q

Expected: PASS.

  • Step 9: Run related backend regression tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_return_warehouse_flow.py tests/test_opening_inventory_import.py tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_scrap_stocktake_keeps_product_scrap_weight_only_after_confirm -q

Expected: PASS.

  • Step 10: Compile changed backend files

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m py_compile app/api/routes/inventory.py app/services/operations.py app/schemas/operations.py tests/test_warehouse_transaction_ledger.py

Expected: exit code 0.


Task 3: Add Frontend Static Checks For Warehouse-Level Ledger

Files:

  • Create: frontend/scripts/test-inventory-ledger-transaction-ledger.mjs

  • Step 1: Create the failing static test script

Create frontend/scripts/test-inventory-ledger-transaction-ledger.mjs:

import fs from "node:fs";
import path from "node:path";

const root = process.cwd();
const viewPath = path.join(root, "src/views/InventoryLedgerView.vue");
const view = fs.readFileSync(viewPath, "utf8");

const checks = [
  ["ledger button exists", view.includes("openWarehouseLedgerDrawer") && view.includes("查看当前仓库出入库流水")],
  ["ledger drawer exists", view.includes("warehouseLedgerDrawerOpen") && view.includes("出入库流水")],
  ["ledger endpoint used", view.includes("/inventory/transaction-ledger")],
  ["warehouse tab drives ledger", view.includes("activeWarehouseType") && view.includes("activeInventoryLabel")],
  ["server pagination state exists", view.includes("warehouseLedgerPage") && view.includes("warehouseLedgerPageSize") && view.includes("warehouseLedgerTotal")],
  ["existing material stock detail remains", view.includes("openStockDetail(balance)") && view.includes("selectedTransactions")],
  ["six warehouse tabs still present", ["raw", "semi", "finished", "auxiliary", "scrap", "return"].every((key) => view.includes(`key: "${key}"`))]
];

const failed = checks.filter(([, passed]) => !passed);
if (failed.length) {
  console.error("Warehouse transaction ledger UI checks failed:");
  failed.forEach(([name]) => console.error(`- ${name}`));
  process.exit(1);
}

console.log("Warehouse transaction ledger UI checks passed");
  • Step 2: Run static test and verify it fails

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-transaction-ledger.mjs

Expected: FAIL and list missing ledger UI checks.


Task 4: Add Warehouse Ledger Drawer UI In InventoryLedgerView

Files:

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

  • Test: frontend/scripts/test-inventory-ledger-transaction-ledger.mjs

  • Step 1: Add the ledger button to the operation bar

In frontend/src/views/InventoryLedgerView.vue, inside <div class="inventory-io-bar" aria-label="出入库业务操作">, after the two existing .inventory-io-row blocks, add:

        <button
          class="inventory-ledger-button no-auto-icon"
          type="button"
          title="查看当前仓库出入库流水"
          @click="openWarehouseLedgerDrawer"
        >
          <span class="inventory-ledger-icon" aria-hidden="true">
            <svg viewBox="0 0 24 24">
              <path d="M6 4h12v16H6z" />
              <path d="M9 8h6" />
              <path d="M9 12h6" />
              <path d="M9 16h4" />
            </svg>
          </span>
          <strong>流水</strong>
        </button>
  • Step 2: Add the ledger drawer template

In the same Vue file, before <StocktakeDialog, add:

    <FormDrawer
      :open="warehouseLedgerDrawerOpen"
      eyebrow="百华仓库 · 库级流水"
      :title="`${activeInventoryLabel} · 出入库流水`"
      tag="库存流水"
      wide
      @close="closeWarehouseLedgerDrawer"
    >
      <div class="stack-form warehouse-ledger-drawer">
        <div v-if="feedbackMessage" class="feedback-banner">{{ feedbackMessage }}</div>
        <div v-if="errorMessage" class="feedback-banner error-banner">{{ errorMessage }}</div>

        <div class="warehouse-ledger-summary">
          <article class="warehouse-ledger-summary-card">
            <span>入库笔数</span>
            <strong>{{ warehouseLedgerSummary.in_count || 0 }}</strong>
          </article>
          <article class="warehouse-ledger-summary-card">
            <span>出库笔数</span>
            <strong>{{ warehouseLedgerSummary.out_count || 0 }}</strong>
          </article>
          <article class="warehouse-ledger-summary-card">
            <span>入库重量</span>
            <strong>{{ formatWeight(warehouseLedgerSummary.in_weight_kg || 0) }}</strong>
          </article>
          <article class="warehouse-ledger-summary-card">
            <span>出库重量</span>
            <strong>{{ formatWeight(warehouseLedgerSummary.out_weight_kg || 0) }}</strong>
          </article>
          <article class="warehouse-ledger-summary-card">
            <span>最近流水</span>
            <strong>{{ warehouseLedgerSummary.latest_biz_time ? formatDateTime(warehouseLedgerSummary.latest_biz_time) : "-" }}</strong>
          </article>
        </div>

        <div class="warehouse-ledger-filters">
          <label class="form-field">
            <span>搜索</span>
            <input v-model.trim="warehouseLedgerFilters.keyword" type="text" placeholder="搜索物料、批次、流水类型、来源、运单号、备注" @keyup.enter="loadWarehouseLedger" />
          </label>
          <label class="form-field">
            <span>方向</span>
            <select v-model="warehouseLedgerFilters.direction" @change="loadWarehouseLedger">
              <option value="ALL">全部</option>
              <option value="IN">入库</option>
              <option value="OUT">出库</option>
              <option value="ADJUST">调整</option>
            </select>
          </label>
          <label class="form-field">
            <span>业务类型</span>
            <input v-model.trim="warehouseLedgerFilters.txn_type" type="text" placeholder="如 SALES_OUT、RETURN_IN" @keyup.enter="loadWarehouseLedger" />
          </label>
          <label class="form-field">
            <span>开始日期</span>
            <input v-model="warehouseLedgerFilters.start_date" type="date" @change="loadWarehouseLedger" />
          </label>
          <label class="form-field">
            <span>结束日期</span>
            <input v-model="warehouseLedgerFilters.end_date" type="date" @change="loadWarehouseLedger" />
          </label>
          <button class="ghost-button" type="button" :disabled="warehouseLedgerLoading" @click="resetWarehouseLedgerFilters">
            重置
          </button>
          <button class="primary-button" type="button" :disabled="warehouseLedgerLoading" @click="loadWarehouseLedger">
            {{ warehouseLedgerLoading ? "查询中..." : "查询" }}
          </button>
        </div>

        <div class="table-wrap">
          <table class="data-table compact-table">
            <thead>
              <tr>
                <th>时间</th>
                <th>方向</th>
                <th>类型</th>
                <th>物料</th>
                <th>批次号</th>
                <th v-if="!activeWeightOnly">变动数量</th>
                <th>变动重量(kg)</th>
                <th>单价</th>
                <th>金额</th>
                <th>来源</th>
                <th>经办人</th>
                <th>运单/照片</th>
                <th>备注</th>
              </tr>
            </thead>
            <tbody>
              <tr v-for="row in warehouseLedgerRows" :key="row.inventory_txn_id">
                <td>{{ formatDateTime(row.biz_time) }}</td>
                <td><span class="status-pill" :class="warehouseLedgerDirectionClass(row.direction)">{{ formatWarehouseLedgerDirection(row.direction) }}</span></td>
                <td>{{ formatInventoryTxnTypeLabel(row.txn_type) }}</td>
                <td>{{ row.item_code }} · {{ row.item_name }}</td>
                <td>{{ row.lot_no || "-" }}</td>
                <td v-if="!activeWeightOnly">{{ formatQty(row.qty_change) }}</td>
                <td>{{ formatWeight(row.weight_change_kg) }}</td>
                <td>¥{{ formatAmount(row.unit_cost) }}</td>
                <td>¥{{ formatAmount(row.amount) }}</td>
                <td>{{ formatSourceDocTypeLabel(row.source_doc_type) }} #{{ row.source_doc_id }}</td>
                <td>{{ row.operator_name || "-" }}</td>
                <td>
                  <span>{{ row.logistics_waybill_no || "-" }}</span>
                  <button v-if="row.logistics_photo_url" class="link-button" type="button" @click="openLogisticsPhoto(row.logistics_photo_url)">查看</button>
                </td>
                <td>{{ row.remark || "-" }}</td>
              </tr>
              <tr v-if="!warehouseLedgerRows.length">
                <td :colspan="activeWeightOnly ? 12 : 13" class="empty-row">
                  {{ warehouseLedgerLoading ? "流水加载中..." : `暂无${activeInventoryLabel}出入库流水` }}
                </td>
              </tr>
            </tbody>
          </table>
        </div>

        <PaginationBar
          v-model:page="warehouseLedgerPage"
          v-model:page-size="warehouseLedgerPageSize"
          :total="warehouseLedgerTotal"
        />
      </div>
    </FormDrawer>
  • Step 3: Add reactive state

In <script setup>, near existing drawer refs, add:

const warehouseLedgerDrawerOpen = ref(false);
const warehouseLedgerLoading = ref(false);
const warehouseLedgerRows = ref([]);
const warehouseLedgerTotal = ref(0);
const warehouseLedgerSummary = reactive(buildEmptyWarehouseLedgerSummary());
const warehouseLedgerPage = ref(1);
const warehouseLedgerPageSize = ref(10);
const warehouseLedgerFilters = reactive(buildEmptyWarehouseLedgerFilters());
  • Step 4: Add builder functions

Near existing buildEmpty... helpers, add:

function buildEmptyWarehouseLedgerSummary() {
  return {
    in_count: 0,
    out_count: 0,
    adjust_count: 0,
    in_qty: 0,
    out_qty: 0,
    in_weight_kg: 0,
    out_weight_kg: 0,
    latest_biz_time: null
  };
}

function buildEmptyWarehouseLedgerFilters() {
  return {
    keyword: "",
    direction: "ALL",
    txn_type: "",
    start_date: "",
    end_date: "",
    sort_key: "biz_time",
    sort_direction: "desc"
  };
}
  • Step 5: Add drawer open/close and load functions

Near other drawer functions, add:

function openWarehouseLedgerDrawer() {
  resetFeedback();
  warehouseLedgerDrawerOpen.value = true;
  warehouseLedgerPage.value = 1;
  loadWarehouseLedger();
}

function closeWarehouseLedgerDrawer() {
  warehouseLedgerDrawerOpen.value = false;
}

function resetWarehouseLedgerFilters() {
  Object.assign(warehouseLedgerFilters, buildEmptyWarehouseLedgerFilters());
  warehouseLedgerPage.value = 1;
  loadWarehouseLedger();
}

function formatWarehouseLedgerDirection(direction) {
  return {
    IN: "入库",
    OUT: "出库",
    ADJUST: "调整"
  }[String(direction || "").toUpperCase()] || "-";
}

function warehouseLedgerDirectionClass(direction) {
  return {
    IN: "status-success",
    OUT: "status-warning",
    ADJUST: "status-info"
  }[String(direction || "").toUpperCase()] || "status-muted";
}

async function loadWarehouseLedger() {
  warehouseLedgerLoading.value = true;
  try {
    const params = new URLSearchParams({
      warehouse_type: activeWarehouseType.value,
      page: String(warehouseLedgerPage.value),
      page_size: String(warehouseLedgerPageSize.value),
      direction: warehouseLedgerFilters.direction || "ALL",
      sort_key: warehouseLedgerFilters.sort_key || "biz_time",
      sort_direction: warehouseLedgerFilters.sort_direction || "desc"
    });
    if (warehouseLedgerFilters.keyword) {
      params.set("keyword", warehouseLedgerFilters.keyword);
    }
    if (warehouseLedgerFilters.txn_type) {
      params.set("txn_type", warehouseLedgerFilters.txn_type);
    }
    if (warehouseLedgerFilters.start_date) {
      params.set("start_date", warehouseLedgerFilters.start_date);
    }
    if (warehouseLedgerFilters.end_date) {
      params.set("end_date", warehouseLedgerFilters.end_date);
    }
    const result = await fetchResource(`/inventory/transaction-ledger?${params.toString()}`, {
      items: [],
      total: 0,
      summary: buildEmptyWarehouseLedgerSummary()
    });
    warehouseLedgerRows.value = Array.isArray(result.items) ? result.items : [];
    warehouseLedgerTotal.value = Number(result.total || 0);
    Object.assign(warehouseLedgerSummary, buildEmptyWarehouseLedgerSummary(), result.summary || {});
    errorMessage.value = "";
  } catch (error) {
    warehouseLedgerRows.value = [];
    warehouseLedgerTotal.value = 0;
    Object.assign(warehouseLedgerSummary, buildEmptyWarehouseLedgerSummary());
    errorMessage.value = error.message || "仓库出入库流水加载失败";
  } finally {
    warehouseLedgerLoading.value = false;
  }
}
  • Step 6: Add watchers for server pagination and active warehouse switching

Below existing watchers, add:

watch([warehouseLedgerPage, warehouseLedgerPageSize], () => {
  if (warehouseLedgerDrawerOpen.value) {
    loadWarehouseLedger();
  }
});

watch(activeInventoryTab, () => {
  if (warehouseLedgerDrawerOpen.value) {
    warehouseLedgerPage.value = 1;
    Object.assign(warehouseLedgerFilters, buildEmptyWarehouseLedgerFilters());
    loadWarehouseLedger();
  }
});
  • Step 7: Include drawer in body scroll lock

In the existing useBodyScrollLock computed list, add:

      warehouseLedgerDrawerOpen.value ||
  • Step 8: Add minimal styles

In the style section of InventoryLedgerView.vue, add these classes near other inventory styles:

.inventory-io-bar {
  position: relative;
}

.inventory-ledger-button {
  position: absolute;
  right: 18px;
  top: 50%;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 42px;
  padding: 0 18px;
  border: 1px solid rgba(47, 118, 255, 0.36);
  border-radius: 14px;
  color: #2563eb;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(236, 244, 255, 0.92));
  box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12);
  cursor: pointer;
}

.inventory-ledger-button:hover {
  transform: translateY(-52%);
  border-color: rgba(37, 99, 235, 0.65);
  box-shadow: 0 14px 30px rgba(37, 99, 235, 0.2);
}

.inventory-ledger-icon {
  width: 18px;
  height: 18px;
}

.inventory-ledger-icon svg {
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.warehouse-ledger-summary {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 12px;
}

.warehouse-ledger-summary-card {
  padding: 14px;
  border: 1px solid rgba(148, 163, 184, 0.22);
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.78);
  box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
}

.warehouse-ledger-summary-card span {
  display: block;
  color: #64748b;
  font-size: 12px;
}

.warehouse-ledger-summary-card strong {
  display: block;
  margin-top: 6px;
  color: #0f172a;
  font-size: 18px;
}

.warehouse-ledger-filters {
  display: grid;
  grid-template-columns: minmax(260px, 1.6fr) repeat(4, minmax(130px, 1fr)) auto auto;
  gap: 12px;
  align-items: end;
}
  • Step 9: Run frontend static test

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-transaction-ledger.mjs

Expected: PASS.

  • Step 10: Run existing frontend static tests

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-return-warehouse.mjs
node scripts/test-inventory-ledger-options.mjs
node scripts/test-inventory-ledger-raw-return.mjs

Expected: all PASS.

  • Step 11: Build frontend

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
npm run build

Expected: build succeeds. Existing Vite chunk-size warning is acceptable.


Task 5: Full Verification And Browser Smoke Test

Files:

  • No new source files expected.

  • Step 1: Run all targeted backend checks

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/backend
.venv/bin/python -m pytest tests/test_warehouse_transaction_ledger.py tests/test_return_warehouse_flow.py tests/test_opening_inventory_import.py tests/test_scrap_warehouse_flow.py::ScrapWarehouseFlowTest::test_scrap_stocktake_keeps_product_scrap_weight_only_after_confirm -q
.venv/bin/python -m py_compile app/api/routes/inventory.py app/services/operations.py app/schemas/operations.py tests/test_warehouse_transaction_ledger.py

Expected: pytest reports all tests passed and py_compile exits 0.

  • Step 2: Run all targeted frontend checks

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP/frontend
node scripts/test-inventory-ledger-transaction-ledger.mjs
node scripts/test-inventory-ledger-return-warehouse.mjs
node scripts/test-inventory-ledger-options.mjs
node scripts/test-inventory-ledger-raw-return.mjs
npm run build

Expected: all node scripts pass and Vite build succeeds.

  • Step 3: Browser smoke test

If the local frontend is already running on port 5173, open:

http://localhost:5173/inventory-ledger

Manual expected results after login:

  • 百华仓库 page renders.

  • Select 原材料库, click 流水, drawer title is 原材料库 · 出入库流水.

  • Switch 成品库 while the drawer is open, drawer reloads and title becomes 成品库 · 出入库流水.

  • Search box can filter by a known material or batch keyword.

  • Pagination bar is visible and uses the global style.

  • Click a material in the main table; existing 批次台账 / 库存流水 dual drawer still opens.

  • Step 4: Record non-git workspace status

Run:

cd /Users/souplearn/Gitlab/py/ForgeFlow-ERP
git rev-parse --show-toplevel

Expected in the current workspace: fatal: not a git repository.... If this remains true, do not attempt commits. If a future execution happens inside a git repository, commit each completed task with a focused message such as:

git add backend/tests/test_warehouse_transaction_ledger.py backend/app/schemas/operations.py backend/app/services/operations.py backend/app/api/routes/inventory.py
git commit -m "feat: add warehouse transaction ledger api"

git add frontend/src/views/InventoryLedgerView.vue frontend/scripts/test-inventory-ledger-transaction-ledger.mjs
git commit -m "feat: add warehouse transaction ledger drawer"

Self-Review Checklist

  • Spec coverage: The plan covers the UI entry, drawer, six warehouse support, unified wh_inventory_txn source, filters, pagination, summary, and existing material-level drawer preservation.
  • Placeholder scan: No TBD, TODO, or unspecified implementation steps remain.
  • Type consistency: Backend schemas use InventoryTxnLedgerRowRead, InventoryTxnLedgerSummaryRead, and InventoryTxnLedgerResponse; frontend state uses warehouseLedger... consistently.
  • Scope check: This is a single cohesive feature. Optional export and charts remain explicitly out of scope.