166 lines
6.6 KiB
Python
166 lines
6.6 KiB
Python
"""purchase async ViewSets。
|
||
|
||
开单页动作接口:
|
||
- `create_bill`(POST /purchase/bills/create-bill/):建草稿(支持批次号/生产日/到期日/录入单位)
|
||
- `confirm_bill`(POST /purchase/bills/<id>/confirm/):过账(写库存 + 生成应付)
|
||
"""
|
||
|
||
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 InvalidLinePrice, InvalidLineQuantity
|
||
from apps.core.viewset import BaseTenantViewSet
|
||
|
||
from .models import PurchaseOrder, PurchaseBill
|
||
from .serializers import PurchaseOrderSerializer, PurchaseBillSerializer
|
||
from . import services as purchase_services
|
||
|
||
|
||
class PurchaseOrderViewSet(BaseTenantViewSet):
|
||
model = PurchaseOrder
|
||
serializer_class = PurchaseOrderSerializer
|
||
search_fields = ["bill_no", "supplier__code", "supplier__name"]
|
||
select_related_fields = ("supplier", "warehouse")
|
||
prefetch_related_fields = (
|
||
"lines", "lines__product", "lines__source_unit", "lines__product__base_unit",
|
||
)
|
||
|
||
|
||
class PurchaseBillViewSet(BaseTenantViewSet):
|
||
model = PurchaseBill
|
||
serializer_class = PurchaseBillSerializer
|
||
search_fields = ["bill_no", "supplier__code", "supplier__name"]
|
||
select_related_fields = ("supplier", "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/purchase/bills/create-bill/
|
||
|
||
body: {supplier, warehouse, bill_date?, remark?, lines: [
|
||
{product, quantity, unit_price, source_unit?, batch_no?,
|
||
production_date?, expiry_date?}]}
|
||
"""
|
||
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)
|
||
|
||
supplier_id, warehouse_id = payload.get("supplier"), payload.get("warehouse")
|
||
lines = payload.get("lines") or []
|
||
if not supplier_id or not warehouse_id:
|
||
raise ValidationError({"detail": "supplier 与 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 Supplier
|
||
from apps.inventory.models import Warehouse
|
||
from apps.catalog.models import Product
|
||
|
||
def _load_refs():
|
||
supplier = Supplier.objects.filter(tenant=tenant, pk=supplier_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 supplier, warehouse, products
|
||
|
||
supplier, warehouse, products = await sync_to_async(_load_refs)()
|
||
if supplier is None:
|
||
raise ValidationError({"supplier": "供应商不存在"})
|
||
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')} 不存在"})
|
||
if ln.get("unit_price") in (None, ""):
|
||
raise ValidationError({"lines": f"商品 {product.code} 缺少进货单价"})
|
||
resolved_lines.append({
|
||
"product": product,
|
||
"quantity": ln.get("quantity"),
|
||
"unit_price": ln.get("unit_price"),
|
||
"source_unit": ln.get("source_unit"),
|
||
"batch_no": ln.get("batch_no") or "",
|
||
"production_date": ln.get("production_date") or None,
|
||
"expiry_date": ln.get("expiry_date") or None,
|
||
})
|
||
|
||
from datetime import date as _date
|
||
|
||
kwargs = {
|
||
"tenant": tenant,
|
||
"supplier": supplier,
|
||
"warehouse": warehouse,
|
||
"lines": resolved_lines,
|
||
"remark": payload.get("remark") or "",
|
||
}
|
||
bill_date = payload.get("bill_date")
|
||
if bill_date:
|
||
kwargs["bill_date"] = _date.fromisoformat(bill_date)
|
||
|
||
# InvalidLine* 等由全局异常处理器统一映射
|
||
try:
|
||
bill = await sync_to_async(purchase_services.create_purchase_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: PurchaseBillSerializer(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/purchase/bills/<id>/confirm/"""
|
||
bill = await self.aget_object()
|
||
|
||
def _confirm():
|
||
return purchase_services.confirm_purchase_bill(
|
||
bill, user=request.user if request.user.is_authenticated else None,
|
||
)
|
||
|
||
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 PurchaseBillSerializer(bill).data
|
||
|
||
data = await sync_to_async(_reload)()
|
||
return Response(data)
|