1
0
forked from erp-dev/erp

feat: statement for supplier and customer

This commit is contained in:
2025-12-01 22:55:52 +08:00
parent fae33809ac
commit d522c0a9b0
15 changed files with 1085 additions and 52 deletions

View File

@@ -4,7 +4,7 @@ from django.db.models import Sum
from django.utils import timezone
from sse.services import push_simple_message_with_object_id
from basic_info import models as basic_models
from decimal import Decimal, InvalidOperation
from decimal import Decimal, InvalidOperation, ROUND_DOWN
import logging
from typing import List, Dict, Any, Tuple
@@ -224,6 +224,17 @@ def _to_decimal(value, field_name: str) -> Decimal:
raise ValueError(f'{field_name} 必须是合法的数值')
def _to_positive_int(value, field_name: str) -> int:
if value is None:
raise ValueError(f'{field_name} 不能为空')
decimal_value = _to_decimal(value, field_name)
if decimal_value <= 0:
raise ValueError(f'{field_name} 必须大于 0')
if decimal_value != decimal_value.to_integral_value():
raise ValueError(f'{field_name} 必须为整数')
return int(decimal_value)
def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]:
if total <= 0:
raise ValueError('quantity.value 必须大于 0')
@@ -244,6 +255,31 @@ def _split_quantities(total: Decimal, unit_size: Decimal) -> List[Decimal]:
return quantities
def _split_quantities_evenly(total: Decimal, rolls: int) -> List[Decimal]:
if total <= 0:
raise ValueError('quantity.value 必须大于 0')
if rolls <= 0:
raise ValueError('quantity.num_of_rolls 必须大于 0')
quantum = Decimal('0.01')
base = (total / Decimal(rolls)).quantize(quantum, rounding=ROUND_DOWN)
if base == 0 and total < quantum:
return [total]
quantities: List[Decimal] = [base for _ in range(rolls)]
distributed = base * rolls
remainder = (total - distributed).quantize(quantum)
idx = 0
while remainder > 0 and quantities:
increment = min(quantum, remainder)
quantities[idx] += increment
remainder -= increment
idx = (idx + 1) % rolls
return [qty for qty in quantities if qty > 0]
def create_stock_change_record_relaxed(
*,
merchant: basic_models.Merchant,
@@ -294,12 +330,10 @@ def create_stock_change_record_relaxed(
if not product_id:
raise ValueError('产品ID不能为空')
total_value = quantity_data.get('value')
total_value = _to_decimal(quantity_data.get('value'), 'quantity.value')
roll_count = quantity_data.get('num_of_rolls')
unit_size = quantity_data.get('unit_count', 1)
total_value = _to_decimal(total_value, 'quantity.value')
unit_size = _to_decimal(unit_size, 'quantity.unit_count')
try:
product = basic_models.Product.objects.get(id=product_id)
except basic_models.Product.DoesNotExist:
@@ -308,7 +342,12 @@ def create_stock_change_record_relaxed(
if product.merchant_id != merchant.id:
raise ValueError(f'产品ID {product_id} 不属于当前商户')
quantities = _split_quantities(total_value, unit_size)
if roll_count is not None:
roll_count = _to_positive_int(roll_count, 'quantity.num_of_rolls')
quantities = _split_quantities_evenly(total_value, roll_count)
else:
unit_size = _to_decimal(unit_size, 'quantity.unit_count')
quantities = _split_quantities(total_value, unit_size)
for quantity in quantities:
detail = models.StockChangeDetail.objects.create(
@@ -403,7 +442,137 @@ class StockFlowService:
Returns:
新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。
"""
raise NotImplementedError('库存红冲功能尚未实现')
if not reason or not reason.strip():
raise ValueError('reason 不能为空')
if items:
raise ValueError('当前版本暂不支持部分红冲,请省略 items 参数')
offset_record, created_details = self._perform_stock_offset(
source_record_id=source_record_id,
reason=reason.strip(),
request_id=request_id,
extra_meta=extra_meta or {},
)
return offset_record, created_details, len(created_details)
def _perform_stock_offset(
self,
*,
source_record_id: int,
reason: str,
request_id: str | None,
extra_meta: Dict[str, Any],
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail]]:
with transaction.atomic():
source_record = (
models.StockChangeRecord.objects.select_related('warehouse', 'merchant')
.prefetch_related('details__product', 'details__consume_with')
.select_for_update()
.get(id=source_record_id)
)
if source_record.merchant_id != self.merchant.id:
raise ValueError('库存变动记录不属于当前商户')
if not source_record.is_finished:
raise ValueError('仅允许对已完成的库存变动执行红冲')
if models.StockSnapshot.objects.filter(
stock_change_record=source_record,
offset_id__isnull=False,
).exists():
raise ValueError('该库存变动记录已执行红冲')
source_details = list(source_record.details.all().order_by('id'))
if not source_details:
raise ValueError('库存变动记录缺少明细,无法红冲')
reverse_type = (
models.StockChangeTypeEnum.ADD
if source_record.is_outgoing
else models.StockChangeTypeEnum.REMOVE
)
remarks = f'红冲原记录 {source_record.id}: {reason}'
if request_id:
remarks = f'[{request_id}] {remarks}'
offset_record = models.StockChangeRecord.objects.create(
merchant=self.merchant,
type=reverse_type,
warehouse=source_record.warehouse,
source_type=models.StockChangeSourceEnum.OFFSET,
source_id=source_record.id,
created_by=self.created_by,
remarks=remarks[:500],
)
created_details: List[models.StockChangeDetail] = []
for detail in source_details:
new_detail = models.StockChangeDetail.objects.create(
merchant=self.merchant,
stock_change_record=offset_record,
product=detail.product,
quantity=detail.quantity,
unit=detail.unit,
)
created_details.append(new_detail)
if source_record.is_outgoing:
consume_ids = [detail.consume_with_id for detail in source_details if detail.consume_with_id]
if consume_ids:
models.StockChangeDetail.objects.filter(id__in=consume_ids).update(is_consumed=False)
if not make_stock_change_completed(offset_record):
raise ValueError('红冲库存记录创建失败')
self._link_offset_snapshots(
source_record=source_record,
offset_record=offset_record,
)
logger.info('库存变动记录 %s 已红冲,生成记录 %s', source_record_id, offset_record.id)
return offset_record, created_details
@staticmethod
def _link_offset_snapshots(
*,
source_record: models.StockChangeRecord,
offset_record: models.StockChangeRecord,
):
original_snapshots = list(
models.StockSnapshot.objects.filter(stock_change_record=source_record).order_by('id')
)
if not original_snapshots:
return
new_snapshots = list(
models.StockSnapshot.objects.filter(stock_change_record=offset_record).order_by('id')
)
if not new_snapshots:
raise ValueError('红冲记录未生成任何库存快照')
now = timezone.now()
pair_count = min(len(original_snapshots), len(new_snapshots))
for index in range(pair_count):
original_snapshot = original_snapshots[index]
offset_snapshot = new_snapshots[index]
original_snapshot.cancelled = True
original_snapshot.cancelled_at = now
original_snapshot.offset_id = offset_snapshot.id
original_snapshot.save(update_fields=['cancelled', 'cancelled_at', 'offset_id'])
offset_snapshot.offset_to = original_snapshot.id
offset_snapshot.offset_at = now
offset_snapshot.save(update_fields=['offset_to', 'offset_at'])
if len(original_snapshots) != len(new_snapshots):
logger.warning(
'红冲快照数量不一致:原始 %s 条,新快照 %s',
len(original_snapshots),
len(new_snapshots),
)
def _get_and_validate_warehouse(self, warehouse_id: int) -> basic_models.WareHouse:
if not warehouse_id:
@@ -478,16 +647,20 @@ class StockFlowService:
for item in items:
product_id = item.get('product_id')
total_value = item.get('value')
unit_size = item.get('num_of_rolls', 1)
roll_count = item.get('num_of_rolls')
unit_size = item.get('unit_size') or item.get('unit_count')
if not product_id or total_value is None:
raise ValueError('宽进宽出需要提供 product_id 与 value')
payload.append({
'product': product_id,
'quantity': {
'value': total_value,
'unit_count': unit_size,
}
})
quantity_payload: Dict[str, Any] = {'value': total_value}
if roll_count is not None:
quantity_payload['num_of_rolls'] = roll_count
elif unit_size is not None:
quantity_payload['unit_count'] = unit_size
else:
quantity_payload['unit_count'] = 1
payload.append({'product': product_id, 'quantity': quantity_payload})
return payload
@staticmethod