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'], 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) created_by_id = getattr(created_by, 'id', None) if MerchantSettingService.get_setting(merchant, basic_info_models.MerchantSettingKeyEnum.AUTO_CREATE_STOCK_CHANGE_TASKS).value is True: logger.info('自动创建出入库记录任务已开启,创建入库记录任务') create_purchase_order_stock_entries.delay( purchase_order_id=purchase_order.id, warehouse_id=warehouse.id, items=stock_flow_items, created_by_id=created_by_id, ) return purchase_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') 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, }) return purchase_items, stock_flow_items 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 _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)