"""完整财务 async ViewSets: 1. 业务往来:ReceivableViewSet, PayableViewSet, ReceiptViewSet, PaymentViewSet, AllocationViewSet 2. 基础科目与期间:AccountViewSet, PeriodViewSet 3. 记账凭证与分录:VoucherViewSet, VoucherEntryViewSet 4. 财务报表:ReportViewSet(科目余额表、资产负债表、利润表、明细分类账) """ from decimal import Decimal from adrf.decorators import api_view from rest_framework.decorators import ( authentication_classes, permission_classes, ) from rest_framework.permissions import AllowAny from rest_framework import status from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.exceptions import ValidationError from asgiref.sync import sync_to_async from adrf.viewsets import ViewSet from apps.core.viewset import BaseTenantViewSet from .models import ( Account, Period, Voucher, VoucherEntry, Receivable, Payable, Receipt, Payment, Allocation, StatementShare, ) from .serializers import ( ReceivableSerializer, PayableSerializer, ReceiptSerializer, PaymentSerializer, AllocationSerializer, ReceiptAllocationInputSerializer, PaymentAllocationInputSerializer, AccountSerializer, PeriodSerializer, VoucherSerializer, VoucherEntrySerializer, VoucherCreateInputSerializer, ) from . import services as fs from apps.partner.models import Customer # ============================================================ # 1. 业务往来 ViewSets # ============================================================ class ReceivableViewSet(BaseTenantViewSet): model = Receivable serializer_class = ReceivableSerializer search_fields = ["bill_no", "customer__code", "customer__name", "source_ref"] select_related_fields = ("customer",) class PayableViewSet(BaseTenantViewSet): model = Payable serializer_class = PayableSerializer search_fields = ["bill_no", "supplier__code", "supplier__name", "source_ref"] select_related_fields = ("supplier",) class ReceiptViewSet(BaseTenantViewSet): model = Receipt serializer_class = ReceiptSerializer search_fields = ["bill_no", "customer__code", "customer__name", "method"] select_related_fields = ("customer",) @action(detail=True, methods=["post"]) async def allocate(self, request, pk=None): """将收款单全额核销到一张或多张同客户应收单。""" tenant = await self.get_tenant() if tenant is None: raise ValidationError({"tenant": "无法识别租户"}) receipt = await sync_to_async( lambda: Receipt.objects.filter( tenant=tenant, pk=pk, is_deleted=False ).select_related("customer").first() )() if receipt is None: return Response({"detail": "收款单不存在"}, status=status.HTTP_404_NOT_FOUND) serializer = ReceiptAllocationInputSerializer(data=request.data or {}) serializer.is_valid(raise_exception=True) items = serializer.validated_data["allocations"] receivable_ids = [item["receivable"] for item in items] def _load_receivables(): return { obj.id: obj for obj in Receivable.objects.filter( tenant=tenant, is_deleted=False, pk__in=receivable_ids ).select_related("customer") } receivables = await sync_to_async(_load_receivables)() missing = [doc_id for doc_id in receivable_ids if doc_id not in receivables] if missing: raise ValidationError({ "allocations": f"应收单不存在或不属于当前租户: {', '.join(map(str, missing))}" }) allocation_rows = [ (receivables[item["receivable"]], item["amount"]) for item in items ] try: created = await sync_to_async(fs.allocate_receipt)(receipt, allocation_rows) except ValueError as exc: return Response( {"code": "invalid_allocation", "detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) receipt = await sync_to_async( lambda: Receipt.objects.select_related("customer").get(pk=receipt.pk) )() response_data = await sync_to_async(lambda: { "ok": True, "receipt": ReceiptSerializer(receipt).data, "allocations": AllocationSerializer(created, many=True).data, })() return Response(response_data) class PaymentViewSet(BaseTenantViewSet): model = Payment serializer_class = PaymentSerializer search_fields = ["bill_no", "supplier__code", "supplier__name", "method"] select_related_fields = ("supplier",) @action(detail=True, methods=["post"]) async def allocate(self, request, pk=None): """将付款单全额核销到一张或多张同供应商应付单。""" tenant = await self.get_tenant() if tenant is None: raise ValidationError({"tenant": "无法识别租户"}) payment = await sync_to_async( lambda: Payment.objects.filter( tenant=tenant, pk=pk, is_deleted=False ).select_related("supplier").first() )() if payment is None: return Response({"detail": "付款单不存在"}, status=status.HTTP_404_NOT_FOUND) serializer = PaymentAllocationInputSerializer(data=request.data or {}) serializer.is_valid(raise_exception=True) items = serializer.validated_data["allocations"] payable_ids = [item["payable"] for item in items] def _load_payables(): return { obj.id: obj for obj in Payable.objects.filter( tenant=tenant, is_deleted=False, pk__in=payable_ids ).select_related("supplier") } payables = await sync_to_async(_load_payables)() missing = [doc_id for doc_id in payable_ids if doc_id not in payables] if missing: raise ValidationError({ "allocations": f"应付单不存在或不属于当前租户: {', '.join(map(str, missing))}" }) allocation_rows = [ (payables[item["payable"]], item["amount"]) for item in items ] try: created = await sync_to_async(fs.allocate_payment)(payment, allocation_rows) except ValueError as exc: return Response( {"code": "invalid_allocation", "detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) payment = await sync_to_async( lambda: Payment.objects.select_related("supplier").get(pk=payment.pk) )() response_data = await sync_to_async(lambda: { "ok": True, "payment": PaymentSerializer(payment).data, "allocations": AllocationSerializer(created, many=True).data, })() return Response(response_data) class AllocationViewSet(BaseTenantViewSet): model = Allocation serializer_class = AllocationSerializer http_method_names = ["get", "head", "options"] # ============================================================ # 2. 会计科目与期间 ViewSets # ============================================================ class AccountViewSet(BaseTenantViewSet): model = Account serializer_class = AccountSerializer search_fields = ["code", "name"] @action(detail=False, methods=["post"], url_path="init-chart") async def init_chart(self, request): """为当前租户一键初始化新准则预置会计科目。""" tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) created_count = await sync_to_async(fs.init_chart_of_accounts)(tenant) return Response({ "ok": True, "created_count": created_count, "message": f"成功初始化 {created_count} 个标准会计科目", }) class PeriodViewSet(BaseTenantViewSet): model = Period serializer_class = PeriodSerializer search_fields = ["code"] @action(detail=True, methods=["post"]) async def close(self, request, pk=None): """期末结转与结账。""" tenant = await self.get_tenant() period = await sync_to_async(self.model.objects.get)(tenant=tenant, pk=pk) user = request.user if request.user.is_authenticated else None try: voucher = await sync_to_async(fs.close_period)(period, user=user) voucher_no = voucher.bill_no if voucher else None return Response({ "ok": True, "period": period.code, "status": "closed", "close_voucher": voucher_no, }) except fs.VoucherError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=["post"]) async def reopen(self, request, pk=None): """反结账(重新开放期间)。""" tenant = await self.get_tenant() period = await sync_to_async(self.model.objects.get)(tenant=tenant, pk=pk) user = request.user if request.user.is_authenticated else None try: await sync_to_async(fs.reopen_period)(period, user=user) return Response({ "ok": True, "period": period.code, "status": "open", }) except fs.VoucherError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) # ============================================================ # 3. 记账凭证 ViewSets # ============================================================ class VoucherViewSet(BaseTenantViewSet): model = Voucher serializer_class = VoucherSerializer search_fields = ["bill_no", "summary", "source_ref"] async def acreate(self, request, *args, **kwargs): """自定义创建凭证:支持嵌套 entries 提交,并在后端执行借贷平衡与科目校验。""" tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) serializer = VoucherCreateInputSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data user = request.user if request.user.is_authenticated else None try: voucher = await sync_to_async(fs.create_voucher)( tenant=tenant, voucher_date=data["voucher_date"], entries=data["entries"], summary=data.get("summary", ""), source_type="manual", user=user, auto_post=data.get("auto_post", False), ) except fs.VoucherError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) # 重新取回完整数据并序列化 data = await sync_to_async(lambda: VoucherSerializer(voucher).data)() return Response(data, status=status.HTTP_201_CREATED) @action(detail=True, methods=["post"]) async def post(self, request, pk=None): """凭证过账。""" tenant = await self.get_tenant() try: voucher = await sync_to_async(self.model.objects.get)(tenant=tenant, pk=pk) except self.model.DoesNotExist: return Response({"detail": "凭证不存在"}, status=status.HTTP_404_NOT_FOUND) user = request.user if request.user.is_authenticated else None try: voucher = await sync_to_async(fs.post_voucher)(voucher, user=user) data = await sync_to_async(lambda: VoucherSerializer(voucher).data)() return Response(data) except fs.VoucherError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) @action(detail=True, methods=["post"]) async def cancel(self, request, pk=None): """凭证作废。""" tenant = await self.get_tenant() try: voucher = await sync_to_async(self.model.objects.get)(tenant=tenant, pk=pk) except self.model.DoesNotExist: return Response({"detail": "凭证不存在"}, status=status.HTTP_404_NOT_FOUND) user = request.user if request.user.is_authenticated else None try: voucher = await sync_to_async(fs.cancel_voucher)(voucher, user=user) data = await sync_to_async(lambda: VoucherSerializer(voucher).data)() return Response(data) except fs.VoucherError as e: return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST) class VoucherEntryViewSet(BaseTenantViewSet): model = VoucherEntry serializer_class = VoucherEntrySerializer search_fields = ["summary", "voucher__bill_no", "account__code", "account__name"] http_method_names = ["get", "head", "options"] # ============================================================ # 4. 财务报表 ViewSet # ============================================================ class ReportViewSet(ViewSet): """财务三大主表及明细分类账。""" async def get_tenant(self): from apps.core.viewset import resolve_tenant from django.conf import settings code = self.request.META.get("HTTP_X_TENANT_ID", settings.TENANT_DEFAULT) return await sync_to_async(resolve_tenant)(code) @action(detail=False, methods=["get"], url_path="trial-balance") async def trial_balance(self, request): """科目余额表(试算平衡表)。参数:period=YYYY-MM(可选)。""" tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) period_code = request.query_params.get("period") period = None if period_code: period = await sync_to_async(Period.objects.filter(tenant=tenant, code=period_code).first)() data = await sync_to_async(fs.trial_balance)(tenant, period=period) return Response(data) @action(detail=False, methods=["get"], url_path="balance-sheet") async def balance_sheet(self, request): """资产负债表。参数:period=YYYY-MM(可选,默认为当前累计)。""" tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) period_code = request.query_params.get("period") period = None if period_code: period = await sync_to_async(Period.objects.filter(tenant=tenant, code=period_code).first)() data = await sync_to_async(fs.balance_sheet)(tenant, period=period) return Response(data) @action(detail=False, methods=["get"], url_path="income-statement") async def income_statement(self, request): """利润表。参数:period=YYYY-MM(默认为当月)。""" tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) period_code = request.query_params.get("period") if period_code: period = await sync_to_async(Period.objects.filter(tenant=tenant, code=period_code).first)() else: period = await sync_to_async(fs.current_period)(tenant) if not period: return Response({"detail": f"期间 {period_code} 不存在"}, status=400) data = await sync_to_async(fs.income_statement)(tenant, period=period) return Response(data) @action(detail=False, methods=["get"], url_path="ledger") async def ledger(self, request): """明细分类账。参数:account=科目编码(必填),period=YYYY-MM(可选)。""" tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) account_code = request.query_params.get("account") if not account_code: return Response({"detail": "参数 account (科目代码) 必填"}, status=400) account = await sync_to_async( Account.objects.filter(tenant=tenant, code=account_code, is_active=True).first )() if not account: return Response({"detail": f"科目 {account_code} 不存在"}, status=404) period_code = request.query_params.get("period") period = None if period_code: period = await sync_to_async(Period.objects.filter(tenant=tenant, code=period_code).first)() data = await sync_to_async(fs.account_ledger)(tenant, account, period=period) return Response(data) # ============================================================ # P0 #6:应收账龄 + 客户对账单 + 公开分享 # ============================================================ class AgingStatementViewSet(ViewSet): """账龄分析与对账单。""" async def get_tenant(self): from apps.core.viewset import resolve_tenant from django.conf import settings code = self.request.META.get("HTTP_X_TENANT_ID", settings.TENANT_DEFAULT) return await sync_to_async(resolve_tenant)(code) @action(detail=False, methods=["get"], url_path="receivable-aging") async def receivable_aging(self, request): """应收账龄:?as_of=YYYY-MM-DD(可选)&customer_id=(可选)。""" from datetime import date as _date tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) as_of = request.query_params.get("as_of") as_of_d = _date.fromisoformat(as_of) if as_of else None customer_id = request.query_params.get("customer_id") customer = None if customer_id: customer = await sync_to_async( Customer.objects.filter(tenant=tenant, pk=customer_id).first )() if customer is None: return Response({"detail": "customer not found"}, status=404) data = await sync_to_async(fs.receivable_aging)( tenant, as_of=as_of_d, customer=customer ) if customer is not None: data["customer"] = {"id": customer.id, "code": customer.code, "name": customer.name} return Response(data) @action(detail=False, methods=["get"], url_path="statement") async def statement(self, request): """客户对账单:?customer_id=&date_from=&date_to=(必填)。""" from datetime import date as _date tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) customer_id = request.query_params.get("customer_id") date_from = request.query_params.get("date_from") date_to = request.query_params.get("date_to") if not (customer_id and date_from and date_to): raise ValidationError({"detail": "customer_id/date_from/date_to 必填"}) customer = await sync_to_async( Customer.objects.filter(tenant=tenant, pk=customer_id).first )() if customer is None: return Response({"detail": "customer not found"}, status=404) data = await sync_to_async(fs.customer_statement)( tenant, customer, date_from=_date.fromisoformat(date_from), date_to=_date.fromisoformat(date_to), ) return Response(data) @action(detail=False, methods=["get", "post"], url_path="share") async def share(self, request): """对账单分享:GET 列出本租户全部分享 / POST 生成新链接。 POST body: {customer_id, date_from, date_to, expires_days?} 注意:DRF 中同一 url_path 只能由一个 action 承载,两个 action 同名同路径会 互相覆盖(后者胜出,前者变 405),因此这里按 request.method 分派。 """ tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) if request.method == "POST": return await self._create_share(request, tenant) shares = await sync_to_async(list)( StatementShare.objects.filter(tenant=tenant).select_related("customer") .order_by("-created_at")[:50] ) return Response([ { "token": str(s.token), "url": f"/api/v1/open/statements/{s.token}/", "customer": {"id": s.customer.id, "code": s.customer.code, "name": s.customer.name}, "date_from": s.date_from.isoformat(), "date_to": s.date_to.isoformat(), "expires_at": s.expires_at.isoformat() if s.expires_at else None, "revoked": s.revoked, } for s in shares ]) async def _create_share(self, request, tenant): """生成对账单公开分享链接(POST /share/ 的实际实现)。""" from datetime import date as _date, timedelta as _td from django.utils import timezone as _tz from uuid import uuid4 payload = request.data customer_id = payload.get("customer_id") date_from = payload.get("date_from") date_to = payload.get("date_to") if not (customer_id and date_from and date_to): raise ValidationError({"detail": "customer_id/date_from/date_to 必填"}) customer = await sync_to_async( Customer.objects.filter(tenant=tenant, pk=customer_id).first )() if customer is None: return Response({"detail": "customer not found"}, status=404) expires_days = int(payload.get("expires_days") or 7) share = await sync_to_async(StatementShare.objects.create)( tenant=tenant, customer=customer, token=uuid4(), date_from=_date.fromisoformat(date_from), date_to=_date.fromisoformat(date_to), expires_at=_tz.now() + _td(days=expires_days), created_by=request.user if request.user.is_authenticated else None, ) return Response({ "token": str(share.token), "url": f"/api/v1/open/statements/{share.token}/", "expires_at": share.expires_at.isoformat(), "customer": {"id": customer.id, "code": customer.code, "name": customer.name}, }, status=201) @action(detail=False, methods=["post"], url_path=r"share/(?P[^/.]+)/revoke") async def revoke_share(self, request, token=None): """吊销对账单分享:POST /api/v1/finance/statements/share//revoke/""" tenant = await self.get_tenant() if not tenant: raise ValidationError({"tenant": "无法识别租户"}) share = await sync_to_async( StatementShare.objects.filter(tenant=tenant, token=token).first )() if share is None: return Response({"detail": "share not found"}, status=404) share.revoked = True await sync_to_async(share.save)(update_fields=["revoked", "updated_at"]) return Response({"ok": True, "token": str(share.token), "revoked": True}) @api_view(["GET"]) @authentication_classes([]) @permission_classes([AllowAny]) async def public_statement(request, token): """匿名对账单:GET /api/v1/open/statements//(过期或吊销返回 404)。 view=html(或浏览器 Accept: text/html)返回客户侧可打印页面; view=json(默认)返回结构化数据。 注意:参数名不能用 format——DRF 用它做 URL_FORMAT_OVERRIDE 内容协商, 传 format=html 会因找不到 html renderer 直接 404。 """ from datetime import date as _date from django.utils import timezone as _tz from rest_framework.response import Response def _load(): s = StatementShare.objects.filter(token=token).select_related( "customer", "tenant" ).first() if s is None or s.revoked: return None if s.expires_at and _tz.now() > s.expires_at: return None return s share = await sync_to_async(_load)() if share is None: return Response({"detail": "not found"}, status=404) data = await sync_to_async(fs.customer_statement)( share.tenant, share.customer, date_from=share.date_from, date_to=share.date_to, ) generated_at = _tz.localtime().strftime("%Y-%m-%d %H:%M") data["share"] = { "token": str(share.token), "generated_at": _tz.now().isoformat(), } fmt = (request.query_params.get("view") or "").lower() accept = (request.META.get("HTTP_ACCEPT") or "").lower() # 浏览器(Accept: text/html)或显式 view=html 才出客户侧页面;其余走 JSON wants_html = fmt == "html" or ( fmt != "json" and "text/html" in accept and "application/json" not in accept ) if wants_html: from django.http import HttpResponse from .statement_html import render_statement_html html_out = await sync_to_async(render_statement_html)(data, generated_at=generated_at) return HttpResponse(html_out, content_type="text/html; charset=utf-8") return Response(data)