458 lines
17 KiB
Python
458 lines
17 KiB
Python
"""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/<id>/confirm/ 确认转销售订单草稿
|
||
- POST /api/v1/storefront/admin/orders/<id>/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 <token> 解析商城账号。"""
|
||
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
|
||
|
||
from apps.core import ratelimit as rl
|
||
|
||
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 必填"})
|
||
|
||
# P0-5:商城登录同样防爆破(按 phone + IP)。
|
||
ip = rl.client_ip(request)
|
||
login_id = f"{tenant_code}:{phone}"
|
||
if rl.is_login_locked(login_id, ip):
|
||
return Response(
|
||
{"code": "login_locked", "detail": "登录失败次数过多,账号已临时锁定 15 分钟"},
|
||
status=status.HTTP_429_TOO_MANY_REQUESTS,
|
||
)
|
||
|
||
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:
|
||
rl.record_login_failure(login_id, ip)
|
||
return Response({"code": "invalid_credentials", "detail": "手机号或密码不正确"},
|
||
status=status.HTTP_401_UNAUTHORIZED)
|
||
|
||
rl.clear_login_failures(login_id, ip)
|
||
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):
|
||
"""内部:商城订单列表(默认只看待确认,支持 page/page_size 分页)。"""
|
||
tenant = await _tenant(request)
|
||
status_filter = request.query_params.get("status", "submitted")
|
||
try:
|
||
page = max(int(request.query_params.get("page", 1)), 1)
|
||
except (TypeError, ValueError):
|
||
page = 1
|
||
try:
|
||
page_size = int(request.query_params.get("page_size", 20))
|
||
except (TypeError, ValueError):
|
||
page_size = 20
|
||
page_size = min(max(page_size, 1), 200)
|
||
|
||
def _list():
|
||
qs = StorefrontOrder.objects.filter(tenant=tenant).select_related("customer")
|
||
if status_filter and status_filter != "all":
|
||
qs = qs.filter(status=status_filter)
|
||
total = qs.count()
|
||
rows = list(qs.prefetch_related("lines__product")
|
||
.order_by("-created_at")[(page - 1) * page_size:page * page_size])
|
||
return total, rows
|
||
|
||
total, orders = await sync_to_async(_list)()
|
||
return Response({"count": total, "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)
|