forked from erp-dev/erp
178 lines
5.1 KiB
Python
178 lines
5.1 KiB
Python
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,
|
||
amount: Decimal,
|
||
occurred_at: date,
|
||
operator: Employee | None = None,
|
||
image1=None,
|
||
image2=None,
|
||
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,
|
||
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,
|
||
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
|
||
] |