"""B2B 订货商城 API(批次 D1)。 客户端(H5,JWT 与后台用户分离,用商城 session token): - POST /api/v1/storefront/login/ 手机号+密码 → token - GET /api/v1/storefront/catalog/ 可见商品目录(含商城价/单位) - POST /api/v1/storefront/orders/ 提交订单 - GET /api/v1/storefront/orders/ 我的订单 内部端(后台用户 JWT): - GET /api/v1/storefront/admin/orders/ 待确认订单列表 - POST /api/v1/storefront/admin/orders//confirm/ 确认转销售订单草稿 - POST /api/v1/storefront/admin/orders//reject/ 驳回 - GET/POST /api/v1/storefront/admin/accounts/ 商城账号管理 - GET/POST /api/v1/storefront/admin/auths/ 商品授权管理 配额:商城能力受 `billing` 的 `storefront` 功能开关约束(专业版起)。 """ from asgiref.sync import sync_to_async from adrf.decorators import api_view from rest_framework import status from rest_framework.decorators import authentication_classes, permission_classes from rest_framework.permissions import AllowAny from rest_framework.response import Response from rest_framework.exceptions import ValidationError from apps.core.viewset import resolve_tenant from . import services as sf_services from .models import ( StorefrontAccount, StorefrontOrder, StorefrontOrderLine, CustomerProductAuth, issue_session_token, verify_session_token, ) async def _tenant(request): code = request.META.get("HTTP_X_TENANT_ID", "") tenant = await sync_to_async(resolve_tenant)(code) if tenant is None: raise ValidationError({"tenant": "无法识别租户"}) return tenant def _check_storefront_feature(tenant): """商城是专业版功能:走 billing 功能开关(未装 billing 则放行)。""" try: from apps.billing import quota as billing_quota billing_quota.check_and_count(tenant, "storefront") except ImportError: return except Exception as exc: # QuotaExceeded 直接冒泡给调用方翻译成 403 raise exc def _account_from_request(request): """从 Authorization: Storefront 解析商城账号。""" raw = request.META.get("HTTP_AUTHORIZATION", "") token = raw.split(" ", 1)[1].strip() if " " in raw else "" return verify_session_token(token) # ============================================================ # 客户端 # ============================================================ @api_view(["POST"]) @authentication_classes([]) @permission_classes([AllowAny]) async def login(request): """商城登录:{phone, password, tenant_code?} → {token, customer}""" from django.conf import settings payload = request.data or {} phone = (payload.get("phone") or "").strip() password = payload.get("password") or "" tenant_code = (payload.get("tenant_code") or request.META.get("HTTP_X_TENANT_ID") or settings.TENANT_DEFAULT) if not phone or not password: raise ValidationError({"detail": "phone 与 password 必填"}) def _do(): tenant = resolve_tenant(tenant_code) if tenant is None: return None, None _check_storefront_feature(tenant) account = StorefrontAccount.objects.filter( tenant=tenant, phone=phone, is_active=True ).select_related("customer").first() if account is None or not account.check_password(password): return None, None from django.utils import timezone account.last_login_at = timezone.now() account.save(update_fields=["last_login_at"]) return tenant, account try: tenant, account = await sync_to_async(_do)() except Exception as exc: code = getattr(exc, "as_dict", None) if code: return Response(exc.as_dict(), status=status.HTTP_403_FORBIDDEN) raise if account is None: return Response({"code": "invalid_credentials", "detail": "手机号或密码不正确"}, status=status.HTTP_401_UNAUTHORIZED) token = await sync_to_async(issue_session_token)(account) return Response({ "token": token, "customer": {"id": account.customer_id, "code": account.customer.code, "name": account.customer.name}, "display_name": account.display_name or account.customer.name, }) @api_view(["GET"]) @authentication_classes([]) @permission_classes([AllowAny]) async def catalog(request): """可见商品目录(未授权不可见)。""" tenant = await _tenant(request) def _do(): account = _account_from_request(request) if account is None: return None, None _check_storefront_feature(tenant) return account, sf_services.catalog_for( account.customer, search=request.query_params.get("search", "") ) account, rows = await sync_to_async(_do)() if account is None: return Response({"code": "unauthorized", "detail": "请先登录"}, status=status.HTTP_401_UNAUTHORIZED) return Response({"count": len(rows), "results": rows}) @api_view(["GET", "POST"]) @authentication_classes([]) @permission_classes([AllowAny]) async def my_orders(request): """我的订单:GET 列表 / POST 提交。""" tenant = await _tenant(request) def _get_account(): account = _account_from_request(request) if account is None: return None _check_storefront_feature(tenant) return account account = await sync_to_async(_get_account)() if account is None: return Response({"code": "unauthorized", "detail": "请先登录"}, status=status.HTTP_401_UNAUTHORIZED) if request.method == "GET": def _list(): qs = ( StorefrontOrder.objects.filter(tenant=tenant, customer=account.customer) .prefetch_related("lines__product") .order_by("-created_at")[:50] ) return [ { "id": o.id, "order_no": o.order_no, "status": o.status, "status_name": o.get_status_display(), "total_amount": str(o.total_amount), "created_at": o.created_at.isoformat(), "remark": o.remark, "lines": [ {"product_code": ln.product.code, "product_name": ln.product.name, "quantity": str(ln.source_quantity or ln.quantity), "unit_price": str(ln.unit_price), "amount": str(ln.amount)} for ln in o.lines.all() ], } for o in qs ] return Response({"results": await sync_to_async(_list)()}) # POST 提交订单 payload = request.data or {} lines = payload.get("lines") or [] remark = payload.get("remark") or "" def _submit(): return sf_services.submit_order( account.customer, account=account, lines=lines, remark=remark ) # 商城业务异常由全局处理器映射(403/402/400) order = await sync_to_async(_submit)() return Response({ "id": order.id, "order_no": order.order_no, "total_amount": str(order.total_amount), "status": order.status, "message": "订单已提交,等待业务员确认", }, status=status.HTTP_201_CREATED) # ============================================================ # 内部端(后台用户) # ============================================================ @api_view(["GET"]) async def admin_orders(request): """内部:商城订单列表(默认只看待确认)。""" tenant = await _tenant(request) status_filter = request.query_params.get("status", "submitted") def _list(): qs = StorefrontOrder.objects.filter(tenant=tenant).select_related("customer") if status_filter and status_filter != "all": qs = qs.filter(status=status_filter) return list(qs.prefetch_related("lines__product").order_by("-created_at")[:100]) orders = await sync_to_async(_list)() return Response({"results": [ { "id": o.id, "order_no": o.order_no, "status": o.status, "status_name": o.get_status_display(), "customer": {"id": o.customer_id, "code": o.customer.code, "name": o.customer.name}, "total_amount": str(o.total_amount), "created_at": o.created_at.isoformat(), "remark": o.remark, "sales_order_id": o.sales_order_id, "lines": [ {"product_code": ln.product.code, "product_name": ln.product.name, "quantity": str(ln.source_quantity or ln.quantity), "unit_price": str(ln.unit_price), "amount": str(ln.amount)} for ln in o.lines.all() ], } for o in orders ]}) @api_view(["POST"]) async def admin_confirm_order(request, order_id): """内部:确认商城订单 → 生成 SalesOrder 草稿。""" tenant = await _tenant(request) payload = request.data or {} warehouse_id = payload.get("warehouse") def _do(): order = StorefrontOrder.objects.filter( tenant=tenant, pk=order_id ).select_related("customer").first() if order is None: return "not_found", None from apps.inventory.models import Warehouse warehouse = None if warehouse_id: warehouse = Warehouse.objects.filter(tenant=tenant, pk=warehouse_id).first() if warehouse is None: warehouse = Warehouse.objects.filter( tenant=tenant, is_active=True ).order_by("-is_default", "id").first() if warehouse is None: return "no_warehouse", None try: sf_services.confirm_order(order, warehouse=warehouse) except sf_services.StorefrontError as exc: return "bad_state", str(exc) return "ok", order result, payload_out = await sync_to_async(_do)() if result == "not_found": return Response({"detail": "订单不存在"}, status=status.HTTP_404_NOT_FOUND) if result == "no_warehouse": return Response({"detail": "请先创建仓库"}, status=status.HTTP_400_BAD_REQUEST) if result == "bad_state": return Response({"detail": payload_out}, status=status.HTTP_400_BAD_REQUEST) order = payload_out return Response({ "ok": True, "order_no": order.order_no, "status": order.status, "sales_order_id": order.sales_order_id, "message": "已转为销售订单草稿,可在销售开单页确认过账", }) @api_view(["POST"]) async def admin_reject_order(request, order_id): """内部:驳回商城订单。""" tenant = await _tenant(request) reason = (request.data or {}).get("reason", "") def _do(): order = StorefrontOrder.objects.filter(tenant=tenant, pk=order_id).first() if order is None: return None try: sf_services.reject_order(order, reason=reason) except sf_services.StorefrontError: return None return order order = await sync_to_async(_do)() if order is None: return Response({"detail": "订单不存在或状态不允许驳回"}, status=status.HTTP_400_BAD_REQUEST) return Response({"ok": True, "order_no": order.order_no, "status": order.status}) @api_view(["GET", "POST"]) async def admin_accounts(request): """内部:商城账号管理。POST {customer_id, phone, password, display_name?}""" tenant = await _tenant(request) if request.method == "GET": def _list(): return list( StorefrontAccount.objects.filter(tenant=tenant) .select_related("customer").order_by("-created_at")[:200] ) accounts = await sync_to_async(_list)() return Response({"results": [ { "id": a.id, "phone": a.phone, "is_active": a.is_active, "display_name": a.display_name, "customer": {"id": a.customer_id, "code": a.customer.code, "name": a.customer.name}, "last_login_at": a.last_login_at.isoformat() if a.last_login_at else None, } for a in accounts ]}) payload = request.data or {} customer_id = payload.get("customer_id") phone = (payload.get("phone") or "").strip() password = payload.get("password") or "" if not (customer_id and phone and password): raise ValidationError({"detail": "customer_id / phone / password 必填"}) def _create(): from apps.partner.models import Customer customer = Customer.objects.filter(tenant=tenant, pk=customer_id).first() if customer is None: return None account, created = StorefrontAccount.objects.get_or_create( tenant=tenant, phone=phone, defaults={"customer": customer, "display_name": payload.get("display_name") or customer.name}, ) if created: account.set_password(password) account.save(update_fields=["password_hash"]) return account account = await sync_to_async(_create)() if account is None: return Response({"detail": "客户不存在"}, status=status.HTTP_404_NOT_FOUND) return Response({"id": account.id, "phone": account.phone, "customer_id": account.customer_id}, status=status.HTTP_201_CREATED) @api_view(["GET", "POST", "DELETE"]) async def admin_auths(request): """内部:商品授权。GET ?customer_id= / POST {customer_id, product_ids} / DELETE ?id=""" tenant = await _tenant(request) if request.method == "GET": customer_id = request.query_params.get("customer_id") def _list(): qs = CustomerProductAuth.objects.filter( tenant=tenant, is_active=True ).select_related("customer", "product") if customer_id: qs = qs.filter(customer_id=customer_id) return list(qs[:500]) rows = await sync_to_async(_list)() return Response({"results": [ { "id": a.id, "customer": {"id": a.customer_id, "code": a.customer.code, "name": a.customer.name}, "product": {"id": a.product_id, "code": a.product.code, "name": a.product.name}, } for a in rows ]}) if request.method == "DELETE": auth_id = request.query_params.get("id") if not auth_id: raise ValidationError({"detail": "id 必填"}) def _del(): return CustomerProductAuth.objects.filter( tenant=tenant, pk=auth_id ).update(is_deleted=True, is_active=False) n = await sync_to_async(_del)() return Response({"ok": True, "deleted": n}) payload = request.data or {} customer_id = payload.get("customer_id") product_ids = payload.get("product_ids") or [] if not customer_id or not product_ids: raise ValidationError({"detail": "customer_id 与 product_ids 必填"}) def _grant(): created = 0 for pid in product_ids: _, was = CustomerProductAuth.objects.get_or_create( tenant=tenant, customer_id=customer_id, product_id=pid, defaults={"is_active": True}, ) created += 1 if was else 0 return created created = await sync_to_async(_grant)() return Response({"ok": True, "created": created}, status=status.HTTP_201_CREATED)