ForgeFlow-ERP/backend/tests/test_warehouse_transaction_ledger.py
2026-06-12 16:00:56 +08:00

312 lines
9.7 KiB
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()
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")
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)
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)