From 7a3db29d019b770e5520caaeb9c4bf663b3908ed Mon Sep 17 00:00:00 2001 From: XingQue Date: Mon, 10 Aug 2026 08:58:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=88=90=E4=BA=A4=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=86=E9=92=9F=E3=80=81=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E4=B8=93=E5=8C=BA=E4=BF=AE=E5=9B=BE=E3=80=81=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=8C=89=E6=9C=AC=E5=BA=97=E4=B8=8A=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/views/system.py | 1 + .../0028_deal_config_timeout_minutes.py | 26 +++ jituan/models.py | 16 +- jituan/services/fake_grab_order_admin.py | 37 +++- jituan/services/finance_detail_stats.py | 16 +- jituan/services/order_deal.py | 182 ++++++++++++++++-- jituan/views.py | 2 +- jituan/views_order_deal.py | 12 ++ 8 files changed, 259 insertions(+), 33 deletions(-) create mode 100644 jituan/migrations/0028_deal_config_timeout_minutes.py diff --git a/backend/views/system.py b/backend/views/system.py index e2f643c..8368ef8 100644 --- a/backend/views/system.py +++ b/backend/views/system.py @@ -286,6 +286,7 @@ class GetProductTypeZoneView(APIView): 'id': z.id, 'mingzi': z.mingzi or '', 'leixing_id': z.leixing_id, + 'tupian_url': getattr(z, 'tupian_url', '') or '', 'shenhezhuangtai': z.shenhezhuangtai, 'paixu': z.paixu, 'club_id': getattr(z, 'club_id', '') or '', diff --git a/jituan/migrations/0028_deal_config_timeout_minutes.py b/jituan/migrations/0028_deal_config_timeout_minutes.py new file mode 100644 index 0000000..b93f5e5 --- /dev/null +++ b/jituan/migrations/0028_deal_config_timeout_minutes.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0027_default_partial_settle_ratio'), + ] + + operations = [ + migrations.AddField( + model_name='cluborderdealconfig', + name='minutes_platform', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='平台单超时分钟'), + ), + migrations.AddField( + model_name='cluborderdealconfig', + name='minutes_merchant', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='商家单超时分钟'), + ), + migrations.AddField( + model_name='cluborderdealconfig', + name='dispatch_block_minutes', + field=models.PositiveIntegerField(blank=True, null=True, verbose_name='禁派单超时分钟'), + ), + ] diff --git a/jituan/models.py b/jituan/models.py index 5b11d8a..fe9c9a2 100644 --- a/jituan/models.py +++ b/jituan/models.py @@ -693,11 +693,21 @@ 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='商家单超时小时') + hours_platform = models.PositiveIntegerField(default=48, verbose_name='平台单超时小时(兼容旧)') + hours_merchant = models.PositiveIntegerField(default=48, verbose_name='商家单超时小时(兼容旧)') + # 精确到分钟:优先使用;空则回落 hours_* × 60 + minutes_platform = models.PositiveIntegerField( + null=True, blank=True, verbose_name='平台单超时分钟', + ) + minutes_merchant = models.PositiveIntegerField( + null=True, blank=True, verbose_name='商家单超时分钟', + ) # 超时禁派单:与自动结算独立;关则派单路径零拦截 dispatch_block_enabled = models.BooleanField(default=False, verbose_name='结算超时禁派单开关') - dispatch_block_hours = models.PositiveIntegerField(default=24, verbose_name='禁派单超时小时') + dispatch_block_hours = models.PositiveIntegerField(default=24, verbose_name='禁派单超时小时(兼容旧)') + dispatch_block_minutes = models.PositiveIntegerField( + null=True, blank=True, verbose_name='禁派单超时分钟', + ) stats_enabled = models.BooleanField(default=False, verbose_name='成交指标统计开关') stats_since = models.DateTimeField(null=True, blank=True, verbose_name='指标统计起点') # 拒绝退款时客服审核默认比例(可改本次值);0.01~1.00,默认 0.5 diff --git a/jituan/services/fake_grab_order_admin.py b/jituan/services/fake_grab_order_admin.py index ebda339..14336eb 100644 --- a/jituan/services/fake_grab_order_admin.py +++ b/jituan/services/fake_grab_order_admin.py @@ -71,8 +71,22 @@ def _leixing_name_map(): } -def build_leixing_options(): - """全部可用商品类型(非审核专用),假单可绑定任意类型。""" +def build_leixing_options(club_id=None): + """本俱乐部已上架商品类型(非审核专用)。未传 club_id 时回落全量(兼容旧调用)。""" + from jituan.services.club_shangpin_leixing import build_club_leixing_list_for_api + + cid = (club_id or '').strip() + if cid: + rows = build_club_leixing_list_for_api(cid) + return [ + { + 'id': r['id'], + 'jieshao': r.get('jieshao') or '', + 'tupian_url': r.get('tupian_url') or '', + 'bankuai_id': r.get('bankuai_id'), + } + for r in rows + ] options = [] for lx in ShangpinLeixing.query.exclude(shenhezhuangtai=2).order_by('-paixu', 'id'): options.append({ @@ -118,7 +132,7 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20 'club_id': resolve_club_id_from_request(request), 'scope': resolve_club_scope(request), 'scope_error': CLUB_READ_SCOPE_MSG, - 'leixing_options': build_leixing_options(), + 'leixing_options': [], 'fake_grab_enabled': False, 'fake_yajin_hide_threshold': 10, **list_response_meta(request), @@ -149,7 +163,7 @@ def build_fake_order_list_payload(request, leixing_id=None, page=1, page_size=20 'page_size': page_size, 'club_id': club_id, 'scope': resolve_club_scope(request), - 'leixing_options': build_leixing_options(), + 'leixing_options': build_leixing_options(club_id), 'fake_grab_enabled': is_club_fake_grab_enabled(club_id), 'fake_yajin_hide_threshold': float(get_fake_yajin_hide_threshold(club_id)), **list_response_meta(request), @@ -218,13 +232,22 @@ def _parse_decimal(value, field_name='金额'): raise ValueError(f'{field_name}格式错误') -def _validate_leixing_id(leixing_id): +def _validate_leixing_id(leixing_id, club_id=None): try: lid = int(leixing_id) except (TypeError, ValueError): return None, '请选择商品类型' if not ShangpinLeixing.query.filter(id=lid).exclude(shenhezhuangtai=2).exists(): return None, '商品类型不存在或为审核专用' + cid = (club_id or '').strip() + if cid: + from jituan.models import ClubShangpinLeixingConfig + from jituan.constants import CLUB_ID_DEFAULT + cfg = ClubShangpinLeixingConfig.query.filter(club_id=cid, leixing_id=lid).first() + if cfg is not None and not cfg.is_enabled: + return None, '该商品类型未在本俱乐部上架' + if cfg is None and cid != CLUB_ID_DEFAULT: + return None, '该商品类型未在本俱乐部上架' return lid, None @@ -233,7 +256,7 @@ def create_fake_order(request, data): return None, CLUB_WRITE_SCOPE_MSG club_id = club_id_for_write(request) - leixing_id, err = _validate_leixing_id(data.get('leixing_id')) + leixing_id, err = _validate_leixing_id(data.get('leixing_id'), club_id) if err: return None, err @@ -282,7 +305,7 @@ def update_fake_order(request, order_id, data): update_fields = ['UpdateTime'] if 'leixing_id' in data and data.get('leixing_id') not in (None, ''): - leixing_id, err = _validate_leixing_id(data.get('leixing_id')) + leixing_id, err = _validate_leixing_id(data.get('leixing_id'), club_id) if err: return None, err row.ProductTypeID = leixing_id diff --git a/jituan/services/finance_detail_stats.py b/jituan/services/finance_detail_stats.py index dbfcbe4..aeaaa05 100644 --- a/jituan/services/finance_detail_stats.py +++ b/jituan/services/finance_detail_stats.py @@ -130,7 +130,21 @@ def _czjilu_summary_qs(request, lx, granularity, summary_q): return qs -def build_order_types_payload(): +def build_order_types_payload(request=None): + """财务筛选用订单类型:有 request 时仅本俱乐部已上架类型。""" + if request is not None: + from jituan.constants import DATA_SCOPE_ALL + from jituan.services.club_context import resolve_club_id_from_request, resolve_club_scope + from jituan.services.club_shangpin_leixing import build_club_leixing_list_for_api + + if resolve_club_scope(request) != DATA_SCOPE_ALL: + club_id = (resolve_club_id_from_request(request) or '').strip() + if club_id: + return [ + {'id': r['id'], 'jieshao': r.get('jieshao') or ''} + for r in build_club_leixing_list_for_api(club_id) + if int(r.get('shenhezhuangtai') or 1) == 1 + ] types = ShangpinLeixing.query.filter(shenhezhuangtai=1).values('id', 'jieshao') return list(types) diff --git a/jituan/services/order_deal.py b/jituan/services/order_deal.py index e20b050..89e92ec 100644 --- a/jituan/services/order_deal.py +++ b/jituan/services/order_deal.py @@ -33,6 +33,81 @@ def _default_stats_since(): return _db_datetime(datetime(2020, 1, 1)) +# 超时上限:30 天(分钟) +_MAX_TIMEOUT_MINUTES = 720 * 60 + + +def _clamp_minutes(value: int, default: int = 60) -> int: + try: + v = int(value) + except (TypeError, ValueError): + v = default + return max(1, min(v, _MAX_TIMEOUT_MINUTES)) + + +def _effective_timeout_minutes(row, minutes_attr: str, hours_attr: str, default_hours: int) -> int: + """优先 minutes_*;否则 hours_* × 60。""" + raw_m = getattr(row, minutes_attr, None) if row else None + if raw_m is not None: + try: + m = int(raw_m) + if m > 0: + return _clamp_minutes(m, default_hours * 60) + except (TypeError, ValueError): + pass + try: + h = int(getattr(row, hours_attr, None) or default_hours) if row else default_hours + except (TypeError, ValueError): + h = default_hours + return _clamp_minutes(max(1, h) * 60, default_hours * 60) + + +def _pick_display_unit(minutes: int) -> tuple[str, int]: + """整小时用 h,否则用 m。""" + m = max(1, int(minutes or 1)) + if m % 60 == 0: + return 'h', m // 60 + return 'm', m + + +def _parse_timeout_to_minutes( + data: dict, + *, + hours_key: str, + minutes_key: str, + unit_key: str, + value_key: str, + default_hours: int, +) -> int | None: + """ + 解析超时为分钟。支持: + - value_key + unit_key ('h'|'m') + - minutes_key + - hours_key(旧:按小时) + 未传任何字段返回 None。 + """ + if not isinstance(data, dict): + return None + unit = (data.get(unit_key) or '').strip().lower() + if value_key in data and data.get(value_key) is not None and unit in ('h', 'm', 'hour', 'hours', 'min', 'minute', 'minutes'): + try: + val = int(data.get(value_key)) + except (TypeError, ValueError): + return _clamp_minutes(default_hours * 60) + if unit in ('h', 'hour', 'hours'): + return _clamp_minutes(val * 60) + return _clamp_minutes(val) + if minutes_key in data and data.get(minutes_key) is not None: + return _clamp_minutes(data.get(minutes_key), default_hours * 60) + if hours_key in data and data.get(hours_key) is not None: + try: + h = int(data.get(hours_key)) + except (TypeError, ValueError): + h = default_hours + return _clamp_minutes(max(1, h) * 60) + return None + + def get_or_create_deal_config(club_id: str): from jituan.models import ClubOrderDealConfig cid = (club_id or '').strip() @@ -98,8 +173,17 @@ def config_to_dict(row) -> dict: 'auto_settle_enabled_at': '', 'hours_platform': 48, 'hours_merchant': 48, + 'minutes_platform': 48 * 60, + 'minutes_merchant': 48 * 60, + 'platform_timeout': 48, + 'platform_timeout_unit': 'h', + 'merchant_timeout': 48, + 'merchant_timeout_unit': 'h', 'dispatch_block_enabled': False, 'dispatch_block_hours': 24, + 'dispatch_block_minutes': 24 * 60, + 'dispatch_block_timeout': 24, + 'dispatch_block_timeout_unit': 'h', 'stats_enabled': False, 'stats_since': '', 'default_partial_settle_ratio': default_ratio, @@ -110,14 +194,30 @@ def config_to_dict(row) -> dict: ratio_d = Decimal(str(ratio if ratio is not None else '0.5')).quantize(Decimal('0.0001')) except Exception: ratio_d = Decimal('0.5000') + m_plat = _effective_timeout_minutes(row, 'minutes_platform', 'hours_platform', 48) + m_merch = _effective_timeout_minutes(row, 'minutes_merchant', 'hours_merchant', 48) + m_block = _effective_timeout_minutes(row, 'dispatch_block_minutes', 'dispatch_block_hours', 24) + u_plat, v_plat = _pick_display_unit(m_plat) + u_merch, v_merch = _pick_display_unit(m_merch) + u_block, v_block = _pick_display_unit(m_block) 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), + # 旧字段:整小时向上取整,兼容旧后台 + 'hours_platform': max(1, (m_plat + 59) // 60), + 'hours_merchant': max(1, (m_merch + 59) // 60), + 'minutes_platform': m_plat, + 'minutes_merchant': m_merch, + 'platform_timeout': v_plat, + 'platform_timeout_unit': u_plat, + 'merchant_timeout': v_merch, + 'merchant_timeout_unit': u_merch, 'dispatch_block_enabled': bool(getattr(row, 'dispatch_block_enabled', False)), - 'dispatch_block_hours': int(getattr(row, 'dispatch_block_hours', None) or 24), + 'dispatch_block_hours': max(1, (m_block + 59) // 60), + 'dispatch_block_minutes': m_block, + 'dispatch_block_timeout': v_block, + 'dispatch_block_timeout_unit': u_block, 'stats_enabled': bool(row.stats_enabled), 'stats_since': row.stats_since.isoformat() if row.stats_since else '', 'default_partial_settle_ratio': str(ratio_d), @@ -288,9 +388,9 @@ def merchant_overdue_pending_q(club_id: str, merchant_uid: str, cfg=None): if merchant_skips_dispatch_block(cid, uid): return None, cfg - hours = max(1, min(int(getattr(cfg, 'dispatch_block_hours', None) or 24), 720)) + minutes = _effective_timeout_minutes(cfg, 'dispatch_block_minutes', 'dispatch_block_hours', 24) now = _db_datetime(timezone.now()) - cutoff = now - timedelta(hours=hours) + cutoff = now - timedelta(minutes=minutes) return _dispatch_block_overdue_q(cutoff), cfg @@ -320,11 +420,12 @@ def count_merchant_overdue_pending(club_id: str, merchant_uid: str) -> dict: db_on = bool(getattr(cfg, 'dispatch_block_enabled', False)) auto_on = bool(getattr(cfg, 'auto_settle_enabled', False)) - hours = max(1, min(int(getattr(cfg, 'dispatch_block_hours', None) or 24), 720)) + minutes = _effective_timeout_minutes(cfg, 'dispatch_block_minutes', 'dispatch_block_hours', 24) base = { 'overdue_pending_enabled': db_on and not merchant_skips_dispatch_block(cid, uid), 'dispatch_block_enabled': db_on, - 'dispatch_block_hours': hours, + 'dispatch_block_hours': max(1, (minutes + 59) // 60), + 'dispatch_block_minutes': minutes, 'auto_settle_enabled': auto_on, 'overdue_pending_amount': '0.00', } @@ -391,13 +492,14 @@ def order_settle_timeout_payload(order, club_id: str = '', merchant_uid: str = ' if mid and merchant_skips_dispatch_block(cid, mid): return {} - hours = max(1, min(int(getattr(cfg, 'dispatch_block_hours', None) or 24), 720)) + minutes = _effective_timeout_minutes(cfg, 'dispatch_block_minutes', 'dispatch_block_hours', 24) + hours = max(1, (minutes + 59) // 60) now = _db_datetime(timezone.now()) st = getattr(order, 'SettlementTime', None) if st is None: deadline_at = now - timedelta(seconds=1) else: - deadline_at = _db_datetime(st) + timedelta(hours=hours) + deadline_at = _db_datetime(st) + timedelta(minutes=minutes) remain = int((deadline_at - now).total_seconds()) overdue = remain <= 0 @@ -406,10 +508,14 @@ def order_settle_timeout_payload(order, club_id: str = '', merchant_uid: str = ' except Exception: deadline_ts = 0 + if minutes % 60 == 0: + dur_tip = f'{minutes // 60}小时' + else: + dur_tip = f'{minutes}分钟' tip = ( - f'已超时待结算(超{hours}小时未结,禁派单)' + f'已超时待结算(超{dur_tip}未结,禁派单)' if overdue - else f'超时禁派倒计时(规定{hours}小时内须结算)' + else f'超时禁派倒计时(规定{dur_tip}内须结算)' ) return { 'settle_timeout_enabled': True, @@ -420,6 +526,7 @@ def order_settle_timeout_payload(order, club_id: str = '', merchant_uid: str = ' 'settle_timeout_tip': tip, 'settle_timeout_label': '结算超时', 'settle_timeout_hours': hours, + 'settle_timeout_minutes': minutes, } @@ -440,9 +547,10 @@ def check_merchant_dispatch_block(club_id: str, merchant_uid: str) -> dict: if merchant_skips_dispatch_block(cid, uid): return {'ok': True} - hours = max(1, min(int(getattr(cfg, 'dispatch_block_hours', None) or 24), 720)) + minutes = _effective_timeout_minutes(cfg, 'dispatch_block_minutes', 'dispatch_block_hours', 24) + hours = max(1, (minutes + 59) // 60) now = _db_datetime(timezone.now()) - cutoff = now - timedelta(hours=hours) + cutoff = now - timedelta(minutes=minutes) base = _merchant_status8_qs(cid, uid).filter(_dispatch_block_overdue_q(cutoff)) total = base.count() @@ -478,13 +586,42 @@ def save_deal_config(club_id: str, data: dict, updated_by: str = '') -> dict: was_stats = bool(row.stats_enabled) now = _db_datetime(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)) + m_plat = _parse_timeout_to_minutes( + data, + hours_key='hours_platform', + minutes_key='minutes_platform', + unit_key='platform_timeout_unit', + value_key='platform_timeout', + default_hours=48, + ) + if m_plat is not None: + row.minutes_platform = m_plat + row.hours_platform = max(1, (m_plat + 59) // 60) + + m_merch = _parse_timeout_to_minutes( + data, + hours_key='hours_merchant', + minutes_key='minutes_merchant', + unit_key='merchant_timeout_unit', + value_key='merchant_timeout', + default_hours=48, + ) + if m_merch is not None: + row.minutes_merchant = m_merch + row.hours_merchant = max(1, (m_merch + 59) // 60) + + m_block = _parse_timeout_to_minutes( + data, + hours_key='dispatch_block_hours', + minutes_key='dispatch_block_minutes', + unit_key='dispatch_block_timeout_unit', + value_key='dispatch_block_timeout', + default_hours=24, + ) + if m_block is not None: + row.dispatch_block_minutes = m_block + row.dispatch_block_hours = max(1, (m_block + 59) // 60) - if 'dispatch_block_hours' in data and data['dispatch_block_hours'] is not None: - row.dispatch_block_hours = max(1, min(int(data['dispatch_block_hours']), 720)) if 'dispatch_block_enabled' in data and data['dispatch_block_enabled'] is not None: row.dispatch_block_enabled = bool(data['dispatch_block_enabled']) @@ -583,9 +720,12 @@ def apply_enter_status_8(instance): and now >= enabled_at and not skip_wl ): - hours = int(cfg.hours_merchant if instance.Platform == 2 else cfg.hours_platform) or 48 + if int(getattr(instance, 'Platform', 0) or 0) == 2: + minutes = _effective_timeout_minutes(cfg, 'minutes_merchant', 'hours_merchant', 48) + else: + minutes = _effective_timeout_minutes(cfg, 'minutes_platform', 'hours_platform', 48) instance.AutoSettleEligible = True - instance.AutoExpireAt = now + timedelta(hours=hours) + instance.AutoExpireAt = now + timedelta(minutes=minutes) else: instance.AutoSettleEligible = False instance.AutoExpireAt = None diff --git a/jituan/views.py b/jituan/views.py index 55ffb9f..c85aea5 100644 --- a/jituan/views.py +++ b/jituan/views.py @@ -1121,7 +1121,7 @@ class ClubOrderTypesView(APIView): return permissions if 'dingdancaiwu' not in permissions: return Response({'code': 403, 'msg': '无订单财务查看权限'}, status=403) - return Response({'code': 0, 'msg': 'ok', 'data': build_order_types_payload()}) + return Response({'code': 0, 'msg': 'ok', 'data': build_order_types_payload(request)}) class ClubOrderFinanceView(APIView): diff --git a/jituan/views_order_deal.py b/jituan/views_order_deal.py index 1a91711..3d55b84 100644 --- a/jituan/views_order_deal.py +++ b/jituan/views_order_deal.py @@ -41,6 +41,12 @@ def _auth_deal_config(request): return kefu, None +def _deny_second_super_deal_module(permissions): + if is_second_super_only(permissions): + return Response({'code': 403, 'msg': '无权访问成交与自动结算相关功能'}) + return None + + class OrderDealConfigView(APIView): """ POST /jituan/houtai/order-deal-config @@ -111,6 +117,9 @@ class MerchantDealStatView(APIView): kefu, permissions, err = _auth_order(request) if err: return err + denied = _deny_second_super_deal_module(permissions) + if denied: + return denied club_id = (resolve_club_id_from_request(request) or '').strip() from jituan.models import MerchantDealStat action = (request.data.get('action') or 'list').strip().lower() @@ -142,6 +151,9 @@ class DashouDealStatView(APIView): kefu, permissions, err = _auth_order(request) if err: return err + denied = _deny_second_super_deal_module(permissions) + if denied: + return denied club_id = (resolve_club_id_from_request(request) or '').strip() from jituan.models import DashouDealStat action = (request.data.get('action') or 'list').strip().lower()