from __future__ import annotations import unittest from datetime import date, datetime from decimal import Decimal from fastapi import HTTPException from sqlalchemy import BigInteger, create_engine, select 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.miniapp # noqa: E402,F401 import app.models.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 import app.models.planning # noqa: E402,F401 import app.models.sales # noqa: E402,F401 from app.api.routes.purchase import create_purchase_order, create_purchase_receipt, lock_purchase_order # noqa: E402 from app.models.base import Base # noqa: E402 from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse # noqa: E402 from app.models.operations import ( # noqa: E402 InventoryTxn, ProcessRoute, PurchaseOrderItem, PurchaseReceiptItem, StockLot, Supplier, WorkOrder, WorkOrderMaterial, WorkOrderMaterialIssue, WorkOrderOperation, ) from app.models.org import Department, Employee, User # noqa: E402 from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseReceiptCreate, PurchaseReceiptItemCreate # noqa: E402 from app.services.auth import AuthContext # noqa: E402 from app.services.operations import issue_item_from_lots, sync_work_order_reference_qty # noqa: E402 class RawMaterialWeightOnlyFlowTest(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() self._seed_master_data() def tearDown(self) -> None: self.db.close() def _seed_master_data(self) -> None: self.department = Department(id=1, dept_code="D-WH", dept_name="仓库部", dept_type="WAREHOUSE", status="ACTIVE") self.employee = Employee(id=1, employee_code="EMP-WH-001", employee_name="仓管员", dept_id=1, status="ACTIVE") self.user = User( id=1, username="tester", password_hash="x", employee_id=1, dept_id=1, nickname="测试员", is_super_admin=1, status="ACTIVE", ) self.material_item = Item( id=1, item_code="RM-WEIGHT-001", item_name="重量口径原材料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1.000000"), status="ACTIVE", ) self.material = Material( item_id=1, default_supplier_id=1, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal("0"), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal("0"), lead_time_days=0, ) self.supplier = Supplier( id=1, supplier_code="SUP-WEIGHT-001", supplier_name="重量供应商", short_name="重量供应商", lead_time_days=0, default_tax_rate=Decimal("0"), status="ACTIVE", ) self.warehouse = Warehouse(id=1, warehouse_code="WH-RM", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE") self.db.add_all([ self.department, self.employee, self.user, self.material_item, self.material, self.supplier, self.warehouse, ]) self.db.commit() def _auth_context(self) -> AuthContext: return AuthContext( user=self.user, employee=self.employee, department=self.department, role_codes=["ADMIN"], role_names=["管理员"], permission_codes=[], ) def _create_locked_raw_purchase_order(self, *, order_weight_kg: Decimal = Decimal("200"), unit_price: Decimal = Decimal("3.5")): order = create_purchase_order( PurchaseOrderCreate( supplier_id=self.supplier.id, expected_date=date.today(), items=[ PurchaseOrderItemCreate( material_item_id=self.material_item.id, order_weight_kg=order_weight_kg, unit_price=unit_price, ) ], ), db=self.db, ) po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) lock_purchase_order(order.purchase_order_id, db=self.db) return order, po_item def _raw_receipt_payload( self, order, po_item, *, received_weight_kg: Decimal = Decimal("100"), unit_cost: Decimal = Decimal("3.5"), waybill_no: str | None = "YD-CG-001", order_photo_url: str | None = None, freight_amount: Decimal | None = Decimal("12.5"), ) -> PurchaseReceiptCreate: return PurchaseReceiptCreate( purchase_order_id=order.purchase_order_id, warehouse_id=self.warehouse.id, receiver_employee_id=self.employee.id, waybill_no=waybill_no, order_photo_url=order_photo_url, freight_amount=freight_amount, items=[ PurchaseReceiptItemCreate( purchase_order_item_id=po_item.id, material_item_id=self.material_item.id, received_weight_kg=received_weight_kg, unit_cost=unit_cost, ) ], ) def test_raw_purchase_receipt_requires_logistics_info(self) -> None: order, po_item = self._create_locked_raw_purchase_order() with self.assertRaises(HTTPException) as exc: create_purchase_receipt( self._raw_receipt_payload(order, po_item, waybill_no="", order_photo_url="", freight_amount=None), context=self._auth_context(), db=self.db, ) self.assertEqual(exc.exception.status_code, 400) self.assertIn("运单号或上传辅助照片", exc.exception.detail) self.assertIn("运费", exc.exception.detail) def test_raw_purchase_receipt_allows_photo_instead_of_waybill_and_records_logistics(self) -> None: order, po_item = self._create_locked_raw_purchase_order() create_purchase_receipt( self._raw_receipt_payload( order, po_item, waybill_no="", order_photo_url="/inventory/logistics-photos/purchase-in.jpg", freight_amount=Decimal("18.5"), ), context=self._auth_context(), db=self.db, ) lot = self.db.scalar(select(StockLot)) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PURCHASE_IN")) self.assertIsNone(lot.logistics_waybill_no) self.assertEqual(lot.logistics_photo_url, "/inventory/logistics-photos/purchase-in.jpg") self.assertEqual(float(lot.logistics_freight_amount), 18.5) self.assertIsNone(txn.logistics_waybill_no) self.assertEqual(txn.logistics_photo_url, "/inventory/logistics-photos/purchase-in.jpg") self.assertEqual(float(txn.logistics_freight_amount), 18.5) def test_purchase_receipt_writes_raw_material_weight_only(self) -> None: order = create_purchase_order( PurchaseOrderCreate( supplier_id=self.supplier.id, expected_date=date.today(), items=[ PurchaseOrderItemCreate( material_item_id=self.material_item.id, order_weight_kg=Decimal("200"), unit_price=Decimal("3.5"), ) ], ), db=self.db, ) po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) self.assertEqual(float(po_item.order_qty), 0) self.assertEqual(float(po_item.order_weight_kg), 200) lock_purchase_order(order.purchase_order_id, db=self.db) create_purchase_receipt( PurchaseReceiptCreate( purchase_order_id=order.purchase_order_id, warehouse_id=self.warehouse.id, receiver_employee_id=self.employee.id, waybill_no="YD-CG-RAW-001", freight_amount=Decimal("10"), items=[ PurchaseReceiptItemCreate( purchase_order_item_id=po_item.id, material_item_id=self.material_item.id, received_weight_kg=Decimal("250"), unit_cost=Decimal("3.5"), ) ], ), context=self._auth_context(), db=self.db, ) self.db.refresh(po_item) receipt_item = self.db.scalar(select(PurchaseReceiptItem)) lot = self.db.scalar(select(StockLot)) balance = self.db.scalar(select(StockBalance)) txn = self.db.scalar(select(InventoryTxn)) self.assertEqual(float(po_item.received_qty), 0) self.assertEqual(float(po_item.received_weight_kg), 250) self.assertEqual(float(receipt_item.received_qty), 0) self.assertEqual(float(receipt_item.received_weight_kg), 250) self.assertEqual(float(lot.inbound_qty), 0) self.assertEqual(float(lot.remaining_qty), 0) self.assertEqual(float(lot.locked_qty), 0) self.assertEqual(float(lot.inbound_weight_kg), 250) self.assertEqual(float(balance.qty_on_hand), 0) self.assertEqual(float(balance.qty_available), 0) self.assertEqual(float(balance.weight_on_hand_kg), 250) self.assertEqual(float(txn.qty_change), 0) self.assertEqual(float(txn.weight_change_kg), 250) def test_purchase_receipt_ignores_legacy_merge_payload_and_creates_new_inventory_lot(self) -> None: order = create_purchase_order( PurchaseOrderCreate( supplier_id=self.supplier.id, expected_date=date.today(), items=[ PurchaseOrderItemCreate( material_item_id=self.material_item.id, order_weight_kg=Decimal("500"), unit_price=Decimal("4"), ) ], ), db=self.db, ) po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) lock_purchase_order(order.purchase_order_id, db=self.db) create_purchase_receipt( PurchaseReceiptCreate( purchase_order_id=order.purchase_order_id, warehouse_id=self.warehouse.id, receiver_employee_id=self.employee.id, waybill_no="YD-CG-RAW-002", freight_amount=Decimal("10"), items=[ PurchaseReceiptItemCreate( purchase_order_item_id=po_item.id, material_item_id=self.material_item.id, received_weight_kg=Decimal("300"), unit_cost=Decimal("4"), ) ], ), context=self._auth_context(), db=self.db, ) first_lot = self.db.scalar(select(StockLot)) self.assertEqual(float(first_lot.unit_cost), 4) create_purchase_receipt( PurchaseReceiptCreate( purchase_order_id=order.purchase_order_id, warehouse_id=self.warehouse.id, receiver_employee_id=self.employee.id, waybill_no="YD-CG-RAW-003", freight_amount=Decimal("10"), items=[ PurchaseReceiptItemCreate( purchase_order_item_id=po_item.id, material_item_id=self.material_item.id, merge_to_lot_id=first_lot.id, received_weight_kg=Decimal("200"), unit_cost=Decimal("99"), ) ], ), context=self._auth_context(), db=self.db, ) self.db.refresh(first_lot) second_lot = self.db.scalars(select(StockLot).order_by(StockLot.id)).all()[1] receipt_items = self.db.scalars(select(PurchaseReceiptItem).order_by(PurchaseReceiptItem.id)).all() txns = self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all() self.assertEqual(float(first_lot.inbound_weight_kg), 300) self.assertEqual(float(first_lot.unit_cost), 4) self.assertEqual(second_lot.lot_no, "YL0002") self.assertEqual(float(second_lot.inbound_weight_kg), 200) self.assertEqual(float(second_lot.unit_cost), 99) self.assertIsNone(receipt_items[1].merge_to_lot_id) self.assertEqual(receipt_items[1].lot_no, "YL0002") self.assertEqual(float(receipt_items[1].unit_cost), 99) self.assertEqual(float(txns[1].unit_cost), 99) self.assertEqual(float(txns[1].amount), 19800) def test_purchase_receipt_ignores_manual_legacy_lot_no(self) -> None: order = create_purchase_order( PurchaseOrderCreate( supplier_id=self.supplier.id, expected_date=date.today(), items=[ PurchaseOrderItemCreate( material_item_id=self.material_item.id, order_weight_kg=Decimal("500"), unit_price=Decimal("4"), ) ], ), db=self.db, ) po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) lock_purchase_order(order.purchase_order_id, db=self.db) create_purchase_receipt( PurchaseReceiptCreate( purchase_order_id=order.purchase_order_id, warehouse_id=self.warehouse.id, receiver_employee_id=self.employee.id, waybill_no="YD-CG-RAW-004", freight_amount=Decimal("10"), items=[ PurchaseReceiptItemCreate( purchase_order_item_id=po_item.id, material_item_id=self.material_item.id, lot_no="YL-NB-20260530-RMWEIGHT-OLD-L01", received_weight_kg=Decimal("300"), unit_cost=Decimal("4"), ) ], ), context=self._auth_context(), db=self.db, ) lot = self.db.scalar(select(StockLot)) receipt_item = self.db.scalar(select(PurchaseReceiptItem)) self.assertEqual(lot.lot_no, "YL0001") self.assertEqual(receipt_item.lot_no, "YL0001") def test_split_arrivals_create_sequential_inventory_lots_for_same_purchase_line(self) -> None: order = create_purchase_order( PurchaseOrderCreate( supplier_id=self.supplier.id, expected_date=date.today(), items=[ PurchaseOrderItemCreate( material_item_id=self.material_item.id, order_weight_kg=Decimal("500"), unit_price=Decimal("4"), ) ], ), db=self.db, ) po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id)) lock_purchase_order(order.purchase_order_id, db=self.db) create_purchase_receipt( PurchaseReceiptCreate( purchase_order_id=order.purchase_order_id, warehouse_id=self.warehouse.id, receiver_employee_id=self.employee.id, waybill_no="YD-CG-RAW-005", freight_amount=Decimal("10"), items=[ PurchaseReceiptItemCreate( purchase_order_item_id=po_item.id, material_item_id=self.material_item.id, received_weight_kg=Decimal("300"), unit_cost=Decimal("4"), ) ], ), context=self._auth_context(), db=self.db, ) create_purchase_receipt( PurchaseReceiptCreate( purchase_order_id=order.purchase_order_id, warehouse_id=self.warehouse.id, receiver_employee_id=self.employee.id, waybill_no="YD-CG-RAW-006", freight_amount=Decimal("10"), items=[ PurchaseReceiptItemCreate( purchase_order_item_id=po_item.id, material_item_id=self.material_item.id, received_weight_kg=Decimal("250"), unit_cost=Decimal("4.5"), ) ], ), context=self._auth_context(), db=self.db, ) lots = self.db.scalars(select(StockLot).order_by(StockLot.id)).all() receipt_items = self.db.scalars(select(PurchaseReceiptItem).order_by(PurchaseReceiptItem.id)).all() self.assertEqual([lot.lot_no for lot in lots], ["YL0001", "YL0002"]) self.assertEqual(float(receipt_items[0].unit_cost), 4) self.assertEqual(float(receipt_items[1].unit_cost), 4.5) self.assertEqual(float(lots[0].unit_cost), 4) self.assertEqual(float(lots[1].unit_cost), 4.5) def test_material_issue_ignores_historical_raw_quantity_pollution(self) -> None: lot = StockLot( lot_no="RML-POLLUTED-001", lot_role="INBOUND_RAW", item_id=self.material_item.id, warehouse_id=self.warehouse.id, source_doc_type="TEST", source_doc_id=1, inbound_qty=Decimal("4000"), inbound_weight_kg=Decimal("3000"), remaining_qty=Decimal("4000"), remaining_weight_kg=Decimal("3000"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("2"), quality_status="PASS", status="AVAILABLE", ) balance = StockBalance( item_id=self.material_item.id, warehouse_id=self.warehouse.id, qty_on_hand=Decimal("4000"), weight_on_hand_kg=Decimal("3000"), qty_available=Decimal("4000"), weight_available_kg=Decimal("3000"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("2"), ) self.db.add_all([lot, balance]) self.db.commit() issued_qty, issued_weight, amount = issue_item_from_lots( self.db, item_id=self.material_item.id, required_qty=Decimal("999"), required_weight=Decimal("200"), enforce_qty=True, enforce_weight=True, source_doc_id=1, source_line_id=None, biz_time=datetime.now(), operator_user_id=self.user.id, remark="测试按重量领料", ) self.db.commit() self.db.refresh(lot) self.db.refresh(balance) txn = self.db.scalar(select(InventoryTxn)) self.assertEqual(float(issued_qty), 0) self.assertEqual(float(issued_weight), 200) self.assertEqual(float(amount), 400) self.assertEqual(float(lot.remaining_qty), 0) self.assertEqual(float(lot.remaining_weight_kg), 2800) self.assertEqual(float(balance.qty_on_hand), 0) self.assertEqual(float(balance.qty_available), 0) self.assertEqual(float(balance.weight_on_hand_kg), 2800) self.assertEqual(float(txn.qty_change), 0) self.assertEqual(float(txn.weight_change_kg), -200) def test_work_order_reference_qty_resyncs_from_issued_weight_and_bom_gross_weight(self) -> None: product_item = Item( id=2, item_code="FG-WEIGHT-001", item_name="重量口径成品", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("1.000000"), status="ACTIVE", ) product = Product( item_id=2, version_no="V1.0", standard_output_qty=Decimal("0"), allowed_scrap_rate=Decimal("0"), ) bom = Bom( id=1, bom_code="BOM-WEIGHT-001", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE", ) bom_item = BomItem( id=1, bom_id=1, seq_no=1, material_item_id=self.material_item.id, usage_type="WEIGHT", qty_per_piece=Decimal("1"), net_weight_per_piece_kg=Decimal("1.000000"), gross_weight_per_piece_kg=Decimal("1.500000"), loss_rate=Decimal("0.3333"), ) route = ProcessRoute( id=1, route_code="ROUTE-WEIGHT-001", route_name="重量测试路线", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE", ) work_order = WorkOrder( id=1, work_order_no="WO-STale-001", work_order_type="NORMAL", product_item_id=2, route_id=1, bom_id=1, planned_qty=Decimal("52"), released_qty=Decimal("52"), finished_qty=Decimal("0"), scrap_qty=Decimal("0"), status="RELEASED", ) material_row = WorkOrderMaterial( id=1, work_order_id=1, bom_item_id=1, material_item_id=self.material_item.id, required_qty=Decimal("0"), required_weight_kg=Decimal("200"), issued_qty=Decimal("0"), issued_weight_kg=Decimal("200"), returned_qty=Decimal("0"), returned_weight_kg=Decimal("0"), status="ISSUED", ) issue_row = WorkOrderMaterialIssue( id=1, work_order_material_id=1, work_order_id=1, material_item_id=self.material_item.id, source_lot_id=1, material_sub_batch_no="JH_RM-WEIGHT-001_0001", issued_qty=Decimal("0"), issued_weight_kg=Decimal("200"), unit_cost=Decimal("2"), amount=Decimal("400"), issue_time=datetime.now(), ) operation = WorkOrderOperation( id=1, work_order_id=1, route_operation_id=1, seq_no=1, operation_name="1序", work_center_id=1, planned_qty=Decimal("52"), report_qty=Decimal("0"), good_qty=Decimal("0"), scrap_qty=Decimal("0"), rework_qty=Decimal("0"), status="PENDING", ) source_lot = StockLot( id=1, lot_no="RML-ISSUE-001", lot_role="INBOUND_RAW", item_id=self.material_item.id, warehouse_id=self.warehouse.id, source_doc_type="TEST", source_doc_id=1, inbound_weight_kg=Decimal("200"), remaining_weight_kg=Decimal("0"), unit_cost=Decimal("2"), quality_status="PASS", status="DEPLETED", ) self.db.add_all([product_item, product, bom, bom_item, route, work_order, material_row, source_lot, issue_row, operation]) self.db.commit() changed = sync_work_order_reference_qty(self.db, work_order.id) self.db.commit() self.db.refresh(work_order) self.db.refresh(operation) self.assertTrue(changed) self.assertEqual(float(work_order.planned_qty), 133) self.assertEqual(float(work_order.released_qty), 133) self.assertEqual(float(operation.planned_qty), 133) if __name__ == "__main__": unittest.main()