baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user