"""sales async ViewSets。 除标准 CRUD 外,提供开单页需要的动作接口: - `create_bill`(POST /sales/bills/create-bill/):按行解析单位/取价/抹零后建草稿单 - `confirm_bill`(POST /sales/bills//confirm/):过账(402 = 信用超限,可 force 放行) 两个接口都把 services 层的业务异常翻译成前端可判别的错误码/字段: - 402 + `{code: "credit_limit_exceeded", ...}` → 前端弹"强制过账"确认框 - 400 + `{code: "below_min_price", ...}` → 前端弹"低于最低售价"审批放行确认框 """ from asgiref.sync import sync_to_async from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import ValidationError from rest_framework.response import Response from apps.core.services import BelowMinPrice, InvalidLinePrice, InvalidLineQuantity from apps.core.viewset import BaseTenantViewSet from .models import SalesOrder, SalesBill from .serializers import SalesOrderSerializer, SalesBillSerializer from . import services as sales_services class SalesOrderViewSet(BaseTenantViewSet): model = SalesOrder serializer_class = SalesOrderSerializer search_fields = ["bill_no", "customer__code", "customer__name"] select_related_fields = ("customer", "warehouse") prefetch_related_fields = ( "lines", "lines__product", "lines__source_unit", "lines__product__base_unit", ) class SalesBillViewSet(BaseTenantViewSet): model = SalesBill serializer_class = SalesBillSerializer search_fields = ["bill_no", "customer__code", "customer__name"] # 列表要展示客户名、仓库、明细及每行商品/单位 → 全部预取,消除 N+1 # 列表要展示客户名、仓库、明细及每行商品/单位 → 全部预取,消除 N+1 select_related_fields = ("customer", "warehouse") prefetch_related_fields = ( "lines", "lines__product", "lines__source_unit", "lines__product__base_unit", ) # ------------------------------------------------------------ # 建单 # ------------------------------------------------------------ @action(detail=False, methods=["post"], url_path="create-bill") async def create_bill(self, request): """POST /api/v1/sales/bills/create-bill/ body: {customer, warehouse, bill_date?, remark?, round_to?, lines: [ {product, quantity, unit_price?, source_unit?, allow_below_min?}]} """ tenant = await self.get_tenant() if tenant is None: raise ValidationError({"tenant": "无法识别租户"}) payload = request.data or {} # 套餐配额:月度单据量(批次 C1) from apps.billing import quota as billing_quota def _quota_check(): return billing_quota.check_and_count(tenant, "bills_monthly", delta=1) try: await sync_to_async(_quota_check)() except billing_quota.QuotaExceeded as exc: return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN) customer_id, warehouse_id = payload.get("customer"), payload.get("warehouse") lines = payload.get("lines") or [] if not customer_id or not warehouse_id: raise ValidationError({"detail": "customer 与 warehouse 必填"}) if not lines: raise ValidationError({"detail": "lines 不能为空"}) if not isinstance(lines, list): raise ValidationError({"detail": "lines 必须是数组"}) # 逐行结构校验:非 dict 或缺 product 直接 400(否则下游 .get() 会 500) for idx, ln in enumerate(lines, start=1): if not isinstance(ln, dict): raise ValidationError({"detail": f"第 {idx} 行明细格式错误(应为对象)"}) if not ln.get("product"): raise ValidationError({"detail": f"第 {idx} 行缺少 product"}) from apps.partner.models import Customer from apps.inventory.models import Warehouse from apps.catalog.models import Product def _load_refs(): customer = Customer.objects.filter(tenant=tenant, pk=customer_id).first() warehouse = Warehouse.objects.filter(tenant=tenant, pk=warehouse_id).first() products = { p.id: p for p in Product.objects.filter( tenant=tenant, pk__in=[ln.get("product") for ln in lines] ) } return customer, warehouse, products customer, warehouse, products = await sync_to_async(_load_refs)() if customer is None: raise ValidationError({"customer": "客户不存在"}) if warehouse is None: raise ValidationError({"warehouse": "仓库不存在"}) resolved_lines = [] for ln in lines: product = products.get(ln.get("product")) if product is None: raise ValidationError({"lines": f"商品 {ln.get('product')} 不存在"}) resolved_lines.append({ "product": product, "quantity": ln.get("quantity"), "unit_price": ln.get("unit_price"), "source_unit": ln.get("source_unit"), "allow_below_min": bool(ln.get("allow_below_min")), }) from datetime import date as _date kwargs = { "tenant": tenant, "customer": customer, "warehouse": warehouse, "lines": resolved_lines, "remark": payload.get("remark") or "", "round_to": payload.get("round_to") or None, } bill_date = payload.get("bill_date") if bill_date: kwargs["bill_date"] = _date.fromisoformat(bill_date) # 业务异常(BelowMinPrice / InvalidLine* / InsufficientStock)由全局 # 异常处理器统一翻译为 4xx + code,见 apps.core.exceptions try: bill = await sync_to_async(sales_services.create_sales_bill)(**kwargs) except ValueError as exc: return Response( {"code": "invalid_line", "detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) data = await sync_to_async(lambda: SalesBillSerializer(bill).data)() return Response(data, status=status.HTTP_201_CREATED) # ------------------------------------------------------------ # 过账 # ------------------------------------------------------------ @action(detail=True, methods=["post"], url_path="confirm") async def confirm_bill(self, request, pk=None): """POST /api/v1/sales/bills//confirm/ body: {force?: bool}""" bill = await self.aget_object() force = bool((request.data or {}).get("force")) def _confirm(): return sales_services.confirm_sales_bill( bill, user=request.user if request.user.is_authenticated else None, force=force, ) # CreditLimitExceeded → 402 / InsufficientStock → 400 均由全局处理器映射 try: await sync_to_async(_confirm)() except ValueError as exc: return Response( {"code": "invalid_state", "detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST, ) def _reload(): bill.refresh_from_db() return SalesBillSerializer(bill).data data = await sync_to_async(_reload)() return Response(data)