baseline: 批次A-D 成果 + membership 半成品(测试红)
This commit is contained in:
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user