Menu/WorkOrder 模型迁入 gvsdsdk;MenuManager 提供菜单树/管理列表/保存/递归删除, WorkOrderManager 提供统计/个人工单/创建/更新,顶层懒加载导出。"
521 lines
21 KiB
Python
521 lines
21 KiB
Python
"""模块:订单管理器
|
||
SAAS 级订单编排器——子服务通过此管理器操作订单生命周期。
|
||
|
||
职责:
|
||
1. 基于模板快速创建订单(冻结模板快照)
|
||
2. 完整订单状态机流转(确认/支付/处理/完成/取消/退款/关闭/重开)
|
||
3. 订单查询(按租户/公司/买家/状态多维度)
|
||
4. 行项目批量管理
|
||
|
||
用法:
|
||
manager = OrderManager(tenant_uuid='xxx', company_uuid='yyy', user=admin_user)
|
||
|
||
# 从模板创建订单
|
||
order = manager.CreateFromTemplate(
|
||
template=tpl,
|
||
level=level,
|
||
buyer_user_uuid='user_001',
|
||
order_title='VIP 月卡',
|
||
)
|
||
|
||
# 状态流转
|
||
manager.Confirm(order.OrderUUID)
|
||
manager.Pay(order.OrderUUID, payment_transaction=tx)
|
||
manager.Complete(order.OrderUUID)
|
||
|
||
# 查询
|
||
orders = manager.ListOrders(status=Order.ORD_PAID)
|
||
"""
|
||
import uuid as _uuid
|
||
import logging
|
||
import datetime
|
||
from decimal import Decimal
|
||
from typing import Optional, List
|
||
|
||
from django.db import transaction
|
||
from django.utils import timezone as _timezone
|
||
|
||
from gvsdsdk.order.models import (
|
||
OrderLevel,
|
||
OrderTemplate,
|
||
OrderTemplateItem,
|
||
Order,
|
||
OrderItem,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OrderError(Exception):
|
||
"""订单业务异常"""
|
||
|
||
def __init__(self, message, order=None):
|
||
super().__init__(message)
|
||
self.Order = order
|
||
|
||
|
||
class OrderManager:
|
||
"""订单编排器——SAAS 主服务器核心入口
|
||
|
||
所有方法均按 tenant_uuid + company_uuid 隔离操作。
|
||
状态流转方法会自动校验当前状态是否允许该流转。
|
||
"""
|
||
|
||
# 状态流转规则:{ target_status: (allowed_from_statuses, timestamp_field) }
|
||
FLOW_RULES = {
|
||
Order.ORD_CONFIRMED: ({Order.ORD_PENDING}, 'ConfirmTime'),
|
||
Order.ORD_PAID: ({Order.ORD_CONFIRMED}, 'PayTime'),
|
||
Order.ORD_PROCESSING: ({Order.ORD_PAID}, None),
|
||
Order.ORD_COMPLETED: ({Order.ORD_PROCESSING}, 'CompleteTime'),
|
||
Order.ORD_CANCELLED: ({Order.ORD_PENDING, Order.ORD_CONFIRMED}, 'CancelTime'),
|
||
Order.ORD_REFUNDING: ({Order.ORD_PAID, Order.ORD_PROCESSING, Order.ORD_COMPLETED}, None),
|
||
Order.ORD_REFUNDED: ({Order.ORD_REFUNDING}, None),
|
||
Order.ORD_CLOSED: ({Order.ORD_CANCELLED, Order.ORD_REFUNDED, Order.ORD_COMPLETED}, None),
|
||
}
|
||
|
||
# Reopen 特殊处理:CLOSED → PENDING
|
||
REOPEN_FROM = Order.ORD_CLOSED
|
||
REOPEN_TO = Order.ORD_PENDING
|
||
|
||
def __init__(self, tenant_uuid, company_uuid=None, user=None):
|
||
self.TenantUUID = self._to_bytes(tenant_uuid)
|
||
self.CompanyUUID = self._to_bytes(company_uuid)
|
||
self.User = user
|
||
|
||
# ========== 查询方法 ==========
|
||
|
||
def GetOrder(self, order_uuid):
|
||
"""查询单个订单(按租户+公司隔离)"""
|
||
order_uuid_bytes = self._to_bytes(order_uuid)
|
||
if not order_uuid_bytes:
|
||
return None
|
||
qs = Order.objects.filter(TenantUUID=self.TenantUUID, OrderUUID=order_uuid_bytes)
|
||
if self.CompanyUUID:
|
||
qs = qs.filter(CompanyUUID=self.CompanyUUID)
|
||
return qs.first()
|
||
|
||
def ListOrders(self, status=None, buyer_user_uuid=None, order_type=None,
|
||
page=1, page_size=20):
|
||
"""列出订单(多维度过滤)"""
|
||
try:
|
||
page = max(int(page), 1)
|
||
page_size = min(max(int(page_size), 1), 200)
|
||
except (TypeError, ValueError):
|
||
page = 1
|
||
page_size = 20
|
||
qs = Order.objects.filter(TenantUUID=self.TenantUUID)
|
||
if self.CompanyUUID:
|
||
qs = qs.filter(CompanyUUID=self.CompanyUUID)
|
||
if status is not None:
|
||
qs = qs.filter(OrderStatus=status)
|
||
if buyer_user_uuid:
|
||
buyer_bytes = self._to_bytes(buyer_user_uuid)
|
||
if buyer_bytes:
|
||
qs = qs.filter(BuyerUserUUID=buyer_bytes)
|
||
if order_type:
|
||
qs = qs.filter(OrderType=order_type)
|
||
qs = qs.order_by('-CreateTime')
|
||
total = qs.count()
|
||
start = (page - 1) * page_size
|
||
records = list(qs[start:start + page_size])
|
||
return {'records': records, 'total': total, 'page': page, 'page_size': page_size}
|
||
|
||
@staticmethod
|
||
def GetStats(tenant_uuid=None, company_uuid=None, buyer_user_uuids=None):
|
||
"""仪表盘订单统计——按租户/公司/买家多维度计数
|
||
|
||
Args:
|
||
tenant_uuid: 租户 UUID(None 不限制)
|
||
company_uuid: 公司 UUID(None 不限制)
|
||
buyer_user_uuids: 买家 UUID 集合(None 不限制;空集合返回全 0)
|
||
|
||
Returns:
|
||
dict: TotalOrders/TodayOrders/WeekOrders/PendingOrders/
|
||
PaidOrders/CompletedOrders
|
||
"""
|
||
now = _timezone.now()
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
week_start = today_start - datetime.timedelta(days=today_start.weekday())
|
||
|
||
qs = Order.objects.all()
|
||
if tenant_uuid:
|
||
qs = qs.filter(TenantUUID=tenant_uuid)
|
||
if company_uuid:
|
||
qs = qs.filter(CompanyUUID=company_uuid)
|
||
if buyer_user_uuids is not None:
|
||
buyer_bytes = [OrderManager._to_bytes(u) for u in buyer_user_uuids]
|
||
buyer_bytes = [b for b in buyer_bytes if b]
|
||
qs = qs.filter(BuyerUserUUID__in=buyer_bytes) if buyer_bytes else qs.none()
|
||
|
||
return {
|
||
'TotalOrders': qs.count(),
|
||
'TodayOrders': qs.filter(CreateTime__gte=today_start).count(),
|
||
'WeekOrders': qs.filter(CreateTime__gte=week_start).count(),
|
||
'PendingOrders': qs.filter(OrderStatus=Order.ORD_PENDING).count(),
|
||
'PaidOrders': qs.filter(
|
||
OrderStatus__in=[Order.ORD_PAID, Order.ORD_PROCESSING, Order.ORD_COMPLETED]
|
||
).count(),
|
||
'CompletedOrders': qs.filter(OrderStatus=Order.ORD_COMPLETED).count(),
|
||
}
|
||
|
||
def GetOrderItems(self, order_uuid):
|
||
"""获取订单行项目列表"""
|
||
order_uuid_bytes = self._to_bytes(order_uuid)
|
||
if not order_uuid_bytes:
|
||
return []
|
||
# 使用 Order_id 直接按 FK 主键过滤(与 create 中 Order=order 风格不同但语义一致:
|
||
# 过滤时无需加载 Order 对象,创建时已有 Order 实例直接赋值)
|
||
return list(OrderItem.objects.filter(Order_id=order_uuid_bytes))
|
||
|
||
# ========== 创建方法 ==========
|
||
|
||
@transaction.atomic
|
||
def CreateFromTemplate(self, template, buyer_user_uuid, level=None,
|
||
seller_user_uuid=None, order_title=None,
|
||
order_description='', order_type=None,
|
||
discount_amount=Decimal('0'), currency='CNY',
|
||
expire_time=None, remark='', items_override=None,
|
||
payment_config=None):
|
||
"""从模板创建订单——自动冻结模板/等级快照,复制模板项到订单项
|
||
|
||
Args:
|
||
template: OrderTemplate 实例
|
||
level: OrderLevel 实例(可选)
|
||
buyer_user_uuid: 买家用户 UUID
|
||
seller_user_uuid: 卖家用户 UUID(可选)
|
||
order_title: 订单标题(默认用模板名)
|
||
order_description: 订单描述
|
||
order_type: 订单类型(默认用模板类型)
|
||
discount_amount: 优惠金额
|
||
currency: 币种
|
||
expire_time: 过期时间
|
||
remark: 备注
|
||
items_override: 自定义行项目列表(覆盖模板项),每项为 dict:
|
||
{ItemCode, ItemName, ItemDescription, ItemType, UnitPrice, Quantity, SubTotal, ItemMetadata}
|
||
payment_config: PaymentGatewayConfig 实例(可选)
|
||
|
||
Returns:
|
||
Order 实例
|
||
"""
|
||
if not template:
|
||
raise OrderError('订单模板不能为空')
|
||
if not buyer_user_uuid:
|
||
raise OrderError('买家用户 UUID 不能为空')
|
||
|
||
buyer_bytes = self._to_bytes(buyer_user_uuid)
|
||
seller_bytes = self._to_bytes(seller_user_uuid) if seller_user_uuid else None
|
||
|
||
# 生成订单编号
|
||
order_no = self._generate_order_no()
|
||
|
||
# 冻结模板快照
|
||
template_snapshot = self._snapshot_template(template)
|
||
level_snapshot = self._snapshot_level(level) if level else {}
|
||
|
||
# 计算金额
|
||
total_amount = Decimal('0')
|
||
if level:
|
||
total_amount = level.Price or Decimal('0')
|
||
pay_amount = total_amount - (discount_amount or Decimal('0'))
|
||
if pay_amount < 0:
|
||
pay_amount = Decimal('0')
|
||
|
||
order = Order.objects.create(
|
||
OrderUUID=_uuid.uuid4().bytes,
|
||
TenantUUID=self.TenantUUID,
|
||
CompanyUUID=self.CompanyUUID,
|
||
OrderNO=order_no,
|
||
Template=template,
|
||
Level=level,
|
||
OrderTitle=order_title or template.TemplateName,
|
||
OrderDescription=order_description,
|
||
OrderType=order_type or template.TemplateType,
|
||
BuyerUserUUID=buyer_bytes,
|
||
SellerUserUUID=seller_bytes,
|
||
TotalAmount=total_amount,
|
||
DiscountAmount=discount_amount or Decimal('0'),
|
||
PayAmount=pay_amount,
|
||
Currency=currency,
|
||
OrderStatus=Order.ORD_PENDING,
|
||
PaymentConfig=payment_config,
|
||
ExpireTime=expire_time,
|
||
TemplateSnapshot=template_snapshot,
|
||
LevelSnapshot=level_snapshot,
|
||
OrderMetadata={},
|
||
Remark=remark,
|
||
)
|
||
|
||
# 创建订单项
|
||
item_list = []
|
||
if items_override:
|
||
for item_data in items_override:
|
||
item_list.append(OrderItem(
|
||
ItemUUID=_uuid.uuid4().bytes,
|
||
Order=order,
|
||
ItemCode=item_data.get('ItemCode', ''),
|
||
ItemName=item_data.get('ItemName', ''),
|
||
ItemDescription=item_data.get('ItemDescription', ''),
|
||
ItemType=item_data.get('ItemType', 'default'),
|
||
UnitPrice=item_data.get('UnitPrice', Decimal('0')),
|
||
Quantity=item_data.get('Quantity', 1),
|
||
Subtotal=item_data.get('SubTotal') or (
|
||
item_data.get('UnitPrice', Decimal('0')) * item_data.get('Quantity', 1)
|
||
),
|
||
ItemMetadata=item_data.get('ItemMetadata', {}),
|
||
))
|
||
else:
|
||
# 复制模板项
|
||
for tpl_item in template.Items.all():
|
||
item_list.append(OrderItem(
|
||
ItemUUID=_uuid.uuid4().bytes,
|
||
Order=order,
|
||
ItemCode=tpl_item.ItemCode,
|
||
ItemName=tpl_item.ItemName,
|
||
ItemDescription=tpl_item.ItemDescription,
|
||
ItemType=tpl_item.ItemType,
|
||
UnitPrice=tpl_item.UnitPrice,
|
||
Quantity=tpl_item.DefaultQuantity,
|
||
Subtotal=tpl_item.UnitPrice * tpl_item.DefaultQuantity,
|
||
ItemMetadata=tpl_item.ItemMetadata,
|
||
))
|
||
if item_list:
|
||
OrderItem.objects.bulk_create(item_list)
|
||
|
||
logger.info('[OrderManager] 创建订单: %s (template=%s, buyer=%s)',
|
||
order_no, template.TemplateName, buyer_user_uuid)
|
||
return order
|
||
|
||
@transaction.atomic
|
||
def CreateGeneric(self, order_title, buyer_user_uuid, order_type='default',
|
||
order_description='', seller_user_uuid=None,
|
||
total_amount=None, discount_amount=None, pay_amount=None,
|
||
currency='CNY', expire_time=None, remark='',
|
||
items=None, order_metadata=None):
|
||
"""通用创建订单——自动生成单号、强制 PENDING 状态
|
||
|
||
供通用 CRUD(form_service)接入,替代裸 ``model.objects.create``,
|
||
保证订单受状态机保护(OrderStatus 恒为 PENDING,后续流转必须走状态机端点)。
|
||
|
||
Args:
|
||
order_title: 订单标题(必填)
|
||
buyer_user_uuid: 买家用户 UUID(必填)
|
||
order_type: 订单类型(默认 'default')
|
||
order_description: 订单描述
|
||
seller_user_uuid: 卖家用户 UUID(可选)
|
||
total_amount: 总金额(Decimal/str;不传按 items 汇总,无 items 则为 0)
|
||
discount_amount: 优惠金额
|
||
pay_amount: 应付金额(不传 = total - discount,最低 0)
|
||
currency: 币种
|
||
expire_time: 过期时间
|
||
remark: 备注
|
||
items: 明细列表,每项 dict:
|
||
{ItemCode, ItemName, ItemDescription, ItemType, UnitPrice, Quantity, SubTotal, ItemMetadata}
|
||
order_metadata: 附加元数据
|
||
|
||
Returns:
|
||
Order 实例
|
||
|
||
Raises:
|
||
OrderError: order_title 或 buyer_user_uuid 缺失时
|
||
"""
|
||
if not order_title:
|
||
raise OrderError('订单标题不能为空')
|
||
if not buyer_user_uuid:
|
||
raise OrderError('买家用户 UUID 不能为空')
|
||
|
||
buyer_bytes = self._to_bytes(buyer_user_uuid)
|
||
seller_bytes = self._to_bytes(seller_user_uuid) if seller_user_uuid else None
|
||
order_no = self._generate_order_no()
|
||
|
||
# 金额计算:优先显式传值,否则按 items 汇总
|
||
computed_total = Decimal('0')
|
||
for item in items or []:
|
||
computed_total += Decimal(str(item.get('UnitPrice', Decimal('0')))) * Decimal(str(item.get('Quantity', 1)))
|
||
total_amount = total_amount if total_amount is not None else (computed_total or Decimal('0'))
|
||
discount_amount = discount_amount or Decimal('0')
|
||
pay_amount = pay_amount if pay_amount is not None else max(total_amount - discount_amount, Decimal('0'))
|
||
|
||
order = Order.objects.create(
|
||
OrderUUID=_uuid.uuid4().bytes,
|
||
TenantUUID=self.TenantUUID,
|
||
CompanyUUID=self.CompanyUUID,
|
||
OrderNO=order_no,
|
||
OrderTitle=order_title,
|
||
OrderDescription=order_description,
|
||
OrderType=order_type,
|
||
BuyerUserUUID=buyer_bytes,
|
||
SellerUserUUID=seller_bytes,
|
||
TotalAmount=total_amount,
|
||
DiscountAmount=discount_amount,
|
||
PayAmount=pay_amount,
|
||
Currency=currency,
|
||
OrderStatus=Order.ORD_PENDING,
|
||
ExpireTime=expire_time,
|
||
TemplateSnapshot={},
|
||
LevelSnapshot={},
|
||
OrderMetadata=order_metadata or {},
|
||
Remark=remark,
|
||
)
|
||
|
||
item_list = []
|
||
for item_data in items or []:
|
||
unit_price = Decimal(str(item_data.get('UnitPrice', Decimal('0'))))
|
||
quantity = int(item_data.get('Quantity', 1))
|
||
item_list.append(OrderItem(
|
||
ItemUUID=_uuid.uuid4().bytes,
|
||
Order=order,
|
||
ItemCode=item_data.get('ItemCode', ''),
|
||
ItemName=item_data.get('ItemName', ''),
|
||
ItemDescription=item_data.get('ItemDescription', ''),
|
||
ItemType=item_data.get('ItemType', 'default'),
|
||
UnitPrice=unit_price,
|
||
Quantity=quantity,
|
||
Subtotal=item_data.get('SubTotal') or (unit_price * quantity),
|
||
ItemMetadata=item_data.get('ItemMetadata', {}),
|
||
))
|
||
if item_list:
|
||
OrderItem.objects.bulk_create(item_list)
|
||
|
||
logger.info('[OrderManager] 通用创建订单: %s (buyer=%s)', order_no, buyer_user_uuid)
|
||
return order
|
||
|
||
# ========== 状态流转方法 ==========
|
||
|
||
def Confirm(self, order_uuid):
|
||
return self._flow(order_uuid, Order.ORD_CONFIRMED, 'Confirm')
|
||
|
||
def Pay(self, order_uuid, payment_transaction=None):
|
||
order = self._flow(order_uuid, Order.ORD_PAID, 'Pay')
|
||
if order and payment_transaction:
|
||
order.PaymentTransaction = payment_transaction
|
||
order.save(update_fields=['PaymentTransaction'])
|
||
return order
|
||
|
||
def Process(self, order_uuid):
|
||
return self._flow(order_uuid, Order.ORD_PROCESSING, 'Process')
|
||
|
||
def Complete(self, order_uuid):
|
||
return self._flow(order_uuid, Order.ORD_COMPLETED, 'Complete')
|
||
|
||
def Cancel(self, order_uuid):
|
||
return self._flow(order_uuid, Order.ORD_CANCELLED, 'Cancel')
|
||
|
||
def Refund(self, order_uuid):
|
||
return self._flow(order_uuid, Order.ORD_REFUNDING, 'Refund')
|
||
|
||
def Refunded(self, order_uuid):
|
||
return self._flow(order_uuid, Order.ORD_REFUNDED, 'Refunded')
|
||
|
||
def Close(self, order_uuid):
|
||
return self._flow(order_uuid, Order.ORD_CLOSED, 'Close')
|
||
|
||
def Reopen(self, order_uuid):
|
||
"""重新打开已关闭的订单(回到待确认状态)"""
|
||
order = self._get_order_or_raise(order_uuid)
|
||
if order.OrderStatus != self.REOPEN_FROM:
|
||
raise OrderError(
|
||
f'订单状态流转失败:当前状态为「{order.OrderStatus}」,仅允许从「{self.REOPEN_FROM}」重新打开',
|
||
order=order,
|
||
)
|
||
old_status = order.OrderStatus
|
||
order.OrderStatus = self.REOPEN_TO
|
||
# 清空所有流转时间戳
|
||
order.ConfirmTime = None
|
||
order.PayTime = None
|
||
order.CompleteTime = None
|
||
order.CancelTime = None
|
||
order.UpdateTime = _timezone.now()
|
||
order.save(update_fields=[
|
||
'OrderStatus', 'ConfirmTime', 'PayTime',
|
||
'CompleteTime', 'CancelTime', 'UpdateTime',
|
||
])
|
||
logger.info('[OrderManager] 订单重新打开: %s (%s → %s)',
|
||
order.OrderNO, old_status, self.REOPEN_TO)
|
||
return order
|
||
|
||
# ========== 内部工具方法 ==========
|
||
|
||
def _flow(self, order_uuid, target_status, action_label):
|
||
"""通用状态流转方法"""
|
||
order = self._get_order_or_raise(order_uuid)
|
||
allowed_from, timestamp_field = self.FLOW_RULES[target_status]
|
||
if order.OrderStatus not in allowed_from:
|
||
allowed_str = ', '.join(str(s) for s in sorted(allowed_from))
|
||
raise OrderError(
|
||
f'订单状态流转失败:当前状态为「{order.OrderStatus}」,仅允许从「{allowed_str}」流转到「{target_status}」',
|
||
order=order,
|
||
)
|
||
old_status = order.OrderStatus
|
||
order.OrderStatus = target_status
|
||
now = _timezone.now()
|
||
if timestamp_field:
|
||
setattr(order, timestamp_field, now)
|
||
order.UpdateTime = now
|
||
update_fields = ['OrderStatus', 'UpdateTime']
|
||
if timestamp_field:
|
||
update_fields.append(timestamp_field)
|
||
order.save(update_fields=update_fields)
|
||
logger.info('[OrderManager] 订单状态流转: %s %s (%s → %s)',
|
||
order.OrderNO, action_label, old_status, target_status)
|
||
return order
|
||
|
||
def _get_order_or_raise(self, order_uuid):
|
||
order = self.GetOrder(order_uuid)
|
||
if not order:
|
||
raise OrderError(f'订单不存在或无权访问: {order_uuid}')
|
||
return order
|
||
|
||
def _generate_order_no(self):
|
||
"""生成唯一订单编号: ORD-YYYYMMDD-XXXXXXXX"""
|
||
now = _timezone.now()
|
||
random_part = _uuid.uuid4().hex[:8].upper()
|
||
return f'ORD-{now.strftime("%Y%m%d")}-{random_part}'
|
||
|
||
def _snapshot_template(self, template):
|
||
"""冻结模板快照"""
|
||
return {
|
||
'TemplateUUID': self._to_str(template.pk),
|
||
'TemplateName': template.TemplateName,
|
||
'TemplateType': template.TemplateType,
|
||
'TemplateStatus': template.TemplateStatus,
|
||
}
|
||
|
||
def _snapshot_level(self, level):
|
||
"""冻结等级快照"""
|
||
return {
|
||
'LevelUUID': self._to_str(level.pk),
|
||
'LevelCode': level.LevelCode,
|
||
'LevelName': level.LevelName,
|
||
'Price': str(level.Price),
|
||
'OriginalPrice': str(level.OriginalPrice),
|
||
'Currency': level.Currency,
|
||
'DurationDays': level.DurationDays,
|
||
'DurationMonths': level.DurationMonths,
|
||
}
|
||
|
||
@staticmethod
|
||
def _to_bytes(value):
|
||
if value is None:
|
||
return None
|
||
if hasattr(value, 'bytes'):
|
||
return value.bytes
|
||
if isinstance(value, bytes):
|
||
return value
|
||
if isinstance(value, str):
|
||
try:
|
||
return _uuid.UUID(value).bytes
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
@staticmethod
|
||
def _to_str(value):
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, bytes) and len(value) == 16:
|
||
try:
|
||
return str(_uuid.UUID(bytes=value))
|
||
except ValueError:
|
||
return None
|
||
return str(value)
|