加入了 GVSDSDK 模块,进行了 QModel 兼容层的尝试,生产环境可用
This commit is contained in:
28
gvsdsdk/commission/__init__.py
Normal file
28
gvsdsdk/commission/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""gvsdsdk.commission — 分佣引擎"""
|
||||
|
||||
__all__ = [
|
||||
'CommissionEvent', 'RateTable', 'RateEntry', 'CommissionRule',
|
||||
'CommissionRecord', 'PreSettlement',
|
||||
'CommissionEngine', 'CommissionError',
|
||||
'CreateStrategy', 'BaseSplitStrategy', 'PercentageStrategy',
|
||||
'FixedStrategy', 'TieredStrategy', 'RemainderStrategy', 'WithdrawalFeeStrategy',
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ('CommissionEvent', 'RateTable', 'RateEntry', 'CommissionRule', 'CommissionRecord', 'PreSettlement'):
|
||||
from gvsdsdk.commission import models as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name in ('CommissionEngine', 'CommissionError'):
|
||||
from gvsdsdk.commission import manager as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
if name in ('CreateStrategy', 'BaseSplitStrategy', 'PercentageStrategy', 'FixedStrategy', 'TieredStrategy', 'RemainderStrategy', 'WithdrawalFeeStrategy'):
|
||||
from gvsdsdk.commission.strategies import engine as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
raise AttributeError(f"module 'gvsdsdk.commission' has no attribute {name!r}")
|
||||
519
gvsdsdk/commission/manager.py
Normal file
519
gvsdsdk/commission/manager.py
Normal file
@@ -0,0 +1,519 @@
|
||||
"""模块:分佣编排器
|
||||
SAAS 级分佣引擎核心入口——子服务通过此引擎触发分佣事件。
|
||||
|
||||
职责:
|
||||
1. 接收分佣事件(Dispatch)
|
||||
2. 匹配分佣规则
|
||||
3. 执行分账策略计算
|
||||
4. 写入 CommissionRecord 流水
|
||||
5. 支持预结算(跨平台确认)
|
||||
6. 支持邀请链分佣(多级分佣)"""
|
||||
|
||||
import uuid
|
||||
import datetime
|
||||
import logging
|
||||
from decimal import Decimal
|
||||
from typing import List, Optional, Dict, Any
|
||||
from django.db.models import Sum as _AggSum
|
||||
from gvsdsdk.commission.models import (
|
||||
CommissionEvent,
|
||||
CommissionRule,
|
||||
CommissionRecord,
|
||||
RateTable,
|
||||
PreSettlement,
|
||||
)
|
||||
from gvsdsdk.commission.strategies.engine import (
|
||||
CreateStrategy,
|
||||
RemainderStrategy,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommissionError(Exception):
|
||||
def __init__(self, message, event=None):
|
||||
super().__init__(message)
|
||||
self.Event = event
|
||||
|
||||
|
||||
class CommissionEngine:
|
||||
"""分佣编排器——SAAS 主服务器核心入口
|
||||
|
||||
用法:
|
||||
engine = CommissionEngine(tenant_uuid='xxx')
|
||||
|
||||
# 配置分佣规则(SAAS管理员操作)
|
||||
engine.ConfigureRule(
|
||||
event_type='order_complete',
|
||||
rule_name='平台订单打手8成',
|
||||
participant_rules=[
|
||||
{'role': 'worker', 'type': 'percentage', 'value': 0.80},
|
||||
{'role': 'platform', 'type': 'remainder'},
|
||||
],
|
||||
)
|
||||
|
||||
# 子服务触发分佣
|
||||
records = engine.Dispatch(
|
||||
event_type='order_complete',
|
||||
event_key='ORDER-20260611-001',
|
||||
total_amount=Decimal('100.00'),
|
||||
source_user_uuid='boss_001',
|
||||
participants={
|
||||
'worker': 'dashou_123',
|
||||
'agent': 'guanshi_456',
|
||||
'leader': 'zuzhang_789',
|
||||
},
|
||||
tier_index=1,
|
||||
payload={'order_id': 'xxx', 'game': 'LoL'},
|
||||
)
|
||||
|
||||
# 跨平台预结算
|
||||
engine.PreSettle(
|
||||
event_key='ORDER-CROSS-001',
|
||||
external_platform='partner_club_002',
|
||||
external_order_id='P-ORDER-888',
|
||||
participant_id='dashou_123',
|
||||
amount=Decimal('80.00'),
|
||||
order_amount=Decimal('100.00'),
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(self, tenant_uuid):
|
||||
self.TenantUUID = tenant_uuid
|
||||
|
||||
def ConfigureRule(self, event_type, rule_name, participant_rules,
|
||||
rule_description='', max_invite_level=2,
|
||||
rate_table_uuid=None, rule_priority=0):
|
||||
"""配置分佣规则——SAAS 管理员为租户配置分佣策略
|
||||
|
||||
Args:
|
||||
event_type: 事件类型 ('order_complete', 'member_purchase', ...)
|
||||
rule_name: 规则名称
|
||||
participant_rules: 参与者分账规则列表
|
||||
[
|
||||
{'role': 'worker', 'type': 'percentage', 'value': 0.80},
|
||||
{'role': 'agent', 'type': 'fixed', 'value': 10},
|
||||
{'role': 'leader', 'type': 'tiered', 'tiers': {1:5, 2:8, 3:10}},
|
||||
{'role': 'platform', 'type': 'remainder'},
|
||||
]
|
||||
rule_description: 规则描述
|
||||
max_invite_level: 邀请链最大层级
|
||||
rate_table_uuid: 关联费率表UUID
|
||||
rule_priority: 优先级(数字越大越优先)
|
||||
|
||||
Returns:
|
||||
CommissionRule 实例
|
||||
"""
|
||||
rate_table = None
|
||||
if rate_table_uuid:
|
||||
try:
|
||||
rate_table = RateTable.objects.get(
|
||||
RateTableUUID=rate_table_uuid, TenantUUID=self.TenantUUID,
|
||||
)
|
||||
except RateTable.DoesNotExist:
|
||||
raise CommissionError(f'费率表不存在: {rate_table_uuid}')
|
||||
|
||||
rule = CommissionRule.objects.create(
|
||||
RuleUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
EventType=event_type,
|
||||
RuleName=rule_name,
|
||||
RuleDescription=rule_description,
|
||||
ParticipantRules=participant_rules,
|
||||
RateTable=rate_table,
|
||||
MaxInviteLevel=max_invite_level,
|
||||
RulePriority=rule_priority,
|
||||
)
|
||||
logger.info(f'分佣规则已配置: {rule_name}@tenant={self.TenantUUID}')
|
||||
return rule
|
||||
|
||||
def ConfigureRateTable(self, table_code, table_name, entries,
|
||||
table_description=''):
|
||||
"""配置费率表
|
||||
|
||||
Args:
|
||||
table_code: 表编码(如 'dashou_order_split')
|
||||
table_name: 表名
|
||||
entries: 费率条目字典 {key: value}
|
||||
table_description: 描述
|
||||
|
||||
Returns:
|
||||
RateTable 实例
|
||||
"""
|
||||
from gvsdsdk.commission.models import RateEntry
|
||||
|
||||
table = RateTable.objects.create(
|
||||
RateTableUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
TableCode=table_code,
|
||||
TableName=table_name,
|
||||
TableDescription=table_description,
|
||||
)
|
||||
|
||||
for key, value in entries.items():
|
||||
RateEntry.objects.create(
|
||||
EntryUUID=uuid.uuid4().bytes,
|
||||
RateTable=table,
|
||||
RateKey=str(key),
|
||||
RateValue=Decimal(str(value)),
|
||||
)
|
||||
|
||||
logger.info(f'费率表已创建: {table_code}@tenant={self.TenantUUID}')
|
||||
return table
|
||||
|
||||
def Dispatch(self, event_type, event_key, total_amount,
|
||||
source_user_uuid=None, participants=None,
|
||||
tier_index=0, payload=None, auto_settle=True):
|
||||
"""触发分佣事件——子服务调用的核心入口
|
||||
|
||||
Args:
|
||||
event_type: 事件类型
|
||||
event_key: 业务唯一键(幂等保护)
|
||||
total_amount: 可分佣总金额
|
||||
source_user_uuid: 触发者UUID(如付费用户)
|
||||
participants: 参与者映射 {role: user_id}
|
||||
tier_index: 阶梯索引(多次分红时用,第1次=1)
|
||||
payload: 业务上下文(JSON)
|
||||
auto_settle: 是否自动结算
|
||||
|
||||
Returns:
|
||||
List[CommissionRecord] 分佣记录列表
|
||||
|
||||
Raises:
|
||||
CommissionError: 重复事件或规则未配置
|
||||
"""
|
||||
if CommissionEvent.objects.filter(
|
||||
TenantUUID=self.TenantUUID, EventKey=event_key,
|
||||
).exists():
|
||||
raise CommissionError(f'事件已处理: {event_key}')
|
||||
|
||||
total_amount = Decimal(str(total_amount))
|
||||
|
||||
event = CommissionEvent.objects.create(
|
||||
EventUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
EventType=event_type,
|
||||
EventKey=event_key,
|
||||
TotalAmount=total_amount,
|
||||
SourceUserUUID=source_user_uuid,
|
||||
EventPayload=payload or {},
|
||||
Participants=participants or {},
|
||||
EventStatus=CommissionEvent.EVT_PENDING,
|
||||
)
|
||||
|
||||
rules = CommissionRule.objects.filter(
|
||||
TenantUUID=self.TenantUUID,
|
||||
EventType=event_type,
|
||||
RuleStatus=1,
|
||||
).order_by('-RulePriority')
|
||||
|
||||
if not rules.exists():
|
||||
event.MarkFailed()
|
||||
raise CommissionError(f'未配置分佣规则: {event_type}', event=event)
|
||||
|
||||
event.MarkProcessing()
|
||||
|
||||
all_records = []
|
||||
for rule in rules:
|
||||
records = self._execute_rule(
|
||||
event=event,
|
||||
rule=rule,
|
||||
total_amount=total_amount,
|
||||
participants=participants or {},
|
||||
tier_index=tier_index,
|
||||
)
|
||||
all_records.extend(records)
|
||||
|
||||
if auto_settle:
|
||||
event.MarkSettled()
|
||||
for rec in all_records:
|
||||
rec.Settle()
|
||||
|
||||
return all_records
|
||||
|
||||
def _execute_rule(self, event, rule, total_amount, participants, tier_index):
|
||||
"""执行单条分佣规则"""
|
||||
rate_table = rule.RateTable
|
||||
participant_rules = rule.ParticipantRules
|
||||
records = []
|
||||
allocated = Decimal('0')
|
||||
|
||||
remainder_rule = None
|
||||
ordered_rules = sorted(
|
||||
participant_rules,
|
||||
key=lambda r: 0 if r.get('Type') == 'remainder' else 1,
|
||||
)
|
||||
|
||||
for pr in ordered_rules:
|
||||
if pr.get('Type') == 'remainder':
|
||||
remainder_rule = pr
|
||||
continue
|
||||
|
||||
strategy = CreateStrategy(pr, rate_table)
|
||||
amount = strategy.Calculate(
|
||||
total_amount,
|
||||
tier_index=tier_index,
|
||||
)
|
||||
allocated += amount
|
||||
|
||||
participant_id = self._resolve_participant(pr, participants)
|
||||
if amount > 0:
|
||||
records.append(self._create_record(
|
||||
event=event,
|
||||
rule=rule,
|
||||
participant_id=participant_id,
|
||||
role=pr.get('Role', 'unknown'),
|
||||
split_type=pr.get('Type', 'unknown'),
|
||||
amount=amount,
|
||||
rule_snapshot=pr,
|
||||
))
|
||||
|
||||
if remainder_rule and allocated < total_amount:
|
||||
strategy = CreateStrategy(remainder_rule, rate_table)
|
||||
amount = strategy.Calculate(
|
||||
total_amount,
|
||||
allocated_amount=allocated,
|
||||
)
|
||||
participant_id = self._resolve_participant(remainder_rule, participants)
|
||||
if amount > 0:
|
||||
records.append(self._create_record(
|
||||
event=event,
|
||||
rule=rule,
|
||||
participant_id=participant_id,
|
||||
role=remainder_rule.get('role', 'platform'),
|
||||
split_type='remainder',
|
||||
amount=amount,
|
||||
rule_snapshot=remainder_rule,
|
||||
))
|
||||
|
||||
for rec in records:
|
||||
rec.TierIndex = tier_index
|
||||
|
||||
return records
|
||||
|
||||
def _resolve_participant(self, participant_rule, participants):
|
||||
role = participant_rule.get('Role', 'unknown')
|
||||
Fallback = participant_rule.get('FallbackID', '')
|
||||
return participants.get(role, Fallback or f'{role}_default')
|
||||
|
||||
def _create_record(self, event, rule, participant_id, role, split_type, amount, rule_snapshot):
|
||||
return CommissionRecord.objects.create(
|
||||
RecordUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
Event=event,
|
||||
Rule=rule,
|
||||
ParticipantID=str(participant_id),
|
||||
ParticipantRole=role,
|
||||
SplitType=split_type,
|
||||
Amount=amount,
|
||||
RuleSnapshot=rule_snapshot,
|
||||
)
|
||||
|
||||
def PreSettle(self, event_key, external_platform, external_order_id,
|
||||
participant_id, amount, order_amount=Decimal('0'),
|
||||
participant_role='worker', confirm_deadline=None):
|
||||
"""创建预结算记录——跨平台订单待确认
|
||||
|
||||
Args:
|
||||
event_key: 业务唯一键
|
||||
external_platform: 外部平台标识
|
||||
external_order_id: 外部订单ID
|
||||
participant_id: 应分账对象
|
||||
amount: 预分金额
|
||||
order_amount: 订单原金额
|
||||
participant_role: 参与者角色
|
||||
confirm_deadline: 确认截止时间
|
||||
|
||||
Returns:
|
||||
PreSettlement 实例
|
||||
"""
|
||||
ps = PreSettlement.objects.create(
|
||||
SettlementUUID=uuid.uuid4().bytes,
|
||||
TenantUUID=self.TenantUUID,
|
||||
EventKey=event_key,
|
||||
ExternalPlatform=external_platform,
|
||||
ExternalOrderID=external_order_id,
|
||||
ParticipantID=participant_id,
|
||||
ParticipantRole=participant_role,
|
||||
Amount=Decimal(str(amount)),
|
||||
OrderAmount=Decimal(str(order_amount)),
|
||||
ConfirmDeadline=confirm_deadline,
|
||||
)
|
||||
logger.info(
|
||||
f'预结算: {event_key}->{participant_id}={amount}'
|
||||
f'@tenant={self.TenantUUID}'
|
||||
)
|
||||
return ps
|
||||
|
||||
def ConfirmPreSettlement(self, event_key, participant_id=None,
|
||||
confirmed_by=''):
|
||||
"""确认预结算——转为正式分佣记录
|
||||
|
||||
Args:
|
||||
event_key: 业务唯一键
|
||||
participant_id: 参与者ID(None=全部确认)
|
||||
confirmed_by: 确认人
|
||||
|
||||
Returns:
|
||||
int 确认条数
|
||||
"""
|
||||
qs = PreSettlement.objects.filter(
|
||||
TenantUUID=self.TenantUUID,
|
||||
EventKey=event_key,
|
||||
Status=PreSettlement.PST_PENDING,
|
||||
)
|
||||
if participant_id:
|
||||
qs = qs.filter(ParticipantID=participant_id)
|
||||
|
||||
count = 0
|
||||
for ps in qs:
|
||||
ps.Confirm(confirmed_by)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def RejectPreSettlement(self, event_key, participant_id=None,
|
||||
confirmed_by=''):
|
||||
"""拒绝预结算
|
||||
|
||||
Args:
|
||||
event_key: 业务唯一键
|
||||
participant_id: 参与者ID(None=全部拒绝)
|
||||
confirmed_by: 操作人
|
||||
|
||||
Returns:
|
||||
int 拒绝条数
|
||||
"""
|
||||
qs = PreSettlement.objects.filter(
|
||||
TenantUUID=self.TenantUUID,
|
||||
EventKey=event_key,
|
||||
Status=PreSettlement.PST_PENDING,
|
||||
)
|
||||
if participant_id:
|
||||
qs = qs.filter(ParticipantID=participant_id)
|
||||
|
||||
count = 0
|
||||
for ps in qs:
|
||||
ps.Reject(confirmed_by)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def CalculateFee(self, total_amount, rate_table_code, rate_key):
|
||||
"""计算手续费——用于提现场景
|
||||
|
||||
Args:
|
||||
total_amount: 提现金额
|
||||
rate_table_code: 费率表编码
|
||||
rate_key: 费率键
|
||||
|
||||
Returns:
|
||||
Decimal 手续费金额
|
||||
"""
|
||||
total_amount = Decimal(str(total_amount))
|
||||
try:
|
||||
table = RateTable.objects.get(
|
||||
TenantUUID=self.TenantUUID,
|
||||
TableCode=rate_table_code,
|
||||
TableStatus=1,
|
||||
)
|
||||
except RateTable.DoesNotExist:
|
||||
return Decimal('0')
|
||||
|
||||
rate = table.GetRate(rate_key)
|
||||
if rate is None:
|
||||
return Decimal('0')
|
||||
|
||||
return total_amount * rate
|
||||
|
||||
def QueryRecords(self, participant_id=None, event_type=None,
|
||||
status=None, start_date=None, end_date=None,
|
||||
limit=50):
|
||||
"""查询分佣记录
|
||||
|
||||
Args:
|
||||
participant_id: 参与者ID
|
||||
event_type: 事件类型
|
||||
status: 状态
|
||||
start_date: 开始日期
|
||||
end_date: 结束日期
|
||||
limit: 返回条数
|
||||
|
||||
Returns:
|
||||
QuerySet[CommissionRecord]
|
||||
"""
|
||||
qs = CommissionRecord.objects.filter(TenantUUID=self.TenantUUID)
|
||||
|
||||
if participant_id:
|
||||
qs = qs.filter(ParticipantID=participant_id)
|
||||
if status is not None:
|
||||
qs = qs.filter(Status=status)
|
||||
if event_type:
|
||||
qs = qs.filter(Event__EventType=event_type)
|
||||
if start_date:
|
||||
qs = qs.filter(CreateTime__gte=start_date)
|
||||
if end_date:
|
||||
qs = qs.filter(CreateTime__lte=end_date)
|
||||
|
||||
return qs.order_by('-CreateTime')[:limit]
|
||||
|
||||
def TotalByParticipant(self, participant_id, start_date=None, end_date=None):
|
||||
"""统计某参与者的分佣总额"""
|
||||
qs = CommissionRecord.objects.filter(
|
||||
TenantUUID=self.TenantUUID,
|
||||
ParticipantID=participant_id,
|
||||
Status__gte=CommissionRecord.REC_SETTLED,
|
||||
)
|
||||
if start_date:
|
||||
qs = qs.filter(CreateTime__gte=start_date)
|
||||
if end_date:
|
||||
qs = qs.filter(CreateTime__lte=end_date)
|
||||
|
||||
result = qs.aggregate(total=_AggSum('Amount'))
|
||||
return result['total'] or Decimal('0')
|
||||
|
||||
def BuildInviteChainCommission(self, event_type, event_key, total_amount,
|
||||
source_user_uuid, invite_chain,
|
||||
rule_name, participant_rules):
|
||||
"""邀请链分佣——按层级对受邀人行为进行多级分佣
|
||||
|
||||
Args:
|
||||
event_type: 事件类型
|
||||
event_key: 事件键
|
||||
total_amount: 总金额
|
||||
source_user_uuid: 行为触发者
|
||||
invite_chain: 邀请链 [{user_uuid, level, role}, ...]
|
||||
level=1 为直接邀请人,level=2 为间推
|
||||
rule_name: 规则名称
|
||||
participant_rules: 参与者规则(按层级配置)
|
||||
例: [{'role': 'agent_l1', 'type': 'fixed', 'value': 10},
|
||||
{'role': 'agent_l2', 'type': 'fixed', 'value': 5}]
|
||||
|
||||
Returns:
|
||||
List[CommissionRecord]
|
||||
"""
|
||||
rule = CommissionRule.objects.filter(
|
||||
TenantUUID=self.TenantUUID,
|
||||
EventType=event_type,
|
||||
RuleName=rule_name,
|
||||
RuleStatus=1,
|
||||
).first()
|
||||
|
||||
if not rule:
|
||||
rule = self.ConfigureRule(
|
||||
event_type=event_type,
|
||||
rule_name=rule_name,
|
||||
participant_rules=participant_rules,
|
||||
)
|
||||
|
||||
participants = {
|
||||
item.get('Role', f'invite_l{item.get("Level",0)}'): item.get('UserUUID', '')
|
||||
for item in invite_chain
|
||||
}
|
||||
participants['source'] = source_user_uuid
|
||||
|
||||
return self.Dispatch(
|
||||
event_type=event_type,
|
||||
event_key=event_key,
|
||||
total_amount=total_amount,
|
||||
source_user_uuid=source_user_uuid,
|
||||
participants=participants,
|
||||
)
|
||||
352
gvsdsdk/commission/models.py
Normal file
352
gvsdsdk/commission/models.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""模块:分佣 SAAS 数据模型
|
||||
SAAS 级分佣引擎——事件驱动、规则外置、参与方可配置。
|
||||
子服务只管说"发生了什么",SAAS 负责"怎么分"。"""
|
||||
|
||||
import uuid
|
||||
import datetime
|
||||
from django.db import models
|
||||
from gvsdsdk.model_base import QModel
|
||||
from gvsdsdk.models import UUIDField
|
||||
|
||||
|
||||
class CommissionEvent(QModel):
|
||||
"""分佣事件表——子服务触发的分佣事件
|
||||
|
||||
子服务在业务发生时(订单完成、会员购买、充值到账等)
|
||||
调用 CommissionEngine.Dispatch() 写入此表。
|
||||
"""
|
||||
|
||||
EVT_ORDER_COMPLETE = 'order_complete'
|
||||
EVT_MEMBER_PURCHASE = 'member_purchase'
|
||||
EVT_RECHARGE = 'recharge'
|
||||
EVT_PENALTY = 'penalty'
|
||||
EVT_REGISTER = 'register'
|
||||
EVT_CUSTOM = 'custom'
|
||||
|
||||
EVT_TYPE_CHOICES = [
|
||||
(EVT_ORDER_COMPLETE, '订单完成'),
|
||||
(EVT_MEMBER_PURCHASE, '会员购买'),
|
||||
(EVT_RECHARGE, '充值到账'),
|
||||
(EVT_PENALTY, '罚款缴纳'),
|
||||
(EVT_REGISTER, '用户注册'),
|
||||
(EVT_CUSTOM, '自定义事件'),
|
||||
]
|
||||
|
||||
EVT_PENDING = 0
|
||||
EVT_PROCESSING = 1
|
||||
EVT_SETTLED = 2
|
||||
EVT_FAILED = 3
|
||||
|
||||
EVT_STATUS_CHOICES = [
|
||||
(EVT_PENDING, '待处理'),
|
||||
(EVT_PROCESSING, '处理中'),
|
||||
(EVT_SETTLED, '已结算'),
|
||||
(EVT_FAILED, '结算失败'),
|
||||
]
|
||||
|
||||
EventUUID = UUIDField(primary_key=True)
|
||||
TenantUUID = UUIDField()
|
||||
EventType = models.CharField(max_length=32, choices=EVT_TYPE_CHOICES)
|
||||
EventKey = models.CharField(max_length=128)
|
||||
TotalAmount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
SourceUserUUID = UUIDField(null=True, blank=True)
|
||||
EventPayload = models.JSONField(default=dict)
|
||||
Participants = models.JSONField(default=dict)
|
||||
EventStatus = models.SmallIntegerField(default=EVT_PENDING, choices=EVT_STATUS_CHOICES)
|
||||
EventTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
ProcessTime = models.DateTimeField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
|
||||
class Meta:
|
||||
db_table = 'CommissionEvent'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '分佣事件'
|
||||
verbose_name_plural = '分佣事件'
|
||||
unique_together = [('TenantUUID', 'EventKey')]
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['TenantUUID', 'EventType', 'EventStatus'], name='idx_cmevt_tenant_type_status'),
|
||||
models.Index(fields=['EventKey'], name='idx_cmevt_event_key'),
|
||||
models.Index(fields=['CreateTime'], name='idx_cmevt_create_time'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.EventType}:{self.EventKey}'
|
||||
|
||||
@property
|
||||
def IsSettled(self):
|
||||
return self.EventStatus == self.EVT_SETTLED
|
||||
|
||||
def MarkProcessing(self):
|
||||
self.EventStatus = self.EVT_PROCESSING
|
||||
self.save(update_fields=['EventStatus'])
|
||||
|
||||
def MarkSettled(self):
|
||||
self.EventStatus = self.EVT_SETTLED
|
||||
self.ProcessTime = datetime.datetime.utcnow()
|
||||
self.save(update_fields=['EventStatus', 'ProcessTime'])
|
||||
|
||||
def MarkFailed(self):
|
||||
self.EventStatus = self.EVT_FAILED
|
||||
self.ProcessTime = datetime.datetime.utcnow()
|
||||
self.save(update_fields=['EventStatus', 'ProcessTime'])
|
||||
|
||||
|
||||
class RateTable(QModel):
|
||||
"""费率表——可复用的费率配置
|
||||
|
||||
每个租户可创建多张费率表,不同事件类型绑定不同的表。
|
||||
例如:打手分成费率表、提现手续费率表、罚款分红费率表。
|
||||
"""
|
||||
|
||||
RateTableUUID = UUIDField(primary_key=True)
|
||||
TenantUUID = UUIDField()
|
||||
TableCode = models.CharField(max_length=64)
|
||||
TableName = models.CharField(max_length=128)
|
||||
TableDescription = models.CharField(max_length=512, default='')
|
||||
TableStatus = models.SmallIntegerField(default=1)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
UpdateTime = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'CommissionRateTable'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '费率表'
|
||||
verbose_name_plural = '费率表'
|
||||
unique_together = [('TenantUUID', 'TableCode')]
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['TenantUUID'], name='idx_cmrate_tenant'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.TableCode}:{self.TableName}'
|
||||
|
||||
def GetRate(self, key):
|
||||
try:
|
||||
entry = self.Entries.get(RateKey=key, EntryStatus=1)
|
||||
return entry.RateValue
|
||||
except RateEntry.DoesNotExist:
|
||||
return None
|
||||
|
||||
def GetRates(self):
|
||||
return {
|
||||
e.RateKey: e.RateValue
|
||||
for e in self.Entries.filter(EntryStatus=1)
|
||||
}
|
||||
|
||||
|
||||
class RateEntry(QModel):
|
||||
"""费率条目——费率表中的一行"""
|
||||
|
||||
EntryUUID = UUIDField(primary_key=True)
|
||||
RateTable = models.ForeignKey(
|
||||
RateTable, on_delete=models.CASCADE,
|
||||
related_name='Entries',
|
||||
)
|
||||
RateKey = models.CharField(max_length=64)
|
||||
RateLabel = models.CharField(max_length=128, default='')
|
||||
RateValue = models.DecimalField(max_digits=10, decimal_places=6)
|
||||
EntryStatus = models.SmallIntegerField(default=1)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
|
||||
class Meta:
|
||||
db_table = 'CommissionRateEntry'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '费率条目'
|
||||
verbose_name_plural = '费率条目'
|
||||
unique_together = [('RateTable', 'RateKey')]
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['RateTable', 'RateKey'], name='idx_cmrentry_table_key'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.RateKey}={self.RateValue}'
|
||||
|
||||
|
||||
class CommissionRule(QModel):
|
||||
"""分佣规则表——定义"怎么分"
|
||||
|
||||
每条规则绑定一种事件类型,定义参与者如何分账。
|
||||
支持比例分账、固定金额、阶梯分账、剩余归平台四种 SplitType。
|
||||
"""
|
||||
|
||||
SPLT_PERCENTAGE = 'percentage'
|
||||
SPLT_FIXED = 'fixed'
|
||||
SPLT_TIERED = 'tiered'
|
||||
SPLT_REMAINDER = 'remainder'
|
||||
|
||||
SPLT_TYPE_CHOICES = [
|
||||
(SPLT_PERCENTAGE, '比例分账'),
|
||||
(SPLT_FIXED, '固定金额'),
|
||||
(SPLT_TIERED, '阶梯分账'),
|
||||
(SPLT_REMAINDER, '剩余归平台'),
|
||||
]
|
||||
|
||||
RuleUUID = UUIDField(primary_key=True)
|
||||
TenantUUID = UUIDField()
|
||||
EventType = models.CharField(max_length=32, choices=CommissionEvent.EVT_TYPE_CHOICES)
|
||||
RuleName = models.CharField(max_length=128)
|
||||
RuleDescription = models.CharField(max_length=512, default='')
|
||||
ParticipantRules = models.JSONField(default=list)
|
||||
RateTable = models.ForeignKey(
|
||||
RateTable, on_delete=models.SET_NULL,
|
||||
null=True, blank=True, related_name='rules',
|
||||
)
|
||||
MaxInviteLevel = models.SmallIntegerField(default=2)
|
||||
RulePriority = models.IntegerField(default=0)
|
||||
RuleStatus = models.SmallIntegerField(default=1)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
UpdateTime = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'CommissionRule'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '分佣规则'
|
||||
verbose_name_plural = '分佣规则'
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['TenantUUID', 'EventType', 'RuleStatus'], name='idx_cmrule_tenant_type_status'),
|
||||
models.Index(fields=['RulePriority'], name='idx_cmrule_priority'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.RuleName}({self.EventType})'
|
||||
|
||||
def FindParticipantRule(self, role):
|
||||
for pr in self.ParticipantRules:
|
||||
if pr.get('role') == role:
|
||||
return pr
|
||||
return None
|
||||
|
||||
|
||||
class CommissionRecord(QModel):
|
||||
"""分佣记录表——不可变分账流水
|
||||
|
||||
每条记录代表一次分账结果:谁、从哪个事件、按什么规则、分了多少。
|
||||
生成后即冻结,规则变更不影响历史记录。
|
||||
"""
|
||||
|
||||
REC_PENDING = 0
|
||||
REC_SETTLED = 1
|
||||
REC_WITHDRAWN = 2
|
||||
REC_CANCELLED = 3
|
||||
|
||||
REC_STATUS_CHOICES = [
|
||||
(REC_PENDING, '待结算'),
|
||||
(REC_SETTLED, '已结算'),
|
||||
(REC_WITHDRAWN, '已提现'),
|
||||
(REC_CANCELLED, '已取消'),
|
||||
]
|
||||
|
||||
RecordUUID = UUIDField(primary_key=True)
|
||||
TenantUUID = UUIDField()
|
||||
Event = models.ForeignKey(
|
||||
CommissionEvent, on_delete=models.CASCADE,
|
||||
related_name='records',
|
||||
)
|
||||
Rule = models.ForeignKey(
|
||||
CommissionRule, on_delete=models.SET_NULL,
|
||||
null=True, related_name='records',
|
||||
)
|
||||
ParticipantID = models.CharField(max_length=128)
|
||||
ParticipantRole = models.CharField(max_length=64)
|
||||
SplitType = models.CharField(max_length=16)
|
||||
Amount = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
RuleSnapshot = models.JSONField(default=dict)
|
||||
Status = models.SmallIntegerField(default=REC_PENDING, choices=REC_STATUS_CHOICES)
|
||||
TierIndex = models.IntegerField(default=0)
|
||||
SettleTime = models.DateTimeField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
|
||||
class Meta:
|
||||
db_table = 'CommissionRecord'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '分佣记录'
|
||||
verbose_name_plural = '分佣记录'
|
||||
ordering = ['-CreateTime']
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['TenantUUID', 'ParticipantID', 'Status'], name='idx_cmrec_tenant_part_status'),
|
||||
models.Index(fields=['Event'], name='idx_cmrec_event'),
|
||||
models.Index(fields=['CreateTime'], name='idx_cmrec_create_time'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.ParticipantRole}:{self.ParticipantID}={self.Amount}'
|
||||
|
||||
@property
|
||||
def IsSettled(self):
|
||||
return self.Status >= self.REC_SETTLED
|
||||
|
||||
def Settle(self):
|
||||
self.Status = self.REC_SETTLED
|
||||
self.SettleTime = datetime.datetime.utcnow()
|
||||
self.save(update_fields=['Status', 'SettleTime'])
|
||||
|
||||
def Cancel(self):
|
||||
self.Status = self.REC_CANCELLED
|
||||
self.save(update_fields=['Status'])
|
||||
|
||||
|
||||
class PreSettlement(QModel):
|
||||
"""预结算表——跨平台订单待确认款项
|
||||
|
||||
当订单涉及外部平台时,打手分成先挂预结算,
|
||||
对方确认后转为正式 CommissionRecord。
|
||||
"""
|
||||
|
||||
PST_PENDING = 0
|
||||
PST_CONFIRMED = 1
|
||||
PST_REJECTED = 2
|
||||
|
||||
PST_STATUS_CHOICES = [
|
||||
(PST_PENDING, '待确认'),
|
||||
(PST_CONFIRMED, '已确认'),
|
||||
(PST_REJECTED, '已拒绝'),
|
||||
]
|
||||
|
||||
SettlementUUID = UUIDField(primary_key=True)
|
||||
TenantUUID = UUIDField()
|
||||
EventKey = models.CharField(max_length=128)
|
||||
ExternalPlatform = models.CharField(max_length=64, default='')
|
||||
ExternalOrderID = models.CharField(max_length=128, default='')
|
||||
ParticipantID = models.CharField(max_length=128)
|
||||
ParticipantRole = models.CharField(max_length=64, default='worker')
|
||||
Amount = models.DecimalField(max_digits=18, decimal_places=4)
|
||||
OrderAmount = models.DecimalField(max_digits=18, decimal_places=4, default=0)
|
||||
Status = models.SmallIntegerField(default=PST_PENDING, choices=PST_STATUS_CHOICES)
|
||||
ConfirmedBy = models.CharField(max_length=128, default='')
|
||||
ConfirmDeadline = models.DateTimeField(null=True, blank=True)
|
||||
ConfirmedAt = models.DateTimeField(null=True, blank=True)
|
||||
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow)
|
||||
UpdateTime = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'CommissionPreSettlement'
|
||||
app_label = 'gvsdsdk'
|
||||
verbose_name = '预结算'
|
||||
verbose_name_plural = '预结算'
|
||||
unique_together = [('EventKey', 'ParticipantID')]
|
||||
managed = False
|
||||
indexes = [
|
||||
models.Index(fields=['TenantUUID', 'Status'], name='idx_cmpst_tenant_status'),
|
||||
models.Index(fields=['EventKey'], name='idx_cmpst_event_key'),
|
||||
models.Index(fields=['ExternalOrderID'], name='idx_cmpst_ext_order'),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'预结算:{self.EventKey}->{self.ParticipantID}={self.Amount}'
|
||||
|
||||
def Confirm(self, confirmed_by=''):
|
||||
self.Status = self.PST_CONFIRMED
|
||||
self.ConfirmedBy = confirmed_by
|
||||
self.ConfirmedAt = datetime.datetime.utcnow()
|
||||
self.save(update_fields=['Status', 'ConfirmedBy', 'ConfirmedAt'])
|
||||
|
||||
def Reject(self, confirmed_by=''):
|
||||
self.Status = self.PST_REJECTED
|
||||
self.ConfirmedBy = confirmed_by
|
||||
self.ConfirmedAt = datetime.datetime.utcnow()
|
||||
self.save(update_fields=['Status', 'ConfirmedBy', 'ConfirmedAt'])
|
||||
10
gvsdsdk/commission/strategies/__init__.py
Normal file
10
gvsdsdk/commission/strategies/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
"""gvsdsdk.commission.strategies — 分佣策略引擎"""
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
if name in ('CreateStrategy', 'BaseSplitStrategy', 'PercentageStrategy', 'FixedStrategy', 'TieredStrategy', 'RemainderStrategy', 'WithdrawalFeeStrategy'):
|
||||
from gvsdsdk.commission.strategies import engine as _m
|
||||
val = getattr(_m, name)
|
||||
globals()[name] = val
|
||||
return val
|
||||
raise AttributeError(f"module 'gvsdsdk.commission.strategies' has no attribute {name!r}")
|
||||
156
gvsdsdk/commission/strategies/engine.py
Normal file
156
gvsdsdk/commission/strategies/engine.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""模块:分佣策略引擎
|
||||
策略模式实现——每种分账方式独立封装,新增分账逻辑只需添加新策略类。
|
||||
|
||||
支持四种策略:
|
||||
PercentageStrategy: 比例分账 → TotalAmount × rate
|
||||
FixedStrategy: 固定金额 → 固定值(受保底/封顶约束)
|
||||
TieredStrategy: 阶梯分账 → 按次数/档位查表
|
||||
RemainderStrategy: 剩余归平台 → TotalAmount - SUM(其他参与者)
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from decimal import Decimal
|
||||
from typing import Dict, List, Optional
|
||||
from gvsdsdk.commission.models import RateTable
|
||||
|
||||
|
||||
class BaseSplitStrategy(ABC):
|
||||
"""分账策略抽象基类"""
|
||||
|
||||
def __init__(self, participant_rule: dict, rate_table: Optional[RateTable] = None):
|
||||
self._rule = participant_rule
|
||||
self._rate_table = rate_table
|
||||
|
||||
@abstractmethod
|
||||
def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal:
|
||||
...
|
||||
|
||||
@property
|
||||
def Role(self):
|
||||
return self._rule.get('Role', 'unknown')
|
||||
|
||||
@property
|
||||
def SplitType(self):
|
||||
return self._rule.get('Type', 'unknown')
|
||||
|
||||
def _get_rate_from_table(self, key):
|
||||
if self._rate_table:
|
||||
rate = self._rate_table.GetRate(key)
|
||||
if rate is not None:
|
||||
return rate
|
||||
return self._rule.get('Rate', Decimal('0'))
|
||||
|
||||
|
||||
class PercentageStrategy(BaseSplitStrategy):
|
||||
"""比例分账策略
|
||||
|
||||
配置示例:
|
||||
{ role: 'worker', type: 'percentage', value: 0.80 }
|
||||
{ role: 'worker', type: 'percentage', rate_table_key: '1' }
|
||||
"""
|
||||
|
||||
def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal:
|
||||
rate_key = self._rule.get('RateTableKey', '')
|
||||
if rate_key:
|
||||
rate = self._get_rate_from_table(rate_key)
|
||||
else:
|
||||
rate = Decimal(str(self._rule.get('Value', 0)))
|
||||
amount = total_amount * rate
|
||||
return self._apply_bounds(amount, total_amount)
|
||||
|
||||
def _apply_bounds(self, amount, total_amount):
|
||||
minimum = self._rule.get('MinAmount')
|
||||
maximum = self._rule.get('MaxAmount')
|
||||
if minimum is not None:
|
||||
amount = max(amount, Decimal(str(minimum)))
|
||||
if maximum is not None:
|
||||
amount = min(amount, Decimal(str(maximum)))
|
||||
return amount
|
||||
|
||||
|
||||
class FixedStrategy(BaseSplitStrategy):
|
||||
"""固定金额策略
|
||||
|
||||
配置示例:
|
||||
{ role: 'agent', type: 'fixed', value: 10 }
|
||||
{ role: 'leader', type: 'fixed', rate_table_key: 'guanshi_fc' }
|
||||
"""
|
||||
|
||||
def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal:
|
||||
rate_key = self._rule.get('RateTableKey', '')
|
||||
if rate_key:
|
||||
amount = self._get_rate_from_table(rate_key)
|
||||
else:
|
||||
amount = Decimal(str(self._rule.get('Value', 0)))
|
||||
return amount
|
||||
|
||||
|
||||
class TieredStrategy(BaseSplitStrategy):
|
||||
"""阶梯分账策略——按次数/档位查表
|
||||
|
||||
配置示例:
|
||||
{ role: 'agent', type: 'tiered',
|
||||
tiers: { 1: 5, 2: 8, 3: 10 } }
|
||||
或从费率表中按 key_N 形式查找。
|
||||
"""
|
||||
|
||||
def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal:
|
||||
tiers = self._rule.get('Tiers', {})
|
||||
if tiers:
|
||||
tier_key = str(tier_index)
|
||||
raw_value = tiers.get(tier_key, tiers.get('default', 0))
|
||||
if isinstance(raw_value, str) and raw_value.endswith('%'):
|
||||
return total_amount * Decimal(raw_value.rstrip('%')) / Decimal('100')
|
||||
return Decimal(str(raw_value))
|
||||
|
||||
tier_key = self._rule.get('TierKeyPrefix', '')
|
||||
if tier_key and self._rate_table:
|
||||
rate = self._rate_table.GetRate(f'{tier_key}_{tier_index}')
|
||||
if rate is not None:
|
||||
return rate
|
||||
|
||||
return Decimal('0')
|
||||
|
||||
|
||||
class RemainderStrategy(BaseSplitStrategy):
|
||||
"""剩余归平台策略——扣除所有其他参与者后的余额
|
||||
|
||||
配置示例:
|
||||
{ role: 'platform', type: 'remainder' }
|
||||
"""
|
||||
|
||||
def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal:
|
||||
allocated = context.get('AllocatedAmount', Decimal('0'))
|
||||
remainder = total_amount - allocated
|
||||
return remainder if remainder > 0 else Decimal('0')
|
||||
|
||||
|
||||
class WithdrawalFeeStrategy(BaseSplitStrategy):
|
||||
"""提现手续费策略
|
||||
|
||||
配置示例:
|
||||
{ role: 'platform', type: 'withdrawal_fee', value: 0.01 }
|
||||
"""
|
||||
|
||||
def Calculate(self, total_amount: Decimal, tier_index: int = 0, **context) -> Decimal:
|
||||
rate_key = self._rule.get('RateTableKey', '')
|
||||
if rate_key:
|
||||
rate = self._get_rate_from_table(rate_key)
|
||||
else:
|
||||
rate = Decimal(str(self._rule.get('Value', 0)))
|
||||
return total_amount * rate
|
||||
|
||||
|
||||
STRATEGY_MAP: Dict[str, type] = {
|
||||
'percentage': PercentageStrategy,
|
||||
'fixed': FixedStrategy,
|
||||
'tiered': TieredStrategy,
|
||||
'remainder': RemainderStrategy,
|
||||
'withdrawal_fee': WithdrawalFeeStrategy,
|
||||
}
|
||||
|
||||
|
||||
def CreateStrategy(participant_rule: dict, rate_table: Optional[RateTable] = None) -> BaseSplitStrategy:
|
||||
split_type = participant_rule.get('Type', 'percentage')
|
||||
strategy_class = STRATEGY_MAP.get(split_type, PercentageStrategy)
|
||||
return strategy_class(participant_rule, rate_table)
|
||||
Reference in New Issue
Block a user