ForgeFlow-ERP/backend/tests/test_scrap_warehouse_flow.py
2026-06-12 16:00:56 +08:00

1167 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()