from __future__ import annotations import unittest from datetime import UTC, 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.operations # noqa: E402,F401 import app.models.org # noqa: E402,F401 from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound # noqa: E402 from app.models.base import Base # noqa: E402 from app.models.master_data import Bom, BomItem, Item, Warehouse # noqa: E402 from app.models.operations import InventoryTxn, Process, ProcessRoute, ProcessRouteOperation, StockLot, WarehouseLocation, WorkCenter # noqa: E402 from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402 class User: id = 1 class Context: user = User() class SemiFinishedOperationInventoryTest(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_route(self) -> tuple[Item, Warehouse, ProcessRoute, ProcessRouteOperation, ProcessRouteOperation]: now = datetime.now(UTC) product = Item( id=1, item_code="产品需规00001", item_name="五金支架", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.6"), safety_stock_weight_kg=Decimal("0"), scrap_sale_price=Decimal("0"), status="ACTIVE", created_at=now, updated_at=now, ) process = Process( id=1, process_code="GX01", process_name="冲压", process_type="INHOUSE", is_report_required=1, is_quality_gate=0, status="ACTIVE", created_at=now, updated_at=now, ) center = WorkCenter( id=1, center_code="WC01", center_name="一号工位", dept_id=1, center_type="INHOUSE", capacity_hours_per_day=Decimal("8"), status="ACTIVE", created_at=now, updated_at=now, ) warehouse = Warehouse( id=1, warehouse_code="WH-SEMI-T", warehouse_name="半成品测试仓", warehouse_type="SEMI", status="ACTIVE", created_at=now, updated_at=now, ) self.db.add_all([product, process, center, warehouse]) self.db.flush() location = WarehouseLocation( id=1, warehouse_id=warehouse.id, location_code="SEMI-A", location_name="半成品默认库位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now, ) route = ProcessRoute( id=1, route_code="R-001", route_name="五金支架工艺", product_item_id=product.id, version_no="V1.0", is_default=1, status="ACTIVE", created_at=now, updated_at=now, ) self.db.add_all([location, route]) self.db.flush() op1 = ProcessRouteOperation( id=1, route_id=route.id, seq_no=1, process_id=process.id, work_center_id=center.id, operation_name="冲压", status="ACTIVE", created_at=now, updated_at=now, ) op2 = ProcessRouteOperation( id=2, route_id=route.id, seq_no=2, process_id=process.id, work_center_id=center.id, operation_name="折弯", status="ACTIVE", created_at=now, updated_at=now, ) self.db.add_all([op1, op2]) self.db.commit() return product, warehouse, route, op1, op2 def _seed_raw_source_lots(self, product: Item) -> tuple[StockLot, StockLot]: now = datetime.now(UTC) material = Item( id=2, item_code="原材料00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("0"), safety_stock_weight_kg=Decimal("0"), scrap_sale_price=Decimal("0"), status="ACTIVE", created_at=now, updated_at=now, ) raw_warehouse = Warehouse( id=2, warehouse_code="WH-RAW-T", warehouse_name="原材料测试仓", warehouse_type="RAW", status="ACTIVE", created_at=now, updated_at=now, ) self.db.add_all([material, raw_warehouse]) self.db.flush() raw_location = WarehouseLocation( id=2, warehouse_id=raw_warehouse.id, location_code="RAW-A", location_name="原材料默认库位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now, ) bom = Bom( id=1, bom_code="BOM-SEMI-001", product_item_id=product.id, version_no="V1.0", is_default=1, status="ACTIVE", created_at=now, updated_at=now, ) bom_item = BomItem( id=1, bom_id=bom.id, seq_no=1, material_item_id=material.id, usage_type="WEIGHT", gross_weight_per_piece_kg=Decimal("0.6"), loss_rate=Decimal("0"), created_at=now, updated_at=now, ) lot_a = StockLot( id=10, lot_no="JH_冷轧钢板00001_0001", item_id=material.id, warehouse_id=raw_warehouse.id, location_id=raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=1, source_line_id=1, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("0"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("4.5"), quality_status="PASS", status="DEPLETED", created_at=now, updated_at=now, ) lot_b = StockLot( id=11, lot_no="JH_冷轧钢板00001_0002", item_id=material.id, warehouse_id=raw_warehouse.id, location_id=raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=2, source_line_id=2, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("200"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("200"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("4.8"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) self.db.add_all([raw_location, bom, bom_item, lot_a, lot_b]) self.db.commit() return lot_a, lot_b def _seed_finished_warehouse(self) -> Warehouse: now = datetime.now(UTC) warehouse = Warehouse( id=3, warehouse_code="WH-FG-T", warehouse_name="成品测试仓", warehouse_type="FINISHED", status="ACTIVE", created_at=now, updated_at=now, ) location = WarehouseLocation( id=3, warehouse_id=warehouse.id, location_code="FG-A", location_name="成品默认库位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now, ) self.db.add_all([warehouse, location]) self.db.commit() return warehouse def test_semi_inbound_records_product_operation_source_line(self) -> None: product, warehouse, route, op1, _ = self._seed_route() row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=120, unit_cost=8, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, source_material_summary="产品:五金支架;已完成工序:第1道 冲压", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertEqual(lot.source_line_id, op1.id) self.assertEqual(lot.source_doc_id, route.id) self.assertIn("第1道", lot.source_material_summary) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) self.assertEqual(txn.source_line_id, op1.id) def test_semi_wip_inbound_accepts_quantity_when_weight_is_empty(self) -> None: product, warehouse, route, op1, _ = self._seed_route() row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=12, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, source_material_summary="产品:五金支架;已完成工序:第1道 冲压", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertEqual(lot.inbound_qty, Decimal("12.000000")) self.assertEqual(lot.remaining_qty, Decimal("12.000000")) self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) self.assertEqual(txn.qty_change, Decimal("12.000000")) self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) def test_semi_wip_inbound_requires_weight_or_quantity(self) -> None: product, warehouse, route, op1, _ = self._seed_route() with self.assertRaises(HTTPException) as caught: create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=0, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, ), context=Context(), db=self.db, ) self.assertEqual(caught.exception.status_code, 400) self.assertIn("至少填写一项", caught.exception.detail) def test_semi_wip_inbound_rejects_weight_and_quantity_together(self) -> None: product, warehouse, route, op1, _ = self._seed_route() with self.assertRaises(HTTPException) as caught: create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=60, inbound_qty=100, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, ), context=Context(), db=self.db, ) self.assertEqual(caught.exception.status_code, 400) self.assertIn("只能选择按件或按重一种口径", caught.exception.detail) def test_semi_wip_inbound_follows_existing_piece_basis(self) -> None: product, warehouse, route, op1, _ = self._seed_route() create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=12, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, ), context=Context(), db=self.db, ) with self.assertRaises(HTTPException) as caught: create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=30, inbound_qty=0, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, ), context=Context(), db=self.db, ) self.assertEqual(caught.exception.status_code, 400) self.assertIn("已有库存按件管理", caught.exception.detail) def test_semi_wip_inbound_follows_existing_weight_basis(self) -> None: product, warehouse, route, op1, _ = self._seed_route() create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=30, inbound_qty=0, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, ), context=Context(), db=self.db, ) with self.assertRaises(HTTPException) as caught: create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=12, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, ), context=Context(), db=self.db, ) self.assertEqual(caught.exception.status_code, 400) self.assertIn("已有库存按重管理", caught.exception.detail) def test_semi_wip_inbound_records_selected_raw_source_lots_without_deducting_them(self) -> None: product, warehouse, route, op1, _ = self._seed_route() lot_a, lot_b = self._seed_raw_source_lots(product) row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=60, inbound_qty=0, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, source_lot_ids=[lot_a.id, lot_b.id], source_material_summary="产品:五金支架;已完成工序:第1道 冲压", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertEqual(lot.source_material_lot_id, lot_a.id) self.assertEqual(lot.source_material_sub_batch_no, f"{lot_a.lot_no}、{lot_b.lot_no}") self.assertIn("来源库存批次号", lot.source_material_summary) self.assertIn(lot_a.lot_no, lot.source_material_summary) self.assertIn(lot_b.lot_no, lot.source_material_summary) self.db.refresh(lot_a) self.db.refresh(lot_b) self.assertEqual(lot_a.remaining_weight_kg, Decimal("0.000000")) self.assertEqual(lot_b.remaining_weight_kg, Decimal("200.000000")) def test_semi_outsourcing_inbound_accepts_quantity_when_weight_is_empty(self) -> None: product, warehouse, route, _, op2 = self._seed_route() row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=8, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op2.id, waybill_no="WW-001", freight_amount=0, source_material_summary="产品:五金支架;已完成工序:第2道 折弯", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertEqual(lot.inbound_qty, Decimal("8.000000")) self.assertEqual(lot.remaining_qty, Decimal("8.000000")) self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) self.assertEqual(txn.qty_change, Decimal("8.000000")) self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) def test_semi_outsourcing_inbound_records_selected_raw_source_lots_without_deducting_them(self) -> None: product, warehouse, route, _, op2 = self._seed_route() lot_a, lot_b = self._seed_raw_source_lots(product) row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=30, inbound_qty=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op2.id, source_lot_ids=[lot_a.id, lot_b.id], waybill_no="WW-002", freight_amount=12, provider_name="委外厂甲", source_material_summary="产品:五金支架;已完成工序:第2道 折弯", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertEqual(lot.source_material_lot_id, lot_a.id) self.assertEqual(lot.source_material_sub_batch_no, f"{lot_a.lot_no}、{lot_b.lot_no}") self.assertIn("来源库存批次号", lot.source_material_summary) self.assertIn(lot_a.lot_no, lot.source_material_summary) self.assertIn(lot_b.lot_no, lot.source_material_summary) self.db.refresh(lot_a) self.db.refresh(lot_b) self.assertEqual(lot_a.remaining_weight_kg, Decimal("0.000000")) self.assertEqual(lot_b.remaining_weight_kg, Decimal("200.000000")) def test_finished_outsourcing_inbound_uses_quantity_and_optional_raw_source_lots(self) -> None: product, _, _, _, _ = self._seed_route() warehouse = self._seed_finished_warehouse() _, lot_b = self._seed_raw_source_lots(product) row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=12, unit_cost=6.5, source_lot_ids=[lot_b.id], waybill_no="WW-FG-001", freight_amount=15, provider_name="委外厂甲", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertEqual(lot.inbound_qty, Decimal("12.000000")) self.assertEqual(lot.remaining_qty, Decimal("12.000000")) self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000")) self.assertEqual(lot.source_material_lot_id, lot_b.id) self.assertEqual(lot.source_material_sub_batch_no, lot_b.lot_no) self.assertIn("来源库存批次号", lot.source_material_summary) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) self.assertEqual(txn.qty_change, Decimal("12.000000")) self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) self.assertEqual(txn.amount, Decimal("78.00")) def test_finished_outsourcing_inbound_allows_no_raw_source_lot(self) -> None: product, _, _, _, _ = self._seed_route() warehouse = self._seed_finished_warehouse() row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=5, unit_cost=7, source_lot_ids=[], waybill_no="WW-FG-002", freight_amount=0, provider_name="委外厂自供原料", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertEqual(lot.inbound_qty, Decimal("5.000000")) self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000")) self.assertIsNone(lot.source_material_lot_id) self.assertIsNone(lot.source_material_sub_batch_no) self.assertIn("委外厂自供原料", lot.source_material_summary) def test_semi_outbound_deducts_only_selected_product_operation(self) -> None: product, warehouse, route, op1, op2 = self._seed_route() first = create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=50, unit_cost=8, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op1.id, source_material_summary="产品:五金支架;已完成工序:第1道 冲压", ), context=Context(), db=self.db, ) second = create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=80, unit_cost=9, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op2.id, source_material_summary="产品:五金支架;已完成工序:第2道 折弯", ), context=Context(), db=self.db, ) create_warehouse_outbound( WarehouseOutboundCreate( biz_type="WIP_OUT", item_id=product.id, warehouse_id=warehouse.id, outbound_weight_kg=30, target_doc_type="PRODUCT_ROUTE_OPERATION", target_doc_id=route.id, target_doc_line_id=op2.id, target_material_sub_batch_no="第2道 折弯", ), context=Context(), db=self.db, ) first_lot = self.db.get(StockLot, first.lot_id) second_lot = self.db.get(StockLot, second.lot_id) self.assertEqual(first_lot.remaining_weight_kg, Decimal("50.000000")) self.assertEqual(second_lot.remaining_weight_kg, Decimal("50.000000")) def test_semi_wip_outbound_accepts_quantity_when_weight_is_empty(self) -> None: product, warehouse, route, _, op2 = self._seed_route() inbound = create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=20, unit_cost=8, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op2.id, source_material_summary="产品:五金支架;已完成工序:第2道 折弯", ), context=Context(), db=self.db, ) create_warehouse_outbound( WarehouseOutboundCreate( biz_type="WIP_OUT", item_id=product.id, warehouse_id=warehouse.id, outbound_weight_kg=0, outbound_qty=5, target_doc_type="PRODUCT_ROUTE_OPERATION", target_doc_id=route.id, target_doc_line_id=op2.id, ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, inbound.lot_id) self.assertEqual(lot.remaining_qty, Decimal("15.000000")) self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000")) txn = self.db.scalar( select(InventoryTxn) .where(InventoryTxn.lot_id == lot.id, InventoryTxn.txn_type == "WIP_OUT") .order_by(InventoryTxn.id.desc()) ) self.assertEqual(txn.qty_change, Decimal("-5.000000")) self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) self.assertIn(lot.lot_no, txn.remark) def test_semi_wip_outbound_requires_weight_or_quantity(self) -> None: product, warehouse, route, _, op2 = self._seed_route() with self.assertRaises(HTTPException) as caught: create_warehouse_outbound( WarehouseOutboundCreate( biz_type="WIP_OUT", item_id=product.id, warehouse_id=warehouse.id, outbound_weight_kg=0, outbound_qty=0, target_doc_type="PRODUCT_ROUTE_OPERATION", target_doc_id=route.id, target_doc_line_id=op2.id, ), context=Context(), db=self.db, ) self.assertEqual(caught.exception.status_code, 400) self.assertIn("至少填写一项", caught.exception.detail) def test_semi_wip_outbound_follows_existing_piece_basis(self) -> None: product, warehouse, route, _, op2 = self._seed_route() create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=20, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op2.id, ), context=Context(), db=self.db, ) with self.assertRaises(HTTPException) as caught: create_warehouse_outbound( WarehouseOutboundCreate( biz_type="WIP_OUT", item_id=product.id, warehouse_id=warehouse.id, outbound_weight_kg=10, outbound_qty=0, target_doc_type="PRODUCT_ROUTE_OPERATION", target_doc_id=route.id, target_doc_line_id=op2.id, ), context=Context(), db=self.db, ) self.assertEqual(caught.exception.status_code, 400) self.assertIn("按件管理", caught.exception.detail) def test_semi_wip_outbound_follows_existing_weight_basis(self) -> None: product, warehouse, route, _, op2 = self._seed_route() create_warehouse_inbound( WarehouseInboundCreate( biz_type="WIP_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=80, inbound_qty=0, unit_cost=0, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op2.id, ), context=Context(), db=self.db, ) with self.assertRaises(HTTPException) as caught: create_warehouse_outbound( WarehouseOutboundCreate( biz_type="WIP_OUT", item_id=product.id, warehouse_id=warehouse.id, outbound_weight_kg=0, outbound_qty=5, target_doc_type="PRODUCT_ROUTE_OPERATION", target_doc_id=route.id, target_doc_line_id=op2.id, ), context=Context(), db=self.db, ) self.assertEqual(caught.exception.status_code, 400) self.assertIn("按重管理", caught.exception.detail) def test_semi_outsourcing_outbound_accepts_quantity_and_records_outsourcing_party(self) -> None: product, warehouse, route, _, op2 = self._seed_route() inbound = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_IN", item_id=product.id, warehouse_id=warehouse.id, inbound_weight_kg=0, inbound_qty=10, source_doc_type="PRODUCT_ROUTE_OPERATION", source_doc_id=route.id, source_line_id=op2.id, waybill_no="WW-IN-001", freight_amount=0, source_material_summary="产品:五金支架;已完成工序:第2道 折弯", ), context=Context(), db=self.db, ) create_warehouse_outbound( WarehouseOutboundCreate( biz_type="OUTSOURCING_OUT", item_id=product.id, warehouse_id=warehouse.id, outbound_weight_kg=0, outbound_qty=4, target_doc_type="PRODUCT_ROUTE_OPERATION", target_doc_id=route.id, target_doc_line_id=op2.id, waybill_no="WW-OUT-001", freight_amount=15, outsourcing_party_name="委外厂乙", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, inbound.lot_id) self.assertEqual(lot.remaining_qty, Decimal("6.000000")) self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000")) txn = self.db.scalar( select(InventoryTxn) .where(InventoryTxn.lot_id == lot.id, InventoryTxn.txn_type == "OUTSOURCING_OUT") .order_by(InventoryTxn.id.desc()) ) self.assertEqual(txn.qty_change, Decimal("-4.000000")) self.assertEqual(txn.weight_change_kg, Decimal("0.000000")) self.assertIn(lot.lot_no, txn.remark) self.assertIn("委外方:委外厂乙", txn.remark) if __name__ == "__main__": unittest.main()