Files

620 lines
25 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""完整财务 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)