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.document_archive # noqa: E402,F401 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.sales import create_delivery, create_sales_order # noqa: E402 from app.models.base import Base # noqa: E402 from app.models.document_archive import DocumentArchive # noqa: E402 from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 from app.models.operations import Delivery, DeliveryItem, InventoryTxn, StockLot # noqa: E402 from app.models.org import Department, Employee, User # noqa: E402 from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402 from app.schemas.database import SalesOrderCreate, SalesOrderItemCreate # noqa: E402 from app.schemas.operations import DeliveryCreate, DeliveryItemCreate # noqa: E402 from app.services.auth import AuthContext # noqa: E402 from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402 from app.services.operations import get_delivery_items_query # noqa: E402 from app.services.sales_planning import get_sales_order_query # noqa: E402 class SalesOrderDeliveryTraceTest(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-SALES", dept_name="销售部", dept_type="SALES", status="ACTIVE") self.employee = Employee(id=1, employee_code="EMP-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.customer = Customer( id=1, customer_code="CUS-001", customer_name="五金客户A", short_name="客户A", contact_name="李工", contact_phone="13800000000", address="宁波测试路1号", credit_days=30, status="ACTIVE", ) self.product = Item( id=1, item_code="FG-001", item_name="测试支架", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("2.000000"), safety_stock_weight_kg=Decimal("0"), status="ACTIVE", ) self.raw_material = Item( id=2, item_code="MAT-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", specification="1.0*1000", material_grade="SPCC", unit_weight_kg=Decimal("1.000000"), safety_stock_weight_kg=Decimal("0"), status="ACTIVE", ) self.warehouse = Warehouse(id=1, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE") self.db.add_all([self.department, self.employee, self.user, self.customer, self.product, self.raw_material, self.warehouse]) self.db.flush() self.raw_lot = StockLot( id=1, lot_no="RML-001", lot_role="INBOUND_RAW", item_id=self.raw_material.id, warehouse_id=self.warehouse.id, source_doc_type="TEST", source_doc_id=1, inbound_qty=Decimal("100"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("60"), remaining_weight_kg=Decimal("60"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("5"), quality_status="PASS", status="AVAILABLE", ) self.finished_lot = StockLot( id=2, lot_no="FGL-001", parent_lot_id=self.raw_lot.id, lot_role="FINISHED_FROM_RAW_BATCH", item_id=self.product.id, warehouse_id=self.warehouse.id, source_doc_type="COMPLETION", source_doc_id=1, source_line_id=1, source_material_lot_id=self.raw_lot.id, source_material_sub_batch_no="RML-001", source_material_summary="RML-001 / 冷轧钢板 / 40kg / ¥200", inbound_qty=Decimal("20"), inbound_weight_kg=Decimal("40"), remaining_qty=Decimal("20"), remaining_weight_kg=Decimal("40"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("10"), production_date=date.today(), quality_status="PASS", status="AVAILABLE", ) self.finished_balance = StockBalance( id=1, item_id=self.product.id, warehouse_id=self.warehouse.id, location_id=None, qty_on_hand=Decimal("20"), weight_on_hand_kg=Decimal("40"), qty_available=Decimal("20"), weight_available_kg=Decimal("40"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("10"), ) self.db.add_all([self.raw_lot, self.finished_lot, self.finished_balance]) self.db.commit() def _seed_second_finished_lot(self) -> StockLot: second_lot = StockLot( id=3, lot_no="FGL-002", parent_lot_id=self.raw_lot.id, lot_role="FINISHED_FROM_RAW_BATCH", item_id=self.product.id, warehouse_id=self.warehouse.id, source_doc_type="COMPLETION", source_doc_id=2, source_line_id=2, source_material_lot_id=self.raw_lot.id, source_material_sub_batch_no="RML-001", source_material_summary="RML-001 / 冷轧钢板 / 10kg / ¥50", inbound_qty=Decimal("5"), inbound_weight_kg=Decimal("10"), remaining_qty=Decimal("5"), remaining_weight_kg=Decimal("10"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("10"), production_date=date.today(), quality_status="PASS", status="AVAILABLE", ) self.finished_balance.qty_on_hand += Decimal("5") self.finished_balance.weight_on_hand_kg += Decimal("10") self.finished_balance.qty_available += Decimal("5") self.finished_balance.weight_available_kg += Decimal("10") self.db.add_all([second_lot, self.finished_balance]) self.db.commit() return second_lot def _auth_context(self) -> AuthContext: return AuthContext( user=self.user, employee=self.employee, department=self.department, role_codes=["ADMIN"], role_names=["管理员"], permission_codes=[], ) def _seed_employee_permission( self, permission_code: str, permission_name: str, module_code: str, role_code: str, role_name: str, ) -> Employee: from app.models.org import Permission, Role, RolePermission, UserRole now = datetime(2026, 6, 8, 9, 0, 0) permission = self.db.scalar(select(Permission).where(Permission.permission_code == permission_code)) if not permission: permission = Permission( permission_code=permission_code, permission_name=permission_name, module_code=module_code, action_code="VIEW", status="ACTIVE", created_at=now, updated_at=now, ) self.db.add(permission) self.db.flush() role = self.db.scalar(select(Role).where(Role.role_code == role_code)) if not role: role = Role( role_code=role_code, role_name=role_name, role_scope="SYSTEM", status="ACTIVE", remark=None, created_at=now, updated_at=now, ) self.db.add(role) self.db.flush() role_permission = self.db.scalar( select(RolePermission).where( RolePermission.role_id == role.id, RolePermission.permission_id == permission.id, ) ) if not role_permission: self.db.add(RolePermission(role_id=role.id, permission_id=permission.id, created_at=now)) user_role = self.db.scalar(select(UserRole).where(UserRole.user_id == self.user.id, UserRole.role_id == role.id)) if not user_role: self.db.add(UserRole(user_id=self.user.id, role_id=role.id, created_at=now)) self.db.commit() return self.employee def _seed_sales_employee_permission(self) -> Employee: return self._seed_employee_permission( "MENU_SALES_PLANNING", "客户名录/销售订单菜单", "SALES_PLANNING", "SALES", "销售人员", ) def _seed_inventory_ledger_employee_permission(self) -> Employee: return self._seed_employee_permission( "MENU_INVENTORY_LEDGER", "嘉恒仓库", "INVENTORY_LEDGER", "WAREHOUSE", "仓库人员", ) def test_create_sales_order_persists_sales_employee(self) -> None: sales_employee = self._seed_sales_employee_permission() order_result = create_sales_order( SalesOrderCreate( customer_id=self.customer.id, sales_employee_id=sales_employee.id, promised_date=date.today(), delivery_address=self.customer.address, tax_rate=0, items=[ SalesOrderItemCreate( product_item_id=self.product.id, order_qty=3, unit_price=12.5, customer_part_no="C-PART-001", ) ], ), db=self.db, ) order = self.db.get(SalesOrder, order_result.order_id) order_row = self.db.execute(get_sales_order_query(limit=10)).mappings().first() self.assertEqual(order.sales_employee_id, sales_employee.id) self.assertEqual(order_row["sales_employee_id"], sales_employee.id) self.assertEqual(order_row["sales_employee_name"], sales_employee.employee_name) def test_sales_order_delivery_updates_fulfillment_and_trace_fields(self) -> None: sales_employee = self._seed_sales_employee_permission() order_result = create_sales_order( SalesOrderCreate( customer_id=self.customer.id, sales_employee_id=sales_employee.id, promised_date=date.today(), delivery_address=self.customer.address, tax_rate=0, items=[ SalesOrderItemCreate( product_item_id=self.product.id, order_qty=10, unit_price=12.5, customer_part_no="C-PART-001", ) ], ), db=self.db, ) order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) create_delivery( DeliveryCreate( sales_order_id=order_result.order_id, customer_id=self.customer.id, warehouse_id=self.warehouse.id, consignee_name=self.customer.contact_name, consignee_phone=self.customer.contact_phone, delivery_address=self.customer.address, waybill_no="YD-XS-001", freight_amount=36.8, items=[ DeliveryItemCreate( sales_order_item_id=order_item.id, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=4, delivery_weight_kg=8, unit_price=12.5, ) ], ), context=self._auth_context(), db=self.db, ) self.db.refresh(order_item) sales_order = self.db.get(SalesOrder, order_result.order_id) delivery_item = self.db.scalar(select(DeliveryItem)) delivery = self.db.scalar(select(Delivery)) trace_row = self.db.execute(get_delivery_items_query(limit=10)).mappings().first() stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id)) self.assertEqual(delivery.logistics_waybill_no, "YD-XS-001") self.assertEqual(float(delivery.logistics_freight_amount), 36.8) self.assertEqual(float(order_item.delivered_qty), 4) self.assertEqual(order_item.status, "PARTIAL") self.assertEqual(sales_order.status, "PARTIAL") self.assertEqual(delivery_item.sales_order_item_id, order_item.id) self.assertEqual(trace_row["source_material_lot_no"], "RML-001") self.assertEqual(trace_row["source_material_code"], "MAT-001") self.assertEqual(trace_row["source_material_name"], "冷轧钢板") self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001") self.assertEqual(trace_row["lot_no"], "FGL-001") self.assertEqual(float(stock_balance.qty_on_hand), 16) self.assertEqual(float(stock_balance.qty_available), 16) def test_delivery_trace_aliases_legacy_sub_batch_to_source_lot_no(self) -> None: self.finished_lot.source_material_lot_id = None self.finished_lot.source_material_sub_batch_no = "JH_MAT001_0001" self.db.commit() sales_employee = self._seed_sales_employee_permission() order_result = create_sales_order( SalesOrderCreate( customer_id=self.customer.id, sales_employee_id=sales_employee.id, promised_date=date.today(), delivery_address=self.customer.address, tax_rate=0, items=[ SalesOrderItemCreate( product_item_id=self.product.id, order_qty=1, unit_price=12.5, ) ], ), db=self.db, ) order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) create_delivery( DeliveryCreate( sales_order_id=order_result.order_id, customer_id=self.customer.id, warehouse_id=self.warehouse.id, consignee_name=self.customer.contact_name, consignee_phone=self.customer.contact_phone, delivery_address=self.customer.address, waybill_no="YD-XS-LEGACY", freight_amount=10, items=[ DeliveryItemCreate( sales_order_item_id=order_item.id, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=1, delivery_weight_kg=2, unit_price=12.5, ) ], ), context=self._auth_context(), db=self.db, ) trace_row = self.db.execute(get_delivery_items_query(limit=10)).mappings().first() self.assertEqual(trace_row["source_material_lot_no"], "JH_MAT001_0001") self.assertEqual(trace_row["source_material_sub_batch_no"], "JH_MAT001_0001") def test_delivery_line_link_infers_sales_order_header(self) -> None: sales_employee = self._seed_sales_employee_permission() order_result = create_sales_order( SalesOrderCreate( customer_id=self.customer.id, sales_employee_id=sales_employee.id, promised_date=date.today(), delivery_address=self.customer.address, tax_rate=0, items=[ SalesOrderItemCreate( product_item_id=self.product.id, order_qty=6, unit_price=10, ) ], ), db=self.db, ) order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) delivery_result = create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=self.warehouse.id, waybill_no="YD-XS-002", freight_amount=20, items=[ DeliveryItemCreate( sales_order_item_id=order_item.id, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=2, delivery_weight_kg=4, unit_price=10, ) ], ), context=self._auth_context(), db=self.db, ) delivery = self.db.get(Delivery, delivery_result.delivery_id) sales_order = self.db.get(SalesOrder, order_result.order_id) sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) self.assertEqual(delivery_result.sales_order_id, order_result.order_id) self.assertIsNotNone(sales_out_txn) self.assertEqual(delivery_result.archive_status, ARCHIVE_STATUS_READY) self.assertEqual(delivery_result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) self.assertEqual(delivery_result.archive_business_id, sales_out_txn.id) archive = self.db.scalar( select(DocumentArchive).where( DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, DocumentArchive.business_id == sales_out_txn.id, ) ) self.assertIsNotNone(archive) self.assertEqual(archive.status, ARCHIVE_STATUS_READY) self.assertEqual(delivery.sales_order_id, order_result.order_id) self.assertEqual(sales_order.status, "PARTIAL") def test_delivery_generates_archive_for_each_sales_out_transaction(self) -> None: second_lot = self._seed_second_finished_lot() sales_employee = self._seed_sales_employee_permission() order_result = create_sales_order( SalesOrderCreate( customer_id=self.customer.id, sales_employee_id=sales_employee.id, promised_date=date.today(), delivery_address=self.customer.address, tax_rate=0, items=[ SalesOrderItemCreate( product_item_id=self.product.id, order_qty=6, unit_price=10, ) ], ), db=self.db, ) order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) delivery_result = create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=self.warehouse.id, waybill_no="YD-MULTI-LOT", freight_amount=20, items=[ DeliveryItemCreate( sales_order_item_id=order_item.id, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=2, delivery_weight_kg=4, unit_price=10, ), DeliveryItemCreate( sales_order_item_id=order_item.id, product_item_id=self.product.id, lot_id=second_lot.id, delivery_qty=1, delivery_weight_kg=2, unit_price=10, ), ], ), context=self._auth_context(), db=self.db, ) sales_out_txns = self.db.scalars( select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT").order_by(InventoryTxn.id) ).all() self.assertEqual(len(sales_out_txns), 2) self.assertEqual(delivery_result.archive_business_id, sales_out_txns[0].id) archive_business_ids = set( self.db.scalars( select(DocumentArchive.business_id).where( DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, DocumentArchive.business_id.in_([txn.id for txn in sales_out_txns]), ) ).all() ) self.assertEqual(archive_business_ids, {txn.id for txn in sales_out_txns}) def test_create_delivery_accepts_inventory_ledger_shipper(self) -> None: shipper = self._seed_inventory_ledger_employee_permission() delivery_result = create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=self.warehouse.id, shipper_employee_id=shipper.id, waybill_no="YD-INVENTORY-SHIPPER", freight_amount=12, items=[ DeliveryItemCreate( product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=1, delivery_weight_kg=2, unit_price=0, ) ], ), context=self._auth_context(), db=self.db, ) delivery = self.db.get(Delivery, delivery_result.delivery_id) self.assertEqual(delivery.shipper_employee_id, shipper.id) def test_sales_delivery_requires_logistics_info(self) -> None: sales_employee = self._seed_sales_employee_permission() order_result = create_sales_order( SalesOrderCreate( customer_id=self.customer.id, sales_employee_id=sales_employee.id, promised_date=date.today(), delivery_address=self.customer.address, tax_rate=0, items=[ SalesOrderItemCreate( product_item_id=self.product.id, order_qty=6, unit_price=10, ) ], ), db=self.db, ) order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) with self.assertRaises(HTTPException) as exc: create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=self.warehouse.id, items=[ DeliveryItemCreate( sales_order_item_id=order_item.id, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=2, delivery_weight_kg=4, unit_price=10, ) ], ), 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_sales_delivery_allows_order_photo_instead_of_waybill(self) -> None: sales_employee = self._seed_sales_employee_permission() order_result = create_sales_order( SalesOrderCreate( customer_id=self.customer.id, sales_employee_id=sales_employee.id, promised_date=date.today(), delivery_address=self.customer.address, tax_rate=0, items=[ SalesOrderItemCreate( product_item_id=self.product.id, order_qty=6, unit_price=10, ) ], ), db=self.db, ) order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id)) delivery_result = create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=self.warehouse.id, order_photo_url="/inventory/logistics-photos/sales-photo.jpg", freight_amount=20, items=[ DeliveryItemCreate( sales_order_item_id=order_item.id, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=2, delivery_weight_kg=4, unit_price=10, ) ], ), context=self._auth_context(), db=self.db, ) delivery = self.db.get(Delivery, delivery_result.delivery_id) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) self.assertIsNone(delivery.logistics_waybill_no) self.assertEqual(delivery.logistics_photo_url, "/inventory/logistics-photos/sales-photo.jpg") self.assertEqual(txn.logistics_photo_url, "/inventory/logistics-photos/sales-photo.jpg") def test_direct_customer_delivery_without_sales_order_preserves_trace_and_inventory(self) -> None: delivery_result = create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=self.warehouse.id, consignee_name=self.customer.contact_name, consignee_phone=self.customer.contact_phone, delivery_address=self.customer.address, waybill_no="YD-DIRECT-001", freight_amount=18, items=[ DeliveryItemCreate( sales_order_item_id=None, product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=3, delivery_weight_kg=6, unit_price=0, ) ], ), context=self._auth_context(), db=self.db, ) delivery = self.db.get(Delivery, delivery_result.delivery_id) delivery_item = self.db.scalar(select(DeliveryItem).where(DeliveryItem.delivery_id == delivery.id)) trace_row = self.db.execute(get_delivery_items_query(limit=10, delivery_id=delivery.id)).mappings().first() stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id)) sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT")) self.assertIsNone(delivery.sales_order_id) self.assertEqual(delivery.customer_id, self.customer.id) self.assertIsNone(delivery_item.sales_order_item_id) self.assertEqual(trace_row["source_material_lot_no"], "RML-001") self.assertEqual(trace_row["source_material_code"], "MAT-001") self.assertEqual(trace_row["source_material_name"], "冷轧钢板") self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001") self.assertEqual(trace_row["source_material_summary"], "RML-001 / 冷轧钢板 / 40kg / ¥200") self.assertEqual(float(stock_balance.qty_on_hand), 17) self.assertEqual(float(stock_balance.qty_available), 17) self.assertEqual(sales_out_txn.source_doc_type, "DELIVERY") self.assertEqual(sales_out_txn.source_doc_id, delivery.id) def test_sales_delivery_rejects_non_finished_warehouse(self) -> None: raw_warehouse = Warehouse( id=2, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE", ) self.db.add(raw_warehouse) self.db.commit() with self.assertRaises(HTTPException) as exc: create_delivery( DeliveryCreate( customer_id=self.customer.id, warehouse_id=raw_warehouse.id, waybill_no="YD-WRONG-WH", freight_amount=12, items=[ DeliveryItemCreate( product_item_id=self.product.id, lot_id=self.finished_lot.id, delivery_qty=1, delivery_weight_kg=2, unit_price=0, ) ], ), context=self._auth_context(), db=self.db, ) self.assertEqual(exc.exception.status_code, 400) self.assertIn("销售出库必须选择成品库", exc.exception.detail) if __name__ == "__main__": unittest.main()