forked from erp-dev/erp
1138 lines
34 KiB
Python
1138 lines
34 KiB
Python
"""
|
||
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
|
||
|
||
|
||
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],
|
||
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(),
|
||
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,
|
||
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_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: {
|
||
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 不能为空")
|
||
|
||
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=[
|
||
"status",
|
||
"status_modified_at",
|
||
"cancelled_by",
|
||
"approved_by",
|
||
"updated_at",
|
||
]
|
||
)
|
||
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,
|
||
remark: str | None = None,
|
||
external_id: str | None = None,
|
||
) -> Shipment:
|
||
"""
|
||
更新出货单业务数据。
|
||
|
||
说明:
|
||
- 仅允许在草稿状态下修改
|
||
- 不处理状态变更
|
||
"""
|
||
from basic_info.models import Customer
|
||
|
||
if shipment.status != ShipmentStatus.DRAFT:
|
||
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 remark is not None:
|
||
shipment.remark = remark or ""
|
||
if external_id is not None:
|
||
shipment.external_id = (external_id or "").strip() or None
|
||
|
||
shipment.save()
|
||
return shipment
|
||
|
||
|
||
@transaction.atomic
|
||
@transaction.atomic
|
||
def create_shipment(
|
||
customer_id: int,
|
||
shipment_date,
|
||
sales_item_ids: List[int],
|
||
created_by,
|
||
address: str = "",
|
||
contact_name: str = "",
|
||
contact_phone: str = "",
|
||
remark: str = "",
|
||
area: str = "",
|
||
) -> Shipment:
|
||
"""
|
||
创建出货单并关联销售品
|
||
|
||
Args:
|
||
customer_id: 客户ID
|
||
shipment_date: 出货日期
|
||
sales_item_ids: 要关联的销售品ID列表
|
||
created_by: 创建人
|
||
remark: 备注
|
||
|
||
Returns:
|
||
创建的 Shipment 实例
|
||
|
||
Raises:
|
||
ValueError: 如果销售品不存在或已被关联到其他出货单
|
||
"""
|
||
from basic_info.models import Customer
|
||
|
||
# 验证客户存在
|
||
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("无权限为该客户创建出货单")
|
||
|
||
# 验证销售品
|
||
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}"
|
||
)
|
||
|
||
printing_order_ids = {
|
||
printing_job_map[item.printing_job_id] for item in sales_items_list
|
||
}
|
||
if len(printing_order_ids) > 1:
|
||
raise ValueError("出货单中的销售品必须来自同一个生产订单")
|
||
|
||
# 创建出货单
|
||
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(),
|
||
remark=remark,
|
||
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 = "",
|
||
) -> 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(),
|
||
remark=remark,
|
||
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]
|