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
+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