1
0
forked from erp-dev/erp
Files
erpnew/cost/services.py
2026-06-09 11:15:02 +08:00

178 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
]