Files
dealerhub/backend/apps/sales/views.py
T

178 lines
7.4 KiB
Python
Raw 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.
"""sales async ViewSets。
除标准 CRUD 外,提供开单页需要的动作接口:
- `create_bill`(POST /sales/bills/create-bill/):按行解析单位/取价/抹零后建草稿单
- `confirm_bill`(POST /sales/bills/<id>/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/<id>/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)