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

372 lines
13 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 __future__ import annotations
import logging
from datetime import date, datetime
from decimal import Decimal, InvalidOperation
from typing import Any, Dict, List, Tuple
from django.contrib.auth import get_user_model
from django.db import transaction
from basic_info import models as basic_info_models
from stock import models as stock_models
from stock.services import StockFlowService
from basic_info.services import MerchantSettingService
from . import models
from .tasks import create_purchase_order_stock_entries
logger = logging.getLogger(__name__)
def _normalize_order_date(value) -> date:
if isinstance(value, date):
return value
if isinstance(value, datetime):
return value.date()
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError('order_date 格式不正确,应为 YYYY-MM-DD') from exc
raise ValueError('order_date 格式不正确')
def create_purchase_order(
*,
merchant: basic_info_models.Merchant,
supplier: basic_info_models.Supplier,
order_date,
warehouse: basic_info_models.WareHouse,
operator: basic_info_models.Employee,
items: List[Dict[str, Any]],
remarks: str | None = '',
created_by=None,
) -> models.PurchaseOrder:
"""
创建采购订单并触发异步创建入库单任务。
Args:
merchant: 采购单所属商户
supplier: 供应商
order_date: 订单日期 (date)
warehouse: 入库仓库实例
operator: 经办人
items: 产品明细,字段会根据仓库模式校验
remarks: 备注
created_by: 创建者用户(可选,用于 stock 记录中的 created_by
"""
if not items:
raise ValueError('items 不能为空')
normalized_date = _normalize_order_date(order_date)
purchase_items, stock_flow_items = _normalize_purchase_items(
merchant=merchant,
warehouse=warehouse,
items=items,
)
with transaction.atomic():
purchase_order = models.PurchaseOrder.objects.create(
merchant=merchant,
supplier=supplier,
purchase_date=normalized_date,
operator=operator,
warehouse=warehouse,
remarks=remarks,
)
bulk_objects = [
models.PurchaseOrderItem(
purchase_order=purchase_order,
product=item_data['product'],
price=item_data['price'],
color=item_data.get('color'),
quantity=item_data['quantity'],
unit=item_data['unit'],
spec=item_data.get('spec'),
empty_diff_percent=item_data['empty_diff_percent'],
quantity_of_rolls=item_data.get('quantity_of_rolls'),
num_of_rolls=item_data['num_of_rolls'],
batch_number=item_data.get('batch_number'),
remarks=item_data.get('remarks'),
)
for item_data in purchase_items
]
models.PurchaseOrderItem.objects.bulk_create(bulk_objects)
purchase_order.refresh_from_db()
return purchase_order
def review_purchase_order(
*,
purchase_order: models.PurchaseOrder | None = None,
purchase_order_id: int | None = None,
target_status: models.PurchaseOrderStatusEnum,
reviewed_by=None,
) -> models.PurchaseOrder:
"""
审批或作废采购单。
当目标状态为 APPROVED 且开启自动入库任务时,将触发入库 Celery 任务;
当目标状态为 CANCELLED 时,会在更新状态前确认未生成任何出入库单。
"""
order = _resolve_purchase_order_instance(purchase_order, purchase_order_id)
if target_status not in {
models.PurchaseOrderStatusEnum.APPROVED,
models.PurchaseOrderStatusEnum.CANCELLED,
}:
raise ValueError('target_status 只能是 APPROVED 或 CANCELLED')
if order.status == target_status:
return order
if target_status == models.PurchaseOrderStatusEnum.APPROVED:
if order.status == models.PurchaseOrderStatusEnum.CANCELLED:
raise ValueError('作废状态的采购单无法再次审批')
return _approve_purchase_order(order, reviewed_by)
return _cancel_purchase_order(order)
def _normalize_purchase_items(
*,
merchant: basic_info_models.Merchant,
warehouse: basic_info_models.WareHouse,
items: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
"""
根据仓库模式校验采购明细,并返回
- purchase_items: 用于创建 PurchaseOrderItem
- stock_flow_items: 传递给 StockFlowService 的 items 结构
"""
purchase_items: List[Dict[str, Any]] = []
stock_flow_items: List[Dict[str, Any]] = []
warehouse_mode = warehouse.mode
for index, raw_item in enumerate(items):
product_id = raw_item.get('product_id')
if not product_id:
raise ValueError(f'items[{index}].product_id 不能为空')
try:
product = basic_info_models.Product.objects.get(id=product_id, merchant=merchant)
except basic_info_models.Product.DoesNotExist as exc:
raise ValueError(f'产品 {product_id} 不存在或不属于当前商户') from exc
price = _to_decimal(raw_item.get('price', '0'), f'items[{index}].price')
empty_diff_percent = _to_decimal(raw_item.get('empty_diff_percent', '0'), f'items[{index}].empty_diff_percent')
color = raw_item.get('color')
batch_number = raw_item.get('batch_number')
remarks = raw_item.get('remarks')
spec = raw_item.get('spec')
unit = raw_item.get('unit') or product.get_unit_display() or ''
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
if 'numbers' in raw_item and raw_item['numbers']:
raise ValueError(f'仓库为宽进模式items[{index}] 不应提供 numbers')
quantity = _to_positive_int(raw_item.get('quantity'), f'items[{index}].quantity')
num_of_rolls = _to_positive_int(raw_item.get('num_of_rolls'), f'items[{index}].num_of_rolls')
quantity_of_rolls = None
stock_flow_items.append({
'product_id': product.id,
'value': str(quantity),
'num_of_rolls': num_of_rolls,
})
else:
numbers = raw_item.get('numbers')
if not numbers or not isinstance(numbers, list):
raise ValueError(f'仓库为严进模式items[{index}] 需要提供 numbers 数组')
normalized_numbers = [
str(_to_positive_int(value, f'items[{index}].numbers[{pos}]'))
for pos, value in enumerate(numbers)
]
num_of_rolls = len(normalized_numbers)
quantity = sum(int(val) for val in normalized_numbers)
quantity_of_rolls = ','.join(normalized_numbers)
stock_flow_items.append({
'product_id': product.id,
'quantities': normalized_numbers,
})
purchase_items.append({
'product': product,
'price': price,
'color': color,
'quantity': quantity,
'unit': unit,
'empty_diff_percent': empty_diff_percent,
'quantity_of_rolls': quantity_of_rolls,
'num_of_rolls': num_of_rolls,
'batch_number': batch_number,
'remarks': remarks,
'spec': spec,
})
return purchase_items, stock_flow_items
def _approve_purchase_order(
purchase_order: models.PurchaseOrder,
reviewed_by,
) -> models.PurchaseOrder:
stock_flow_items = _build_stock_flow_items_from_order(purchase_order)
with transaction.atomic():
purchase_order.status = models.PurchaseOrderStatusEnum.APPROVED
purchase_order.save(update_fields=['status', 'updated_at'])
created_by_id = getattr(reviewed_by, 'id', None)
if _auto_stock_task_enabled(purchase_order.merchant):
logger.info('审批通过采购单 %s,触发入库任务', purchase_order.id)
create_purchase_order_stock_entries.delay(
purchase_order_id=purchase_order.id,
warehouse_id=purchase_order.warehouse_id,
items=stock_flow_items,
created_by_id=created_by_id,
)
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
return purchase_order
def _cancel_purchase_order(purchase_order: models.PurchaseOrder) -> models.PurchaseOrder:
if _purchase_order_has_stock_records(purchase_order):
raise ValueError('采购单已生成出入库记录,无法作废')
with transaction.atomic():
purchase_order.status = models.PurchaseOrderStatusEnum.CANCELLED
purchase_order.save(update_fields=['status', 'updated_at'])
purchase_order.refresh_from_db(fields=['status', 'updated_at'])
return purchase_order
def create_purchase_order_stock_entries_sync(
*,
purchase_order_id: int,
warehouse_id: int,
items: List[Dict[str, Any]],
created_by_id: int | None = None,
) -> Dict[str, Any]:
"""
根据采购单生成入库记录。
"""
try:
purchase_order = models.PurchaseOrder.objects.select_related('merchant').get(id=purchase_order_id)
except models.PurchaseOrder.DoesNotExist:
logger.error('PurchaseOrder %s 不存在,无法创建入库单', purchase_order_id)
return {'error': 'purchase_order_not_found', 'purchase_order_id': purchase_order_id}
merchant = purchase_order.merchant
created_by = None
if created_by_id:
UserModel = get_user_model()
created_by = UserModel.objects.filter(id=created_by_id).first()
service = StockFlowService(merchant=merchant, created_by=created_by)
record, details, created_count = service.stock_in(
warehouse_id=warehouse_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
items=items,
)
payload = {
'purchase_order_id': purchase_order.id,
'stock_change_record_id': getattr(record, 'id', None),
'created_details_count': created_count,
}
logger.info('采购单 %s 入库任务完成: %s', purchase_order.id, payload)
return payload
def _build_stock_flow_items_from_order(purchase_order: models.PurchaseOrder) -> List[Dict[str, Any]]:
"""
根据采购单明细还原 StockFlowService 所需的 items 结构。
"""
warehouse_mode = purchase_order.warehouse.mode
items_payload: List[Dict[str, Any]] = []
order_items = purchase_order.items.all()
if not order_items:
raise ValueError('采购单没有任何明细,无法生成入库记录')
if warehouse_mode == basic_info_models.WareHouseModeEnum.UNRESTRICTED:
for item in order_items:
items_payload.append({
'product_id': item.product_id,
'value': str(item.quantity),
'num_of_rolls': item.num_of_rolls,
})
return items_payload
# 严进 / 严进严出模式
for item in order_items:
raw_numbers = (item.quantity_of_rolls or '').split(',')
normalized = [value.strip() for value in raw_numbers if value.strip()]
if not normalized:
raise ValueError('严进仓采购单缺少 numbers 数据,无法生成入库记录')
items_payload.append({
'product_id': item.product_id,
'quantities': normalized,
})
return items_payload
def _auto_stock_task_enabled(merchant: basic_info_models.Merchant) -> bool:
try:
setting = MerchantSettingService.get_setting(
merchant,
basic_info_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS,
)
except basic_info_models.MerchantSetting.DoesNotExist:
logger.warning('商户 %s 未配置 auto_create_stock_change_tasks默认关闭', merchant.id)
return False
return setting.value is True
def _purchase_order_has_stock_records(purchase_order: models.PurchaseOrder) -> bool:
return stock_models.StockChangeRecord.objects.filter(
merchant_id=purchase_order.merchant_id,
source_type=stock_models.StockChangeSourceEnum.PURCHASE,
source_id=purchase_order.id,
).exists()
def _resolve_purchase_order_instance(
purchase_order: models.PurchaseOrder | None,
purchase_order_id: int | None,
) -> models.PurchaseOrder:
if purchase_order is None and purchase_order_id is None:
raise ValueError('必须提供 purchase_order 或 purchase_order_id')
if purchase_order is not None:
purchase_order_id = purchase_order.id
return models.PurchaseOrder.objects.select_related('merchant', 'warehouse').prefetch_related('items').get(
id=purchase_order_id
)
def _to_decimal(value, field_name: str) -> Decimal:
try:
return Decimal(str(value))
except (InvalidOperation, TypeError) as exc:
raise ValueError(f'{field_name} 必须是合法数值') from exc
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)