Initial commit
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user