620 lines
25 KiB
Python
620 lines
25 KiB
Python
"""完整财务 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<token>[^/.]+)/revoke")
|
||
async def revoke_share(self, request, token=None):
|
||
"""吊销对账单分享:POST /api/v1/finance/statements/share/<token>/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/<token>/(过期或吊销返回 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)
|