1
0
forked from erp-dev/erp

feat: completed

This commit is contained in:
2026-06-09 11:15:02 +08:00
parent 3b2aacaa5c
commit 65564f772a
20 changed files with 2444 additions and 5 deletions

178
cost/services.py Normal file
View 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
]