295 lines
11 KiB
Python
295 lines
11 KiB
Python
"""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
|