baseline: 批次A-D 成果 + membership 半成品(测试红)

This commit is contained in:
agent
2026-09-11 23:11:35 +08:00
commit b3f3095d53
311 changed files with 40540 additions and 0 deletions
+432
View File
@@ -0,0 +1,432 @@
"""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
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)