From 0e102fbcbaf5c81b0f8e37186fc3e03712d3c1ca Mon Sep 17 00:00:00 2001 From: XingQue Date: Mon, 27 Jul 2026 01:28:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=88=90=E4=BA=A4=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E6=96=B0=E8=A1=A8+=E5=8F=AF=E6=8E=A7=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E7=BB=93=E7=AE=97=EF=BC=88=E9=BB=98=E8=AE=A4=E5=85=B3=EF=BC=8C?= =?UTF-8?q?=E4=BB=85=E6=96=B0=E8=BF=9B=E7=BB=93=E7=AE=97=E4=B8=ADeligible?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按设计文档:SettlementTime起表;关开关清eligible;定时任务只结到期eligible单;打款走统一结单;指标从stats_since起幂等记账。不打开旧全局ORDER_AUTO_SETTLEMENT。 Co-authored-by: Cursor --- a_long_dianjing/celery.py | 10 + .../0020_order_deal_config_stats.py | 87 ++++++ jituan/models.py | 68 +++++ jituan/services/czjilu_fulfill.py | 5 + jituan/services/deal_stats.py | 220 ++++++++++++++++ jituan/services/kefu_menu_definitions.py | 2 + jituan/services/order_deal.py | 249 ++++++++++++++++++ jituan/tasks.py | 12 + jituan/urls.py | 8 + jituan/views_order_deal.py | 116 ++++++++ .../0016_order_auto_settle_eligible.py | 22 ++ orders/models.py | 5 + orders/signals.py | 62 +++-- 13 files changed, 840 insertions(+), 26 deletions(-) create mode 100644 jituan/migrations/0020_order_deal_config_stats.py create mode 100644 jituan/services/deal_stats.py create mode 100644 jituan/services/order_deal.py create mode 100644 jituan/views_order_deal.py create mode 100644 orders/migrations/0016_order_auto_settle_eligible.py diff --git a/a_long_dianjing/celery.py b/a_long_dianjing/celery.py index 9a30ae8..afc447e 100644 --- a/a_long_dianjing/celery.py +++ b/a_long_dianjing/celery.py @@ -95,6 +95,16 @@ app.conf.beat_schedule = { 'priority': 4, }, }, + + # 6. 可控订单自动结算(仅 AutoSettleEligible;俱乐部开关关则空跑) + 'scan_order_auto_settle_due': { + 'task': 'jituan.tasks.scan_order_auto_settle_due', + 'schedule': crontab(minute='*/5'), + 'options': { + 'queue': 'periodic_tasks', + 'priority': 4, + }, + }, } diff --git a/jituan/migrations/0020_order_deal_config_stats.py b/jituan/migrations/0020_order_deal_config_stats.py new file mode 100644 index 0000000..2fd0d4c --- /dev/null +++ b/jituan/migrations/0020_order_deal_config_stats.py @@ -0,0 +1,87 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0019_role_agreement_gates'), + ] + + operations = [ + migrations.CreateModel( + name='ClubOrderDealConfig', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16, unique=True, verbose_name='俱乐部')), + ('auto_settle_enabled', models.BooleanField(default=False, verbose_name='自动结算开关')), + ('auto_settle_enabled_at', models.DateTimeField(blank=True, null=True, verbose_name='本次打开自动结算时间')), + ('hours_platform', models.PositiveIntegerField(default=48, verbose_name='平台单超时小时')), + ('hours_merchant', models.PositiveIntegerField(default=48, verbose_name='商家单超时小时')), + ('stats_enabled', models.BooleanField(default=False, verbose_name='成交指标统计开关')), + ('stats_since', models.DateTimeField(blank=True, null=True, verbose_name='指标统计起点')), + ('updated_by', models.CharField(blank=True, default='', max_length=32)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '成交与自动结算配置', + 'db_table': 'club_order_deal_config', + }, + ), + migrations.CreateModel( + name='MerchantDealStat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('merchant_uid', models.CharField(db_index=True, max_length=16, verbose_name='商家UserUID')), + ('order_count', models.PositiveIntegerField(default=0)), + ('completed_count', models.PositiveIntegerField(default=0)), + ('refund_count', models.PositiveIntegerField(default=0)), + ('fine_paid_count', models.PositiveIntegerField(default=0)), + ('settle_duration_sum_seconds', models.BigIntegerField(default=0)), + ('settle_duration_sample_count', models.PositiveIntegerField(default=0)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '商家成交指标', + 'db_table': 'merchant_deal_stat', + 'unique_together': {('club_id', 'merchant_uid')}, + }, + ), + migrations.CreateModel( + name='DashouDealStat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('dashou_uid', models.CharField(db_index=True, max_length=16, verbose_name='打手UserUID')), + ('order_count', models.PositiveIntegerField(default=0)), + ('completed_count', models.PositiveIntegerField(default=0)), + ('fine_paid_count', models.PositiveIntegerField(default=0)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '打手成交指标', + 'db_table': 'dashou_deal_stat', + 'unique_together': {('club_id', 'dashou_uid')}, + }, + ), + migrations.CreateModel( + name='DealStatEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16)), + ('event_key', models.CharField(db_index=True, max_length=96, unique=True)), + ('event_type', models.CharField(db_index=True, max_length=32)), + ('order_id', models.CharField(blank=True, db_index=True, default='', max_length=32)), + ('subject_uid', models.CharField(blank=True, db_index=True, default='', max_length=16)), + ('meta_json', models.JSONField(blank=True, default=dict)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'verbose_name': '成交指标事件', + 'db_table': 'deal_stat_event', + }, + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 0bf3778..29c39a3 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -672,3 +672,71 @@ class ClubRoleAgreementSign(QModel): models.Index(fields=['user_id', 'role', 'version'], name='idx_cras_user_role_ver'), models.Index(fields=['club_id', 'role', 'signed_at'], name='idx_cras_club_role_t'), ] + + +class ClubOrderDealConfig(QModel): + """俱乐部:成交指标统计开关 + 可控自动结算开关(默认全关)。""" + club_id = models.CharField(max_length=16, unique=True, db_index=True, verbose_name='俱乐部') + auto_settle_enabled = models.BooleanField(default=False, verbose_name='自动结算开关') + auto_settle_enabled_at = models.DateTimeField(null=True, blank=True, verbose_name='本次打开自动结算时间') + hours_platform = models.PositiveIntegerField(default=48, verbose_name='平台单超时小时') + hours_merchant = models.PositiveIntegerField(default=48, verbose_name='商家单超时小时') + stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关') + stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点') + updated_by = models.CharField(max_length=32, blank=True, default='') + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'club_order_deal_config' + verbose_name = '成交与自动结算配置' + + +class MerchantDealStat(QModel): + """商家成交/罚款/退款指标快照(仅统计起点之后事件累加)。""" + club_id = models.CharField(max_length=16, db_index=True) + merchant_uid = models.CharField(max_length=16, db_index=True, verbose_name='商家UserUID') + order_count = models.PositiveIntegerField(default=0) + completed_count = models.PositiveIntegerField(default=0) + refund_count = models.PositiveIntegerField(default=0) + fine_paid_count = models.PositiveIntegerField(default=0) + settle_duration_sum_seconds = models.BigIntegerField(default=0) + settle_duration_sample_count = models.PositiveIntegerField(default=0) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'merchant_deal_stat' + verbose_name = '商家成交指标' + unique_together = [['club_id', 'merchant_uid']] + + +class DashouDealStat(QModel): + """打手成交/被罚款指标快照。""" + club_id = models.CharField(max_length=16, db_index=True) + dashou_uid = models.CharField(max_length=16, db_index=True, verbose_name='打手UserUID') + order_count = models.PositiveIntegerField(default=0) + completed_count = models.PositiveIntegerField(default=0) + fine_paid_count = models.PositiveIntegerField(default=0) + CreateTime = models.DateTimeField(auto_now_add=True) + UpdateTime = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'dashou_deal_stat' + verbose_name = '打手成交指标' + unique_together = [['club_id', 'dashou_uid']] + + +class DealStatEvent(QModel): + """指标幂等事件(防重复记账)。""" + club_id = models.CharField(max_length=16, db_index=True) + event_key = models.CharField(max_length=96, unique=True, db_index=True) + event_type = models.CharField(max_length=32, db_index=True) + order_id = models.CharField(max_length=32, blank=True, default='', db_index=True) + subject_uid = models.CharField(max_length=16, blank=True, default='', db_index=True) + meta_json = models.JSONField(default=dict, blank=True) + CreateTime = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'deal_stat_event' + verbose_name = '成交指标事件' diff --git a/jituan/services/czjilu_fulfill.py b/jituan/services/czjilu_fulfill.py index 07136f8..c063c41 100644 --- a/jituan/services/czjilu_fulfill.py +++ b/jituan/services/czjilu_fulfill.py @@ -294,6 +294,11 @@ def fulfill_fakuan(dingdan_id, paid_amount_yuan=None): if fadan: fadan.Status = 2 fadan.save(update_fields=['Status']) + try: + from jituan.services.deal_stats import on_penalty_paid + on_penalty_paid(fadan) + except Exception as exc: + logger.warning('成交罚款指标记账跳过 fadan=%s: %s', fadan.id, exc) success, msg = process_fadan_fenhong(fadan.id) if not success: logger.error('罚单 %s 分红处理失败: %s', fadan.id, msg) diff --git a/jituan/services/deal_stats.py b/jituan/services/deal_stats.py new file mode 100644 index 0000000..c1087cd --- /dev/null +++ b/jituan/services/deal_stats.py @@ -0,0 +1,220 @@ +"""成交/罚款指标幂等记账(仅 stats_enabled 且 CreateTime>=stats_since)。""" +from __future__ import annotations + +import logging +from django.db import transaction +from django.db.models import F +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def _cfg(club_id: str): + from jituan.services.order_deal import get_or_create_deal_config + return get_or_create_deal_config(club_id) + + +def _stats_on(cfg, order) -> bool: + if not cfg or not cfg.stats_enabled or not cfg.stats_since: + return False + ct = getattr(order, 'CreateTime', None) + if not ct or ct < cfg.stats_since: + return False + return True + + +def _claim_event(club_id: str, event_key: str, event_type: str, order_id='', subject_uid='', meta=None) -> bool: + from jituan.models import DealStatEvent + if DealStatEvent.query.filter(event_key=event_key).exists(): + return False + try: + DealStatEvent.objects.create( + club_id=club_id, + event_key=event_key[:96], + event_type=event_type[:32], + order_id=(order_id or '')[:32], + subject_uid=(subject_uid or '')[:16], + meta_json=meta or {}, + ) + return True + except Exception: + return False + + +def _ensure_merchant(club_id, uid): + from jituan.models import MerchantDealStat + row = MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first() + if not row: + row = MerchantDealStat(club_id=club_id, merchant_uid=uid) + row.save() + return row + + +def _ensure_dashou(club_id, uid): + from jituan.models import DashouDealStat + row = DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first() + if not row: + row = DashouDealStat(club_id=club_id, dashou_uid=uid) + row.save() + return row + + +def _merchant_uid(order): + try: + ext = getattr(order, 'shangjia_kuozhan', None) + return (getattr(ext, 'MerchantID', None) or '') if ext else '' + except Exception: + return '' + + +@transaction.atomic +def on_order_enter_pool(order): + """接单入池:商家/打手 order_count+1。""" + club_id = (getattr(order, 'ClubID', None) or '').strip() + cfg = _cfg(club_id) + if not _stats_on(cfg, order): + return + oid = order.OrderID + dashou = (order.PlayerID or '').strip() + if dashou and _claim_event(club_id, f'{oid}:dashou_in', 'order_in_pool', oid, dashou): + _ensure_dashou(club_id, dashou) + from jituan.models import DashouDealStat + DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update( + order_count=F('order_count') + 1, UpdateTime=timezone.now(), + ) + if order.Platform == 2: + mid = _merchant_uid(order) + if mid and _claim_event(club_id, f'{oid}:merchant_in', 'order_in_pool', oid, mid): + _ensure_merchant(club_id, mid) + from jituan.models import MerchantDealStat + MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update( + order_count=F('order_count') + 1, UpdateTime=timezone.now(), + ) + + +@transaction.atomic +def on_order_completed(order, settle_started_at=None): + club_id = (getattr(order, 'ClubID', None) or '').strip() + cfg = _cfg(club_id) + if not _stats_on(cfg, order): + return + oid = order.OrderID + if not _claim_event(club_id, f'{oid}:completed', 'completed', oid): + return + # 确保入池 + on_order_enter_pool(order) + + st = settle_started_at or getattr(order, '_deal_settle_started_at', None) + duration = None + if st: + try: + duration = max(0, int((timezone.now() - st).total_seconds())) + except Exception: + duration = None + + dashou = (order.PlayerID or '').strip() + if dashou: + _ensure_dashou(club_id, dashou) + from jituan.models import DashouDealStat + DashouDealStat.objects.filter(club_id=club_id, dashou_uid=dashou).update( + completed_count=F('completed_count') + 1, UpdateTime=timezone.now(), + ) + if order.Platform == 2: + mid = _merchant_uid(order) + if mid: + _ensure_merchant(club_id, mid) + from jituan.models import MerchantDealStat + qs = MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid) + qs.update(completed_count=F('completed_count') + 1, UpdateTime=timezone.now()) + if duration is not None: + qs.update( + settle_duration_sum_seconds=F('settle_duration_sum_seconds') + duration, + settle_duration_sample_count=F('settle_duration_sample_count') + 1, + ) + + +@transaction.atomic +def on_order_refunded(order): + club_id = (getattr(order, 'ClubID', None) or '').strip() + cfg = _cfg(club_id) + if not _stats_on(cfg, order) or order.Platform != 2: + return + oid = order.OrderID + mid = _merchant_uid(order) + if not mid: + return + if not _claim_event(club_id, f'{oid}:refunded', 'refunded', oid, mid): + return + on_order_enter_pool(order) + _ensure_merchant(club_id, mid) + from jituan.models import MerchantDealStat + MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=mid).update( + refund_count=F('refund_count') + 1, UpdateTime=timezone.now(), + ) + + +@transaction.atomic +def on_penalty_paid(penalty): + """罚单 Status→2 已缴纳。""" + from utils.penalty_status import PENALTY_PAID + if int(getattr(penalty, 'Status', 0) or 0) != PENALTY_PAID: + return + club_id = (getattr(penalty, 'ClubID', None) or '').strip() + cfg = _cfg(club_id) + if not cfg or not cfg.stats_enabled or not cfg.stats_since: + return + pid = getattr(penalty, 'id', None) or getattr(penalty, 'pk', None) + key = f'penalty:{pid}:paid' + if not _claim_event(club_id, key, 'fine_paid', getattr(penalty, 'RelatedOrderID', '') or ''): + return + applicant = (getattr(penalty, 'ApplicantID', None) or '').strip() + penalized = (getattr(penalty, 'PenalizedUserID', None) or '').strip() + # 商家申请人 → 商家罚款率分子 + if applicant: + _ensure_merchant(club_id, applicant) + from jituan.models import MerchantDealStat + MerchantDealStat.objects.filter(club_id=club_id, merchant_uid=applicant).update( + fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(), + ) + # 被罚打手 → 被罚款率 + if penalized: + _ensure_dashou(club_id, penalized) + from jituan.models import DashouDealStat + DashouDealStat.objects.filter(club_id=club_id, dashou_uid=penalized).update( + fine_paid_count=F('fine_paid_count') + 1, UpdateTime=timezone.now(), + ) + + +def serialize_merchant_stat(row) -> dict: + n = int(row.order_count or 0) + c = int(row.completed_count or 0) + r = int(row.refund_count or 0) + f = int(row.fine_paid_count or 0) + sc = int(row.settle_duration_sample_count or 0) + ss = int(row.settle_duration_sum_seconds or 0) + return { + 'merchant_uid': row.merchant_uid, + 'order_count': n, + 'completed_count': c, + 'refund_count': r, + 'fine_paid_count': f, + 'deal_rate': round(c / n, 6) if n else 0, + 'refund_rate': round(r / n, 6) if n else 0, + 'fine_rate': round(f / n, 6) if n else 0, + 'avg_deal_hours': round((ss / sc) / 3600.0, 4) if sc else 0, + 'settle_duration_sample_count': sc, + } + + +def serialize_dashou_stat(row) -> dict: + n = int(row.order_count or 0) + c = int(row.completed_count or 0) + f = int(row.fine_paid_count or 0) + return { + 'dashou_uid': row.dashou_uid, + 'order_count': n, + 'completed_count': c, + 'fine_paid_count': f, + 'deal_rate': round(c / n, 6) if n else 0, + 'fined_rate': round(f / n, 6) if n else 0, + } diff --git a/jituan/services/kefu_menu_definitions.py b/jituan/services/kefu_menu_definitions.py index 47cdb22..9fd7639 100644 --- a/jituan/services/kefu_menu_definitions.py +++ b/jituan/services/kefu_menu_definitions.py @@ -24,6 +24,8 @@ KEFU_MENU_ROW_DEFS = [ 'perm_codes': ['002ab']}, {'page_id': 'order.merchant', 'name': '商家订单', 'path': '/order/merchant', 'parent_id': 'order', 'sort_order': 32, 'perm_codes': ['002ac', '002ab', '003aa']}, + {'page_id': 'order.deal-config', 'name': '成交与自动结算', 'path': '/order/deal-config', 'parent_id': 'order', 'sort_order': 33, + 'perm_codes': ['002ab', '002ac', '003aa']}, {'page_id': 'cross-platform', 'name': '跨平台管理', 'path': '', 'parent_id': '', 'sort_order': 40}, {'page_id': 'cross-platform.order', 'name': '跨平台订单', 'path': '/cross-order', 'parent_id': 'cross-platform', 'sort_order': 41, 'perm_codes': ['003aa']}, diff --git a/jituan/services/order_deal.py b/jituan/services/order_deal.py new file mode 100644 index 0000000..f345e66 --- /dev/null +++ b/jituan/services/order_deal.py @@ -0,0 +1,249 @@ +"""成交指标 + 可控自动结算(俱乐部开关,默认全关)。 + +对齐设计文档:成交率与自动结算_设计方案.md +""" +from __future__ import annotations + +import logging +from datetime import timedelta +from decimal import Decimal + +from django.db import transaction +from django.db.models import F +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +def get_or_create_deal_config(club_id: str): + from jituan.models import ClubOrderDealConfig + cid = (club_id or '').strip() + if not cid: + return None + row = ClubOrderDealConfig.query.filter(club_id=cid).first() + if not row: + row = ClubOrderDealConfig(club_id=cid) + row.save() + return row + + +def config_to_dict(row) -> dict: + if not row: + return { + 'club_id': '', + 'auto_settle_enabled': False, + 'auto_settle_enabled_at': '', + 'hours_platform': 48, + 'hours_merchant': 48, + 'stats_enabled': False, + 'stats_since': '', + } + return { + 'club_id': row.club_id, + 'auto_settle_enabled': bool(row.auto_settle_enabled), + 'auto_settle_enabled_at': row.auto_settle_enabled_at.isoformat() if row.auto_settle_enabled_at else '', + 'hours_platform': int(row.hours_platform or 48), + 'hours_merchant': int(row.hours_merchant or 48), + 'stats_enabled': bool(row.stats_enabled), + 'stats_since': row.stats_since.isoformat() if row.stats_since else '', + } + + +def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict: + """保存配置。关自动结算时清空本俱乐部所有 eligible。""" + row = get_or_create_deal_config(club_id) + if not row: + raise ValueError('俱乐部不能为空') + + was_auto = bool(row.auto_settle_enabled) + was_stats = bool(row.stats_enabled) + now = timezone.now() + + if 'hours_platform' in data and data['hours_platform'] is not None: + row.hours_platform = max(1, min(int(data['hours_platform']), 720)) + if 'hours_merchant' in data and data['hours_merchant'] is not None: + row.hours_merchant = max(1, min(int(data['hours_merchant']), 720)) + + if 'auto_settle_enabled' in data and data['auto_settle_enabled'] is not None: + enabled = bool(data['auto_settle_enabled']) + row.auto_settle_enabled = enabled + if enabled and not was_auto: + row.auto_settle_enabled_at = now + if not enabled: + row.auto_settle_enabled_at = None + _clear_club_auto_settle_eligible(row.club_id) + + if 'stats_enabled' in data and data['stats_enabled'] is not None: + enabled = bool(data['stats_enabled']) + row.stats_enabled = enabled + if enabled and not was_stats: + row.stats_since = now + if not enabled: + # 停止写入;保留 since 供只读理解历史起点;再次开启会刷新 since + pass + if enabled and was_stats and data.get('reset_stats_since'): + row.stats_since = now + + # 若刚打开统计且 since 空 + if row.stats_enabled and not row.stats_since: + row.stats_since = now + + row.updated_by = (updated_by or '')[:32] + row.save() + return config_to_dict(row) + + +def _clear_club_auto_settle_eligible(club_id: str): + from orders.models import Order + Order.objects.filter(ClubID=club_id, AutoSettleEligible=True).update( + AutoSettleEligible=False, + AutoExpireAt=None, + PendingDispatch=False, + AutoTaskID='', + ) + + +def apply_enter_status_8(instance): + """ + 订单进入结算中(8):写 SettlementTime;若俱乐部自动结算已开且本次开启后进入则打 eligible。 + 由 signal 调用。instance 为即将 save 的 Order。 + """ + now = timezone.now() + instance.SettlementTime = now + instance.PendingDispatch = False + instance.AutoTaskID = '' + + club_id = (getattr(instance, 'ClubID', None) or '').strip() + cfg = get_or_create_deal_config(club_id) if club_id else None + if ( + cfg + and cfg.auto_settle_enabled + and cfg.auto_settle_enabled_at + and now >= cfg.auto_settle_enabled_at + ): + hours = int(cfg.hours_merchant if instance.Platform == 2 else cfg.hours_platform) or 48 + instance.AutoSettleEligible = True + instance.AutoExpireAt = now + timedelta(hours=hours) + else: + instance.AutoSettleEligible = False + instance.AutoExpireAt = None + + +def apply_leave_status_8(instance): + instance.SettlementTime = None + instance.AutoSettleEligible = False + instance.AutoExpireAt = None + instance.PendingDispatch = False + instance.AutoTaskID = '' + + +@transaction.atomic +def mark_order_completed_unified(order, *, source='auto_settle', operator=''): + """ + 与客服强制结单等价的核心结算(打手入账 + 商家成交 + 管事分红)。 + 自动结算路径额外要求 AutoSettleEligible。 + """ + from orders.models import Order + from users.business_models import User + from orders.utils import settle_shangjia_order_guanshi_fenhong + from jituan.services.fund_freeze import credit_dashou_order_settle + + locked = Order.objects.select_for_update().filter(OrderID=order.OrderID).first() + if not locked: + return False, '订单不存在' + if locked.Status != 8: + return False, f'状态不是结算中({locked.Status})' + if source == 'auto_settle': + if not locked.AutoSettleEligible: + return False, '非自动结算资格单' + if locked.AutoExpireAt and locked.AutoExpireAt > timezone.now(): + return False, '未到自动结算时间' + + dashou_id = locked.PlayerID + dashou_fencheng = locked.PlayerCommission + if not dashou_id: + return False, '无接单打手' + if not dashou_fencheng or dashou_fencheng <= 0: + return False, '打手分成无效' + + try: + dashou_user = User.query.get(UserUID=dashou_id) + dashou_profile = dashou_user.DashouProfile + except Exception: + return False, '打手不存在' + + credit_dashou_order_settle( + order=locked, + dashou_profile=dashou_profile, + user_id=dashou_id, + amount=dashou_fencheng, + ) + + if locked.Platform == 2: + try: + ext = getattr(locked, 'shangjia_kuozhan', None) + mid = getattr(ext, 'MerchantID', None) if ext else None + if mid: + shangjia_user = User.query.get(UserUID=mid) + profile = shangjia_user.ShopProfile + type(profile).objects.filter(pk=profile.pk).update(chengjiao=F('chengjiao') + 1) + try: + from backend.utils import update_shangjia_daily + update_shangjia_daily( + yonghuid=mid, + amount=Decimal(str(locked.Amount or 0)), + action=2, + order_id=locked.OrderID, + ) + except Exception as e: + logger.warning('商家日统计跳过 %s: %s', locked.OrderID, e) + except Exception as e: + logger.warning('商家成交累加跳过 %s: %s', locked.OrderID, e) + settle_shangjia_order_guanshi_fenhong(locked, dashou_id) + + locked.Status = 3 + locked.AutoSettleEligible = False + locked.AutoExpireAt = None + # pre_save 会清 SettlementTime;必须写入 update_fields 才能落库 + update_fields = ['Status', 'AutoSettleEligible', 'AutoExpireAt', 'SettlementTime', 'UpdateTime'] + if operator: + locked.AssignedCS = (operator or '')[:20] + update_fields.append('AssignedCS') + locked.save(update_fields=update_fields) + # 成交指标由 post_save signal 幂等记账,此处不重复调用 + return True, 'ok' + + +def scan_due_auto_settle(limit: int = 100) -> dict: + """只处理 eligible 到期单;无关自动结算的俱乐部空跑。""" + from jituan.models import ClubOrderDealConfig + from orders.models import Order + + clubs = list( + ClubOrderDealConfig.query.filter(auto_settle_enabled=True).values_list('club_id', flat=True) + ) + if not clubs: + return {'scanned': 0, 'settled': 0, 'failed': 0, 'clubs': 0} + + now = timezone.now() + qs = ( + Order.objects.filter( + ClubID__in=list(clubs), + Status=8, + AutoSettleEligible=True, + AutoExpireAt__isnull=False, + AutoExpireAt__lte=now, + ) + .order_by('AutoExpireAt')[:limit] + ) + settled = 0 + failed = 0 + for order in qs: + ok, msg = mark_order_completed_unified(order, source='auto_settle', operator='auto') + if ok: + settled += 1 + logger.info('自动结算成功 %s', order.OrderID) + else: + failed += 1 + logger.warning('自动结算跳过 %s: %s', order.OrderID, msg) + return {'scanned': settled + failed, 'settled': settled, 'failed': failed, 'clubs': len(clubs)} diff --git a/jituan/tasks.py b/jituan/tasks.py index 84ceb83..90fe2d6 100644 --- a/jituan/tasks.py +++ b/jituan/tasks.py @@ -13,3 +13,15 @@ def scan_fund_freeze_due(limit: int = 200): result = scan_due_freeze_ledgers(limit=limit) logger.info('scan_fund_freeze_due %s', result) return result + + +@shared_task(name='jituan.tasks.scan_order_auto_settle_due') +def scan_order_auto_settle_due(limit: int = 100): + """ + 可控自动结算:仅 Status=8 且 AutoSettleEligible 且 AutoExpireAt 到期。 + 俱乐部开关关闭时空跑;绝不扫老结算中单。 + """ + from jituan.services.order_deal import scan_due_auto_settle + result = scan_due_auto_settle(limit=limit) + logger.info('scan_order_auto_settle_due %s', result) + return result diff --git a/jituan/urls.py b/jituan/urls.py index 278f51f..698a118 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -107,6 +107,11 @@ from jituan.views_fund_freeze import ( FundFreezeRiskView, ) from jituan.views_role_agreement import RoleAgreementAdminView +from jituan.views_order_deal import ( + OrderDealConfigView, + MerchantDealStatView, + DashouDealStatView, +) urlpatterns = [ path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'), @@ -122,6 +127,9 @@ urlpatterns = [ path('houtai/fund-freeze-ledger', FundFreezeLedgerView.as_view(), name='jituan_fund_freeze_ledger'), path('houtai/fund-freeze-risk', FundFreezeRiskView.as_view(), name='jituan_fund_freeze_risk'), path('houtai/role-agreement', RoleAgreementAdminView.as_view(), name='jituan_role_agreement'), + path('houtai/order-deal-config', OrderDealConfigView.as_view(), name='jituan_order_deal_config'), + path('houtai/merchant-deal-stat', MerchantDealStatView.as_view(), name='jituan_merchant_deal_stat'), + path('houtai/dashou-deal-stat', DashouDealStatView.as_view(), name='jituan_dashou_deal_stat'), path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'), path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'), path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'), diff --git a/jituan/views_order_deal.py b/jituan/views_order_deal.py new file mode 100644 index 0000000..d828b8b --- /dev/null +++ b/jituan/views_order_deal.py @@ -0,0 +1,116 @@ +"""客服:成交指标配置 / 商家·打手指标查询。""" +from rest_framework.parsers import JSONParser +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from backend.utils import verify_kefu_permission, has_merchant_order_permission +from jituan.services.club_context import resolve_club_id_from_request +from jituan.services import order_deal as deal_cfg +from jituan.services import deal_stats as stats_svc + + +def _auth_order(request): + username = (request.data.get('username') or request.data.get('phone') or '').strip() + if not username: + return None, Response({'code': 400, 'msg': '缺少username'}) + kefu, permissions = verify_kefu_permission(request, username) + if kefu is None: + return None, permissions + codes = set(permissions or []) + user = getattr(request, 'user', None) + if getattr(user, 'IsSuperuser', False) or '000001' in codes: + return kefu, None + if has_merchant_order_permission(permissions) or '002ab' in codes: + return kefu, None + return None, Response({'code': 403, 'msg': '无订单管理权限'}) + + +class OrderDealConfigView(APIView): + """ + POST /jituan/houtai/order-deal-config + action: get | save + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + kefu, err = _auth_order(request) + if err: + return err + club_id = (resolve_club_id_from_request(request) or '').strip() + if not club_id: + return Response({'code': 400, 'msg': '请先选择俱乐部'}) + action = (request.data.get('action') or 'get').strip().lower() + if action == 'get': + row = deal_cfg.get_or_create_deal_config(club_id) + return Response({'code': 0, 'msg': 'ok', 'data': deal_cfg.config_to_dict(row)}) + if action == 'save': + try: + operator = getattr(request.user, 'UserUID', '') or '' + data = deal_cfg.save_deal_config(club_id, request.data, updated_by=operator) + return Response({'code': 0, 'msg': '已保存', 'data': data}) + except ValueError as e: + return Response({'code': 400, 'msg': str(e)}) + return Response({'code': 400, 'msg': '无效 action'}) + + +class MerchantDealStatView(APIView): + """POST /jituan/houtai/merchant-deal-stat action: list|detail""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + kefu, err = _auth_order(request) + if err: + return err + club_id = (resolve_club_id_from_request(request) or '').strip() + from jituan.models import MerchantDealStat + action = (request.data.get('action') or 'list').strip().lower() + uid = (request.data.get('merchant_uid') or '').strip() + if action == 'detail' and uid: + row = MerchantDealStat.query.filter(club_id=club_id, merchant_uid=uid).first() + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': stats_svc.serialize_merchant_stat(row) if row else { + 'merchant_uid': uid, 'order_count': 0, 'deal_rate': 0, 'refund_rate': 0, + 'fine_rate': 0, 'avg_deal_hours': 0, + }, + }) + qs = MerchantDealStat.query.filter(club_id=club_id).order_by('-order_count')[:200] + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': {'list': [stats_svc.serialize_merchant_stat(r) for r in qs]}, + }) + + +class DashouDealStatView(APIView): + """POST /jituan/houtai/dashou-deal-stat action: list|detail""" + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + kefu, err = _auth_order(request) + if err: + return err + club_id = (resolve_club_id_from_request(request) or '').strip() + from jituan.models import DashouDealStat + action = (request.data.get('action') or 'list').strip().lower() + uid = (request.data.get('dashou_uid') or '').strip() + if action == 'detail' and uid: + row = DashouDealStat.query.filter(club_id=club_id, dashou_uid=uid).first() + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': stats_svc.serialize_dashou_stat(row) if row else { + 'dashou_uid': uid, 'order_count': 0, 'deal_rate': 0, 'fined_rate': 0, + }, + }) + qs = DashouDealStat.query.filter(club_id=club_id).order_by('-order_count')[:200] + return Response({ + 'code': 0, + 'msg': 'ok', + 'data': {'list': [stats_svc.serialize_dashou_stat(r) for r in qs]}, + }) diff --git a/orders/migrations/0016_order_auto_settle_eligible.py b/orders/migrations/0016_order_auto_settle_eligible.py new file mode 100644 index 0000000..2d4de59 --- /dev/null +++ b/orders/migrations/0016_order_auto_settle_eligible.py @@ -0,0 +1,22 @@ +# 订单 AutoSettleEligible + jituan 成交/自动结算配置与指标表 +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orders', '0015_merchantorderext_bossphone'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='AutoSettleEligible', + field=models.BooleanField( + db_column='auto_settle_eligible', + db_index=True, + default=False, + verbose_name='允许自动结算', + ), + ), + ] diff --git a/orders/models.py b/orders/models.py index 0333f2d..4272d52 100644 --- a/orders/models.py +++ b/orders/models.py @@ -156,6 +156,11 @@ class Order(QModel): null=True, blank=True, db_column='status_8_time', verbose_name='状态变为8的时间', ) + # 可控自动结算:仅「俱乐部开启后新进入结算中」的单为 True;老单永不置 True + AutoSettleEligible = models.BooleanField( + default=False, db_index=True, + db_column='auto_settle_eligible', verbose_name='允许自动结算', + ) # ---- 时间戳 ---- CreateTime = models.DateTimeField( auto_now_add=True, diff --git a/orders/signals.py b/orders/signals.py index 4c0134b..6b53f5c 100644 --- a/orders/signals.py +++ b/orders/signals.py @@ -6,11 +6,9 @@ import sys import importlib import logging -from django.db.models.signals import pre_save +from django.db.models.signals import pre_save, post_save from django.dispatch import receiver -from django.conf import settings -from django.utils import timezone -from gvsdsdk.fluent import db, func, FQ +import logging logger = logging.getLogger(__name__) @@ -42,30 +40,42 @@ ensure_redis_loaded() @receiver(pre_save, sender='orders.Order') def handle_order_status_8(sender, instance, **kwargs): """ - 订单状态变为8时的信号处理。 - ORDER_AUTO_SETTLEMENT_ENABLED=False 时不提交任何 Celery 超时结算任务。 + 订单进入/离开结算中(8): + - 写/清 SettlementTime(进结算时刻) + - 俱乐部自动结算开启后新进 8 → AutoSettleEligible;否则永不 eligible(老单安全) + 旧全局 ORDER_AUTO_SETTLEMENT_ENABLED 保持 False,不投递旧延时任务。 """ - if not getattr(settings, 'ORDER_AUTO_SETTLEMENT_ENABLED', False): - if instance.pk is None: - return - try: - from orders.models import Order - old = Order.query.filter(pk=instance.pk).values_list('Status', flat=True).first() - if old != 8 and instance.Status == 8: - instance.SettlementTime = timezone.now() - instance.pending_dispatch = False - instance.auto_task_id = '' - instance.auto_expire_at = None - elif old == 8 and instance.Status != 8: - instance.SettlementTime = None - instance.pending_dispatch = False - instance.auto_task_id = '' - except Exception as e: - logger.error(f'信号处理失败: {e}') + if instance.pk is None: return + try: + from orders.models import Order + from jituan.services.order_deal import apply_enter_status_8, apply_leave_status_8 - # --- 以下原自动结算逻辑保留备查(开关打开时才启用,当前生产应为 False)--- - - + old = Order.query.filter(pk=instance.pk).values_list('Status', flat=True).first() + if old != 8 and instance.Status == 8: + apply_enter_status_8(instance) + elif old == 8 and instance.Status != 8: + # 成交时长:在清空 SettlementTime 前挂到实例上供 post_save 指标使用 + instance._deal_settle_started_at = instance.SettlementTime + apply_leave_status_8(instance) + except Exception as e: + logger.error(f'结算中信号处理失败: {e}') +@receiver(post_save, sender='orders.Order') +def handle_order_deal_stats(sender, instance, created, **kwargs): + """成交指标:入池 / 完成 / 退款(统计开关关闭或 since 前订单自动跳过)。""" + try: + from jituan.services import deal_stats as ds + if created: + return + # 入池:有打手且进行中/结算中/已完成等 + if instance.PlayerID and instance.Status in (2, 3, 4, 5, 6, 8): + ds.on_order_enter_pool(instance) + if instance.Status == 3: + started = getattr(instance, '_deal_settle_started_at', None) + ds.on_order_completed(instance, settle_started_at=started) + elif instance.Status == 5: + ds.on_order_refunded(instance) + except Exception as e: + logger.error(f'成交指标信号处理失败: {e}')