forked from erp-dev/erp
352 lines
13 KiB
Python
352 lines
13 KiB
Python
from datetime import date
|
||
|
||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||
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),
|
||
'unit_amount': str(entry.unit_amount) if entry.unit_amount is not None else None,
|
||
'quantity': str(entry.quantity) if entry.quantity is not None else None,
|
||
'unit_name': entry.unit_name or '',
|
||
'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(
|
||
required=False, allow_null=True, max_digits=15, decimal_places=2, min_value=0,
|
||
)
|
||
unit_amount = serializers.DecimalField(
|
||
required=False, allow_null=True, max_digits=15, decimal_places=4, min_value=0,
|
||
)
|
||
quantity = serializers.DecimalField(
|
||
required=False, allow_null=True, max_digits=12, decimal_places=4, min_value=0,
|
||
)
|
||
unit_name = serializers.CharField(required=False, allow_blank=True, max_length=20, default='')
|
||
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='')
|
||
|
||
def validate(self, attrs):
|
||
unit_amount = attrs.get('unit_amount')
|
||
quantity = attrs.get('quantity')
|
||
has_unit_amount = unit_amount is not None
|
||
has_quantity = quantity is not None
|
||
if has_unit_amount != has_quantity:
|
||
raise serializers.ValidationError('unit_amount 和 quantity 必须同时填写或同时为空')
|
||
if not has_unit_amount and attrs.get('amount') is None:
|
||
raise serializers.ValidationError('普通支出必须填写 amount;倍数型支出必须填写 unit_amount 和 quantity')
|
||
return attrs
|
||
|
||
|
||
class EntryUpdateSerializer(serializers.Serializer):
|
||
category_id = serializers.IntegerField(required=False, min_value=1)
|
||
amount = serializers.DecimalField(
|
||
required=False, allow_null=True, max_digits=15, decimal_places=2, min_value=0,
|
||
)
|
||
unit_amount = serializers.DecimalField(
|
||
required=False, allow_null=True, max_digits=15, decimal_places=4, min_value=0,
|
||
)
|
||
quantity = serializers.DecimalField(
|
||
required=False, allow_null=True, max_digits=12, decimal_places=4, min_value=0,
|
||
)
|
||
unit_name = serializers.CharField(required=False, allow_blank=True, max_length=20)
|
||
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,
|
||
)
|
||
|
||
try:
|
||
entry = cost_services.create_cost_entry(
|
||
merchant=employee.merchant,
|
||
category=category,
|
||
occurred_at=data['occurred_at'],
|
||
amount=data.get('amount'),
|
||
unit_amount=data.get('unit_amount'),
|
||
quantity=data.get('quantity'),
|
||
unit_name=data.get('unit_name', ''),
|
||
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', ''),
|
||
)
|
||
except DjangoValidationError as exc:
|
||
raise serializers.ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
||
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:
|
||
try:
|
||
entry.save(update_fields=update_fields)
|
||
except DjangoValidationError as exc:
|
||
raise serializers.ValidationError(exc.message_dict if hasattr(exc, 'message_dict') else exc.messages)
|
||
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,
|
||
}) |