forked from erp-dev/erp
feat: completed
This commit is contained in:
178
cost/services.py
Normal file
178
cost/services.py
Normal file
@@ -0,0 +1,178 @@
|
||||
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
|
||||
]
|
||||
Reference in New Issue
Block a user