1
0
forked from erp-dev/erp
Files
erpnew/stock/services.py

607 lines
22 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
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 _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)
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 _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')
unit_size = item.get('num_of_rolls', 1)
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,
}
})
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 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