from __future__ import annotations import unittest from datetime import UTC, datetime from decimal import Decimal from io import BytesIO from fastapi import HTTPException from openpyxl import Workbook, load_workbook 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 from app.models.base import Base # noqa: E402 from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402 from app.models.operations import InventoryTxn, StockLot, StocktakeLine, WarehouseLocation # noqa: E402 from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402 class User: id = 1 class Context: user = User() class ScrapWarehouseFlowTest(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() def tearDown(self) -> None: self.db.close() def _seed(self) -> None: now = datetime.now(UTC) self.scrap_warehouse = Warehouse( id=1, warehouse_code="WH-SCRAP-01", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE", created_at=now, updated_at=now, ) self.raw_warehouse = Warehouse( id=2, warehouse_code="WH-RM-01", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE", created_at=now, updated_at=now, ) self.scrap_location = WarehouseLocation( id=1, warehouse_id=1, location_code="SCR-E-01", location_name="废料暂存位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now, ) self.raw_location = WarehouseLocation( id=2, warehouse_id=2, location_code="RM-A-01", location_name="原料主库位", is_default=1, is_locked=0, status="ACTIVE", created_at=now, updated_at=now, ) self.raw_item = Item( id=1, item_code="原材料00001", item_name="304不锈钢边角料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), safety_stock_weight_kg=Decimal("0"), scrap_sale_price=Decimal("1.6"), status="ACTIVE", created_at=now, updated_at=now, ) self.product_item = Item( id=2, item_code="产品需规00001", item_name="冲压支架报废品", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("2.5"), safety_stock_weight_kg=Decimal("0"), scrap_sale_price=Decimal("1.2"), status="ACTIVE", created_at=now, updated_at=now, ) self.db.add_all( [ self.scrap_warehouse, self.raw_warehouse, self.scrap_location, self.raw_location, self.raw_item, self.product_item, ] ) self.db.commit() def _opening_workbook(self, rows: list[list[object]]) -> bytes: from app.api.routes.inventory import OPENING_INVENTORY_HEADERS workbook = Workbook() worksheet = workbook.active worksheet.title = "废料库期初导入" worksheet.append(OPENING_INVENTORY_HEADERS) for row in rows: worksheet.append(row) output = BytesIO() workbook.save(output) return output.getvalue() def test_scrap_opening_template_exports_sample_rows(self) -> None: from app.api.routes.inventory import build_opening_inventory_template workbook = build_opening_inventory_template(self.db, "SCRAP") output = BytesIO() workbook.save(output) loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) rows = list(loaded["废料库期初导入"].iter_rows(values_only=True)) headers = list(rows[0]) self.assertEqual(rows[1][headers.index("仓库类型")], "废料库") self.assertEqual(rows[1][headers.index("仓库名称")], "废料库") self.assertIn("示例-SCRAP-", rows[1][headers.index("批次号")]) self.assertGreater(rows[1][headers.index("重量(kg)")], 0) def test_scrap_opening_import_is_weight_only(self) -> None: from app.api.routes.inventory import import_opening_inventory_workbook content = self._opening_workbook( [[ "废料库", "废料库", "废料暂存位", "原材料00001", "304不锈钢边角料", "OPEN-SCRAP-001", 99, 125, 1.6, "初始化废料", "期初废料", ]] ) result = import_opening_inventory_workbook(self.db, "SCRAP", content, user_id=1) lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "OPEN-SCRAP-001")) balance = self.db.scalar( select(StockBalance).where( StockBalance.item_id == self.raw_item.id, StockBalance.warehouse_id == self.scrap_warehouse.id, ) ) self.assertIsNotNone(lot) self.assertIsNotNone(balance) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) self.assertIsNotNone(txn) self.assertEqual(result["warehouse_type"], "SCRAP") self.assertEqual(result["imported"], 1) self.assertEqual(float(lot.inbound_qty), 0) self.assertEqual(float(lot.remaining_qty), 0) self.assertEqual(float(lot.inbound_weight_kg), 125) self.assertEqual(float(balance.qty_on_hand), 0) self.assertEqual(float(balance.weight_on_hand_kg), 125) self.assertEqual(txn.txn_type, "OPENING_IN") self.assertEqual(float(txn.qty_change), 0) self.assertEqual(float(txn.weight_change_kg), 125) def test_production_scrap_inbound_creates_scrap_lot_balance_and_txn(self) -> None: from app.api.routes.inventory import create_warehouse_inbound with self.assertRaises(HTTPException) as cm: create_warehouse_inbound( WarehouseInboundCreate( biz_type="PRODUCTION_SCRAP_IN", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=36.5, unit_cost=1.6, source_material_sub_batch_no="JH_110501000185_0001", source_material_summary="LAC-51012170/71左右后纵梁后段 1序报废边角料", remark="生产废料入库", ), context=Context(), db=self.db, ) self.assertEqual(cm.exception.status_code, 400) self.assertIn("生产废料入库必须选择生产工单", cm.exception.detail) def test_production_surplus_returns_weight_to_selected_source_lot(self) -> None: from app.api.routes.inventory import create_warehouse_inbound now = datetime.now(UTC) source_lot = StockLot( id=101, lot_no="JH_5系钢板00002_0001", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30003, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("600"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("400"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("6"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) balance = StockBalance( item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("400"), qty_available=Decimal("0"), weight_available_kg=Decimal("400"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("6"), updated_at=now, ) self.db.add_all([source_lot, balance]) self.db.commit() with self.assertRaises(HTTPException) as cm: create_warehouse_inbound( WarehouseInboundCreate( biz_type="PRODUCTION_SURPLUS", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, inbound_weight_kg=80, unit_cost=99, source_lot_id=source_lot.id, remark="生产余料归还", ), context=Context(), db=self.db, ) self.assertEqual(cm.exception.status_code, 400) self.assertIn("生产余料入库必须选择生产工单", cm.exception.detail) def test_generated_inventory_remarks_use_chinese_business_labels(self) -> None: from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound now = datetime.now(UTC) source_lot = StockLot( id=103, lot_no="JH_5系钢板00002_0003", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30007, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("500"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("180"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("5"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) balance = StockBalance( item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("180"), qty_available=Decimal("0"), weight_available_kg=Decimal("180"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("5"), updated_at=now, ) self.db.add_all([source_lot, balance]) self.db.commit() with self.assertRaises(HTTPException) as surplus_exc: create_warehouse_inbound( WarehouseInboundCreate( biz_type="PRODUCTION_SURPLUS", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, inbound_weight_kg=20, unit_cost=99, source_lot_id=source_lot.id, ), context=Context(), db=self.db, ) create_warehouse_outbound( WarehouseOutboundCreate( biz_type="SCRAP_OUT", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, outbound_weight_kg=15, source_lots=[{"source_lot_id": source_lot.id, "outbound_weight_kg": 15}], ), context=Context(), db=self.db, ) scrap_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SCRAP_OUT")) self.assertEqual(surplus_exc.exception.status_code, 400) self.assertIn("生产余料入库必须选择生产工单", surplus_exc.exception.detail) self.assertIsNotNone(scrap_txn) self.assertIn("报废出库", scrap_txn.remark) self.assertNotIn("SCRAP_OUT", scrap_txn.remark) def test_outsourcing_surplus_returns_weight_to_selected_source_lot(self) -> None: from app.api.routes.inventory import create_warehouse_inbound now = datetime.now(UTC) source_lot = StockLot( id=102, lot_no="JH_5系钢板00002_0002", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30006, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("500"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("120"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("5"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) balance = StockBalance( item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("120"), qty_available=Decimal("0"), weight_available_kg=Decimal("120"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("5"), updated_at=now, ) self.db.add_all([source_lot, balance]) self.db.commit() row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_SURPLUS", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, inbound_weight_kg=30, unit_cost=99, source_lot_id=source_lot.id, provider_name="委外厂A", waybill_no="YD-WWSY-001", freight_amount=12, remark="委外余料归还", ), context=Context(), db=self.db, ) self.db.refresh(source_lot) self.db.refresh(balance) lots = self.db.scalars(select(StockLot).where(StockLot.item_id == self.raw_item.id)).all() txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "OUTSOURCING_SURPLUS_IN")) self.assertEqual(row.lot_id, source_lot.id) self.assertEqual(len(lots), 1) self.assertEqual(float(source_lot.inbound_weight_kg), 500) self.assertEqual(float(source_lot.remaining_weight_kg), 150) self.assertEqual(float(balance.weight_on_hand_kg), 150) self.assertEqual(float(balance.weight_available_kg), 150) self.assertIsNotNone(txn) self.assertEqual(txn.lot_id, source_lot.id) self.assertEqual(float(txn.weight_change_kg), 30) self.assertEqual(float(txn.unit_cost), 5) self.assertEqual(float(txn.amount), 150) self.assertIn("来源/提供方:委外厂A", txn.remark) self.assertEqual(txn.logistics_waybill_no, "YD-WWSY-001") self.assertEqual(float(txn.logistics_freight_amount), 12) def test_outsourcing_outbound_deducts_selected_source_lots(self) -> None: from app.api.routes.inventory import create_warehouse_outbound now = datetime.now(UTC) lot_a = StockLot( id=201, lot_no="JH_5系钢板00002_0001", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30001, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("50"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("5"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) lot_b = StockLot( id=202, lot_no="JH_5系钢板00002_0002", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30002, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("120"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("90"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("6"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) lot_c = StockLot( id=203, lot_no="JH_5系钢板00002_0003", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30003, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("80"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("60"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("7"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) balance = StockBalance( item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("200"), qty_available=Decimal("0"), weight_available_kg=Decimal("200"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("6"), updated_at=now, ) self.db.add_all([lot_a, lot_b, lot_c, balance]) self.db.commit() row = create_warehouse_outbound( WarehouseOutboundCreate( biz_type="OUTSOURCING_OUT", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, outbound_weight_kg=50, source_lots=[ {"source_lot_id": lot_b.id, "outbound_weight_kg": 30}, {"source_lot_id": lot_c.id, "outbound_weight_kg": 20}, ], waybill_no="YD-WWCK-001", freight_amount=18, remark="委外出库指定批次", ), context=Context(), db=self.db, ) self.db.refresh(lot_a) self.db.refresh(lot_b) self.db.refresh(lot_c) self.db.refresh(balance) txns = self.db.scalars( select(InventoryTxn) .where(InventoryTxn.txn_type == "OUTSOURCING_OUT") .order_by(InventoryTxn.lot_id.asc()) ).all() self.assertEqual(row.lot_id, lot_b.id) self.assertEqual(float(lot_a.remaining_weight_kg), 50) self.assertEqual(float(lot_b.remaining_weight_kg), 60) self.assertEqual(float(lot_c.remaining_weight_kg), 40) self.assertEqual(float(balance.weight_on_hand_kg), 150) self.assertEqual(float(balance.weight_available_kg), 150) self.assertEqual([txn.lot_id for txn in txns], [lot_b.id, lot_c.id]) self.assertEqual([float(txn.weight_change_kg) for txn in txns], [-30, -20]) self.assertTrue(all(txn.logistics_waybill_no == "YD-WWCK-001" for txn in txns)) def test_scrap_outbound_deducts_selected_source_lots(self) -> None: from app.api.routes.inventory import create_warehouse_outbound now = datetime.now(UTC) lot_a = StockLot( id=211, lot_no="JH_5系钢板00002_0001", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30011, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("40"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("5"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) lot_b = StockLot( id=212, lot_no="JH_5系钢板00002_0002", lot_role="RAW_MATERIAL", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=30012, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("130"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("75"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("6"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) balance = StockBalance( item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("115"), qty_available=Decimal("0"), weight_available_kg=Decimal("115"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("5.5"), updated_at=now, ) self.db.add_all([lot_a, lot_b, balance]) self.db.commit() row = create_warehouse_outbound( WarehouseOutboundCreate( biz_type="SCRAP_OUT", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, outbound_weight_kg=45, source_lots=[ {"source_lot_id": lot_a.id, "outbound_weight_kg": 15}, {"source_lot_id": lot_b.id, "outbound_weight_kg": 30}, ], remark="报废出库指定批次", ), context=Context(), db=self.db, ) self.db.refresh(lot_a) self.db.refresh(lot_b) self.db.refresh(balance) txns = self.db.scalars( select(InventoryTxn) .where(InventoryTxn.txn_type == "SCRAP_OUT") .order_by(InventoryTxn.lot_id.asc()) ).all() self.assertEqual(row.lot_id, lot_a.id) self.assertEqual(float(lot_a.remaining_weight_kg), 25) self.assertEqual(float(lot_b.remaining_weight_kg), 45) self.assertEqual(float(balance.weight_on_hand_kg), 70) self.assertEqual(float(balance.weight_available_kg), 70) self.assertEqual([txn.lot_id for txn in txns], [lot_a.id, lot_b.id]) self.assertEqual([float(txn.weight_change_kg) for txn in txns], [-15, -30]) self.assertTrue(all("来源库存批次号" in (txn.remark or "") for txn in txns)) def test_outsourcing_scrap_inbound_accepts_product_item(self) -> None: from app.api.routes.inventory import create_warehouse_inbound row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_SCRAP_IN", item_id=self.product_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=12, unit_cost=1.2, provider_name="外协加工厂A", waybill_no="YD-WWFL-001", freight_amount=18.5, remark="委外废料入库", ), context=Context(), db=self.db, ) lot = self.db.get(StockLot, row.lot_id) self.assertIsNotNone(lot) txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id)) self.assertIsNotNone(txn) self.assertEqual(lot.lot_role, "OUTSOURCING_SCRAP") self.assertEqual(lot.source_doc_type, "OUTSOURCING_SCRAP_IN") self.assertEqual(float(lot.inbound_qty), 0) self.assertEqual(lot.logistics_waybill_no, "YD-WWFL-001") self.assertEqual(float(lot.logistics_freight_amount), 18.5) self.assertEqual(txn.txn_type, "OUTSOURCING_SCRAP_IN") self.assertEqual(txn.logistics_waybill_no, "YD-WWFL-001") self.assertEqual(float(txn.logistics_freight_amount), 18.5) def test_selected_generic_warehouse_operations_require_logistics_info(self) -> None: from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound with self.assertRaises(HTTPException) as inbound_exc: create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_SCRAP_IN", item_id=self.product_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=12, unit_cost=1.2, provider_name="外协加工厂A", ), context=Context(), db=self.db, ) self.assertEqual(inbound_exc.exception.status_code, 400) self.assertIn("运单号", inbound_exc.exception.detail) self.assertIn("运费", inbound_exc.exception.detail) create_warehouse_inbound( WarehouseInboundCreate( biz_type="OPENING", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=100, unit_cost=1.6, ), context=Context(), db=self.db, ) with self.assertRaises(HTTPException) as outbound_exc: create_warehouse_outbound( WarehouseOutboundCreate( biz_type="SCRAP_SALE_OUT", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, outbound_weight_kg=40, sale_unit_price=2.5, buyer_name="废品回收商A", ), context=Context(), db=self.db, ) self.assertEqual(outbound_exc.exception.status_code, 400) self.assertIn("运单号", outbound_exc.exception.detail) self.assertIn("运费", outbound_exc.exception.detail) def test_required_generic_logistics_allows_order_photo_instead_of_waybill(self) -> None: from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound inbound_row = create_warehouse_inbound( WarehouseInboundCreate( biz_type="OUTSOURCING_SCRAP_IN", item_id=self.product_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=12, unit_cost=1.2, provider_name="外协加工厂A", order_photo_url="/inventory/logistics-photos/test-inbound.jpg", freight_amount=18.5, ), context=Context(), db=self.db, ) inbound_lot = self.db.get(StockLot, inbound_row.lot_id) inbound_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == inbound_lot.id)) self.assertIsNone(inbound_lot.logistics_waybill_no) self.assertEqual(inbound_lot.logistics_photo_url, "/inventory/logistics-photos/test-inbound.jpg") self.assertEqual(inbound_txn.logistics_photo_url, "/inventory/logistics-photos/test-inbound.jpg") create_warehouse_inbound( WarehouseInboundCreate( biz_type="OPENING", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=100, unit_cost=1.6, ), context=Context(), db=self.db, ) create_warehouse_outbound( WarehouseOutboundCreate( biz_type="SCRAP_SALE_OUT", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, outbound_weight_kg=40, sale_unit_price=2.5, buyer_name="废品回收商A", order_photo_url="/inventory/logistics-photos/test-outbound.jpg", freight_amount=10, ), context=Context(), db=self.db, ) outbound_txn = self.db.scalar( select(InventoryTxn) .where(InventoryTxn.txn_type == "SCRAP_SALE_OUT") .order_by(InventoryTxn.id.desc()) .limit(1) ) self.assertIsNone(outbound_txn.logistics_waybill_no) self.assertEqual(outbound_txn.logistics_photo_url, "/inventory/logistics-photos/test-outbound.jpg") def test_scrap_sale_outbound_schema_preserves_sale_fields(self) -> None: payload = WarehouseOutboundCreate( biz_type="SCRAP_SALE_OUT", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, outbound_weight_kg=40, sale_unit_price=2.5, buyer_name="废品回收商A", sale_remark="按废不锈钢售卖", ) self.assertEqual(float(payload.sale_unit_price), 2.5) self.assertEqual(payload.buyer_name, "废品回收商A") self.assertEqual(payload.sale_remark, "按废不锈钢售卖") def test_scrap_sale_outbound_uses_sale_price_for_transaction_amount(self) -> None: from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound create_warehouse_inbound( WarehouseInboundCreate( biz_type="OPENING", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=100, unit_cost=1.6, ), context=Context(), db=self.db, ) row = create_warehouse_outbound( WarehouseOutboundCreate( biz_type="SCRAP_SALE_OUT", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, outbound_weight_kg=40, sale_unit_price=2.5, buyer_name="废品回收商A", waybill_no="YD-FLSM-001", freight_amount=10, sale_remark="按废不锈钢售卖", ), context=Context(), db=self.db, ) txn = self.db.scalar( select(InventoryTxn) .where(InventoryTxn.txn_type == "SCRAP_SALE_OUT") .order_by(InventoryTxn.id.desc()) .limit(1) ) balance = self.db.scalar( select(StockBalance).where( StockBalance.item_id == self.raw_item.id, StockBalance.warehouse_id == self.scrap_warehouse.id, ) ) self.assertIsNotNone(txn) self.assertIsNotNone(balance) self.assertEqual(row.warehouse_type, "SCRAP") self.assertEqual(float(balance.weight_on_hand_kg), 60) self.assertEqual(float(txn.unit_cost), 2.5) self.assertEqual(float(txn.amount), 100) self.assertEqual(txn.logistics_waybill_no, "YD-FLSM-001") self.assertEqual(float(txn.logistics_freight_amount), 10) self.assertIn("废品回收商A", txn.remark) def test_scrap_sale_outbound_deducts_selected_scrap_lots_and_records_raw_source_lots(self) -> None: from app.api.routes.inventory import create_warehouse_outbound now = datetime.now(UTC) raw_source_lot = StockLot( id=511, lot_no="JH_304不锈钢00001_0001", lot_role="INVENTORY", item_id=self.raw_item.id, warehouse_id=self.raw_warehouse.id, location_id=self.raw_location.id, source_doc_type="PURCHASE_RECEIPT", source_doc_id=401, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("20"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("6"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) scrap_lot_a = StockLot( id=611, lot_no="FL-20260602-001", lot_role="SCRAP", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, location_id=self.scrap_location.id, source_doc_type="PRODUCTION_SCRAP_IN", source_doc_id=501, source_material_lot_id=raw_source_lot.id, source_material_sub_batch_no="JH_旧文本来源_0009", source_material_summary="来源库存批次号:JH_304不锈钢00001_0001", inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("40"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("25"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("1.2"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) scrap_lot_b = StockLot( id=612, lot_no="FL-20260602-002", lot_role="SCRAP", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, location_id=self.scrap_location.id, source_doc_type="OUTSOURCING_SCRAP_IN", source_doc_id=502, source_material_sub_batch_no="JH_304不锈钢00001_0002", source_material_summary="委外废料;来源库存批次号:JH_304不锈钢00001_0002", inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("60"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("45"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("1.3"), quality_status="PASS", status="AVAILABLE", created_at=now, updated_at=now, ) balance = StockBalance( item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, location_id=self.scrap_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("70"), qty_available=Decimal("0"), weight_available_kg=Decimal("70"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("1.25"), updated_at=now, ) self.db.add_all([raw_source_lot, scrap_lot_a, scrap_lot_b, balance]) self.db.commit() create_warehouse_outbound( WarehouseOutboundCreate( biz_type="SCRAP_SALE_OUT", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, outbound_weight_kg=45, sale_unit_price=2.5, buyer_name="废品回收商A", waybill_no="YD-FLSM-002", freight_amount=12, source_lots=[ {"source_lot_id": scrap_lot_a.id, "outbound_weight_kg": 15}, {"source_lot_id": scrap_lot_b.id, "outbound_weight_kg": 30}, ], ), context=Context(), db=self.db, ) self.db.refresh(scrap_lot_a) self.db.refresh(scrap_lot_b) self.db.refresh(balance) txns = self.db.scalars( select(InventoryTxn) .where(InventoryTxn.txn_type == "SCRAP_SALE_OUT") .order_by(InventoryTxn.id.asc()) ).all() self.assertEqual(float(scrap_lot_a.remaining_weight_kg), 10) self.assertEqual(float(scrap_lot_b.remaining_weight_kg), 15) self.assertEqual(float(balance.weight_on_hand_kg), 25) self.assertEqual(len(txns), 2) self.assertIn("来源库存批次号 JH_304不锈钢00001_0001", txns[0].remark) self.assertIn("来源库存批次号 JH_304不锈钢00001_0002", txns[1].remark) def test_scrap_warehouse_rejects_non_scrap_operations(self) -> None: from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound with self.assertRaises(HTTPException) as inbound_exc: create_warehouse_inbound( WarehouseInboundCreate( biz_type="CUSTOMER_SUPPLIED", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, inbound_weight_kg=5, ), context=Context(), db=self.db, ) self.assertEqual(inbound_exc.exception.status_code, 400) self.assertIn("废料库不允许执行该入库业务", inbound_exc.exception.detail) with self.assertRaises(HTTPException) as outbound_exc: create_warehouse_outbound( WarehouseOutboundCreate( biz_type="SCRAP_OUT", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, outbound_weight_kg=5, ), context=Context(), db=self.db, ) self.assertEqual(outbound_exc.exception.status_code, 400) self.assertIn("废料库不允许执行该出库业务", outbound_exc.exception.detail) def test_stocktake_export_uses_scrap_sheet_name(self) -> None: from app.services.stocktake import build_stocktake_workbook, start_stocktake self.db.add( StockLot( id=10, lot_no="SCRAP-STOCKTAKE-001", lot_role="PRODUCTION_SCRAP", item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, location_id=self.scrap_location.id, source_doc_type="PRODUCTION_SCRAP_IN", source_doc_id=10, source_material_sub_batch_no="JH_110501000185_0001", source_material_summary="盘库用废料", inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("66"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("66"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("1.6"), quality_status="PASS", status="AVAILABLE", ) ) self.db.add( StockBalance( item_id=self.raw_item.id, warehouse_id=self.scrap_warehouse.id, location_id=self.scrap_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("66"), qty_available=Decimal("0"), weight_available_kg=Decimal("66"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("1.6"), ) ) self.db.commit() stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="废料库盘库") workbook = build_stocktake_workbook(self.db, stocktake.id) output = BytesIO() workbook.save(output) loaded = load_workbook(BytesIO(output.getvalue()), read_only=True, data_only=True) self.assertIn("废料库", loaded.sheetnames) rows = list(loaded["废料库"].iter_rows(values_only=True)) headers = list(rows[0]) self.assertEqual(rows[1][headers.index("仓库")], "废料库") self.assertEqual(rows[1][headers.index("库位")], "废料暂存位") self.assertEqual(rows[1][headers.index("物料编码")], "原材料00001") self.assertEqual(rows[1][headers.index("批次号")], "SCRAP-STOCKTAKE-001") self.assertEqual(rows[1][headers.index("来源库存批次号")], "JH_110501000185_0001") self.assertNotIn("系统数量", headers) self.assertNotIn("盘点数量", headers) self.assertEqual(rows[1][headers.index("系统重量(kg)")], 66) self.assertEqual(rows[1][headers.index("系统单价")], 1.6) def test_runtime_seed_normalizes_existing_scrap_warehouse_name(self) -> None: from app.api.routes.master_data import _ensure_jiaheng_warehouses self.scrap_warehouse.warehouse_name = "废料仓" self.scrap_warehouse.remark = "旧废料仓说明" self.db.add(self.scrap_warehouse) self.db.commit() _ensure_jiaheng_warehouses(self.db) self.db.refresh(self.scrap_warehouse) self.assertEqual(self.scrap_warehouse.warehouse_name, "废料库") self.assertEqual(self.scrap_warehouse.remark, "生产废料、边角料、委外废料、报废品等废料库存") def test_scrap_stocktake_keeps_product_scrap_weight_only_after_confirm(self) -> None: from app.services.stocktake import build_stocktake_workbook, confirm_stocktake, import_stocktake_workbook, start_stocktake self.db.add( StockLot( id=11, lot_no="SCRAP-PRODUCT-001", lot_role="OUTSOURCING_SCRAP", item_id=self.product_item.id, warehouse_id=self.scrap_warehouse.id, location_id=self.scrap_location.id, source_doc_type="OUTSOURCING_SCRAP_IN", source_doc_id=11, source_material_summary="产品报废件盘库", inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("30"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("30"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("1.2"), quality_status="PASS", status="AVAILABLE", ) ) self.db.add( StockBalance( item_id=self.product_item.id, warehouse_id=self.scrap_warehouse.id, location_id=self.scrap_location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("30"), qty_available=Decimal("0"), weight_available_kg=Decimal("30"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("1.2"), ) ) self.db.commit() stocktake = start_stocktake(self.db, warehouse_ids=[self.scrap_warehouse.id], user_id=1, remark="产品废料盘库") workbook = build_stocktake_workbook(self.db, stocktake.id) output = BytesIO() workbook.save(output) loaded = load_workbook(BytesIO(output.getvalue())) worksheet = loaded["废料库"] headers = [cell.value for cell in worksheet[1]] self.assertNotIn("盘点数量", headers) worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1, value=45) imported_output = BytesIO() loaded.save(imported_output) import_stocktake_workbook(self.db, stocktake.id, imported_output.getvalue(), user_id=1) confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="确认产品废料盘库") self.assertEqual(confirmed.status, "CONFIRMED") lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "SCRAP-PRODUCT-001")) balance = self.db.scalar( select(StockBalance).where( StockBalance.item_id == self.product_item.id, StockBalance.warehouse_id == self.scrap_warehouse.id, ) ) line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id)) self.assertIsNotNone(lot) self.assertIsNotNone(balance) self.assertIsNotNone(line) self.assertEqual(float(line.counted_qty), 0) self.assertEqual(float(line.diff_qty), 0) self.assertEqual(float(lot.remaining_qty), 0) self.assertEqual(float(lot.remaining_weight_kg), 45) self.assertEqual(float(balance.qty_on_hand), 0) self.assertEqual(float(balance.qty_available), 0) self.assertEqual(float(balance.weight_on_hand_kg), 45) if __name__ == "__main__": unittest.main()