758 lines
30 KiB
Python
758 lines
30 KiB
Python
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()
|