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)