Files

458 lines
17 KiB
Python
Raw Permalink 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.
"""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)