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
View File
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class StorefrontConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.storefront"
verbose_name = "B2B 订货商城"
@@ -0,0 +1,130 @@
# Generated by Django 5.2.12 on 2026-09-10 14:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('catalog', '0002_product_is_batch_managed_product_min_sale_price_and_more'),
('core', '0001_initial'),
('partner', '0002_customerproductprice'),
('sales', '0002_salesbill_round_off_salesbillline_source_quantity_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StorefrontAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('ext_data', models.JSONField(blank=True, default=dict)),
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
('phone', models.CharField(help_text='登录手机号', max_length=32)),
('password_hash', models.CharField(max_length=128)),
('display_name', models.CharField(blank=True, default='', max_length=64)),
('is_active', models.BooleanField(default=True)),
('last_login_at', models.DateTimeField(blank=True, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='storefront_accounts', to='partner.customer')),
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': '商城客户账号',
'db_table': 'storefront_account',
'ordering': ['-created_at'],
'unique_together': {('tenant', 'phone')},
},
),
migrations.CreateModel(
name='StorefrontOrder',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('ext_data', models.JSONField(blank=True, default=dict)),
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
('order_no', models.CharField(max_length=64)),
('status', models.CharField(choices=[('submitted', '已提交待确认'), ('confirmed', '已确认转单'), ('rejected', '已驳回')], default='submitted', max_length=16)),
('total_amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
('remark', models.TextField(blank=True, default='')),
('account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='storefront.storefrontaccount')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='storefront_orders', to='partner.customer')),
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
('sales_order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='storefront_orders', to='sales.salesorder')),
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': '商城订单',
'db_table': 'storefront_order',
'ordering': ['-created_at'],
'unique_together': {('tenant', 'order_no')},
},
),
migrations.CreateModel(
name='StorefrontOrderLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('ext_data', models.JSONField(blank=True, default=dict)),
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
('quantity', models.DecimalField(decimal_places=4, max_digits=18)),
('unit_price', models.DecimalField(decimal_places=4, max_digits=18)),
('amount', models.DecimalField(decimal_places=4, default=0, max_digits=18)),
('source_quantity', models.DecimalField(blank=True, decimal_places=4, max_digits=18, null=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='storefront.storefrontorder')),
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='storefront_lines', to='catalog.product')),
('source_unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='storefront_lines', to='catalog.unit')),
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'storefront_order_line',
'ordering': ['id'],
},
),
migrations.CreateModel(
name='CustomerProductAuth',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_deleted', models.BooleanField(default=False)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('ext_data', models.JSONField(blank=True, default=dict)),
('source_channel', models.CharField(blank=True, default='manual', max_length=32)),
('is_active', models.BooleanField(default=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_auths', to='partner.customer')),
('org', models.ForeignKey(blank=True, help_text='所属组织(多机构预留)', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.org')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='customer_auths', to='catalog.product')),
('tenant', models.ForeignKey(help_text='所属租户', on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.tenant')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name_plural': '客户可见商品授权',
'db_table': 'storefront_product_auth',
'ordering': ['customer', 'product'],
'unique_together': {('tenant', 'customer', 'product')},
},
),
]
+199
View File
@@ -0,0 +1,199 @@
"""B2B 订货商城模型(批次 D1)。
设计:
- `StorefrontAccount`:客户在商城的登录凭证(与后台用户体系分离,**不占用户席**,
不消耗 billing 的 users 配额)。
- `CustomerProductAuth`:客户可见商品的白名单(默认不可见,授予才可见 —— 白名单比
黑名单安全,漏配不会误暴露)。
- 价格复用 `partner.CustomerProductPrice` + `quote_price` 的既有优先级,
不在商城侧另建价格体系(避免两处价格打架)。
下单走 **SalesOrder 草稿**(内部确认后才转销售单),客户不能直接过账出库。
"""
from __future__ import annotations
import hashlib
import secrets
from django.db import models
from apps.core.base_models import TenantScopedModel
def _hash_token(raw: str) -> str:
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
class StorefrontAccount(TenantScopedModel):
"""商城客户账号(手机号 + 密码,独立于后台用户)。"""
customer = models.ForeignKey(
"partner.Customer", on_delete=models.CASCADE, related_name="storefront_accounts"
)
phone = models.CharField(max_length=32, help_text="登录手机号")
password_hash = models.CharField(max_length=128)
display_name = models.CharField(max_length=64, blank=True, default="")
is_active = models.BooleanField(default=True)
last_login_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "storefront_account"
unique_together = [("tenant", "phone")]
verbose_name_plural = "商城客户账号"
ordering = ["-created_at"]
def __str__(self):
return f"{self.phone} → {self.customer.code}"
# ---- 口令 ----
def set_password(self, raw: str) -> None:
salt = secrets.token_hex(8)
digest = hashlib.pbkdf2_hmac(
"sha256", raw.encode("utf-8"), salt.encode("utf-8"), 120_000
).hex()
self.password_hash = f"pbkdf2${salt}${digest}"
def check_password(self, raw: str) -> bool:
try:
algo, salt, digest = self.password_hash.split("$", 2)
except ValueError:
return False
if algo != "pbkdf2":
return False
candidate = hashlib.pbkdf2_hmac(
"sha256", raw.encode("utf-8"), salt.encode("utf-8"), 120_000
).hex()
return secrets.compare_digest(candidate, digest)
class CustomerProductAuth(TenantScopedModel):
"""客户可见商品授权(白名单)。"""
customer = models.ForeignKey(
"partner.Customer", on_delete=models.CASCADE, related_name="product_auths"
)
product = models.ForeignKey(
"catalog.Product", on_delete=models.CASCADE, related_name="customer_auths"
)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "storefront_product_auth"
unique_together = [("tenant", "customer", "product")]
verbose_name_plural = "客户可见商品授权"
ordering = ["customer", "product"]
def __str__(self):
return f"{self.customer.code} → {self.product.code}"
class StorefrontOrder(TenantScopedModel):
"""商城订单(客户自助提交,对内是 SalesOrder 草稿的来源单)。"""
STATUS_SUBMITTED = "submitted"
STATUS_CONFIRMED = "confirmed"
STATUS_REJECTED = "rejected"
STATUS_CHOICES = [
(STATUS_SUBMITTED, "已提交待确认"),
(STATUS_CONFIRMED, "已确认转单"),
(STATUS_REJECTED, "已驳回"),
]
order_no = models.CharField(max_length=64)
customer = models.ForeignKey(
"partner.Customer", on_delete=models.PROTECT, related_name="storefront_orders"
)
account = models.ForeignKey(
StorefrontAccount, null=True, blank=True,
on_delete=models.SET_NULL, related_name="orders",
)
status = models.CharField(max_length=16, choices=STATUS_CHOICES,
default=STATUS_SUBMITTED)
total_amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
remark = models.TextField(blank=True, default="")
sales_order = models.ForeignKey(
"sales.SalesOrder", null=True, blank=True,
on_delete=models.SET_NULL, related_name="storefront_orders",
)
class Meta:
db_table = "storefront_order"
unique_together = [("tenant", "order_no")]
verbose_name_plural = "商城订单"
ordering = ["-created_at"]
def __str__(self):
return f"{self.order_no} ({self.get_status_display()})"
class StorefrontOrderLine(TenantScopedModel):
"""商城订单行(价格在下单时快照,避免事后调价影响历史单)。"""
order = models.ForeignKey(
StorefrontOrder, on_delete=models.CASCADE, related_name="lines"
)
product = models.ForeignKey(
"catalog.Product", on_delete=models.PROTECT, related_name="storefront_lines"
)
quantity = models.DecimalField(max_digits=18, decimal_places=4)
unit_price = models.DecimalField(max_digits=18, decimal_places=4)
amount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
source_unit = models.ForeignKey(
"catalog.Unit", null=True, blank=True,
on_delete=models.SET_NULL, related_name="storefront_lines",
)
source_quantity = models.DecimalField(
max_digits=18, decimal_places=4, null=True, blank=True
)
class Meta:
db_table = "storefront_order_line"
ordering = ["id"]
def __str__(self):
return f"{self.product.code} × {self.quantity}"
# ------------------------------------------------------------
# 会话 token(无状态签名,省一张表;密钥取 SECRET_KEY)
# ------------------------------------------------------------
def issue_session_token(account: StorefrontAccount, *, ttl_hours: int = 72) -> str:
"""签发商城会话 token:`account_id.expires.sig`(HMAC-SHA256)。"""
import time
from django.conf import settings
expires = int(time.time()) + ttl_hours * 3600
payload = f"{account.id}.{expires}"
sig = hashlib.sha256(
f"{payload}.{settings.SECRET_KEY}".encode("utf-8")
).hexdigest()[:32]
return f"{payload}.{sig}"
def verify_session_token(token: str):
"""校验 token,返回 StorefrontAccount 或 None。"""
import time
from django.conf import settings
if not token or token.count(".") != 2:
return None
raw_id, raw_exp, sig = token.split(".")
expect = hashlib.sha256(
f"{raw_id}.{raw_exp}.{settings.SECRET_KEY}".encode("utf-8")
).hexdigest()[:32]
if not secrets.compare_digest(expect, sig):
return None
try:
if int(raw_exp) < int(time.time()):
return None
account_id = int(raw_id)
except ValueError:
return None
return StorefrontAccount.objects.filter(
pk=account_id, is_active=True
).select_related("customer", "tenant").first()
+294
View File
@@ -0,0 +1,294 @@
"""B2B 订货商城服务(批次 D1)。
关键约束(计划要求):
- 未授权商品**不可见**(白名单),客户拿不到目录外的商品
- 价格复用 `partner.services.quote_price`(客户专属价 → 等级价 → 最近成交价 → 默认价),
不在商城侧另立一套价格
- 下单生成 **SalesOrder 草稿**,不直接过账;额度超限在提交时就拦截
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from django.db import transaction
from apps.catalog.models import Product, UnitConversion
from apps.catalog.services import UnitConversionNotFound, to_base
class StorefrontError(Exception):
"""商城业务错误(调用方翻译为 400)。"""
class ProductNotAuthorized(StorefrontError):
"""商品未授权给该客户。"""
class CreditLimitExceeded(StorefrontError):
"""客户信用额度不足(提交即拦,不等内部确认)。"""
def visible_products(customer, *, search: str = "") -> list:
"""客户可见商品(授权白名单 ∩ 启用中 ∩ 未删)。"""
from .models import CustomerProductAuth
auths = CustomerProductAuth.objects.filter(
customer=customer, is_active=True, is_deleted=False
).values_list("product_id", flat=True)
qs = Product.objects.filter(
tenant=customer.tenant, id__in=list(auths),
status=Product.STATUS_ACTIVE, is_deleted=False,
).select_related("base_unit", "category", "brand")
if search:
from django.db.models import Q
qs = qs.filter(Q(name__icontains=search) | Q(code__icontains=search)
| Q(barcode__icontains=search))
return list(qs.order_by("code"))
def is_authorized(customer, product) -> bool:
from .models import CustomerProductAuth
return CustomerProductAuth.objects.filter(
customer=customer, product=product, is_active=True, is_deleted=False
).exists()
def price_for(customer, product) -> dict:
"""商城价:复用后台取价引擎(单一价格来源)。"""
from apps.partner.services import quote_price
return quote_price(tenant=customer.tenant, customer=customer, product=product)
def catalog_for(customer, *, search: str = "") -> list:
"""商城商品目录(含商城价与可用单位)。"""
rows = []
for p in visible_products(customer, search=search):
q = price_for(customer, p)
units = []
if p.base_unit_id:
units.append({
"unit_id": p.base_unit_id, "unit_name": p.base_unit.name,
"rate": "1", "is_base": True, "price": str(q["price"]),
})
for c in UnitConversion.objects.filter(product=p).select_related("unit"):
units.append({
"unit_id": c.unit_id, "unit_name": c.unit.name,
"rate": str(c.rate), "is_base": False,
"price": str(q["price"] * c.rate),
})
rows.append({
"product_id": p.id,
"code": p.code,
"name": p.name,
"spec": p.spec,
"category": p.category.name if p.category else "",
"sale_price": str(p.sale_price),
"price": str(q["price"]),
"price_source": q["source"],
"base_unit_name": p.base_unit.name if p.base_unit else "",
"units": units,
})
return rows
def _next_order_no(tenant) -> str:
"""取候选商城单号(唯一性由 create_with_unique_bill_no 重试保证)。
旧实现用 `count() + 1`:并发下单会撞号、删除后会复用——与销售单号同一类问题。
"""
from apps.core.services import next_bill_no
from .models import StorefrontOrder
return next_bill_no(tenant, "HD", StorefrontOrder, date_str=None,
field="order_no")
@transaction.atomic
def submit_order(customer, *, account=None, lines: list, remark: str = "",
warehouse=None) -> "StorefrontOrder":
"""客户自助下单:校验授权 + 逐行取价 + 额度预检 → 生成 StorefrontOrder。
lines: [{product_id, quantity, source_unit?}]
"""
from apps.partner.services import credit_usage
from .models import StorefrontOrder, StorefrontOrderLine
if not lines:
raise StorefrontError("请至少选择一件商品")
if not isinstance(lines, list):
raise StorefrontError("订单明细格式错误(应为数组)")
# 逐行结构校验:非 dict 直接拒绝,避免下游 AttributeError → 500
for idx, ln in enumerate(lines, start=1):
if not isinstance(ln, dict):
raise StorefrontError(f"第 {idx} 行明细格式错误")
resolved = []
total = Decimal("0")
for ln in lines:
product = Product.objects.filter(
tenant=customer.tenant, pk=ln.get("product_id"), is_deleted=False
).first()
if product is None:
raise StorefrontError(f"商品不存在:{ln.get('product_id')}")
if not is_authorized(customer, product):
# 未授权商品:明确拒绝(不静默忽略,便于排查)
raise ProductNotAuthorized(f"商品 {product.code} 未对您开放")
try:
qty = Decimal(str(ln.get("quantity") or 0))
except Exception:
raise StorefrontError(f"数量格式错误:{ln.get('quantity')}")
if qty <= 0:
raise StorefrontError(f"商品 {product.code} 数量必须大于 0")
src_unit = None
src_qty = None
unit_id = ln.get("source_unit")
if unit_id:
src_unit = UnitConversion.objects.filter(
product=product, unit_id=unit_id
).select_related("unit").first()
if src_unit is None and product.base_unit_id == unit_id:
src_unit = None # 基本单位按无换算处理
src_qty = qty
elif src_unit is None:
raise StorefrontError(f"商品 {product.code} 不支持该单位")
else:
src_qty = qty
try:
base_qty = to_base(product, src_unit.unit if src_unit else None, qty)
except UnitConversionNotFound as exc:
raise StorefrontError(str(exc))
q = price_for(customer, product)
unit_price = Decimal(str(q["price"]))
if src_unit is not None:
unit_price = unit_price * src_unit.rate
amount = qty * unit_price
total += amount
resolved.append({
"product": product, "quantity": base_qty,
"unit_price": unit_price, "amount": amount,
"source_unit": src_unit.unit if src_unit else None,
"source_quantity": src_qty,
})
# 额度预检:商城单提交即拦(避免内部确认时才发现)
usage = credit_usage(tenant=customer.tenant, customer=customer)
if usage["limit"] > 0 and usage["outstanding"] + total > usage["limit"]:
raise CreditLimitExceeded(
f"订单金额 ¥{total:.2f} 超出可用额度"
f"(已用 ¥{usage['outstanding']:.2f} / 额度 ¥{usage['limit']:.2f})"
)
from apps.core.services import create_with_unique_bill_no
order = create_with_unique_bill_no(
StorefrontOrder,
tenant=customer.tenant,
prefix="HD",
field="order_no",
defaults=dict(
customer=customer,
account=account,
total_amount=total,
remark=remark,
ext_data={"warehouse_id": getattr(warehouse, "id", None)},
),
)
for row in resolved:
StorefrontOrderLine.objects.create(
tenant=customer.tenant, order=order,
product=row["product"], quantity=row["quantity"],
unit_price=row["unit_price"], amount=row["amount"],
source_unit=row["source_unit"], source_quantity=row["source_quantity"],
)
return order
@transaction.atomic
def confirm_order(order, *, warehouse, user=None) -> "StorefrontOrder":
"""内部确认:把商城订单转成 SalesOrder 草稿(不直接过账出库)。"""
from apps.sales.models import SalesOrder, SalesOrderLine
from apps.sales.services import _generate_bill_no
if order.status != "submitted":
raise StorefrontError(f"订单状态为 {order.get_status_display()},无法确认")
from apps.core.services import create_with_unique_bill_no
so = create_with_unique_bill_no(
SalesOrder,
tenant=order.tenant,
prefix="SO",
defaults=dict(
customer=order.customer,
warehouse=warehouse,
bill_date=date.today(),
total_amount=order.total_amount,
state="draft",
remark=f"来自商城订单 {order.order_no}",
),
)
for ln in order.lines.select_related("product", "source_unit"):
SalesOrderLine.objects.create(
tenant=order.tenant, order=so, product=ln.product,
quantity=ln.quantity, unit_price=ln.unit_price, amount=ln.amount,
source_unit=ln.source_unit, source_quantity=ln.source_quantity,
)
order.status = "confirmed"
order.sales_order = so
order.save(update_fields=["status", "sales_order", "updated_at"])
return order
def reject_order(order, *, reason: str = "") -> "StorefrontOrder":
"""内部驳回。"""
if order.status != "submitted":
raise StorefrontError("只能驳回待确认的订单")
order.status = "rejected"
if reason:
order.remark = (order.remark + "\n" if order.remark else "") + f"驳回原因:{reason}"
order.save(update_fields=["status", "remark", "updated_at"])
else:
order.save(update_fields=["status", "updated_at"])
return order
def seed_demo_storefront(tenant) -> dict:
"""给演示租户开商城账号 + 授权商品(幂等)。"""
from apps.partner.models import Customer
from .models import CustomerProductAuth, StorefrontAccount
created = {"accounts": 0, "auths": 0}
customers = Customer.objects.filter(tenant=tenant, is_active=True)
for c in customers:
account, was_created = StorefrontAccount.objects.get_or_create(
tenant=tenant, phone=c.phone or f"139{c.id:08d}",
defaults={"customer": c, "display_name": c.name},
)
if was_created:
account.set_password("store12345")
account.save(update_fields=["password_hash"])
created["accounts"] += 1
# 授权该客户前 4 个商品(演示用,真实场景按合同授)
for p in Product.objects.filter(tenant=tenant, status="active")[:4]:
_, auth_created = CustomerProductAuth.objects.get_or_create(
tenant=tenant, customer=c, product=p, defaults={"is_active": True}
)
created["auths"] += 1 if auth_created else 0
return created
+22
View File
@@ -0,0 +1,22 @@
"""B2B 订货商城路由。"""
from django.urls import path
from .views import (
login, catalog, my_orders,
admin_orders, admin_confirm_order, admin_reject_order,
admin_accounts, admin_auths,
)
urlpatterns = [
# 客户端(H5)
path("login/", login, name="sf-login"),
path("catalog/", catalog, name="sf-catalog"),
path("orders/", my_orders, name="sf-my-orders"),
# 内部端
path("admin/orders/", admin_orders, name="sf-admin-orders"),
path("admin/orders/<int:order_id>/confirm/", admin_confirm_order, name="sf-admin-confirm"),
path("admin/orders/<int:order_id>/reject/", admin_reject_order, name="sf-admin-reject"),
path("admin/accounts/", admin_accounts, name="sf-admin-accounts"),
path("admin/auths/", admin_auths, name="sf-admin-auths"),
]
+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)