from __future__ import annotations import unittest from datetime import datetime from decimal import Decimal from tempfile import TemporaryDirectory from types import SimpleNamespace from unittest.mock import patch from sqlalchemy import BigInteger, create_engine, select from sqlalchemy.ext.compiler import compiles from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool @compiles(BigInteger, "sqlite") def _compile_big_integer_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.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 InventoryTxn, StockLot, WarehouseLocation # noqa: E402 from app.api.routes.inventory import create_warehouse_inbound, create_warehouse_outbound # noqa: E402 from app.schemas.operations import WarehouseInboundCreate, WarehouseOutboundCreate # noqa: E402 from app.services.document_archives import ( # noqa: E402 ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION, collect_archive_context, collect_warehouse_operation_archive_context, generate_document_archive, ) class WarehouseOperationDocumentArchiveTest(unittest.TestCase): def setUp(self) -> None: engine = create_engine( "sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}, poolclass=StaticPool, future=True, ) Base.metadata.create_all(engine) self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) self.db: Session = self.SessionLocal() self.now = datetime(2026, 6, 11, 9, 0, 0) def tearDown(self) -> None: self.db.close() def _seed_customer_supplied_txn(self) -> InventoryTxn: warehouse = Warehouse( id=1, warehouse_code="RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE", created_at=self.now, updated_at=self.now, ) location = WarehouseLocation( id=2, warehouse_id=warehouse.id, location_code="RAW-A", location_name="原材料暂存位", status="ACTIVE", created_at=self.now, updated_at=self.now, ) item = Item( id=3, item_code="RM00001", item_name="冷轧钢板", item_type="RAW_MATERIAL", specification="1.2mm", unit_weight_kg=Decimal("1"), status="ACTIVE", created_at=self.now, updated_at=self.now, ) source_lot = StockLot( id=4, lot_no="SRC-YL0001", lot_role="RAW_MATERIAL", item_id=item.id, warehouse_id=warehouse.id, source_doc_type="期初入库", source_doc_id=1, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("100"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("0"), quality_status="PASS", status="AVAILABLE", created_at=self.now, updated_at=self.now, ) lot = StockLot( id=5, lot_no="YL0001", lot_role="RAW_MATERIAL", item_id=item.id, warehouse_id=warehouse.id, location_id=location.id, source_doc_type="客料入库", source_doc_id=1, source_material_lot_id=source_lot.id, inbound_qty=Decimal("0"), inbound_weight_kg=Decimal("100"), remaining_qty=Decimal("0"), remaining_weight_kg=Decimal("100"), locked_qty=Decimal("0"), locked_weight_kg=Decimal("0"), unit_cost=Decimal("0"), quality_status="PASS", status="AVAILABLE", created_at=self.now, updated_at=self.now, ) txn = InventoryTxn( id=6, txn_no="TXN-CUSTOMER-001", txn_type="CUSTOMER_SUPPLIED_IN", item_id=item.id, warehouse_id=warehouse.id, location_id=location.id, lot_id=lot.id, qty_change=Decimal("0"), weight_change_kg=Decimal("100"), unit_cost=Decimal("0"), amount=Decimal("0"), source_doc_type="客料入库", source_doc_id=1, logistics_waybill_no="SF123", logistics_freight_amount=Decimal("12.5"), logistics_photo_url="/uploads/demo.jpg", biz_time=self.now, remark="客户来料测试", created_at=self.now, updated_at=self.now, ) balance = StockBalance( id=7, item_id=item.id, warehouse_id=warehouse.id, location_id=location.id, qty_on_hand=Decimal("0"), weight_on_hand_kg=Decimal("100"), qty_available=Decimal("0"), weight_available_kg=Decimal("100"), qty_allocated=Decimal("0"), weight_allocated_kg=Decimal("0"), avg_unit_cost=Decimal("0"), updated_at=self.now, ) self.db.add_all([warehouse, location, item, source_lot, lot, txn, balance]) self.db.commit() return txn def test_collect_warehouse_operation_archive_context_uses_inventory_txn(self) -> None: txn = self._seed_customer_supplied_txn() context = collect_warehouse_operation_archive_context(self.db, txn.id) self.assertEqual(context.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) self.assertEqual(context.business_id, txn.id) self.assertEqual(context.title, "客料入库单归档") self.assertEqual(context.document_no, "客料入库-TXN-CUSTOMER-001") self.assertEqual(context.partner_label, "仓库") self.assertEqual(context.partner_name, "原材料库") self.assertEqual(context.status, "客料入库") self.assertEqual(context.address, "原材料库 / 原材料暂存位") self.assertEqual(context.total_amount, Decimal("0")) self.assertEqual(len(context.lines), 1) line = context.lines[0] self.assertEqual(line.item_code, "RM00001") self.assertEqual(line.item_name, "冷轧钢板") self.assertEqual(line.quantity, Decimal("100.000000")) self.assertEqual(line.delivered_or_received_quantity, Decimal("100.000000")) self.assertIn("库存流水号:TXN-CUSTOMER-001", line.remark or "") self.assertIn("库存批次号:YL0001", line.remark or "") self.assertIn("来源库存批次号:SRC-YL0001", line.remark or "") self.assertIn("库位:原材料暂存位", line.remark or "") self.assertIn("运单号:SF123", line.remark or "") self.assertIn("运费:12.5", line.remark or "") self.assertIn("辅助照片:已上传", line.remark or "") self.assertIn("客户来料测试", line.remark or "") def test_collect_archive_context_dispatches_warehouse_operation(self) -> None: txn = self._seed_customer_supplied_txn() context = collect_archive_context(self.db, DOCUMENT_TYPE_WAREHOUSE_OPERATION, txn.id) self.assertEqual(context.document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) self.assertEqual(context.business_id, txn.id) def test_unknown_english_txn_type_is_not_exposed_in_context(self) -> None: txn = self._seed_customer_supplied_txn() txn.txn_type = "unknown_code" self.db.commit() context = collect_warehouse_operation_archive_context(self.db, txn.id) self.assertEqual(context.status, "未识别仓库操作") self.assertEqual(context.title, "未识别仓库操作单归档") self.assertEqual(context.document_no, "未识别仓库操作-TXN-CUSTOMER-001") self.assertNotIn("unknown_code", context.remark) self.assertNotIn("unknown_code", context.lines[0].remark or "") def test_generate_document_archive_creates_pdf_for_warehouse_operation(self) -> None: txn = self._seed_customer_supplied_txn() with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): result = generate_document_archive(self.db, DOCUMENT_TYPE_WAREHOUSE_OPERATION, txn.id) archive = self.db.scalar( select(DocumentArchive).where( DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, DocumentArchive.business_id == txn.id, ) ) self.assertIsNotNone(archive) self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) self.assertEqual(archive.status, ARCHIVE_STATUS_READY) self.assertTrue(archive.file_name.endswith(".pdf")) with open(archive.file_path, "rb") as pdf_file: self.assertEqual(pdf_file.read(4), b"%PDF") def test_customer_supplied_inbound_save_generates_warehouse_archive(self) -> None: self._seed_customer_supplied_txn() payload = WarehouseInboundCreate( biz_type="CUSTOMER_SUPPLIED", item_id=3, warehouse_id=1, location_id=2, inbound_weight_kg=25, inbound_qty=0, unit_cost=0, provider_name="测试客户", waybill_no="SF999", freight_amount=Decimal("8.5"), remark="保存时生成仓库出入库单", ) context = SimpleNamespace(user=SimpleNamespace(id=99)) with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): result = create_warehouse_inbound(payload=payload, context=context, db=self.db) self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) self.assertIsNotNone(result.archive_business_id) archive = self.db.scalar( select(DocumentArchive).where( DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, DocumentArchive.business_id == result.archive_business_id, ) ) self.assertIsNotNone(archive) self.assertTrue(archive.file_name.endswith(".pdf")) def test_outsourcing_outbound_save_generates_warehouse_archive(self) -> None: self._seed_customer_supplied_txn() payload = WarehouseOutboundCreate( biz_type="OUTSOURCING_OUT", item_id=3, warehouse_id=1, location_id=2, outbound_weight_kg=10, outsourcing_party_name="测试委外厂", waybill_no="SF-WW-001", freight_amount=Decimal("9.9"), remark="委外出库保存归档", ) context = SimpleNamespace(user=SimpleNamespace(id=99)) with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir): result = create_warehouse_outbound(payload=payload, context=context, db=self.db) self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY) self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION) self.assertIsNotNone(result.archive_business_id) archive = self.db.scalar( select(DocumentArchive).where( DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION, DocumentArchive.business_id == result.archive_business_id, ) ) self.assertIsNotNone(archive) self.assertTrue(archive.file_name.endswith(".pdf")) if __name__ == "__main__": unittest.main()