Initial commit

This commit is contained in:
汤学会
2026-06-12 16:00:56 +08:00
commit c927886888
337 changed files with 160396 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,273 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from decimal import Decimal
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.inventory import create_warehouse_outbound # noqa: E402
from app.api.routes.purchase import create_purchase_order, create_purchase_receipt, lock_purchase_order # noqa: E402
from app.api.routes.quality import inspect_receipt_item # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item, Material, StockBalance, Unit, Warehouse # noqa: E402
from app.models.operations import InventoryTxn, PurchaseOrderItem, PurchaseReceiptItem, StockLot, Supplier # noqa: E402
from app.models.org import Department, Employee, Permission, User # noqa: E402
from app.schemas.operations import ( # noqa: E402
PurchaseOrderCreate,
PurchaseOrderItemCreate,
PurchaseReceiptCreate,
PurchaseReceiptItemCreate,
QualityInspectionAction,
WarehouseOutboundCreate,
)
from app.services.auth import AuthContext # noqa: E402
from app.services.operations import ( # noqa: E402
get_inventory_txn_ledger_query,
get_inventory_txn_query,
get_stock_balances_query,
get_stock_lots_query,
)
class AuxiliaryQuantityFlowTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed_master_data()
def tearDown(self) -> None:
self.db.close()
def _seed_master_data(self) -> None:
self.department = Department(id=1, dept_code="D-AUX", dept_name="仓库部", dept_type="WAREHOUSE", status="ACTIVE")
self.employee = Employee(id=1, employee_code="EMP-AUX-001", employee_name="辅料仓管", dept_id=1, status="ACTIVE")
self.user = User(
id=1,
username="aux_tester",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="辅料测试员",
is_super_admin=1,
status="ACTIVE",
)
self.purchase_receipt_permission = Permission(
id=1,
permission_code="MENU_PURCHASE_RECEIPT",
permission_name="到货入库",
module_code="PURCHASE",
action_code="MENU",
status="ACTIVE",
created_at=datetime.now(),
updated_at=datetime.now(),
)
self.unit = Unit(id=1, unit_code="PCS_BOX", unit_name="个", precision_digits=0)
self.aux_item = Item(
id=1,
item_code="辅料00001",
item_name="包装薄膜",
item_type="RAW_MATERIAL",
specification="500*300",
unit_weight_kg=Decimal("0"),
sales_unit_id=1,
stock_unit_id=1,
purchase_unit_id=1,
status="ACTIVE",
)
self.material = Material(
item_id=1,
default_supplier_id=1,
purchase_calc_mode="BY_QTY",
min_purchase_qty=Decimal("0"),
purchase_multiple_qty=Decimal("0"),
purchase_multiple_weight_kg=Decimal("0"),
lead_time_days=0,
)
self.supplier = Supplier(
id=1,
supplier_code="SUP-AUX-001",
supplier_name="辅料供应商",
short_name="辅料供应商",
lead_time_days=0,
default_tax_rate=Decimal("0"),
status="ACTIVE",
)
self.warehouse = Warehouse(id=1, warehouse_code="WH-AUX", warehouse_name="辅料库", warehouse_type="AUX", status="ACTIVE")
self.db.add_all([
self.department,
self.employee,
self.user,
self.purchase_receipt_permission,
self.unit,
self.aux_item,
self.material,
self.supplier,
self.warehouse,
])
self.db.commit()
def _auth_context(self) -> AuthContext:
return AuthContext(
user=self.user,
employee=self.employee,
department=self.department,
role_codes=["ADMIN"],
role_names=["管理员"],
permission_codes=[],
)
def test_auxiliary_purchase_receipt_quality_and_production_out_use_quantity(self) -> None:
order = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="AUX",
items=[
PurchaseOrderItemCreate(
material_item_id=self.aux_item.id,
order_qty=Decimal("10"),
unit_price=Decimal("2.5"),
)
],
),
db=self.db,
)
po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
self.assertEqual(float(po_item.order_qty), 10)
self.assertEqual(float(po_item.order_weight_kg), 0)
self.assertEqual(float(po_item.line_amount), 25)
self.assertEqual(float(order.total_amount), 25)
self.assertEqual(float(order.total_order_qty), 10)
self.assertEqual(float(order.total_order_weight_kg), 0)
lock_purchase_order(order.purchase_order_id, db=self.db)
create_purchase_receipt(
PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.aux_item.id,
received_qty=Decimal("6"),
unit_cost=Decimal("2.5"),
)
],
),
context=self._auth_context(),
db=self.db,
)
receipt_item = self.db.scalar(select(PurchaseReceiptItem))
lot = self.db.scalar(select(StockLot))
balance = self.db.scalar(select(StockBalance))
inbound_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PURCHASE_IN"))
self.assertEqual(float(receipt_item.received_qty), 6)
self.assertEqual(float(receipt_item.received_weight_kg), 0)
self.assertEqual(float(lot.inbound_qty), 6)
self.assertEqual(float(lot.remaining_qty), 6)
self.assertEqual(float(lot.locked_qty), 6)
self.assertEqual(float(lot.inbound_weight_kg), 0)
self.assertEqual(float(balance.qty_on_hand), 6)
self.assertEqual(float(balance.qty_available), 0)
self.assertEqual(float(balance.weight_on_hand_kg), 0)
self.assertEqual(float(inbound_txn.qty_change), 6)
self.assertEqual(float(inbound_txn.weight_change_kg), 0)
self.assertEqual(float(inbound_txn.amount), 15)
inspect_receipt_item(
receipt_item.id,
QualityInspectionAction(result="PASS", remark="合格"),
context=self._auth_context(),
db=self.db,
)
self.db.refresh(po_item)
self.db.refresh(receipt_item)
self.db.refresh(lot)
self.db.refresh(balance)
self.assertEqual(float(receipt_item.accepted_qty), 6)
self.assertEqual(float(receipt_item.accepted_weight_kg), 0)
self.assertEqual(float(po_item.received_qty), 6)
self.assertEqual(float(po_item.received_weight_kg), 0)
self.assertEqual(po_item.status, "PARTIAL")
self.assertEqual(float(lot.locked_qty), 0)
self.assertEqual(float(balance.qty_available), 6)
balance_rows = self.db.execute(get_stock_balances_query()).mappings().all()
aux_balance_row = next(row for row in balance_rows if row["warehouse_type"] == "AUX")
self.assertEqual(float(aux_balance_row["qty_on_hand"]), 6)
self.assertEqual(float(aux_balance_row["qty_available"]), 6)
self.assertEqual(aux_balance_row["specification"], "500*300")
self.assertEqual(aux_balance_row["stock_unit_name"], "个")
lot_rows = self.db.execute(get_stock_lots_query()).mappings().all()
aux_lot_row = next(row for row in lot_rows if row["warehouse_type"] == "AUX")
self.assertEqual(float(aux_lot_row["inbound_qty"]), 6)
self.assertEqual(float(aux_lot_row["remaining_qty"]), 6)
self.assertEqual(float(aux_lot_row["locked_qty"]), 0)
txn_rows = self.db.execute(get_inventory_txn_query()).mappings().all()
aux_purchase_txn_row = next(row for row in txn_rows if row["warehouse_type"] == "AUX" and row["txn_type"] == "PURCHASE_IN")
self.assertEqual(float(aux_purchase_txn_row["qty_change"]), 6)
ledger_rows = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="AUX")).mappings().all()
aux_purchase_ledger_row = next(row for row in ledger_rows if row["txn_type"] == "PURCHASE_IN")
self.assertEqual(float(aux_purchase_ledger_row["qty_change"]), 6)
create_warehouse_outbound(
WarehouseOutboundCreate(
biz_type="PRODUCTION_OUT",
item_id=self.aux_item.id,
warehouse_id=self.warehouse.id,
outbound_qty=Decimal("2"),
remark="包装一线使用",
),
context=self._auth_context(),
db=self.db,
)
self.db.refresh(lot)
self.db.refresh(balance)
outbound_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PRODUCTION_OUT"))
self.assertEqual(float(lot.remaining_qty), 4)
self.assertEqual(float(lot.remaining_weight_kg), 0)
self.assertEqual(float(balance.qty_on_hand), 4)
self.assertEqual(float(balance.qty_available), 4)
self.assertEqual(float(outbound_txn.qty_change), -2)
self.assertEqual(float(outbound_txn.weight_change_kg), 0)
self.assertEqual(float(outbound_txn.amount), 5)
self.assertIn("用途:包装一线使用", outbound_txn.remark)
lot_rows_after_outbound = self.db.execute(get_stock_lots_query()).mappings().all()
aux_lot_after_outbound = next(row for row in lot_rows_after_outbound if row["warehouse_type"] == "AUX")
self.assertEqual(float(aux_lot_after_outbound["remaining_qty"]), 4)
txn_rows_after_outbound = self.db.execute(get_inventory_txn_query()).mappings().all()
aux_outbound_txn_row = next(row for row in txn_rows_after_outbound if row["warehouse_type"] == "AUX" and row["txn_type"] == "PRODUCTION_OUT")
self.assertEqual(float(aux_outbound_txn_row["qty_change"]), -2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,153 @@
from __future__ import annotations
import unittest
from decimal import Decimal
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.api.routes.inventory import create_customer_supplied_inbound, create_warehouse_inbound # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402
from app.models.operations import StockLot # noqa: E402
from app.schemas.operations import CustomerSuppliedInboundCreate, WarehouseInboundCreate # noqa: E402
class User:
id = 1
class Context:
user = User()
class CustomerSuppliedInboundLotNoTest(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.material = Item(
id=1,
item_code="110501000185",
item_name="左右后纵梁后段毛坯",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
)
self.warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
)
self.db.add_all([self.material, self.warehouse])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _seed_existing_lot(self, lot_no: str) -> None:
self.db.add(
StockLot(
lot_no=lot_no,
lot_role="CUSTOMER_SUPPLIED_RAW",
item_id=self.material.id,
warehouse_id=self.warehouse.id,
source_doc_type="CUSTOMER_SUPPLIED_IN",
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",
)
)
self.db.commit()
def test_generic_customer_supplied_inbound_ignores_manual_lot_no_and_uses_global_raw_sequence(self) -> None:
self._seed_existing_lot("JH_左右后纵梁后段毛坯00185_0001")
first_row = create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="CUSTOMER_SUPPLIED",
item_id=self.material.id,
warehouse_id=self.warehouse.id,
inbound_weight_kg=50,
unit_cost=0,
lot_no="用户手填批次号",
source_material_sub_batch_no="用户手填来源批次",
provider_name="客户A",
waybill_no="YD-KL-001",
freight_amount=0,
),
context=Context(),
db=self.db,
)
second_row = create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="CUSTOMER_SUPPLIED",
item_id=self.material.id,
warehouse_id=self.warehouse.id,
inbound_weight_kg=25,
unit_cost=0,
lot_no="第二个用户手填批次号",
source_material_sub_batch_no="第二个用户手填来源批次",
provider_name="客户A",
waybill_no="YD-KL-002",
freight_amount=0,
),
context=Context(),
db=self.db,
)
lot = self.db.scalar(select(StockLot).where(StockLot.id == first_row.lot_id))
second_lot = self.db.scalar(select(StockLot).where(StockLot.id == second_row.lot_id))
balance = self.db.scalar(
select(StockBalance).where(
StockBalance.item_id == self.material.id,
StockBalance.warehouse_id == self.warehouse.id,
)
)
self.assertEqual(lot.lot_no, "YL0001")
self.assertEqual(second_lot.lot_no, "YL0002")
self.assertIsNone(lot.source_material_sub_batch_no)
self.assertEqual(lot.source_material_summary, "客户A")
self.assertIsNotNone(balance)
self.assertEqual(balance.weight_on_hand_kg, Decimal("75.000000"))
def test_dedicated_customer_supplied_inbound_ignores_manual_lot_no_and_starts_from_one(self) -> None:
row = create_customer_supplied_inbound(
CustomerSuppliedInboundCreate(
material_item_id=self.material.id,
warehouse_id=self.warehouse.id,
provider_name="客户B",
inbound_weight_kg=80,
lot_no="旧接口手填批次号",
),
context=Context(),
db=self.db,
)
lot = self.db.scalar(select(StockLot).where(StockLot.id == row.lot_id))
self.assertEqual(lot.lot_no, "YL0001")
self.assertEqual(lot.source_material_summary, "客户B")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,76 @@
from __future__ import annotations
import unittest
from pydantic import ValidationError
from sqlalchemy import create_engine, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
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.schemas.document_archives import DocumentArchiveBatchDownloadRequest # noqa: E402
class DocumentArchiveModelTest(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 _archive(self, *, business_id: int = 1001, archive_version: int = 1) -> DocumentArchive:
return DocumentArchive(
document_type="销售订单",
business_id=business_id,
document_no="SO-20260611-0001",
archive_version=archive_version,
template_version="单据纸面V1",
file_format="PDF",
file_name="SO-20260611-0001.pdf",
file_path="/archives/sales/SO-20260611-0001.pdf",
file_hash="abc123",
status="已归档",
error_message=None,
created_by=1,
)
def test_document_archive_round_trips_chinese_business_values(self) -> None:
archive = self._archive()
self.db.add(archive)
self.db.commit()
saved = self.db.execute(select(DocumentArchive).where(DocumentArchive.id == archive.id)).scalar_one()
self.assertIsNotNone(saved.id)
self.assertEqual(saved.status, "已归档")
self.assertEqual(saved.template_version, "单据纸面V1")
self.assertEqual(saved.archive_version, 1)
def test_document_archive_rejects_duplicate_archive_version_format(self) -> None:
self.db.add(self._archive())
self.db.commit()
self.db.add(self._archive())
with self.assertRaises(IntegrityError):
self.db.commit()
def test_batch_download_request_requires_business_ids_when_omitted(self) -> None:
with self.assertRaises(ValidationError):
DocumentArchiveBatchDownloadRequest(document_type="销售订单")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,88 @@
from __future__ import annotations
import unittest
from tempfile import TemporaryDirectory
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import BigInteger, create_engine
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.document_archives import normalize_document_type_key, router # noqa: E402
from app.db.session import get_db # noqa: E402
from app.models.base import Base # noqa: E402
from app.services.auth import require_authenticated_user # noqa: E402
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
class DocumentArchiveRouteTest(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)
app = FastAPI()
app.include_router(router, prefix="/document-archives")
def override_get_db() -> Session:
db = self.SessionLocal()
try:
yield db
finally:
db.close()
def override_require_authenticated_user() -> dict[str, int]:
return {"user_id": 123}
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[require_authenticated_user] = override_require_authenticated_user
self.client = TestClient(app)
def test_normalize_document_type_key_supports_route_keys(self) -> None:
self.assertEqual(normalize_document_type_key("sales-order"), "销售订单")
self.assertEqual(normalize_document_type_key("purchase-order"), "采购订单")
self.assertEqual(normalize_document_type_key("quality-inspection"), "质量校验单")
self.assertEqual(normalize_document_type_key("production-material-out"), "生产领料出库单")
self.assertEqual(normalize_document_type_key("production-inbound-settlement"), "生产入库结算单")
self.assertEqual(normalize_document_type_key("warehouse-operation"), "仓库出入库单")
self.assertEqual(normalize_document_type_key("仓库出入库单"), "仓库出入库单")
def test_preview_missing_archive_returns_chinese_404(self) -> None:
response = self.client.get("/document-archives/sales-order/999/latest/preview")
self.assertEqual(response.status_code, 404)
self.assertIn("归档文件不存在", response.json()["detail"])
def test_batch_download_missing_archives_returns_chinese_404(self) -> None:
with TemporaryDirectory() as tmp_dir, patch("app.api.routes.document_archives.default_archive_root", return_value=tmp_dir):
response = self.client.post(
"/document-archives/batch-download",
json={"document_type": "sales-order", "business_ids": [999]},
)
self.assertEqual(response.status_code, 404)
self.assertIn("没有可下载的归档文件", response.json()["detail"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,777 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from reportlab.lib.pagesizes import A4
from reportlab.pdfbase import pdfmetrics
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
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, Warehouse # noqa: E402
from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier # noqa: E402
from app.models.org import Department, Employee, User # noqa: E402
from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402
from app.services.document_archives import ( # noqa: E402
ARCHIVE_STATUS_FAILED,
ARCHIVE_STATUS_READY,
ArchiveContext,
ArchiveLine,
DOCUMENT_TYPE_PURCHASE_RECEIPT,
DOCUMENT_TYPE_QUALITY_INSPECTION,
DOCUMENT_TYPE_SALES_ORDER,
DOCUMENT_TYPE_PURCHASE_ORDER,
FILE_FORMAT_PDF,
TEMPLATE_VERSION,
collect_purchase_order_archive_context,
collect_purchase_receipt_archive_context,
collect_quality_inspection_archive_context,
generate_document_archive,
get_latest_archive,
render_pdf_archive,
_archive_table_columns,
_draw_cell,
_draw_archive_footer,
)
@compiles(BigInteger, "sqlite")
def _compile_big_integer_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
class DocumentArchiveServiceTest(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_sales_order(self) -> SalesOrder:
now = datetime.now()
customer = Customer(
id=1,
customer_code="CUST-001",
customer_name="上海测试客户有限公司",
short_name="测试客户",
contact_name="张三",
contact_phone="13800000000",
address="上海市浦东新区测试路 100 号",
tax_no="91310000TEST",
credit_days=30,
status="启用",
created_at=now,
updated_at=now,
)
item = Item(
id=10,
item_code="FG-001",
item_name="精密锻件",
item_type="成品",
specification="A-100",
material_grade="42CrMo",
unit_weight_kg=1.25,
status="启用",
created_at=now,
updated_at=now,
)
order = SalesOrder(
id=100,
order_no="SO-20260611-0001",
customer_id=customer.id,
order_date=date(2026, 6, 11),
promised_date=date(2026, 6, 20),
sales_employee_id=None,
delivery_address="上海市浦东新区测试路 100 号",
tax_rate=0.13,
total_amount=1234.56,
status="已审核",
created_at=now,
updated_at=now,
)
order_item = SalesOrderItem(
id=1000,
sales_order_id=order.id,
line_no=1,
product_item_id=item.id,
customer_part_no="CPN-001",
order_qty=12,
delivered_qty=2,
unit_price=102.88,
line_amount=1234.56,
promised_date=date(2026, 6, 20),
status="未交付",
created_at=now,
updated_at=now,
)
self.db.add_all([customer, item, order, order_item])
self.db.commit()
return order
def _seed_archive_user(self, *, nickname: str | None = "张制单") -> User:
now = datetime.now()
department = Department(
id=900,
dept_code="D-ARCHIVE",
dept_name="归档测试部",
parent_id=None,
org_node_type="DEPARTMENT",
dept_type="部门",
manager_name=None,
manager_employee_id=None,
status="ACTIVE",
sort_no=0,
remark=None,
created_at=now,
updated_at=now,
)
employee = Employee(
id=901,
employee_code="EMP-ARCHIVE",
employee_name="归档操作员",
dept_id=department.id,
mobile="13811112222",
gender=None,
hire_date=None,
job_title=None,
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
user = User(
id=902,
username="archive_admin",
password_hash="hashed",
employee_id=employee.id,
dept_id=department.id,
nickname=nickname,
email=None,
is_super_admin=0,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([department, employee, user])
self.db.commit()
return user
def _seed_purchase_order(self, *, target_warehouse_type: str) -> PurchaseOrder:
now = datetime.now()
supplier = Supplier(
id=200,
supplier_code="SUP-001",
supplier_name="宁波测试供应商有限公司",
short_name="测试供应商",
contact_name="李四",
contact_phone="13900000000",
address="宁波市测试路 1 号",
lead_time_days=0,
default_tax_rate=0.13,
status="启用",
created_at=now,
updated_at=now,
)
item = Item(
id=210,
item_code="RM-001" if target_warehouse_type == "RAW" else "AUX-001",
item_name="圆钢" if target_warehouse_type == "RAW" else "包装辅料",
item_type="原材料" if target_warehouse_type == "RAW" else "辅料",
specification="D40",
material_grade="42CrMo",
unit_weight_kg=1,
status="启用",
created_at=now,
updated_at=now,
)
order = PurchaseOrder(
id=300,
po_no=f"PO-{target_warehouse_type}-20260611-0001",
supplier_id=supplier.id,
order_date=date(2026, 6, 11),
expected_date=date(2026, 6, 20),
warning_lead_days=0,
purchaser_employee_id=None,
target_warehouse_type=target_warehouse_type,
tax_rate=0.13,
total_amount=8641.98,
status="已审核",
remark="采购归档测试",
created_at=now,
updated_at=now,
)
order_item = PurchaseOrderItem(
id=3000,
purchase_order_id=order.id,
line_no=1,
material_item_id=item.id,
source_demand_id=None,
order_qty=0 if target_warehouse_type == "RAW" else 12,
order_weight_kg=1234.56 if target_warehouse_type == "RAW" else 0,
received_qty=0 if target_warehouse_type == "RAW" else 3,
received_weight_kg=234.5 if target_warehouse_type == "RAW" else 0,
unit_price=7,
line_amount=8641.98,
status="未收货",
remark="测试明细",
created_at=now,
updated_at=now,
)
self.db.add_all([supplier, item, order, order_item])
self.db.commit()
return order
def _seed_purchase_receipt(self, *, target_warehouse_type: str = "RAW") -> PurchaseReceipt:
now = datetime.now()
supplier = Supplier(
id=400,
supplier_code="SUP-RCPT",
supplier_name="宁波到货供应商有限公司",
short_name="到货供应商",
contact_name="王五",
contact_phone="13700000000",
address="宁波市到货路 2 号",
lead_time_days=0,
default_tax_rate=0.13,
status="启用",
created_at=now,
updated_at=now,
)
item = Item(
id=410,
item_code="RM-RCPT-001" if target_warehouse_type == "RAW" else "AUX-RCPT-001",
item_name="到货圆钢" if target_warehouse_type == "RAW" else "到货辅料",
item_type="原材料" if target_warehouse_type == "RAW" else "辅料",
specification="D50",
material_grade="42CrMo",
unit_weight_kg=1,
status="启用",
created_at=now,
updated_at=now,
)
warehouse = Warehouse(
id=420,
warehouse_code="WH-RCPT",
warehouse_name="原材料库" if target_warehouse_type == "RAW" else "辅料库",
warehouse_type=target_warehouse_type,
manager_employee_id=None,
status="启用",
created_at=now,
updated_at=now,
)
order = PurchaseOrder(
id=430,
po_no="采购2026-00088",
supplier_id=supplier.id,
order_date=date(2026, 6, 11),
expected_date=date(2026, 6, 20),
warning_lead_days=0,
purchaser_employee_id=None,
target_warehouse_type=target_warehouse_type,
tax_rate=0.13,
total_amount=3500,
status="LOCKED",
remark="到货归档采购单",
created_at=now,
updated_at=now,
)
order_item = PurchaseOrderItem(
id=440,
purchase_order_id=order.id,
line_no=1,
material_item_id=item.id,
source_demand_id=None,
order_qty=0 if target_warehouse_type == "RAW" else 10,
order_weight_kg=500 if target_warehouse_type == "RAW" else 0,
received_qty=0 if target_warehouse_type == "RAW" else 4,
received_weight_kg=125.5 if target_warehouse_type == "RAW" else 0,
unit_price=7,
line_amount=3500,
status="PARTIAL_RECEIVED",
remark="采购明细备注",
created_at=now,
updated_at=now,
)
receipt = PurchaseReceipt(
id=450,
receipt_no="入库2026-00009",
purchase_order_id=order.id,
warehouse_id=warehouse.id,
receipt_date=datetime(2026, 6, 12, 9, 30, 0),
receiver_employee_id=None,
supplier_delivery_no=None,
logistics_waybill_no="YD-20260612",
logistics_freight_amount=88.8,
logistics_photo_url="/uploads/receipt.jpg",
status="PENDING_QC",
remark="到货入库归档测试",
created_at=now,
updated_at=now,
)
receipt_item = PurchaseReceiptItem(
id=460,
receipt_id=receipt.id,
line_no=1,
purchase_order_item_id=order_item.id,
material_item_id=item.id,
location_id=None,
lot_no="JH到货圆钢00001_0001",
merge_to_lot_id=None,
received_qty=0 if target_warehouse_type == "RAW" else 4,
received_weight_kg=125.5 if target_warehouse_type == "RAW" else 0,
accepted_qty=0,
accepted_weight_kg=0,
unit_cost=7.2,
status="PENDING_QC",
remark="入库明细备注",
created_at=now,
updated_at=now,
)
self.db.add_all([supplier, item, warehouse, order, order_item, receipt, receipt_item])
self.db.commit()
return receipt
def test_generate_sales_order_pdf_archive_creates_latest_ready_archive(self) -> None:
order = self._seed_sales_order()
with TemporaryDirectory() as tempdir:
result = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
archive_root=tempdir,
)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(result.archive_version, 1)
self.assertEqual(result.document_no, order.order_no)
latest = get_latest_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id)
self.assertIsNotNone(latest)
assert latest is not None
self.assertEqual(latest.document_no, order.order_no)
self.assertEqual(latest.archive_version, 1)
self.assertEqual(latest.status, ARCHIVE_STATUS_READY)
pdf_path = Path(latest.file_path)
self.assertTrue(pdf_path.exists())
self.assertEqual(pdf_path.read_bytes()[:4], b"%PDF")
def test_generate_archive_fills_preparer_and_operator_from_current_user(self) -> None:
order = self._seed_sales_order()
user = self._seed_archive_user(nickname="张制单")
captured_contexts: list[ArchiveContext] = []
def capture_render(context: ArchiveContext, output_path: str | Path) -> None:
captured_contexts.append(context)
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
Path(output_path).write_bytes(b"%PDF archive test")
with TemporaryDirectory() as tempdir, patch(
"app.services.document_archives.render_pdf_archive",
side_effect=capture_render,
):
result = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
created_by=user.id,
archive_root=tempdir,
)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(len(captured_contexts), 1)
self.assertEqual(captured_contexts[0].prepared_by, "张制单")
self.assertEqual(captured_contexts[0].operator_name, "张制单")
def test_draw_archive_footer_renders_preparer_and_operator_names(self) -> None:
class FakePdf:
def __init__(self) -> None:
self.drawn_texts: list[str] = []
def setFont(self, *args, **kwargs) -> None:
_ = args, kwargs
def drawRightString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
def drawString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
context = ArchiveContext(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=100,
document_no="SO-20260611-0001",
title="销售订单归档",
partner_label="客户",
partner_name="上海测试客户有限公司",
document_date=date(2026, 6, 11),
due_date_label="承诺日期",
due_date=date(2026, 6, 20),
status="已审核",
tax_rate=0.13,
total_amount=2500,
prepared_by="张制单",
operator_name="张经办",
)
fake_pdf = FakePdf()
_draw_archive_footer(fake_pdf, context, 120, "Helvetica", "Helvetica-Bold")
self.assertIn("制单: 张制单", fake_pdf.drawn_texts)
self.assertIn("经办人: 张经办", fake_pdf.drawn_texts)
self.assertNotIn("制单: __________", fake_pdf.drawn_texts)
def test_collect_raw_purchase_order_archive_context_uses_weight_as_quantity(self) -> None:
order = self._seed_purchase_order(target_warehouse_type="RAW")
context = collect_purchase_order_archive_context(self.db, order.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_PURCHASE_ORDER)
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(float(line.quantity or 0), 1234.56)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 234.5)
self.assertNotEqual(float(line.quantity or 0), 0)
self.assertIn("kg", line.remark or "")
def test_collect_aux_purchase_order_archive_context_uses_order_quantity(self) -> None:
order = self._seed_purchase_order(target_warehouse_type="AUX")
context = collect_purchase_order_archive_context(self.db, order.id)
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(float(line.quantity or 0), 12)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 3)
self.assertEqual(line.remark, "测试明细")
def test_collect_purchase_receipt_archive_context_uses_receipt_items(self) -> None:
receipt = self._seed_purchase_receipt(target_warehouse_type="RAW")
context = collect_purchase_receipt_archive_context(self.db, receipt.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_PURCHASE_RECEIPT)
self.assertEqual(context.business_id, receipt.id)
self.assertEqual(context.document_no, "入库2026-00009")
self.assertEqual(context.title, "到货入库单归档")
self.assertEqual(context.partner_label, "供应商")
self.assertEqual(context.partner_name, "宁波到货供应商有限公司")
self.assertEqual(context.due_date_label, "采购订单")
self.assertEqual(context.due_date, "采购2026-00088")
self.assertIn("运单号:YD-20260612", context.remark or "")
self.assertIn("运费:88.8", context.remark or "")
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(line.item_code, "RM-RCPT-001")
self.assertEqual(line.item_name, "到货圆钢")
self.assertEqual(float(line.quantity or 0), 125.5)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 0)
self.assertEqual(float(line.unit_price or 0), 7.2)
self.assertEqual(context.status, "待质检")
self.assertEqual(line.remark, "批次:JH到货圆钢00001_0001;状态:待质检;入库明细备注")
def test_collect_quality_inspection_archive_context_uses_inspection_result(self) -> None:
receipt = self._seed_purchase_receipt(target_warehouse_type="RAW")
receipt_item = self.db.scalar(select(PurchaseReceiptItem).where(PurchaseReceiptItem.receipt_id == receipt.id))
self.assertIsNotNone(receipt_item)
assert receipt_item is not None
receipt.status = "PASSED"
receipt_item.status = "PASSED"
receipt_item.accepted_weight_kg = receipt_item.received_weight_kg
receipt_item.remark = "表面无划伤,尺寸符合要求"
lot = StockLot(
id=470,
lot_no=receipt_item.lot_no,
parent_lot_id=None,
lot_role="INBOUND_RAW",
material_sub_batch_no=None,
item_id=receipt_item.material_item_id,
warehouse_id=receipt.warehouse_id,
location_id=None,
source_doc_type="PURCHASE_RECEIPT",
source_doc_id=receipt.id,
source_line_id=receipt_item.id,
source_material_lot_id=None,
source_material_sub_batch_no=None,
source_material_summary=None,
inbound_qty=0,
inbound_weight_kg=receipt_item.received_weight_kg,
remaining_qty=0,
remaining_weight_kg=receipt_item.received_weight_kg,
locked_qty=0,
locked_weight_kg=0,
unit_cost=receipt_item.unit_cost,
production_date=None,
expire_date=None,
quality_status="PASS",
logistics_waybill_no=None,
logistics_freight_amount=None,
logistics_photo_url=None,
status="AVAILABLE",
remark="质检员王工",
created_at=datetime.now(),
updated_at=datetime.now(),
)
self.db.add_all([receipt, receipt_item, lot])
self.db.commit()
context = collect_quality_inspection_archive_context(self.db, receipt_item.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_QUALITY_INSPECTION)
self.assertEqual(context.business_id, receipt_item.id)
self.assertEqual(context.document_no, f"质检-{receipt.receipt_no}-{receipt_item.line_no}")
self.assertEqual(context.title, "质量校验单归档")
self.assertEqual(context.partner_label, "供应商")
self.assertEqual(context.partner_name, "宁波到货供应商有限公司")
self.assertEqual(context.due_date_label, "入库单")
self.assertEqual(context.due_date, receipt.receipt_no)
self.assertEqual(context.status, "已放行")
self.assertIn("质检人员:质检员王工", context.remark or "")
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(line.item_code, "RM-RCPT-001")
self.assertEqual(line.item_name, "到货圆钢")
self.assertEqual(float(line.quantity or 0), 125.5)
self.assertEqual(float(line.delivered_or_received_quantity or 0), 125.5)
self.assertEqual(line.remark, "库存批次号:JH到货圆钢00001_0001;质检结论:已放行;表面无划伤,尺寸符合要求")
def test_generate_purchase_receipt_pdf_archive_creates_latest_ready_archive(self) -> None:
receipt = self._seed_purchase_receipt(target_warehouse_type="RAW")
with TemporaryDirectory() as tempdir:
result = generate_document_archive(
self.db,
DOCUMENT_TYPE_PURCHASE_RECEIPT,
receipt.id,
archive_root=tempdir,
)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(result.document_no, "入库2026-00009")
latest = get_latest_archive(self.db, DOCUMENT_TYPE_PURCHASE_RECEIPT, receipt.id)
self.assertIsNotNone(latest)
assert latest is not None
self.assertEqual(latest.status, ARCHIVE_STATUS_READY)
self.assertTrue(Path(latest.file_path).exists())
def test_generate_sales_order_pdf_archive_increments_archive_version(self) -> None:
order = self._seed_sales_order()
with TemporaryDirectory() as tempdir:
first = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
archive_root=tempdir,
)
second = generate_document_archive(
self.db,
DOCUMENT_TYPE_SALES_ORDER,
order.id,
archive_root=tempdir,
)
self.assertEqual(first.archive_version, 1)
self.assertEqual(second.archive_version, 2)
archives = self.db.execute(
select(DocumentArchive)
.where(
DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER,
DocumentArchive.business_id == order.id,
)
.order_by(DocumentArchive.archive_version)
).scalars().all()
self.assertEqual(len(archives), 2)
self.assertEqual([archive.archive_version for archive in archives], [1, 2])
self.assertTrue(all(Path(archive.file_path).exists() for archive in archives))
def test_generate_archive_records_failed_row_when_version_lookup_fails(self) -> None:
order = self._seed_sales_order()
with patch(
"app.services.document_archives.next_archive_version",
side_effect=RuntimeError("version lookup failed"),
):
result = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_FAILED)
self.assertEqual(result.archive_version, 1)
self.assertIn("version lookup failed", result.archive_error_message or "")
failed_archives = self.db.execute(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER,
DocumentArchive.business_id == order.id,
DocumentArchive.status == ARCHIVE_STATUS_FAILED,
)
).scalars().all()
self.assertEqual(len(failed_archives), 1)
self.assertEqual(failed_archives[0].archive_version, 1)
self.assertEqual(failed_archives[0].document_no, f"{DOCUMENT_TYPE_SALES_ORDER}-{order.id}")
def test_render_pdf_archive_includes_more_than_eighteen_lines(self) -> None:
context = ArchiveContext(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=100,
document_no="SO-20260611-0001",
title="销售订单归档",
partner_label="客户",
partner_name="上海测试客户有限公司",
document_date=date(2026, 6, 11),
due_date_label="承诺日期",
due_date=date(2026, 6, 20),
status="已审核",
tax_rate=0.13,
total_amount=2500,
lines=[
ArchiveLine(
line_no=line_no,
item_code=f"FG-{line_no:03d}",
item_name=f"精密锻件{line_no}",
specification="A-100",
quantity=line_no,
delivered_or_received_quantity=0,
unit_price=100,
line_amount=100 * line_no,
promised_or_expected_date=date(2026, 6, 20),
)
for line_no in range(1, 26)
],
)
captured_line_numbers: list[int] = []
def capture_draw_cell(pdf, x, y, width, height, text, font_name, size=8, align="left"):
_ = pdf, x, y, width, height, font_name, size, align
if str(text).isdigit():
captured_line_numbers.append(int(text))
with TemporaryDirectory() as tempdir, patch("app.services.document_archives._draw_cell", side_effect=capture_draw_cell):
render_pdf_archive(context, Path(tempdir) / "archive.pdf")
for line_no in range(1, 26):
self.assertIn(line_no, captured_line_numbers)
def test_archive_table_columns_fit_inside_a4_page_frame(self) -> None:
table_x = 42
right_margin = 42
total_width = sum(width for _, width, _ in _archive_table_columns())
self.assertLessEqual(table_x + total_width, A4[0] - right_margin)
def test_draw_cell_wraps_long_text_without_ellipsis(self) -> None:
class FakePdf:
def __init__(self) -> None:
self.font_name = "Helvetica"
self.size = 8
self.drawn_texts: list[str] = []
def rect(self, *args, **kwargs) -> None:
_ = args, kwargs
def setFont(self, font_name, size) -> None:
self.font_name = font_name
self.size = size
def drawString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
def drawRightString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
def drawCentredString(self, x, y, text) -> None:
_ = x, y
self.drawn_texts.append(text)
fake_pdf = FakePdf()
_draw_cell(
fake_pdf,
42,
600,
62,
72,
"批次:JH_不锈钢板用于测试00101_0001;状态:待质检;入库明细备注",
"Helvetica",
8,
"left",
)
rendered_text = "".join(fake_pdf.drawn_texts)
self.assertNotIn("...", rendered_text)
self.assertEqual(rendered_text, "批次:JH_不锈钢板用于测试00101_0001;状态:待质检;入库明细备注")
for line in fake_pdf.drawn_texts:
rendered_width = pdfmetrics.stringWidth(line, fake_pdf.font_name, fake_pdf.size)
self.assertLessEqual(rendered_width, 54)
def test_generate_archive_failed_row_uses_free_version_after_collision(self) -> None:
order = self._seed_sales_order()
def insert_colliding_archive_and_fail(context, output_path):
_ = context, output_path
self.db.add(
DocumentArchive(
document_type=DOCUMENT_TYPE_SALES_ORDER,
business_id=order.id,
document_no=order.order_no,
archive_version=1,
template_version=TEMPLATE_VERSION,
file_format=FILE_FORMAT_PDF,
file_name="existing.pdf",
file_path="/tmp/existing.pdf",
file_hash="existing",
status=ARCHIVE_STATUS_READY,
error_message=None,
created_by=None,
)
)
self.db.commit()
raise RuntimeError("render failed after version collision")
with patch("app.services.document_archives.render_pdf_archive", side_effect=insert_colliding_archive_and_fail):
result = generate_document_archive(self.db, DOCUMENT_TYPE_SALES_ORDER, order.id)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_FAILED)
self.assertEqual(result.archive_version, 2)
failed_archive = self.db.execute(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_SALES_ORDER,
DocumentArchive.business_id == order.id,
DocumentArchive.status == ARCHIVE_STATUS_FAILED,
)
).scalar_one()
self.assertEqual(failed_archive.archive_version, 2)
self.assertIn("render failed after version collision", failed_archive.error_message or "")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,330 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from decimal import Decimal
from io import BytesIO
from openpyxl import load_workbook
from sqlalchemy import BigInteger, create_engine
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.api.routes.inventory import export_inventory_transaction_ledger # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, Warehouse # noqa: E402
from app.models.operations import InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot, WarehouseLocation # noqa: E402
from app.api.routes.inventory import list_inventory_transaction_ledger # noqa: E402
from app.services.operations import get_inventory_txn_ledger_query, get_inventory_txn_query # noqa: E402
class InventoryLedgerExportTest(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)
self._next_id = 100
self._seed_master_data()
def tearDown(self) -> None:
self.db.close()
def _id(self) -> int:
self._next_id += 1
return self._next_id
def _seed_master_data(self) -> None:
self.warehouse = Warehouse(
id=1,
warehouse_code="RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.location = WarehouseLocation(
id=2,
warehouse_id=self.warehouse.id,
location_code="RAW-A",
location_name="原料主库位",
is_default=1,
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.item = Item(
id=3,
item_code="RM-LEDGER-001",
item_name="测试冷轧钢",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.lot = StockLot(
id=4,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=self.item.id,
warehouse_id=self.warehouse.id,
location_id=self.location.id,
source_doc_type="期初入库",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("500"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("500"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("5.5"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([self.warehouse, self.location, self.item, self.lot])
self.db.commit()
def _seed_raw_inventory_txn(
self,
*,
txn_type: str = "CUSTOMER_SUPPLIED_IN",
source_doc_type: str = "客料入库",
source_doc_id: int = 1,
txn_no: str | None = None,
biz_time: datetime | None = None,
) -> InventoryTxn:
txn = InventoryTxn(
id=self._id(),
txn_no=txn_no or f"TXN-{self._next_id}",
txn_type=txn_type,
item_id=self.item.id,
warehouse_id=self.warehouse.id,
location_id=self.location.id,
lot_id=self.lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("100"),
unit_cost=Decimal("5.5"),
amount=Decimal("550"),
source_doc_type=source_doc_type,
source_doc_id=source_doc_id,
logistics_waybill_no="SF123",
logistics_freight_amount=Decimal("12.5"),
logistics_photo_url="/uploads/ledger.jpg",
biz_time=biz_time or self.now,
remark="流水导出测试备注",
created_at=self.now,
updated_at=self.now,
)
self.db.add(txn)
self.db.commit()
return txn
def test_inventory_ledger_returns_warehouse_archive_fields(self) -> None:
txn = self._seed_raw_inventory_txn(txn_no="TXN-ARCHIVE-001")
self.db.add(
DocumentArchive(
document_type="仓库出入库单",
business_id=txn.id,
document_no="客料入库-TXN-ARCHIVE-001",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="客料入库.pdf",
file_path="/tmp/客料入库.pdf",
file_hash="abc",
status="已归档",
)
)
self.db.commit()
row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one()
self.assertEqual(row["archive_status"], "已归档")
self.assertEqual(row["archive_business_id"], txn.id)
self.assertEqual(row["archive_document_type"], "仓库出入库单")
self.assertIsNone(row["archive_error_message"])
def test_inventory_detail_transactions_return_archive_fields(self) -> None:
txn = self._seed_raw_inventory_txn(txn_no="TXN-DETAIL-ARCHIVE")
self.db.add(
DocumentArchive(
document_type="仓库出入库单",
business_id=txn.id,
document_no="客料入库-TXN-DETAIL-ARCHIVE",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="客料入库.pdf",
file_path="/tmp/客料入库.pdf",
file_hash="detail",
status="已归档",
)
)
self.db.commit()
row = self.db.execute(get_inventory_txn_query()).mappings().one()
self.assertEqual(row["archive_status"], "已归档")
self.assertEqual(row["archive_business_id"], txn.id)
self.assertEqual(row["archive_document_type"], "仓库出入库单")
self.assertIsNone(row["archive_error_message"])
def test_inventory_ledger_points_production_out_to_material_out_archive(self) -> None:
self._seed_raw_inventory_txn(
txn_type="PRODUCTION_OUT",
source_doc_type="生产台账",
source_doc_id=88,
txn_no="TXN-PRODUCTION-OUT",
)
row = self.db.execute(get_inventory_txn_ledger_query(warehouse_type="RAW")).mappings().one()
self.assertEqual(row["archive_status"], "未生成")
self.assertEqual(row["archive_document_type"], "生产领料出库单")
self.assertEqual(row["archive_business_id"], 88)
def test_transaction_ledger_export_uses_date_filter_and_chinese_headers(self) -> None:
self._seed_raw_inventory_txn(
txn_no="TXN-IN-RANGE",
biz_time=datetime(2026, 6, 10, 8, 0, 0),
)
self._seed_raw_inventory_txn(
txn_no="TXN-OUT-RANGE",
biz_time=datetime(2026, 5, 1, 8, 0, 0),
)
response = export_inventory_transaction_ledger(
warehouse_type="RAW",
direction="IN",
start_date=date(2026, 6, 1),
end_date=date(2026, 6, 30),
db=self.db,
)
self.assertEqual(response.status_code, 200)
self.assertIn(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
response.media_type,
)
workbook = load_workbook(BytesIO(response.body))
sheet = workbook.active
headers = [cell.value for cell in sheet[1]]
self.assertIn("业务时间", headers)
self.assertIn("PDF归档状态", headers)
rows = [[cell.value for cell in row] for row in sheet.iter_rows(min_row=2)]
flattened = "\n".join(str(value) for row in rows for value in row)
self.assertIn("TXN-IN-RANGE", flattened)
self.assertNotIn("TXN-OUT-RANGE", flattened)
def test_production_inbound_rows_share_settlement_archive_business_id(self) -> None:
finished_txn_id = 301
surplus_txn = self._seed_raw_inventory_txn(
txn_type="PRODUCTION_SURPLUS_IN",
source_doc_type="生产台账",
source_doc_id=66,
txn_no="TXN-SURPLUS-SHARED",
biz_time=datetime(2026, 6, 10, 10, 0, 0),
)
ledger = ProductionBatchLedger(
id=66,
material_lot_id=self.lot.id,
material_lot_no=self.lot.lot_no,
material_item_id=self.item.id,
product_item_id=self.item.id,
status="在生产",
total_issued_weight_kg=Decimal("100"),
outside_weight_kg=Decimal("80"),
created_at=self.now,
updated_at=self.now,
)
batch_no = "生产结算20260610100000000001"
finished_ledger_txn = ProductionBatchLedgerTxn(
id=401,
production_ledger_id=ledger.id,
txn_type="成品入库",
qty_delta=Decimal("20"),
weight_delta_kg=Decimal("-12"),
outside_weight_after_kg=Decimal("88"),
source_doc_type="库存流水",
source_doc_id=finished_txn_id,
biz_time=datetime(2026, 6, 10, 10, 0, 0),
remark=f"成品入库联动生产台账;单据批次号:{batch_no}",
created_at=self.now,
updated_at=self.now,
)
surplus_ledger_txn = ProductionBatchLedgerTxn(
id=402,
production_ledger_id=ledger.id,
txn_type="生产余料入库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("8"),
outside_weight_after_kg=Decimal("80"),
source_doc_type="库存流水",
source_doc_id=surplus_txn.id,
biz_time=datetime(2026, 6, 10, 10, 0, 0),
remark=f"生产余料入库联动生产台账;单据批次号:{batch_no}",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([
ledger,
finished_ledger_txn,
surplus_ledger_txn,
DocumentArchive(
document_type="生产入库结算单",
business_id=finished_txn_id,
document_no="生产入库结算-TXN-FINISHED-SHARED",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="生产入库结算.pdf",
file_path="/tmp/生产入库结算.pdf",
file_hash="abc",
status="已归档",
),
])
self.db.commit()
result = list_inventory_transaction_ledger(
warehouse_type="RAW",
direction="IN",
page=1,
page_size=10,
db=self.db,
)
row = next(item for item in result.items if item.inventory_txn_id == surplus_txn.id)
self.assertEqual(row.archive_document_type, "生产入库结算单")
self.assertEqual(row.archive_business_id, finished_txn_id)
self.assertEqual(row.archive_status, "已归档")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,125 @@
from __future__ import annotations
import unittest
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine
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.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.master_data import Item, Warehouse # noqa: E402
from app.models.operations import StockLot # noqa: E402
from app.services.system_config import RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE, upsert_system_config # noqa: E402
from app.services.operations import build_inventory_lot_no # noqa: E402
class InventoryLotNumberingTest(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.material = Item(
id=1,
item_code="110501000185",
item_name="左右后纵梁后段毛坯",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
)
self.other_material = Item(
id=2,
item_code="110501000186",
item_name="其他原材料",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
)
self.warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
)
self.db.add_all([self.material, self.other_material, self.warehouse])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_inventory_lot_no_starts_from_global_prefix_sequence(self) -> None:
self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0001")
def test_inventory_lot_no_increments_from_existing_lot_for_different_material(self) -> None:
self.db.add(
StockLot(
lot_no="YL0001",
lot_role="INBOUND_RAW",
item_id=self.other_material.id,
warehouse_id=self.warehouse.id,
source_doc_type="TEST",
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("5"),
quality_status="PASS",
status="AVAILABLE",
)
)
self.db.commit()
self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL0002")
def test_inventory_lot_no_uses_natural_width_after_four_digits(self) -> None:
self.db.add(
StockLot(
lot_no="YL9999",
lot_role="INBOUND_RAW",
item_id=self.material.id,
warehouse_id=self.warehouse.id,
source_doc_type="TEST",
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("5"),
quality_status="PASS",
status="AVAILABLE",
)
)
self.db.commit()
self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "YL10000")
def test_inventory_lot_no_uses_configured_prefix(self) -> None:
upsert_system_config(
self.db,
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
config_name="原材料库存批次号前缀",
config_value="rm",
)
self.db.commit()
self.assertEqual(build_inventory_lot_no(self.db, self.material.id), "RM0001")
@@ -0,0 +1,96 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine
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
from app.api.routes import master_data # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item # noqa: E402
from app.schemas.database import MaterialCreate # noqa: E402
class MaterialCodeGenerationTest(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 _payload(self) -> MaterialCreate:
return MaterialCreate(
item_name="冷轧钢板",
specification="1.0*1000",
material_grade="SPCC",
unit_weight_kg=1,
safety_stock_weight_kg=0,
scrap_sale_price=0,
default_supplier_id=None,
purchase_calc_mode="BY_WEIGHT",
min_purchase_qty=0,
purchase_multiple_qty=0,
purchase_multiple_weight_kg=0,
lead_time_days=0,
quality_rule=None,
status="ACTIVE",
)
def _add_item(self, code: str, item_type: str = "RAW_MATERIAL") -> None:
now = datetime.now(UTC)
self.db.add(
Item(
item_code=code,
item_name=f"测试物料{code}",
item_type=item_type,
specification="",
material_grade="",
unit_weight_kg=Decimal("0"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
)
self.db.commit()
def test_material_code_starts_from_named_five_digit_sequence(self) -> None:
self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00001")
def test_material_code_increments_existing_named_material_codes(self) -> None:
self._add_item("原材料00001")
self._add_item("RM-SPCC-001")
self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00002")
def test_material_code_ignores_legacy_numeric_codes(self) -> None:
self._add_item("00001")
self._add_item("110801001050")
self._add_item("110501000185")
self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00001")
def test_material_code_skips_codes_used_by_other_item_types(self) -> None:
self._add_item("原材料00001")
self._add_item("原材料00002", item_type="FINISHED_GOOD")
self.assertEqual(master_data._build_material_code(self.db, self._payload()), "原材料00003")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,160 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from io import BytesIO
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes import master_data, sales # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item, Material # noqa: E402
from app.models.sales import Customer # noqa: E402
def _workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes:
workbook = Workbook()
worksheet = workbook.active
worksheet.append(headers)
for row in rows:
worksheet.append(row)
output = BytesIO()
workbook.save(output)
return output.getvalue()
class MaterialCustomerExcelImportExportTest(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 test_material_import_upserts_by_code_and_exports_performance_requirement(self) -> None:
now = datetime.now(UTC)
existing = Item(
item_code="原材料00001",
item_name="旧冷轧钢板",
item_type="RAW_MATERIAL",
specification="旧规格",
material_grade="旧材质",
unit_weight_kg=Decimal("1"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(existing)
self.db.flush()
self.db.add(
Material(
item_id=existing.id,
default_supplier_id=None,
purchase_calc_mode="BY_WEIGHT",
min_purchase_qty=Decimal("0"),
purchase_multiple_qty=Decimal("0"),
purchase_multiple_weight_kg=Decimal("0"),
lead_time_days=0,
quality_rule="旧性能要求",
)
)
self.db.commit()
result = master_data._import_material_excel(
self.db,
_workbook_bytes(
master_data.MATERIAL_EXCEL_HEADERS,
[
["原材料00001", "冷轧钢板", "SPCC", "1.0*1000", 2.5, "表面无划伤", "启用"],
["", "铝合金", "6061", "2.0*500", 4.2, "阳极氧化", "启用"],
],
),
)
self.assertEqual(result["updated"], 1)
self.assertEqual(result["imported"], 1)
updated_item = self.db.scalar(select(Item).where(Item.item_code == "原材料00001"))
self.assertEqual(updated_item.item_name, "冷轧钢板")
self.assertEqual(updated_item.material_grade, "SPCC")
self.assertEqual(float(updated_item.scrap_sale_price), 2.5)
updated_material = self.db.scalar(select(Material).where(Material.item_id == updated_item.id))
self.assertEqual(updated_material.quality_rule, "表面无划伤")
workbook = master_data._build_material_workbook(
self.db.execute(master_data._material_export_stmt()).mappings().all()
)
exported = BytesIO()
workbook.save(exported)
rows = list(load_workbook(BytesIO(exported.getvalue()), read_only=True, data_only=True).active.iter_rows(values_only=True))
self.assertEqual(list(rows[0]), master_data.MATERIAL_EXCEL_HEADERS)
self.assertIn(("原材料00001", "冷轧钢板", "SPCC", "1.0*1000", 2.5, "表面无划伤", "启用"), rows[1:])
def test_customer_import_upserts_by_name_and_exports_three_contacts(self) -> None:
now = datetime.now(UTC)
self.db.add(
Customer(
customer_code="CUS-EXCEL-001",
customer_name="老客户",
short_name="老客户",
contact_name="旧联系人",
contact_phone="13000000000",
address="旧地址",
credit_days=15,
status="ACTIVE",
created_at=now,
updated_at=now,
)
)
self.db.commit()
result = sales._import_customer_excel(
self.db,
_workbook_bytes(
sales.CUSTOMER_EXCEL_HEADERS,
[
["老客户", "老客", "张三", "13800000001", "李四", "13800000002", "王五", "13800000003", "新地址", 30],
["新客户", "新客", "赵六", "13900000001", "", "", "", "", "新客户地址", 45],
],
),
)
self.assertEqual(result["updated"], 1)
self.assertEqual(result["imported"], 1)
updated = self.db.scalar(select(Customer).where(Customer.customer_name == "老客户"))
self.assertEqual(updated.short_name, "老客")
self.assertEqual(updated.contact_name, "张三、李四、王五")
self.assertEqual(updated.contact_phone, "13800000001、13800000002、13800000003")
self.assertEqual(updated.address, "新地址")
self.assertEqual(updated.credit_days, 30)
workbook = sales._build_customer_workbook(self.db.scalars(select(Customer).order_by(Customer.customer_name.asc())).all())
exported = BytesIO()
workbook.save(exported)
rows = list(load_workbook(BytesIO(exported.getvalue()), read_only=True, data_only=True).active.iter_rows(values_only=True))
self.assertEqual(list(rows[0]), sales.CUSTOMER_EXCEL_HEADERS)
self.assertIn(("老客户", "老客", "张三", "13800000001", "李四", "13800000002", "王五", "13800000003", "新地址", 30), rows[1:])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,278 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes import production as production_routes # noqa: E402
from app.api.routes.production import _miniapp_report_item_rows, list_miniapp_operation_reports # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.miniapp import MiniAppPersonnel, MiniAppProductionReport, MiniAppProductionReportItem # noqa: E402
from app.models.operations import OperationReport # noqa: E402
from app.models.org import SystemConfig # noqa: E402
from app.services.system_config import ( # noqa: E402
MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE,
format_miniapp_operation_report_last_sync,
get_miniapp_operation_report_last_sync,
parse_miniapp_operation_report_last_sync,
upsert_miniapp_operation_report_last_sync,
)
class MiniAppOperationReportDateFilterTest(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_reports()
def tearDown(self) -> None:
self.db.close()
def _seed_reports(self) -> None:
now = datetime(2026, 6, 1, 8, 0, 0)
self.db.add(MiniAppPersonnel(phone="13800000000", name="测试员工", created_at=now, updated_at=now))
for report_id, report_date in [(1, date(2026, 5, 10)), (2, date(2026, 5, 20)), (3, date(2026, 6, 1))]:
self.db.add(
MiniAppProductionReport(
id=report_id,
attendance_point_name="测试考勤点",
session_id=report_id,
employee_phone="13800000000",
report_date=report_date,
start_at=datetime.combine(report_date, now.time()),
end_at=datetime.combine(report_date, now.time()),
duration_minutes=0,
break_minutes=0,
effective_minutes=0,
total_good_qty=0,
total_output_qty=0,
actual_beat=0,
standard_beat=0,
expected_workload=0,
pace_rate=0,
workload_rate=0,
status="approved",
submitted_at=datetime.combine(report_date, now.time()),
updated_at=datetime.combine(report_date, now.time()),
)
)
self.db.add(
MiniAppProductionReportItem(
id=report_id,
report_id=report_id,
attendance_point_name="测试考勤点",
device_no="1#",
project_no="P-001",
product_name=f"测试产品{report_id}",
raw_material_batch_no=f"JH-RM-{report_id:04d}",
process_name="1序",
standard_beat=0,
standard_workload=0,
good_qty=10,
defect_qty=0,
scrap_qty=0,
allocated_minutes=0,
started_at=datetime.combine(report_date, now.time()),
created_at=datetime.combine(report_date, now.time()),
)
)
self.db.commit()
def test_miniapp_report_item_rows_filters_by_report_date_range(self) -> None:
rows = _miniapp_report_item_rows(
self.db,
limit=100,
start_date=date(2026, 5, 18),
end_date=date(2026, 6, 1),
)
self.assertEqual([row["source_item_id"] for row in rows], [3, 2])
self.assertEqual({row["report_date"] for row in rows}, {date(2026, 5, 20), date(2026, 6, 1)})
def test_miniapp_operation_reports_rejects_start_date_after_end_date(self) -> None:
with self.assertRaises(HTTPException) as exc:
list_miniapp_operation_reports(
limit=100,
sync_to_erp=False,
start_date=date(2026, 6, 1),
end_date=date(2026, 5, 18),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("开始日期不能晚于结束日期", exc.exception.detail)
def test_miniapp_operation_report_last_sync_config_round_trips_datetime(self) -> None:
timestamp = datetime(2026, 6, 11, 10, 30, 15)
row = upsert_miniapp_operation_report_last_sync(self.db, timestamp, updated_by=1)
self.db.commit()
persisted = self.db.scalar(
select(SystemConfig).where(SystemConfig.config_code == MINIAPP_OPERATION_REPORT_LAST_SYNC_CONFIG_CODE)
)
self.assertIsNotNone(persisted)
self.assertEqual(row.config_value, "2026-06-11 10:30:15")
self.assertEqual(format_miniapp_operation_report_last_sync(timestamp), "2026-06-11 10:30:15")
self.assertEqual(parse_miniapp_operation_report_last_sync("2026-06-11 10:30:15"), timestamp)
self.assertEqual(get_miniapp_operation_report_last_sync(self.db), timestamp)
def test_list_reports_with_sync_disabled_does_not_call_sync(self) -> None:
calls: list[int] = []
original_sync = production_routes._sync_miniapp_item_to_erp
def fake_sync(db, source_item_id, **kwargs):
calls.append(source_item_id)
return None
production_routes._sync_miniapp_item_to_erp = fake_sync
try:
rows = list_miniapp_operation_reports(
limit=100,
sync_to_erp=False,
start_date=date(2026, 5, 18),
end_date=date(2026, 6, 1),
db=self.db,
)
finally:
production_routes._sync_miniapp_item_to_erp = original_sync
self.assertEqual(len(rows), 2)
self.assertEqual(calls, [])
self.assertEqual(self.db.query(OperationReport).count(), 0)
def test_list_reports_with_sync_disabled_skips_erp_target_resolution(self) -> None:
calls: list[str] = []
original_find_ledger = production_routes._find_production_ledger_for_miniapp_item
original_find_work_order = production_routes._find_work_order_for_miniapp_item
original_find_operation = production_routes._find_work_order_operation_for_miniapp_item
def fake_find_ledger(*args, **kwargs):
calls.append("production_ledger")
return None
def fake_find_work_order(*args, **kwargs):
calls.append("work_order")
return None
def fake_find_operation(*args, **kwargs):
calls.append("operation")
return None
production_routes._find_production_ledger_for_miniapp_item = fake_find_ledger
production_routes._find_work_order_for_miniapp_item = fake_find_work_order
production_routes._find_work_order_operation_for_miniapp_item = fake_find_operation
try:
rows = list_miniapp_operation_reports(
limit=100,
sync_to_erp=False,
start_date=date(2026, 5, 18),
end_date=date(2026, 6, 1),
db=self.db,
)
finally:
production_routes._find_production_ledger_for_miniapp_item = original_find_ledger
production_routes._find_work_order_for_miniapp_item = original_find_work_order
production_routes._find_work_order_operation_for_miniapp_item = original_find_operation
self.assertEqual(len(rows), 2)
self.assertEqual(calls, [])
def test_list_reports_caches_missing_erp_reports_without_per_row_scalar_lookup(self) -> None:
original_scalar = self.db.scalar
def fail_scalar(*args, **kwargs):
raise AssertionError("list refresh must not query missing ERP reports one row at a time")
rows = _miniapp_report_item_rows(
self.db,
limit=100,
start_date=date(2026, 5, 18),
end_date=date(2026, 6, 1),
)
cache = production_routes._new_miniapp_resolve_cache()
report_nos = [
production_routes._miniapp_report_no(int(row["source_report_id"]), int(row["source_item_id"]))
for row in rows
]
production_routes._preload_miniapp_operation_reports(self.db, report_nos, cache)
self.db.scalar = fail_scalar
try:
result = [
production_routes._build_miniapp_operation_report_read(
self.db,
row,
None,
cache=cache,
resolve_targets=False,
)
for row in rows
]
finally:
self.db.scalar = original_scalar
self.assertEqual(len(result), 2)
self.assertTrue(all(not row.is_synced_to_erp for row in result))
def test_sync_reports_uses_last_success_time_not_page_filter(self) -> None:
upsert_miniapp_operation_report_last_sync(self.db, datetime(2026, 5, 19, 0, 0, 0), updated_by=1)
self.db.commit()
calls: list[int] = []
original_sync = production_routes._sync_miniapp_item_to_erp
original_now = production_routes.datetime
class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 6, 11, 12, 0, 0)
def fake_sync(db, source_item_id, **kwargs):
calls.append(source_item_id)
return None
production_routes._sync_miniapp_item_to_erp = fake_sync
production_routes.datetime = FixedDateTime
try:
result = production_routes.sync_miniapp_operation_reports(db=self.db)
finally:
production_routes._sync_miniapp_item_to_erp = original_sync
production_routes.datetime = original_now
self.assertEqual(calls, [2, 3])
self.assertEqual(result.total, 2)
self.assertEqual(result.sync_from, datetime(2026, 5, 19, 0, 0, 0))
self.assertEqual(result.sync_to, datetime(2026, 6, 11, 12, 0, 0))
self.assertEqual(get_miniapp_operation_report_last_sync(self.db), datetime(2026, 6, 11, 12, 0, 0))
def test_sync_state_returns_last_success_time(self) -> None:
timestamp = datetime(2026, 6, 10, 18, 20, 30)
upsert_miniapp_operation_report_last_sync(self.db, timestamp, updated_by=1)
self.db.commit()
result = production_routes.get_miniapp_operation_report_sync_state(db=self.db)
self.assertEqual(result.last_synced_at, timestamp)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,310 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from io import BytesIO
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, WarehouseLocation # noqa: E402
class OpeningInventoryImportTest(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.raw_warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料仓",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished_warehouse = Warehouse(
id=2,
warehouse_code="WH-FG",
warehouse_name="成品仓",
warehouse_type="FINISHED",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.raw_location = WarehouseLocation(
id=1,
warehouse_id=1,
location_code="RAW-A",
location_name="原料主库位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished_location = WarehouseLocation(
id=2,
warehouse_id=2,
location_code="FG-A",
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("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished_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("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([
self.raw_warehouse,
self.finished_warehouse,
self.raw_location,
self.finished_location,
self.raw_item,
self.finished_item,
])
self.db.commit()
def _workbook_bytes(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_opening_template_exports_expected_headers(self) -> None:
from app.api.routes.inventory import OPENING_INVENTORY_HEADERS, build_opening_inventory_template
workbook = build_opening_inventory_template(self.db, "FINISHED")
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(headers, OPENING_INVENTORY_HEADERS)
self.assertEqual(len(rows), 3)
self.assertEqual(rows[1][headers.index("仓库类型")], "成品库")
self.assertEqual(rows[1][headers.index("批次号")], "示例-FG-0001")
self.assertEqual(rows[2][headers.index("批次号")], "示例-FG-0002")
def test_raw_opening_template_uses_weight_only_headers(self) -> None:
from app.api.routes.inventory import build_opening_inventory_template
workbook = build_opening_inventory_template(self.db, "RAW")
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.assertNotIn("数量", headers)
self.assertIn("重量(kg)", headers)
self.assertEqual(rows[1][headers.index("仓库类型")], "原材料库")
self.assertEqual(rows[1][headers.index("重量(kg)")], 500)
def test_import_opening_template_ignores_sample_rows(self) -> None:
from app.api.routes.inventory import build_opening_inventory_template, import_opening_inventory_workbook
workbook = build_opening_inventory_template(self.db, "AUX")
output = BytesIO()
workbook.save(output)
with self.assertRaises(ValueError) as exc:
import_opening_inventory_workbook(self.db, "AUX", output.getvalue(), user_id=1)
self.assertIn("没有可导入的期初库存明细", str(exc.exception))
def test_import_opening_inventory_writes_finished_qty_weight_balance_and_txn(self) -> None:
from app.api.routes.inventory import import_opening_inventory_workbook
content = self._workbook_bytes(
[[
"成品库",
"成品仓",
"成品主库位",
"产品需规00001",
"五金支架",
"OPEN-FG-001",
12,
30,
8.5,
"初始化清点",
"期初成品",
]]
)
result = import_opening_inventory_workbook(self.db, "FINISHED", content, user_id=1)
lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "OPEN-FG-001"))
balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.finished_item.id))
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))
self.assertEqual(result["imported"], 1)
self.assertEqual(float(lot.inbound_qty), 12)
self.assertEqual(float(lot.inbound_weight_kg), 30)
self.assertEqual(float(lot.remaining_qty), 12)
self.assertEqual(float(lot.remaining_weight_kg), 30)
self.assertEqual(float(balance.qty_on_hand), 12)
self.assertEqual(float(balance.weight_on_hand_kg), 30)
self.assertEqual(float(txn.qty_change), 12)
self.assertEqual(float(txn.weight_change_kg), 30)
self.assertEqual(txn.txn_type, "OPENING_IN")
def test_import_opening_inventory_keeps_raw_material_quantity_zero(self) -> None:
from app.api.routes.inventory import import_opening_inventory_workbook
content = self._workbook_bytes(
[[
"原材料库",
"原材料仓",
"原料主库位",
"原材料00001",
"304不锈钢",
"OPEN-RAW-001",
99,
500,
4.2,
"初始化清点",
"期初原材料",
]]
)
import_opening_inventory_workbook(self.db, "RAW", content, user_id=1)
lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "YL0001"))
balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.raw_item.id))
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.lot_id == lot.id))
self.assertIsNotNone(lot)
self.assertEqual(float(lot.inbound_qty), 0)
self.assertEqual(float(lot.remaining_qty), 0)
self.assertEqual(float(balance.qty_on_hand), 0)
self.assertEqual(float(txn.qty_change), 0)
self.assertEqual(float(lot.inbound_weight_kg), 500)
def test_import_opening_inventory_generates_global_raw_lot_numbers(self) -> None:
from app.api.routes.inventory import import_opening_inventory_workbook
content = self._workbook_bytes(
[
[
"原材料库",
"原材料仓",
"原料主库位",
"原材料00001",
"304不锈钢",
"用户手填RAW-001",
0,
500,
4.2,
"初始化清点",
"期初原材料1",
],
[
"原材料库",
"原材料仓",
"原料主库位",
"原材料00001",
"304不锈钢",
"用户手填RAW-002",
0,
600,
4.2,
"初始化清点",
"期初原材料2",
],
]
)
import_opening_inventory_workbook(self.db, "RAW", content, user_id=1)
lot_nos = self.db.scalars(
select(StockLot.lot_no)
.where(
StockLot.item_id == self.raw_item.id,
StockLot.source_doc_type == "OPENING_STOCK",
)
.order_by(StockLot.id)
).all()
self.assertEqual(lot_nos, ["YL0001", "YL0002"])
def test_import_opening_inventory_accepts_raw_weight_only_template_without_quantity(self) -> None:
from app.api.routes.inventory import import_opening_inventory_workbook
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "原材料库期初导入"
worksheet.append(["仓库类型", "仓库名称", "库位", "物料编码", "物料名称", "批次号", "重量(kg)", "单价", "来源说明", "备注"])
worksheet.append(["原材料库", "原材料仓", "原料主库位", "原材料00001", "304不锈钢", "OPEN-RAW-WEIGHT-ONLY", 600, 4.2, "初始化清点", "无数量列"])
output = BytesIO()
workbook.save(output)
import_opening_inventory_workbook(self.db, "RAW", output.getvalue(), user_id=1)
lot = self.db.scalar(select(StockLot).where(StockLot.lot_no == "YL0001"))
balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.raw_item.id))
self.assertIsNotNone(lot)
self.assertEqual(float(lot.inbound_qty), 0)
self.assertEqual(float(lot.remaining_qty), 0)
self.assertEqual(float(lot.inbound_weight_kg), 600)
self.assertEqual(float(balance.qty_on_hand), 0)
self.assertEqual(float(balance.weight_on_hand_kg), 600)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,113 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine
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.miniapp # noqa: E402,F401
from app.api.routes import master_data # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item # noqa: E402
from app.models.miniapp import MiniAppProduct # noqa: E402
from app.schemas.database import ProductCreate # noqa: E402
class ProductSpecCodeGenerationTest(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 _payload(self) -> ProductCreate:
return ProductCreate(
item_name="测试支架",
specification=None,
drawing_no=None,
product_family=None,
version_no="V1.0",
unit_weight_kg=1.2,
allowed_scrap_rate=0,
standard_output_qty=0,
quality_standard=None,
safety_stock_weight_kg=0,
status="ACTIVE",
)
def _add_item(self, code: str, item_type: str = "FINISHED_GOOD") -> None:
now = datetime.now(UTC)
self.db.add(
Item(
item_code=code,
item_name=f"测试产品{code}",
item_type=item_type,
specification=None,
material_grade=None,
unit_weight_kg=Decimal("1.000000"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
)
self.db.commit()
def test_product_spec_code_starts_from_named_five_digit_sequence(self) -> None:
self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00001")
def test_product_spec_code_increments_matching_codes_only(self) -> None:
self._add_item("产品需规00001")
self._add_item("FG-TEST-001")
self._add_item("PRJ-001")
self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00002")
def test_product_spec_code_ignores_legacy_non_matching_codes(self) -> None:
self._add_item("FG-LAC-51012170-001")
self._add_item("LAC-51012170")
self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00001")
def test_product_spec_code_skips_same_code_used_by_other_item_types(self) -> None:
self._add_item("产品需规00001")
self._add_item("产品需规00002", item_type="RAW_MATERIAL")
self.assertEqual(master_data._build_product_code(self.db, self._payload()), "产品需规00003")
def test_miniapp_created_product_uses_product_spec_code_rule(self) -> None:
row = MiniAppProduct(
attendance_point_name="测试考勤点",
project_no="XCX-PRJ-001",
product_name="小程序同步支架",
device_no="",
process_name="1",
profile_no="PF-001",
product_net_weight_kg=Decimal("1.2000"),
scrap_loss_rate=Decimal("0.0100"),
)
product, item = master_data._create_product_from_miniapp_group(self.db, [row])
self.assertIsNotNone(product)
self.assertIsNotNone(item)
self.assertEqual(item.item_code, "产品需规00001")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,510 @@
from __future__ import annotations
import unittest
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine, func, 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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes import master_data # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Bom, BomItem, Item, Material, Product # noqa: E402
from app.models.miniapp import MiniAppAttendancePoint, MiniAppProduct # noqa: E402
from app.models.operations import Process, ProcessRoute, ProcessRouteOperation, WorkCenter # noqa: E402
from app.models.org import Department # noqa: E402
from app.schemas.database import MiniAppProductUpsert, ProductUpdate # noqa: E402
class MiniAppProductMaterialSyncTest(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_required_master_data()
def tearDown(self) -> None:
self.db.close()
def _seed_required_master_data(self) -> None:
self.db.add_all(
[
Department(id=1, dept_code="D-TEST", dept_name="测试车间", dept_type="WORKSHOP", status="ACTIVE"),
Process(id=1, process_code="PROC-1", process_name="1", process_type="STAMPING", status="ACTIVE"),
WorkCenter(
id=1,
center_code="WC-1",
center_name="测试工作中心",
dept_id=1,
center_type="PRESS",
status="ACTIVE",
),
MiniAppAttendancePoint(name="测试考勤点", is_active=True),
]
)
self.db.commit()
def _add_miniapp_product(self, *, material_code: str | None, material_name: str | None) -> MiniAppProduct:
row = MiniAppProduct(
attendance_point_name="测试考勤点",
project_no="PRJ-001",
product_name="测试支架",
device_no="",
process_name="1",
profile_no="PF-001",
material_code=material_code,
material_name=material_name,
supplier="小程序供应商",
product_net_weight_kg=Decimal("1.2000"),
product_gross_weight_kg=Decimal("1.5000"),
scrap_loss_rate=Decimal("0.020000"),
waste_price_yuan_per_kg=Decimal("3.5000"),
stamping_method="冲压",
operator_count=Decimal("1"),
standard_beat=Decimal("12.000"),
standard_workload=Decimal("2400.00"),
)
self.db.add(row)
self.db.commit()
return row
def _add_material(self) -> Item:
item = Item(
item_code="MAT-ERP-001",
item_name="ERP原材料",
item_type="RAW_MATERIAL",
specification="1.0*100",
material_grade="SPCC",
unit_weight_kg=Decimal("1.000000"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("2.5000"),
status="ACTIVE",
)
self.db.add(item)
self.db.flush()
self.db.add(
Material(
item_id=item.id,
default_supplier_id=None,
purchase_calc_mode="BY_WEIGHT",
min_purchase_qty=Decimal("0"),
purchase_multiple_qty=Decimal("0"),
purchase_multiple_weight_kg=Decimal("0"),
lead_time_days=0,
quality_rule=None,
)
)
self.db.commit()
return item
def test_miniapp_material_not_in_erp_does_not_block_product_spec_sync(self) -> None:
self._add_miniapp_product(material_code="WX-MAT-001", material_name="小程序自由物料")
result = master_data._sync_miniapp_product_specs_to_master_data(self.db)
self.db.commit()
self.assertEqual(result["created"], 1)
self.assertEqual(result["matched"], 1)
self.assertEqual(self.db.scalar(select(func.count(Item.id)).where(Item.item_type == "RAW_MATERIAL")), 0)
self.assertEqual(self.db.scalar(select(func.count(Bom.id))), 0)
self.assertEqual(self.db.scalar(select(func.count(ProcessRoute.id))), 1)
miniapp_row = self.db.get(MiniAppProduct, ("测试考勤点", "PRJ-001", "测试支架", "", "1"))
self.assertEqual(miniapp_row.material_code, "WX-MAT-001")
self.assertEqual(miniapp_row.material_name, "小程序自由物料")
def test_miniapp_material_matching_erp_material_creates_default_bom(self) -> None:
material = self._add_material()
self._add_miniapp_product(material_code=material.item_code, material_name=material.item_name)
result = master_data._sync_miniapp_product_specs_to_master_data(self.db)
self.db.commit()
self.assertEqual(result["bom_updated"], 1)
bom_item = self.db.scalar(select(BomItem).limit(1))
self.assertIsNotNone(bom_item)
self.assertEqual(bom_item.material_item_id, material.id)
def test_upsert_miniapp_product_preserves_erp_selected_material_code_and_name(self) -> None:
payload = MiniAppProductUpsert(
attendance_point_name="测试考勤点",
project_no="ERP-PRJ-001",
product_name="ERP新建支架",
profile_no="PF-ERP",
material_code="MAT-ERP-SELECTED",
material_name="ERP选择原材料",
supplier=None,
product_net_weight_kg=1.2,
product_gross_weight_kg=1.5,
allowed_scrap_rate=0.02,
waste_price_yuan_per_kg=3.5,
device_no="",
process_name="1",
stamping_method="冲压",
operator_count=1,
standard_beat=12,
standard_workload=2400,
)
row = master_data._upsert_miniapp_product(self.db, payload)
self.db.commit()
self.assertEqual(row.material_code, "MAT-ERP-SELECTED")
self.assertEqual(row.material_name, "ERP选择原材料")
def test_partial_miniapp_route_sync_does_not_deactivate_erp_authored_operations(self) -> None:
product_item = Item(
item_code="产品需规00001",
item_name="测试支架",
item_type="FINISHED_GOOD",
specification=None,
material_grade=None,
unit_weight_kg=Decimal("1.000000"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
)
self.db.add(product_item)
self.db.flush()
route = ProcessRoute(
route_code="ROUTE-001",
route_name="测试路线",
product_item_id=product_item.id,
version_no="V1.0",
is_default=1,
status="ACTIVE",
effective_date=None,
expire_date=None,
remark=None,
)
self.db.add(route)
self.db.flush()
operations = []
for seq_no, status in [(1, "ACTIVE"), (2, "ACTIVE"), (3, "INACTIVE")]:
operation = ProcessRouteOperation(
route_id=route.id,
seq_no=seq_no,
process_id=1,
work_center_id=1,
operation_name=str(seq_no),
std_setup_minutes=Decimal("0"),
std_cycle_seconds=Decimal("10"),
std_labor_cost=Decimal("0"),
std_machine_cost=Decimal("0"),
std_outsource_cost=Decimal("0"),
allowed_scrap_rate=Decimal("0"),
report_required=1,
is_key_operation=0,
status=status,
remark=None,
)
self.db.add(operation)
operations.append(operation)
self.db.commit()
changed = master_data._sync_miniapp_route_operations(
self.db,
route,
operations,
[
{
"seq_no": 1,
"process_id": 1,
"work_center_id": 1,
"operation_name": "1",
"std_cycle_seconds": Decimal("10"),
"allowed_scrap_rate": Decimal("0"),
}
],
)
self.db.flush()
self.assertTrue(changed)
self.assertEqual(operations[0].status, "ACTIVE")
self.assertEqual(operations[1].status, "ACTIVE")
self.assertEqual(operations[2].status, "ACTIVE")
def test_miniapp_owned_missing_operation_is_deactivated(self) -> None:
product_item = Item(
item_code="产品需规00002",
item_name="测试支架2",
item_type="FINISHED_GOOD",
specification=None,
material_grade=None,
unit_weight_kg=Decimal("1.000000"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
)
self.db.add(product_item)
self.db.flush()
route = ProcessRoute(
route_code="ROUTE-002",
route_name="测试路线2",
product_item_id=product_item.id,
version_no="V1.0",
is_default=1,
status="ACTIVE",
effective_date=None,
expire_date=None,
remark=master_data.MINIAPP_SYNC_REMARK,
)
self.db.add(route)
self.db.flush()
operation = ProcessRouteOperation(
route_id=route.id,
seq_no=2,
process_id=1,
work_center_id=1,
operation_name="2",
std_setup_minutes=Decimal("0"),
std_cycle_seconds=Decimal("10"),
std_labor_cost=Decimal("0"),
std_machine_cost=Decimal("0"),
std_outsource_cost=Decimal("0"),
allowed_scrap_rate=Decimal("0"),
report_required=1,
is_key_operation=0,
status="ACTIVE",
remark=master_data.MINIAPP_SYNC_REMARK,
)
self.db.add(operation)
self.db.commit()
master_data._sync_miniapp_route_operations(self.db, route, [operation], [])
self.db.flush()
self.assertEqual(operation.status, "INACTIVE")
def test_stale_product_with_only_miniapp_owned_operations_is_deactivated(self) -> None:
product_item = Item(
item_code="产品需规00003",
item_name="保温杯",
item_type="FINISHED_GOOD",
specification=None,
material_grade=None,
unit_weight_kg=Decimal("1.000000"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
)
self.db.add(product_item)
self.db.flush()
product = Product(
item_id=product_item.id,
drawing_no=None,
product_family=None,
version_no="V1.0",
standard_output_qty=Decimal("0"),
allowed_scrap_rate=Decimal("0"),
quality_standard=None,
)
self.db.add(product)
route = ProcessRoute(
route_code="ROUTE-003",
route_name="保温杯默认工艺路线",
product_item_id=product_item.id,
version_no="V1.0",
is_default=1,
status="ACTIVE",
effective_date=None,
expire_date=None,
remark=None,
)
self.db.add(route)
self.db.flush()
operation = ProcessRouteOperation(
route_id=route.id,
seq_no=1,
process_id=1,
work_center_id=1,
operation_name="1",
std_setup_minutes=Decimal("0"),
std_cycle_seconds=Decimal("10"),
std_labor_cost=Decimal("0"),
std_machine_cost=Decimal("0"),
std_outsource_cost=Decimal("0"),
allowed_scrap_rate=Decimal("0"),
report_required=1,
is_key_operation=0,
status="ACTIVE",
remark=master_data.MINIAPP_SYNC_REMARK,
)
self.db.add(operation)
self.db.commit()
result = master_data._deactivate_stale_miniapp_product_specs_from_master_data(self.db, [])
self.db.flush()
self.assertEqual(result["deactivated"], 1)
self.assertEqual(product_item.status, "INACTIVE")
self.assertEqual(route.status, "INACTIVE")
self.assertEqual(operation.status, "INACTIVE")
def test_process_route_read_includes_miniapp_stamping_method(self) -> None:
product_item = Item(
item_code="产品需规00001",
item_name="测试支架",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("1.000000"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
)
self.db.add(product_item)
self.db.flush()
product = Product(
item_id=product_item.id,
drawing_no="PRJ-001",
product_family="PRJ-001",
version_no="V1.0",
standard_output_qty=Decimal("0"),
allowed_scrap_rate=Decimal("0"),
quality_standard=None,
)
route = ProcessRoute(
route_code="ROUTE-004",
route_name="测试支架默认工艺路线",
product_item_id=product_item.id,
version_no="V1.0",
is_default=1,
status="ACTIVE",
effective_date=None,
expire_date=None,
remark=master_data.MINIAPP_SYNC_REMARK,
)
self.db.add_all([product, route])
self.db.flush()
operation = ProcessRouteOperation(
route_id=route.id,
seq_no=1,
process_id=1,
work_center_id=1,
operation_name="1",
std_setup_minutes=Decimal("0"),
std_cycle_seconds=Decimal("10"),
std_labor_cost=Decimal("0"),
std_machine_cost=Decimal("0"),
std_outsource_cost=Decimal("0"),
allowed_scrap_rate=Decimal("0"),
report_required=1,
is_key_operation=0,
status="ACTIVE",
remark=master_data.MINIAPP_SYNC_REMARK,
)
miniapp_product = MiniAppProduct(
attendance_point_name="测试考勤点",
project_no="PRJ-001",
product_name="测试支架",
device_no="",
process_name="1",
profile_no="PF-001",
material_code="原材料00001",
material_name="钢板",
supplier="测试供应商",
product_net_weight_kg=Decimal("1.0000"),
product_gross_weight_kg=Decimal("1.2000"),
scrap_loss_rate=Decimal("0.020000"),
waste_price_yuan_per_kg=Decimal("3.5000"),
stamping_method="切边冲孔",
operator_count=Decimal("1"),
standard_beat=Decimal("10.000"),
standard_workload=Decimal("2400.00"),
)
self.db.add_all([operation, miniapp_product])
self.db.commit()
rows = master_data._list_process_route_reads(self.db, limit=10)
target = next(row for row in rows if row.route_id == route.id)
self.assertEqual(target.operations[0].operation_name, "1")
self.assertEqual(target.operations[0].stamping_method, "切边冲孔")
def test_product_update_allows_existing_bom_and_route_without_downstream_usage(self) -> None:
product_item = Item(
item_code="产品需规00004",
item_name="钢制碗",
item_type="FINISHED_GOOD",
specification=None,
material_grade=None,
unit_weight_kg=Decimal("0.500000"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
)
self.db.add(product_item)
self.db.flush()
product = Product(
item_id=product_item.id,
drawing_no=None,
product_family=None,
version_no="V1.0",
standard_output_qty=Decimal("0"),
allowed_scrap_rate=Decimal("0.03"),
quality_standard=None,
)
self.db.add(product)
self.db.add(
Bom(
bom_code="BOM-00004",
product_item_id=product_item.id,
version_no="V1.0",
is_default=1,
status="ACTIVE",
effective_date=None,
expire_date=None,
remark=master_data.MINIAPP_SYNC_REMARK,
)
)
self.db.add(
ProcessRoute(
route_code="ROUTE-004",
route_name="钢制碗默认工艺路线",
product_item_id=product_item.id,
version_no="V1.0",
is_default=1,
status="ACTIVE",
effective_date=None,
expire_date=None,
remark=master_data.MINIAPP_SYNC_REMARK,
)
)
self.db.commit()
updated = master_data.update_product(
product_item.id,
ProductUpdate(
item_name="钢制碗",
specification=None,
drawing_no=None,
product_family=None,
version_no="V1.0",
unit_weight_kg=0.5,
allowed_scrap_rate=0.03,
standard_output_qty=0,
quality_standard=None,
safety_stock_weight_kg=0,
status="ACTIVE",
),
db=self.db,
)
self.assertEqual(updated.item_id, product_item.id)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,371 @@
from __future__ import annotations
import unittest
from datetime import datetime
from decimal import Decimal
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.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.master_data import Bom, BomItem, Item, Warehouse # noqa: E402
from app.models.operations import ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot # noqa: E402
class ProductionBatchLedgerModelTest(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.now = datetime(2026, 6, 8, 8, 30)
def tearDown(self) -> None:
self.db.close()
def _seed_basic_items_and_lot(self) -> None:
raw_item = Item(
id=1,
item_code="RM-001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
product = Item(
id=2,
item_code="FG-001",
item_name="钢制碗",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("0.5"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
lot = StockLot(
id=1,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="期初入库",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("500"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("300"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([raw_item, product, warehouse, lot])
self.db.flush()
def _seed_basic_items_bom_and_lot(self) -> None:
self._seed_basic_items_and_lot()
bom = Bom(
id=1,
bom_code="BOM-001",
product_item_id=2,
version_no="V1.0",
is_default=1,
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
bom_item = BomItem(
id=1,
bom_id=1,
seq_no=1,
material_item_id=1,
usage_type="WEIGHT",
gross_weight_per_piece_kg=Decimal("0.6"),
net_weight_per_piece_kg=Decimal("0.5"),
loss_rate=Decimal("0"),
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([bom, bom_item])
self.db.flush()
def _seed_ledger(
self,
*,
total_issued_weight_kg: Decimal,
outside_weight_kg: Decimal | None = None,
) -> ProductionBatchLedger:
ledger = ProductionBatchLedger(
material_lot_id=1,
material_lot_no="YL0001",
material_item_id=1,
product_item_id=2,
status="在生产",
miniapp_selectable=1,
total_issued_weight_kg=total_issued_weight_kg,
outside_weight_kg=outside_weight_kg if outside_weight_kg is not None else total_issued_weight_kg,
finished_inbound_qty=Decimal("0"),
surplus_inbound_weight_kg=Decimal("0"),
scrap_inbound_weight_kg=Decimal("0"),
writeoff_weight_kg=Decimal("0"),
first_issue_time=self.now,
last_issue_time=self.now,
created_at=self.now,
updated_at=self.now,
)
self.db.add(ledger)
self.db.flush()
return ledger
def test_models_persist_chinese_status_and_business_key(self) -> None:
raw_item = Item(
id=1,
item_code="RM-001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
product = Item(
id=2,
item_code="FG-001",
item_name="钢制碗",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("0.5"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
lot = StockLot(
id=1,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="期初入库",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("500"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("300"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([raw_item, product, warehouse, lot])
self.db.flush()
ledger = ProductionBatchLedger(
material_lot_id=1,
material_lot_no="YL0001",
material_item_id=1,
product_item_id=2,
status="在生产",
miniapp_selectable=1,
total_issued_weight_kg=Decimal("200"),
outside_weight_kg=Decimal("200"),
finished_inbound_qty=Decimal("0"),
surplus_inbound_weight_kg=Decimal("0"),
scrap_inbound_weight_kg=Decimal("0"),
writeoff_weight_kg=Decimal("0"),
first_issue_time=self.now,
last_issue_time=self.now,
created_at=self.now,
updated_at=self.now,
)
self.db.add(ledger)
self.db.flush()
self.db.add(
ProductionBatchLedgerTxn(
production_ledger_id=ledger.id,
txn_type="生产出库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("200"),
outside_weight_after_kg=Decimal("200"),
source_doc_type="库存流水",
source_doc_id=10,
biz_time=self.now,
operator_user_id=1,
remark="生产出库累计到生产台账",
created_at=self.now,
updated_at=self.now,
)
)
self.db.commit()
saved = self.db.scalar(select(ProductionBatchLedger).where(ProductionBatchLedger.material_lot_no == "YL0001"))
self.assertIsNotNone(saved)
self.assertEqual(saved.status, "在生产")
self.assertEqual(saved.miniapp_selectable, 1)
self.assertEqual(float(saved.outside_weight_kg), 200)
txns = self.db.scalars(select(ProductionBatchLedgerTxn)).all()
self.assertEqual(txns[0].txn_type, "生产出库")
def test_record_issue_accumulates_same_lot_and_product(self) -> None:
from app.services.production_batch_ledger import record_production_issue_to_ledger
self._seed_basic_items_and_lot()
first = record_production_issue_to_ledger(
self.db,
material_lot_id=1,
product_item_id=2,
issued_weight_kg=Decimal("120"),
operator_user_id=1,
source_doc_type="库存流水",
source_doc_id=100,
source_line_id=1001,
biz_time=datetime(2026, 6, 8, 9, 0),
remark="第一次出库",
)
second = record_production_issue_to_ledger(
self.db,
material_lot_id=1,
product_item_id=2,
issued_weight_kg=Decimal("80"),
operator_user_id=1,
source_doc_type="库存流水",
source_doc_id=101,
source_line_id=1002,
biz_time=datetime(2026, 6, 8, 14, 0),
remark="第二次出库",
)
self.db.commit()
self.assertEqual(first.id, second.id)
self.assertEqual(second.status, "在生产")
self.assertEqual(float(second.total_issued_weight_kg), 200)
self.assertEqual(float(second.outside_weight_kg), 200)
txns = self.db.scalars(select(ProductionBatchLedgerTxn).order_by(ProductionBatchLedgerTxn.id)).all()
self.assertEqual([row.txn_type for row in txns], ["生产出库", "生产出库"])
def test_lock_and_reopen_same_lot_product(self) -> None:
from app.services.production_batch_ledger import lock_production_batch_ledger, record_production_issue_to_ledger
self._seed_basic_items_and_lot()
ledger = record_production_issue_to_ledger(
self.db,
material_lot_id=1,
product_item_id=2,
issued_weight_kg=Decimal("200"),
operator_user_id=1,
source_doc_type="库存流水",
source_doc_id=100,
source_line_id=1001,
biz_time=datetime(2026, 6, 8, 9, 0),
remark="生产出库",
)
locked = lock_production_batch_ledger(
self.db,
production_ledger_id=ledger.id,
operator_user_id=1,
biz_time=datetime(2026, 6, 8, 18, 0),
remark="现场确认该批材料已无库外余量",
)
self.assertEqual(locked.status, "锁单")
self.assertEqual(locked.miniapp_selectable, 0)
self.assertEqual(float(locked.outside_weight_kg), 0)
self.assertEqual(float(locked.writeoff_weight_kg), 200)
reopened = record_production_issue_to_ledger(
self.db,
material_lot_id=1,
product_item_id=2,
issued_weight_kg=Decimal("50"),
operator_user_id=1,
source_doc_type="库存流水",
source_doc_id=102,
source_line_id=1003,
biz_time=datetime(2026, 6, 9, 9, 0),
remark="继续出库生产",
)
self.assertEqual(reopened.id, locked.id)
self.assertEqual(reopened.status, "在生产")
self.assertEqual(reopened.miniapp_selectable, 1)
self.assertEqual(float(reopened.total_issued_weight_kg), 250)
self.assertEqual(float(reopened.outside_weight_kg), 50)
txns = self.db.scalars(select(ProductionBatchLedgerTxn).order_by(ProductionBatchLedgerTxn.id)).all()
self.assertEqual([row.txn_type for row in txns], ["生产出库", "结单核销", "该批材料结单", "重新开工", "生产出库"])
def test_disabled_mode_calculates_from_finished_inbound_and_bom(self) -> None:
from app.services.production_batch_ledger import calculate_production_batch_ledger_snapshot
self._seed_basic_items_bom_and_lot()
ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200"))
ledger.finished_inbound_qty = Decimal("100")
snapshot = calculate_production_batch_ledger_snapshot(self.db, ledger.id, smart_reporting_enabled=False)
self.assertEqual(float(snapshot.reference_finished_qty), 333)
self.assertEqual(float(snapshot.used_material_weight_kg), 60)
self.assertEqual(float(snapshot.theoretical_scrap_weight_kg), 10)
self.assertEqual(float(snapshot.theoretical_surplus_weight_kg), 140)
def test_lock_writes_off_remaining_outside_weight_after_inbounds(self) -> None:
from app.services.production_batch_ledger import lock_production_batch_ledger, post_finished_inbound_to_ledger
self._seed_basic_items_bom_and_lot()
ledger = self._seed_ledger(total_issued_weight_kg=Decimal("200"), outside_weight_kg=Decimal("200"))
post_finished_inbound_to_ledger(
self.db,
production_ledger_id=ledger.id,
finished_qty=Decimal("100"),
operator_user_id=1,
source_doc_type="成品入库",
source_doc_id=1,
source_line_id=1,
biz_time=datetime(2026, 6, 8, 12, 0),
remark="成品入库",
)
locked = lock_production_batch_ledger(
self.db,
production_ledger_id=ledger.id,
operator_user_id=1,
biz_time=datetime(2026, 6, 8, 18, 0),
remark="该批材料结单",
)
self.assertEqual(locked.status, "锁单")
self.assertEqual(float(locked.outside_weight_kg), 0)
self.assertEqual(float(locked.finished_inbound_qty), 100)
@@ -0,0 +1,606 @@
from __future__ import annotations
import unittest
from datetime import datetime
from decimal import Decimal
from tempfile import TemporaryDirectory
from unittest.mock import patch
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@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, Warehouse # noqa: E402
from app.models.operations import CompletionReceipt, InventoryTxn, ProductionBatchLedger, ProductionBatchLedgerTxn, StockLot, WorkOrder # noqa: E402
from app.api.routes.inventory import _should_archive_production_inbound # noqa: E402
from app.api.routes.production import _production_ledger_txn_read, list_production_batch_ledger # noqa: E402
from app.services.document_archives import ( # noqa: E402
ARCHIVE_STATUS_READY,
DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
collect_production_inbound_settlement_archive_context,
collect_production_material_out_archive_context,
generate_document_archive,
)
class ProductionWarehouseDocumentArchiveTest(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.now = datetime(2026, 6, 11, 9, 0, 0)
self.biz_time = datetime(2026, 6, 11, 10, 30, 0)
self._seed_archive_context_data()
def tearDown(self) -> None:
self.db.close()
def _seed_archive_context_data(self) -> None:
self.raw_warehouse = Warehouse(
id=1,
warehouse_code="RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.finished_warehouse = Warehouse(
id=2,
warehouse_code="FINISHED",
warehouse_name="成品库",
warehouse_type="FINISHED",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.scrap_warehouse = Warehouse(
id=3,
warehouse_code="SCRAP",
warehouse_name="废料库",
warehouse_type="SCRAP",
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.material_item = Item(
id=10,
item_code="RM-ARCHIVE-001",
item_name="测试冷轧板",
item_type="RAW_MATERIAL",
specification="1.0mm",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.product_item = Item(
id=20,
item_code="FG-ARCHIVE-001",
item_name="测试钢制碗",
item_type="FINISHED_GOOD",
specification="碗型",
unit_weight_kg=Decimal("0.4"),
status="ACTIVE",
created_at=self.now,
updated_at=self.now,
)
self.raw_lot = StockLot(
id=100,
lot_no="YL0001",
lot_role="RAW_MATERIAL",
item_id=self.material_item.id,
warehouse_id=self.raw_warehouse.id,
source_doc_type="期初入库",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("500"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("300"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("6.6"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.ledger = ProductionBatchLedger(
id=200,
material_lot_id=self.raw_lot.id,
material_lot_no="YL0001",
material_item_id=self.material_item.id,
product_item_id=self.product_item.id,
status="在生产",
miniapp_selectable=1,
total_issued_weight_kg=Decimal("200"),
outside_weight_kg=Decimal("80"),
finished_inbound_qty=Decimal("198"),
surplus_inbound_weight_kg=Decimal("60"),
scrap_inbound_weight_kg=Decimal("2"),
writeoff_weight_kg=Decimal("0"),
first_issue_time=self.biz_time,
last_issue_time=self.biz_time,
created_at=self.now,
updated_at=self.now,
)
self.issue_txn = InventoryTxn(
id=400,
txn_no="TXN-OUT-001",
txn_type="PRODUCTION_OUT",
item_id=self.material_item.id,
warehouse_id=self.raw_warehouse.id,
lot_id=self.raw_lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("-200"),
unit_cost=Decimal("6.6"),
amount=Decimal("1320.00"),
source_doc_type="生产台账",
source_doc_id=self.ledger.id,
biz_time=self.biz_time,
remark="生产领料出库",
created_at=self.now,
updated_at=self.now,
)
self.ledger_txn = ProductionBatchLedgerTxn(
id=300,
production_ledger_id=self.ledger.id,
txn_type="生产出库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("200"),
outside_weight_after_kg=Decimal("80"),
source_doc_type="库存流水",
source_doc_id=self.issue_txn.id,
biz_time=self.biz_time,
operator_user_id=1,
remark="生产出库累计到生产台账",
created_at=self.now,
updated_at=self.now,
)
self.finished_lot = StockLot(
id=101,
lot_no="FG0001",
lot_role="FINISHED_GOOD",
item_id=self.product_item.id,
warehouse_id=self.finished_warehouse.id,
source_doc_type="生产台账",
source_doc_id=self.ledger.id,
source_material_lot_id=self.raw_lot.id,
inbound_qty=Decimal("198"),
inbound_weight_kg=Decimal("79.2"),
remaining_qty=Decimal("198"),
remaining_weight_kg=Decimal("79.2"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("6.6667"),
quality_status="PASS",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
self.finished_txn = InventoryTxn(
id=401,
txn_no="TXN-FG-001",
txn_type="FG_IN",
item_id=self.product_item.id,
warehouse_id=self.finished_warehouse.id,
lot_id=self.finished_lot.id,
qty_change=Decimal("198"),
weight_change_kg=Decimal("79.2"),
unit_cost=Decimal("6.6667"),
amount=Decimal("1320.00"),
source_doc_type="生产台账",
source_doc_id=self.ledger.id,
biz_time=self.biz_time,
remark="偏差说明:称重复核通过;单据批次号:生产结算20260611103000000001",
created_at=self.now,
updated_at=self.now,
)
self.surplus_txn = InventoryTxn(
id=402,
txn_no="TXN-SURPLUS-001",
txn_type="PRODUCTION_SURPLUS_IN",
item_id=self.material_item.id,
warehouse_id=self.raw_warehouse.id,
lot_id=self.raw_lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("60"),
unit_cost=Decimal("6.6"),
amount=Decimal("396.00"),
source_doc_type="生产台账",
source_doc_id=self.ledger.id,
biz_time=self.biz_time,
remark="偏差说明:称重复核通过;单据批次号:生产结算20260611103000000001",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all(
[
self.raw_warehouse,
self.finished_warehouse,
self.scrap_warehouse,
self.material_item,
self.product_item,
self.raw_lot,
self.ledger,
self.issue_txn,
self.ledger_txn,
self.finished_lot,
self.finished_txn,
self.surplus_txn,
]
)
self.db.commit()
def test_collect_material_out_context_uses_production_ledger(self) -> None:
context = collect_production_material_out_archive_context(self.db, self.ledger.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT)
self.assertEqual(context.business_id, self.ledger.id)
self.assertEqual(context.document_no, "生产领料-YL0001-测试钢制碗")
self.assertEqual(context.partner_label, "生产产品")
self.assertEqual(context.partner_name, "测试钢制碗")
self.assertEqual(context.address, "原材料库")
self.assertIn("材料库存批次号:YL0001", context.remark)
self.assertEqual(len(context.lines), 1)
line = context.lines[0]
self.assertEqual(line.item_name, "测试冷轧板")
self.assertEqual(Decimal(str(line.quantity)), Decimal("200"))
self.assertEqual(Decimal(str(line.line_amount)), Decimal("1320.00"))
def test_collect_inbound_settlement_context_groups_same_save_action(self) -> None:
context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id)
self.assertEqual(context.document_type, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
self.assertEqual(context.business_id, self.finished_txn.id)
self.assertEqual(context.document_no, "生产入库结算-TXN-FG-001")
self.assertEqual(context.partner_label, "生产产品")
self.assertEqual(context.partner_name, "测试钢制碗")
self.assertIn("材料库存批次号:YL0001", context.remark)
self.assertEqual(len(context.lines), 2)
first_chunks = [str(line.remark or "").split(";")[0] for line in context.lines]
self.assertEqual(first_chunks, ["入库类型:成品入库", "入库类型:生产余料入库"])
def test_collect_inbound_settlement_context_labels_all_related_warehouses(self) -> None:
scrap_lot = StockLot(
id=102,
lot_no="SCRAP0001",
lot_role="PRODUCTION_SCRAP",
item_id=self.material_item.id,
warehouse_id=self.scrap_warehouse.id,
source_doc_type="生产台账",
source_doc_id=self.ledger.id,
source_material_lot_id=self.raw_lot.id,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("2"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("2"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("6.6"),
quality_status="SCRAP",
status="AVAILABLE",
created_at=self.now,
updated_at=self.now,
)
scrap_txn = InventoryTxn(
id=403,
txn_no="TXN-SCRAP-001",
txn_type="PRODUCTION_SCRAP_IN",
item_id=self.material_item.id,
warehouse_id=self.scrap_warehouse.id,
lot_id=scrap_lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("2"),
unit_cost=Decimal("6.6"),
amount=Decimal("13.20"),
source_doc_type="生产台账",
source_doc_id=self.ledger.id,
biz_time=self.biz_time,
remark="偏差说明:称重复核通过;单据批次号:生产结算20260611103000000001",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([scrap_lot, scrap_txn])
self.db.commit()
context = collect_production_inbound_settlement_archive_context(self.db, self.finished_txn.id)
self.assertEqual(context.address_label, "涉及仓库")
self.assertEqual(context.address, "成品库、原材料库、废料库")
self.assertIn("涉及仓库:成品库、原材料库、废料库", context.remark)
self.assertEqual([str(line.remark or "").split(";")[0] for line in context.lines], ["入库类型:成品入库", "入库类型:生产余料入库", "入库类型:生产废料入库"])
def test_collect_work_order_inbound_settlement_groups_receipt_and_work_order_txns(self) -> None:
work_order = WorkOrder(
id=500,
work_order_no="WO-ARCHIVE-001",
work_order_type="正常生产",
product_item_id=self.product_item.id,
route_id=1,
bom_id=1,
planned_qty=Decimal("100"),
released_qty=Decimal("100"),
finished_qty=Decimal("0"),
scrap_qty=Decimal("0"),
priority_level=3,
status="RELEASED",
created_at=self.now,
updated_at=self.now,
)
receipt = CompletionReceipt(
id=600,
receipt_no="FGI-ARCHIVE-001",
work_order_id=work_order.id,
warehouse_id=self.finished_warehouse.id,
receipt_time=self.biz_time,
status="POSTED",
remark="旧工单入库",
created_at=self.now,
updated_at=self.now,
)
finished_txn = InventoryTxn(
id=601,
txn_no="TXN-WO-FG-001",
txn_type="FG_IN",
item_id=self.product_item.id,
warehouse_id=self.finished_warehouse.id,
lot_id=self.finished_lot.id,
qty_change=Decimal("50"),
weight_change_kg=Decimal("20"),
unit_cost=Decimal("6.6"),
amount=Decimal("330.00"),
source_doc_type="FG_RECEIPT",
source_doc_id=receipt.id,
biz_time=self.biz_time,
remark="旧工单成品入库",
created_at=self.now,
updated_at=self.now,
)
surplus_txn = InventoryTxn(
id=602,
txn_no="TXN-WO-SURPLUS-001",
txn_type="PRODUCTION_SURPLUS_IN",
item_id=self.material_item.id,
warehouse_id=self.raw_warehouse.id,
lot_id=self.raw_lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("12"),
unit_cost=Decimal("6.6"),
amount=Decimal("79.20"),
source_doc_type="WORK_ORDER",
source_doc_id=work_order.id,
biz_time=self.biz_time,
remark="旧工单余料入库",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([work_order, receipt, finished_txn, surplus_txn])
self.db.commit()
context = collect_production_inbound_settlement_archive_context(self.db, finished_txn.id)
self.assertEqual(context.partner_name, "测试钢制碗")
self.assertEqual(len(context.lines), 2)
self.assertEqual([line.remark.split(";")[0] for line in context.lines], ["入库类型:成品入库", "入库类型:生产余料入库"])
def test_generic_finished_inbound_without_production_source_does_not_archive(self) -> None:
self.assertFalse(
_should_archive_production_inbound(
"PRODUCTION_FINISHED",
production_ledger_id=None,
production_work_order_id=None,
)
)
self.assertTrue(
_should_archive_production_inbound(
"PRODUCTION_FINISHED",
production_ledger_id=self.ledger.id,
production_work_order_id=None,
)
)
self.assertTrue(
_should_archive_production_inbound(
"PRODUCTION_FINISHED",
production_ledger_id=None,
production_work_order_id=500,
)
)
def test_production_out_ledger_txn_does_not_expose_inbound_settlement_archive(self) -> None:
row = _production_ledger_txn_read(self.ledger_txn)
self.assertEqual(row["txn_type"], "生产出库")
self.assertIsNone(row["archive_business_id"])
self.assertIsNone(row["archive_document_type"])
self.assertIsNone(row["archive_status"])
def test_production_ledger_inbound_txns_share_main_settlement_archive_id(self) -> None:
finished_ledger_txn = ProductionBatchLedgerTxn(
id=301,
production_ledger_id=self.ledger.id,
txn_type="成品入库",
qty_delta=Decimal("198"),
weight_delta_kg=Decimal("-118.8"),
outside_weight_after_kg=Decimal("81.2"),
source_doc_type="库存流水",
source_doc_id=self.finished_txn.id,
source_line_id=self.finished_lot.id,
biz_time=self.biz_time,
operator_user_id=1,
remark="成品入库联动生产台账;单据批次号:生产结算20260611103000000001",
created_at=self.now,
updated_at=self.now,
)
surplus_ledger_txn = ProductionBatchLedgerTxn(
id=302,
production_ledger_id=self.ledger.id,
txn_type="生产余料入库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-60"),
outside_weight_after_kg=Decimal("21.2"),
source_doc_type="库存流水",
source_doc_id=self.surplus_txn.id,
source_line_id=self.raw_lot.id,
biz_time=self.biz_time,
operator_user_id=1,
remark="生产余料入库联动生产台账;单据批次号:生产结算20260611103000000001",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([finished_ledger_txn, surplus_ledger_txn])
self.db.commit()
with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id)
rows = list_production_batch_ledger(production_ledger_id=self.ledger.id, status=None, limit=20, db=self.db)
txn_by_type = {txn.txn_type: txn for txn in rows[0].txns}
self.assertEqual(txn_by_type["成品入库"].archive_business_id, self.finished_txn.id)
self.assertEqual(txn_by_type["生产余料入库"].archive_business_id, self.finished_txn.id)
self.assertEqual(txn_by_type["成品入库"].archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(txn_by_type["生产余料入库"].archive_status, ARCHIVE_STATUS_READY)
def test_same_second_different_document_batches_do_not_share_archive_id(self) -> None:
first_finished_ledger_txn = ProductionBatchLedgerTxn(
id=311,
production_ledger_id=self.ledger.id,
txn_type="成品入库",
qty_delta=Decimal("198"),
weight_delta_kg=Decimal("-118.8"),
outside_weight_after_kg=Decimal("81.2"),
source_doc_type="库存流水",
source_doc_id=self.finished_txn.id,
source_line_id=self.finished_lot.id,
biz_time=self.biz_time,
operator_user_id=1,
remark="成品入库联动生产台账;单据批次号:生产结算20260611103000000001",
created_at=self.now,
updated_at=self.now,
)
second_surplus_txn = InventoryTxn(
id=403,
txn_no="TXN-SURPLUS-002",
txn_type="PRODUCTION_SURPLUS_IN",
item_id=self.material_item.id,
warehouse_id=self.raw_warehouse.id,
lot_id=self.raw_lot.id,
qty_change=Decimal("0"),
weight_change_kg=Decimal("12"),
unit_cost=Decimal("6.6"),
amount=Decimal("79.20"),
source_doc_type="生产台账",
source_doc_id=self.ledger.id,
biz_time=self.biz_time,
remark="同秒第二次余料入库;单据批次号:生产结算20260611103000000002",
created_at=self.now,
updated_at=self.now,
)
second_surplus_ledger_txn = ProductionBatchLedgerTxn(
id=312,
production_ledger_id=self.ledger.id,
txn_type="生产余料入库",
qty_delta=Decimal("0"),
weight_delta_kg=Decimal("-12"),
outside_weight_after_kg=Decimal("69.2"),
source_doc_type="库存流水",
source_doc_id=second_surplus_txn.id,
source_line_id=self.raw_lot.id,
biz_time=self.biz_time,
operator_user_id=1,
remark="同秒第二次余料入库联动生产台账;单据批次号:生产结算20260611103000000002",
created_at=self.now,
updated_at=self.now,
)
self.db.add_all([first_finished_ledger_txn, second_surplus_txn, second_surplus_ledger_txn])
self.db.commit()
with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id)
generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, second_surplus_txn.id)
rows = list_production_batch_ledger(production_ledger_id=self.ledger.id, status=None, limit=20, db=self.db)
txn_by_id = {txn.production_ledger_txn_id: txn for txn in rows[0].txns}
self.assertEqual(txn_by_id[first_finished_ledger_txn.id].archive_business_id, self.finished_txn.id)
self.assertEqual(txn_by_id[second_surplus_ledger_txn.id].archive_business_id, second_surplus_txn.id)
def test_generate_production_material_out_archive_pdf(self) -> None:
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_PRODUCTION_MATERIAL_OUT, self.ledger.id)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
archive = self.db.scalar(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT,
DocumentArchive.business_id == self.ledger.id,
DocumentArchive.archive_version == 1,
)
)
self.assertIsNotNone(archive)
def test_generate_production_inbound_settlement_archive_pdf(self) -> None:
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_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
archive = self.db.scalar(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DocumentArchive.business_id == self.finished_txn.id,
DocumentArchive.archive_version == 1,
)
)
self.assertIsNotNone(archive)
def test_archive_generation_failure_keeps_existing_business_rows(self) -> None:
with patch("app.services.document_archives.render_pdf_archive", side_effect=RuntimeError("PDF服务异常")):
result = generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id)
self.assertEqual(result.archive_status, "归档失败")
self.assertIsNotNone(self.db.get(InventoryTxn, self.finished_txn.id))
archive = self.db.scalar(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT,
DocumentArchive.business_id == self.finished_txn.id,
)
)
self.assertIsNotNone(archive)
self.assertEqual(archive.status, "归档失败")
def test_latest_archive_rows_can_be_joined_by_business_id(self) -> None:
with TemporaryDirectory() as tmp_dir, patch("app.services.document_archives.default_archive_root", return_value=tmp_dir):
generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT, self.ledger.id)
generate_document_archive(self.db, DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT, self.finished_txn.id)
material_archive = self.db.scalar(
select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_MATERIAL_OUT)
)
settlement_archive = self.db.scalar(
select(DocumentArchive).where(DocumentArchive.document_type == DOCUMENT_TYPE_PRODUCTION_INBOUND_SETTLEMENT)
)
self.assertIsNotNone(material_archive)
self.assertIsNotNone(settlement_archive)
self.assertEqual(material_archive.business_id, self.ledger.id)
self.assertEqual(settlement_archive.business_id, self.finished_txn.id)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,393 @@
from __future__ import annotations
import unittest
from datetime import 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.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.master_data import Bom, BomItem, Item, StockBalance, Warehouse # noqa: E402
from app.models.operations import ( # noqa: E402
InventoryTxn,
Process,
ProcessRoute,
ProcessRouteOperation,
StockLot,
Stocktake,
StocktakeWarehouse,
WorkCenter,
WorkOrder,
WorkOrderMaterial,
WorkOrderMaterialIssue,
WorkOrderOperation,
)
from app.models.org import Department # noqa: E402
from app.schemas.operations import ProductionWorkOrderInboundCreate # noqa: E402
from app.services.production_work_order_inbound import ( # noqa: E402
create_production_work_order_inbound,
preview_production_work_order_inbound,
)
class ProductionWorkOrderInboundTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
raw_connection = engine.raw_connection()
raw_connection.create_function("greatest", -1, lambda *values: max(value for value in values if value is not None))
raw_connection.close()
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self.raw_item = Item(id=1, item_code="RM-001", item_name="冷轧钢板", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE")
self.product = Item(id=2, item_code="FG-001", item_name="钢制碗", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("0.50"), status="ACTIVE")
self.raw_wh = Warehouse(id=1, warehouse_code="WH-RAW", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE")
self.finished_wh = Warehouse(id=2, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE")
self.scrap_wh = Warehouse(id=3, warehouse_code="WH-SCRAP", warehouse_name="废料库", warehouse_type="SCRAP", status="ACTIVE")
self.db.add_all([self.raw_item, self.product, self.raw_wh, self.finished_wh, self.scrap_wh])
self.db.flush()
self.bom = Bom(id=1, bom_code="BOM-001", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE")
self.bom_item = BomItem(
id=1,
bom_id=1,
seq_no=1,
material_item_id=1,
usage_type="WEIGHT",
net_weight_per_piece_kg=Decimal("0.50"),
gross_weight_per_piece_kg=Decimal("0.60"),
loss_rate=Decimal("0.1667"),
)
self.department = Department(id=1, dept_code="PROD", dept_name="生产部", dept_type="PRODUCTION", status="ACTIVE")
self.process1 = Process(id=1, process_code="P1", process_name="开平", process_type="INTERNAL", status="ACTIVE")
self.process2 = Process(id=2, process_code="P2", process_name="冲压", process_type="INTERNAL", status="ACTIVE")
self.work_center = WorkCenter(id=1, center_code="WC-001", center_name="车间", dept_id=1, center_type="INTERNAL", capacity_hours_per_day=Decimal("8"), status="ACTIVE")
self.route = ProcessRoute(id=1, route_code="RT-001", route_name="钢制碗工艺", product_item_id=2, version_no="V1.0", is_default=1, status="ACTIVE")
self.route_op1 = ProcessRouteOperation(id=1, route_id=1, seq_no=1, process_id=1, operation_name="1序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE")
self.route_op2 = ProcessRouteOperation(id=2, route_id=1, seq_no=2, process_id=2, operation_name="2序", work_center_id=1, std_cycle_seconds=Decimal("10"), status="ACTIVE")
self.db.add_all([self.bom, self.bom_item, self.department, self.process1, self.process2, self.work_center, self.route, self.route_op1, self.route_op2])
self.source_lot = StockLot(
id=1,
lot_no="JH_冷轧钢板00001_0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="PURCHASE_RECEIPT",
source_doc_id=10,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("500"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("300"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("4"),
quality_status="PASS",
status="AVAILABLE",
)
self.balance = StockBalance(
id=1,
item_id=1,
warehouse_id=1,
qty_on_hand=Decimal("0"),
weight_on_hand_kg=Decimal("300"),
qty_available=Decimal("0"),
weight_available_kg=Decimal("300"),
qty_allocated=Decimal("0"),
weight_allocated_kg=Decimal("0"),
avg_unit_cost=Decimal("4"),
)
self.work_order = WorkOrder(
id=1,
work_order_no="WO-001",
work_order_type="NORMAL",
product_item_id=2,
route_id=1,
bom_id=1,
planned_qty=Decimal("333"),
released_qty=Decimal("333"),
finished_qty=Decimal("0"),
scrap_qty=Decimal("0"),
status="RELEASED",
)
self.material_row = WorkOrderMaterial(
id=1,
work_order_id=1,
bom_item_id=1,
material_item_id=1,
required_qty=Decimal("0"),
required_weight_kg=Decimal("200"),
issued_qty=Decimal("0"),
issued_weight_kg=Decimal("200"),
returned_qty=Decimal("0"),
returned_weight_kg=Decimal("0"),
status="ISSUED",
)
self.issue = WorkOrderMaterialIssue(
id=1,
work_order_material_id=1,
work_order_id=1,
material_item_id=1,
source_lot_id=1,
material_sub_batch_no="JH_冷轧钢板00001_0001",
issued_qty=Decimal("0"),
issued_weight_kg=Decimal("200"),
unit_cost=Decimal("4"),
amount=Decimal("800"),
issue_time=datetime.now(),
)
self.op1 = WorkOrderOperation(
id=1,
work_order_id=1,
route_operation_id=1,
seq_no=1,
operation_name="1序",
work_center_id=1,
planned_qty=Decimal("333"),
report_qty=Decimal("210"),
good_qty=Decimal("206"),
scrap_qty=Decimal("4"),
status="RUNNING",
)
self.op2 = WorkOrderOperation(
id=2,
work_order_id=1,
route_operation_id=2,
seq_no=2,
operation_name="2序",
work_center_id=1,
planned_qty=Decimal("333"),
report_qty=Decimal("200"),
good_qty=Decimal("198"),
scrap_qty=Decimal("2"),
status="RUNNING",
)
self.db.add_all([self.source_lot, self.balance, self.work_order, self.material_row, self.issue, self.op1, self.op2])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_preview_calculates_defaults_from_finished_qty(self) -> None:
preview = preview_production_work_order_inbound(self.db, work_order_id=1, finished_qty=Decimal("198"))
self.assertEqual(preview.work_order_no, "WO-001")
self.assertEqual(preview.source_lot_no, "JH_冷轧钢板00001_0001")
self.assertEqual(preview.finished_qty, 198)
self.assertEqual(preview.issued_weight_kg, 200)
self.assertEqual(preview.returned_weight_kg, 0)
self.assertEqual(preview.final_operation_good_qty, 198)
self.assertEqual(preview.reported_scrap_qty, 6)
self.assertEqual(preview.theoretical_surplus_weight_kg, 81.2)
self.assertEqual(preview.theoretical_scrap_weight_kg, 24)
def test_preview_defaults_finished_qty_from_issued_weight_reference(self) -> None:
preview = preview_production_work_order_inbound(self.db, work_order_id=1)
self.assertEqual(preview.reference_finished_qty, 333)
self.assertEqual(preview.remaining_reference_finished_qty, 333)
self.assertEqual(preview.finished_qty, 333)
self.assertEqual(preview.theoretical_surplus_weight_kg, 0.2)
def test_submit_requires_deviation_remark_when_finished_qty_exceeds_reference_by_15_percent(self) -> None:
payload = ProductionWorkOrderInboundCreate(
work_order_id=1,
finished_qty=500,
surplus_weight_kg=0,
scrap_weight_kg=24,
)
with self.assertRaises(HTTPException) as raised:
create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1)
self.assertEqual(raised.exception.status_code, 400)
self.assertEqual(raised.exception.detail, "成品入库数量超过本工单参考生产数量上限15%,请填写偏差说明")
def test_submit_requires_deviation_remark_when_surplus_or_scrap_differs_more_than_15_percent(self) -> None:
payload = ProductionWorkOrderInboundCreate(
work_order_id=1,
finished_qty=198,
surplus_weight_kg=60,
scrap_weight_kg=24,
)
with self.assertRaises(HTTPException) as raised:
create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1)
self.assertEqual(raised.exception.status_code, 400)
self.assertEqual(raised.exception.detail, "余料或废料入库重量与理论值偏差超过上下15%,请填写偏差说明")
def test_submit_posts_finished_surplus_and_scrap_in_one_transaction(self) -> None:
payload = ProductionWorkOrderInboundCreate(
work_order_id=1,
finished_qty=198,
surplus_weight_kg=81.2,
scrap_weight_kg=24,
)
result = create_production_work_order_inbound(self.db, payload=payload, operator_user_id=7)
self.assertEqual(result.finished_qty, 198)
self.assertEqual(result.surplus_weight_kg, 81.2)
self.assertEqual(result.scrap_weight_kg, 24)
txns = self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all()
self.assertEqual([txn.txn_type for txn in txns], ["FG_IN", "PRODUCTION_SURPLUS_IN", "PRODUCTION_SCRAP_IN"])
self.assertEqual(txns[0].operator_user_id, 7)
self.assertEqual(txns[0].source_doc_type, "FG_RECEIPT")
self.assertEqual(txns[1].source_doc_type, "WORK_ORDER")
self.assertEqual(txns[1].lot_id, 1)
self.assertEqual(float(txns[1].weight_change_kg), 81.2)
self.assertEqual(txns[2].source_doc_type, "WORK_ORDER")
self.assertEqual(txns[2].source_doc_id, 1)
source_lot = self.db.get(StockLot, 1)
work_order = self.db.get(WorkOrder, 1)
material_row = self.db.get(WorkOrderMaterial, 1)
finished_lot = self.db.get(StockLot, result.finished_lot_id)
scrap_lot = self.db.get(StockLot, result.scrap_lot_id)
self.assertEqual(float(source_lot.remaining_weight_kg), 381.2)
self.assertEqual(float(work_order.finished_qty), 198)
self.assertEqual(float(material_row.returned_weight_kg), 81.2)
self.assertEqual(finished_lot.warehouse_id, 2)
self.assertEqual(finished_lot.source_material_lot_id, 1)
self.assertEqual(float(finished_lot.unit_cost), 2.4)
self.assertEqual(scrap_lot.warehouse_id, 3)
self.assertEqual(scrap_lot.source_material_lot_id, 1)
self.assertEqual(scrap_lot.quality_status, "SCRAP")
def test_submit_settles_only_when_requested_and_confirmed(self) -> None:
payload = ProductionWorkOrderInboundCreate(
work_order_id=1,
finished_qty=198,
surplus_weight_kg=81.2,
scrap_weight_kg=24,
settle_work_order=True,
)
with self.assertRaises(HTTPException) as raised:
create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1)
self.assertEqual(raised.exception.status_code, 400)
self.assertEqual(raised.exception.detail, "结单前必须确认本次入库明细及结单影响")
self.assertEqual(self.db.get(WorkOrder, 1).status, "RELEASED")
payload.settle_confirmed = True
result = create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1)
work_order = self.db.get(WorkOrder, 1)
self.assertTrue(result.settled)
self.assertEqual(work_order.status, "SETTLED")
self.assertIsNotNone(work_order.actual_end_time)
self.assertIn("生产工单入库已结单", work_order.remark)
def test_submit_without_settle_flags_does_not_settle(self) -> None:
payload = ProductionWorkOrderInboundCreate(
work_order_id=1,
finished_qty=198,
surplus_weight_kg=81.2,
scrap_weight_kg=24,
)
result = create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1)
work_order = self.db.get(WorkOrder, 1)
self.assertFalse(result.settled)
self.assertEqual(work_order.status, "RELEASED")
self.assertIsNone(work_order.actual_end_time)
def test_preview_rejects_work_order_with_more_than_one_source_lot(self) -> None:
other_lot = StockLot(
id=2,
lot_no="JH_冷轧钢板00002_0001",
lot_role="RAW_MATERIAL",
item_id=1,
warehouse_id=1,
source_doc_type="PURCHASE_RECEIPT",
source_doc_id=11,
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("4"),
quality_status="PASS",
status="AVAILABLE",
)
other_issue = WorkOrderMaterialIssue(
id=2,
work_order_material_id=1,
work_order_id=1,
material_item_id=1,
source_lot_id=2,
material_sub_batch_no="JH_冷轧钢板00002_0001",
issued_qty=Decimal("0"),
issued_weight_kg=Decimal("10"),
unit_cost=Decimal("4"),
amount=Decimal("40"),
issue_time=datetime.now(),
)
self.db.add_all([other_lot, other_issue])
self.db.commit()
with self.assertRaises(HTTPException) as raised:
preview_production_work_order_inbound(self.db, work_order_id=1, finished_qty=Decimal("198"))
self.assertEqual(raised.exception.status_code, 400)
self.assertEqual(raised.exception.detail, "该工单存在多个材料来源库存批次,不能进行生产工单入库")
def test_submit_rejects_locked_involved_warehouse_before_posting_inventory(self) -> None:
stocktake = Stocktake(
stocktake_no="PK-20260608-001",
status="LOCKED",
started_by_user_id=1,
started_at=datetime.now(),
remark="成品库盘库",
)
self.db.add(stocktake)
self.db.flush()
self.db.add(
StocktakeWarehouse(
stocktake_id=stocktake.id,
warehouse_id=self.finished_wh.id,
warehouse_type="FINISHED",
warehouse_name=self.finished_wh.warehouse_name,
status="LOCKED",
)
)
self.db.commit()
payload = ProductionWorkOrderInboundCreate(
work_order_id=1,
finished_qty=198,
surplus_weight_kg=81.2,
scrap_weight_kg=24,
)
with self.assertRaises(HTTPException) as raised:
create_production_work_order_inbound(self.db, payload=payload, operator_user_id=1)
self.assertEqual(raised.exception.status_code, 423)
self.assertIn("成品库正在盘库", raised.exception.detail)
txns = self.db.scalars(select(InventoryTxn)).all()
self.assertEqual(txns, [])
self.assertEqual(float(self.db.get(WorkOrder, 1).finished_qty), 0)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,255 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from unittest.mock import patch
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@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.api.routes.purchase import create_purchase_order, list_purchase_orders, update_purchase_order # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, Material # noqa: E402
from app.models.operations import Supplier # noqa: E402
from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402
from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseOrderUpdate # noqa: E402
class PurchaseOrderDocumentArchiveTest(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()
now = datetime.now()
self.supplier = Supplier(
id=1,
supplier_code="SUP-001",
supplier_name="测试供应商",
short_name="供应商",
address="宁波市测试路 1 号",
lead_time_days=0,
default_tax_rate=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.material_item = Item(
id=10,
item_code="RM-001",
item_name="测试原材料",
item_type="RAW_MATERIAL",
specification="A-100",
material_grade="42CrMo",
unit_weight_kg=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.material = Material(
id=100,
item_id=self.material_item.id,
default_supplier_id=self.supplier.id,
purchase_calc_mode="WEIGHT",
min_purchase_qty=0,
purchase_multiple_qty=0,
purchase_multiple_weight_kg=0,
lead_time_days=0,
quality_rule=None,
created_at=now,
updated_at=now,
)
self.db.add_all([self.supplier, self.material_item, self.material])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _payload(self, *, order_weight_kg: float = 12) -> PurchaseOrderCreate:
return PurchaseOrderCreate(
supplier_id=self.supplier.id,
order_date=date(2026, 6, 11),
expected_date=date(2026, 6, 20),
warning_lead_days=0,
purchaser_employee_id=None,
target_warehouse_type="RAW",
sales_order_ids=[],
tax_rate=0.13,
remark="采购归档测试",
items=[
PurchaseOrderItemCreate(
material_item_id=self.material_item.id,
order_weight_kg=order_weight_kg,
unit_price=100,
remark="测试明细",
)
],
)
def _archive_result(self, *, business_id: int, document_no: str, archive_version: int = 1) -> DocumentArchiveGenerateResult:
return DocumentArchiveGenerateResult(
business_id=business_id,
document_type="采购订单",
document_no=document_no,
archive_status="已归档",
archive_version=archive_version,
archive_error_message=None,
)
def test_create_purchase_order_generates_archive_and_returns_archive_metadata(self) -> None:
def fake_generate_archive(
db: Session,
document_type: str,
business_id: int,
) -> DocumentArchiveGenerateResult:
db.add(
DocumentArchive(
document_type=document_type,
business_id=business_id,
document_no="采购2026-00001",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="采购2026-00001.pdf",
file_path="/tmp/采购2026-00001.pdf",
file_hash="abc123",
status="已归档",
error_message=None,
created_by=None,
created_at=datetime(2026, 6, 11, 10, 30, 0),
updated_at=datetime(2026, 6, 11, 10, 30, 0),
)
)
db.commit()
return self._archive_result(business_id=business_id, document_no="采购2026-00001")
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch("app.api.routes.purchase.generate_document_archive", side_effect=fake_generate_archive) as archive_mock,
):
result = create_purchase_order(self._payload(), self.db)
archive_mock.assert_called_once_with(self.db, "采购订单", int(result.purchase_order_id))
self.assertEqual(result.archive_status, "已归档")
self.assertEqual(result.archive_version, 1)
self.assertIsNone(result.archive_error_message)
saved_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.business_id == result.purchase_order_id))
self.assertIsNotNone(saved_archive)
def test_create_purchase_order_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None:
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch("app.api.routes.purchase.generate_document_archive", side_effect=RuntimeError("renderer exploded")),
):
result = create_purchase_order(self._payload(), self.db)
self.assertIsNotNone(result.purchase_order_id)
self.assertTrue(result.po_no)
self.assertEqual(result.archive_status, "归档失败")
self.assertIsNone(result.archive_version)
self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:"))
def test_list_purchase_orders_uses_pdf_archive_when_same_version_has_other_format_without_duplicates(self) -> None:
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch(
"app.api.routes.purchase.generate_document_archive",
return_value=self._archive_result(business_id=1, document_no="采购2026-00001"),
),
):
result = create_purchase_order(self._payload(), self.db)
self.db.add_all(
[
DocumentArchive(
document_type="采购订单",
business_id=result.purchase_order_id,
document_no=result.po_no,
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="order.pdf",
file_path="/tmp/order.pdf",
file_hash="pdf",
status="已归档",
error_message=None,
created_by=None,
created_at=datetime(2026, 6, 11, 10, 30, 0),
updated_at=datetime(2026, 6, 11, 10, 30, 0),
),
DocumentArchive(
document_type="采购订单",
business_id=result.purchase_order_id,
document_no=result.po_no,
archive_version=1,
template_version="单据纸面V1",
file_format="DOCX",
file_name="order.docx",
file_path="/tmp/order.docx",
file_hash="docx",
status="DOCX不应显示",
error_message="wrong format",
created_by=None,
created_at=datetime(2026, 6, 11, 10, 35, 0),
updated_at=datetime(2026, 6, 11, 10, 35, 0),
),
]
)
self.db.commit()
rows = list_purchase_orders(limit=200, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].archive_status, "已归档")
self.assertEqual(rows[0].archive_version, 1)
self.assertIsNone(rows[0].archive_error_message)
def test_update_purchase_order_generates_archive_and_returns_archive_metadata(self) -> None:
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch(
"app.api.routes.purchase.generate_document_archive",
return_value=self._archive_result(business_id=1, document_no="采购2026-00001"),
),
):
created = create_purchase_order(self._payload(), self.db)
update_payload = PurchaseOrderUpdate.model_validate(self._payload(order_weight_kg=18).model_dump())
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch(
"app.api.routes.purchase.generate_document_archive",
return_value=self._archive_result(
business_id=created.purchase_order_id,
document_no=created.po_no,
archive_version=2,
),
) as archive_mock,
):
updated = update_purchase_order(created.purchase_order_id, update_payload, self.db)
archive_mock.assert_called_once_with(self.db, "采购订单", int(created.purchase_order_id))
self.assertEqual(updated.archive_status, "已归档")
self.assertEqual(updated.archive_version, 2)
self.assertEqual(updated.total_order_weight_kg, 18)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,166 @@
from __future__ import annotations
import unittest
from datetime import date
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.purchase import create_purchase_order, update_purchase_order # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Item, Material # noqa: E402
from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseOrderSalesOrderLink, Supplier # noqa: E402
from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402
from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseOrderUpdate # noqa: E402
class PurchaseOrderMultiSalesLinksTest(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.supplier = Supplier(id=1, supplier_code="SUP-MULTI-001", supplier_name="多订单供应商", short_name="多订单供应商", lead_time_days=0, default_tax_rate=Decimal("0"), status="ACTIVE")
self.customer = Customer(id=1, customer_code="CUS-MULTI-001", customer_name="多订单客户", short_name="客户", status="ACTIVE")
self.product = Item(id=1, item_code="FG-MULTI-001", item_name="多订单成品", item_type="FINISHED_GOOD", unit_weight_kg=Decimal("1"), status="ACTIVE")
self.material = Item(id=2, item_code="RM-MULTI-001", item_name="多订单原料", item_type="RAW_MATERIAL", unit_weight_kg=Decimal("1"), status="ACTIVE")
self.db.add_all([self.supplier, self.customer, self.product, self.material])
self.db.flush()
self.db.add(Material(item_id=self.material.id, default_supplier_id=self.supplier.id, purchase_calc_mode="BY_WEIGHT", min_purchase_qty=Decimal("0"), purchase_multiple_qty=Decimal("0"), purchase_multiple_weight_kg=Decimal("0"), lead_time_days=0))
self.order1 = SalesOrder(id=1, order_no="销售2026-00001", customer_id=self.customer.id, order_date=date.today(), status="OPEN")
self.order2 = SalesOrder(id=2, order_no="销售2026-00002", customer_id=self.customer.id, order_date=date.today(), status="OPEN")
self.item1 = SalesOrderItem(id=1, sales_order_id=1, line_no=1, product_item_id=self.product.id, order_qty=Decimal("100"), delivered_qty=Decimal("0"), unit_price=Decimal("10"), line_amount=Decimal("1000"), status="OPEN")
self.item2 = SalesOrderItem(id=2, sales_order_id=2, line_no=1, product_item_id=self.product.id, order_qty=Decimal("200"), delivered_qty=Decimal("0"), unit_price=Decimal("10"), line_amount=Decimal("2000"), status="OPEN")
self.db.add_all([self.order1, self.order2, self.item1, self.item2])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def test_purchase_order_can_link_multiple_sales_orders(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="RAW",
sales_order_ids=[self.order1.id, self.order2.id],
items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))],
),
db=self.db,
)
links = self.db.scalars(select(PurchaseOrderSalesOrderLink).order_by(PurchaseOrderSalesOrderLink.sales_order_id)).all()
self.assertEqual([link.sales_order_id for link in links], [self.order1.id, self.order2.id])
self.assertEqual(result.source_type, "SALES_ORDER")
self.assertEqual(result.source_order_nos, "销售2026-00001、销售2026-00002")
def test_purchase_order_recreates_missing_sales_order_link_table(self) -> None:
PurchaseOrderSalesOrderLink.__table__.drop(self.db.get_bind())
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="RAW",
sales_order_ids=[self.order1.id],
items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))],
),
db=self.db,
)
links = self.db.scalars(select(PurchaseOrderSalesOrderLink)).all()
self.assertEqual(result.source_type, "SALES_ORDER")
self.assertEqual([link.sales_order_id for link in links], [self.order1.id])
def test_purchase_order_update_replaces_sales_order_links(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="RAW",
sales_order_ids=[self.order1.id, self.order2.id],
items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))],
),
db=self.db,
)
updated = update_purchase_order(
result.purchase_order_id,
PurchaseOrderUpdate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="RAW",
sales_order_ids=[self.order2.id],
items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("250"), unit_price=Decimal("6"))],
),
db=self.db,
)
links = self.db.scalars(select(PurchaseOrderSalesOrderLink).order_by(PurchaseOrderSalesOrderLink.sales_order_id)).all()
self.assertEqual([link.sales_order_id for link in links], [self.order2.id])
self.assertEqual(updated.source_type, "SALES_ORDER")
self.assertEqual(updated.source_order_nos, "销售2026-00002")
def test_purchase_order_update_missing_sales_order_keeps_existing_links_visible(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="RAW",
sales_order_ids=[self.order1.id],
items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))],
),
db=self.db,
)
with self.assertRaises(HTTPException) as exc:
update_purchase_order(
result.purchase_order_id,
PurchaseOrderUpdate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="RAW",
sales_order_ids=[999],
items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("250"), unit_price=Decimal("6"))],
),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 404)
links = self.db.scalars(select(PurchaseOrderSalesOrderLink).where(PurchaseOrderSalesOrderLink.purchase_order_id == result.purchase_order_id)).all()
self.assertEqual([link.sales_order_id for link in links], [self.order1.id])
order = self.db.get(PurchaseOrder, result.purchase_order_id)
self.assertEqual(float(order.total_amount), 1800)
items = self.db.scalars(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == result.purchase_order_id)).all()
self.assertEqual([float(item.order_weight_kg) for item in items], [300])
def test_purchase_order_rejects_non_positive_sales_order_ids(self) -> None:
with self.assertRaises(HTTPException) as exc:
create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
target_warehouse_type="RAW",
sales_order_ids=[0],
items=[PurchaseOrderItemCreate(material_item_id=self.material.id, order_weight_kg=Decimal("300"), unit_price=Decimal("6"))],
),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 400)
@@ -0,0 +1,94 @@
from __future__ import annotations
import unittest
from datetime import UTC, date, datetime
from sqlalchemy import BigInteger, create_engine
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.purchase import build_purchase_order_no # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.operations import PurchaseOrder, Supplier # noqa: E402
class PurchaseOrderNoGenerationTest(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.supplier = Supplier(
id=1,
supplier_code="SUP-001",
supplier_name="测试供应商",
short_name="供应商",
lead_time_days=0,
default_tax_rate=0,
status="ACTIVE",
)
self.db.add(self.supplier)
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _add_purchase_order(self, po_no: str) -> None:
now = datetime.now(UTC)
self.db.add(
PurchaseOrder(
po_no=po_no,
supplier_id=self.supplier.id,
order_date=date.today(),
expected_date=None,
purchaser_employee_id=None,
tax_rate=0,
total_amount=0,
status="OPEN",
remark=None,
created_at=now,
updated_at=now,
)
)
self.db.commit()
def test_purchase_order_no_starts_from_named_year_sequence(self) -> None:
year = date.today().strftime("%Y")
self.assertEqual(build_purchase_order_no(self.db, self.supplier), f"采购{year}-00001")
def test_purchase_order_no_increments_matching_current_year_codes(self) -> None:
year = date.today().strftime("%Y")
previous_year = str(int(year) - 1)
self._add_purchase_order(f"采购{year}-00001")
self._add_purchase_order(f"采购{year}-00009")
self._add_purchase_order(f"采购{previous_year}-00099")
self._add_purchase_order(f"{year}-00099")
self._add_purchase_order("PO-NB-20260523-SUP001-001")
self.assertEqual(build_purchase_order_no(self.db, self.supplier), f"采购{year}-00010")
def test_purchase_order_no_ignores_malformed_same_year_codes(self) -> None:
year = date.today().strftime("%Y")
self._add_purchase_order(f"采购{year}-001")
self._add_purchase_order(f"采购{year}-000001")
self._add_purchase_order(f"采购{year}-ABCDE")
self.assertEqual(build_purchase_order_no(self.db, self.supplier), f"采购{year}-00001")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,283 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from unittest.mock import patch
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@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.api.routes.purchase import create_purchase_order, create_purchase_receipt, lock_purchase_order # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, Material, Warehouse # noqa: E402
from app.models.operations import PurchaseOrderItem, Supplier # noqa: E402
from app.models.org import Department, Employee, User # noqa: E402
from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402
from app.schemas.operations import ( # noqa: E402
PurchaseOrderCreate,
PurchaseOrderItemCreate,
PurchaseReceiptCreate,
PurchaseReceiptItemCreate,
)
from app.services.auth import AuthContext # noqa: E402
class PurchaseReceiptDocumentArchiveTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed_master_data()
def tearDown(self) -> None:
self.db.close()
def _seed_master_data(self) -> None:
now = datetime(2026, 6, 11, 9, 0, 0)
self.department = Department(
id=1,
dept_code="D-WH-001",
dept_name="仓储部",
dept_type="WAREHOUSE",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.employee = Employee(
id=1,
employee_code="EMP-WH-001",
employee_name="仓库接收员",
dept_id=1,
mobile="13800000000",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.user = User(
id=1,
username="receipt_tester",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="到货测试员",
is_super_admin=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.supplier = Supplier(
id=1,
supplier_code="SUP-RC-001",
supplier_name="到货供应商",
short_name="到货供应商",
lead_time_days=0,
default_tax_rate=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.material_item = Item(
id=10,
item_code="RM-RC-001",
item_name="到货原材料",
item_type="RAW_MATERIAL",
specification="A-200",
material_grade="45#",
unit_weight_kg=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.material = Material(
id=100,
item_id=self.material_item.id,
default_supplier_id=self.supplier.id,
purchase_calc_mode="WEIGHT",
min_purchase_qty=0,
purchase_multiple_qty=0,
purchase_multiple_weight_kg=0,
lead_time_days=0,
quality_rule=None,
created_at=now,
updated_at=now,
)
self.warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([
self.department,
self.employee,
self.user,
self.supplier,
self.material_item,
self.material,
self.warehouse,
])
self.db.commit()
def _auth_context(self) -> AuthContext:
return AuthContext(
user=self.user,
employee=self.employee,
department=self.department,
role_codes=["ADMIN"],
role_names=["管理员"],
permission_codes=[],
)
def _create_locked_purchase_order(self) -> tuple[int, PurchaseOrderItem]:
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch(
"app.api.routes.purchase.generate_document_archive",
return_value=DocumentArchiveGenerateResult(
business_id=1,
document_type="采购订单",
document_no="采购2026-00001",
archive_status="已归档",
archive_version=1,
archive_error_message=None,
),
),
):
order = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
order_date=date(2026, 6, 11),
expected_date=date(2026, 6, 20),
target_warehouse_type="RAW",
items=[
PurchaseOrderItemCreate(
material_item_id=self.material_item.id,
order_weight_kg=120,
unit_price=6.8,
)
],
),
db=self.db,
)
lock_purchase_order(order.purchase_order_id, db=self.db)
po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
self.assertIsNotNone(po_item)
return int(order.purchase_order_id), po_item
def _receipt_payload(self, purchase_order_id: int, po_item: PurchaseOrderItem) -> PurchaseReceiptCreate:
return PurchaseReceiptCreate(
purchase_order_id=purchase_order_id,
warehouse_id=self.warehouse.id,
receipt_date=datetime(2026, 6, 11, 10, 0, 0),
receiver_employee_id=self.employee.id,
waybill_no="WB-RC-001",
freight_amount=88.8,
remark="到货归档测试",
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
received_weight_kg=60,
unit_cost=6.8,
)
],
)
def _archive_result(self, *, business_id: int, document_no: str) -> DocumentArchiveGenerateResult:
return DocumentArchiveGenerateResult(
business_id=business_id,
document_type="到货入库单",
document_no=document_no,
archive_status="已归档",
archive_version=1,
archive_error_message=None,
)
def test_create_purchase_receipt_generates_archive_and_returns_archive_metadata(self) -> None:
purchase_order_id, po_item = self._create_locked_purchase_order()
def fake_generate_archive(
db: Session,
document_type: str,
business_id: int,
created_by: int | None = None,
) -> DocumentArchiveGenerateResult:
db.add(
DocumentArchive(
document_type=document_type,
business_id=business_id,
document_no="到货2026-00001",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="到货2026-00001.pdf",
file_path="/tmp/到货2026-00001.pdf",
file_hash="receipt123",
status="已归档",
error_message=None,
created_by=created_by,
created_at=datetime(2026, 6, 11, 10, 30, 0),
updated_at=datetime(2026, 6, 11, 10, 30, 0),
)
)
db.commit()
return self._archive_result(business_id=business_id, document_no="到货2026-00001")
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch("app.api.routes.purchase.generate_document_archive", side_effect=fake_generate_archive) as archive_mock,
):
result = create_purchase_receipt(self._receipt_payload(purchase_order_id, po_item), context=self._auth_context(), db=self.db)
archive_mock.assert_called_once_with(self.db, "到货入库单", int(result.receipt_id), created_by=1)
self.assertEqual(result.archive_status, "已归档")
self.assertEqual(result.archive_version, 1)
self.assertIsNone(result.archive_error_message)
saved_archive = self.db.scalar(
select(DocumentArchive).where(
DocumentArchive.document_type == "到货入库单",
DocumentArchive.business_id == result.receipt_id,
)
)
self.assertIsNotNone(saved_archive)
def test_create_purchase_receipt_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None:
purchase_order_id, po_item = self._create_locked_purchase_order()
with (
patch("app.api.routes.purchase.ensure_employee_has_permission"),
patch("app.api.routes.purchase.generate_document_archive", side_effect=RuntimeError("renderer exploded")),
):
result = create_purchase_receipt(self._receipt_payload(purchase_order_id, po_item), context=self._auth_context(), db=self.db)
self.assertIsNotNone(result.receipt_id)
self.assertTrue(result.receipt_no)
self.assertEqual(result.archive_status, "归档失败")
self.assertIsNone(result.archive_version)
self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,112 @@
from __future__ import annotations
import unittest
from datetime import UTC, date, datetime
from sqlalchemy import BigInteger, create_engine
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.purchase import build_purchase_receipt_no # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Warehouse # noqa: E402
from app.models.operations import PurchaseOrder, PurchaseReceipt, Supplier # noqa: E402
class PurchaseReceiptNoGenerationTest(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.supplier = Supplier(
id=1,
supplier_code="SUP-RECEIPT-001",
supplier_name="入库编号供应商",
short_name="入库编号供应商",
lead_time_days=0,
default_tax_rate=0,
status="ACTIVE",
)
self.warehouse = Warehouse(
id=1,
warehouse_code="WH-RM",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
)
self.order = PurchaseOrder(
id=1,
po_no="采购2026-00001",
supplier_id=1,
order_date=date.today(),
expected_date=None,
purchaser_employee_id=None,
tax_rate=0,
total_amount=0,
status="LOCKED",
remark=None,
)
self.db.add_all([self.supplier, self.warehouse, self.order])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _add_receipt(self, receipt_no: str) -> None:
now = datetime.now(UTC)
self.db.add(
PurchaseReceipt(
receipt_no=receipt_no,
purchase_order_id=self.order.id,
warehouse_id=self.warehouse.id,
receipt_date=now,
receiver_employee_id=None,
supplier_delivery_no=None,
status="PENDING_QC",
remark=None,
created_at=now,
updated_at=now,
)
)
self.db.commit()
def test_purchase_receipt_no_starts_from_named_year_sequence(self) -> None:
year = date.today().strftime("%Y")
self.assertEqual(build_purchase_receipt_no(self.db, self.order), f"入库{year}-00001")
def test_purchase_receipt_no_increments_matching_current_year_codes(self) -> None:
year = date.today().strftime("%Y")
previous_year = str(int(year) - 1)
self._add_receipt(f"入库{year}-00001")
self._add_receipt(f"入库{year}-00009")
self._add_receipt(f"入库{previous_year}-00099")
self._add_receipt(f"RCV-NB-{year}0523-PO-001")
self.assertEqual(build_purchase_receipt_no(self.db, self.order), f"入库{year}-00010")
def test_purchase_receipt_no_ignores_malformed_same_year_codes(self) -> None:
year = date.today().strftime("%Y")
self._add_receipt(f"入库{year}-001")
self._add_receipt(f"入库{year}-000001")
self._add_receipt(f"入库{year}-ABCDE")
self.assertEqual(build_purchase_receipt_no(self.db, self.order), f"入库{year}-00001")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,331 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from unittest.mock import patch
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@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.api.routes.quality import inspect_receipt_item # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402
from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseReceipt, PurchaseReceiptItem, StockLot, Supplier # noqa: E402
from app.models.org import Department, Employee, User # noqa: E402
from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402
from app.schemas.operations import QualityInspectionAction # noqa: E402
from app.services.auth import AuthContext # noqa: E402
class QualityInspectionDocumentArchiveTest(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_inspection_data()
def tearDown(self) -> None:
self.db.close()
def _seed_inspection_data(self) -> None:
now = datetime(2026, 6, 11, 9, 0, 0)
self.department = Department(
id=1,
dept_code="D-QC-001",
dept_name="质检部",
dept_type="QUALITY",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.employee = Employee(
id=1,
employee_code="EMP-QC-001",
employee_name="质检员王工",
dept_id=1,
mobile="13800000001",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.user = User(
id=1,
username="quality_tester",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="质检测试员",
is_super_admin=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.supplier = Supplier(
id=10,
supplier_code="SUP-QC-001",
supplier_name="质检供应商",
short_name="质检供应商",
lead_time_days=0,
default_tax_rate=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.material_item = Item(
id=20,
item_code="RM-QC-001",
item_name="质检原材料",
item_type="RAW_MATERIAL",
specification="D60",
material_grade="45#",
unit_weight_kg=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.warehouse = Warehouse(
id=30,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.purchase_order = PurchaseOrder(
id=40,
po_no="采购2026-00001",
supplier_id=self.supplier.id,
order_date=date(2026, 6, 11),
expected_date=date(2026, 6, 20),
warning_lead_days=0,
purchaser_employee_id=None,
target_warehouse_type="RAW",
tax_rate=0.13,
total_amount=660,
status="PENDING_QC",
remark=None,
created_at=now,
updated_at=now,
)
self.purchase_order_item = PurchaseOrderItem(
id=50,
purchase_order_id=self.purchase_order.id,
line_no=1,
material_item_id=self.material_item.id,
source_demand_id=None,
order_qty=0,
order_weight_kg=100,
received_qty=0,
received_weight_kg=0,
unit_price=6.6,
line_amount=660,
status="OPEN",
remark=None,
created_at=now,
updated_at=now,
)
self.receipt = PurchaseReceipt(
id=60,
receipt_no="入库2026-00001",
purchase_order_id=self.purchase_order.id,
warehouse_id=self.warehouse.id,
receipt_date=datetime(2026, 6, 12, 10, 0, 0),
receiver_employee_id=None,
supplier_delivery_no=None,
logistics_waybill_no="YD-QC-001",
logistics_freight_amount=50,
logistics_photo_url=None,
status="PENDING_QC",
remark=None,
created_at=now,
updated_at=now,
)
self.receipt_item = PurchaseReceiptItem(
id=70,
receipt_id=self.receipt.id,
line_no=1,
purchase_order_item_id=self.purchase_order_item.id,
material_item_id=self.material_item.id,
location_id=None,
lot_no="YL0001",
merge_to_lot_id=None,
received_qty=0,
received_weight_kg=100,
accepted_qty=0,
accepted_weight_kg=0,
unit_cost=6.6,
status="PENDING_QC",
remark=None,
created_at=now,
updated_at=now,
)
self.stock_lot = StockLot(
id=80,
lot_no=self.receipt_item.lot_no,
parent_lot_id=None,
lot_role="INBOUND_RAW",
material_sub_batch_no=None,
item_id=self.material_item.id,
warehouse_id=self.warehouse.id,
location_id=None,
source_doc_type="PURCHASE_RECEIPT",
source_doc_id=self.receipt.id,
source_line_id=self.receipt_item.id,
source_material_lot_id=None,
source_material_sub_batch_no=None,
source_material_summary=None,
inbound_qty=0,
inbound_weight_kg=100,
remaining_qty=0,
remaining_weight_kg=100,
locked_qty=0,
locked_weight_kg=100,
unit_cost=6.6,
production_date=None,
expire_date=None,
quality_status="PENDING_QC",
logistics_waybill_no="YD-QC-001",
logistics_freight_amount=50,
logistics_photo_url=None,
status="LOCKED",
remark="到货待检",
created_at=now,
updated_at=now,
)
self.balance = StockBalance(
id=90,
item_id=self.material_item.id,
warehouse_id=self.warehouse.id,
location_id=None,
qty_on_hand=0,
weight_on_hand_kg=100,
qty_available=0,
weight_available_kg=0,
qty_allocated=0,
weight_allocated_kg=0,
avg_unit_cost=6.6,
created_at=now,
updated_at=now,
)
self.db.add_all([
self.department,
self.employee,
self.user,
self.supplier,
self.material_item,
self.warehouse,
self.purchase_order,
self.purchase_order_item,
self.receipt,
self.receipt_item,
self.stock_lot,
self.balance,
])
self.db.commit()
def _auth_context(self) -> AuthContext:
return AuthContext(
user=self.user,
employee=self.employee,
department=self.department,
role_codes=["ADMIN"],
role_names=["管理员"],
permission_codes=[],
)
def _archive_result(self, *, business_id: int) -> DocumentArchiveGenerateResult:
return DocumentArchiveGenerateResult(
business_id=business_id,
document_type="质量校验单",
document_no="质检-入库2026-00001-1",
archive_status="已归档",
archive_version=1,
archive_error_message=None,
)
def test_inspect_receipt_item_generates_quality_archive_and_returns_metadata(self) -> None:
def fake_generate_archive(
db: Session,
document_type: str,
business_id: int,
created_by: int | None = None,
) -> DocumentArchiveGenerateResult:
db.add(
DocumentArchive(
document_type=document_type,
business_id=business_id,
document_no="质检-入库2026-00001-1",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="质检-入库2026-00001-1.pdf",
file_path="/tmp/质检-入库2026-00001-1.pdf",
file_hash="quality123",
status="已归档",
error_message=None,
created_by=created_by,
created_at=datetime(2026, 6, 12, 10, 30, 0),
updated_at=datetime(2026, 6, 12, 10, 30, 0),
)
)
db.commit()
return self._archive_result(business_id=business_id)
with patch("app.api.routes.quality.generate_document_archive", side_effect=fake_generate_archive) as archive_mock:
result = inspect_receipt_item(
self.receipt_item.id,
QualityInspectionAction(result="PASS", remark="表面无划伤"),
context=self._auth_context(),
db=self.db,
)
archive_mock.assert_called_once_with(self.db, "质量校验单", int(self.receipt_item.id), created_by=1)
self.assertEqual(result.status, "PASSED")
self.assertEqual(result.quality_status, "PASS")
self.assertEqual(result.archive_status, "已归档")
self.assertEqual(result.archive_version, 1)
self.assertIsNone(result.archive_error_message)
saved_archive = self.db.scalar(
select(DocumentArchive).where(
DocumentArchive.document_type == "质量校验单",
DocumentArchive.business_id == self.receipt_item.id,
)
)
self.assertIsNotNone(saved_archive)
def test_inspect_receipt_item_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None:
with patch("app.api.routes.quality.generate_document_archive", side_effect=RuntimeError("renderer exploded")):
result = inspect_receipt_item(
self.receipt_item.id,
QualityInspectionAction(result="REJECT", remark="硬度不合格"),
context=self._auth_context(),
db=self.db,
)
self.assertEqual(result.status, "REJECTED")
self.assertEqual(result.archive_status, "归档失败")
self.assertIsNone(result.archive_version)
self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,760 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine, event, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # 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 ( # noqa: E402
InventoryTxn,
PurchaseOrder,
PurchaseOrderItem,
PurchaseReceipt,
PurchaseReceiptItem,
PurchaseReturn,
PurchaseReturnItem,
StockLot,
Supplier,
WarehouseLocation,
)
from app.models.org import Department, Employee, User # noqa: E402
from app.services.auth import AuthContext # noqa: E402
from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402
class RawMaterialReturnOutboundTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
event.listen(engine, "connect", self._enable_sqlite_foreign_keys)
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()
@staticmethod
def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record) -> None:
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def _seed(self) -> None:
now = datetime.now(UTC)
department = Department(
id=1,
dept_code="D-WH",
dept_name="仓库部",
dept_type="WAREHOUSE",
status="ACTIVE",
created_at=now,
updated_at=now,
)
employee = Employee(
id=1,
employee_code="EMP-WH-001",
employee_name="仓管员",
dept_id=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
user = User(
id=1,
username="tester",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="测试员",
is_super_admin=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
supplier = Supplier(
id=1,
supplier_code="SUP-RAW-001",
supplier_name="原材料供应商",
status="ACTIVE",
created_at=now,
updated_at=now,
)
warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW-01",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
location = WarehouseLocation(
id=1,
warehouse_id=1,
location_code="RM-A-01",
location_name="原料主库位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
item = Item(
id=1,
item_code="RAW-00001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
purchase_order = PurchaseOrder(
id=1,
po_no="PO-2026-00001",
supplier_id=1,
order_date=now.date(),
expected_date=now.date(),
target_warehouse_type="RAW",
tax_rate=Decimal("0"),
total_amount=Decimal("500"),
status="CONFIRMED",
created_at=now,
updated_at=now,
)
purchase_order_item = PurchaseOrderItem(
id=1,
purchase_order_id=1,
line_no=1,
material_item_id=1,
order_qty=Decimal("100"),
order_weight_kg=Decimal("100"),
received_qty=Decimal("100"),
received_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
line_amount=Decimal("500"),
status="RECEIVED",
created_at=now,
updated_at=now,
)
purchase_receipt = PurchaseReceipt(
id=1,
receipt_no="RCV-2026-00001",
purchase_order_id=1,
warehouse_id=1,
receipt_date=now,
supplier_delivery_no="DLV-001",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
purchase_receipt_item = PurchaseReceiptItem(
id=1,
receipt_id=1,
line_no=1,
purchase_order_item_id=1,
material_item_id=1,
location_id=1,
lot_no="LOT-RAW-001",
received_qty=Decimal("100"),
received_weight_kg=Decimal("100"),
accepted_qty=Decimal("100"),
accepted_weight_kg=Decimal("100"),
unit_cost=Decimal("5"),
status="PASSED",
created_at=now,
updated_at=now,
)
stock_lot = StockLot(
id=1,
lot_no="LOT-RAW-001",
lot_role="INVENTORY",
item_id=1,
warehouse_id=1,
location_id=1,
source_doc_type="PURCHASE_RECEIPT",
source_doc_id=1,
source_line_id=1,
inbound_qty=Decimal("100"),
inbound_weight_kg=Decimal("100"),
remaining_qty=Decimal("100"),
remaining_weight_kg=Decimal("100"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("5"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
stock_balance = StockBalance(
id=1,
item_id=1,
warehouse_id=1,
location_id=1,
qty_on_hand=Decimal("100"),
weight_on_hand_kg=Decimal("100"),
qty_available=Decimal("100"),
weight_available_kg=Decimal("100"),
qty_allocated=Decimal("0"),
weight_allocated_kg=Decimal("0"),
avg_unit_cost=Decimal("5"),
created_at=now,
updated_at=now,
)
self.db.add_all([department, supplier, warehouse, item])
self.db.flush()
self.db.add_all([employee, user, location, purchase_order])
self.db.flush()
self.db.add_all([purchase_order_item, purchase_receipt])
self.db.flush()
self.db.add_all([purchase_receipt_item, stock_lot, stock_balance])
self.db.commit()
def test_purchase_return_tables_exist(self) -> None:
now = datetime.now(UTC)
purchase_return = PurchaseReturn(
id=1,
return_no="退货2026-00001",
purchase_order_id=1,
warehouse_id=1,
return_date=now,
operator_user_id=1,
logistics_waybill_no="YT123",
logistics_freight_amount=None,
logistics_photo_url=None,
reason="性能不合格",
status="CONFIRMED",
remark="测试退货",
created_at=now,
updated_at=now,
)
self.db.add(purchase_return)
self.db.flush()
purchase_return_item = PurchaseReturnItem(
id=1,
purchase_return_id=purchase_return.id,
line_no=1,
purchase_order_item_id=1,
material_item_id=1,
lot_id=1,
return_weight_kg=Decimal("20"),
unit_cost=Decimal("5"),
amount=Decimal("100"),
reason="性能不合格",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
self.db.add(purchase_return_item)
self.db.commit()
saved = self.db.get(PurchaseReturn, purchase_return.id)
saved_item = self.db.get(PurchaseReturnItem, purchase_return_item.id)
self.assertEqual(saved.status, "CONFIRMED")
self.assertEqual(float(saved_item.return_weight_kg), 20)
def _context(self) -> AuthContext:
user = self.db.get(User, 1)
employee = self.db.get(Employee, 1)
department = self.db.get(Department, 1)
return AuthContext(
user=user,
employee=employee,
department=department,
role_codes=["ADMIN"],
role_names=["管理员"],
permission_codes=[],
)
def test_raw_material_return_outbound_deducts_lot_balance_and_creates_records(self) -> None:
from app.api.routes.inventory import create_raw_material_return_outbound
from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
payload = RawMaterialReturnOutboundCreate(
warehouse_id=1,
waybill_no="YT123456",
freight_amount=None,
reason="性能不合格",
remark="退回供应商",
items=[
RawMaterialReturnOutboundItemCreate(
lot_id=1,
purchase_order_item_id=1,
return_weight_kg=80,
reason="硬度不达标",
)
],
)
result = create_raw_material_return_outbound(payload, context=self._context(), db=self.db)
self.assertEqual(result.status, "CONFIRMED")
self.assertEqual(result.logistics_waybill_no, "YT123456")
self.assertEqual(float(result.total_return_weight_kg), 80)
self.assertEqual(len(result.items), 1)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION)
self.assertIsNotNone(result.archive_business_id)
self.db.expire_all()
lot = self.db.get(StockLot, 1)
balance = self.db.get(StockBalance, 1)
self.assertEqual(float(lot.remaining_weight_kg), 20)
self.assertEqual(float(balance.weight_on_hand_kg), 20)
self.assertEqual(float(balance.weight_available_kg), 20)
txn = self.db.query(InventoryTxn).filter_by(txn_type="PURCHASE_RETURN_OUT").one()
self.assertEqual(txn.source_doc_type, "PURCHASE_RETURN")
self.assertEqual(txn.source_doc_id, result.purchase_return_id)
self.assertEqual(txn.source_line_id, result.items[0].purchase_return_item_id)
self.assertEqual(float(txn.weight_change_kg), -80)
self.assertEqual(txn.logistics_waybill_no, "YT123456")
archive = self.db.scalar(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION,
DocumentArchive.business_id == txn.id,
)
)
self.assertIsNotNone(archive)
self.assertEqual(archive.status, ARCHIVE_STATUS_READY)
def test_purchase_order_query_preserves_status_and_marks_has_return(self) -> None:
from app.api.routes.inventory import create_raw_material_return_outbound
from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
from app.services.operations import get_purchase_order_items_query, get_purchase_order_query
create_raw_material_return_outbound(
RawMaterialReturnOutboundCreate(
warehouse_id=1,
waybill_no="YT123456",
reason="性能不合格",
items=[
RawMaterialReturnOutboundItemCreate(
lot_id=1,
purchase_order_item_id=1,
return_weight_kg=80,
)
],
),
context=self._context(),
db=self.db,
)
order_row = self.db.execute(get_purchase_order_query(limit=1).where(PurchaseOrder.id == 1)).mappings().first()
item_row = self.db.execute(get_purchase_order_items_query(limit=1, purchase_order_id=1)).mappings().first()
self.assertEqual(order_row["display_status"], "RECEIVED")
self.assertTrue(order_row["has_return"])
self.assertEqual(float(order_row["total_return_weight_kg"]), 80)
self.assertTrue(item_row["has_return"])
self.assertEqual(float(item_row["returned_weight_kg"]), 80)
self.assertEqual(float(item_row["net_received_weight_kg"]), 20)
def test_raw_material_return_requires_waybill_or_photo(self) -> None:
from fastapi import HTTPException
from app.api.routes.inventory import create_raw_material_return_outbound
from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
payload = RawMaterialReturnOutboundCreate(
warehouse_id=1,
freight_amount=None,
reason="性能不合格",
items=[
RawMaterialReturnOutboundItemCreate(
lot_id=1,
purchase_order_item_id=1,
return_weight_kg=80,
)
],
)
with self.assertRaises(HTTPException) as exc:
create_raw_material_return_outbound(payload, context=self._context(), db=self.db)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("运单号或上传辅助照片", str(exc.exception.detail))
def test_raw_material_return_rejects_weight_above_remaining(self) -> None:
from fastapi import HTTPException
from app.api.routes.inventory import create_raw_material_return_outbound
from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
payload = RawMaterialReturnOutboundCreate(
warehouse_id=1,
order_photo_url="/inventory/logistics-photos/test.jpg",
freight_amount=None,
reason="性能不合格",
items=[
RawMaterialReturnOutboundItemCreate(
lot_id=1,
purchase_order_item_id=1,
return_weight_kg=101,
)
],
)
with self.assertRaises(HTTPException) as exc:
create_raw_material_return_outbound(payload, context=self._context(), db=self.db)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("退货重量不能超过批次可用重量", str(exc.exception.detail))
def test_raw_material_return_rejects_weight_above_unlocked_available_weight(self) -> None:
from fastapi import HTTPException
from app.api.routes.inventory import create_raw_material_return_outbound
from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
lot = self.db.get(StockLot, 1)
lot.locked_weight_kg = Decimal("30")
self.db.commit()
payload = RawMaterialReturnOutboundCreate(
warehouse_id=1,
order_photo_url="/inventory/logistics-photos/test.jpg",
freight_amount=None,
reason="性能不合格",
items=[
RawMaterialReturnOutboundItemCreate(
lot_id=1,
purchase_order_item_id=1,
return_weight_kg=80,
)
],
)
with self.assertRaises(HTTPException) as exc:
create_raw_material_return_outbound(payload, context=self._context(), db=self.db)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("退货重量不能超过批次可用重量", str(exc.exception.detail))
def test_raw_material_return_conditional_deduction_rechecks_locked_weight_after_validation(self) -> None:
from fastapi import HTTPException
from unittest.mock import patch
from app.api.routes import inventory as inventory_routes
from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
payload = RawMaterialReturnOutboundCreate(
warehouse_id=1,
order_photo_url="/inventory/logistics-photos/test.jpg",
freight_amount=None,
reason="性能不合格",
items=[
RawMaterialReturnOutboundItemCreate(
lot_id=1,
purchase_order_item_id=1,
return_weight_kg=80,
)
],
)
def lock_lot_after_validation(_db: Session) -> str:
lot = self.db.get(StockLot, 1)
lot.locked_weight_kg = Decimal("30")
self.db.flush()
return "退货2026-00099"
with patch.object(inventory_routes, "_build_purchase_return_no", side_effect=lock_lot_after_validation):
with self.assertRaises(HTTPException) as exc:
inventory_routes.create_raw_material_return_outbound(payload, context=self._context(), db=self.db)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("退货重量不能超过批次可用重量", str(exc.exception.detail))
self.assertEqual(self.db.query(PurchaseReturn).count(), 0)
def test_raw_material_return_rechecks_purchase_line_returnable_after_validation(self) -> None:
from fastapi import HTTPException
from unittest.mock import patch
from app.api.routes import inventory as inventory_routes
from app.schemas.operations import RawMaterialReturnOutboundCreate, RawMaterialReturnOutboundItemCreate
payload = RawMaterialReturnOutboundCreate(
warehouse_id=1,
order_photo_url="/inventory/logistics-photos/test.jpg",
freight_amount=None,
reason="性能不合格",
items=[
RawMaterialReturnOutboundItemCreate(
lot_id=1,
purchase_order_item_id=1,
return_weight_kg=80,
)
],
)
original_lock = inventory_routes._lock_purchase_order_items_for_return
def insert_prior_return_after_validation(_db: Session, purchase_order_item_ids: set[int]) -> None:
now = datetime.now(UTC)
purchase_return = PurchaseReturn(
id=10,
return_no="退货2026-00010",
purchase_order_id=1,
warehouse_id=1,
return_date=now,
operator_user_id=1,
reason="性能不合格",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
purchase_return_item = PurchaseReturnItem(
id=10,
purchase_return_id=10,
line_no=1,
purchase_order_item_id=1,
material_item_id=1,
lot_id=1,
return_weight_kg=Decimal("30"),
unit_cost=Decimal("5"),
amount=Decimal("150"),
reason="性能不合格",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
self.db.add_all([purchase_return, purchase_return_item])
self.db.commit()
original_lock(_db, purchase_order_item_ids)
with patch.object(
inventory_routes,
"_lock_purchase_order_items_for_return",
side_effect=insert_prior_return_after_validation,
):
with self.assertRaises(HTTPException) as exc:
inventory_routes.create_raw_material_return_outbound(payload, context=self._context(), db=self.db)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("退货重量不能超过该采购明细可退重量", str(exc.exception.detail))
self.db.expire_all()
lot = self.db.get(StockLot, 1)
self.assertEqual(float(lot.remaining_weight_kg), 100)
self.assertEqual(self.db.query(PurchaseReturn).count(), 1)
self.assertEqual(self.db.query(PurchaseReturnItem).count(), 1)
def test_stock_lot_purchase_links_returns_receipt_purchase_order_item(self) -> None:
from app.services.operations import get_stock_lot_purchase_links
links = get_stock_lot_purchase_links(self.db, 1)
self.assertEqual(len(links), 1)
self.assertEqual(links[0]["purchase_order_id"], 1)
self.assertEqual(links[0]["purchase_order_item_id"], 1)
self.assertEqual(links[0]["po_no"], "PO-2026-00001")
self.assertEqual(float(links[0]["accepted_weight_kg"]), 100)
self.assertEqual(float(links[0]["returned_weight_kg"]), 0)
self.assertEqual(float(links[0]["returnable_weight_kg"]), 100)
def test_stock_lot_purchase_links_excludes_same_lot_no_from_unrelated_receipt(self) -> None:
from app.services.operations import get_stock_lot_purchase_links
now = datetime.now(UTC)
other_purchase_order = PurchaseOrder(
id=2,
po_no="PO-2026-00002",
supplier_id=1,
order_date=now.date(),
expected_date=now.date(),
target_warehouse_type="RAW",
tax_rate=Decimal("0"),
total_amount=Decimal("300"),
status="CONFIRMED",
created_at=now,
updated_at=now,
)
other_purchase_order_item = PurchaseOrderItem(
id=2,
purchase_order_id=2,
line_no=1,
material_item_id=1,
order_qty=Decimal("60"),
order_weight_kg=Decimal("60"),
received_qty=Decimal("60"),
received_weight_kg=Decimal("60"),
unit_price=Decimal("5"),
line_amount=Decimal("300"),
status="RECEIVED",
created_at=now,
updated_at=now,
)
other_receipt = PurchaseReceipt(
id=2,
receipt_no="RCV-2026-00002",
purchase_order_id=2,
warehouse_id=1,
receipt_date=now,
supplier_delivery_no="DLV-002",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
wrong_receipt_item = PurchaseReceiptItem(
id=2,
receipt_id=2,
line_no=1,
purchase_order_item_id=2,
material_item_id=1,
location_id=1,
lot_no="LOT-RAW-001",
received_qty=Decimal("60"),
received_weight_kg=Decimal("60"),
accepted_qty=Decimal("60"),
accepted_weight_kg=Decimal("60"),
unit_cost=Decimal("5"),
status="PASSED",
created_at=now,
updated_at=now,
)
self.db.add_all([other_purchase_order, other_purchase_order_item, other_receipt, wrong_receipt_item])
self.db.commit()
links = get_stock_lot_purchase_links(self.db, 1)
self.assertEqual(len(links), 1)
self.assertEqual(links[0]["purchase_order_item_id"], 1)
def test_stock_lot_purchase_links_caps_returnable_by_prior_returns_and_locked_weight(self) -> None:
from app.services.operations import get_stock_lot_purchase_links
now = datetime.now(UTC)
purchase_return = PurchaseReturn(
id=1,
return_no="退货2026-00001",
purchase_order_id=1,
warehouse_id=1,
return_date=now,
operator_user_id=1,
reason="性能不合格",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
purchase_return_item = PurchaseReturnItem(
id=1,
purchase_return_id=1,
line_no=1,
purchase_order_item_id=1,
material_item_id=1,
lot_id=1,
return_weight_kg=Decimal("30"),
unit_cost=Decimal("5"),
amount=Decimal("150"),
reason="性能不合格",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
stock_lot = self.db.get(StockLot, 1)
stock_lot.remaining_weight_kg = Decimal("100")
stock_lot.locked_weight_kg = Decimal("40")
self.db.add_all([purchase_return, purchase_return_item])
self.db.commit()
links = get_stock_lot_purchase_links(self.db, 1)
self.assertEqual(len(links), 1)
self.assertEqual(float(links[0]["accepted_weight_kg"]), 100)
self.assertEqual(float(links[0]["returned_weight_kg"]), 30)
self.assertEqual(float(links[0]["returnable_weight_kg"]), 60)
stock_lot.locked_weight_kg = Decimal("0")
self.db.commit()
links = get_stock_lot_purchase_links(self.db, 1)
self.assertEqual(float(links[0]["returnable_weight_kg"]), 70)
stock_lot.locked_weight_kg = Decimal("120")
self.db.commit()
links = get_stock_lot_purchase_links(self.db, 1)
self.assertEqual(float(links[0]["returnable_weight_kg"]), 0)
def test_stock_lot_purchase_links_ignore_canceled_return_items(self) -> None:
from app.services.operations import get_stock_lot_purchase_links
now = datetime.now(UTC)
purchase_return = PurchaseReturn(
id=1,
return_no="退货2026-00001",
purchase_order_id=1,
warehouse_id=1,
return_date=now,
operator_user_id=1,
reason="性能不合格",
status="CONFIRMED",
created_at=now,
updated_at=now,
)
canceled_return_item = PurchaseReturnItem(
id=1,
purchase_return_id=1,
line_no=1,
purchase_order_item_id=1,
material_item_id=1,
lot_id=1,
return_weight_kg=Decimal("30"),
unit_cost=Decimal("5"),
amount=Decimal("150"),
reason="性能不合格",
status="CANCELED",
created_at=now,
updated_at=now,
)
self.db.add_all([purchase_return, canceled_return_item])
self.db.commit()
links = get_stock_lot_purchase_links(self.db, 1)
self.assertEqual(float(links[0]["returned_weight_kg"]), 0)
self.assertEqual(float(links[0]["returnable_weight_kg"]), 100)
def test_logistics_can_require_waybill_or_photo_without_freight(self) -> None:
from app.services.logistics import normalize_logistics_fields
waybill, freight, photo_url = normalize_logistics_fields(
None,
None,
required=True,
freight_required=False,
order_photo_url="/inventory/logistics-photos/test.jpg",
)
self.assertIsNone(waybill)
self.assertIsNone(freight)
self.assertEqual(photo_url, "/inventory/logistics-photos/test.jpg")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,638 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.purchase import create_purchase_order, create_purchase_receipt, lock_purchase_order # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Bom, BomItem, Item, Material, Product, StockBalance, Warehouse # noqa: E402
from app.models.operations import ( # noqa: E402
InventoryTxn,
ProcessRoute,
PurchaseOrderItem,
PurchaseReceiptItem,
StockLot,
Supplier,
WorkOrder,
WorkOrderMaterial,
WorkOrderMaterialIssue,
WorkOrderOperation,
)
from app.models.org import Department, Employee, User # noqa: E402
from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseReceiptCreate, PurchaseReceiptItemCreate # noqa: E402
from app.services.auth import AuthContext # noqa: E402
from app.services.operations import issue_item_from_lots, sync_work_order_reference_qty # noqa: E402
class RawMaterialWeightOnlyFlowTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed_master_data()
def tearDown(self) -> None:
self.db.close()
def _seed_master_data(self) -> None:
self.department = Department(id=1, dept_code="D-WH", dept_name="仓库部", dept_type="WAREHOUSE", status="ACTIVE")
self.employee = Employee(id=1, employee_code="EMP-WH-001", employee_name="仓管员", dept_id=1, status="ACTIVE")
self.user = User(
id=1,
username="tester",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="测试员",
is_super_admin=1,
status="ACTIVE",
)
self.material_item = Item(
id=1,
item_code="RM-WEIGHT-001",
item_name="重量口径原材料",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1.000000"),
status="ACTIVE",
)
self.material = Material(
item_id=1,
default_supplier_id=1,
purchase_calc_mode="BY_WEIGHT",
min_purchase_qty=Decimal("0"),
purchase_multiple_qty=Decimal("0"),
purchase_multiple_weight_kg=Decimal("0"),
lead_time_days=0,
)
self.supplier = Supplier(
id=1,
supplier_code="SUP-WEIGHT-001",
supplier_name="重量供应商",
short_name="重量供应商",
lead_time_days=0,
default_tax_rate=Decimal("0"),
status="ACTIVE",
)
self.warehouse = Warehouse(id=1, warehouse_code="WH-RM", warehouse_name="原材料库", warehouse_type="RAW", status="ACTIVE")
self.db.add_all([
self.department,
self.employee,
self.user,
self.material_item,
self.material,
self.supplier,
self.warehouse,
])
self.db.commit()
def _auth_context(self) -> AuthContext:
return AuthContext(
user=self.user,
employee=self.employee,
department=self.department,
role_codes=["ADMIN"],
role_names=["管理员"],
permission_codes=[],
)
def _create_locked_raw_purchase_order(self, *, order_weight_kg: Decimal = Decimal("200"), unit_price: Decimal = Decimal("3.5")):
order = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material_item.id,
order_weight_kg=order_weight_kg,
unit_price=unit_price,
)
],
),
db=self.db,
)
po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
lock_purchase_order(order.purchase_order_id, db=self.db)
return order, po_item
def _raw_receipt_payload(
self,
order,
po_item,
*,
received_weight_kg: Decimal = Decimal("100"),
unit_cost: Decimal = Decimal("3.5"),
waybill_no: str | None = "YD-CG-001",
order_photo_url: str | None = None,
freight_amount: Decimal | None = Decimal("12.5"),
) -> PurchaseReceiptCreate:
return PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
waybill_no=waybill_no,
order_photo_url=order_photo_url,
freight_amount=freight_amount,
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
received_weight_kg=received_weight_kg,
unit_cost=unit_cost,
)
],
)
def test_raw_purchase_receipt_requires_logistics_info(self) -> None:
order, po_item = self._create_locked_raw_purchase_order()
with self.assertRaises(HTTPException) as exc:
create_purchase_receipt(
self._raw_receipt_payload(order, po_item, waybill_no="", order_photo_url="", freight_amount=None),
context=self._auth_context(),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("运单号或上传辅助照片", exc.exception.detail)
self.assertIn("运费", exc.exception.detail)
def test_raw_purchase_receipt_allows_photo_instead_of_waybill_and_records_logistics(self) -> None:
order, po_item = self._create_locked_raw_purchase_order()
create_purchase_receipt(
self._raw_receipt_payload(
order,
po_item,
waybill_no="",
order_photo_url="/inventory/logistics-photos/purchase-in.jpg",
freight_amount=Decimal("18.5"),
),
context=self._auth_context(),
db=self.db,
)
lot = self.db.scalar(select(StockLot))
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "PURCHASE_IN"))
self.assertIsNone(lot.logistics_waybill_no)
self.assertEqual(lot.logistics_photo_url, "/inventory/logistics-photos/purchase-in.jpg")
self.assertEqual(float(lot.logistics_freight_amount), 18.5)
self.assertIsNone(txn.logistics_waybill_no)
self.assertEqual(txn.logistics_photo_url, "/inventory/logistics-photos/purchase-in.jpg")
self.assertEqual(float(txn.logistics_freight_amount), 18.5)
def test_purchase_receipt_writes_raw_material_weight_only(self) -> None:
order = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material_item.id,
order_weight_kg=Decimal("200"),
unit_price=Decimal("3.5"),
)
],
),
db=self.db,
)
po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
self.assertEqual(float(po_item.order_qty), 0)
self.assertEqual(float(po_item.order_weight_kg), 200)
lock_purchase_order(order.purchase_order_id, db=self.db)
create_purchase_receipt(
PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
waybill_no="YD-CG-RAW-001",
freight_amount=Decimal("10"),
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
received_weight_kg=Decimal("250"),
unit_cost=Decimal("3.5"),
)
],
),
context=self._auth_context(),
db=self.db,
)
self.db.refresh(po_item)
receipt_item = self.db.scalar(select(PurchaseReceiptItem))
lot = self.db.scalar(select(StockLot))
balance = self.db.scalar(select(StockBalance))
txn = self.db.scalar(select(InventoryTxn))
self.assertEqual(float(po_item.received_qty), 0)
self.assertEqual(float(po_item.received_weight_kg), 250)
self.assertEqual(float(receipt_item.received_qty), 0)
self.assertEqual(float(receipt_item.received_weight_kg), 250)
self.assertEqual(float(lot.inbound_qty), 0)
self.assertEqual(float(lot.remaining_qty), 0)
self.assertEqual(float(lot.locked_qty), 0)
self.assertEqual(float(lot.inbound_weight_kg), 250)
self.assertEqual(float(balance.qty_on_hand), 0)
self.assertEqual(float(balance.qty_available), 0)
self.assertEqual(float(balance.weight_on_hand_kg), 250)
self.assertEqual(float(txn.qty_change), 0)
self.assertEqual(float(txn.weight_change_kg), 250)
def test_purchase_receipt_ignores_legacy_merge_payload_and_creates_new_inventory_lot(self) -> None:
order = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material_item.id,
order_weight_kg=Decimal("500"),
unit_price=Decimal("4"),
)
],
),
db=self.db,
)
po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
lock_purchase_order(order.purchase_order_id, db=self.db)
create_purchase_receipt(
PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
waybill_no="YD-CG-RAW-002",
freight_amount=Decimal("10"),
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
received_weight_kg=Decimal("300"),
unit_cost=Decimal("4"),
)
],
),
context=self._auth_context(),
db=self.db,
)
first_lot = self.db.scalar(select(StockLot))
self.assertEqual(float(first_lot.unit_cost), 4)
create_purchase_receipt(
PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
waybill_no="YD-CG-RAW-003",
freight_amount=Decimal("10"),
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
merge_to_lot_id=first_lot.id,
received_weight_kg=Decimal("200"),
unit_cost=Decimal("99"),
)
],
),
context=self._auth_context(),
db=self.db,
)
self.db.refresh(first_lot)
second_lot = self.db.scalars(select(StockLot).order_by(StockLot.id)).all()[1]
receipt_items = self.db.scalars(select(PurchaseReceiptItem).order_by(PurchaseReceiptItem.id)).all()
txns = self.db.scalars(select(InventoryTxn).order_by(InventoryTxn.id)).all()
self.assertEqual(float(first_lot.inbound_weight_kg), 300)
self.assertEqual(float(first_lot.unit_cost), 4)
self.assertEqual(second_lot.lot_no, "YL0002")
self.assertEqual(float(second_lot.inbound_weight_kg), 200)
self.assertEqual(float(second_lot.unit_cost), 99)
self.assertIsNone(receipt_items[1].merge_to_lot_id)
self.assertEqual(receipt_items[1].lot_no, "YL0002")
self.assertEqual(float(receipt_items[1].unit_cost), 99)
self.assertEqual(float(txns[1].unit_cost), 99)
self.assertEqual(float(txns[1].amount), 19800)
def test_purchase_receipt_ignores_manual_legacy_lot_no(self) -> None:
order = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material_item.id,
order_weight_kg=Decimal("500"),
unit_price=Decimal("4"),
)
],
),
db=self.db,
)
po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
lock_purchase_order(order.purchase_order_id, db=self.db)
create_purchase_receipt(
PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
waybill_no="YD-CG-RAW-004",
freight_amount=Decimal("10"),
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
lot_no="YL-NB-20260530-RMWEIGHT-OLD-L01",
received_weight_kg=Decimal("300"),
unit_cost=Decimal("4"),
)
],
),
context=self._auth_context(),
db=self.db,
)
lot = self.db.scalar(select(StockLot))
receipt_item = self.db.scalar(select(PurchaseReceiptItem))
self.assertEqual(lot.lot_no, "YL0001")
self.assertEqual(receipt_item.lot_no, "YL0001")
def test_split_arrivals_create_sequential_inventory_lots_for_same_purchase_line(self) -> None:
order = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material_item.id,
order_weight_kg=Decimal("500"),
unit_price=Decimal("4"),
)
],
),
db=self.db,
)
po_item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == order.purchase_order_id))
lock_purchase_order(order.purchase_order_id, db=self.db)
create_purchase_receipt(
PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
waybill_no="YD-CG-RAW-005",
freight_amount=Decimal("10"),
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
received_weight_kg=Decimal("300"),
unit_cost=Decimal("4"),
)
],
),
context=self._auth_context(),
db=self.db,
)
create_purchase_receipt(
PurchaseReceiptCreate(
purchase_order_id=order.purchase_order_id,
warehouse_id=self.warehouse.id,
receiver_employee_id=self.employee.id,
waybill_no="YD-CG-RAW-006",
freight_amount=Decimal("10"),
items=[
PurchaseReceiptItemCreate(
purchase_order_item_id=po_item.id,
material_item_id=self.material_item.id,
received_weight_kg=Decimal("250"),
unit_cost=Decimal("4.5"),
)
],
),
context=self._auth_context(),
db=self.db,
)
lots = self.db.scalars(select(StockLot).order_by(StockLot.id)).all()
receipt_items = self.db.scalars(select(PurchaseReceiptItem).order_by(PurchaseReceiptItem.id)).all()
self.assertEqual([lot.lot_no for lot in lots], ["YL0001", "YL0002"])
self.assertEqual(float(receipt_items[0].unit_cost), 4)
self.assertEqual(float(receipt_items[1].unit_cost), 4.5)
self.assertEqual(float(lots[0].unit_cost), 4)
self.assertEqual(float(lots[1].unit_cost), 4.5)
def test_material_issue_ignores_historical_raw_quantity_pollution(self) -> None:
lot = StockLot(
lot_no="RML-POLLUTED-001",
lot_role="INBOUND_RAW",
item_id=self.material_item.id,
warehouse_id=self.warehouse.id,
source_doc_type="TEST",
source_doc_id=1,
inbound_qty=Decimal("4000"),
inbound_weight_kg=Decimal("3000"),
remaining_qty=Decimal("4000"),
remaining_weight_kg=Decimal("3000"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("2"),
quality_status="PASS",
status="AVAILABLE",
)
balance = StockBalance(
item_id=self.material_item.id,
warehouse_id=self.warehouse.id,
qty_on_hand=Decimal("4000"),
weight_on_hand_kg=Decimal("3000"),
qty_available=Decimal("4000"),
weight_available_kg=Decimal("3000"),
qty_allocated=Decimal("0"),
weight_allocated_kg=Decimal("0"),
avg_unit_cost=Decimal("2"),
)
self.db.add_all([lot, balance])
self.db.commit()
issued_qty, issued_weight, amount = issue_item_from_lots(
self.db,
item_id=self.material_item.id,
required_qty=Decimal("999"),
required_weight=Decimal("200"),
enforce_qty=True,
enforce_weight=True,
source_doc_id=1,
source_line_id=None,
biz_time=datetime.now(),
operator_user_id=self.user.id,
remark="测试按重量领料",
)
self.db.commit()
self.db.refresh(lot)
self.db.refresh(balance)
txn = self.db.scalar(select(InventoryTxn))
self.assertEqual(float(issued_qty), 0)
self.assertEqual(float(issued_weight), 200)
self.assertEqual(float(amount), 400)
self.assertEqual(float(lot.remaining_qty), 0)
self.assertEqual(float(lot.remaining_weight_kg), 2800)
self.assertEqual(float(balance.qty_on_hand), 0)
self.assertEqual(float(balance.qty_available), 0)
self.assertEqual(float(balance.weight_on_hand_kg), 2800)
self.assertEqual(float(txn.qty_change), 0)
self.assertEqual(float(txn.weight_change_kg), -200)
def test_work_order_reference_qty_resyncs_from_issued_weight_and_bom_gross_weight(self) -> None:
product_item = Item(
id=2,
item_code="FG-WEIGHT-001",
item_name="重量口径成品",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("1.000000"),
status="ACTIVE",
)
product = Product(
item_id=2,
version_no="V1.0",
standard_output_qty=Decimal("0"),
allowed_scrap_rate=Decimal("0"),
)
bom = Bom(
id=1,
bom_code="BOM-WEIGHT-001",
product_item_id=2,
version_no="V1.0",
is_default=1,
status="ACTIVE",
)
bom_item = BomItem(
id=1,
bom_id=1,
seq_no=1,
material_item_id=self.material_item.id,
usage_type="WEIGHT",
qty_per_piece=Decimal("1"),
net_weight_per_piece_kg=Decimal("1.000000"),
gross_weight_per_piece_kg=Decimal("1.500000"),
loss_rate=Decimal("0.3333"),
)
route = ProcessRoute(
id=1,
route_code="ROUTE-WEIGHT-001",
route_name="重量测试路线",
product_item_id=2,
version_no="V1.0",
is_default=1,
status="ACTIVE",
)
work_order = WorkOrder(
id=1,
work_order_no="WO-STale-001",
work_order_type="NORMAL",
product_item_id=2,
route_id=1,
bom_id=1,
planned_qty=Decimal("52"),
released_qty=Decimal("52"),
finished_qty=Decimal("0"),
scrap_qty=Decimal("0"),
status="RELEASED",
)
material_row = WorkOrderMaterial(
id=1,
work_order_id=1,
bom_item_id=1,
material_item_id=self.material_item.id,
required_qty=Decimal("0"),
required_weight_kg=Decimal("200"),
issued_qty=Decimal("0"),
issued_weight_kg=Decimal("200"),
returned_qty=Decimal("0"),
returned_weight_kg=Decimal("0"),
status="ISSUED",
)
issue_row = WorkOrderMaterialIssue(
id=1,
work_order_material_id=1,
work_order_id=1,
material_item_id=self.material_item.id,
source_lot_id=1,
material_sub_batch_no="JH_RM-WEIGHT-001_0001",
issued_qty=Decimal("0"),
issued_weight_kg=Decimal("200"),
unit_cost=Decimal("2"),
amount=Decimal("400"),
issue_time=datetime.now(),
)
operation = WorkOrderOperation(
id=1,
work_order_id=1,
route_operation_id=1,
seq_no=1,
operation_name="1序",
work_center_id=1,
planned_qty=Decimal("52"),
report_qty=Decimal("0"),
good_qty=Decimal("0"),
scrap_qty=Decimal("0"),
rework_qty=Decimal("0"),
status="PENDING",
)
source_lot = StockLot(
id=1,
lot_no="RML-ISSUE-001",
lot_role="INBOUND_RAW",
item_id=self.material_item.id,
warehouse_id=self.warehouse.id,
source_doc_type="TEST",
source_doc_id=1,
inbound_weight_kg=Decimal("200"),
remaining_weight_kg=Decimal("0"),
unit_cost=Decimal("2"),
quality_status="PASS",
status="DEPLETED",
)
self.db.add_all([product_item, product, bom, bom_item, route, work_order, material_row, source_lot, issue_row, operation])
self.db.commit()
changed = sync_work_order_reference_qty(self.db, work_order.id)
self.db.commit()
self.db.refresh(work_order)
self.db.refresh(operation)
self.assertTrue(changed)
self.assertEqual(float(work_order.planned_qty), 133)
self.assertEqual(float(work_order.released_qty), 133)
self.assertEqual(float(operation.planned_qty), 133)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,757 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from decimal import Decimal
from fastapi import HTTPException
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.sales import create_delivery, create_sales_order # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, StockBalance, Warehouse # noqa: E402
from app.models.operations import Delivery, DeliveryItem, InventoryTxn, StockLot # noqa: E402
from app.models.org import Department, Employee, User # noqa: E402
from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402
from app.schemas.database import SalesOrderCreate, SalesOrderItemCreate # noqa: E402
from app.schemas.operations import DeliveryCreate, DeliveryItemCreate # noqa: E402
from app.services.auth import AuthContext # noqa: E402
from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402
from app.services.operations import get_delivery_items_query # noqa: E402
from app.services.sales_planning import get_sales_order_query # noqa: E402
class SalesOrderDeliveryTraceTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed_master_data()
def tearDown(self) -> None:
self.db.close()
def _seed_master_data(self) -> None:
self.department = Department(id=1, dept_code="D-SALES", dept_name="销售部", dept_type="SALES", status="ACTIVE")
self.employee = Employee(id=1, employee_code="EMP-001", employee_name="张三", dept_id=1, status="ACTIVE")
self.user = User(
id=1,
username="tester",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="测试员",
is_super_admin=1,
status="ACTIVE",
)
self.customer = Customer(
id=1,
customer_code="CUS-001",
customer_name="五金客户A",
short_name="客户A",
contact_name="李工",
contact_phone="13800000000",
address="宁波测试路1号",
credit_days=30,
status="ACTIVE",
)
self.product = Item(
id=1,
item_code="FG-001",
item_name="测试支架",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("2.000000"),
safety_stock_weight_kg=Decimal("0"),
status="ACTIVE",
)
self.raw_material = Item(
id=2,
item_code="MAT-001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
specification="1.0*1000",
material_grade="SPCC",
unit_weight_kg=Decimal("1.000000"),
safety_stock_weight_kg=Decimal("0"),
status="ACTIVE",
)
self.warehouse = Warehouse(id=1, warehouse_code="WH-FG", warehouse_name="成品库", warehouse_type="FINISHED", status="ACTIVE")
self.db.add_all([self.department, self.employee, self.user, self.customer, self.product, self.raw_material, self.warehouse])
self.db.flush()
self.raw_lot = StockLot(
id=1,
lot_no="RML-001",
lot_role="INBOUND_RAW",
item_id=self.raw_material.id,
warehouse_id=self.warehouse.id,
source_doc_type="TEST",
source_doc_id=1,
inbound_qty=Decimal("100"),
inbound_weight_kg=Decimal("100"),
remaining_qty=Decimal("60"),
remaining_weight_kg=Decimal("60"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("5"),
quality_status="PASS",
status="AVAILABLE",
)
self.finished_lot = StockLot(
id=2,
lot_no="FGL-001",
parent_lot_id=self.raw_lot.id,
lot_role="FINISHED_FROM_RAW_BATCH",
item_id=self.product.id,
warehouse_id=self.warehouse.id,
source_doc_type="COMPLETION",
source_doc_id=1,
source_line_id=1,
source_material_lot_id=self.raw_lot.id,
source_material_sub_batch_no="RML-001",
source_material_summary="RML-001 / 冷轧钢板 / 40kg / ¥200",
inbound_qty=Decimal("20"),
inbound_weight_kg=Decimal("40"),
remaining_qty=Decimal("20"),
remaining_weight_kg=Decimal("40"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("10"),
production_date=date.today(),
quality_status="PASS",
status="AVAILABLE",
)
self.finished_balance = StockBalance(
id=1,
item_id=self.product.id,
warehouse_id=self.warehouse.id,
location_id=None,
qty_on_hand=Decimal("20"),
weight_on_hand_kg=Decimal("40"),
qty_available=Decimal("20"),
weight_available_kg=Decimal("40"),
qty_allocated=Decimal("0"),
weight_allocated_kg=Decimal("0"),
avg_unit_cost=Decimal("10"),
)
self.db.add_all([self.raw_lot, self.finished_lot, self.finished_balance])
self.db.commit()
def _seed_second_finished_lot(self) -> StockLot:
second_lot = StockLot(
id=3,
lot_no="FGL-002",
parent_lot_id=self.raw_lot.id,
lot_role="FINISHED_FROM_RAW_BATCH",
item_id=self.product.id,
warehouse_id=self.warehouse.id,
source_doc_type="COMPLETION",
source_doc_id=2,
source_line_id=2,
source_material_lot_id=self.raw_lot.id,
source_material_sub_batch_no="RML-001",
source_material_summary="RML-001 / 冷轧钢板 / 10kg / ¥50",
inbound_qty=Decimal("5"),
inbound_weight_kg=Decimal("10"),
remaining_qty=Decimal("5"),
remaining_weight_kg=Decimal("10"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("10"),
production_date=date.today(),
quality_status="PASS",
status="AVAILABLE",
)
self.finished_balance.qty_on_hand += Decimal("5")
self.finished_balance.weight_on_hand_kg += Decimal("10")
self.finished_balance.qty_available += Decimal("5")
self.finished_balance.weight_available_kg += Decimal("10")
self.db.add_all([second_lot, self.finished_balance])
self.db.commit()
return second_lot
def _auth_context(self) -> AuthContext:
return AuthContext(
user=self.user,
employee=self.employee,
department=self.department,
role_codes=["ADMIN"],
role_names=["管理员"],
permission_codes=[],
)
def _seed_employee_permission(
self,
permission_code: str,
permission_name: str,
module_code: str,
role_code: str,
role_name: str,
) -> Employee:
from app.models.org import Permission, Role, RolePermission, UserRole
now = datetime(2026, 6, 8, 9, 0, 0)
permission = self.db.scalar(select(Permission).where(Permission.permission_code == permission_code))
if not permission:
permission = Permission(
permission_code=permission_code,
permission_name=permission_name,
module_code=module_code,
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(permission)
self.db.flush()
role = self.db.scalar(select(Role).where(Role.role_code == role_code))
if not role:
role = Role(
role_code=role_code,
role_name=role_name,
role_scope="SYSTEM",
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
self.db.add(role)
self.db.flush()
role_permission = self.db.scalar(
select(RolePermission).where(
RolePermission.role_id == role.id,
RolePermission.permission_id == permission.id,
)
)
if not role_permission:
self.db.add(RolePermission(role_id=role.id, permission_id=permission.id, created_at=now))
user_role = self.db.scalar(select(UserRole).where(UserRole.user_id == self.user.id, UserRole.role_id == role.id))
if not user_role:
self.db.add(UserRole(user_id=self.user.id, role_id=role.id, created_at=now))
self.db.commit()
return self.employee
def _seed_sales_employee_permission(self) -> Employee:
return self._seed_employee_permission(
"MENU_SALES_PLANNING",
"客户名录/销售订单菜单",
"SALES_PLANNING",
"SALES",
"销售人员",
)
def _seed_inventory_ledger_employee_permission(self) -> Employee:
return self._seed_employee_permission(
"MENU_INVENTORY_LEDGER",
"嘉恒仓库",
"INVENTORY_LEDGER",
"WAREHOUSE",
"仓库人员",
)
def test_create_sales_order_persists_sales_employee(self) -> None:
sales_employee = self._seed_sales_employee_permission()
order_result = create_sales_order(
SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=sales_employee.id,
promised_date=date.today(),
delivery_address=self.customer.address,
tax_rate=0,
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=3,
unit_price=12.5,
customer_part_no="C-PART-001",
)
],
),
db=self.db,
)
order = self.db.get(SalesOrder, order_result.order_id)
order_row = self.db.execute(get_sales_order_query(limit=10)).mappings().first()
self.assertEqual(order.sales_employee_id, sales_employee.id)
self.assertEqual(order_row["sales_employee_id"], sales_employee.id)
self.assertEqual(order_row["sales_employee_name"], sales_employee.employee_name)
def test_sales_order_delivery_updates_fulfillment_and_trace_fields(self) -> None:
sales_employee = self._seed_sales_employee_permission()
order_result = create_sales_order(
SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=sales_employee.id,
promised_date=date.today(),
delivery_address=self.customer.address,
tax_rate=0,
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=10,
unit_price=12.5,
customer_part_no="C-PART-001",
)
],
),
db=self.db,
)
order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id))
create_delivery(
DeliveryCreate(
sales_order_id=order_result.order_id,
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
consignee_name=self.customer.contact_name,
consignee_phone=self.customer.contact_phone,
delivery_address=self.customer.address,
waybill_no="YD-XS-001",
freight_amount=36.8,
items=[
DeliveryItemCreate(
sales_order_item_id=order_item.id,
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=4,
delivery_weight_kg=8,
unit_price=12.5,
)
],
),
context=self._auth_context(),
db=self.db,
)
self.db.refresh(order_item)
sales_order = self.db.get(SalesOrder, order_result.order_id)
delivery_item = self.db.scalar(select(DeliveryItem))
delivery = self.db.scalar(select(Delivery))
trace_row = self.db.execute(get_delivery_items_query(limit=10)).mappings().first()
stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id))
self.assertEqual(delivery.logistics_waybill_no, "YD-XS-001")
self.assertEqual(float(delivery.logistics_freight_amount), 36.8)
self.assertEqual(float(order_item.delivered_qty), 4)
self.assertEqual(order_item.status, "PARTIAL")
self.assertEqual(sales_order.status, "PARTIAL")
self.assertEqual(delivery_item.sales_order_item_id, order_item.id)
self.assertEqual(trace_row["source_material_lot_no"], "RML-001")
self.assertEqual(trace_row["source_material_code"], "MAT-001")
self.assertEqual(trace_row["source_material_name"], "冷轧钢板")
self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001")
self.assertEqual(trace_row["lot_no"], "FGL-001")
self.assertEqual(float(stock_balance.qty_on_hand), 16)
self.assertEqual(float(stock_balance.qty_available), 16)
def test_delivery_trace_aliases_legacy_sub_batch_to_source_lot_no(self) -> None:
self.finished_lot.source_material_lot_id = None
self.finished_lot.source_material_sub_batch_no = "JH_MAT001_0001"
self.db.commit()
sales_employee = self._seed_sales_employee_permission()
order_result = create_sales_order(
SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=sales_employee.id,
promised_date=date.today(),
delivery_address=self.customer.address,
tax_rate=0,
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=1,
unit_price=12.5,
)
],
),
db=self.db,
)
order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id))
create_delivery(
DeliveryCreate(
sales_order_id=order_result.order_id,
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
consignee_name=self.customer.contact_name,
consignee_phone=self.customer.contact_phone,
delivery_address=self.customer.address,
waybill_no="YD-XS-LEGACY",
freight_amount=10,
items=[
DeliveryItemCreate(
sales_order_item_id=order_item.id,
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=1,
delivery_weight_kg=2,
unit_price=12.5,
)
],
),
context=self._auth_context(),
db=self.db,
)
trace_row = self.db.execute(get_delivery_items_query(limit=10)).mappings().first()
self.assertEqual(trace_row["source_material_lot_no"], "JH_MAT001_0001")
self.assertEqual(trace_row["source_material_sub_batch_no"], "JH_MAT001_0001")
def test_delivery_line_link_infers_sales_order_header(self) -> None:
sales_employee = self._seed_sales_employee_permission()
order_result = create_sales_order(
SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=sales_employee.id,
promised_date=date.today(),
delivery_address=self.customer.address,
tax_rate=0,
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=6,
unit_price=10,
)
],
),
db=self.db,
)
order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id))
delivery_result = create_delivery(
DeliveryCreate(
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
waybill_no="YD-XS-002",
freight_amount=20,
items=[
DeliveryItemCreate(
sales_order_item_id=order_item.id,
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=2,
delivery_weight_kg=4,
unit_price=10,
)
],
),
context=self._auth_context(),
db=self.db,
)
delivery = self.db.get(Delivery, delivery_result.delivery_id)
sales_order = self.db.get(SalesOrder, order_result.order_id)
sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT"))
self.assertEqual(delivery_result.sales_order_id, order_result.order_id)
self.assertIsNotNone(sales_out_txn)
self.assertEqual(delivery_result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(delivery_result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION)
self.assertEqual(delivery_result.archive_business_id, sales_out_txn.id)
archive = self.db.scalar(
select(DocumentArchive).where(
DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION,
DocumentArchive.business_id == sales_out_txn.id,
)
)
self.assertIsNotNone(archive)
self.assertEqual(archive.status, ARCHIVE_STATUS_READY)
self.assertEqual(delivery.sales_order_id, order_result.order_id)
self.assertEqual(sales_order.status, "PARTIAL")
def test_delivery_generates_archive_for_each_sales_out_transaction(self) -> None:
second_lot = self._seed_second_finished_lot()
sales_employee = self._seed_sales_employee_permission()
order_result = create_sales_order(
SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=sales_employee.id,
promised_date=date.today(),
delivery_address=self.customer.address,
tax_rate=0,
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=6,
unit_price=10,
)
],
),
db=self.db,
)
order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id))
delivery_result = create_delivery(
DeliveryCreate(
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
waybill_no="YD-MULTI-LOT",
freight_amount=20,
items=[
DeliveryItemCreate(
sales_order_item_id=order_item.id,
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=2,
delivery_weight_kg=4,
unit_price=10,
),
DeliveryItemCreate(
sales_order_item_id=order_item.id,
product_item_id=self.product.id,
lot_id=second_lot.id,
delivery_qty=1,
delivery_weight_kg=2,
unit_price=10,
),
],
),
context=self._auth_context(),
db=self.db,
)
sales_out_txns = self.db.scalars(
select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT").order_by(InventoryTxn.id)
).all()
self.assertEqual(len(sales_out_txns), 2)
self.assertEqual(delivery_result.archive_business_id, sales_out_txns[0].id)
archive_business_ids = set(
self.db.scalars(
select(DocumentArchive.business_id).where(
DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION,
DocumentArchive.business_id.in_([txn.id for txn in sales_out_txns]),
)
).all()
)
self.assertEqual(archive_business_ids, {txn.id for txn in sales_out_txns})
def test_create_delivery_accepts_inventory_ledger_shipper(self) -> None:
shipper = self._seed_inventory_ledger_employee_permission()
delivery_result = create_delivery(
DeliveryCreate(
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
shipper_employee_id=shipper.id,
waybill_no="YD-INVENTORY-SHIPPER",
freight_amount=12,
items=[
DeliveryItemCreate(
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=1,
delivery_weight_kg=2,
unit_price=0,
)
],
),
context=self._auth_context(),
db=self.db,
)
delivery = self.db.get(Delivery, delivery_result.delivery_id)
self.assertEqual(delivery.shipper_employee_id, shipper.id)
def test_sales_delivery_requires_logistics_info(self) -> None:
sales_employee = self._seed_sales_employee_permission()
order_result = create_sales_order(
SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=sales_employee.id,
promised_date=date.today(),
delivery_address=self.customer.address,
tax_rate=0,
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=6,
unit_price=10,
)
],
),
db=self.db,
)
order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id))
with self.assertRaises(HTTPException) as exc:
create_delivery(
DeliveryCreate(
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
items=[
DeliveryItemCreate(
sales_order_item_id=order_item.id,
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=2,
delivery_weight_kg=4,
unit_price=10,
)
],
),
context=self._auth_context(),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("运单号", exc.exception.detail)
self.assertIn("运费", exc.exception.detail)
def test_sales_delivery_allows_order_photo_instead_of_waybill(self) -> None:
sales_employee = self._seed_sales_employee_permission()
order_result = create_sales_order(
SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=sales_employee.id,
promised_date=date.today(),
delivery_address=self.customer.address,
tax_rate=0,
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=6,
unit_price=10,
)
],
),
db=self.db,
)
order_item = self.db.scalar(select(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_result.order_id))
delivery_result = create_delivery(
DeliveryCreate(
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
order_photo_url="/inventory/logistics-photos/sales-photo.jpg",
freight_amount=20,
items=[
DeliveryItemCreate(
sales_order_item_id=order_item.id,
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=2,
delivery_weight_kg=4,
unit_price=10,
)
],
),
context=self._auth_context(),
db=self.db,
)
delivery = self.db.get(Delivery, delivery_result.delivery_id)
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT"))
self.assertIsNone(delivery.logistics_waybill_no)
self.assertEqual(delivery.logistics_photo_url, "/inventory/logistics-photos/sales-photo.jpg")
self.assertEqual(txn.logistics_photo_url, "/inventory/logistics-photos/sales-photo.jpg")
def test_direct_customer_delivery_without_sales_order_preserves_trace_and_inventory(self) -> None:
delivery_result = create_delivery(
DeliveryCreate(
customer_id=self.customer.id,
warehouse_id=self.warehouse.id,
consignee_name=self.customer.contact_name,
consignee_phone=self.customer.contact_phone,
delivery_address=self.customer.address,
waybill_no="YD-DIRECT-001",
freight_amount=18,
items=[
DeliveryItemCreate(
sales_order_item_id=None,
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=3,
delivery_weight_kg=6,
unit_price=0,
)
],
),
context=self._auth_context(),
db=self.db,
)
delivery = self.db.get(Delivery, delivery_result.delivery_id)
delivery_item = self.db.scalar(select(DeliveryItem).where(DeliveryItem.delivery_id == delivery.id))
trace_row = self.db.execute(get_delivery_items_query(limit=10, delivery_id=delivery.id)).mappings().first()
stock_balance = self.db.scalar(select(StockBalance).where(StockBalance.item_id == self.product.id))
sales_out_txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "SALES_OUT"))
self.assertIsNone(delivery.sales_order_id)
self.assertEqual(delivery.customer_id, self.customer.id)
self.assertIsNone(delivery_item.sales_order_item_id)
self.assertEqual(trace_row["source_material_lot_no"], "RML-001")
self.assertEqual(trace_row["source_material_code"], "MAT-001")
self.assertEqual(trace_row["source_material_name"], "冷轧钢板")
self.assertEqual(trace_row["source_material_sub_batch_no"], "RML-001")
self.assertEqual(trace_row["source_material_summary"], "RML-001 / 冷轧钢板 / 40kg / ¥200")
self.assertEqual(float(stock_balance.qty_on_hand), 17)
self.assertEqual(float(stock_balance.qty_available), 17)
self.assertEqual(sales_out_txn.source_doc_type, "DELIVERY")
self.assertEqual(sales_out_txn.source_doc_id, delivery.id)
def test_sales_delivery_rejects_non_finished_warehouse(self) -> None:
raw_warehouse = Warehouse(
id=2,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
)
self.db.add(raw_warehouse)
self.db.commit()
with self.assertRaises(HTTPException) as exc:
create_delivery(
DeliveryCreate(
customer_id=self.customer.id,
warehouse_id=raw_warehouse.id,
waybill_no="YD-WRONG-WH",
freight_amount=12,
items=[
DeliveryItemCreate(
product_item_id=self.product.id,
lot_id=self.finished_lot.id,
delivery_qty=1,
delivery_weight_kg=2,
unit_price=0,
)
],
),
context=self._auth_context(),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("销售出库必须选择成品库", exc.exception.detail)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,275 @@
from __future__ import annotations
import unittest
from datetime import date, datetime
from unittest.mock import patch
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@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.api.routes.sales import create_sales_order, list_sales_orders # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item # noqa: E402
from app.models.sales import Customer # noqa: E402
from app.schemas.database import SalesOrderCreate, SalesOrderItemCreate # noqa: E402
from app.schemas.document_archives import DocumentArchiveGenerateResult # noqa: E402
class SalesOrderDocumentArchiveTest(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()
now = datetime.now()
self.customer = Customer(
id=1,
customer_code="CUS-001",
customer_name="测试客户",
short_name="客户",
address="上海市测试路 1 号",
credit_days=30,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.product = Item(
id=10,
item_code="FG-001",
item_name="测试成品",
item_type="成品",
specification="A-100",
unit_weight_kg=1.25,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([self.customer, self.product])
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _payload(self) -> SalesOrderCreate:
return SalesOrderCreate(
customer_id=self.customer.id,
sales_employee_id=None,
promised_date=date(2026, 6, 20),
delivery_address="上海市测试路 2 号",
tax_rate=0.13,
remark="归档测试",
items=[
SalesOrderItemCreate(
product_item_id=self.product.id,
order_qty=2,
unit_price=100,
promised_date=date(2026, 6, 20),
customer_part_no="CPN-001",
)
],
)
def test_create_sales_order_generates_archive_and_returns_archive_metadata(self) -> None:
def fake_generate_archive(
db: Session,
document_type: str,
business_id: int,
) -> DocumentArchiveGenerateResult:
db.add(
DocumentArchive(
document_type=document_type,
business_id=business_id,
document_no="销售2026-00001",
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="销售2026-00001.pdf",
file_path="/tmp/销售2026-00001.pdf",
file_hash="abc123",
status="已归档",
error_message=None,
created_by=None,
)
)
db.commit()
return DocumentArchiveGenerateResult(
business_id=business_id,
document_type=document_type,
document_no="销售2026-00001",
archive_status="已归档",
archive_version=1,
archive_error_message=None,
)
with (
patch("app.api.routes.sales.ensure_employee_has_permission"),
patch("app.api.routes.sales.generate_document_archive", side_effect=fake_generate_archive) as archive_mock,
):
result = create_sales_order(self._payload(), self.db)
archive_mock.assert_called_once_with(self.db, "销售订单", int(result.order_id))
self.assertEqual(result.archive_status, "已归档")
self.assertEqual(result.archive_version, 1)
self.assertIsNone(result.archive_error_message)
saved_archive = self.db.scalar(select(DocumentArchive).where(DocumentArchive.business_id == result.order_id))
self.assertIsNotNone(saved_archive)
def test_list_sales_orders_exposes_latest_archive_fields(self) -> None:
with (
patch("app.api.routes.sales.ensure_employee_has_permission"),
patch(
"app.api.routes.sales.generate_document_archive",
return_value=DocumentArchiveGenerateResult(
business_id=1,
document_type="销售订单",
document_no="销售2026-00001",
archive_status="已归档",
archive_version=1,
archive_error_message=None,
),
),
):
result = create_sales_order(self._payload(), self.db)
created_at = datetime(2026, 6, 11, 10, 30, 0)
self.db.add_all(
[
DocumentArchive(
document_type="销售订单",
business_id=result.order_id,
document_no=result.order_no,
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="v1.pdf",
file_path="/tmp/v1.pdf",
file_hash="v1",
status="已归档",
error_message=None,
created_by=None,
created_at=created_at,
updated_at=created_at,
),
DocumentArchive(
document_type="销售订单",
business_id=result.order_id,
document_no=result.order_no,
archive_version=2,
template_version="单据纸面V1",
file_format="PDF",
file_name="v2.pdf",
file_path="/tmp/v2.pdf",
file_hash="v2",
status="归档失败",
error_message="render failed",
created_by=None,
created_at=datetime(2026, 6, 11, 10, 35, 0),
updated_at=datetime(2026, 6, 11, 10, 35, 0),
),
]
)
self.db.commit()
rows = list_sales_orders(limit=200, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].archive_status, "归档失败")
self.assertEqual(rows[0].archive_version, 2)
self.assertEqual(rows[0].archive_created_at, datetime(2026, 6, 11, 10, 35, 0))
self.assertEqual(rows[0].archive_error_message, "render failed")
def test_list_sales_orders_uses_pdf_archive_when_same_version_has_other_format(self) -> None:
with (
patch("app.api.routes.sales.ensure_employee_has_permission"),
patch(
"app.api.routes.sales.generate_document_archive",
return_value=DocumentArchiveGenerateResult(
business_id=1,
document_type="销售订单",
document_no="销售2026-00001",
archive_status="已归档",
archive_version=1,
archive_error_message=None,
),
),
):
result = create_sales_order(self._payload(), self.db)
self.db.add_all(
[
DocumentArchive(
document_type="销售订单",
business_id=result.order_id,
document_no=result.order_no,
archive_version=1,
template_version="单据纸面V1",
file_format="PDF",
file_name="order.pdf",
file_path="/tmp/order.pdf",
file_hash="pdf",
status="已归档",
error_message=None,
created_by=None,
created_at=datetime(2026, 6, 11, 10, 30, 0),
updated_at=datetime(2026, 6, 11, 10, 30, 0),
),
DocumentArchive(
document_type="销售订单",
business_id=result.order_id,
document_no=result.order_no,
archive_version=1,
template_version="单据纸面V1",
file_format="DOCX",
file_name="order.docx",
file_path="/tmp/order.docx",
file_hash="docx",
status="DOCX不应显示",
error_message="wrong format",
created_by=None,
created_at=datetime(2026, 6, 11, 10, 35, 0),
updated_at=datetime(2026, 6, 11, 10, 35, 0),
),
]
)
self.db.commit()
rows = list_sales_orders(limit=200, db=self.db)
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].archive_status, "已归档")
self.assertEqual(rows[0].archive_version, 1)
self.assertIsNone(rows[0].archive_error_message)
def test_create_sales_order_returns_failed_archive_metadata_when_archive_generation_raises(self) -> None:
with (
patch("app.api.routes.sales.ensure_employee_has_permission"),
patch("app.api.routes.sales.generate_document_archive", side_effect=RuntimeError("renderer exploded")),
):
result = create_sales_order(self._payload(), self.db)
self.assertIsNotNone(result.order_id)
self.assertTrue(result.order_no)
self.assertEqual(result.archive_status, "归档失败")
self.assertIsNone(result.archive_version)
self.assertTrue(result.archive_error_message and result.archive_error_message.startswith("归档失败:"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,92 @@
from __future__ import annotations
import unittest
from datetime import UTC, date, datetime
from sqlalchemy import BigInteger, create_engine
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.sales import build_sales_order_no # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.sales import Customer, SalesOrder # noqa: E402
class SalesOrderNoGenerationTest(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.customer = Customer(
id=1,
customer_code="CUS-001",
customer_name="测试客户",
short_name="客户",
status="ACTIVE",
)
self.db.add(self.customer)
self.db.commit()
def tearDown(self) -> None:
self.db.close()
def _add_sales_order(self, order_no: str) -> None:
now = datetime.now(UTC)
self.db.add(
SalesOrder(
order_no=order_no,
customer_id=self.customer.id,
order_date=date.today(),
promised_date=None,
sales_employee_id=None,
delivery_address=None,
tax_rate=0,
total_amount=0,
status="OPEN",
created_at=now,
updated_at=now,
)
)
self.db.commit()
def test_sales_order_no_starts_from_year_five_digit_sequence(self) -> None:
year = date.today().strftime("%Y")
self.assertEqual(build_sales_order_no(self.db, self.customer), f"销售{year}-00001")
def test_sales_order_no_increments_only_matching_current_year_orders(self) -> None:
year = date.today().strftime("%Y")
previous_year = str(int(year) - 1)
self._add_sales_order(f"销售{year}-00001")
self._add_sales_order(f"销售{year}-00009")
self._add_sales_order(f"{previous_year}-00099")
self._add_sales_order(f"{year}-00099")
self._add_sales_order("SO-NB-20260523-CUS001-001")
self.assertEqual(build_sales_order_no(self.db, self.customer), f"销售{year}-00010")
def test_sales_order_no_ignores_malformed_same_year_codes(self) -> None:
year = date.today().strftime("%Y")
self._add_sales_order(f"销售{year}-001")
self._add_sales_order(f"销售{year}-000001")
self._add_sales_order(f"销售{year}-ABCDE")
self.assertEqual(build_sales_order_no(self.db, self.customer), f"销售{year}-00001")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,428 @@
from __future__ import annotations
import unittest
from datetime import date, timedelta
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.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.api.routes.purchase import create_purchase_order, delete_purchase_order, lock_purchase_order, update_purchase_order # noqa: E402
from app.api.routes.sales import delete_sales_order # noqa: E402
from app.models.base import Base # noqa: E402
from app.models.master_data import Bom, BomItem, Item, Material # noqa: E402
from app.models.operations import PurchaseOrder, PurchaseOrderItem, PurchaseOrderSalesOrderLink, Supplier # noqa: E402
from app.models.planning import MaterialDemand # noqa: E402
from app.models.sales import Customer, SalesOrder, SalesOrderItem # noqa: E402
from app.schemas.operations import PurchaseOrderCreate, PurchaseOrderItemCreate, PurchaseOrderUpdate # noqa: E402
from app.services.operations import get_purchase_order_query, sync_purchase_order_item_status, sync_purchase_order_status # noqa: E402
class SalesOrderPurchaseLockFlowTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed_master_data()
def tearDown(self) -> None:
self.db.close()
def _seed_master_data(self) -> None:
self.customer = Customer(
id=1,
customer_code="CUS-LOCK-001",
customer_name="采购关联客户",
short_name="采购客户",
status="ACTIVE",
)
self.product = Item(
id=1,
item_code="FG-LOCK-001",
item_name="锁单测试成品",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
)
self.material = Item(
id=2,
item_code="RM-LOCK-001",
item_name="锁单测试原料",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
)
self.supplier = Supplier(
id=1,
supplier_code="SUP-LOCK-001",
supplier_name="锁单供应商",
short_name="锁单供应商",
lead_time_days=0,
default_tax_rate=Decimal("0"),
status="ACTIVE",
)
self.db.add_all([self.customer, self.product, self.material, self.supplier])
self.db.flush()
self.db.add(
Material(
item_id=self.material.id,
default_supplier_id=self.supplier.id,
purchase_calc_mode="BY_WEIGHT",
min_purchase_qty=Decimal("0"),
purchase_multiple_qty=Decimal("0"),
purchase_multiple_weight_kg=Decimal("0"),
lead_time_days=0,
)
)
self.bom = Bom(
id=1,
bom_code="BOM-LOCK-001",
product_item_id=self.product.id,
version_no="V1.0",
is_default=1,
status="ACTIVE",
)
self.bom_item = BomItem(
id=1,
bom_id=1,
seq_no=1,
material_item_id=self.material.id,
usage_type="WEIGHT",
gross_weight_per_piece_kg=Decimal("1"),
loss_rate=Decimal("0"),
)
self.db.add_all([self.bom, self.bom_item])
self.sales_order = SalesOrder(
id=1,
order_no="SO-LOCK-001",
customer_id=self.customer.id,
order_date=date.today(),
status="OPEN",
)
self.sales_order_item = SalesOrderItem(
id=1,
sales_order_id=self.sales_order.id,
line_no=1,
product_item_id=self.product.id,
order_qty=Decimal("100"),
delivered_qty=Decimal("0"),
unit_price=Decimal("10"),
line_amount=Decimal("1000"),
status="OPEN",
)
self.demand = MaterialDemand(
id=1,
demand_no="MRP-LOCK-001",
sales_order_item_id=self.sales_order_item.id,
product_item_id=self.product.id,
material_item_id=self.material.id,
bom_item_id=self.bom_item.id,
order_qty=Decimal("100"),
theoretical_weight_kg=Decimal("100"),
planned_weight_kg=Decimal("100"),
available_stock_weight_kg=Decimal("0"),
shortage_weight_kg=Decimal("100"),
suggested_purchase_qty=Decimal("100"),
suggested_purchase_weight_kg=Decimal("100"),
status="NEW",
)
self.db.add_all([self.sales_order, self.sales_order_item, self.demand])
self.db.commit()
def test_purchase_order_tracks_sales_order_source_and_can_be_locked(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
source_demand_id=self.demand.id,
order_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
self.assertEqual(result.source_type, "SALES_ORDER")
self.assertEqual(result.source_order_nos, "SO-LOCK-001")
locked = lock_purchase_order(result.purchase_order_id, db=self.db)
self.assertEqual(locked.status, "LOCKED")
with self.assertRaises(HTTPException) as exc:
delete_sales_order(self.sales_order.id, db=self.db)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("已关联采购订单", exc.exception.detail)
def test_purchase_order_warning_lead_days_marks_unreceived_order(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today() + timedelta(days=3),
warning_lead_days=3,
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
order_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
self.assertEqual(result.warning_lead_days, 3)
self.assertEqual(result.warning_start_date, date.today())
self.assertTrue(result.is_warning_active)
def test_purchase_order_warning_is_inactive_before_warning_start(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today() + timedelta(days=4),
warning_lead_days=3,
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
order_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
self.assertEqual(result.warning_lead_days, 3)
self.assertEqual(result.warning_start_date, date.today() + timedelta(days=1))
self.assertFalse(result.is_warning_active)
def test_explicit_purchase_order_link_blocks_sales_order_delete(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
sales_order_ids=[self.sales_order.id],
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
order_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
self.assertIsNotNone(
self.db.scalar(
select(PurchaseOrderSalesOrderLink).where(
PurchaseOrderSalesOrderLink.purchase_order_id == result.purchase_order_id,
PurchaseOrderSalesOrderLink.sales_order_id == self.sales_order.id,
)
)
)
with self.assertRaises(HTTPException) as exc:
delete_sales_order(self.sales_order.id, db=self.db)
self.assertEqual(exc.exception.status_code, 400)
self.assertEqual(exc.exception.detail, "销售订单已关联采购订单,不能删除")
def test_purchase_order_query_does_not_use_group_concat(self) -> None:
compiled = str(get_purchase_order_query().compile(self.db.get_bind()))
self.assertNotIn("group_concat", compiled.lower())
def test_open_purchase_order_can_be_updated_before_lock(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
source_demand_id=self.demand.id,
order_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
updated = update_purchase_order(
result.purchase_order_id,
PurchaseOrderUpdate(
supplier_id=self.supplier.id,
expected_date=date.today(),
remark="修改未锁单采购订单",
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
source_demand_id=self.demand.id,
order_weight_kg=Decimal("120"),
unit_price=Decimal("6"),
)
],
),
db=self.db,
)
self.assertEqual(updated.po_no, result.po_no)
self.assertEqual(updated.status, "OPEN")
self.assertEqual(float(updated.total_order_weight_kg), 120)
self.assertEqual(float(updated.total_amount), 720)
item = self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == result.purchase_order_id))
self.assertEqual(float(item.order_weight_kg), 120)
self.assertEqual(float(item.unit_price), 6)
self.assertEqual(self.db.get(MaterialDemand, self.demand.id).status, "ORDERED")
def test_locked_purchase_order_cannot_be_updated_or_deleted(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
source_demand_id=self.demand.id,
order_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
lock_purchase_order(result.purchase_order_id, db=self.db)
with self.assertRaises(HTTPException) as update_exc:
update_purchase_order(
result.purchase_order_id,
PurchaseOrderUpdate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
source_demand_id=self.demand.id,
order_weight_kg=Decimal("110"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
self.assertEqual(update_exc.exception.status_code, 400)
with self.assertRaises(HTTPException) as delete_exc:
delete_purchase_order(result.purchase_order_id, db=self.db)
self.assertEqual(delete_exc.exception.status_code, 400)
def test_open_purchase_order_can_be_deleted_and_resets_unreferenced_demand(self) -> None:
result = create_purchase_order(
PurchaseOrderCreate(
supplier_id=self.supplier.id,
expected_date=date.today(),
items=[
PurchaseOrderItemCreate(
material_item_id=self.material.id,
source_demand_id=self.demand.id,
order_weight_kg=Decimal("100"),
unit_price=Decimal("5"),
)
],
),
db=self.db,
)
self.assertEqual(self.db.get(MaterialDemand, self.demand.id).status, "ORDERED")
delete_result = delete_purchase_order(result.purchase_order_id, db=self.db)
self.assertIn("已删除", delete_result["message"])
self.assertIsNone(self.db.get(PurchaseOrder, result.purchase_order_id))
self.assertIsNone(self.db.scalar(select(PurchaseOrderItem).where(PurchaseOrderItem.purchase_order_id == result.purchase_order_id)))
self.assertEqual(self.db.get(MaterialDemand, self.demand.id).status, "NEW")
def test_unlinked_sales_order_can_be_deleted(self) -> None:
order = SalesOrder(
id=2,
order_no="SO-DELETE-001",
customer_id=self.customer.id,
order_date=date.today(),
status="OPEN",
)
item = SalesOrderItem(
id=2,
sales_order_id=order.id,
line_no=1,
product_item_id=self.product.id,
order_qty=Decimal("1"),
delivered_qty=Decimal("0"),
unit_price=Decimal("1"),
line_amount=Decimal("1"),
status="OPEN",
)
self.db.add_all([order, item])
self.db.commit()
order_id = order.id
item_id = item.id
result = delete_sales_order(order_id, db=self.db)
self.assertIn("已删除", result["message"])
self.assertIsNone(self.db.get(SalesOrder, order_id))
self.assertIsNone(self.db.get(SalesOrderItem, item_id))
def test_locked_purchase_order_stays_locked_after_partial_receipt_status_sync(self) -> None:
order = PurchaseOrder(
id=10,
po_no="PO-LOCK-001",
supplier_id=self.supplier.id,
order_date=date.today(),
status="LOCKED",
)
item = PurchaseOrderItem(
id=10,
purchase_order_id=order.id,
line_no=1,
material_item_id=self.material.id,
order_qty=Decimal("100"),
order_weight_kg=Decimal("100"),
received_qty=Decimal("50"),
received_weight_kg=Decimal("50"),
unit_price=Decimal("5"),
line_amount=Decimal("500"),
status="OPEN",
)
self.db.add_all([order, item])
self.db.commit()
sync_purchase_order_item_status(item)
self.db.add(item)
sync_purchase_order_status(self.db, order.id)
self.db.commit()
self.assertEqual(item.status, "PARTIAL")
self.assertEqual(self.db.get(PurchaseOrder, order.id).status, "LOCKED")
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,857 @@
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()
@@ -0,0 +1,56 @@
from __future__ import annotations
import unittest
from sqlalchemy import BigInteger, create_engine
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.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.services.system_config import ( # noqa: E402
get_smart_operation_report_enabled,
normalize_enabled_config_value,
upsert_smart_operation_report_config,
)
class SmartOperationReportConfigTest(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 test_defaults_enabled(self) -> None:
self.assertTrue(get_smart_operation_report_enabled(self.db))
def test_normalizes_chinese_values(self) -> None:
self.assertEqual(normalize_enabled_config_value("开启"), "开启")
self.assertEqual(normalize_enabled_config_value("关闭"), "关闭")
self.assertEqual(normalize_enabled_config_value(True), "开启")
self.assertEqual(normalize_enabled_config_value(False), "关闭")
def test_upsert_changes_enabled_state(self) -> None:
upsert_smart_operation_report_config(self.db, enabled=False, updated_by=1, remark="未购买小程序")
self.db.commit()
self.assertFalse(get_smart_operation_report_enabled(self.db))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,619 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from types import SimpleNamespace
from sqlalchemy import BigInteger, create_engine, select
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import Session, sessionmaker
@compiles(BigInteger, "sqlite")
def _compile_bigint_for_sqlite(type_, compiler, **kw) -> str:
_ = type_, compiler, kw
return "INTEGER"
import app.models.document_archive # noqa: E402,F401
import app.models.master_data # noqa: E402,F401
import app.models.miniapp # noqa: E402,F401
import app.models.operations # noqa: E402,F401
import app.models.org # noqa: E402,F401
import app.models.planning # noqa: E402,F401
import app.models.sales # noqa: E402,F401
from app.models.base import Base # noqa: E402
from app.models.document_archive import DocumentArchive # noqa: E402
from app.models.master_data import Item, Product, StockBalance, Unit, Warehouse # noqa: E402
from app.models.operations import InventoryTxn, StockLot, WarehouseLocation # noqa: E402
from app.models.org import Department, Employee, User # noqa: E402
from app.schemas.operations import ( # noqa: E402
SpecialWarehouseAdjustmentCreate,
SpecialWarehouseAdjustmentLineCreate,
)
from app.api.routes.inventory import create_special_warehouse_adjustment # noqa: E402
from app.services.document_archives import ARCHIVE_STATUS_READY, DOCUMENT_TYPE_WAREHOUSE_OPERATION # noqa: E402
from app.services.operations import get_inventory_txn_ledger_query # noqa: E402
from app.services.special_inventory_adjustment import create_special_inventory_adjustment # noqa: E402
class SpecialWarehouseAdjustmentTest(unittest.TestCase):
def setUp(self) -> None:
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
Base.metadata.create_all(engine)
self.SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
self.db: Session = self.SessionLocal()
self._seed_master_data()
def tearDown(self) -> None:
self.db.close()
def _seed_master_data(self) -> None:
now = datetime(2026, 6, 6, 9, 0, tzinfo=UTC)
self.department = Department(
id=1,
dept_code="D-SPECIAL",
dept_name="仓库部",
dept_type="WAREHOUSE",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.employee = Employee(
id=1,
employee_code="EMP-SPECIAL-001",
employee_name="特殊调整员",
dept_id=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.user = User(
id=1,
username="special_tester",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="特殊调整员",
is_super_admin=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.unit = Unit(id=1, unit_code="PCS", unit_name="个", precision_digits=0, 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("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.aux_item = Item(
id=2,
item_code="辅料00001",
item_name="包装薄膜",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("0"),
stock_unit_id=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished_item = Item(
id=3,
item_code="产品需规00001",
item_name="冲压支架",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("2.5"),
stock_unit_id=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished_product = Product(
id=1,
item_id=3,
drawing_no="FG-DWG-001",
product_family="支架",
version_no="V1",
created_at=now,
updated_at=now,
)
self.raw_warehouse = Warehouse(
id=1,
warehouse_code="WH-RAW",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.aux_warehouse = Warehouse(
id=2,
warehouse_code="WH-AUX",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished_warehouse = Warehouse(
id=3,
warehouse_code="WH-FG",
warehouse_name="成品库",
warehouse_type="FINISHED",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.raw_location = WarehouseLocation(
id=1,
warehouse_id=1,
location_code="RAW-A",
location_name="原材料默认库位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.aux_location = WarehouseLocation(
id=2,
warehouse_id=2,
location_code="AUX-A",
location_name="辅料默认库位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished_location = WarehouseLocation(
id=3,
warehouse_id=3,
location_code="FG-A",
location_name="成品默认库位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.raw_lot = StockLot(
id=1,
lot_no="RAW-LOT-001",
lot_role="INBOUND_RAW",
item_id=1,
warehouse_id=1,
location_id=1,
source_doc_type="OPENING_STOCK",
source_doc_id=1,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("100"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("80"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("10"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
self.aux_lot = StockLot(
id=2,
lot_no="AUX-LOT-001",
lot_role="INVENTORY",
item_id=2,
warehouse_id=2,
location_id=2,
source_doc_type="OPENING_STOCK",
source_doc_id=2,
inbound_qty=Decimal("10"),
inbound_weight_kg=Decimal("0"),
remaining_qty=Decimal("6"),
remaining_weight_kg=Decimal("0"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("2.5"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
self.finished_lot = StockLot(
id=3,
lot_no="FG-LOT-001",
lot_role="INBOUND_FINISHED",
item_id=3,
warehouse_id=3,
location_id=3,
source_doc_type="FG_RECEIPT",
source_doc_id=3,
inbound_qty=Decimal("20"),
inbound_weight_kg=Decimal("50"),
remaining_qty=Decimal("12"),
remaining_weight_kg=Decimal("30"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("18"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
self.raw_balance = StockBalance(
id=1,
item_id=1,
warehouse_id=1,
location_id=1,
qty_on_hand=Decimal("0"),
weight_on_hand_kg=Decimal("80"),
qty_available=Decimal("0"),
weight_available_kg=Decimal("80"),
avg_unit_cost=Decimal("10"),
created_at=now,
updated_at=now,
)
self.aux_balance = StockBalance(
id=2,
item_id=2,
warehouse_id=2,
location_id=2,
qty_on_hand=Decimal("6"),
weight_on_hand_kg=Decimal("0"),
qty_available=Decimal("6"),
weight_available_kg=Decimal("0"),
avg_unit_cost=Decimal("2.5"),
created_at=now,
updated_at=now,
)
self.finished_balance = StockBalance(
id=3,
item_id=3,
warehouse_id=3,
location_id=3,
qty_on_hand=Decimal("12"),
weight_on_hand_kg=Decimal("30"),
qty_available=Decimal("12"),
weight_available_kg=Decimal("30"),
avg_unit_cost=Decimal("18"),
created_at=now,
updated_at=now,
)
self.db.add_all(
[
self.department,
self.employee,
self.user,
self.unit,
self.raw_item,
self.aux_item,
self.finished_item,
self.finished_product,
self.raw_warehouse,
self.aux_warehouse,
self.finished_warehouse,
self.raw_location,
self.aux_location,
self.finished_location,
self.raw_lot,
self.aux_lot,
self.finished_lot,
self.raw_balance,
self.aux_balance,
self.finished_balance,
]
)
self.db.commit()
def _adjustment_payload(
self,
*,
direction: str,
warehouse_id: int,
item_id: int,
lot_id: int | None = None,
location_id: int | None = None,
adjust_qty: Decimal = Decimal("0"),
adjust_weight_kg: Decimal = Decimal("0"),
reason: str | None = None,
unit_cost: Decimal | None = None,
line_reason: str | None = None,
) -> SpecialWarehouseAdjustmentCreate:
return SpecialWarehouseAdjustmentCreate(
warehouse_id=warehouse_id,
direction=direction,
reason=reason,
lines=[
SpecialWarehouseAdjustmentLineCreate(
item_id=item_id,
lot_id=lot_id,
location_id=location_id,
adjust_qty=adjust_qty,
adjust_weight_kg=adjust_weight_kg,
unit_cost=unit_cost,
line_reason=line_reason,
)
],
)
def _only_txn(self, txn_type: str) -> InventoryTxn:
return self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == txn_type))
def _auth_context(self):
return SimpleNamespace(user=SimpleNamespace(id=self.user.id))
def _seed_second_raw_lot(self) -> StockLot:
now = datetime(2026, 6, 6, 9, 0, tzinfo=UTC)
second_lot = StockLot(
id=99,
lot_no="RAW-LOT-002",
lot_role="INBOUND_RAW",
item_id=self.raw_item.id,
warehouse_id=self.raw_warehouse.id,
location_id=self.raw_location.id,
source_doc_type="OPENING_STOCK",
source_doc_id=99,
inbound_qty=Decimal("0"),
inbound_weight_kg=Decimal("20"),
remaining_qty=Decimal("0"),
remaining_weight_kg=Decimal("20"),
locked_qty=Decimal("0"),
locked_weight_kg=Decimal("0"),
unit_cost=Decimal("10"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
self.raw_balance.weight_on_hand_kg += Decimal("20")
self.raw_balance.weight_available_kg += Decimal("20")
self.db.add_all([second_lot, self.raw_balance])
self.db.commit()
return second_lot
def test_special_out_requires_remark_and_keeps_inventory_unchanged(self) -> None:
before_lot_weight = self.raw_lot.remaining_weight_kg
before_balance_weight = self.raw_balance.weight_on_hand_kg
with self.assertRaises(ValueError) as exc:
create_special_inventory_adjustment(
self.db,
self._adjustment_payload(
direction="OUT",
warehouse_id=self.raw_warehouse.id,
item_id=self.raw_item.id,
lot_id=self.raw_lot.id,
adjust_weight_kg=Decimal("5"),
reason="",
),
user_id=9,
)
self.assertIn("特殊出库必须填写说明", str(exc.exception))
self.db.refresh(self.raw_lot)
self.db.refresh(self.raw_balance)
self.assertEqual(self.raw_lot.remaining_weight_kg, before_lot_weight)
self.assertEqual(self.raw_balance.weight_on_hand_kg, before_balance_weight)
self.assertIsNone(self._only_txn("SPECIAL_OUT"))
def test_raw_special_out_deducts_lot_balance_by_weight_and_writes_ledger(self) -> None:
create_special_inventory_adjustment(
self.db,
self._adjustment_payload(
direction="OUT",
warehouse_id=self.raw_warehouse.id,
item_id=self.raw_item.id,
lot_id=self.raw_lot.id,
adjust_weight_kg=Decimal("12.5"),
reason="样品寄出",
),
user_id=9,
)
self.db.refresh(self.raw_lot)
self.db.refresh(self.raw_balance)
txn = self._only_txn("SPECIAL_OUT")
self.assertEqual(self.raw_lot.remaining_qty, Decimal("0.000000"))
self.assertEqual(self.raw_lot.remaining_weight_kg, Decimal("67.500000"))
self.assertEqual(self.raw_balance.qty_on_hand, Decimal("0.000000"))
self.assertEqual(self.raw_balance.weight_on_hand_kg, Decimal("67.500000"))
self.assertEqual(self.raw_balance.weight_available_kg, Decimal("67.500000"))
self.assertIsNotNone(txn)
self.assertEqual(txn.lot_id, self.raw_lot.id)
self.assertEqual(txn.qty_change, Decimal("0.000000"))
self.assertEqual(txn.weight_change_kg, Decimal("-12.500000"))
self.assertIn("特殊出库说明:样品寄出", txn.remark)
self.assertIn("调整前重量:80", txn.remark)
self.assertIn("调整后重量:67.5", txn.remark)
def test_raw_special_out_is_visible_in_inventory_transaction_ledger(self) -> None:
create_special_inventory_adjustment(
self.db,
self._adjustment_payload(
direction="OUT",
warehouse_id=self.raw_warehouse.id,
item_id=self.raw_item.id,
lot_id=self.raw_lot.id,
adjust_weight_kg=Decimal("6.5"),
reason="研发打样领出",
),
user_id=9,
)
rows = self.db.execute(
get_inventory_txn_ledger_query(warehouse_type="RAW", txn_type="SPECIAL_OUT")
).mappings().all()
self.assertEqual(len(rows), 1)
row = rows[0]
self.assertEqual(row["direction"], "OUT")
self.assertEqual(row["txn_type"], "SPECIAL_OUT")
self.assertIn("特殊出库说明:研发打样领出", row["remark"])
def test_special_adjustment_generates_archive_for_each_adjustment_transaction(self) -> None:
second_lot = self._seed_second_raw_lot()
result = create_special_warehouse_adjustment(
SpecialWarehouseAdjustmentCreate(
warehouse_id=self.raw_warehouse.id,
direction="OUT",
reason="研发打样多批次领出",
lines=[
SpecialWarehouseAdjustmentLineCreate(
item_id=self.raw_item.id,
lot_id=self.raw_lot.id,
adjust_weight_kg=Decimal("5"),
),
SpecialWarehouseAdjustmentLineCreate(
item_id=self.raw_item.id,
lot_id=second_lot.id,
adjust_weight_kg=Decimal("7"),
),
],
),
context=self._auth_context(),
db=self.db,
)
txns = self.db.scalars(
select(InventoryTxn).where(InventoryTxn.txn_type == "SPECIAL_OUT").order_by(InventoryTxn.id)
).all()
self.assertEqual(len(txns), 2)
self.assertEqual(result.archive_status, ARCHIVE_STATUS_READY)
self.assertEqual(result.archive_document_type, DOCUMENT_TYPE_WAREHOUSE_OPERATION)
self.assertEqual(result.archive_business_id, txns[0].id)
archive_business_ids = set(
self.db.scalars(
select(DocumentArchive.business_id).where(
DocumentArchive.document_type == DOCUMENT_TYPE_WAREHOUSE_OPERATION,
DocumentArchive.business_id.in_([txn.id for txn in txns]),
)
).all()
)
self.assertEqual(archive_business_ids, {txn.id for txn in txns})
def test_special_out_rejects_quantity_greater_than_auxiliary_available_quantity(self) -> None:
with self.assertRaises(ValueError) as exc:
create_special_inventory_adjustment(
self.db,
self._adjustment_payload(
direction="OUT",
warehouse_id=self.aux_warehouse.id,
item_id=self.aux_item.id,
lot_id=self.aux_lot.id,
adjust_qty=Decimal("7"),
reason="盘亏调整",
),
user_id=9,
)
self.assertIn("特殊出库数量不能超过当前可用数量", str(exc.exception))
self.db.refresh(self.aux_lot)
self.db.refresh(self.aux_balance)
self.assertEqual(self.aux_lot.remaining_qty, Decimal("6.000000"))
self.assertEqual(self.aux_balance.qty_available, Decimal("6.000000"))
self.assertIsNone(self._only_txn("SPECIAL_OUT"))
def test_auxiliary_special_in_increases_quantity_only_and_writes_ledger(self) -> None:
create_special_inventory_adjustment(
self.db,
self._adjustment_payload(
direction="IN",
warehouse_id=self.aux_warehouse.id,
item_id=self.aux_item.id,
location_id=self.aux_location.id,
adjust_qty=Decimal("4"),
adjust_weight_kg=Decimal("99"),
reason="供应商补送",
unit_cost=Decimal("2.5"),
),
user_id=9,
)
lot = self.db.scalar(
select(StockLot).where(
StockLot.item_id == self.aux_item.id,
StockLot.warehouse_id == self.aux_warehouse.id,
StockLot.source_doc_type == "SPECIAL_ADJUSTMENT",
)
)
self.db.refresh(self.aux_balance)
txn = self._only_txn("SPECIAL_IN")
self.assertIsNotNone(lot)
self.assertEqual(lot.inbound_qty, Decimal("4.000000"))
self.assertEqual(lot.remaining_qty, Decimal("4.000000"))
self.assertEqual(lot.inbound_weight_kg, Decimal("0.000000"))
self.assertEqual(lot.remaining_weight_kg, Decimal("0.000000"))
self.assertEqual(self.aux_balance.qty_on_hand, Decimal("10.000000"))
self.assertEqual(self.aux_balance.qty_available, Decimal("10.000000"))
self.assertEqual(self.aux_balance.weight_on_hand_kg, Decimal("0.000000"))
self.assertEqual(self.aux_balance.weight_available_kg, Decimal("0.000000"))
self.assertIsNotNone(txn)
self.assertEqual(txn.qty_change, Decimal("4.000000"))
self.assertEqual(txn.weight_change_kg, Decimal("0.000000"))
self.assertIn("特殊入库说明:供应商补送", txn.remark)
def test_raw_special_in_generates_global_raw_lot_number(self) -> None:
create_special_inventory_adjustment(
self.db,
self._adjustment_payload(
direction="IN",
warehouse_id=self.raw_warehouse.id,
item_id=self.raw_item.id,
location_id=self.raw_location.id,
adjust_weight_kg=Decimal("15"),
reason="供应商补送原料",
unit_cost=Decimal("10"),
),
user_id=9,
)
lot = self.db.scalar(
select(StockLot).where(
StockLot.item_id == self.raw_item.id,
StockLot.warehouse_id == self.raw_warehouse.id,
StockLot.source_doc_type == "SPECIAL_ADJUSTMENT",
)
)
self.assertIsNotNone(lot)
self.assertEqual(lot.lot_no, "YL0001")
def test_finished_special_out_deducts_quantity_and_weight_together(self) -> None:
create_special_inventory_adjustment(
self.db,
self._adjustment_payload(
direction="OUT",
warehouse_id=self.finished_warehouse.id,
item_id=self.finished_item.id,
lot_id=self.finished_lot.id,
adjust_qty=Decimal("3"),
adjust_weight_kg=Decimal("7.5"),
reason="展会样品",
),
user_id=9,
)
self.db.refresh(self.finished_lot)
self.db.refresh(self.finished_balance)
txn = self._only_txn("SPECIAL_OUT")
self.assertEqual(self.finished_lot.remaining_qty, Decimal("9.000000"))
self.assertEqual(self.finished_lot.remaining_weight_kg, Decimal("22.500000"))
self.assertEqual(self.finished_balance.qty_on_hand, Decimal("9.000000"))
self.assertEqual(self.finished_balance.qty_available, Decimal("9.000000"))
self.assertEqual(self.finished_balance.weight_on_hand_kg, Decimal("22.500000"))
self.assertEqual(self.finished_balance.weight_available_kg, Decimal("22.500000"))
self.assertIsNotNone(txn)
self.assertEqual(txn.qty_change, Decimal("-3.000000"))
self.assertEqual(txn.weight_change_kg, Decimal("-7.500000"))
self.assertIn("特殊出库说明:展会样品", txn.remark)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,561 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from decimal import Decimal
from io import BytesIO
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 StockLot, StocktakeLine, StocktakeWarehouse # noqa: E402
from app.services.stocktake import build_stocktake_workbook, start_stocktake # noqa: E402
class StocktakeExcelDiffConfirmTest(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.raw = Warehouse(
id=1,
warehouse_code="WH-RAW-01",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.item = Item(
id=1,
item_code="原材料00001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([self.raw, self.item])
self.db.flush()
self.lot = StockLot(
id=1,
lot_no="LOT-RAW-001",
lot_role="INVENTORY",
item_id=self.item.id,
warehouse_id=self.raw.id,
source_doc_type="OPENING",
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("2.5"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
self.balance = StockBalance(
item_id=self.item.id,
warehouse_id=self.raw.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("2.5"),
created_at=now,
updated_at=now,
)
self.db.add_all([self.lot, self.balance])
self.db.commit()
def test_empty_warehouse_stocktake_import_can_be_confirmed_and_unlocked(self) -> None:
from app.services.stocktake import confirm_stocktake, import_stocktake_workbook
now = datetime.now(UTC)
aux = Warehouse(
id=2,
warehouse_code="WH-AUX-01",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(aux)
self.db.commit()
stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库")
workbook = build_stocktake_workbook(self.db, stocktake.id)
output = BytesIO()
workbook.save(output)
preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(preview["summary"]["match_count"], 0)
self.assertEqual(preview["lines"], [])
imported = self.db.get(type(stocktake), stocktake.id)
self.assertEqual(imported.status, "IMPORTED")
confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="空仓确认")
stocktake_warehouse = self.db.scalar(
select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake.id)
)
self.assertEqual(confirmed.status, "CONFIRMED")
self.assertEqual(stocktake_warehouse.status, "UNLOCKED")
def test_empty_warehouse_export_includes_stocktake_marker_row(self) -> None:
now = datetime.now(UTC)
aux = Warehouse(
id=2,
warehouse_code="WH-AUX-01",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(aux)
self.db.commit()
stocktake = start_stocktake(self.db, warehouse_ids=[aux.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)
rows = list(loaded["辅料库"].iter_rows(values_only=True))
headers = rows[0]
self.assertEqual(rows[1][headers.index("盘库单号")], stocktake.stocktake_no)
self.assertEqual(rows[1][headers.index("仓库")], "辅料库")
self.assertIn("空库", rows[1][headers.index("盘点备注")])
def test_empty_warehouse_marker_row_imports_as_clean_empty_preview(self) -> None:
from openpyxl import Workbook
from app.services.stocktake import import_stocktake_workbook
now = datetime.now(UTC)
aux = Warehouse(
id=2,
warehouse_code="WH-AUX-01",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(aux)
self.db.commit()
stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库")
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "辅料库"
worksheet.append([
"盘库单号",
"盘库校验码",
"仓库ID",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源库存批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
])
worksheet.append([
stocktake.stocktake_no,
f"{stocktake.stocktake_no}|{aux.id}|AUX",
aux.id,
"",
"辅料库",
"",
"",
"",
"",
"",
0,
0,
0,
0,
0,
"空库盘点校验行",
])
output = BytesIO()
workbook.save(output)
preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(preview["lines"], [])
self.assertEqual(preview["summary"]["match_count"], 0)
self.assertEqual(self.db.get(type(stocktake), stocktake.id).status, "IMPORTED")
def test_exported_empty_warehouse_stocktake_can_be_confirmed_without_import_rows(self) -> None:
from app.services.stocktake import confirm_stocktake
now = datetime.now(UTC)
aux = Warehouse(
id=2,
warehouse_code="WH-AUX-01",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(aux)
self.db.commit()
stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库")
build_stocktake_workbook(self.db, stocktake.id)
confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="空仓确认")
stocktake_warehouse = self.db.scalar(
select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake.id)
)
self.assertEqual(confirmed.status, "CONFIRMED")
self.assertIsNotNone(confirmed.imported_at)
self.assertEqual(stocktake_warehouse.status, "UNLOCKED")
def test_confirm_stocktake_does_not_require_manual_confirm_text(self) -> None:
from app.services.stocktake import confirm_stocktake
now = datetime.now(UTC)
aux = Warehouse(
id=2,
warehouse_code="WH-AUX-01",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(aux)
self.db.commit()
stocktake = start_stocktake(self.db, warehouse_ids=[aux.id], user_id=1, remark="空仓盘库")
build_stocktake_workbook(self.db, stocktake.id)
confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="空仓确认")
self.assertEqual(confirmed.status, "CONFIRMED")
def test_start_stocktake_locks_warehouse_and_snapshots_lots(self) -> None:
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
self.assertEqual(stocktake.status, "LOCKED")
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
self.assertIsNotNone(line)
self.assertEqual(line.lot_no, "LOT-RAW-001")
self.assertEqual(float(line.snapshot_weight_kg), 100)
self.assertEqual(line.diff_type, "MATCH")
def test_start_stocktake_rejects_multiple_warehouses_for_current_bar_flow(self) -> None:
from fastapi import HTTPException
now = datetime.now(UTC)
aux = Warehouse(
id=2,
warehouse_code="WH-AUX-01",
warehouse_name="辅料库",
warehouse_type="AUX",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(aux)
self.db.commit()
with self.assertRaises(HTTPException) as exc:
start_stocktake(self.db, warehouse_ids=[self.raw.id, aux.id], user_id=1, remark="多库盘库")
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("一次只能盘一个仓库", exc.exception.detail)
def test_export_workbook_has_one_sheet_per_selected_warehouse(self) -> None:
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.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.assertEqual(loaded.sheetnames, ["原材料库"])
rows = list(loaded["原材料库"].iter_rows(values_only=True))
self.assertIn("快照行ID", rows[0])
self.assertIn("盘点重量(kg)", rows[0])
self.assertNotIn("系统数量", rows[0])
self.assertNotIn("盘点数量", rows[0])
self.assertEqual(rows[1][rows[0].index("批次号")], "LOT-RAW-001")
def test_single_warehouse_export_contains_validation_marker(self) -> None:
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.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.assertEqual(loaded.sheetnames, ["原材料库"])
rows = list(loaded["原材料库"].iter_rows(values_only=True))
headers = rows[0]
self.assertIn("盘库校验码", headers)
self.assertIn("仓库ID", headers)
self.assertEqual(rows[1][headers.index("盘库单号")], stocktake.stocktake_no)
self.assertEqual(rows[1][headers.index("仓库ID")], self.raw.id)
self.assertEqual(
rows[1][headers.index("盘库校验码")],
f"{stocktake.stocktake_no}|{self.raw.id}|RAW",
)
def _exported_workbook_bytes(self, stocktake) -> bytes:
workbook = build_stocktake_workbook(self.db, stocktake.id)
output = BytesIO()
workbook.save(output)
return output.getvalue()
def test_import_rejects_workbook_from_other_stocktake_number(self) -> None:
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库")
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("盘库单号") + 1).value = "PK-OTHER-001"
worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = "PK-OTHER-001|1|RAW"
output = BytesIO()
workbook.save(output)
with self.assertRaises(ValueError) as exc:
import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertIn("不属于当前盘库单", str(exc.exception))
def test_import_rejects_workbook_from_other_warehouse(self) -> None:
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库")
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("仓库") + 1).value = "成品库"
worksheet.cell(row=2, column=headers.index("仓库ID") + 1).value = 999
worksheet.cell(row=2, column=headers.index("盘库校验码") + 1).value = f"{stocktake.stocktake_no}|999|FINISHED"
output = BytesIO()
workbook.save(output)
with self.assertRaises(ValueError) as exc:
import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertIn("不属于当前仓库", str(exc.exception))
def test_reimport_overwrites_previous_stocktake_preview(self) -> None:
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="原材料盘库")
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 90
output = BytesIO()
workbook.save(output)
first_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(first_preview["summary"]["loss_count"], 1)
workbook = load_workbook(BytesIO(self._exported_workbook_bytes(stocktake)))
worksheet = workbook["原材料库"]
headers = [cell.value for cell in worksheet[1]]
worksheet.cell(row=2, column=headers.index("盘点重量(kg)") + 1).value = 100
output = BytesIO()
workbook.save(output)
second_preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(second_preview["summary"]["loss_count"], 0)
self.assertEqual(second_preview["summary"]["match_count"], 1)
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
self.assertEqual(line.diff_type, "MATCH")
self.assertEqual(float(line.counted_weight_kg), 100)
def test_import_counted_workbook_marks_loss_gain_and_missing_rows(self) -> None:
from app.services.stocktake import import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "原材料库"
worksheet.append([
"盘库单号",
"盘库校验码",
"仓库ID",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源库存批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
])
worksheet.append([
stocktake.stocktake_no,
f"{stocktake.stocktake_no}|{self.raw.id}|RAW",
self.raw.id,
line.id,
"原材料库",
"",
"原材料00001",
"冷轧钢板",
"LOT-RAW-001",
"",
0,
100,
2.5,
0,
90,
"实盘少10kg",
])
output = BytesIO()
workbook.save(output)
preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(preview["summary"]["loss_count"], 1)
loss_line = self.db.get(StocktakeLine, line.id)
self.assertEqual(loss_line.diff_type, "LOSS")
self.assertEqual(float(loss_line.diff_weight_kg), -10)
self.assertEqual(loss_line.row_status, "READY")
def test_confirm_stocktake_adjusts_lot_balance_and_writes_txn(self) -> None:
from openpyxl import Workbook
from app.models.operations import InventoryTxn, StocktakeAdjustment
from app.services.stocktake import confirm_stocktake, import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
line = self.db.scalar(select(StocktakeLine).where(StocktakeLine.stocktake_id == stocktake.id))
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "原材料库"
worksheet.append([
"盘库单号",
"盘库校验码",
"仓库ID",
"快照行ID",
"仓库",
"库位",
"物料编码",
"物料名称",
"批次号",
"来源库存批次号",
"系统数量",
"系统重量(kg)",
"系统单价",
"盘点数量",
"盘点重量(kg)",
"盘点备注",
])
worksheet.append([
stocktake.stocktake_no,
f"{stocktake.stocktake_no}|{self.raw.id}|RAW",
self.raw.id,
line.id,
"原材料库",
"",
"原材料00001",
"冷轧钢板",
"LOT-RAW-001",
"",
0,
100,
2.5,
0,
90,
"实盘少10kg",
])
output = BytesIO()
workbook.save(output)
import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="确认盘库", remark="确认调整")
self.assertEqual(confirmed.status, "CONFIRMED")
self.db.refresh(self.lot)
self.db.refresh(self.balance)
self.assertEqual(float(self.lot.remaining_weight_kg), 90)
self.assertEqual(float(self.balance.weight_on_hand_kg), 90)
txn = self.db.scalar(select(InventoryTxn).where(InventoryTxn.txn_type == "STOCKTAKE_LOSS"))
self.assertIsNotNone(txn)
adjustment = self.db.scalar(select(StocktakeAdjustment).where(StocktakeAdjustment.stocktake_id == stocktake.id))
self.assertIsNotNone(adjustment)
self.assertEqual(float(adjustment.weight_change_kg), -10)
def test_stocktake_status_sequence_supports_status_driven_ui(self) -> None:
from app.services.stocktake import confirm_stocktake, import_stocktake_workbook
stocktake = start_stocktake(self.db, warehouse_ids=[self.raw.id], user_id=1, remark="月底盘库")
self.assertEqual(stocktake.status, "LOCKED")
workbook = build_stocktake_workbook(self.db, stocktake.id)
self.assertEqual(self.db.get(type(stocktake), stocktake.id).status, "EXPORTED")
output = BytesIO()
workbook.save(output)
preview = import_stocktake_workbook(self.db, stocktake.id, output.getvalue(), user_id=1)
self.assertEqual(preview["stocktake"].status, "IMPORTED")
self.assertEqual(preview["summary"]["match_count"], 1)
confirmed = confirm_stocktake(self.db, stocktake.id, user_id=2, confirm_text="", remark="确认盘库")
self.assertEqual(confirmed.status, "CONFIRMED")
stocktake_warehouse = self.db.scalar(
select(StocktakeWarehouse).where(StocktakeWarehouse.stocktake_id == stocktake.id)
)
self.assertEqual(stocktake_warehouse.status, "UNLOCKED")
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime
from fastapi import HTTPException
from sqlalchemy import BigInteger, create_engine
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 Warehouse # noqa: E402
from app.models.operations import Stocktake, StocktakeWarehouse # noqa: E402
from app.services.stocktake import ensure_warehouses_unlocked, get_locked_warehouse_ids # noqa: E402
class StocktakeLockingTest(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.raw = Warehouse(
id=1,
warehouse_code="WH-RAW-01",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.finished = Warehouse(
id=2,
warehouse_code="WH-FG-01",
warehouse_name="成品库",
warehouse_type="FINISHED",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([self.raw, self.finished])
self.db.flush()
stocktake = Stocktake(
stocktake_no="PK-20260530-001",
status="LOCKED",
started_by_user_id=1,
started_at=now,
remark="测试锁库",
)
self.db.add(stocktake)
self.db.flush()
self.db.add(
StocktakeWarehouse(
stocktake_id=stocktake.id,
warehouse_id=self.raw.id,
warehouse_type="RAW",
warehouse_name="原材料库",
status="LOCKED",
)
)
self.db.commit()
def test_get_locked_warehouse_ids_returns_only_active_stocktakes(self) -> None:
locked = get_locked_warehouse_ids(self.db, [self.raw.id, self.finished.id])
self.assertEqual(locked, {self.raw.id})
def test_ensure_warehouses_unlocked_raises_for_locked_warehouse(self) -> None:
with self.assertRaises(HTTPException) as exc:
ensure_warehouses_unlocked(self.db, [self.raw.id], "入库")
self.assertEqual(exc.exception.status_code, 423)
self.assertIn("原材料库正在盘库", exc.exception.detail)
def test_ensure_warehouses_unlocked_allows_unlocked_warehouse(self) -> None:
ensure_warehouses_unlocked(self.db, [self.finished.id], "销售出库")
def test_inventory_inbound_is_blocked_when_warehouse_is_locked(self) -> None:
from decimal import Decimal
from app.api.routes.inventory import create_warehouse_inbound
from app.models.master_data import Item
from app.schemas.operations import WarehouseInboundCreate
now = datetime.now(UTC)
item = Item(
id=1,
item_code="原材料00001",
item_name="冷轧钢板",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
safety_stock_weight_kg=Decimal("0"),
scrap_sale_price=Decimal("0"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(item)
self.db.commit()
class User:
id = 1
class Context:
user = User()
with self.assertRaises(HTTPException) as exc:
create_warehouse_inbound(
WarehouseInboundCreate(
biz_type="OPENING",
item_id=item.id,
warehouse_id=self.raw.id,
inbound_weight_kg=10,
unit_cost=1,
),
context=Context(),
db=self.db,
)
self.assertEqual(exc.exception.status_code, 423)
self.assertIn("正在盘库", exc.exception.detail)
def test_lock_guard_message_names_action(self) -> None:
with self.assertRaises(HTTPException) as exc:
ensure_warehouses_unlocked(self.db, [self.raw.id], "到货入库")
self.assertEqual(exc.exception.status_code, 423)
self.assertIn("到货入库", exc.exception.detail)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,96 @@
from __future__ import annotations
import unittest
from datetime import datetime
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.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.org import SystemConfig # noqa: E402
from app.services.system_config import ( # noqa: E402
RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
get_raw_material_lot_prefix,
normalize_raw_material_lot_prefix,
upsert_system_config,
)
class SystemConfigRawLotPrefixTest(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 test_raw_material_lot_prefix_defaults_and_normalizes(self) -> None:
self.assertEqual(get_raw_material_lot_prefix(self.db), "YL")
self.assertEqual(normalize_raw_material_lot_prefix(" yl-01 "), "YL01")
self.assertEqual(normalize_raw_material_lot_prefix(""), "YL")
self.assertEqual(normalize_raw_material_lot_prefix("原材料"), "YL")
def test_upsert_raw_material_lot_prefix_normalizes_and_reads_active_config(self) -> None:
fixed_now = datetime(2026, 6, 7, 8, 30)
row = upsert_system_config(
self.db,
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
config_name="原材料库存批次号前缀",
config_value="rm",
remark="测试配置",
updated_by=1,
now=fixed_now,
)
self.db.commit()
persisted = self.db.scalar(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE))
self.assertIsNotNone(persisted)
self.assertEqual(row.config_value, "RM")
self.assertEqual(persisted.config_value, "RM")
self.assertEqual(persisted.config_name, "原材料库存批次号前缀")
self.assertEqual(persisted.remark, "测试配置")
self.assertEqual(persisted.updated_by, 1)
self.assertEqual(persisted.updated_at, fixed_now)
self.assertEqual(get_raw_material_lot_prefix(self.db), "RM")
updated_now = datetime(2026, 6, 7, 9, 45)
updated_row = upsert_system_config(
self.db,
config_code=RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE,
config_name="原材料库存批次号前缀",
config_value=" yl-02 ",
remark="更新配置",
updated_by=2,
now=updated_now,
)
self.db.commit()
rows = self.db.scalars(select(SystemConfig).where(SystemConfig.config_code == RAW_MATERIAL_LOT_PREFIX_CONFIG_CODE)).all()
self.assertEqual(len(rows), 1)
self.assertEqual(updated_row.id, persisted.id)
self.assertEqual(rows[0].config_value, "YL02")
self.assertEqual(rows[0].remark, "更新配置")
self.assertEqual(rows[0].updated_by, 2)
self.assertEqual(rows[0].updated_at, updated_now)
self.assertEqual(get_raw_material_lot_prefix(self.db), "YL02")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,736 @@
from __future__ import annotations
import unittest
from datetime import datetime
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.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.org import Department, Employee, Permission, Role, RolePermission, User, UserRole # noqa: E402
class SystemPermissionManagementTest(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()
now = datetime(2026, 6, 8, 9, 0, 0)
self.company = Department(
dept_code="ORG_ROOT",
dept_name="总公司",
parent_id=None,
org_node_type="COMPANY",
dept_type="ADMIN",
manager_name="王总",
manager_employee_id=None,
status="ACTIVE",
sort_no=1,
remark="总公司节点",
created_at=now,
updated_at=now,
)
self.db.add(self.company)
self.db.flush()
def tearDown(self) -> None:
self.db.close()
def test_department_model_exposes_tree_and_manager_fields(self) -> None:
row = self.db.scalar(select(Department).where(Department.dept_code == "ORG_ROOT"))
self.assertIsNotNone(row)
self.assertEqual(row.parent_id, None)
self.assertEqual(row.org_node_type, "COMPANY")
self.assertEqual(row.manager_name, "王总")
self.assertEqual(row.manager_employee_id, None)
self.assertEqual(row.remark, "总公司节点")
def _seed_role_permission_user_data(self) -> None:
now = datetime(2026, 6, 8, 9, 0, 0)
purchase = Department(
dept_code="PURCHASE",
dept_name="采购部",
parent_id=self.company.id,
org_node_type="DEPARTMENT",
dept_type="PURCHASE",
manager_name="采购主管",
manager_employee_id=None,
status="ACTIVE",
sort_no=10,
remark=None,
created_at=now,
updated_at=now,
)
self.db.add(purchase)
self.db.flush()
manager = Employee(
employee_code="EMP-001",
employee_name="张三",
dept_id=purchase.id,
mobile="13800000011",
gender=None,
hire_date=None,
job_title="采购负责人",
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark="来自人员主数据",
created_at=now,
updated_at=now,
)
buyer = Employee(
employee_code="EMP-002",
employee_name="李四",
dept_id=purchase.id,
mobile="13800000012",
gender=None,
hire_date=None,
job_title="采购专员",
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
role = Role(
role_code="PURCHASER",
role_name="采购专员",
role_scope="SYSTEM",
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
permission = Permission(
permission_code="MENU_PURCHASE_ORDER",
permission_name="采购订单菜单",
module_code="PURCHASE_ORDER",
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add_all([manager, buyer, role, permission])
self.db.flush()
purchase.manager_employee_id = manager.id
purchase.manager_name = manager.employee_name
self.db.add(purchase)
self.db.commit()
self.purchase_dept_id = purchase.id
self.manager_employee_id = manager.id
self.buyer_employee_id = buyer.id
self.purchaser_role_id = role.id
self.purchase_permission_id = permission.id
def test_build_org_tree_returns_managed_root_and_ignores_legacy_root_departments(self) -> None:
from app.services.system_permissions import build_org_tree
self._seed_role_permission_user_data()
now = datetime(2026, 6, 8, 9, 0, 0)
legacy = Department(
dept_code="LEGACY-SALES",
dept_name="旧销售部",
parent_id=None,
org_node_type="DEPARTMENT",
dept_type="SALES",
manager_name="旧主管",
manager_employee_id=None,
status="ACTIVE",
sort_no=99,
remark="旧部门不直接作为脑图根节点展示",
created_at=now,
updated_at=now,
)
self.db.add(legacy)
self.db.commit()
tree = build_org_tree(self.db)
self.assertEqual(len(tree), 1)
self.assertEqual(tree[0].node_label, "总公司")
self.assertEqual(tree[0].node_type, "COMPANY")
purchase = tree[0].children[0]
self.assertEqual(purchase.node_label, "采购部")
self.assertEqual(purchase.manager_employee_id, self.manager_employee_id)
self.assertEqual(purchase.manager_name, "张三")
self.assertEqual(purchase.employees, [])
def test_org_employee_leaves_are_explicit_bindings_not_department_membership(self) -> None:
from app.schemas.system_permissions import OrgEmployeeBind
from app.services.system_permissions import bind_org_employee, build_org_tree, unbind_org_employee
self._seed_role_permission_user_data()
tree_before = build_org_tree(self.db)
purchase_before = tree_before[0].children[0]
self.assertEqual(purchase_before.employees, [])
bind_org_employee(
self.db,
self.purchase_dept_id,
OrgEmployeeBind(employee_id=self.buyer_employee_id, sort_no=1),
)
tree_after = build_org_tree(self.db)
purchase_after = tree_after[0].children[0]
self.assertEqual([employee.employee_name for employee in purchase_after.employees], ["李四"])
self.assertEqual(purchase_after.employees[0].mobile, "13800000012")
unbind_org_employee(self.db, self.purchase_dept_id, self.buyer_employee_id)
tree_unbound = build_org_tree(self.db)
self.assertEqual(tree_unbound[0].children[0].employees, [])
def test_update_org_node_supports_multiple_managers(self) -> None:
from app.schemas.system_permissions import OrgNodeUpdate
from app.services.system_permissions import build_org_tree, update_org_node
self._seed_role_permission_user_data()
update_org_node(
self.db,
self.purchase_dept_id,
OrgNodeUpdate(
node_label="采购部",
dept_type="PURCHASE",
manager_employee_ids=[self.manager_employee_id, self.buyer_employee_id],
sort_no=10,
remark=None,
status="ACTIVE",
),
)
tree = build_org_tree(self.db)
purchase = tree[0].children[0]
self.assertEqual(purchase.manager_employee_ids, [self.manager_employee_id, self.buyer_employee_id])
self.assertEqual(purchase.manager_names, ["张三", "李四"])
self.assertEqual(purchase.manager_employee_id, self.manager_employee_id)
self.assertEqual(purchase.manager_name, "张三、李四")
def test_build_org_tree_falls_back_to_legacy_single_manager(self) -> None:
from app.services.system_permissions import build_org_tree
self._seed_role_permission_user_data()
tree = build_org_tree(self.db)
purchase = tree[0].children[0]
self.assertEqual(purchase.manager_employee_ids, [self.manager_employee_id])
self.assertEqual(purchase.manager_names, ["张三"])
def test_create_system_user_uses_employee_phone_as_account_and_hashes_password(self) -> None:
from app.schemas.system_permissions import SystemUserCreate
from app.services.auth import verify_password
from app.services.system_permissions import create_system_user
self._seed_role_permission_user_data()
result = create_system_user(
self.db,
SystemUserCreate(
employee_id=self.buyer_employee_id,
role_ids=[self.purchaser_role_id],
password="secret123",
status="ACTIVE",
),
)
self.assertEqual(result.username, "13800000012")
self.assertEqual(result.employee_name, "李四")
self.assertEqual(result.role_names, ["采购专员"])
user = self.db.scalar(select(User).where(User.username == "13800000012"))
self.assertIsNotNone(user)
self.assertTrue(verify_password("secret123", user.password_hash))
links = self.db.scalars(select(UserRole).where(UserRole.user_id == user.id)).all()
self.assertEqual(len(links), 1)
self.assertEqual(links[0].role_id, self.purchaser_role_id)
def test_create_system_user_rejects_employee_without_phone(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import SystemUserCreate
from app.services.system_permissions import create_system_user
self._seed_role_permission_user_data()
now = datetime(2026, 6, 8, 9, 0, 0)
employee = Employee(
employee_code="EMP-NOPHONE",
employee_name="无手机号人员",
dept_id=self.purchase_dept_id,
mobile=None,
gender=None,
hire_date=None,
job_title=None,
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
self.db.add(employee)
self.db.commit()
with self.assertRaises(HTTPException) as ctx:
create_system_user(
self.db,
SystemUserCreate(
employee_id=employee.id,
role_ids=[self.purchaser_role_id],
password="secret123",
status="ACTIVE",
),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("人员没有电话号码", str(ctx.exception.detail))
def test_create_role_invalid_permission_does_not_leave_pending_role(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import RoleCreate
from app.services.system_permissions import create_role
with self.assertRaises(HTTPException):
create_role(
self.db,
RoleCreate(
role_code="INVALID_PERMISSION_ROLE",
role_name="无效权限角色",
role_scope="SYSTEM",
status="ACTIVE",
remark=None,
permission_codes=["MISSING_PERMISSION"],
),
)
self.db.commit()
row = self.db.scalar(select(Role).where(Role.role_code == "INVALID_PERMISSION_ROLE"))
self.assertIsNone(row)
def test_update_role_invalid_permission_does_not_persist_name_change(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import RoleUpdate
from app.services.system_permissions import update_role
self._seed_role_permission_user_data()
with self.assertRaises(HTTPException):
update_role(
self.db,
self.purchaser_role_id,
RoleUpdate(
role_name="错误改名",
role_scope="SYSTEM",
status="ACTIVE",
remark="不应持久化",
permission_codes=["MISSING_PERMISSION"],
),
)
self.db.commit()
row = self.db.get(Role, self.purchaser_role_id)
self.assertIsNotNone(row)
self.assertEqual(row.role_name, "采购专员")
self.assertIsNone(row.remark)
def test_create_system_user_invalid_role_does_not_leave_pending_user(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import SystemUserCreate
from app.services.system_permissions import create_system_user
self._seed_role_permission_user_data()
with self.assertRaises(HTTPException):
create_system_user(
self.db,
SystemUserCreate(
employee_id=self.buyer_employee_id,
role_ids=[999999],
password="secret123",
status="ACTIVE",
),
)
self.db.commit()
employee = self.db.get(Employee, self.buyer_employee_id)
self.assertIsNotNone(employee)
row = self.db.scalar(
select(User).where(
User.employee_id == self.buyer_employee_id,
User.username == employee.mobile,
)
)
self.assertIsNone(row)
def test_create_org_node_adds_child_under_parent(self) -> None:
from app.schemas.system_permissions import OrgNodeCreate
from app.services.system_permissions import create_org_node, build_org_tree
payload = OrgNodeCreate(
parent_id=self.company.id,
node_type="BRANCH",
node_label="分公司A",
dept_code="BR-A",
dept_type="ADMIN",
manager_employee_id=None,
sort_no=1,
remark="分公司节点",
status="ACTIVE",
)
create_org_node(self.db, payload)
tree = build_org_tree(self.db)
self.assertEqual(tree[0].children[0].node_label, "分公司A")
self.assertEqual(tree[0].children[0].parent_id, self.company.id)
self.assertEqual(tree[0].children[0].node_type, "BRANCH")
def test_create_org_node_rejects_invalid_child_level(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import OrgNodeCreate
from app.services.system_permissions import create_org_node
with self.assertRaises(HTTPException) as ctx:
create_org_node(
self.db,
OrgNodeCreate(
parent_id=self.company.id,
node_type="DEPARTMENT",
node_label="错误部门",
dept_code="BAD-DEPT",
dept_type="ADMIN",
manager_employee_id=None,
sort_no=1,
remark=None,
status="ACTIVE",
),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("总公司下只能新增分公司", str(ctx.exception.detail))
def test_delete_org_node_rejects_nodes_with_children_or_bound_employees(self) -> None:
from fastapi import HTTPException
from app.schemas.system_permissions import OrgEmployeeBind, OrgNodeCreate
from app.services.system_permissions import bind_org_employee, create_org_node, delete_org_node
self._seed_role_permission_user_data()
branch = create_org_node(
self.db,
OrgNodeCreate(
parent_id=self.company.id,
node_type="BRANCH",
node_label="分公司A",
dept_code="BR-A",
dept_type="ADMIN",
manager_employee_id=None,
sort_no=1,
remark=None,
status="ACTIVE",
),
)
with self.assertRaises(HTTPException) as child_ctx:
delete_org_node(self.db, self.company.id)
self.assertEqual(child_ctx.exception.status_code, 400)
self.assertIn("存在下级节点", str(child_ctx.exception.detail))
department = create_org_node(
self.db,
OrgNodeCreate(
parent_id=branch.node_id,
node_type="DEPARTMENT",
node_label="业务部门",
dept_code="DEPT-A",
dept_type="ADMIN",
manager_employee_id=None,
sort_no=1,
remark=None,
status="ACTIVE",
),
)
bind_org_employee(
self.db,
department.node_id,
OrgEmployeeBind(employee_id=self.buyer_employee_id, sort_no=1),
)
with self.assertRaises(HTTPException) as employee_ctx:
delete_org_node(self.db, department.node_id)
self.assertEqual(employee_ctx.exception.status_code, 400)
self.assertIn("存在人员节点", str(employee_ctx.exception.detail))
def test_admin_context_gets_system_permission_code(self) -> None:
from app.services.auth import get_auth_context, hash_password
self._seed_role_permission_user_data()
now = datetime(2026, 6, 8, 9, 0, 0)
admin_role = Role(
role_code="ADMIN",
role_name="超级管理员",
role_scope="SYSTEM",
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
admin_permission = Permission(
permission_code="MENU_SYSTEM_PERMISSION",
permission_name="系统权限管理菜单",
module_code="SYSTEM_PERMISSION",
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
admin_employee = Employee(
employee_code="EMP-ADMIN",
employee_name="系统管理员",
dept_id=self.purchase_dept_id,
mobile="13800000099",
gender=None,
hire_date=None,
job_title=None,
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
self.db.add_all([admin_role, admin_permission, admin_employee])
self.db.flush()
admin_user = User(
username="admin",
password_hash=hash_password("secret123"),
employee_id=admin_employee.id,
dept_id=self.purchase_dept_id,
nickname="系统管理员",
email=None,
is_super_admin=1,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
)
self.db.add(admin_user)
self.db.flush()
self.db.add(UserRole(user_id=admin_user.id, role_id=admin_role.id, created_at=now))
self.db.commit()
context = get_auth_context(self.db, "admin")
self.assertIn("MENU_SYSTEM_PERMISSION", context.permission_codes)
def _seed_permission_employee_selector_data(self) -> dict[str, int]:
from app.services.auth import hash_password
self._seed_role_permission_user_data()
now = datetime(2026, 6, 8, 9, 0, 0)
sales_permission = Permission(
permission_code="MENU_SALES_PLANNING",
permission_name="客户名录/销售订单菜单",
module_code="SALES_PLANNING",
action_code="VIEW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
sales_role = Role(
role_code="SALES",
role_name="销售人员",
role_scope="SYSTEM",
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
sales_employee = Employee(
employee_code="EMP-SALES",
employee_name="赵销售",
dept_id=self.purchase_dept_id,
mobile="13800000021",
gender=None,
hire_date=None,
job_title="销售人员",
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="ACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
inactive_employee = Employee(
employee_code="EMP-INACTIVE",
employee_name="停用员工",
dept_id=self.purchase_dept_id,
mobile="13800000022",
gender=None,
hire_date=None,
job_title="停用",
shift_code=None,
manager_employee_id=None,
is_operator=0,
is_workshop_staff=0,
status="INACTIVE",
remark=None,
created_at=now,
updated_at=now,
)
self.db.add_all([sales_permission, sales_role, sales_employee, inactive_employee])
self.db.flush()
self.db.add_all(
[
RolePermission(role_id=self.purchaser_role_id, permission_id=self.purchase_permission_id, created_at=now),
RolePermission(role_id=sales_role.id, permission_id=sales_permission.id, created_at=now),
User(
username="13800000012",
password_hash=hash_password("secret123"),
employee_id=self.buyer_employee_id,
dept_id=self.purchase_dept_id,
nickname="李四",
email=None,
is_super_admin=0,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
),
User(
username="13800000021",
password_hash=hash_password("secret123"),
employee_id=sales_employee.id,
dept_id=self.purchase_dept_id,
nickname="赵销售",
email=None,
is_super_admin=0,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
),
User(
username="13800000022",
password_hash=hash_password("secret123"),
employee_id=inactive_employee.id,
dept_id=self.purchase_dept_id,
nickname="停用员工",
email=None,
is_super_admin=0,
last_login_at=None,
status="ACTIVE",
created_at=now,
updated_at=now,
),
]
)
self.db.flush()
buyer_user = self.db.scalar(select(User).where(User.employee_id == self.buyer_employee_id))
sales_user = self.db.scalar(select(User).where(User.employee_id == sales_employee.id))
inactive_user = self.db.scalar(select(User).where(User.employee_id == inactive_employee.id))
self.db.add_all(
[
UserRole(user_id=buyer_user.id, role_id=self.purchaser_role_id, created_at=now),
UserRole(user_id=sales_user.id, role_id=sales_role.id, created_at=now),
UserRole(user_id=inactive_user.id, role_id=sales_role.id, created_at=now),
]
)
self.db.commit()
return {
"sales_employee_id": sales_employee.id,
"inactive_employee_id": inactive_employee.id,
}
def test_list_employee_options_filters_by_permission_code(self) -> None:
from app.services.system_permissions import list_employee_options
ids = self._seed_permission_employee_selector_data()
purchase_options = list_employee_options(self.db, permission_code="MENU_PURCHASE_ORDER")
sales_options = list_employee_options(self.db, permission_code="MENU_SALES_PLANNING")
full_options = list_employee_options(self.db)
self.assertEqual([option.employee_name for option in purchase_options], ["李四"])
self.assertEqual([option.employee_name for option in sales_options], ["赵销售"])
self.assertIn("销售人员", sales_options[0].role_names)
self.assertEqual(sales_options[0].username, "13800000021")
self.assertNotIn(ids["inactive_employee_id"], [option.employee_id for option in full_options])
def test_ensure_employee_has_permission_rejects_unqualified_employee(self) -> None:
from fastapi import HTTPException
from app.services.system_permissions import ensure_employee_has_permission
ids = self._seed_permission_employee_selector_data()
ensure_employee_has_permission(
self.db,
ids["sales_employee_id"],
"MENU_SALES_PLANNING",
"销售人员",
)
with self.assertRaises(HTTPException) as ctx:
ensure_employee_has_permission(
self.db,
self.buyer_employee_id,
"MENU_SALES_PLANNING",
"销售人员",
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("销售人员必须选择有“客户名录/销售订单菜单”权限的人员", str(ctx.exception.detail))
def test_optional_personnel_validator_allows_empty_value(self) -> None:
from app.services.system_permissions import ensure_employee_has_permission
self._seed_permission_employee_selector_data()
ensure_employee_has_permission(
self.db,
None,
"MENU_PURCHASE_ORDER",
"采购员",
required=False,
)
def test_optional_personnel_validator_rejects_zero_value(self) -> None:
from fastapi import HTTPException
from app.services.system_permissions import ensure_employee_has_permission
self._seed_permission_employee_selector_data()
with self.assertRaises(HTTPException) as ctx:
ensure_employee_has_permission(
self.db,
0,
"MENU_PURCHASE_ORDER",
"采购员",
required=False,
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("采购员不存在或已停用", str(ctx.exception.detail))
def test_login_session_exposes_employee_id_for_default_selectors(self) -> None:
from app.services.auth import get_auth_context, hash_password
self._seed_permission_employee_selector_data()
context = get_auth_context(self.db, "13800000012")
self.assertEqual(context.employee.id, self.buyer_employee_id)
self.assertEqual(context.employee.mobile, "13800000012")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,305 @@
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()
@@ -0,0 +1,311 @@
from __future__ import annotations
import unittest
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from sqlalchemy import BigInteger, create_engine
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.models.base import Base # noqa: E402
class WarehouseTransactionLedgerTest(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_ledger_data(self) -> None:
from app.models.master_data import Item, Warehouse
from app.models.operations import InventoryTxn, StockLot, WarehouseLocation
from app.models.org import Department, Employee, User
now = datetime(2026, 6, 1, 9, 0, tzinfo=UTC)
department = Department(
id=1,
dept_code="D001",
dept_name="仓储部",
dept_type="WAREHOUSE",
status="ACTIVE",
created_at=now,
updated_at=now,
)
employee = Employee(
id=1,
employee_code="EMP001",
employee_name="张仓管",
dept_id=1,
status="ACTIVE",
created_at=now,
updated_at=now,
)
user = User(
id=1,
username="keeper",
password_hash="x",
employee_id=1,
dept_id=1,
nickname="张仓管",
status="ACTIVE",
created_at=now,
updated_at=now,
)
raw_wh = Warehouse(
id=1,
warehouse_code="WH-RAW-01",
warehouse_name="原材料库",
warehouse_type="RAW",
status="ACTIVE",
created_at=now,
updated_at=now,
)
finished_wh = Warehouse(
id=2,
warehouse_code="WH-FG-01",
warehouse_name="成品库",
warehouse_type="FINISHED",
status="ACTIVE",
created_at=now,
updated_at=now,
)
raw_location = WarehouseLocation(
id=1,
warehouse_id=1,
location_code="RAW-A",
location_name="原料主库位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
finished_location = WarehouseLocation(
id=2,
warehouse_id=2,
location_code="FG-A",
location_name="成品主库位",
is_default=1,
is_locked=0,
status="ACTIVE",
created_at=now,
updated_at=now,
)
raw_item = Item(
id=1,
item_code="原材料00001",
item_name="304不锈钢",
item_type="RAW_MATERIAL",
unit_weight_kg=Decimal("1"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
finished_item = Item(
id=2,
item_code="产品需规00001",
item_name="冲压支架",
item_type="FINISHED_GOOD",
unit_weight_kg=Decimal("2.5"),
status="ACTIVE",
created_at=now,
updated_at=now,
)
raw_lot = StockLot(
id=1,
lot_no="JH_原材料00001_0001",
lot_role="INBOUND_RAW",
item_id=1,
warehouse_id=1,
location_id=1,
source_doc_type="OPENING_STOCK",
source_doc_id=1,
inbound_qty=0,
inbound_weight_kg=1000,
remaining_qty=0,
remaining_weight_kg=850,
locked_qty=0,
locked_weight_kg=0,
unit_cost=Decimal("10"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
finished_lot = StockLot(
id=2,
lot_no="FG-20260601-001",
lot_role="INBOUND_FINISHED",
item_id=2,
warehouse_id=2,
location_id=2,
source_doc_type="FG_RECEIPT",
source_doc_id=9,
inbound_qty=100,
inbound_weight_kg=250,
remaining_qty=80,
remaining_weight_kg=200,
locked_qty=0,
locked_weight_kg=0,
unit_cost=Decimal("18"),
quality_status="PASS",
status="AVAILABLE",
created_at=now,
updated_at=now,
)
transactions = [
InventoryTxn(
id=1,
txn_no="TXN-RAW-IN",
txn_type="OPENING_IN",
item_id=1,
warehouse_id=1,
location_id=1,
lot_id=1,
qty_change=0,
weight_change_kg=1000,
unit_cost=10,
amount=10000,
source_doc_type="OPENING_STOCK",
source_doc_id=1,
biz_time=now,
operator_user_id=1,
remark="期初入库 304",
created_at=now,
updated_at=now,
),
InventoryTxn(
id=2,
txn_no="TXN-RAW-OUT",
txn_type="MATERIAL_ISSUE",
item_id=1,
warehouse_id=1,
location_id=1,
lot_id=1,
qty_change=0,
weight_change_kg=-150,
unit_cost=10,
amount=1500,
source_doc_type="WORK_ORDER",
source_doc_id=2,
biz_time=now + timedelta(hours=1),
operator_user_id=1,
remark="生产出库",
created_at=now,
updated_at=now,
),
InventoryTxn(
id=3,
txn_no="TXN-FG-IN",
txn_type="FG_IN",
item_id=2,
warehouse_id=2,
location_id=2,
lot_id=2,
qty_change=100,
weight_change_kg=250,
unit_cost=18,
amount=1800,
source_doc_type="FG_RECEIPT",
source_doc_id=9,
biz_time=now + timedelta(hours=2),
operator_user_id=1,
remark="成品入库",
created_at=now,
updated_at=now,
),
]
self.db.add_all(
[
department,
employee,
user,
raw_wh,
finished_wh,
raw_location,
finished_location,
raw_item,
finished_item,
raw_lot,
finished_lot,
*transactions,
]
)
self.db.commit()
def test_transaction_ledger_filters_by_warehouse_type_and_returns_summary(self) -> None:
from app.api.routes.inventory import list_inventory_transaction_ledger
self.seed_ledger_data()
result = list_inventory_transaction_ledger(
warehouse_type="RAW",
page=1,
page_size=10,
db=self.db,
)
self.assertEqual(result.total, 2)
self.assertEqual([row.warehouse_type for row in result.items], ["RAW", "RAW"])
self.assertEqual(result.summary.in_count, 1)
self.assertEqual(result.summary.out_count, 1)
self.assertEqual(Decimal(str(result.summary.in_weight_kg)), Decimal("1000.0"))
self.assertEqual(Decimal(str(result.summary.out_weight_kg)), Decimal("150.0"))
self.assertEqual(result.items[0].txn_type, "MATERIAL_ISSUE")
self.assertEqual(result.items[0].direction, "OUT")
def test_transaction_ledger_supports_keyword_direction_pagination_and_raw_qty_zero(self) -> None:
from app.api.routes.inventory import list_inventory_transaction_ledger
self.seed_ledger_data()
result = list_inventory_transaction_ledger(
warehouse_type="RAW",
keyword="304",
direction="OUT",
page=1,
page_size=1,
db=self.db,
)
self.assertEqual(result.total, 1)
self.assertEqual(len(result.items), 1)
row = result.items[0]
self.assertEqual(row.txn_no, "TXN-RAW-OUT")
self.assertEqual(row.item_code, "原材料00001")
self.assertEqual(row.lot_no, "JH_原材料00001_0001")
self.assertEqual(row.qty_change, 0)
self.assertEqual(row.operator_name, "张仓管")
self.assertEqual(result.summary.out_count, 1)
def test_transaction_ledger_rejects_unknown_warehouse_type(self) -> None:
from app.api.routes.inventory import list_inventory_transaction_ledger
from fastapi import HTTPException
self.seed_ledger_data()
with self.assertRaises(HTTPException) as exc:
list_inventory_transaction_ledger(
warehouse_type="UNKNOWN",
page=1,
page_size=10,
db=self.db,
)
self.assertEqual(exc.exception.status_code, 400)
self.assertIn("未知仓库类型", exc.exception.detail)