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