Files

295 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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