from __future__ import annotations import logging from datetime import date from decimal import Decimal from typing import Any from django.db.models import Sum from basic_info.models import Employee, Merchant from . import models as cost_models logger = logging.getLogger(__name__) def ensure_category( *, merchant: Merchant, category_key: str, category_name: str, description: str = '', ) -> cost_models.CostCategory: """按 unique_key 查找或创建支出类目。 这是 Provider 可以反向调用的基础能力。 """ normalized_key = str(category_key or '').strip() normalized_name = str(category_name or '').strip() if not normalized_key: raise ValueError('category_key 不能为空') if not normalized_name: raise ValueError('category_name 不能为空') category = cost_models.CostCategory.objects.filter( merchant=merchant, unique_key=normalized_key, ).first() if category: return category return cost_models.CostCategory.objects.create( merchant=merchant, unique_key=normalized_key, name=normalized_name, description=description, ) def create_cost_entry( *, merchant: Merchant, category: cost_models.CostCategory, occurred_at: date, amount: Decimal | None = None, operator: Employee | None = None, image1=None, image2=None, unit_amount: Decimal | None = None, quantity: Decimal | None = None, unit_name: str = '', source_module: str = '', source_id: str = '', remarks: str = '', ) -> cost_models.CostEntry: """创建一条支出记录。""" if category.merchant_id != merchant.id: raise ValueError('category 与 merchant 不属于同一商户') if operator is not None and operator.merchant_id != merchant.id: raise ValueError('operator 与 merchant 不属于同一商户') return cost_models.CostEntry.objects.create( merchant=merchant, category=category, amount=amount, unit_amount=unit_amount, quantity=quantity, unit_name=unit_name or '', occurred_at=occurred_at, operator=operator, image1=image1, image2=image2, source_module=source_module or '', source_id=source_id or '', remarks=remarks or '', ) def collect_from_provider( provider: cost_models.CostProviderPort, *, merchant: Merchant, start_date: date, end_date: date, ) -> dict[str, Any]: """从 Provider 采集成本数据,返回写入统计。""" entries = provider.get_cost_entries(merchant=merchant, start_date=start_date, end_date=end_date) if not isinstance(entries, list): raise TypeError(f'Provider.get_cost_entries 必须返回 list,实际返回 {type(entries).__name__}') created_count = 0 errors: list[dict[str, Any]] = [] for index, entry in enumerate(entries, start=1): if not isinstance(entry, cost_models.CostEntryInput): errors.append({ 'index': index, 'error': f'条目不是 CostEntryInput 实例,实际类型: {type(entry).__name__}', }) continue try: category = ensure_category( merchant=merchant, category_key=entry.category_key, category_name=entry.category_name, ) except ValueError as exc: errors.append({ 'index': index, 'source_module': entry.source_module, 'source_id': entry.source_id, 'error': str(exc), }) continue create_cost_entry( merchant=merchant, category=category, amount=entry.amount, unit_amount=entry.unit_amount, quantity=entry.quantity, unit_name=entry.unit_name, occurred_at=entry.occurred_at, operator=None, # provider 采集的记录没有经办人 source_module=entry.source_module, source_id=entry.source_id, remarks=entry.remarks, ) created_count += 1 return { 'provider_category_key': getattr(provider, 'category_key', ''), 'total_seen': len(entries), 'created_count': created_count, 'error_count': len(errors), 'errors': errors, } def aggregate_by_category( *, merchant: Merchant, start_date: date | None = None, end_date: date | None = None, ) -> list[dict[str, Any]]: """按支出类目汇总。 返回按 total_amount 降序排列的结果列表。 """ queryset = cost_models.CostEntry.objects.filter(merchant=merchant) if start_date is not None: queryset = queryset.filter(occurred_at__gte=start_date) if end_date is not None: queryset = queryset.filter(occurred_at__lte=end_date) aggregated = ( queryset.values( 'category_id', 'category__name', 'category__unique_key', ) .annotate(total_amount=Sum('amount'), entry_count=Sum(1)) .order_by('-total_amount') ) return [ { 'category_id': row['category_id'], 'category_name': row['category__name'], 'category_key': row['category__unique_key'], 'total_amount': str(row['total_amount']), 'entry_count': row['entry_count'], } for row in aggregated ]