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.
- Add
- 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_txnsource, 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, andInventoryTxnLedgerResponse; frontend state useswarehouseLedger...consistently. - Scope check: This is a single cohesive feature. Optional export and charts remain explicitly out of scope.