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