1249 lines
43 KiB
Markdown
1249 lines
43 KiB
Markdown
# 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:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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`:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
@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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```vue
|
|
<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:
|
|
|
|
```vue
|
|
<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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
warehouseLedgerDrawerOpen.value ||
|
|
```
|
|
|
|
- [ ] **Step 8: Add minimal styles**
|
|
|
|
In the style section of `InventoryLedgerView.vue`, add these classes near other inventory styles:
|
|
|
|
```css
|
|
.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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```text
|
|
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:
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
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.
|