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