forked from erp-dev/erp
feat: red flush
This commit is contained in:
106
business/migrations/0031_red_flush_fields.py
Normal file
106
business/migrations/0031_red_flush_fields.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
@@ -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 = '余额变动记录'
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
438
business/tests/test_red_flush_services.py
Normal file
438
business/tests/test_red_flush_services.py
Normal 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,
|
||||
)
|
||||
343
business/tests/test_service_edge_cases.py
Normal file
343
business/tests/test_service_edge_cases.py
Normal 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,
|
||||
)
|
||||
165
business/tests/test_statement_services.py
Normal file
165
business/tests/test_statement_services.py
Normal 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'))
|
||||
Reference in New Issue
Block a user