forked from erp-dev/erp
929 lines
34 KiB
Python
929 lines
34 KiB
Python
from . import models
|
||
from django.db import transaction
|
||
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, ROUND_DOWN
|
||
import logging
|
||
from typing import List, Dict, Any, Tuple
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _raise_unimplemented_mode():
|
||
raise ValueError('仓库出入库模式为【严进严出】,该模式暂未支持当前操作')
|
||
|
||
|
||
def _ensure_strict_mode(warehouse: basic_models.WareHouse, *, allow_restrict_in_out: bool = False):
|
||
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN:
|
||
return
|
||
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
|
||
if allow_restrict_in_out:
|
||
return
|
||
_raise_unimplemented_mode()
|
||
raise ValueError('仓库出入库模式为【宽进宽出】,请使用宽松模式接口创建出入库记录')
|
||
|
||
|
||
def _ensure_relaxed_mode(warehouse: basic_models.WareHouse):
|
||
if warehouse.mode == basic_models.WareHouseModeEnum.UNRESTRICTED:
|
||
return
|
||
if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
|
||
_raise_unimplemented_mode()
|
||
raise ValueError('仓库出入库模式为【严进宽出】,请使用严谨模式接口创建出入库记录')
|
||
|
||
|
||
def _create_details_from_quantities(
|
||
*,
|
||
stock_change_record: models.StockChangeRecord,
|
||
merchant: basic_models.Merchant,
|
||
products: List[Dict[str, Any]],
|
||
) -> Tuple[List[models.StockChangeDetail], int]:
|
||
created_details: List[models.StockChangeDetail] = []
|
||
created_count = 0
|
||
|
||
for product_data in products:
|
||
product_id = product_data.get('product')
|
||
quantities = product_data.get('quantity') or []
|
||
|
||
if not product_id or not quantities:
|
||
raise ValueError('产品数据不完整,缺少 product 或 quantity')
|
||
|
||
try:
|
||
product = basic_models.Product.objects.get(id=product_id)
|
||
except basic_models.Product.DoesNotExist:
|
||
raise ValueError(f'产品ID {product_id} 不存在')
|
||
|
||
if product.merchant_id != merchant.id:
|
||
raise ValueError(f'产品ID {product_id} 不属于当前商户')
|
||
|
||
for quantity in quantities:
|
||
detail = models.StockChangeDetail.objects.create(
|
||
stock_change_record=stock_change_record,
|
||
product=product,
|
||
quantity=quantity,
|
||
merchant=merchant,
|
||
unit=product.unit,
|
||
)
|
||
created_details.append(detail)
|
||
created_count += 1
|
||
|
||
return created_details, created_count
|
||
|
||
|
||
def _create_consumption_details(
|
||
*,
|
||
stock_change_record: models.StockChangeRecord,
|
||
merchant: basic_models.Merchant,
|
||
products: List[Dict[str, Any]],
|
||
) -> List[models.StockChangeDetail]:
|
||
created_details: List[models.StockChangeDetail] = []
|
||
|
||
for product_data in products:
|
||
product_id = product_data.get('product')
|
||
consume_ids = product_data.get('consume_with') or []
|
||
|
||
if not product_id or not consume_ids:
|
||
raise ValueError('严进严出出库必须提供 consume_with 列表')
|
||
|
||
try:
|
||
product = basic_models.Product.objects.get(id=product_id)
|
||
except basic_models.Product.DoesNotExist:
|
||
raise ValueError(f'产品ID {product_id} 不存在')
|
||
|
||
if product.merchant_id != merchant.id:
|
||
raise ValueError(f'产品ID {product_id} 不属于当前商户')
|
||
|
||
inbound_details = models.StockChangeDetail.objects.select_related('stock_change_record').filter(
|
||
id__in=consume_ids
|
||
)
|
||
if inbound_details.count() != len(consume_ids):
|
||
raise ValueError('部分入库明细不存在')
|
||
|
||
inbound_map = {detail.id: detail for detail in inbound_details}
|
||
|
||
for detail_id in consume_ids:
|
||
inbound_detail = inbound_map.get(detail_id)
|
||
if inbound_detail is None:
|
||
raise ValueError('部分入库明细不存在')
|
||
if inbound_detail.stock_change_record.warehouse_id != stock_change_record.warehouse_id:
|
||
raise ValueError('入库明细与当前仓库不一致')
|
||
if inbound_detail.stock_change_record.merchant_id != merchant.id:
|
||
raise ValueError('入库明细与当前商户不一致')
|
||
if not inbound_detail.stock_change_record.is_incoming:
|
||
raise ValueError('只能引用入库明细进行出库')
|
||
if inbound_detail.is_consumed:
|
||
raise ValueError(f'入库明细 {inbound_detail.id} 已被消耗')
|
||
if inbound_detail.product_id != product_id:
|
||
raise ValueError('入库明细与当前产品不匹配')
|
||
|
||
detail = models.StockChangeDetail.objects.create(
|
||
stock_change_record=stock_change_record,
|
||
product=product,
|
||
quantity=inbound_detail.quantity,
|
||
merchant=merchant,
|
||
unit=inbound_detail.unit,
|
||
consume_with=inbound_detail,
|
||
)
|
||
inbound_detail.is_consumed = True
|
||
inbound_detail.save(update_fields=['is_consumed'])
|
||
created_details.append(detail)
|
||
|
||
return created_details
|
||
|
||
|
||
def create_stock_change_record_with_details(
|
||
*,
|
||
merchant: basic_models.Merchant,
|
||
created_by,
|
||
type: int,
|
||
warehouse_id: int,
|
||
source_type: int,
|
||
source_id: int | None = None,
|
||
products: List[Dict[str, Any]] | None = None,
|
||
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]:
|
||
"""
|
||
创建库存变动记录及其明细
|
||
|
||
参数:
|
||
merchant: 当前商户
|
||
created_by: 操作人(可为空)
|
||
type: 出入库类型
|
||
warehouse_id: 仓库ID
|
||
source_type: 来源类型
|
||
source_id: 来源单据ID,可选
|
||
products: 产品及数量列表
|
||
"""
|
||
|
||
if not merchant:
|
||
raise ValueError('必须提供商户信息')
|
||
if not warehouse_id:
|
||
raise ValueError('必须提供仓库ID')
|
||
if not products:
|
||
raise ValueError('产品列表不能为空')
|
||
|
||
try:
|
||
warehouse = basic_models.WareHouse.objects.get(id=warehouse_id)
|
||
except basic_models.WareHouse.DoesNotExist:
|
||
raise ValueError(f'仓库ID {warehouse_id} 不存在')
|
||
|
||
if warehouse.merchant_id != merchant.id:
|
||
raise ValueError('仓库不属于当前商户')
|
||
|
||
allow_restrict_in_out = (
|
||
warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT
|
||
and type == models.StockChangeTypeEnum.ADD
|
||
)
|
||
is_strict_outgoing = (
|
||
warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT
|
||
and type == models.StockChangeTypeEnum.REMOVE
|
||
)
|
||
if not is_strict_outgoing:
|
||
_ensure_strict_mode(warehouse, allow_restrict_in_out=allow_restrict_in_out)
|
||
|
||
created_details: List[models.StockChangeDetail] = []
|
||
created_count = 0
|
||
|
||
with transaction.atomic():
|
||
stock_change_record = models.StockChangeRecord.objects.create(
|
||
type=type,
|
||
warehouse=warehouse,
|
||
source_type=source_type,
|
||
source_id=source_id,
|
||
merchant=merchant,
|
||
created_by=created_by,
|
||
)
|
||
logger.info('创建新库存变动记录 ID: %s', stock_change_record.id)
|
||
|
||
if is_strict_outgoing:
|
||
created_details = _create_consumption_details(
|
||
stock_change_record=stock_change_record,
|
||
merchant=merchant,
|
||
products=products,
|
||
)
|
||
created_count = len(created_details)
|
||
else:
|
||
created_details, created_count = _create_details_from_quantities(
|
||
stock_change_record=stock_change_record,
|
||
merchant=merchant,
|
||
products=products,
|
||
)
|
||
|
||
if warehouse.merchant.auto_complete_stock_change:
|
||
make_stock_change_completed(stock_change_record)
|
||
logger.info('自动确认库存变动记录 ID: %s', stock_change_record.id)
|
||
|
||
return stock_change_record, created_details, created_count
|
||
|
||
|
||
def _to_decimal(value, field_name: str) -> Decimal:
|
||
try:
|
||
return Decimal(str(value))
|
||
except (InvalidOperation, TypeError):
|
||
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')
|
||
if unit_size <= 0:
|
||
raise ValueError('quantity.unit_count 必须大于 0')
|
||
|
||
quantities: List[Decimal] = []
|
||
num_full = int(total // unit_size)
|
||
remainder = total % unit_size
|
||
|
||
if num_full == 0:
|
||
quantities.append(total)
|
||
else:
|
||
quantities.extend([unit_size] * num_full)
|
||
if remainder > 0:
|
||
quantities.append(remainder)
|
||
|
||
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,
|
||
created_by,
|
||
type: int,
|
||
warehouse_id: int,
|
||
source_type: int,
|
||
source_id: int | None = None,
|
||
products: List[Dict[str, Any]] | None = None,
|
||
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]:
|
||
"""
|
||
宽松模式:根据总数量和单条数量自动拆分明细
|
||
"""
|
||
|
||
if not merchant:
|
||
raise ValueError('必须提供商户信息')
|
||
if not warehouse_id:
|
||
raise ValueError('必须提供仓库ID')
|
||
if not products:
|
||
raise ValueError('产品列表不能为空')
|
||
|
||
try:
|
||
warehouse = basic_models.WareHouse.objects.get(id=warehouse_id)
|
||
except basic_models.WareHouse.DoesNotExist:
|
||
raise ValueError(f'仓库ID {warehouse_id} 不存在')
|
||
|
||
if warehouse.merchant_id != merchant.id:
|
||
raise ValueError('仓库不属于当前商户')
|
||
_ensure_relaxed_mode(warehouse)
|
||
|
||
created_details: List[models.StockChangeDetail] = []
|
||
|
||
with transaction.atomic():
|
||
stock_change_record = models.StockChangeRecord.objects.create(
|
||
type=type,
|
||
warehouse=warehouse,
|
||
source_type=source_type,
|
||
source_id=source_id,
|
||
merchant=merchant,
|
||
created_by=created_by,
|
||
)
|
||
logger.info('创建宽松模式库存变动记录 ID: %s', stock_change_record.id)
|
||
|
||
for product_data in products:
|
||
product_id = product_data.get('product')
|
||
quantity_data = product_data.get('quantity') or {}
|
||
|
||
if not product_id:
|
||
raise ValueError('产品ID不能为空')
|
||
|
||
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)
|
||
|
||
try:
|
||
product = basic_models.Product.objects.get(id=product_id)
|
||
except basic_models.Product.DoesNotExist:
|
||
raise ValueError(f'产品ID {product_id} 不存在')
|
||
|
||
if product.merchant_id != merchant.id:
|
||
raise ValueError(f'产品ID {product_id} 不属于当前商户')
|
||
|
||
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(
|
||
stock_change_record=stock_change_record,
|
||
product=product,
|
||
quantity=quantity,
|
||
merchant=merchant,
|
||
unit=product.unit,
|
||
)
|
||
created_details.append(detail)
|
||
|
||
if warehouse.merchant.auto_complete_stock_change:
|
||
make_stock_change_completed(stock_change_record)
|
||
logger.info('自动确认宽松模式库存变动记录 ID: %s', stock_change_record.id)
|
||
|
||
return stock_change_record, created_details, len(created_details)
|
||
|
||
|
||
class StockFlowService:
|
||
"""
|
||
统一出入库服务,屏蔽仓库模式差异。
|
||
|
||
items 结构支持以下字段:
|
||
- product_id: 产品ID,必填
|
||
- quantities: List[Decimal | str],用于严谨/严进严出入库
|
||
- value: Decimal | str,总数量,用于宽进宽出
|
||
- num_of_rolls: Decimal | int,每条数量,用于宽进宽出
|
||
- consume_detail_ids: List[int],严进严出出库消耗的入库明细ID
|
||
"""
|
||
|
||
def __init__(self, *, merchant: basic_models.Merchant, created_by):
|
||
if merchant is None:
|
||
raise ValueError('StockFlowService 初始化必须提供商户')
|
||
self.merchant = merchant
|
||
self.created_by = created_by
|
||
|
||
def stock_in(
|
||
self,
|
||
*,
|
||
warehouse_id: int,
|
||
source_type: int,
|
||
source_id: int | None = None,
|
||
items: List[Dict[str, Any]],
|
||
):
|
||
warehouse = self._get_and_validate_warehouse(warehouse_id)
|
||
products = self._build_products_payload(warehouse, is_incoming=True, items=items)
|
||
return self._create_record(
|
||
warehouse=warehouse,
|
||
record_type=models.StockChangeTypeEnum.ADD,
|
||
source_type=source_type,
|
||
source_id=source_id,
|
||
products=products,
|
||
)
|
||
|
||
def stock_out(
|
||
self,
|
||
*,
|
||
warehouse_id: int,
|
||
source_type: int,
|
||
source_id: int | None = None,
|
||
items: List[Dict[str, Any]],
|
||
):
|
||
warehouse = self._get_and_validate_warehouse(warehouse_id)
|
||
products = self._build_products_payload(warehouse, is_incoming=False, items=items)
|
||
return self._create_record(
|
||
warehouse=warehouse,
|
||
record_type=models.StockChangeTypeEnum.REMOVE,
|
||
source_type=source_type,
|
||
source_id=source_id,
|
||
products=products,
|
||
)
|
||
|
||
def offset_stock_change(
|
||
self,
|
||
*,
|
||
source_record_id: int,
|
||
reason: str,
|
||
items: List[Dict[str, Any]] | None = None,
|
||
request_id: str | None = None,
|
||
extra_meta: Dict[str, Any] | None = None,
|
||
red_flush_id=None,
|
||
) -> Tuple[models.StockChangeRecord, List[models.StockChangeDetail], int]:
|
||
"""
|
||
创建针对既有库存变动记录的红冲(对冲)占位接口。
|
||
|
||
Args:
|
||
source_record_id: 需要被红冲的库存变动记录 ID。
|
||
reason: 红冲原因,必须明确记录以便审计。
|
||
items: 可选的部分红冲明细(支持宽进/严进等结构);为空时表示全量对冲。
|
||
request_id: 幂等键;同一 request_id 的请求应视为一次操作。
|
||
extra_meta: 额外的上下文信息(触发来源、操作者备注等)。
|
||
red_flush_id: 业务红冲批次 ID,用于跨模块审计追踪。
|
||
|
||
Returns:
|
||
新生成的反向 `StockChangeRecord`、其明细列表以及明细数量。
|
||
"""
|
||
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 {},
|
||
red_flush_id=red_flush_id,
|
||
)
|
||
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],
|
||
red_flush_id,
|
||
) -> 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,
|
||
red_flush_id=red_flush_id,
|
||
remarks=remarks[:500],
|
||
)
|
||
|
||
if red_flush_id and source_record.red_flush_id is None:
|
||
source_record.red_flush_id = red_flush_id
|
||
source_record.save(update_fields=['red_flush_id', 'updated_at'])
|
||
|
||
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:
|
||
raise ValueError('必须提供仓库ID')
|
||
try:
|
||
warehouse = basic_models.WareHouse.objects.get(id=warehouse_id)
|
||
except basic_models.WareHouse.DoesNotExist:
|
||
raise ValueError(f'仓库ID {warehouse_id} 不存在')
|
||
if warehouse.merchant_id != self.merchant.id:
|
||
raise ValueError('仓库不属于当前商户')
|
||
return warehouse
|
||
|
||
def _create_record(
|
||
self,
|
||
*,
|
||
warehouse: basic_models.WareHouse,
|
||
record_type: int,
|
||
source_type: int,
|
||
source_id: int | None,
|
||
products: List[Dict[str, Any]],
|
||
):
|
||
common_kwargs = dict(
|
||
merchant=self.merchant,
|
||
created_by=self.created_by,
|
||
type=record_type,
|
||
warehouse_id=warehouse.id,
|
||
source_type=source_type,
|
||
source_id=source_id,
|
||
products=products,
|
||
)
|
||
|
||
if warehouse.mode == basic_models.WareHouseModeEnum.UNRESTRICTED:
|
||
return create_stock_change_record_relaxed(**common_kwargs)
|
||
|
||
# 严谨或严进严出模式统一由严谨服务处理
|
||
return create_stock_change_record_with_details(**common_kwargs)
|
||
|
||
def _build_products_payload(
|
||
self,
|
||
warehouse: basic_models.WareHouse,
|
||
*,
|
||
is_incoming: bool,
|
||
items: List[Dict[str, Any]],
|
||
) -> List[Dict[str, Any]]:
|
||
if not items:
|
||
raise ValueError('产品明细不能为空')
|
||
|
||
mode = warehouse.mode
|
||
if mode == basic_models.WareHouseModeEnum.UNRESTRICTED:
|
||
return self._build_relaxed_items(items)
|
||
|
||
if mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT and not is_incoming:
|
||
return self._build_restrict_out_items(items)
|
||
|
||
# 严谨模式 + 严进严出入库复用数量列表
|
||
return self._build_strict_items(items)
|
||
|
||
@staticmethod
|
||
def _build_strict_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
payload: List[Dict[str, Any]] = []
|
||
for item in items:
|
||
product_id = item.get('product_id')
|
||
quantities = item.get('quantities') or []
|
||
if not product_id or not quantities:
|
||
raise ValueError('严谨模式需要提供 product_id 与 quantities 列表')
|
||
payload.append({'product': product_id, 'quantity': quantities})
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _build_relaxed_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
payload: List[Dict[str, Any]] = []
|
||
for item in items:
|
||
product_id = item.get('product_id')
|
||
total_value = item.get('value')
|
||
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')
|
||
|
||
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
|
||
def _build_restrict_out_items(items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
payload: List[Dict[str, Any]] = []
|
||
for item in items:
|
||
product_id = item.get('product_id')
|
||
consume_ids = item.get('consume_detail_ids') or []
|
||
if not product_id or not consume_ids:
|
||
raise ValueError('严进严出出库需要提供 product_id 与 consume_detail_ids')
|
||
payload.append({
|
||
'product': product_id,
|
||
'consume_with': consume_ids,
|
||
})
|
||
return payload
|
||
|
||
|
||
def _aggregate_transfer_details(details: List[models.StockChangeDetail]) -> Dict[int, Dict[str, Any]]:
|
||
aggregated: Dict[int, Dict[str, Any]] = {}
|
||
for detail in details:
|
||
product_id = detail.product_id
|
||
if product_id not in aggregated:
|
||
aggregated[product_id] = {
|
||
'product': detail.product,
|
||
'unit': detail.unit,
|
||
'total': Decimal('0'),
|
||
'num_of_rolls': 0,
|
||
}
|
||
aggregated[product_id]['total'] += Decimal(detail.quantity)
|
||
aggregated[product_id]['num_of_rolls'] += 1
|
||
return aggregated
|
||
|
||
|
||
def create_transfer_order(
|
||
*,
|
||
merchant: basic_models.Merchant,
|
||
created_by,
|
||
operator: basic_models.Employee,
|
||
from_warehouse_id: int,
|
||
to_warehouse_id: int,
|
||
items: List[Dict[str, Any]],
|
||
transfer_date=None,
|
||
remarks: str | None = '',
|
||
request_id: str | None = None,
|
||
) -> models.TransferOrder:
|
||
if not merchant:
|
||
raise ValueError('必须提供商户信息')
|
||
if operator is None:
|
||
raise ValueError('必须提供调拨经办人')
|
||
if operator.merchant_id != merchant.id:
|
||
raise ValueError('经办人不属于当前商户')
|
||
if not items:
|
||
raise ValueError('调拨产品明细不能为空')
|
||
if not from_warehouse_id or not to_warehouse_id:
|
||
raise ValueError('必须提供调出仓和调入仓')
|
||
if from_warehouse_id == to_warehouse_id:
|
||
raise ValueError('调入仓与调出仓不能相同')
|
||
|
||
transfer_date = transfer_date or timezone.now().date()
|
||
|
||
if request_id:
|
||
existing = models.TransferOrder.objects.filter(merchant=merchant, request_id=request_id).first()
|
||
if existing:
|
||
return existing
|
||
|
||
try:
|
||
from_warehouse = basic_models.WareHouse.objects.get(id=from_warehouse_id, merchant=merchant)
|
||
except basic_models.WareHouse.DoesNotExist:
|
||
raise ValueError('调出仓不存在或不属于当前商户')
|
||
|
||
try:
|
||
to_warehouse = basic_models.WareHouse.objects.get(id=to_warehouse_id, merchant=merchant)
|
||
except basic_models.WareHouse.DoesNotExist:
|
||
raise ValueError('调入仓不存在或不属于当前商户')
|
||
|
||
if from_warehouse.mode != to_warehouse.mode:
|
||
raise ValueError('调拨要求调出仓与调入仓的出入库模式一致')
|
||
if from_warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT:
|
||
raise ValueError('调拨暂不支持严进严出模式,请先转为其他模式')
|
||
|
||
flow_service = StockFlowService(merchant=merchant, created_by=created_by)
|
||
|
||
with transaction.atomic():
|
||
transfer_order = models.TransferOrder.objects.create(
|
||
merchant=merchant,
|
||
from_warehouse=from_warehouse,
|
||
to_warehouse=to_warehouse,
|
||
operator=operator,
|
||
created_by=created_by,
|
||
transfer_date=transfer_date,
|
||
status=models.TransferOrderStatusEnum.DRAFT,
|
||
remarks=remarks or '',
|
||
mode=from_warehouse.mode,
|
||
request_id=request_id,
|
||
)
|
||
|
||
out_record, out_details, _ = flow_service.stock_out(
|
||
warehouse_id=from_warehouse.id,
|
||
source_type=models.StockChangeSourceEnum.TRANSPORT_OUT,
|
||
source_id=transfer_order.id,
|
||
items=items,
|
||
)
|
||
in_record, _, _ = flow_service.stock_in(
|
||
warehouse_id=to_warehouse.id,
|
||
source_type=models.StockChangeSourceEnum.TRANSPORT_IN,
|
||
source_id=transfer_order.id,
|
||
items=items,
|
||
)
|
||
|
||
make_stock_change_completed(out_record)
|
||
make_stock_change_completed(in_record)
|
||
|
||
aggregated = _aggregate_transfer_details(out_details)
|
||
transfer_items = [
|
||
models.TransferOrderItem(
|
||
transfer_order=transfer_order,
|
||
product=data['product'],
|
||
unit=data['unit'],
|
||
total_quantity=data['total'],
|
||
num_of_rolls=data['num_of_rolls'],
|
||
)
|
||
for data in aggregated.values()
|
||
]
|
||
if transfer_items:
|
||
models.TransferOrderItem.objects.bulk_create(transfer_items)
|
||
|
||
transfer_order.outgoing_record = out_record
|
||
transfer_order.incoming_record = in_record
|
||
transfer_order.status = models.TransferOrderStatusEnum.COMPLETED
|
||
transfer_order.save(update_fields=['outgoing_record', 'incoming_record', 'status'])
|
||
|
||
return transfer_order
|
||
|
||
|
||
def find_inventory(product_id: int, warehouse_id: int) -> models.Inventory | None:
|
||
"""根据产品ID和仓库ID查找库存记录"""
|
||
|
||
try:
|
||
inventory = models.Inventory.objects.get(
|
||
product_id=product_id,
|
||
warehouse_id=warehouse_id,
|
||
)
|
||
return inventory
|
||
except models.Inventory.DoesNotExist:
|
||
return None
|
||
|
||
|
||
def create_stock_snapshot(stock_change_detail: models.StockChangeDetail, inventory: models.Inventory) -> models.StockSnapshot:
|
||
"""创建库存快照记录"""
|
||
|
||
if not stock_change_detail or stock_change_detail.id is None:
|
||
raise ValueError("无效的库存变动明细记录")
|
||
|
||
delta = stock_change_detail.quantity if stock_change_detail.stock_change_record.is_incoming else -stock_change_detail.quantity
|
||
|
||
# 必须在库存变动发生后创建快照
|
||
snapshot = models.StockSnapshot(
|
||
merchant_id=stock_change_detail.merchant_id,
|
||
product_id=inventory.product_id,
|
||
warehouse_id=inventory.warehouse_id,
|
||
delta=delta,
|
||
quantity_before=inventory.quantity - delta,
|
||
quantity_after=inventory.quantity,
|
||
stock_change_record_id=stock_change_detail.stock_change_record.id,
|
||
unit=stock_change_detail.unit,
|
||
num_of_rolls=inventory.num_of_rolls,
|
||
)
|
||
snapshot.save()
|
||
logger.info(
|
||
f'创建库存快照,库存ID: {inventory.id}, 数量: {inventory.quantity}, '
|
||
f'匹数: {inventory.num_of_rolls}'
|
||
)
|
||
return snapshot
|
||
|
||
|
||
def make_stock_change_completed(stock_change_record: models.StockChangeRecord) -> bool:
|
||
"""处理库存变动完成后的逻辑, 实际扣减库存也在这里发生"""
|
||
# TODO: 这里可以添加事务处理以确保数据一致性
|
||
|
||
if not stock_change_record or stock_change_record.id is None:
|
||
# 无效的库存变动记录
|
||
return False
|
||
|
||
if stock_change_record.is_finished:
|
||
return True
|
||
|
||
|
||
# TODO: 逻辑更改 = 出入库单现在不记录库存总量了
|
||
# 需要根据产品分别统计变动数量(要考虑不同单位)
|
||
|
||
for detail in stock_change_record.details.all():
|
||
detail: models.StockChangeDetail
|
||
|
||
logger.info(
|
||
f'处理库存变动明细,产品ID: {detail.product_id}, '
|
||
f'数量: {detail.quantity}, 单位: {detail.get_unit_display()}'
|
||
)
|
||
|
||
inventory_record = find_inventory(
|
||
product_id=detail.product_id,
|
||
warehouse_id=stock_change_record.warehouse_id,
|
||
)
|
||
|
||
# 确定方向
|
||
positive = 1 if stock_change_record.is_incoming else -1
|
||
|
||
if inventory_record:
|
||
# 更新现有库存记录
|
||
inventory_record.quantity += detail.quantity * positive
|
||
inventory_record.num_of_rolls += positive
|
||
logger.info(
|
||
f'更新库存记录 {inventory_record.id},新数量: {inventory_record.quantity}, '
|
||
f'新匹数: {inventory_record.num_of_rolls}'
|
||
)
|
||
else:
|
||
# 创建新库存记录
|
||
inventory_record = models.Inventory(
|
||
merchant_id=stock_change_record.merchant_id,
|
||
product_id=detail.product_id,
|
||
warehouse_id=stock_change_record.warehouse_id,
|
||
quantity=detail.quantity * positive,
|
||
num_of_rolls=1 if stock_change_record.is_incoming else -1,
|
||
)
|
||
logger.info(
|
||
f'创建新库存记录 {inventory_record.id},数量: {inventory_record.quantity}, '
|
||
f'匹数: {inventory_record.num_of_rolls}'
|
||
)
|
||
inventory_record.save()
|
||
# 发送库存变动通知
|
||
push_simple_message_with_object_id(
|
||
event_type='stock_change',
|
||
message=f'产品ID {detail.product_id} 位于 {stock_change_record.warehouse_id} 的库存已更新',
|
||
object_id=inventory_record.id
|
||
)
|
||
|
||
# 无论库存记录原本是否存在都创建库存快照
|
||
create_stock_snapshot(detail, inventory_record)
|
||
|
||
# 所有明细处理完毕,标记库存变动记录为已完成
|
||
stock_change_record.is_finished = True
|
||
stock_change_record.finished_at = timezone.now()
|
||
stock_change_record.save()
|
||
logger.info(
|
||
f'库存变动记录 {stock_change_record.id} 标记为已完成。'
|
||
)
|
||
return True
|
||
|
||
|
||
def get_frozen_stock_count(product_id: int, warehouse_id: int) -> int:
|
||
"""获取指定产品在指定仓库的冻结库存数量"""
|
||
total_frozen = models.StockFreeze.objects.filter(
|
||
product_id=product_id,
|
||
warehouse_id=warehouse_id,
|
||
status=models.StockFreezeStatusEnum.FROZEN
|
||
).aggregate(total=Sum('quantity'))['total'] or 0
|
||
return total_frozen
|