1
0
forked from erp-dev/erp
Files
erpnew/shipment/services.py

1183 lines
36 KiB
Python
Raw 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.
"""
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]