forked from erp-dev/erp
372 lines
13 KiB
Python
372 lines
13 KiB
Python
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)
|
||
|