""" Shipment 模块业务逻辑层 """ from __future__ import annotations from decimal import Decimal, InvalidOperation from typing import List from django.conf import settings from django.contrib.auth import get_user_model from django.db import transaction from django.db.models import Count, Exists, IntegerField, OuterRef, QuerySet, Subquery from django.db.models.functions import Coalesce from django.utils import timezone from api_v1.utils.wecom_webhook import send_wecom_webhook_message from shipment.models import ( ExternalFinishedProduct, SalesItem, SalesItemChangeRecord, SalesItemRebuildRecord, Shipment, ShipmentDelivery, ShipmentDeliveryStatus, ShipmentStatus, ) from shipment.signals import shipment_created UNSET = object() def _resolve_user_merchant(user): emp = getattr(user, "employee", None) return getattr(emp, "merchant", None) if emp else None def _resolve_user_employee(user): return getattr(user, "employee", None) def _user_label(user) -> str: if not user: return "系统自动发送" emp = getattr(user, "employee", None) name = getattr(emp, "name", None) if emp is not None else None if name: return str(name) username = getattr(user, "username", None) if username: return str(username) return "系统自动发送" def get_active_sales_items_queryset() -> QuerySet[SalesItem]: """ 返回未软删除的销售品查询集。 """ return SalesItem.objects.filter(delete_at__isnull=True) def ensure_sales_item_can_be_soft_deleted(sales_item: SalesItem) -> None: """ 校验销售品是否允许软删除。 当前规则: - 已关联出货单的销售品不允许删除 """ if sales_item.shipment_id is not None: raise ValueError("已关联出货单的销售品不允许删除") def _get_printing_job_for_sales_item(*, printing_job_id: int, merchant): from printing.models import PrintingJob try: return PrintingJob.objects.select_related("printing_order").get( id=printing_job_id, merchant=merchant, ) except PrintingJob.DoesNotExist: raise ValueError(f"生产任务 {printing_job_id} 不存在或不属于当前商户") def _normalize_sales_item_quantity(quantity: str | Decimal) -> Decimal: try: return Decimal(str(quantity)) except (InvalidOperation, ValueError, TypeError) as e: raise ValueError(f"数量 {quantity} 格式无效: {e}") def _resolve_delivery_shipments( *, shipment_ids: list[int], merchant, current_delivery: ShipmentDelivery | None = None, ) -> list[Shipment]: normalized_ids = list(dict.fromkeys(shipment_ids or [])) if not normalized_ids: return [] shipments = list( Shipment.objects.filter(id__in=normalized_ids, merchant=merchant) .select_related("customer", "delivery") .order_by("id") ) found_ids = {shipment.id for shipment in shipments} missing_ids = sorted(set(normalized_ids) - found_ids) if missing_ids: raise ValueError(f"以下出货单不存在或不属于当前商户: {missing_ids}") occupied_ids = sorted( shipment.id for shipment in shipments if shipment.delivery_id is not None and (current_delivery is None or shipment.delivery_id != current_delivery.id) ) if occupied_ids: raise ValueError(f"以下出货单已关联到其他送货单: {occupied_ids}") not_approved_ids = sorted( shipment.id for shipment in shipments if shipment.status != ShipmentStatus.APPROVED ) if not_approved_ids: raise ValueError(f"以下出货单未处于已审核状态,不能绑定到送货单: {not_approved_ids}") return shipments def get_customers_with_unshipped_sales_items(*, merchant) -> QuerySet: """ 查询当前商户下“存在未出货销售品”的客户列表。 返回 Customer 查询集,并附带: - unshipped_sales_items_count: 该客户未出货销售品数量 """ from basic_info.models import Customer base_sales_items = get_active_sales_items_queryset().filter( merchant=merchant, shipment__isnull=True, customer_id=OuterRef("pk"), ) count_subquery = ( base_sales_items.values("customer_id") .annotate(total=Count("id")) .values("total")[:1] ) return ( Customer.objects.filter(merchant=merchant) .annotate(has_unshipped_sales_items=Exists(base_sales_items)) .filter(has_unshipped_sales_items=True) .annotate( unshipped_sales_items_count=Coalesce( Subquery(count_subquery, output_field=IntegerField()), 0, ) ) .order_by("id") ) def get_sales_items_by_printing_order( printing_order_id: int | str, include_already_has_shipment: bool = False, merchant=None, ) -> QuerySet[SalesItem]: """ 通过生产订单 ID 或 external_order_id 查询对应的销售品。 Args: printing_order_id: 生产订单内部 ID,或 external_order_id include_already_has_shipment: 是否包含已关联出货单的销售品,默认为 False merchant: 可选的商户约束;传入后会限定只在该商户下解析生产订单 Returns: SalesItem 查询集 """ from printing.models import PrintingJob, PrintingOrder raw_value = str(printing_order_id).strip() order_queryset = PrintingOrder.objects.all() if merchant is not None: order_queryset = order_queryset.filter(merchant=merchant) resolved_order_ids: list[int] = [] # 优先按内部 ID 解析,保持和当前 API 路径语义一致。 if raw_value.isdigit(): resolved_order_ids = list( order_queryset.filter(id=int(raw_value)).values_list("id", flat=True)[:1] ) # 内部 ID 未命中时,再按 external_order_id 查询。 if not resolved_order_ids and raw_value: resolved_order_ids = list( order_queryset.filter(external_order_id=raw_value).values_list( "id", flat=True ) ) if not resolved_order_ids: return SalesItem.objects.none().select_related("shipment").order_by("id") # 1. 获取目标生产订单下所有 PrintingJob 的 ID job_ids = PrintingJob.objects.filter( printing_order_id__in=resolved_order_ids ).values_list("id", flat=True) # 2. 查询 SalesItem,过滤 printing_job_id 在这些 job_ids 中 queryset = get_active_sales_items_queryset().filter( printing_job_id__in=list(job_ids) ) # 3. 根据参数决定是否过滤已出货的销售品 if not include_already_has_shipment: queryset = queryset.filter(shipment__isnull=True) return queryset.select_related("shipment").order_by("id") def get_sales_items_by_customer( *, merchant, customer_id: int, include_already_has_shipment: bool = False, external_order_id: str | None = None, ) -> QuerySet[SalesItem]: """ 通过客户ID查询对应销售品。 仅返回当前商户下、customer_id 匹配的销售品。 """ queryset = get_active_sales_items_queryset().filter( merchant=merchant, customer_id=customer_id, ) if external_order_id: from printing.models import PrintingJob printing_job_ids = PrintingJob.objects.filter( merchant=merchant, printing_order__external_order_id=external_order_id, ).values_list("id", flat=True) queryset = queryset.filter(printing_job_id__in=list(printing_job_ids)) if not include_already_has_shipment: queryset = queryset.filter(shipment__isnull=True) return queryset.select_related("shipment").order_by("id") @transaction.atomic def update_sales_item( sales_item: SalesItem, *, quantity: str | Decimal | None = None, remark: str | None = None, position: str | None = None, operator=None, ) -> SalesItem: """ 更新销售品的非关系字段。 当前仅允许修改: - quantity - remark - position """ changes: dict[str, tuple[object, object]] = {} if quantity is not None: try: normalized_quantity = Decimal(str(quantity)) except (InvalidOperation, ValueError, TypeError) as exc: raise ValueError(f"数量 {quantity} 格式无效: {exc}") if normalized_quantity != sales_item.quantity: changes["quantity"] = (sales_item.quantity, normalized_quantity) sales_item.quantity = normalized_quantity if remark is not None: normalized_remark = remark or "" if normalized_remark != sales_item.remark: changes["remark"] = (sales_item.remark, normalized_remark) sales_item.remark = normalized_remark if position is not None: normalized_position = position or "" if normalized_position != sales_item.position: changes["position"] = (sales_item.position, normalized_position) sales_item.position = normalized_position if not changes: return sales_item sales_item.save(update_fields=[*changes.keys(), "updated_at"]) def _serialize_value(value): if isinstance(value, Decimal): return str(value) return value SalesItemChangeRecord.objects.create( sales_item=sales_item, operator=operator, operated_at=timezone.now(), before_values={ field: _serialize_value(old_value) for field, (old_value, _) in changes.items() }, after_values={ field: _serialize_value(new_value) for field, (_, new_value) in changes.items() }, ) return sales_item @transaction.atomic def delete_sales_item( sales_item: SalesItem, *, deleted_by=None, ) -> SalesItem: """ 软删除销售品。 """ if sales_item.delete_at is not None: return sales_item ensure_sales_item_can_be_soft_deleted(sales_item) sales_item.delete_at = timezone.now() sales_item.delete_by = deleted_by sales_item.save(update_fields=["delete_at", "delete_by", "updated_at"]) return sales_item @transaction.atomic def rebuild_sales_item( sales_item: SalesItem, *, new_printing_job_id: int, operator, quantity: str | Decimal | None = None, ) -> SalesItem: """ 软删除旧销售品,并基于新 printing_job 重建一个新销售品。 """ ensure_sales_item_can_be_soft_deleted(sales_item) merchant = sales_item.merchant printing_job = _get_printing_job_for_sales_item( printing_job_id=new_printing_job_id, merchant=merchant, ) new_quantity = ( _normalize_sales_item_quantity(quantity) if quantity is not None else sales_item.quantity ) deleted_sales_item = delete_sales_item(sales_item, deleted_by=operator) customer_id = sales_item.customer_id if customer_id is None and printing_job.printing_order and printing_job.printing_order.customer: customer_id = printing_job.printing_order.customer_id new_sales_item = SalesItem.objects.create( shipment=None, merchant=merchant, name=sales_item.name, quantity=new_quantity, unit=sales_item.unit, created_by=sales_item.created_by, printing_job_id=printing_job.id, customer_id=customer_id, remark=sales_item.remark, position=sales_item.position, ) SalesItemRebuildRecord.objects.create( old_sales_item=deleted_sales_item, new_sales_item=new_sales_item, operator=operator, rebuilt_at=timezone.now(), old_printing_job_id=deleted_sales_item.printing_job_id, new_printing_job_id=printing_job.id, old_quantity=deleted_sales_item.quantity, new_quantity=new_sales_item.quantity, ) return new_sales_item @transaction.atomic def create_shipment_delivery( *, driver_name: str, vehicle_trip: str, contact_phone: str = "", vehicle_capacity: str = "", shipment_ids: list[int], shipment_order_ids: list | None = None, created_by, remark: str = "", internal_remark: str = "", ) -> ShipmentDelivery: merchant = _resolve_user_merchant(created_by) operator = _resolve_user_employee(created_by) if not merchant: raise ValueError("用户未关联商户,无法创建送货单") driver_name = (driver_name or "").strip() vehicle_trip = (vehicle_trip or "").strip() if not driver_name: raise ValueError("driver_name 不能为空") if not vehicle_trip: raise ValueError("vehicle_trip 不能为空") shipments = _resolve_delivery_shipments( shipment_ids=shipment_ids, merchant=merchant, ) delivery = ShipmentDelivery.objects.create( merchant=merchant, driver_name=driver_name, vehicle_trip=vehicle_trip, contact_phone=(contact_phone or "").strip(), vehicle_capacity=(vehicle_capacity or "").strip(), remark=(remark or "").strip(), internal_remark=(internal_remark or "").strip(), shipment_order_ids=shipment_order_ids, created_by=created_by, operator=operator, ) if shipments: Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update( delivery=delivery ) return delivery @transaction.atomic def update_shipment_delivery( delivery: ShipmentDelivery, *, driver_name: str | None = None, vehicle_trip: str | None = None, contact_phone: str | None = None, vehicle_capacity: str | None = None, shipment_ids: list[int] | None = None, shipment_order_ids=UNSET, remark: str | None = None, internal_remark: str | None = None, operator=None, ) -> ShipmentDelivery: if driver_name is not None: normalized_driver_name = driver_name.strip() if not normalized_driver_name: raise ValueError("driver_name 不能为空") delivery.driver_name = normalized_driver_name if vehicle_trip is not None: normalized_vehicle_trip = vehicle_trip.strip() if not normalized_vehicle_trip: raise ValueError("vehicle_trip 不能为空") delivery.vehicle_trip = normalized_vehicle_trip if contact_phone is not None: delivery.contact_phone = (contact_phone or "").strip() if vehicle_capacity is not None: delivery.vehicle_capacity = (vehicle_capacity or "").strip() if remark is not None: delivery.remark = (remark or "").strip() if internal_remark is not None: delivery.internal_remark = (internal_remark or "").strip() if shipment_order_ids is not UNSET: delivery.shipment_order_ids = shipment_order_ids if shipment_ids is not None: shipments = _resolve_delivery_shipments( shipment_ids=shipment_ids, merchant=delivery.merchant, current_delivery=delivery, ) Shipment.objects.filter(delivery=delivery).exclude( id__in=[shipment.id for shipment in shipments] ).update(delivery=None) if shipments: Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update( delivery=delivery ) else: Shipment.objects.filter(delivery=delivery).update(delivery=None) if operator is not None: delivery.operator = _resolve_user_employee(operator) delivery.save() return delivery @transaction.atomic def bind_shipments_to_delivery( delivery: ShipmentDelivery, *, shipment_ids: list[int], operator=None, ) -> ShipmentDelivery: shipments = _resolve_delivery_shipments( shipment_ids=shipment_ids, merchant=delivery.merchant, current_delivery=delivery, ) if not shipments: return delivery Shipment.objects.filter(id__in=[shipment.id for shipment in shipments]).update( delivery=delivery ) if operator is not None: delivery.operator = _resolve_user_employee(operator) delivery.save(update_fields=["operator", "updated_at"]) return delivery @transaction.atomic def modify_shipment_delivery_status( delivery: ShipmentDelivery, *, target_status: int, operator=None, ) -> ShipmentDelivery: try: target_status = int(target_status) except (TypeError, ValueError): raise ValueError("无效的送货单状态") current_status = delivery.status if current_status == target_status: return delivery allowed_transitions = { ShipmentDeliveryStatus.PENDING: {ShipmentDeliveryStatus.IN_TRANSIT}, ShipmentDeliveryStatus.IN_TRANSIT: {ShipmentDeliveryStatus.DELIVERED}, ShipmentDeliveryStatus.DELIVERED: set(), ShipmentDeliveryStatus.CANCELLED: set(), } if target_status not in ShipmentDeliveryStatus.values: raise ValueError("无效的送货单状态") if target_status == ShipmentDeliveryStatus.CANCELLED: raise ValueError("取消送货单请使用独立的取消接口") if target_status not in allowed_transitions.get(current_status, set()): raise ValueError( f"不允许将送货单状态从 {delivery.get_status_display()} 修改为 " f"{ShipmentDeliveryStatus(target_status).label}" ) now = timezone.now() delivery.status = target_status update_fields = ["status", "updated_at"] if operator is not None: delivery.operator = _resolve_user_employee(operator) update_fields.append("operator") if target_status == ShipmentDeliveryStatus.IN_TRANSIT: delivery.started_at = now update_fields.append("started_at") elif target_status == ShipmentDeliveryStatus.DELIVERED: delivery.delivered_at = now update_fields.append("delivered_at") delivery.save(update_fields=update_fields) return delivery @transaction.atomic def cancel_shipment_delivery( delivery: ShipmentDelivery, *, cancelled_by=None, operator=None, ) -> ShipmentDelivery: if delivery.status == ShipmentDeliveryStatus.CANCELLED: return delivery delivery.status = ShipmentDeliveryStatus.CANCELLED delivery.cancelled_at = timezone.now() delivery.cancelled_by = cancelled_by if operator is not None: delivery.operator = _resolve_user_employee(operator) delivery.save( update_fields=[ "status", "cancelled_at", "cancelled_by", "operator", "updated_at", ] ) return delivery @transaction.atomic def delete_shipment_delivery(delivery: ShipmentDelivery) -> None: Shipment.objects.filter(delivery=delivery).update(delivery=None) delivery.delete() @transaction.atomic def modify_status( shipment: Shipment, *, target_status: int, operator=None, approved_by=None, ) -> Shipment: """ 修改出货单状态,并记录状态修改时间。 规则: - 草稿 -> 只能已发布 - 已发布 -> 已审核 / 已驳回 / 已取消 - 已驳回 -> 已取消 - 已审核 -> 已取消 - 已取消 -> 不可再变 - 重复设置同一状态保持幂等,直接返回 """ current_status = shipment.status if current_status == target_status: return shipment allowed_transitions = { ShipmentStatus.DRAFT: {ShipmentStatus.PUBLISHED}, ShipmentStatus.PUBLISHED: { ShipmentStatus.APPROVED, ShipmentStatus.REJECTED, ShipmentStatus.CANCELLED, }, ShipmentStatus.REJECTED: { # 驳回现在会解绑销售品并回退到待分配池。 # 在没有重新绑定流程前,暂时关闭 REJECTED -> APPROVED,避免审核空出货单。 # ShipmentStatus.APPROVED, ShipmentStatus.CANCELLED, }, ShipmentStatus.APPROVED: { ShipmentStatus.CANCELLED, }, ShipmentStatus.CANCELLED: set(), } if target_status not in allowed_transitions.get(current_status, set()): raise ValueError( f"不允许将出货单状态从 {shipment.get_status_display()} 修改为 " f"{ShipmentStatus(target_status).label}" ) if target_status == ShipmentStatus.APPROVED and approved_by is None: raise ValueError("目标状态为已审核时,approved_by 不能为空") update_fields = [ "status", "status_modified_at", "cancelled_by", "approved_by", "updated_at", ] if target_status == ShipmentStatus.REJECTED: bound_sales_item_qs = SalesItem.objects.select_for_update().filter(shipment=shipment) shipment.rejected_sales_item_ids = list( bound_sales_item_qs.order_by("id").values_list("id", flat=True) ) bound_sales_item_qs.update(shipment=None) update_fields.append("rejected_sales_item_ids") shipment.status = target_status shipment.status_modified_at = timezone.now() if target_status == ShipmentStatus.CANCELLED: shipment.cancelled_by = operator if target_status == ShipmentStatus.APPROVED: shipment.approved_by = approved_by shipment.save(update_fields=update_fields) return shipment @transaction.atomic def update_shipment( shipment: Shipment, *, customer_id: int | None = None, shipment_date=None, address: str | None = None, contact_name: str | None = None, contact_phone: str | None = None, area: str | None = None, coordinates=UNSET, remark: str | None = None, external_id: str | None = None, extra=UNSET, ) -> Shipment: """ 更新出货单业务数据。 说明: - 仅允许修改未关联送货单的出货单 - 不处理状态变更 """ from basic_info.models import Customer if shipment.delivery_id is not None: raise ValueError("已关联送货单的出货单不允许修改") if customer_id is not None: customer = Customer.objects.filter(id=customer_id).first() if customer is None: raise ValueError(f"客户 {customer_id} 不存在") if customer.merchant_id != shipment.merchant_id: raise ValueError("无权限绑定该客户") shipment.customer = customer if shipment_date is not None: shipment.shipment_date = shipment_date if address is not None: shipment.address = address or "" if contact_name is not None: shipment.contact_name = contact_name or "" if contact_phone is not None: shipment.contact_phone = contact_phone or "" if area is not None: shipment.area = (area or "").strip() if coordinates is not UNSET: shipment.coordinates = coordinates or None if remark is not None: shipment.remark = remark or "" if external_id is not None: shipment.external_id = (external_id or "").strip() or None if extra is not UNSET: shipment.extra = extra shipment.save() return shipment @transaction.atomic @transaction.atomic def create_shipment( customer_id: int, shipment_date, sales_item_ids: List[int], created_by, address_id: int | None = None, address: str = "", contact_name: str = "", contact_phone: str = "", remark: str = "", area: str = "", coordinates: str | None = None, extra: dict | list | None = None, status: int = ShipmentStatus.DRAFT, ) -> Shipment: """ 创建出货单并关联销售品 Args: customer_id: 客户ID shipment_date: 出货日期 sales_item_ids: 要关联的销售品ID列表 created_by: 创建人 remark: 备注 Returns: 创建的 Shipment 实例 Raises: ValueError: 如果销售品不存在或已被关联到其他出货单 """ from basic_info.models import Customer, CustomerAddress # 验证客户存在 try: customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: raise ValueError(f"客户 {customer_id} 不存在") # merchant 隔离:必须能解析出当前用户 merchant emp = getattr(created_by, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: # superuser 也必须绑定 merchant(避免产生无法隔离的数据) raise ValueError("用户未关联商户,无法创建出货单") if customer.merchant_id != merchant.id: raise ValueError("无权限为该客户创建出货单") customer_address = None if address_id is not None: try: customer_address = CustomerAddress.objects.get(id=address_id) except CustomerAddress.DoesNotExist: raise ValueError(f"客户地址 {address_id} 不存在") if customer_address.deleted_at is not None: raise ValueError(f"客户地址 {address_id} 已删除") if customer_address.merchant_id != merchant.id: raise ValueError("无权限使用该客户地址") if customer_address.customer_id != customer.id: raise ValueError("客户地址与客户不匹配") # 验证销售品 if sales_item_ids: from printing.models import PrintingJob # 查询销售品 sales_items = get_active_sales_items_queryset().filter(id__in=sales_item_ids) found_ids = set(sales_items.values_list("id", flat=True)) missing_ids = set(sales_item_ids) - found_ids if missing_ids: raise ValueError(f"以下销售品不存在: {list(missing_ids)}") # 检查是否有已关联出货单的销售品 already_shipped = sales_items.filter(shipment__isnull=False) if already_shipped.exists(): shipped_ids = list(already_shipped.values_list("id", flat=True)) raise ValueError(f"以下销售品已关联到其他出货单: {shipped_ids}") sales_items_list = list(sales_items) missing_printing_job_ids = [ item.id for item in sales_items_list if not item.printing_job_id ] if missing_printing_job_ids: raise ValueError( f"以下销售品缺少关联生产任务,无法创建出货单: {missing_printing_job_ids}" ) printing_job_ids = {item.printing_job_id for item in sales_items_list} printing_job_map = { job.id: job.printing_order_id for job in PrintingJob.objects.filter(id__in=printing_job_ids).only( "id", "printing_order_id" ) } invalid_printing_job_ids = sorted(printing_job_ids - set(printing_job_map.keys())) if invalid_printing_job_ids: affected_item_ids = sorted( item.id for item in sales_items_list if item.printing_job_id in invalid_printing_job_ids ) raise ValueError( f"以下销售品关联的生产任务不存在,无法创建出货单: {affected_item_ids}" ) # 创建出货单 shipment = Shipment.objects.create( merchant=merchant, customer=customer, customer_address=customer_address, shipment_date=shipment_date, address=address or "", contact_name=contact_name or "", contact_phone=contact_phone or "", area=(area or "").strip(), coordinates=coordinates or None, remark=remark, extra=extra, status=status, created_by=created_by, ) # 关联销售品 if sales_item_ids: updated = get_active_sales_items_queryset().filter( id__in=sales_item_ids, merchant=merchant ).update(shipment=shipment) if updated != len(sales_item_ids): raise ValueError("存在不属于当前商户的销售品,无法关联到出货单") try: shipment_created.send( sender=Shipment, instance=shipment, created_by=created_by, ) except Exception: import logging logging.getLogger(__name__).exception( "[shipment.services] 触发 shipment_created signal 失败(已忽略)" ) return shipment def render_shipment_created_markdown( *, shipment_id: str, customer_name: str, shipment_date: str, items_count: str, sender_label: str, ) -> str: template = getattr( settings, "SHIPMENT_CREATED_WECOM_MARKDOWN_TEMPLATE", ( "### 出货单创建\n" "\n" "- **出货单ID**:`{shipment_id}`\n" "- **客户**:{customer_name}\n" "- **出货日期**:`{shipment_date}`\n" "- **销售品数量**:`{items_count}`\n" "- **发送者**:{sender}\n" ), ) return template.format( shipment_id=str(shipment_id or "-"), customer_name=str(customer_name or "-"), shipment_date=str(shipment_date or "-"), items_count=str(items_count or "0"), sender=str(sender_label or "系统自动发送"), ) def send_shipment_created_wecom( *, shipment_id: int, created_by_id: int | None = None, key: str | None = None, timeout_seconds: float = 10.0, dry_run: bool = False, ) -> dict: shipment = ( Shipment.objects.select_related("customer") .prefetch_related("items") .filter(id=int(shipment_id)) .first() ) if not shipment: raise ValueError(f"Shipment 不存在:id={shipment_id}") created_by = None if created_by_id: created_by = get_user_model().objects.filter(id=int(created_by_id)).first() customer_name = getattr(getattr(shipment, "customer", None), "name", None) or "-" shipment_date_text = ( shipment.shipment_date.isoformat() if getattr(shipment, "shipment_date", None) else "-" ) items_count = shipment.items.filter(delete_at__isnull=True).count() sender_label = _user_label(created_by) msg = render_shipment_created_markdown( shipment_id=str(shipment.id), customer_name=str(customer_name), shipment_date=str(shipment_date_text), items_count=str(items_count), sender_label=str(sender_label), ) if dry_run: return { "dry_run": True, "message": msg, "shipment_id": shipment.id, "customer_name": str(customer_name), "shipment_date": str(shipment_date_text), "items_count": items_count, "sender_label": str(sender_label), } resp = send_wecom_webhook_message( content=msg, msgtype="markdown", key=key, timeout_seconds=timeout_seconds, ) return { "dry_run": False, "message": msg, "shipment_id": shipment.id, "customer_name": str(customer_name), "shipment_date": str(shipment_date_text), "items_count": items_count, "sender_label": str(sender_label), "wecom": resp.raw, "ok": resp.ok, "errcode": resp.errcode, "errmsg": resp.errmsg, } @transaction.atomic def create_external_shipment( customer_id: int, shipment_date, external_id: str, external_finished_products: List[dict], created_by, address: str = "", contact_name: str = "", contact_phone: str = "", remark: str = "", area: str = "", coordinates: str | None = None, extra: dict | list | None = None, ) -> Shipment: """ 创建出货单(external 版),并批量写入外部成品表并关联到出货单。 特点: - 不绑定任何 SalesItem - external_id 必填 - external_finished_products 必填(至少 1 条) """ from basic_info.models import Customer # 验证客户存在 try: customer = Customer.objects.get(id=customer_id) except Customer.DoesNotExist: raise ValueError(f"客户 {customer_id} 不存在") # merchant 隔离 emp = getattr(created_by, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: raise ValueError("用户未关联商户,无法创建出货单") if customer.merchant_id != merchant.id: raise ValueError("无权限为该客户创建出货单") external_id = (external_id or "").strip() if not external_id: raise ValueError("external_id 不能为空") if not external_finished_products: raise ValueError("external_finished_products 不能为空") shipment = Shipment.objects.create( merchant=merchant, customer=customer, shipment_date=shipment_date, address=address or "", contact_name=contact_name or "", contact_phone=contact_phone or "", area=(area or "").strip(), coordinates=coordinates or None, remark=remark, extra=extra, created_by=created_by, external_id=external_id, ) objs = [] for item in external_finished_products: objs.append( ExternalFinishedProduct( shipment=shipment, style_name=item.get("style_name", ""), num_of_rolls=item.get("num_of_rolls", 0), remark=item.get("remark") or "", created_by=created_by, ) ) ExternalFinishedProduct.objects.bulk_create(objs) return shipment @transaction.atomic def create_sales_item( printing_job_id: int, name: str, quantity: str, unit: int, created_by, customer_id: int | None = None, remark: str = "", position: str = "", merge_remark: dict | None = None, ) -> SalesItem: """ 手动创建销售品 Args: printing_job_id: 生产任务ID(必填) name: 销售品名称 quantity: 数量(字符串,会被转换为Decimal) unit: 单位(1=米, 2=件, 3=码, 4=个) created_by: 创建人 customer_id: 客户ID(可选) remark: 备注(可选) position: 货位(可选) Returns: 创建的 SalesItem 实例 Raises: ValueError: 如果生产任务不存在或不属于当前商户 """ # 获取当前用户的商户 emp = getattr(created_by, "employee", None) merchant = getattr(emp, "merchant", None) if emp else None if not merchant: raise ValueError("用户未关联商户,无法创建销售品") # 验证生产任务存在且属于当前商户 printing_job = _get_printing_job_for_sales_item( printing_job_id=printing_job_id, merchant=merchant, ) # 转换数量为Decimal quantity_decimal = _normalize_sales_item_quantity(quantity) # 如果未提供客户ID,尝试从主订单获取 if ( customer_id is None and printing_job.printing_order and printing_job.printing_order.customer ): customer_id = printing_job.printing_order.customer_id # 创建销售品 sales_item = SalesItem.objects.create( shipment=None, # 初始状态:待分配 merchant=merchant, name=name, quantity=quantity_decimal, unit=unit, created_by=created_by, printing_job_id=printing_job.id, customer_id=customer_id, remark=remark, position=position, merge_remark=merge_remark, ) return sales_item def get_merge_remark_jobs_by_main_job( printing_job_id: int, merchant, ) -> list[list[int]]: """ 查找指定 printing_job_id 作为 main_job 的所有销售品,返回每条记录的 jobs 列表。 同一个 printing_job_id 可能出现在多条销售品的 main_job 中, 因此返回值是一个二维列表,每个元素对应一条匹配记录的 jobs。 Args: printing_job_id: 要查询的生产任务ID merchant: 当前商户 Returns: 匹配记录的 jobs 列表集合,无匹配时返回空列表。 示例: [[9777, 9776], [9777, 9780]] """ merge_remarks = list( get_active_sales_items_queryset() .filter(merchant=merchant, merge_remark__main_job=printing_job_id) .values_list("merge_remark", flat=True) ) if not merge_remarks: return [] return [mr.get("jobs", []) for mr in merge_remarks if mr]