From 65564f772a883630b28d14bfa59d9b8ee304aaa5 Mon Sep 17 00:00:00 2001 From: colaftc Date: Tue, 9 Jun 2026 11:15:02 +0800 Subject: [PATCH] feat: completed --- .coverage | Bin 114688 -> 53248 bytes api_v2/test_cost_api.py | 416 ++++++++++++++++++++++ api_v2/urls.py | 12 + api_v2/views/__init__.py | 12 + api_v2/views/cost.py | 310 +++++++++++++++++ cost/__init__.py | 0 cost/admin.py | 22 ++ cost/apps.py | 7 + cost/migrations/0001_initial.py | 58 +++ cost/migrations/__init__.py | 0 cost/models.py | 151 ++++++++ cost/services.py | 178 ++++++++++ cost/tasks.py | 1 + cost/tests/__init__.py | 0 cost/tests/test_models.py | 145 ++++++++ cost/tests/test_services.py | 445 ++++++++++++++++++++++++ docs/cost/api.md | 329 ++++++++++++++++++ docs/cost/design.md | 180 ++++++++++ docs/cost/inventory-cost-feasibility.md | 172 +++++++++ flower/settings.py | 11 +- 20 files changed, 2444 insertions(+), 5 deletions(-) create mode 100644 api_v2/test_cost_api.py create mode 100644 api_v2/views/cost.py create mode 100644 cost/__init__.py create mode 100644 cost/admin.py create mode 100644 cost/apps.py create mode 100644 cost/migrations/0001_initial.py create mode 100644 cost/migrations/__init__.py create mode 100644 cost/models.py create mode 100644 cost/services.py create mode 100644 cost/tasks.py create mode 100644 cost/tests/__init__.py create mode 100644 cost/tests/test_models.py create mode 100644 cost/tests/test_services.py create mode 100644 docs/cost/api.md create mode 100644 docs/cost/design.md create mode 100644 docs/cost/inventory-cost-feasibility.md diff --git a/.coverage b/.coverage index 0e299dee27dcb15da7356935e9bf4e2af252a7b6..fa0c08ea4fc888da927a887087135664d6c9ec4d 100644 GIT binary patch delta 960 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 L6b39}AGz/', ProductionAssignmentDetailView.as_view(), name='api_v2_mes_production_assignment_detail'), path('shipment-delivery-photos/', ShipmentDeliveryPhotoListCreateView.as_view(), name='api_v2_shipment_delivery_photo_list_create'), path('shipment-delivery-photos//', ShipmentDeliveryPhotoDetailView.as_view(), name='api_v2_shipment_delivery_photo_detail'), + path('cost-categories/', CostCategoryListCreateView.as_view(), name='api_v2_cost_category_list_create'), + path('cost-categories//', CostCategoryDetailView.as_view(), name='api_v2_cost_category_detail'), + path('cost-entries/', CostEntryListCreateView.as_view(), name='api_v2_cost_entry_list_create'), + path('cost-entries//', CostEntryDetailView.as_view(), name='api_v2_cost_entry_detail'), + path('cost-summary/by-category/', CostSummaryByCategoryView.as_view(), name='api_v2_cost_summary_by_category'), path('wecom/binduser/', WecomBindingView.as_view(), name='api_v2_wecom_binduser'), path('wecom/login/', WecomLoginView.as_view(), name='api_v2_wecom_login'), ] diff --git a/api_v2/views/__init__.py b/api_v2/views/__init__.py index 770d896..9a8e97f 100644 --- a/api_v2/views/__init__.py +++ b/api_v2/views/__init__.py @@ -47,6 +47,13 @@ from .ai import ( AgentMesProductionAssignmentListView, ) from .shipment_delivery_photo import ShipmentDeliveryPhotoDetailView, ShipmentDeliveryPhotoListCreateView +from .cost import ( + CostCategoryDetailView, + CostCategoryListCreateView, + CostEntryDetailView, + CostEntryListCreateView, + CostSummaryByCategoryView, +) from .wecom import WecomBindingView, WecomLoginView __all__ = [ @@ -89,4 +96,9 @@ __all__ = [ 'AgentMesProductionAssignmentListView', 'ShipmentDeliveryPhotoListCreateView', 'ShipmentDeliveryPhotoDetailView', + 'CostCategoryListCreateView', + 'CostCategoryDetailView', + 'CostEntryListCreateView', + 'CostEntryDetailView', + 'CostSummaryByCategoryView', ] diff --git a/api_v2/views/cost.py b/api_v2/views/cost.py new file mode 100644 index 0000000..80ef889 --- /dev/null +++ b/api_v2/views/cost.py @@ -0,0 +1,310 @@ +from datetime import date + +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, serializers, status +from rest_framework.parsers import FormParser, JSONParser, MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from basic_info.models import Merchant +from cost import models as cost_models +from cost import services as cost_services + + +def _get_employee(request): + employee = getattr(request.user, 'employee', None) + if employee is None: + raise serializers.ValidationError('当前用户未关联员工') + return employee + + +def _category_payload(category: cost_models.CostCategory) -> dict: + return { + 'id': category.id, + 'merchant_id': category.merchant_id, + 'unique_key': category.unique_key, + 'name': category.name, + 'parent_id': category.parent_id, + 'description': category.description, + 'created_at': category.created_at.isoformat().replace('+00:00', 'Z'), + 'updated_at': category.updated_at.isoformat().replace('+00:00', 'Z'), + } + + +def _entry_payload(entry: cost_models.CostEntry) -> dict: + return { + 'id': entry.id, + 'merchant_id': entry.merchant_id, + 'category_id': entry.category_id, + 'category_name': entry.category.name if entry.category_id else '', + 'amount': str(entry.amount), + 'occurred_at': entry.occurred_at.isoformat(), + 'operator_id': entry.operator_id, + 'image1': _image_url(entry.image1), + 'image2': _image_url(entry.image2), + 'source_module': entry.source_module or '', + 'source_id': entry.source_id or '', + 'remarks': entry.remarks or '', + 'created_at': entry.created_at.isoformat().replace('+00:00', 'Z'), + 'updated_at': entry.updated_at.isoformat().replace('+00:00', 'Z'), + } + + +def _image_url(image_field) -> str: + if not image_field: + return '' + try: + return image_field.url + except (ValueError, AttributeError): + return '' + + +# ==================== Category Write Serializers ==================== + + +class CategoryWriteSerializer(serializers.Serializer): + unique_key = serializers.CharField(max_length=100) + name = serializers.CharField(max_length=100) + parent_id = serializers.IntegerField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True, default='') + + +class CategoryUpdateSerializer(serializers.Serializer): + unique_key = serializers.CharField(required=False, max_length=100) + name = serializers.CharField(required=False, max_length=100) + parent_id = serializers.IntegerField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True) + + +# ==================== Entry Write Serializers ==================== + + +class EntryWriteSerializer(serializers.Serializer): + category_id = serializers.IntegerField(min_value=1) + amount = serializers.DecimalField(max_digits=15, decimal_places=2, min_value=0) + occurred_at = serializers.DateField() + image1 = serializers.ImageField(required=False, allow_null=True) + image2 = serializers.ImageField(required=False, allow_null=True) + source_module = serializers.CharField(required=False, allow_blank=True, default='') + source_id = serializers.CharField(required=False, allow_blank=True, default='') + remarks = serializers.CharField(required=False, allow_blank=True, default='') + + +class EntryUpdateSerializer(serializers.Serializer): + category_id = serializers.IntegerField(required=False, min_value=1) + amount = serializers.DecimalField(required=False, max_digits=15, decimal_places=2, min_value=0) + occurred_at = serializers.DateField(required=False) + image1 = serializers.ImageField(required=False, allow_null=True) + image2 = serializers.ImageField(required=False, allow_null=True) + source_module = serializers.CharField(required=False, allow_blank=True) + source_id = serializers.CharField(required=False, allow_blank=True) + remarks = serializers.CharField(required=False, allow_blank=True) + + +# ==================== Category Views ==================== + + +class CostCategoryListCreateView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + employee = _get_employee(request) + merchant_id = request.query_params.get('merchant_id') + queryset = cost_models.CostCategory.objects.filter( + merchant_id=merchant_id if merchant_id else employee.merchant_id, + ).order_by('name') + return Response([_category_payload(c) for c in queryset]) + + def post(self, request): + employee = _get_employee(request) + serializer = CategoryWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + category = cost_models.CostCategory.objects.create( + merchant=employee.merchant, + unique_key=data['unique_key'], + name=data['name'], + parent_id=data.get('parent_id'), + description=data.get('description', ''), + ) + return Response(_category_payload(category), status=status.HTTP_201_CREATED) + + +class CostCategoryDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request, category_id): + employee = _get_employee(request) + category = get_object_or_404( + cost_models.CostCategory, + id=category_id, + merchant_id=employee.merchant_id, + ) + return Response(_category_payload(category)) + + def put(self, request, category_id): + employee = _get_employee(request) + category = get_object_or_404( + cost_models.CostCategory, + id=category_id, + merchant_id=employee.merchant_id, + ) + serializer = CategoryUpdateSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + for field in ('unique_key', 'name', 'parent_id', 'description'): + if field in data: + setattr(category, field, data[field]) + category.save(update_fields=[k for k in data if k in data]) + return Response(_category_payload(category)) + + def delete(self, request, category_id): + employee = _get_employee(request) + category = get_object_or_404( + cost_models.CostCategory, + id=category_id, + merchant_id=employee.merchant_id, + ) + category.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +# ==================== Entry Views ==================== + + +class CostEntryListCreateView(APIView): + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser, FormParser, MultiPartParser] + + def get(self, request): + employee = _get_employee(request) + merchant_id = request.query_params.get('merchant_id') + category_id = request.query_params.get('category_id') + start = request.query_params.get('start') + end = request.query_params.get('end') + + queryset = cost_models.CostEntry.objects.filter( + merchant_id=merchant_id if merchant_id else employee.merchant_id, + ).select_related('category').order_by('-occurred_at', '-created_at') + + if category_id: + queryset = queryset.filter(category_id=category_id) + if start: + queryset = queryset.filter(occurred_at__gte=start) + if end: + queryset = queryset.filter(occurred_at__lte=end) + + return Response([_entry_payload(e) for e in queryset]) + + def post(self, request): + employee = _get_employee(request) + serializer = EntryWriteSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + category = get_object_or_404( + cost_models.CostCategory, + id=data['category_id'], + merchant_id=employee.merchant_id, + ) + + entry = cost_models.CostEntry.objects.create( + merchant=employee.merchant, + category=category, + amount=data['amount'], + occurred_at=data['occurred_at'], + operator=employee, + image1=data.get('image1'), + image2=data.get('image2'), + source_module=data.get('source_module', ''), + source_id=data.get('source_id', ''), + remarks=data.get('remarks', ''), + ) + return Response(_entry_payload(entry), status=status.HTTP_201_CREATED) + + +class CostEntryDetailView(APIView): + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser, FormParser, MultiPartParser] + + def get(self, request, entry_id): + employee = _get_employee(request) + entry = get_object_or_404( + cost_models.CostEntry.objects.select_related('category'), + id=entry_id, + merchant_id=employee.merchant_id, + ) + return Response(_entry_payload(entry)) + + def put(self, request, entry_id): + employee = _get_employee(request) + entry = get_object_or_404( + cost_models.CostEntry, + id=entry_id, + merchant_id=employee.merchant_id, + ) + serializer = EntryUpdateSerializer(data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data + + update_fields = [] + if 'category_id' in data: + entry.category = get_object_or_404( + cost_models.CostCategory, + id=data.pop('category_id'), + merchant_id=employee.merchant_id, + ) + update_fields.append('category') + for field, value in data.items(): + setattr(entry, field, value) + update_fields.append(field) + if update_fields: + entry.save(update_fields=update_fields) + entry.refresh_from_db() + return Response(_entry_payload(entry)) + + def delete(self, request, entry_id): + employee = _get_employee(request) + entry = get_object_or_404( + cost_models.CostEntry, + id=entry_id, + merchant_id=employee.merchant_id, + ) + entry.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +# ==================== Summary Views ==================== + + +class CostSummaryByCategoryView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + employee = _get_employee(request) + merchant_id = request.query_params.get('merchant_id') + start = request.query_params.get('start') + end = request.query_params.get('end') + + if merchant_id: + merchant = get_object_or_404(Merchant, id=merchant_id) + else: + merchant = employee.merchant + + start_date = date.fromisoformat(start) if start else None + end_date = date.fromisoformat(end) if end else None + + aggregated = cost_services.aggregate_by_category( + merchant=merchant, + start_date=start_date, + end_date=end_date, + ) + + return Response({ + 'merchant_id': merchant.id, + 'start_date': str(start_date) if start_date else None, + 'end_date': str(end_date) if end_date else None, + 'results': aggregated, + }) \ No newline at end of file diff --git a/cost/__init__.py b/cost/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cost/admin.py b/cost/admin.py new file mode 100644 index 0000000..963e2a5 --- /dev/null +++ b/cost/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from . import models + + +@admin.register(models.CostCategory) +class CostCategoryAdmin(admin.ModelAdmin): + list_display = ('id', 'merchant', 'unique_key', 'name', 'parent', 'created_at') + search_fields = ('name', 'unique_key') + list_filter = ('merchant', 'created_at') + ordering = ('merchant', 'name') + + +@admin.register(models.CostEntry) +class CostEntryAdmin(admin.ModelAdmin): + list_display = ( + 'id', 'merchant', 'category', 'amount', 'occurred_at', + 'operator', 'source_module', 'source_id', 'created_at', + ) + search_fields = ('category__name', 'source_module', 'source_id') + list_filter = ('merchant', 'category', 'occurred_at', 'source_module', 'created_at') + ordering = ('-occurred_at', '-created_at') \ No newline at end of file diff --git a/cost/apps.py b/cost/apps.py new file mode 100644 index 0000000..5fd34e4 --- /dev/null +++ b/cost/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CostConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'cost' + verbose_name = '成本模块' \ No newline at end of file diff --git a/cost/migrations/0001_initial.py b/cost/migrations/0001_initial.py new file mode 100644 index 0000000..d2ab2b1 --- /dev/null +++ b/cost/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.8 on 2026-06-06 08:01 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('basic_info', '0027_employee_wecom_user_id'), + ] + + operations = [ + migrations.CreateModel( + name='CostCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('unique_key', models.CharField(max_length=100, verbose_name='唯一标识键')), + ('name', models.CharField(max_length=100, verbose_name='类目名称')), + ('description', models.TextField(blank=True, null=True, verbose_name='描述')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cost_categories', to='basic_info.merchant', verbose_name='所属商户')), + ('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='children', to='cost.costcategory', verbose_name='父类目')), + ], + options={ + 'verbose_name': '支出类目', + 'verbose_name_plural': '支出类目', + 'ordering': ('merchant', 'name'), + 'unique_together': {('merchant', 'unique_key')}, + }, + ), + migrations.CreateModel( + name='CostEntry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('amount', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='金额')), + ('occurred_at', models.DateField(verbose_name='发生日期')), + ('image1', models.ImageField(blank=True, null=True, upload_to='cost/entries/', verbose_name='凭证图片')), + ('image2', models.ImageField(blank=True, null=True, upload_to='cost/entries/', verbose_name='备用凭证图片')), + ('source_module', models.CharField(blank=True, max_length=50, null=True, verbose_name='来源模块')), + ('source_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='来源记录ID')), + ('remarks', models.TextField(blank=True, null=True, verbose_name='备注')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='entries', to='cost.costcategory', verbose_name='支出类目')), + ('merchant', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cost_entries', to='basic_info.merchant', verbose_name='所属商户')), + ('operator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='cost_entries', to='basic_info.employee', verbose_name='经办人')), + ], + options={ + 'verbose_name': '支出明细', + 'verbose_name_plural': '支出明细', + 'ordering': ('-occurred_at', '-created_at'), + }, + ), + ] diff --git a/cost/migrations/__init__.py b/cost/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cost/models.py b/cost/models.py new file mode 100644 index 0000000..87ddec4 --- /dev/null +++ b/cost/models.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from decimal import Decimal +from typing import Protocol, runtime_checkable + +from django.db import models + +from flower.common import ModelBase + + +class CostCategory(ModelBase): + """支出类目""" + + merchant = models.ForeignKey( + 'basic_info.Merchant', + on_delete=models.PROTECT, + related_name='cost_categories', + verbose_name='所属商户', + ) + unique_key = models.CharField(max_length=100, verbose_name='唯一标识键') + name = models.CharField(max_length=100, verbose_name='类目名称') + parent = models.ForeignKey( + 'self', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='children', + verbose_name='父类目', + ) + description = models.TextField(null=True, blank=True, verbose_name='描述') + + class Meta: + verbose_name = '支出类目' + verbose_name_plural = '支出类目' + unique_together = ('merchant', 'unique_key') + ordering = ('merchant', 'name') + + def __str__(self): + return f'{self.name} ({self.unique_key})' + + +class CostEntry(ModelBase): + """支出明细""" + + merchant = models.ForeignKey( + 'basic_info.Merchant', + on_delete=models.PROTECT, + related_name='cost_entries', + verbose_name='所属商户', + ) + category = models.ForeignKey( + CostCategory, + on_delete=models.PROTECT, + related_name='entries', + verbose_name='支出类目', + ) + amount = models.DecimalField(max_digits=15, decimal_places=2, verbose_name='金额') + occurred_at = models.DateField(verbose_name='发生日期') + operator = models.ForeignKey( + 'basic_info.Employee', + on_delete=models.PROTECT, + null=True, + blank=True, + related_name='cost_entries', + verbose_name='经办人', + ) + image1 = models.ImageField(null=True, blank=True, upload_to='cost/entries/', verbose_name='凭证图片') + image2 = models.ImageField(null=True, blank=True, upload_to='cost/entries/', verbose_name='备用凭证图片') + source_module = models.CharField( + max_length=50, null=True, blank=True, verbose_name='来源模块', + ) + source_id = models.CharField( + max_length=100, null=True, blank=True, verbose_name='来源记录ID', + ) + remarks = models.TextField(null=True, blank=True, verbose_name='备注') + + class Meta: + verbose_name = '支出明细' + verbose_name_plural = '支出明细' + ordering = ('-occurred_at', '-created_at') + + def __str__(self): + return f'{self.category.name} {self.amount} ({self.occurred_at})' + + +# ==================== 六边形端口协议 ==================== + + +@dataclass(slots=True) +class CostEntryInput: + """Provider 返回的结构化成本条目""" + + category_key: str + category_name: str + amount: Decimal + occurred_at: date + source_module: str + source_id: str + remarks: str = '' + + +@runtime_checkable +class CostProviderPort(Protocol): + """ + 成本数据提供者接口。 + + 其它模块实现此接口即可被 cost.services.collect_from_provider() 统一采集。 + + Provider 可以通过 category_key 声明自己使用的类目: + - 如果 cost 模块已有匹配的 category_key,直接使用 + - 如果没有,cost 模块会调用 ensure_category() 自动创建 + + Provider 也可以反向依赖 cost 模块的基础能力: + - from cost.services import ensure_category + - 在实现 get_cost_entries 前主动调用 ensure_category 预建类目 + + 使用示例:: + + from cost.models import CostProviderPort, CostEntryInput + from cost.services import ensure_category + + class PrintingCostProvider: + + category_key = 'printing_consumables' + + def get_cost_entries(self, *, merchant, start_date, end_date): + ensure_category( + merchant=merchant, + category_key=self.category_key, + category_name='印刷耗材', + ) + return [ + CostEntryInput( + category_key=self.category_key, + category_name='印刷耗材', + amount=Decimal('100.00'), + occurred_at=date.today(), + source_module='printing', + source_id='PJ-001', + ), + ] + """ + + category_key: str + + def get_cost_entries( + self, *, merchant, start_date: date, end_date: date + ) -> list[CostEntryInput]: + ... \ No newline at end of file diff --git a/cost/services.py b/cost/services.py new file mode 100644 index 0000000..c3f76a9 --- /dev/null +++ b/cost/services.py @@ -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 + ] \ No newline at end of file diff --git a/cost/tasks.py b/cost/tasks.py new file mode 100644 index 0000000..4a3c500 --- /dev/null +++ b/cost/tasks.py @@ -0,0 +1 @@ +"""Celery 任务(当前版本预留,暂无定时任务)。""" \ No newline at end of file diff --git a/cost/tests/__init__.py b/cost/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cost/tests/test_models.py b/cost/tests/test_models.py new file mode 100644 index 0000000..ac07f2f --- /dev/null +++ b/cost/tests/test_models.py @@ -0,0 +1,145 @@ +"""Cost 模块模型测试""" +from datetime import date +from decimal import Decimal + +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.test import TestCase + +from basic_info import models as basic_models +from cost import models as cost_models + + +class CostCategoryModelTests(TestCase): + def setUp(self): + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', type=basic_models.MerchantTypeEnum.STORE, + ) + + def test_str_representation(self): + cat = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费', + ) + self.assertEqual(str(cat), '电费 (electricity)') + + def test_unique_together_merchant_key(self): + """同一商户 same unique_key 不可重复""" + cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费', + ) + with self.assertRaises(IntegrityError): + cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费2', + ) + + def test_different_merchants_same_key_allowed(self): + """不同商户 same unique_key 允许""" + other = basic_models.Merchant.objects.create( + name='其他商户', type=basic_models.MerchantTypeEnum.STORE, + ) + cat1 = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费', + ) + cat2 = cost_models.CostCategory.objects.create( + merchant=other, unique_key='electricity', name='电费', + ) + self.assertNotEqual(cat1.id, cat2.id) + + def test_parent_self_reference(self): + """父类目 self-FK""" + parent = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='utilities', name='公共事业', + ) + child = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费', + parent=parent, + ) + self.assertEqual(child.parent_id, parent.id) + self.assertEqual(list(parent.children.all()), [child]) + + def test_description_nullable(self): + cat = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='key', name='name', + ) + self.assertIsNone(cat.description) + + def test_ordering_by_merchant_then_name(self): + other = basic_models.Merchant.objects.create( + name='其他商户', type=basic_models.MerchantTypeEnum.STORE, + ) + cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='z', name='Z类目', + ) + cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='a', name='A类目', + ) + cost_models.CostCategory.objects.create( + merchant=other, unique_key='m', name='M类目', + ) + # ordering = ('merchant', 'name') — 同一个 merchant 内按 name 排序 + own = cost_models.CostCategory.objects.filter(merchant=self.merchant) + own_names = [c.name for c in own] + self.assertEqual(own_names, ['A类目', 'Z类目']) # name 升序 + + +class CostEntryModelTests(TestCase): + def setUp(self): + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', type=basic_models.MerchantTypeEnum.STORE, + ) + self.category = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='transport', name='运输费', + ) + + def test_str_representation(self): + entry = cost_models.CostEntry.objects.create( + merchant=self.merchant, + category=self.category, + amount=Decimal('150.00'), + occurred_at=date(2026, 6, 1), + ) + self.assertIn('运输费', str(entry)) + self.assertIn('150.00', str(entry)) + + def test_default_ordering_by_occurred_at_desc(self): + e1 = cost_models.CostEntry.objects.create( + merchant=self.merchant, category=self.category, + amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), + ) + e2 = cost_models.CostEntry.objects.create( + merchant=self.merchant, category=self.category, + amount=Decimal('200.00'), occurred_at=date(2026, 6, 10), + ) + entries = list(cost_models.CostEntry.objects.all()) + self.assertEqual(entries[0].id, e2.id) + self.assertEqual(entries[1].id, e1.id) + + def test_operator_nullable(self): + entry = cost_models.CostEntry.objects.create( + merchant=self.merchant, + category=self.category, + amount=Decimal('100.00'), + occurred_at=date(2026, 6, 1), + operator=None, + ) + self.assertIsNone(entry.operator_id) + + def test_source_fields_nullable(self): + entry = cost_models.CostEntry.objects.create( + merchant=self.merchant, + category=self.category, + amount=Decimal('100.00'), + occurred_at=date(2026, 6, 1), + ) + self.assertIsNone(entry.source_module) + self.assertIsNone(entry.source_id) + + def test_image_fields_nullable(self): + entry = cost_models.CostEntry.objects.create( + merchant=self.merchant, + category=self.category, + amount=Decimal('100.00'), + occurred_at=date(2026, 6, 1), + ) + self.assertFalse(bool(entry.image1)) + self.assertFalse(bool(entry.image2)) \ No newline at end of file diff --git a/cost/tests/test_services.py b/cost/tests/test_services.py new file mode 100644 index 0000000..e8092ac --- /dev/null +++ b/cost/tests/test_services.py @@ -0,0 +1,445 @@ +"""Cost 模块服务测试""" +from datetime import date +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase + +from basic_info import models as basic_models +from cost import models as cost_models +from cost import services as cost_services +from cost.models import CostEntryInput, CostProviderPort + + +class EnsureCategoryTests(TestCase): + def setUp(self): + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', type=basic_models.MerchantTypeEnum.STORE, + ) + self.other_merchant = basic_models.Merchant.objects.create( + name='其他商户', type=basic_models.MerchantTypeEnum.STORE, + ) + + def test_ensure_category_creates_new(self): + """category_key 不存在时创建新类目""" + cat = cost_services.ensure_category( + merchant=self.merchant, + category_key='electricity', + category_name='电费', + description='每月电费支出', + ) + self.assertEqual(cat.unique_key, 'electricity') + self.assertEqual(cat.name, '电费') + self.assertEqual(cat.description, '每月电费支出') + self.assertEqual(cat.merchant_id, self.merchant.id) + + def test_ensure_category_returns_existing(self): + """category_key 已存在时返回已有类目""" + existing = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费', + ) + cat = cost_services.ensure_category( + merchant=self.merchant, category_key='electricity', category_name='电力', + ) + self.assertEqual(cat.id, existing.id) + self.assertEqual(cat.name, '电费') # 不覆盖已有 name + + def test_ensure_category_empty_key_raises(self): + """空 category_key 抛 ValueError""" + with self.assertRaises(ValueError) as ctx: + cost_services.ensure_category( + merchant=self.merchant, category_key='', category_name='电费', + ) + self.assertIn('category_key', str(ctx.exception)) + + def test_ensure_category_whitespace_key_raises(self): + """纯空格 category_key 抛 ValueError""" + with self.assertRaises(ValueError) as ctx: + cost_services.ensure_category( + merchant=self.merchant, category_key=' ', category_name='电费', + ) + self.assertIn('category_key', str(ctx.exception)) + + def test_ensure_category_empty_name_raises(self): + """空 category_name 抛 ValueError""" + with self.assertRaises(ValueError) as ctx: + cost_services.ensure_category( + merchant=self.merchant, category_key='key', category_name='', + ) + self.assertIn('category_name', str(ctx.exception)) + + def test_ensure_category_different_merchants_same_key(self): + """不同商户相同 key 不冲突""" + cat1 = cost_services.ensure_category( + merchant=self.merchant, category_key='same_key', category_name='类目A', + ) + cat2 = cost_services.ensure_category( + merchant=self.other_merchant, category_key='same_key', category_name='类目B', + ) + self.assertNotEqual(cat1.id, cat2.id) + self.assertEqual(cat1.name, '类目A') + self.assertEqual(cat2.name, '类目B') + + +class CreateCostEntryTests(TestCase): + def setUp(self): + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', type=basic_models.MerchantTypeEnum.STORE, + ) + self.other_merchant = basic_models.Merchant.objects.create( + name='其他商户', type=basic_models.MerchantTypeEnum.STORE, + ) + self.user = get_user_model().objects.create_user(username='testuser', password='pass12345') + self.employee = basic_models.Employee.objects.create( + merchant=self.merchant, sys_user=self.user, name='测试员工', + ) + self.other_user = get_user_model().objects.create_user(username='otheruser', password='pass12345') + self.other_employee = basic_models.Employee.objects.create( + merchant=self.other_merchant, sys_user=self.other_user, name='其他员工', + ) + self.category = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='transport', name='运输费', + ) + self.other_category = cost_models.CostCategory.objects.create( + merchant=self.other_merchant, unique_key='transport', name='运输费', + ) + + def test_create_cost_entry_normal(self): + """正常创建支出记录""" + entry = cost_services.create_cost_entry( + merchant=self.merchant, + category=self.category, + amount=Decimal('150.00'), + occurred_at=date(2026, 6, 1), + operator=self.employee, + source_module='manual', + source_id='', + remarks='测试备注', + ) + self.assertEqual(entry.amount, Decimal('150.00')) + self.assertEqual(entry.occurred_at, date(2026, 6, 1)) + self.assertEqual(entry.operator_id, self.employee.id) + self.assertEqual(entry.merchant_id, self.merchant.id) + self.assertEqual(entry.source_module, 'manual') + self.assertEqual(entry.remarks, '测试备注') + + def test_create_cost_entry_operator_none(self): + """operator=None 正常创建(provider 场景)""" + entry = cost_services.create_cost_entry( + merchant=self.merchant, + category=self.category, + amount=Decimal('200.00'), + occurred_at=date(2026, 6, 2), + operator=None, + ) + self.assertIsNone(entry.operator_id) + self.assertEqual(entry.amount, Decimal('200.00')) + + def test_create_cost_entry_category_mismatch_merchant_raises(self): + """category 所属商户与 merchant 不一致抛 ValueError""" + with self.assertRaises(ValueError) as ctx: + cost_services.create_cost_entry( + merchant=self.merchant, + category=self.other_category, + amount=Decimal('100.00'), + occurred_at=date(2026, 6, 1), + operator=self.employee, + ) + self.assertIn('category', str(ctx.exception)) + + def test_create_cost_entry_operator_mismatch_merchant_raises(self): + """operator 所属商户与 merchant 不一致抛 ValueError""" + with self.assertRaises(ValueError) as ctx: + cost_services.create_cost_entry( + merchant=self.merchant, + category=self.category, + amount=Decimal('100.00'), + occurred_at=date(2026, 6, 1), + operator=self.other_employee, + ) + self.assertIn('operator', str(ctx.exception)) + + def test_create_cost_entry_default_values(self): + """不传 source_module/source_id/remarks 时默认为空字符串""" + entry = cost_services.create_cost_entry( + merchant=self.merchant, + category=self.category, + amount=Decimal('50.00'), + occurred_at=date(2026, 6, 3), + ) + self.assertEqual(entry.source_module, '') + self.assertEqual(entry.source_id, '') + self.assertEqual(entry.remarks, '') + + +class MockCostProvider: + """测试用 Provider""" + category_key = 'test_provider' + + def __init__(self, entries): + self._entries = entries + + def get_cost_entries(self, *, merchant, start_date, end_date): + return self._entries + + +class CollectFromProviderTests(TestCase): + def setUp(self): + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', type=basic_models.MerchantTypeEnum.STORE, + ) + + def test_collect_creates_entries(self): + """正常采集并创建 CostEntry""" + entries = [ + CostEntryInput( + category_key='electricity', category_name='电费', + amount=Decimal('300.00'), occurred_at=date(2026, 6, 1), + source_module='test', source_id='E001', + ), + CostEntryInput( + category_key='water', category_name='水费', + amount=Decimal('80.00'), occurred_at=date(2026, 6, 2), + source_module='test', source_id='W001', + ), + ] + provider = MockCostProvider(entries) + + result = cost_services.collect_from_provider( + provider=provider, + merchant=self.merchant, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + ) + + self.assertEqual(result['total_seen'], 2) + self.assertEqual(result['created_count'], 2) + self.assertEqual(result['error_count'], 0) + self.assertEqual(len(result['errors']), 0) + self.assertEqual(result['provider_category_key'], 'test_provider') + + # 验证数据库 + self.assertEqual(cost_models.CostEntry.objects.count(), 2) + self.assertEqual(cost_models.CostCategory.objects.count(), 2) + + def test_collect_returns_not_list_raises(self): + """Provider 返回非 list 抛 TypeError""" + class BadProvider: + category_key = 'bad' + def get_cost_entries(self, *, merchant, start_date, end_date): + return 'not_a_list' + + with self.assertRaises(TypeError): + cost_services.collect_from_provider( + provider=BadProvider(), + merchant=self.merchant, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + ) + + def test_collect_non_entry_input_records_error(self): + """非 CostEntryInput 的条目记入 errors""" + provider = MockCostProvider(['not_a_dataclass', 123]) + + result = cost_services.collect_from_provider( + provider=provider, + merchant=self.merchant, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + ) + + self.assertEqual(result['total_seen'], 2) + self.assertEqual(result['created_count'], 0) + self.assertEqual(result['error_count'], 2) + self.assertEqual(cost_models.CostEntry.objects.count(), 0) + + def test_collect_empty_category_key_records_error(self): + """category_key 为空导致 ValueError 记入 errors""" + entries = [ + CostEntryInput( + category_key='', category_name='无key类目', + amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), + source_module='test', source_id='X001', + ), + ] + provider = MockCostProvider(entries) + + result = cost_services.collect_from_provider( + provider=provider, + merchant=self.merchant, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + ) + + self.assertEqual(result['total_seen'], 1) + self.assertEqual(result['created_count'], 0) + self.assertEqual(result['error_count'], 1) + self.assertIn('category_key', result['errors'][0]['error']) + + def test_collect_empty_list(self): + """空列表返回 created_count=0""" + provider = MockCostProvider([]) + + result = cost_services.collect_from_provider( + provider=provider, + merchant=self.merchant, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + ) + + self.assertEqual(result['total_seen'], 0) + self.assertEqual(result['created_count'], 0) + self.assertEqual(result['error_count'], 0) + + def test_collect_mixed_valid_invalid_partial_success(self): + """混合合法+非法条目,合法条目正常创建""" + entries = [ + CostEntryInput( + category_key='electricity', category_name='电费', + amount=Decimal('300.00'), occurred_at=date(2026, 6, 1), + source_module='test', source_id='E001', + ), + 'invalid_item', + CostEntryInput( + category_key='', category_name='', + amount=Decimal('50.00'), occurred_at=date(2026, 6, 2), + source_module='test', source_id='X002', + ), + ] + provider = MockCostProvider(entries) + + result = cost_services.collect_from_provider( + provider=provider, + merchant=self.merchant, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + ) + + self.assertEqual(result['total_seen'], 3) + self.assertEqual(result['created_count'], 1) + self.assertEqual(result['error_count'], 2) + self.assertEqual(cost_models.CostEntry.objects.count(), 1) + + def test_collect_reuses_existing_category(self): + """已有类目时不重复创建""" + cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费旧名', + ) + entries = [ + CostEntryInput( + category_key='electricity', category_name='电费新名', + amount=Decimal('100.00'), occurred_at=date(2026, 6, 1), + source_module='test', source_id='E001', + ), + ] + provider = MockCostProvider(entries) + + cost_services.collect_from_provider( + provider=provider, + merchant=self.merchant, + start_date=date(2026, 6, 1), + end_date=date(2026, 6, 30), + ) + + self.assertEqual(cost_models.CostCategory.objects.count(), 1) + cat = cost_models.CostCategory.objects.first() + self.assertEqual(cat.name, '电费旧名') # 不覆盖已有 name + + +class AggregateByCategoryTests(TestCase): + def setUp(self): + self.merchant = basic_models.Merchant.objects.create( + name='测试商户', type=basic_models.MerchantTypeEnum.STORE, + ) + self.other_merchant = basic_models.Merchant.objects.create( + name='其他商户', type=basic_models.MerchantTypeEnum.STORE, + ) + self.cat_a = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='electricity', name='电费', + ) + self.cat_b = cost_models.CostCategory.objects.create( + merchant=self.merchant, unique_key='water', name='水费', + ) + + def _create_entries(self): + """创建测试数据:电费 300+200=500,水费 80,其他商户 1000""" + cost_models.CostEntry.objects.create( + merchant=self.merchant, category=self.cat_a, + amount=Decimal('300.00'), occurred_at=date(2026, 6, 1), + ) + cost_models.CostEntry.objects.create( + merchant=self.merchant, category=self.cat_a, + amount=Decimal('200.00'), occurred_at=date(2026, 6, 10), + ) + cost_models.CostEntry.objects.create( + merchant=self.merchant, category=self.cat_b, + amount=Decimal('80.00'), occurred_at=date(2026, 6, 5), + ) + other_cat = cost_models.CostCategory.objects.create( + merchant=self.other_merchant, unique_key='other', name='其他', + ) + cost_models.CostEntry.objects.create( + merchant=self.other_merchant, category=other_cat, + amount=Decimal('1000.00'), occurred_at=date(2026, 6, 15), + ) + + def test_aggregate_by_category_desc_order(self): + """按 total_amount 降序,只统计本商户""" + self._create_entries() + result = cost_services.aggregate_by_category(merchant=self.merchant) + + self.assertEqual(len(result), 2) + self.assertEqual(result[0]['category_name'], '电费') + self.assertEqual(result[0]['total_amount'], '500.00') + self.assertEqual(result[0]['entry_count'], 2) + self.assertEqual(result[1]['category_name'], '水费') + self.assertEqual(result[1]['total_amount'], '80.00') + self.assertEqual(result[1]['entry_count'], 1) + + def test_aggregate_by_category_with_start_date(self): + """start_date 过滤""" + self._create_entries() + result = cost_services.aggregate_by_category( + merchant=self.merchant, + start_date=date(2026, 6, 5), + ) + self.assertEqual(len(result), 2) + totals = {r['category_name']: r['total_amount'] for r in result} + self.assertEqual(totals['电费'], '200.00') # 只有 6.10 那条 + self.assertEqual(totals['水费'], '80.00') + + def test_aggregate_by_category_with_end_date(self): + """end_date 过滤""" + self._create_entries() + result = cost_services.aggregate_by_category( + merchant=self.merchant, + end_date=date(2026, 6, 5), + ) + self.assertEqual(len(result), 2) + totals = {r['category_name']: r['total_amount'] for r in result} + self.assertEqual(totals['电费'], '300.00') # 只有 6.1 那条 + self.assertEqual(totals['水费'], '80.00') + + def test_aggregate_by_category_with_both_dates(self): + """start_date + end_date 同时过滤""" + self._create_entries() + result = cost_services.aggregate_by_category( + merchant=self.merchant, + start_date=date(2026, 6, 5), + end_date=date(2026, 6, 8), + ) + self.assertEqual(len(result), 1) + self.assertEqual(result[0]['category_name'], '水费') + self.assertEqual(result[0]['total_amount'], '80.00') + + def test_aggregate_no_data_returns_empty(self): + """无数据返回空列表""" + result = cost_services.aggregate_by_category(merchant=self.merchant) + self.assertEqual(result, []) + + def test_aggregate_includes_category_key(self): + """返回结果包含 category_key""" + self._create_entries() + result = cost_services.aggregate_by_category(merchant=self.merchant) + self.assertEqual(result[0]['category_key'], 'electricity') + self.assertEqual(result[1]['category_key'], 'water') \ No newline at end of file diff --git a/docs/cost/api.md b/docs/cost/api.md new file mode 100644 index 0000000..cb711ad --- /dev/null +++ b/docs/cost/api.md @@ -0,0 +1,329 @@ +# API v2 成本模块接口文档 + +本文档面向前端,描述 `cost` 成本模块的 API。 + +## 基本约定 + +- Base URL: `/api/v2` +- 认证:所有接口都需要登录(JWT)。 +- 人员身份:后端使用 `request.user.employee` 作为当前员工身份。 +- 多商户隔离:所有类目、支出明细只允许访问当前员工所属商户的数据;可通过 `?merchant_id=` 参数跨商户查询(如果权限允许)。 +- 时间格式:请求参数中的日期使用 `YYYY-MM-DD`(如 `2026-06-01`),响应中的日期时间使用 ISO 8601。 + +--- + +## 1. 支出类目 + +### 1.1 类目列表 + +``` +GET /api/v2/cost-categories/ +``` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `merchant_id` | int | 否 | 按商户筛选,不传则使用当前员工的商户 | + +成功响应 `200`: + +```json +[ + { + "id": 1, + "merchant_id": 1, + "unique_key": "electricity", + "name": "电费", + "parent_id": null, + "description": "每月电费支出", + "created_at": "2026-06-06T10:00:00Z", + "updated_at": "2026-06-06T10:00:00Z" + } +] +``` + +### 1.2 创建类目 + +``` +POST /api/v2/cost-categories/ +``` + +请求体(JSON): + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `unique_key` | string | **是** | 唯一标识键,同商户不可重复,如 `electricity` | +| `name` | string | **是** | 类目名称 | +| `parent_id` | int | 否 | 父类目 ID,用于层级 | +| `description` | string | 否 | 描述 | + +请求示例: + +```json +{ + "unique_key": "electricity", + "name": "电费", + "description": "每月电费支出" +} +``` + +成功响应 `201`: + +```json +{ + "id": 1, + "merchant_id": 1, + "unique_key": "electricity", + "name": "电费", + "parent_id": null, + "description": "每月电费支出", + "created_at": "2026-06-06T10:00:00Z", + "updated_at": "2026-06-06T10:00:00Z" +} +``` + +错误响应 `400`:缺少必填字段时返回校验错误。 + +### 1.3 类目详情 + +``` +GET /api/v2/cost-categories/{category_id}/ +``` + +成功响应 `200`:返回单个类目对象,结构同上。 + +错误响应 `404`:类目不存在或不属于当前商户。 + +### 1.4 修改类目 + +``` +PUT /api/v2/cost-categories/{category_id}/ +``` + +请求体(JSON,所有字段可选): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `unique_key` | string | 唯一标识键 | +| `name` | string | 类目名称 | +| `parent_id` | int | 父类目 ID,传 `null` 清除 | +| `description` | string | 描述 | + +成功响应 `200`:返回更新后的类目对象。 + +### 1.5 删除类目 + +``` +DELETE /api/v2/cost-categories/{category_id}/ +``` + +成功响应 `204`(无响应体)。 + +错误响应 `404`:类目不存在或不属于当前商户。 + +--- + +## 2. 支出明细 + +### 2.1 支出明细列表 + +``` +GET /api/v2/cost-entries/ +``` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `merchant_id` | int | 否 | 按商户筛选 | +| `category_id` | int | 否 | 按支出类目筛选 | +| `start` | date | 否 | 发生日期起始,格式 `YYYY-MM-DD` | +| `end` | date | 否 | 发生日期截止,格式 `YYYY-MM-DD` | + +默认按 `occurred_at` 降序排列。 + +成功响应 `200`: + +```json +[ + { + "id": 1, + "merchant_id": 1, + "category_id": 1, + "category_name": "电费", + "amount": "350.00", + "occurred_at": "2026-06-01", + "operator_id": 12, + "image1": "", + "image2": "", + "source_module": "", + "source_id": "", + "remarks": "六月份电费", + "created_at": "2026-06-06T10:00:00Z", + "updated_at": "2026-06-06T10:00:00Z" + } +] +``` + +字段说明: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `amount` | string | 金额(Decimal 转字符串,前端展示时注意格式化) | +| `occurred_at` | string | 发生日期,`YYYY-MM-DD` | +| `operator_id` | int | 经办人 ID,自动从当前登录用户获取 | +| `image1` | string | 凭证图片 URL(七牛云 CDN),无则为空字符串 | +| `image2` | string | 备用凭证图片 URL,无则为空字符串 | +| `source_module` | string | 来源模块名,手工录入时为空 | +| `source_id` | string | 来源记录 ID,手工录入时为空 | + +### 2.2 创建支出明细 + +``` +POST /api/v2/cost-entries/ +``` + +Content-Type: `multipart/form-data`(支持图片上传)或 `application/json` + +请求体: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `category_id` | int | **是** | 支出类目 ID | +| `amount` | decimal | **是** | 金额,如 `"350.00"` | +| `occurred_at` | date | **是** | 发生日期,`YYYY-MM-DD` | +| `image1` | file | 否 | 凭证图片 | +| `image2` | file | 否 | 备用凭证图片 | +| `source_module` | string | 否 | 来源模块名 | +| `source_id` | string | 否 | 来源记录 ID | +| `remarks` | string | 否 | 备注 | + +请求示例(JSON): + +```json +{ + "category_id": 1, + "amount": "350.00", + "occurred_at": "2026-06-01", + "remarks": "六月份电费" +} +``` + +成功响应 `201`:返回创建的支出明细对象。 + +错误响应 `400`:校验失败。 +错误响应 `404`:`category_id` 不存在或不属于当前商户。 + +### 2.3 支出明细详情 + +``` +GET /api/v2/cost-entries/{entry_id}/ +``` + +成功响应 `200`:返回单个支出明细对象,含 `category_name`。 + +错误响应 `404`:明细不存在或不属于当前商户。 + +### 2.4 修改支出明细 + +``` +PUT /api/v2/cost-entries/{entry_id}/ +``` + +Content-Type: `multipart/form-data` 或 `application/json` + +请求体(所有字段可选): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `category_id` | int | 切换类目 | +| `amount` | decimal | 金额 | +| `occurred_at` | date | 发生日期 | +| `image1` | file | 凭证图片 | +| `image2` | file | 备用凭证图片 | +| `source_module` | string | 来源模块名 | +| `source_id` | string | 来源记录 ID | +| `remarks` | string | 备注 | + +成功响应 `200`:返回更新后的支出明细对象。 + +### 2.5 删除支出明细 + +``` +DELETE /api/v2/cost-entries/{entry_id}/ +``` + +成功响应 `204`。 + +错误响应 `404`:明细不存在或不属于当前商户。 + +--- + +## 3. 按类目汇总 + +``` +GET /api/v2/cost-summary/by-category/ +``` + +查询参数: + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `merchant_id` | int | 否 | 按商户筛选 | +| `start` | date | 否 | 发生日期起始 | +| `end` | date | 否 | 发生日期截止 | + +按 `total_amount` 降序排列。 + +成功响应 `200`: + +```json +{ + "merchant_id": 1, + "start_date": "2026-06-01", + "end_date": "2026-06-30", + "results": [ + { + "category_id": 1, + "category_name": "电费", + "category_key": "electricity", + "total_amount": "500.00", + "entry_count": 2 + }, + { + "category_id": 2, + "category_name": "水费", + "category_key": "water", + "total_amount": "80.00", + "entry_count": 1 + } + ] +} +``` + +字段说明: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `total_amount` | string | 该类别在查询时间段内的总金额 | +| `entry_count` | integer | 该类别在查询时间段内的记录数 | +| `category_key` | string | 类目唯一键,可用于前端做图表的 key 映射 | + +--- + +## 快速参考 + +| Method | URL | 说明 | +|--------|-----|------| +| GET | `/api/v2/cost-categories/` | 类目列表 | +| POST | `/api/v2/cost-categories/` | 创建类目 | +| GET | `/api/v2/cost-categories/{id}/` | 类目详情 | +| PUT | `/api/v2/cost-categories/{id}/` | 修改类目 | +| DELETE | `/api/v2/cost-categories/{id}/` | 删除类目 | +| GET | `/api/v2/cost-entries/` | 支出明细列表 | +| POST | `/api/v2/cost-entries/` | 创建支出明细 | +| GET | `/api/v2/cost-entries/{id}/` | 支出明细详情 | +| PUT | `/api/v2/cost-entries/{id}/` | 修改支出明细 | +| DELETE | `/api/v2/cost-entries/{id}/` | 删除支出明细 | +| GET | `/api/v2/cost-summary/by-category/` | 按类目汇总 | \ No newline at end of file diff --git a/docs/cost/design.md b/docs/cost/design.md new file mode 100644 index 0000000..b084fde --- /dev/null +++ b/docs/cost/design.md @@ -0,0 +1,180 @@ +# 成本模块 (cost) 设计文档 + +## 1. 概述 + +成本模块是 ERP 系统中用于记录和管理各项支出的独立模块。第一版实现手工开支记账功能(支出类目 + 支出明细),后续版本将纳入从其它模块(如印刷 `printing`、库存 `stock`、物流 `shipment` 等)自动采集的成本数据。 + +### 设计原则 + +- **六边形架构**:通过 `CostProviderPort` 协议定义成本数据输入端口,其它模块只需实现该接口即可被成本模块统一采集。 +- **双向可依赖**:Provider 既可以被动被 cost 模块调用,也可以主动 `import` cost 模块的基础能力(如 `ensure_category`)来预创建类目。 +- **独立部署单元**:cost 是独立的 Django app,不修改现有模块。 + +--- + +## 2. 模块结构 + +``` +cost/ # 新 Django app +├── __init__.py +├── apps.py # CostConfig +├── models.py # CostCategory, CostEntry, CostProviderPort, CostEntryInput +├── services.py # ensure_category, create_cost_entry, aggregate_by_category, collect_from_provider +├── admin.py # CostCategoryAdmin, CostEntryAdmin +├── tasks.py # Celery 任务(预留) +├── tests/ +│ ├── __init__.py +│ ├── test_models.py +│ └── test_services.py +└── migrations/ + └── __init__.py + +api_v2/views/cost.py # API View(不放在 cost 内部) +``` + +--- + +## 3. 模型设计 + +### 3.1 CostCategory — 支出类目 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BigAutoField (PK) | | +| `merchant` | FK → Merchant | 所属商户 | +| `unique_key` | CharField (max_length=100) | 全局唯一标识键,用于 Port 协议匹配 | +| `name` | CharField (max_length=100) | 类目显示名 | +| `parent` | FK → self (null) | 父类目,支持层级 | +| `description` | TextField (null) | 备注 | +| `created_at` | DateTimeField | ModelBase | +| `updated_at` | DateTimeField | ModelBase | + +**约束**:`unique_together = ('merchant', 'unique_key')` + +### 3.2 CostEntry — 支出明细 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | BigAutoField (PK) | | +| `merchant` | FK → Merchant | 所属商户 | +| `category` | FK → CostCategory | 支出类目 | +| `amount` | DecimalField(15, 2) | 金额 | +| `occurred_at` | DateField | 发生日期 | +| `operator` | FK → Employee | 经办人 | +| `image1` | ImageField (null) | 凭证图片 | +| `image2` | ImageField (null) | 备用凭证图片 | +| `source_module` | CharField (null, max_length=50) | 来源模块名,如 'printing' | +| `source_id` | CharField (null, max_length=100) | 来源记录 ID | +| `remarks` | TextField (null) | 备注 | +| `created_at` | DateTimeField | ModelBase | +| `updated_at` | DateTimeField | ModelBase | + +--- + +## 4. 六边形端口协议 + +### 4.1 CostEntryInput + +```python +@dataclass +class CostEntryInput: + category_key: str # 类目标识键,用于匹配 CostCategory.unique_key + category_name: str # 类目显示名(匹配不到时用此名自动创建) + amount: Decimal + occurred_at: date + source_module: str + source_id: str + remarks: str = '' +``` + +### 4.2 CostProviderPort (Protocol) + +```python +@runtime_checkable +class CostProviderPort(Protocol): + category_key: str # 类级别:该 Provider 默认使用的类目标识键 + + def get_cost_entries( + self, *, merchant, start_date: date, end_date: date + ) -> list[CostEntryInput]: + ... +``` + +### 4.3 双向依赖机制 + +**方向 1:Cost 模块调用 Provider** +```python +# cost/services.py +def collect_from_provider(provider: CostProviderPort, *, merchant, start_date, end_date): + """从 Provider 采集成本数据""" + for entry in provider.get_cost_entries(merchant=merchant, start_date=start_date, end_date=end_date): + cat = ensure_category(merchant=merchant, category_key=entry.category_key, category_name=entry.category_name) + create_cost_entry(merchant=merchant, category=cat, ...) +``` + +**方向 2:Provider 调用 Cost 模块基础能力** +```python +# 第三方模块中 +from cost.services import ensure_category + +class PrintingCostProvider: + category_key = 'printing_consumables' + + def get_cost_entries(self, *, merchant, start_date, end_date): + # Provider 主动确保类目存在 + ensure_category(merchant=merchant, category_key=self.category_key, category_name='印刷耗材') + # ... 计算成本条目 ... +``` + +### 4.4 类目匹配逻辑 + +`ensure_category()` 优先按 `unique_key` 匹配现有类目,匹配不到时自动创建: + +``` +1. 查 CostCategory.objects.filter(merchant=merchant, unique_key=category_key) +2. 命中 → 返回已有类目 +3. 未命中 → 创建:CostCategory(merchant=merchant, unique_key=category_key, name=category_name) +``` + +--- + +## 5. Services 层 + +| 函数 | 说明 | +|------|------| +| `ensure_category(*, merchant, category_key, category_name)` | 按 key 查找或创建支出类目 | +| `create_cost_entry(*, merchant, category, amount, occurred_at, operator, image1, image2, source_module, source_id, remarks)` | 创建支出记录 | +| `collect_from_provider(provider, *, merchant, start_date, end_date)` | 从 Provider 采集成本数据 | +| `aggregate_by_category(*, merchant, start_date, end_date)` | 按类别汇总(group by category) | + +--- + +## 6. API 设计 (api_v2) + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/v2/cost-categories/` | 类目列表(支持 `?merchant_id=` 筛选) | +| POST | `/api/v2/cost-categories/` | 创建类目 | +| GET | `/api/v2/cost-categories//` | 类目详情 | +| PUT | `/api/v2/cost-categories//` | 修改类目 | +| DELETE | `/api/v2/cost-categories//` | 删除类目 | +| GET | `/api/v2/cost-entries/` | 支出明细列表(支持 `?start=&end=&category_id=&merchant_id=`) | +| POST | `/api/v2/cost-entries/` | 创建支出记录(multipart/form-data 支持图片上传) | +| GET | `/api/v2/cost-entries//` | 支出明细详情 | +| PUT | `/api/v2/cost-entries//` | 修改支出记录 | +| DELETE | `/api/v2/cost-entries//` | 删除支出记录 | +| GET | `/api/v2/cost-summary/by-category/?start=&end=&merchant_id=` | 按支出类目汇总 | + +--- + +## 7. 决策记录 + +| # | 决策 | 原因 | +|---|------|------| +| 1 | 成本模块独立为 `cost` app | 遵循项目惯例 `business`/`printing`/`stock` 各有独立 app;成本后续会关联多模块,独立 app 避免循环依赖 | +| 2 | API 放在 `api_v2` 而非 `cost` 内部 | 项目约定 API 层与业务模型分离 | +| 3 | 用 `typing.Protocol` 而非 ABC 定义端口 | 不需要显式注册/继承,符合 Python 鸭子类型习惯 | +| 4 | `CostCategory.unique_key` 用于 Port 匹配 | 比按 `name` 匹配更稳定,避免重名/改名问题 | +| 5 | `CostEntry` 带两个 `ImageField` | 用户要求:一个用于凭证图片,一个预留备用 | +| 6 | 汇总 API 按 `start/end` 时间段 + `group by category` | 用户指定的统计方式 | +| 7 | 第一版不做 Provider 注册表 | Provider 暂时只有一个调用入口 `collect_from_provider()`,后续可扩展为注册表模式 | \ No newline at end of file diff --git a/docs/cost/inventory-cost-feasibility.md b/docs/cost/inventory-cost-feasibility.md new file mode 100644 index 0000000..ce4c405 --- /dev/null +++ b/docs/cost/inventory-cost-feasibility.md @@ -0,0 +1,172 @@ +# 库存成本结算可行性分析 + +## 背景 + +纺织/印花行业 ERP 中,库存成本核算通常使用两种方法: + +- **先进先出(FIFO)**:先入库的批次先出库,出库成本按对应入库批次的实际采购价计算。 +- **加权平均**:每次入库后重新计算库存均价(总成本 ÷ 总数量),出库统一按当前均价计算。 + +本文档对当前系统的库存模块进行研判,评估是否具备实现这两种成本结算方法的要素。 + +--- + +## 1. 当前库存模块数据结构 + +### 1.1 核心模型 + +``` +StockChangeRecord(库存变动记录) +├── type: 入库/出库 +├── source_type: 来源类型(采购/销售/调拨/盘盈盘亏/红冲等) +├── warehouse: 仓库 +├── is_finished: 是否已完成 +└── details ────── StockChangeDetail(库存变动明细) + ├── product: 产品 + ├── quantity: 数量 + ├── consume_with: 消耗关联(出库指向入库明细,自引用 FK) + └── is_consumed: 是否已被消耗 + +Inventory(库存汇总) +├── product: 产品 +├── warehouse: 仓库 +├── quantity: 当前库存数量 +└── num_of_rolls: 匹数 + +StockSnapshot(库存变动快照) +├── delta: 变动量 +├── quantity_before: 变动前库存 +├── quantity_after: 变动后库存 +└── (offset/cancelled 红冲链路) +``` + +### 1.2 价格数据所在位置 + +价格数据**不在库存模块**,而在业务模块的明细行中: + +| 模型 | 价格字段 | 说明 | +|------|---------|------| +| `business.PurchaseOrderItem.price` | 采购单价 | 入库成本价的唯一来源 | +| `business.SalesOrderItem.price` | 销售单价 | 出库售价,非成本价 | + +### 1.3 关键发现:库存模块不做任何成本核算 + +所有 `StockChangeDetail`、`Inventory`、`StockSnapshot` 只记录**数量(quantity)**,完全没有 `cost_price`、`unit_cost`、`total_cost` 等成本维度字段。 + +--- + +## 2. 按结算方法逐一分析 + +### 2.1 先进先出(FIFO) + +#### 原理 + +每次出库时,从最早未消耗的入库批次开始扣除,出库成本等于对应入库批次的实际采购单价。 + +#### 已有基础设施 ✅ + +`StockChangeDetail.consume_with` 是一个自引用外键,在严进严出模式下,出库明细通过它指向被消耗的入库明细: + +```python +# stock/models.py +consume_with = models.OneToOneField( + 'self', + on_delete=models.PROTECT, + null=True, blank=True, + related_name='consumed_by_detail', + verbose_name='所消耗的入库明细', +) +``` + +这是**天然的 FIFO 追踪链**。如果入库明细携带了成本价,出库成本可以直接通过 `consume_with.unit_cost` 确定。 + +#### 缺失要素 + +| 缺失项 | 说明 | +|--------|------| +| `StockChangeDetail.unit_cost` | 入库明细需要记录该批次的采购成本单价 | +| 宽进宽出模式的批次追踪 | 当前 `consume_with` 仅在严进严出模式下使用,宽进宽出需要补充批次追踪或按 FIFO 规则自动匹配 | + +#### 落地难度:低 + +改动范围极小——给 `StockChangeDetail` 加一个 `unit_cost` 字段,在入库时从采购单同步价格,出库成本沿 `consume_with` 链读取即可。 + +--- + +### 2.2 加权平均 + +#### 原理 + +每次入库后,重新计算加权平均单价: +``` +加权均价 = (库存总成本 + 本次入库成本) ÷ (库存数量 + 本次入库数量) +``` +出库时统一按当前加权均价计算成本。 + +#### 已有基础设施 ⚠️ + +`Inventory` 表是天然的加权平均计算锚点——它汇总了每个产品在每个仓库的当前库存数量。只需增加一个总成本字段即可完成计算。 + +#### 缺失要素 + +| 缺失项 | 说明 | +|--------|------| +| `Inventory.total_cost` | 库存表需要增加总成本字段,每次入库累加 | +| `StockChangeDetail.unit_cost` | 同上,入库明细需要知道入库单价 | +| 红冲/退货的成本回冲逻辑 | 红冲或退货时需反向调整 `total_cost` 和重新计算均价 | + +#### 落地难度:低 + +给 `Inventory` 加一个 `total_cost` 字段,在 `make_stock_change_completed()` 中补充成本累加逻辑即可。 + +--- + +## 3. 两种方法对比 + +| 维度 | 先进先出 (FIFO) | 加权平均 | +|------|:---:|:---:| +| 追踪粒度 | 批次级别 | 仓库+产品级别 | +| 已有基础设施 | `consume_with` 链 ✅ | `Inventory` 汇总表 ⚠️ | +| 需新增字段 | `StockChangeDetail.unit_cost` | `StockChangeDetail.unit_cost` + `Inventory.total_cost` | +| 计算复杂度 | 需维护批次消耗顺序 | 每次入库后重新算均价 | +| 红冲处理 | 恢复原批次 | 重新计算均价 | +| 适用场景 | 价格波动大、需精确追踪每批成本 | 价格稳定、简化核算 | + +--- + +## 4. 实施建议 + +### 最小改动方案 + +两个方法都需要以下共同改动: + +1. **`StockChangeDetail` 增加 `unit_cost` 字段** + ```python + unit_cost = models.DecimalField(max_digits=15, decimal_places=6, null=True, verbose_name='成本单价') + ``` + +2. **入库时填充 `unit_cost`** + - 采购入库:从 `PurchaseOrderItem.price` 同步 + - 销退入库:从原销售出库的成本回冲 + - 调拨入库:从调出仓的当前成本同步 + - 盘盈:可设为 0 或要求手动录入 + +3. **出库时计算成本** + - FIFO:沿 `consume_with` 链读取对应入库批次的 `unit_cost` + - 加权平均:需额外在 `Inventory` 表增加 `total_cost` 字段,在 `make_stock_change_completed` 中维护 + +### 建议优先实现 FIFO + +对于纺织行业(布料批次间价格差异大),**FIFO 更合适**。且当前 `consume_with` 链已就绪,实现成本最低。 + +若后续需要加权平均,在 FIFO 的基础上给 `Inventory` 增加 `total_cost` 即可,两者不冲突。 + +--- + +## 5. 结论 + +**当前系统不具备直接进行先进先出或加权平均成本结算的能力**,库存模块完全是数量管理。 + +但 **FIFO 所需的基础设施已经存在**(`consume_with` 追踪链),改动范围极小——仅需给 `StockChangeDetail` 增加 `unit_cost` 字段,并在入库时同步采购价格。加权平均也只需在 `Inventory` 表增加 `total_cost` 字段即可。 + +两个方法的落地成本都很低,不存在结构性障碍。 \ No newline at end of file diff --git a/flower/settings.py b/flower/settings.py index 3cc54ba..2bc76ab 100644 --- a/flower/settings.py +++ b/flower/settings.py @@ -102,11 +102,11 @@ HAOBUYE_FINANCE_SYNC_OPERATOR_ID = env.int('HAOBUYE_FINANCE_SYNC_OPERATOR_ID', d # 定时财务同步客户列表(临时需求,直接写死不走 env) FINANCE_SYNC_CUSTOMER_NAMES: list[str] = [ - '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎', - '罗标', '永琪服饰', '罗兵', '杜辉', '李群', '超麦汇大洋', - '展兴', '王杰敏', '锐木', '何玄', '周刚(周总)', '彤彤服饰', - '杰恩', '来发裁床', '刘静贤', '金源', '达达-金腾达qs', '周乔峰', - '杨锐辉', + # '曾念', '紫琪', '胡肖宇', '歌斯拉-胜利星厂', '胡鼎', + # '罗标', '永琪服饰', '罗兵', '杜辉', '李群', '超麦汇大洋', + # '展兴', '王杰敏', '锐木', '何玄', '周刚(周总)', '彤彤服饰', + # '杰恩', '来发裁床', '刘静贤', '金源', '达达-金腾达qs', '周乔峰', + '杨锐辉', '杨赛英', '别坤', 'A黄宇', '英豪' ] @@ -169,6 +169,7 @@ INSTALLED_APPS = [ 'mission', 'notifier', 'mes', + 'cost', ] MIDDLEWARE = [