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 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): if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN: return if warehouse.mode == basic_models.WareHouseModeEnum.RESTRICT_IN_OUT: _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_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('仓库不属于当前商户') _ensure_strict_mode(warehouse) 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) for product_data in products: product_id = product_data.get('product') quantities = product_data.get('quantity', []) 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 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 _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 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 = quantity_data.get('value') 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: raise ValueError(f'产品ID {product_id} 不存在') if product.merchant_id != merchant.id: raise ValueError(f'产品ID {product_id} 不属于当前商户') 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) 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 rolls_delta = 1 if stock_change_detail.stock_change_record.is_incoming else -1 # 必须在库存变动发生后创建快照 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 + rolls_delta, ) 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