forked from erp-dev/erp
feat: statement for supplier and customer
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user