1
0
forked from erp-dev/erp
Files
erpnew/stock/services.py
2026-06-12 13:57:21 +08:00

929 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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