1
0
forked from erp-dev/erp

feat: red flush

This commit is contained in:
2026-06-12 13:57:21 +08:00
parent 80db7867f4
commit 0bcaa2db44
33 changed files with 2997 additions and 17 deletions

View File

@@ -0,0 +1,106 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('business', '0030_external_statement_add_sf_zk_amount'),
]
operations = [
migrations.AddField(
model_name='purchaseorder',
name='is_red_flushed',
field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'),
),
migrations.AddField(
model_name='purchaseorder',
name='red_flush_id',
field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'),
),
migrations.AddField(
model_name='purchaseorder',
name='red_flushed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'),
),
migrations.AddField(
model_name='salesorder',
name='is_red_flushed',
field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'),
),
migrations.AddField(
model_name='salesorder',
name='red_flush_id',
field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'),
),
migrations.AddField(
model_name='salesorder',
name='red_flushed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'),
),
migrations.AddField(
model_name='purchasereturnorder',
name='is_red_flushed',
field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'),
),
migrations.AddField(
model_name='purchasereturnorder',
name='red_flush_id',
field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'),
),
migrations.AddField(
model_name='purchasereturnorder',
name='red_flushed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'),
),
migrations.AddField(
model_name='salesreturnorder',
name='is_red_flushed',
field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'),
),
migrations.AddField(
model_name='salesreturnorder',
name='red_flush_id',
field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'),
),
migrations.AddField(
model_name='salesreturnorder',
name='red_flushed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'),
),
migrations.AddField(
model_name='paymentorder',
name='is_red_flushed',
field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'),
),
migrations.AddField(
model_name='paymentorder',
name='red_flush_id',
field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'),
),
migrations.AddField(
model_name='paymentorder',
name='red_flushed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'),
),
migrations.AddField(
model_name='receiptorder',
name='is_red_flushed',
field=models.BooleanField(db_index=True, default=False, verbose_name='已红冲'),
),
migrations.AddField(
model_name='receiptorder',
name='red_flush_id',
field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'),
),
migrations.AddField(
model_name='receiptorder',
name='red_flushed_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='红冲时间'),
),
migrations.AddField(
model_name='balancechangerecord',
name='red_flush_id',
field=models.UUIDField(blank=True, db_index=True, null=True, verbose_name='红冲批次ID'),
),
]

View File

@@ -130,6 +130,9 @@ class PurchaseOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounte
default=PurchaseOrderStatusEnum.PENDING,
verbose_name='状态',
)
is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间')
from_pre_purchase_order_id = models.BigIntegerField(
null=True,
blank=True,
@@ -277,6 +280,9 @@ class SalesOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCounterpa
default=SalesOrderStatusEnum.PENDING,
verbose_name='状态',
)
is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
from_pre_sales_order_id = models.BigIntegerField(
blank=True,
@@ -786,6 +792,9 @@ class PurchaseReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, Order
default=PurchaseReturnStatusEnum.PENDING,
verbose_name='状态',
)
is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
class Meta:
@@ -911,6 +920,9 @@ class SalesReturnOrder(OrderItemsAggregationMixin, OrderDirectionMixin, OrderCou
default=SalesReturnStatusEnum.PENDING,
verbose_name='状态',
)
is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间')
remarks = models.TextField(blank=True, null=True, verbose_name='备注')
class Meta:
@@ -1098,6 +1110,9 @@ class PaymentOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
default=PaymentOrderStatusEnum.PENDING,
verbose_name='状态',
)
is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间')
is_external_source = models.BooleanField(
default=False,
verbose_name='是否外部来源',
@@ -1181,6 +1196,9 @@ class ReceiptOrder(OrderDirectionMixin, OrderCounterpartyMixin, ModelBase):
default=ReceiptOrderStatusEnum.PENDING,
verbose_name='状态',
)
is_red_flushed = models.BooleanField(default=False, db_index=True, verbose_name='已红冲')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
red_flushed_at = models.DateTimeField(null=True, blank=True, verbose_name='红冲时间')
is_external_source = models.BooleanField(
default=False,
verbose_name='是否外部来源',
@@ -1324,6 +1342,7 @@ class BalanceChangeRecord(ModelBase):
offset_id = models.BigIntegerField(null=True, blank=True, verbose_name='冲抵来源ID')
cancelled = models.BooleanField(default=False, verbose_name='已被冲抵')
cancelled_at = models.DateTimeField(null=True, blank=True, verbose_name='被冲抵时间')
red_flush_id = models.UUIDField(null=True, blank=True, db_index=True, verbose_name='红冲批次ID')
class Meta:
verbose_name = '余额变动记录'

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import uuid
from collections import OrderedDict
from datetime import date, datetime
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
@@ -34,8 +35,9 @@ class BalanceService:
request_id: str | None = None,
remarks: str | None = '',
extra_meta: Dict[str, Any] | None = None,
):
BalanceService._adjust_balance(
red_flush_id=None,
) -> models.BalanceChangeRecord:
return BalanceService._adjust_balance(
merchant=merchant,
counterparty=supplier,
balance_model=models.SupplierBalance,
@@ -47,6 +49,7 @@ class BalanceService:
request_id=request_id,
remarks=remarks,
extra_meta=extra_meta,
red_flush_id=red_flush_id,
)
@staticmethod
@@ -60,8 +63,9 @@ class BalanceService:
request_id: str | None = None,
remarks: str | None = '',
extra_meta: Dict[str, Any] | None = None,
):
BalanceService._adjust_balance(
red_flush_id=None,
) -> models.BalanceChangeRecord:
return BalanceService._adjust_balance(
merchant=merchant,
counterparty=customer,
balance_model=models.CustomerBalance,
@@ -73,6 +77,7 @@ class BalanceService:
request_id=request_id,
remarks=remarks,
extra_meta=extra_meta,
red_flush_id=red_flush_id,
)
@staticmethod
@@ -89,7 +94,8 @@ class BalanceService:
request_id: str | None,
remarks: str | None,
extra_meta: Dict[str, Any] | None,
):
red_flush_id,
) -> models.BalanceChangeRecord:
meta_payload = extra_meta or {}
remarks_value = remarks or ''
with transaction.atomic():
@@ -118,13 +124,14 @@ class BalanceService:
'request_id': request_id,
'remarks': remarks_value,
'extra_meta': meta_payload,
'red_flush_id': red_flush_id,
}
if target_type == models.BalanceChangeTargetEnum.SUPPLIER:
record_kwargs['supplier'] = counterparty
else:
record_kwargs['customer'] = counterparty
models.BalanceChangeRecord.objects.create(**record_kwargs)
return models.BalanceChangeRecord.objects.create(**record_kwargs)
@staticmethod
def get_customer_balance(
@@ -1118,6 +1125,317 @@ def review_receipt_order(
)
def red_flush_purchase_order(
*,
merchant: basic_info_models.Merchant,
purchase_order: models.PurchaseOrder | None = None,
purchase_order_id: int | None = None,
red_flushed_by=None,
reason: str | None = '',
) -> models.PurchaseOrder:
order = _resolve_purchase_order_instance(purchase_order, purchase_order_id)
_ensure_order_merchant(order, merchant, '采购单')
return _red_flush_order_impl(
order=order,
order_model_cls=models.PurchaseOrder,
approved_status=models.PurchaseOrderStatusEnum.APPROVED,
balance_source_type=models.BalanceChangeSourceEnum.PURCHASE_ORDER,
stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE,
counterparty_field='supplier',
error_label='采购单',
red_flushed_by=red_flushed_by,
reason=reason,
)
def red_flush_sales_order(
*,
merchant: basic_info_models.Merchant,
sales_order: models.SalesOrder | None = None,
sales_order_id: int | None = None,
red_flushed_by=None,
reason: str | None = '',
) -> models.SalesOrder:
order = _resolve_sales_order_instance(sales_order, sales_order_id)
_ensure_order_merchant(order, merchant, '销售单')
return _red_flush_order_impl(
order=order,
order_model_cls=models.SalesOrder,
approved_status=models.SalesOrderStatusEnum.APPROVED,
balance_source_type=models.BalanceChangeSourceEnum.SALES_ORDER,
stock_source_type=stock_models.StockChangeSourceEnum.SALES,
counterparty_field='customer',
error_label='销售单',
red_flushed_by=red_flushed_by,
reason=reason,
)
def red_flush_purchase_return_order(
*,
merchant: basic_info_models.Merchant,
purchase_return_order: models.PurchaseReturnOrder | None = None,
purchase_return_order_id: int | None = None,
red_flushed_by=None,
reason: str | None = '',
) -> models.PurchaseReturnOrder:
order = _resolve_purchase_return_order_instance(purchase_return_order, purchase_return_order_id)
_ensure_order_merchant(order, merchant, '采购退货单')
return _red_flush_order_impl(
order=order,
order_model_cls=models.PurchaseReturnOrder,
approved_status=models.PurchaseReturnStatusEnum.APPROVED,
balance_source_type=models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER,
stock_source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN,
counterparty_field='supplier',
error_label='采购退货单',
red_flushed_by=red_flushed_by,
reason=reason,
)
def red_flush_sales_return_order(
*,
merchant: basic_info_models.Merchant,
sales_return_order: models.SalesReturnOrder | None = None,
sales_return_order_id: int | None = None,
red_flushed_by=None,
reason: str | None = '',
) -> models.SalesReturnOrder:
order = _resolve_sales_return_order_instance(sales_return_order, sales_return_order_id)
_ensure_order_merchant(order, merchant, '销售退货单')
return _red_flush_order_impl(
order=order,
order_model_cls=models.SalesReturnOrder,
approved_status=models.SalesReturnStatusEnum.APPROVED,
balance_source_type=models.BalanceChangeSourceEnum.SALES_RETURN_ORDER,
stock_source_type=stock_models.StockChangeSourceEnum.SALES_RETURN,
counterparty_field='customer',
error_label='销售退货单',
red_flushed_by=red_flushed_by,
reason=reason,
)
def red_flush_payment_order(
*,
merchant: basic_info_models.Merchant,
payment_order: models.PaymentOrder | None = None,
payment_order_id: int | None = None,
red_flushed_by=None,
reason: str | None = '',
) -> models.PaymentOrder:
order = _resolve_payment_order_instance(payment_order, payment_order_id)
_ensure_order_merchant(order, merchant, '付款单')
return _red_flush_order_impl(
order=order,
order_model_cls=models.PaymentOrder,
approved_status=models.PaymentOrderStatusEnum.APPROVED,
balance_source_type=models.BalanceChangeSourceEnum.PAYMENT_ORDER,
stock_source_type=None,
counterparty_field='supplier',
error_label='付款单',
red_flushed_by=red_flushed_by,
reason=reason,
)
def red_flush_receipt_order(
*,
merchant: basic_info_models.Merchant,
receipt_order: models.ReceiptOrder | None = None,
receipt_order_id: int | None = None,
red_flushed_by=None,
reason: str | None = '',
) -> models.ReceiptOrder:
order = _resolve_receipt_order_instance(receipt_order, receipt_order_id)
_ensure_order_merchant(order, merchant, '收款单')
return _red_flush_order_impl(
order=order,
order_model_cls=models.ReceiptOrder,
approved_status=models.ReceiptOrderStatusEnum.APPROVED,
balance_source_type=models.BalanceChangeSourceEnum.RECEIPT_ORDER,
stock_source_type=None,
counterparty_field='customer',
error_label='收款单',
red_flushed_by=red_flushed_by,
reason=reason,
)
def _ensure_order_merchant(order, merchant: basic_info_models.Merchant, error_label: str) -> None:
if merchant is None:
raise ValueError('必须提供 merchant')
if order.merchant_id != merchant.id:
raise ValueError(f'{error_label}不属于当前商户')
def _red_flush_order_impl(
*,
order,
order_model_cls,
approved_status,
balance_source_type: models.BalanceChangeSourceEnum,
stock_source_type: stock_models.StockChangeSourceEnum | None,
counterparty_field: str,
error_label: str,
red_flushed_by=None,
reason: str | None = '',
):
reason_text = (reason or '').strip() or f'红冲{error_label}'
current_red_flush_id = uuid.uuid4()
with transaction.atomic():
locked_order = order_model_cls.objects.select_for_update().get(id=order.id)
if locked_order.status != approved_status:
raise ValueError(f'仅允许红冲已审批的{error_label}')
if locked_order.is_red_flushed:
raise ValueError(f'{error_label}已红冲,不能重复红冲')
_ensure_order_not_external_source(locked_order, error_label)
original_balance_record = _get_original_balance_change_record(
merchant_id=locked_order.merchant_id,
source_type=balance_source_type,
source_id=locked_order.id,
error_label=error_label,
)
reverse_record = _create_reverse_balance_change_record(
original_record=original_balance_record,
order=locked_order,
counterparty_field=counterparty_field,
red_flush_id=current_red_flush_id,
reason=reason_text,
)
now = timezone.now()
original_balance_record.cancelled = True
original_balance_record.cancelled_at = now
original_balance_record.offset_id = reverse_record.id
original_balance_record.red_flush_id = current_red_flush_id
original_balance_record.save(update_fields=['cancelled', 'cancelled_at', 'offset_id', 'red_flush_id', 'updated_at'])
reverse_record.offset_to = original_balance_record.id
reverse_record.offset_at = now
reverse_record.red_flush_id = current_red_flush_id
reverse_record.save(update_fields=['offset_to', 'offset_at', 'red_flush_id', 'updated_at'])
if stock_source_type is not None:
_red_flush_stock_records_for_order(
order=locked_order,
stock_source_type=stock_source_type,
red_flush_id=current_red_flush_id,
red_flushed_by=red_flushed_by,
reason=reason_text,
error_label=error_label,
)
locked_order.is_red_flushed = True
locked_order.red_flush_id = current_red_flush_id
locked_order.red_flushed_at = now
locked_order.save(update_fields=['is_red_flushed', 'red_flush_id', 'red_flushed_at', 'updated_at'])
locked_order.refresh_from_db()
return locked_order
def _ensure_order_not_external_source(order, error_label: str) -> None:
if getattr(order, 'is_external_source', False) or getattr(order, 'external_source_id', None):
raise ValueError(f'外部来源{error_label}不允许红冲')
def _get_original_balance_change_record(
*,
merchant_id: int,
source_type: models.BalanceChangeSourceEnum,
source_id: int,
error_label: str,
) -> models.BalanceChangeRecord:
qs = models.BalanceChangeRecord.objects.select_for_update().filter(
merchant_id=merchant_id,
source_type=source_type,
source_id=source_id,
offset_to__isnull=True,
cancelled=False,
)
try:
return qs.get()
except models.BalanceChangeRecord.DoesNotExist as exc:
raise ValueError(f'{error_label}缺少可红冲的余额变动记录') from exc
except models.BalanceChangeRecord.MultipleObjectsReturned as exc:
raise ValueError(f'{error_label}存在多条可红冲的余额变动记录') from exc
def _create_reverse_balance_change_record(
*,
original_record: models.BalanceChangeRecord,
order,
counterparty_field: str,
red_flush_id,
reason: str,
) -> models.BalanceChangeRecord:
reverse_delta = -original_record.delta
remarks = f'红冲余额记录 {original_record.id}: {reason}'
extra_meta = {
'red_flush_of_balance_change_record': original_record.id,
}
if counterparty_field == 'supplier':
return BalanceService.adjust_supplier_balance(
merchant=order.merchant,
supplier=order.supplier,
delta=reverse_delta,
source_type=original_record.source_type,
source_id=original_record.source_id,
remarks=remarks,
extra_meta=extra_meta,
red_flush_id=red_flush_id,
)
if counterparty_field == 'customer':
return BalanceService.adjust_customer_balance(
merchant=order.merchant,
customer=order.customer,
delta=reverse_delta,
source_type=original_record.source_type,
source_id=original_record.source_id,
remarks=remarks,
extra_meta=extra_meta,
red_flush_id=red_flush_id,
)
raise ValueError('未知的业务主体类型')
def _red_flush_stock_records_for_order(
*,
order,
stock_source_type: stock_models.StockChangeSourceEnum,
red_flush_id,
red_flushed_by,
reason: str,
error_label: str,
) -> None:
stock_records = list(
stock_models.StockChangeRecord.objects.select_for_update().filter(
merchant_id=order.merchant_id,
source_type=stock_source_type,
source_id=order.id,
).order_by('id')
)
if not stock_records:
raise ValueError(f'{error_label}缺少可红冲的库存记录')
stock_service = StockFlowService(merchant=order.merchant, created_by=red_flushed_by)
for stock_record in stock_records:
stock_service.offset_stock_change(
source_record_id=stock_record.id,
reason=reason,
red_flush_id=red_flush_id,
extra_meta={
'business_order_type': order.__class__.__name__,
'business_order_id': order.id,
},
)
def _normalize_order_items(
*,
merchant: basic_info_models.Merchant,
@@ -2468,4 +2786,3 @@ def _normalize_statement_amount(value) -> Decimal:
def _decimal_to_string(value: Decimal) -> str:
normalized = _normalize_statement_amount(value)
return format(normalized, 'f')

View File

@@ -0,0 +1,438 @@
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.utils import timezone
from basic_info import models as basic_models
from business import models as business_models, services
from stock import models as stock_models
from stock import services as stock_services
from .fixtures import create_basic_fixtures, create_sales_fixtures
class BusinessRedFlushServiceTestCase(TestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username='red-flush', password='pass123')
def _disable_auto_stock_tasks(self, merchant):
basic_models.MerchantSetting.objects.filter(
merchant=merchant,
key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
).update(val_bool=False)
def _complete_stock_record(self, record_id):
record = stock_models.StockChangeRecord.objects.get(id=record_id)
stock_services.make_stock_change_completed(record)
return record
def _assert_balance_red_flushed(self, *, source_type, source_id, red_flush_id):
records = business_models.BalanceChangeRecord.objects.filter(
source_type=source_type,
source_id=source_id,
).order_by('id')
self.assertEqual(records.count(), 2)
original = records[0]
reverse = records[1]
self.assertTrue(original.cancelled)
self.assertEqual(original.offset_id, reverse.id)
self.assertEqual(original.red_flush_id, red_flush_id)
self.assertEqual(reverse.offset_to, original.id)
self.assertEqual(reverse.red_flush_id, red_flush_id)
self.assertEqual(reverse.delta, -original.delta)
return original, reverse
def _assert_stock_red_flushed(self, *, source_type, source_id, red_flush_id):
original = stock_models.StockChangeRecord.objects.get(
source_type=source_type,
source_id=source_id,
)
reverse = stock_models.StockChangeRecord.objects.get(
source_type=stock_models.StockChangeSourceEnum.OFFSET,
source_id=original.id,
)
self.assertEqual(original.red_flush_id, red_flush_id)
self.assertEqual(reverse.red_flush_id, red_flush_id)
self.assertTrue(original.is_reversed)
self.assertTrue(reverse.is_offset)
return original, reverse
def test_red_flush_purchase_order_reverses_balance_and_stock(self):
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 40, 'num_of_rolls': 2, 'price': '10'}],
)
services.review_purchase_order(
purchase_order=order,
target_status=business_models.PurchaseOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
payload = services.create_purchase_order_stock_entries_sync(
purchase_order_id=order.id,
warehouse_id=warehouse.id,
items=services._build_stock_flow_items_from_order(order),
created_by_id=self.user.id,
)
self._complete_stock_record(payload['stock_change_record_id'])
flushed = services.red_flush_purchase_order(
merchant=merchant,
purchase_order=order,
red_flushed_by=self.user,
reason='采购单测试红冲',
)
self.assertEqual(flushed.status, business_models.PurchaseOrderStatusEnum.APPROVED)
self.assertTrue(flushed.is_red_flushed)
self.assertIsNotNone(flushed.red_flush_id)
balance = business_models.SupplierBalance.objects.get(merchant=merchant, supplier=supplier)
self.assertEqual(balance.balance, Decimal('0.00'))
self._assert_balance_red_flushed(
source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
self._assert_stock_red_flushed(
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
def test_red_flush_sales_order_reverses_balance_and_stock(self):
merchant, customer, _, warehouse, _, product, operator = create_sales_fixtures()
self._disable_auto_stock_tasks(merchant)
order = services.create_sales_order(
merchant=merchant,
customer=customer,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 20, 'num_of_rolls': 1, 'price': '15'}],
)
services.review_sales_order(
sales_order=order,
target_status=business_models.SalesOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
payload = services.create_sales_order_stock_entries_sync(
sales_order_id=order.id,
warehouse_id=warehouse.id,
items=services._build_stock_flow_items_from_order(order),
created_by_id=self.user.id,
)
self._complete_stock_record(payload['stock_change_record_id'])
flushed = services.red_flush_sales_order(merchant=merchant, sales_order=order, red_flushed_by=self.user)
self.assertTrue(flushed.is_red_flushed)
balance = business_models.CustomerBalance.objects.get(merchant=merchant, customer=customer)
self.assertEqual(balance.balance, Decimal('0.00'))
self._assert_balance_red_flushed(
source_type=business_models.BalanceChangeSourceEnum.SALES_ORDER,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
self._assert_stock_red_flushed(
source_type=stock_models.StockChangeSourceEnum.SALES,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
def test_red_flush_purchase_return_order_reverses_balance_and_stock(self):
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
order = services.create_purchase_return_order(
merchant=merchant,
supplier=supplier,
return_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 12, 'num_of_rolls': 1, 'price': '8'}],
)
services.review_purchase_return_order(
purchase_return_order=order,
target_status=business_models.PurchaseReturnStatusEnum.APPROVED,
reviewed_by=self.user,
)
payload = services.create_purchase_return_order_stock_entries_sync(
purchase_return_order_id=order.id,
warehouse_id=warehouse.id,
items=services._build_stock_flow_items_from_order(order),
created_by_id=self.user.id,
)
self._complete_stock_record(payload['stock_change_record_id'])
flushed = services.red_flush_purchase_return_order(
merchant=merchant,
purchase_return_order=order,
red_flushed_by=self.user,
)
self.assertTrue(flushed.is_red_flushed)
balance = business_models.SupplierBalance.objects.get(merchant=merchant, supplier=supplier)
self.assertEqual(balance.balance, Decimal('0.00'))
self._assert_balance_red_flushed(
source_type=business_models.BalanceChangeSourceEnum.PURCHASE_RETURN_ORDER,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
self._assert_stock_red_flushed(
source_type=stock_models.StockChangeSourceEnum.PURCHASE_RETURN,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
def test_red_flush_sales_return_order_reverses_balance_and_stock(self):
merchant, customer, _, warehouse, _, product, operator = create_sales_fixtures()
self._disable_auto_stock_tasks(merchant)
order = services.create_sales_return_order(
merchant=merchant,
customer=customer,
return_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 12, 'num_of_rolls': 1, 'price': '8'}],
)
services.review_sales_return_order(
sales_return_order=order,
target_status=business_models.SalesReturnStatusEnum.APPROVED,
reviewed_by=self.user,
)
payload = services.create_sales_return_order_stock_entries_sync(
sales_return_order_id=order.id,
warehouse_id=warehouse.id,
items=services._build_stock_flow_items_from_order(order),
created_by_id=self.user.id,
)
self._complete_stock_record(payload['stock_change_record_id'])
flushed = services.red_flush_sales_return_order(
merchant=merchant,
sales_return_order=order,
red_flushed_by=self.user,
)
self.assertTrue(flushed.is_red_flushed)
balance = business_models.CustomerBalance.objects.get(merchant=merchant, customer=customer)
self.assertEqual(balance.balance, Decimal('0.00'))
self._assert_balance_red_flushed(
source_type=business_models.BalanceChangeSourceEnum.SALES_RETURN_ORDER,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
self._assert_stock_red_flushed(
source_type=stock_models.StockChangeSourceEnum.SALES_RETURN,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
def test_red_flush_payment_order_reverses_balance_only(self):
merchant, supplier, _, _, _, operator = create_basic_fixtures()
order = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=timezone.now().date(),
amount='30',
operator=operator,
)
services.review_payment_order(
payment_order=order,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
flushed = services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user)
self.assertTrue(flushed.is_red_flushed)
balance = business_models.SupplierBalance.objects.get(merchant=merchant, supplier=supplier)
self.assertEqual(balance.balance, Decimal('0.00'))
self._assert_balance_red_flushed(
source_type=business_models.BalanceChangeSourceEnum.PAYMENT_ORDER,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
self.assertFalse(stock_models.StockChangeRecord.objects.filter(red_flush_id=flushed.red_flush_id).exists())
def test_red_flush_receipt_order_reverses_balance_only(self):
merchant, _, _, _, _, operator = create_basic_fixtures()
customer = basic_models.Customer.objects.create(merchant=merchant, name='红冲客户')
order = services.create_receipt_order(
merchant=merchant,
customer=customer,
receipt_date=timezone.now().date(),
amount='45',
operator=operator,
)
services.review_receipt_order(
receipt_order=order,
target_status=business_models.ReceiptOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
flushed = services.red_flush_receipt_order(merchant=merchant, receipt_order=order, red_flushed_by=self.user)
self.assertTrue(flushed.is_red_flushed)
balance = business_models.CustomerBalance.objects.get(merchant=merchant, customer=customer)
self.assertEqual(balance.balance, Decimal('0.00'))
self._assert_balance_red_flushed(
source_type=business_models.BalanceChangeSourceEnum.RECEIPT_ORDER,
source_id=order.id,
red_flush_id=flushed.red_flush_id,
)
self.assertFalse(stock_models.StockChangeRecord.objects.filter(red_flush_id=flushed.red_flush_id).exists())
def test_red_flush_rejects_external_payment_order(self):
merchant, supplier, _, _, _, operator = create_basic_fixtures()
order = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=timezone.now().date(),
amount='30',
operator=operator,
)
order.is_external_source = True
order.external_source_id = 'XT-EXTERNAL-001'
order.save(update_fields=['is_external_source', 'external_source_id', 'updated_at'])
services.review_payment_order(
payment_order=order,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
with self.assertRaisesMessage(ValueError, '外部来源付款单不允许红冲'):
services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user)
order.refresh_from_db()
self.assertFalse(order.is_red_flushed)
def test_red_flush_rejects_external_receipt_order(self):
merchant, _, _, _, _, operator = create_basic_fixtures()
customer = basic_models.Customer.objects.create(merchant=merchant, name='外部红冲客户')
order = services.create_receipt_order(
merchant=merchant,
customer=customer,
receipt_date=timezone.now().date(),
amount='45',
operator=operator,
)
order.external_source_id = 'SK-EXTERNAL-001'
order.save(update_fields=['external_source_id', 'updated_at'])
services.review_receipt_order(
receipt_order=order,
target_status=business_models.ReceiptOrderStatusEnum.APPROVED,
reviewed_by=self.user,
)
with self.assertRaisesMessage(ValueError, '外部来源收款单不允许红冲'):
services.red_flush_receipt_order(merchant=merchant, receipt_order=order, red_flushed_by=self.user)
order.refresh_from_db()
self.assertFalse(order.is_red_flushed)
def test_red_flush_rejects_non_approved_order(self):
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 10, 'num_of_rolls': 1, 'price': '5'}],
)
with self.assertRaises(ValueError):
services.red_flush_purchase_order(merchant=merchant, purchase_order=order, red_flushed_by=self.user)
def test_red_flush_rejects_repeated_order(self):
merchant, supplier, _, _, _, operator = create_basic_fixtures()
order = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=timezone.now().date(),
amount='10',
operator=operator,
)
services.review_payment_order(
payment_order=order,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
)
services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user)
with self.assertRaises(ValueError):
services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user)
def test_red_flush_rejects_missing_balance_record(self):
merchant, supplier, _, _, _, operator = create_basic_fixtures()
order = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=timezone.now().date(),
amount='10',
operator=operator,
)
order.status = business_models.PaymentOrderStatusEnum.APPROVED
order.save(update_fields=['status'])
with self.assertRaises(ValueError):
services.red_flush_payment_order(merchant=merchant, payment_order=order, red_flushed_by=self.user)
def test_red_flush_rolls_back_balance_when_stock_missing(self):
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 10, 'num_of_rolls': 1, 'price': '5'}],
)
services.review_purchase_order(
purchase_order=order,
target_status=business_models.PurchaseOrderStatusEnum.APPROVED,
)
with self.assertRaises(ValueError):
services.red_flush_purchase_order(merchant=merchant, purchase_order=order, red_flushed_by=self.user)
order.refresh_from_db()
self.assertFalse(order.is_red_flushed)
balance_records = business_models.BalanceChangeRecord.objects.filter(
source_type=business_models.BalanceChangeSourceEnum.PURCHASE_ORDER,
source_id=order.id,
)
self.assertEqual(balance_records.count(), 1)
self.assertFalse(balance_records.first().cancelled)
def test_red_flush_rejects_cross_merchant_order(self):
merchant, supplier, _, _, _, operator = create_basic_fixtures()
other_merchant = basic_models.Merchant.objects.create(
name='其他商户',
type=basic_models.MerchantTypeEnum.FACTORY,
)
order = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=timezone.now().date(),
amount='10',
operator=operator,
)
services.review_payment_order(
payment_order=order,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
)
with self.assertRaises(ValueError):
services.red_flush_payment_order(
merchant=other_merchant,
payment_order_id=order.id,
red_flushed_by=self.user,
)

View File

@@ -0,0 +1,343 @@
from django.test import TestCase
from django.utils import timezone
from basic_info import models as basic_models
from business import models as business_models, services
from stock import models as stock_models
from .fixtures import create_basic_fixtures, create_sales_fixtures
class BusinessServiceEdgeCaseTestCase(TestCase):
def _disable_auto_stock_tasks(self, merchant):
basic_models.MerchantSetting.objects.filter(
merchant=merchant,
key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
).update(val_bool=False)
def test_sales_order_kind_and_date_validation_errors(self):
merchant, customer, _, warehouse, _, product, operator = create_sales_fixtures()
with self.assertRaises(ValueError):
services.create_sales_order(
merchant=merchant,
customer=customer,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
kind='invalid',
)
with self.assertRaises(ValueError):
services.create_sales_order(
merchant=merchant,
customer=customer,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
kind=999,
)
with self.assertRaises(ValueError):
services.create_sales_order(
merchant=merchant,
customer=customer,
order_date='not-a-date',
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.create_sales_order(
merchant=merchant,
customer=customer,
order_date=object(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
def test_return_order_source_validation_errors(self):
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
other_supplier = basic_models.Supplier.objects.create(merchant=merchant, name='其他供应商')
purchase_order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.create_purchase_return_order(
merchant=merchant,
supplier=supplier,
return_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
purchase_order_id=999999,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.create_purchase_return_order(
merchant=merchant,
supplier=other_supplier,
return_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
purchase_order=purchase_order,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
sales_merchant, customer, _, sales_warehouse, _, sales_product, sales_operator = create_sales_fixtures()
other_customer = basic_models.Customer.objects.create(merchant=sales_merchant, name='其他客户')
sales_order = services.create_sales_order(
merchant=sales_merchant,
customer=customer,
order_date=timezone.now().date(),
warehouse=sales_warehouse,
operator=sales_operator,
items=[{'product_id': sales_product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.create_sales_return_order(
merchant=sales_merchant,
customer=customer,
return_date=timezone.now().date(),
warehouse=sales_warehouse,
operator=sales_operator,
sales_order_id=999999,
items=[{'product_id': sales_product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.create_sales_return_order(
merchant=sales_merchant,
customer=other_customer,
return_date=timezone.now().date(),
warehouse=sales_warehouse,
operator=sales_operator,
sales_order=sales_order,
items=[{'product_id': sales_product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
def test_empty_items_and_update_source_validation_errors(self):
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
purchase_order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.update_purchase_order(purchase_order=purchase_order, items=[])
updated = services.update_purchase_order(
purchase_order=purchase_order,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
from_pre_purchase_order_id=123,
)
self.assertEqual(updated.from_pre_purchase_order_id, 123)
sales_merchant, customer, _, sales_warehouse, _, sales_product, sales_operator = create_sales_fixtures()
with self.assertRaises(ValueError):
services.create_sales_order(
merchant=sales_merchant,
customer=customer,
order_date=timezone.now().date(),
warehouse=sales_warehouse,
operator=sales_operator,
items=[],
)
with self.assertRaises(ValueError):
services.create_purchase_return_order(
merchant=merchant,
supplier=supplier,
return_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[],
)
return_order = services.create_purchase_return_order(
merchant=merchant,
supplier=supplier,
return_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
return_order.purchase_order = None
return_order.save(update_fields=['purchase_order'])
with self.assertRaises(ValueError):
services.update_purchase_return_order(
purchase_return_order=return_order,
purchase_order_id=999999,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.create_sales_return_order(
merchant=sales_merchant,
customer=customer,
return_date=timezone.now().date(),
warehouse=sales_warehouse,
operator=sales_operator,
items=[],
)
def test_review_invalid_targets_and_cancelled_reapprove_errors(self):
merchant, supplier, _, warehouse, product, operator = create_basic_fixtures()
purchase_order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse,
operator=operator,
items=[{'product_id': product.id, 'quantity': 1, 'num_of_rolls': 1, 'price': '1'}],
)
with self.assertRaises(ValueError):
services.review_purchase_order(purchase_order=purchase_order, target_status=999)
services.review_purchase_order(
purchase_order=purchase_order,
target_status=business_models.PurchaseOrderStatusEnum.CANCELLED,
)
with self.assertRaises(ValueError):
services.review_purchase_order(
purchase_order=purchase_order,
target_status=business_models.PurchaseOrderStatusEnum.APPROVED,
)
payment = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=timezone.now().date(),
amount='1',
operator=operator,
)
services.review_payment_order(payment_order=payment, target_status=business_models.PaymentOrderStatusEnum.CANCELLED)
with self.assertRaises(ValueError):
services.review_payment_order(payment_order=payment, target_status=business_models.PaymentOrderStatusEnum.APPROVED)
def test_resolve_helpers_require_instance_or_id(self):
with self.assertRaises(ValueError):
services._resolve_purchase_order_instance(None, None)
with self.assertRaises(ValueError):
services._resolve_sales_order_instance(None, None)
with self.assertRaises(ValueError):
services._resolve_purchase_return_order_instance(None, None)
with self.assertRaises(ValueError):
services._resolve_sales_return_order_instance(None, None)
with self.assertRaises(ValueError):
services._resolve_payment_order_instance(None, None)
with self.assertRaises(ValueError):
services._resolve_receipt_order_instance(None, None)
def test_stock_entry_sync_not_found_payloads(self):
self.assertEqual(
services.create_purchase_order_stock_entries_sync(
purchase_order_id=999999,
warehouse_id=1,
items=[],
)['error'],
'purchase_order_not_found',
)
self.assertEqual(
services.create_sales_order_stock_entries_sync(
sales_order_id=999999,
warehouse_id=1,
items=[],
)['error'],
'sales_order_not_found',
)
self.assertEqual(
services.create_purchase_return_order_stock_entries_sync(
purchase_return_order_id=999999,
warehouse_id=1,
items=[],
)['error'],
'purchase_return_order_not_found',
)
self.assertEqual(
services.create_sales_return_order_stock_entries_sync(
sales_return_order_id=999999,
warehouse_id=1,
items=[],
)['error'],
'sales_return_order_not_found',
)
def test_build_stock_flow_items_error_branches(self):
merchant, supplier, warehouse_strict, _, product, operator = create_basic_fixtures()
order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse_strict,
operator=operator,
items=[{'product_id': product.id, 'numbers': [1], 'price': '1'}],
)
order.items.all().delete()
with self.assertRaises(ValueError):
services._build_stock_flow_items_from_order(order)
order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse_strict,
operator=operator,
items=[{'product_id': product.id, 'numbers': [1], 'price': '1'}],
)
item = order.items.first()
item.quantity_of_rolls = ''
item.save(update_fields=['quantity_of_rolls'])
with self.assertRaises(ValueError):
services._build_stock_flow_items_from_order(order)
def test_bind_purchase_order_stock_change_record_error_branches(self):
merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=timezone.now().date(),
warehouse=warehouse_strict,
operator=operator,
items=[{'product_id': product.id, 'numbers': [1], 'price': '1'}],
)
bound_record = stock_models.StockChangeRecord.objects.create(
merchant=merchant,
type=stock_models.StockChangeTypeEnum.ADD,
warehouse=warehouse_strict,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=123,
)
with self.assertRaises(ValueError):
services.bind_purchase_order_stock_change_record(
purchase_order_id=order.id,
stock_change_record_id=bound_record.id,
)
wrong_warehouse_record = stock_models.StockChangeRecord.objects.create(
merchant=merchant,
type=stock_models.StockChangeTypeEnum.ADD,
warehouse=warehouse_relaxed,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
)
with self.assertRaises(ValueError):
services.bind_purchase_order_stock_change_record(
purchase_order_id=order.id,
stock_change_record_id=wrong_warehouse_record.id,
)
sales_source_record = stock_models.StockChangeRecord.objects.create(
merchant=merchant,
type=stock_models.StockChangeTypeEnum.ADD,
warehouse=warehouse_strict,
source_type=stock_models.StockChangeSourceEnum.SALES_RETURN,
)
with self.assertRaises(ValueError):
services.bind_purchase_order_stock_change_record(
purchase_order_id=order.id,
stock_change_record_id=sales_source_record.id,
)

View File

@@ -0,0 +1,165 @@
from decimal import Decimal
from django.test import TestCase
from django.utils import timezone
from basic_info import models as basic_models
from business import models as business_models, services
from .fixtures import create_basic_fixtures, create_sales_fixtures
class BusinessStatementServiceTestCase(TestCase):
def _disable_auto_stock_tasks(self, merchant):
basic_models.MerchantSetting.objects.filter(
merchant=merchant,
key=basic_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
).update(val_bool=False)
def test_build_customer_statement_collects_all_customer_records(self):
merchant, customer, warehouse_strict, warehouse_relaxed, _, product, operator = create_sales_fixtures()
self._disable_auto_stock_tasks(merchant)
today = timezone.now().date()
sales_order = services.create_sales_order(
merchant=merchant,
customer=customer,
order_date=today,
warehouse=warehouse_strict,
operator=operator,
items=[{'product_id': product.id, 'numbers': [6, 4], 'price': '10', 'unit': '米'}],
remarks='销售对账',
)
services.review_sales_order(
sales_order=sales_order,
target_status=business_models.SalesOrderStatusEnum.APPROVED,
)
return_order = services.create_sales_return_order(
merchant=merchant,
customer=customer,
return_date=today,
warehouse=warehouse_relaxed,
operator=operator,
items=[{'product_id': product.id, 'quantity': 3, 'num_of_rolls': 1, 'price': '10'}],
remarks='销退对账',
)
services.review_sales_return_order(
sales_return_order=return_order,
target_status=business_models.SalesReturnStatusEnum.APPROVED,
)
receipt_order = services.create_receipt_order(
merchant=merchant,
customer=customer,
receipt_date=today,
amount='20',
discount_amount='2',
operator=operator,
remarks='收款对账',
)
services.review_receipt_order(
receipt_order=receipt_order,
target_status=business_models.ReceiptOrderStatusEnum.APPROVED,
)
business_models.ExternalCustomerStatementOrder.objects.create(
merchant=merchant,
customer=customer,
category=business_models.ExternalCustomerStatementCategoryEnum.SALE,
external_source_id='EXT-SALE-1',
occurred_at=today,
total_amount=Decimal('50'),
sf_amount=Decimal('5'),
zk_amount=Decimal('3'),
items_payload=[{'product_name': '外部销售'}],
)
business_models.ExternalCustomerStatementOrder.objects.create(
merchant=merchant,
customer=customer,
category=business_models.ExternalCustomerStatementCategoryEnum.SALE_RETURN,
external_source_id='EXT-RETURN-1',
occurred_at=today,
total_amount=Decimal('7'),
zk_amount=Decimal('1'),
items_payload=[{'product_name': '外部退货'}],
)
payload = services.build_customer_statement(merchant=merchant, customer=customer)
source_types = {record['source_type'] for record in payload['records']}
self.assertEqual(
source_types,
{
'sales_order',
'sales_return_order',
'receipt_order',
'external_sales_order',
'external_sales_return_order',
},
)
sales_record = next(record for record in payload['records'] if record['source_type'] == 'sales_order')
self.assertEqual(sales_record['items'][0]['quantity'], Decimal('10'))
self.assertEqual(sales_record['items'][0]['quantity_of_rolls'], [6, 4])
receipt_record = next(record for record in payload['records'] if record['source_type'] == 'receipt_order')
self.assertEqual(receipt_record['positive_amount'], Decimal('-2.00'))
self.assertEqual(receipt_record['negative_amount'], Decimal('20.00'))
summary = services.build_statement_summary(payload)
self.assertIn('positive_total', summary)
self.assertIn('negative_total', summary)
def test_build_supplier_statement_collects_all_supplier_records(self):
merchant, supplier, warehouse_strict, warehouse_relaxed, product, operator = create_basic_fixtures()
self._disable_auto_stock_tasks(merchant)
today = timezone.now().date()
purchase_order = services.create_purchase_order(
merchant=merchant,
supplier=supplier,
order_date=today,
warehouse=warehouse_strict,
operator=operator,
items=[{'product_id': product.id, 'numbers': [8, 2], 'price': '9', 'unit': '米'}],
remarks='采购对账',
)
services.review_purchase_order(
purchase_order=purchase_order,
target_status=business_models.PurchaseOrderStatusEnum.APPROVED,
)
return_order = services.create_purchase_return_order(
merchant=merchant,
supplier=supplier,
return_date=today,
warehouse=warehouse_relaxed,
operator=operator,
items=[{'product_id': product.id, 'quantity': 4, 'num_of_rolls': 1, 'price': '9'}],
remarks='采退对账',
)
services.review_purchase_return_order(
purchase_return_order=return_order,
target_status=business_models.PurchaseReturnStatusEnum.APPROVED,
)
payment_order = services.create_payment_order(
merchant=merchant,
supplier=supplier,
payment_date=today,
amount='15',
operator=operator,
remarks='付款对账',
)
services.review_payment_order(
payment_order=payment_order,
target_status=business_models.PaymentOrderStatusEnum.APPROVED,
)
payload = services.build_supplier_statement(merchant=merchant, supplier=supplier)
source_types = {record['source_type'] for record in payload['records']}
self.assertEqual(source_types, {'purchase_order', 'purchase_return_order', 'payment_order'})
purchase_record = next(record for record in payload['records'] if record['source_type'] == 'purchase_order')
self.assertEqual(purchase_record['items'][0]['quantity'], Decimal('10'))
self.assertEqual(purchase_record['items'][0]['num_of_rolls'], 2)
payment_record = next(record for record in payload['records'] if record['source_type'] == 'payment_order')
self.assertEqual(payment_record['negative_amount'], Decimal('15.00'))