feat: 成交超时支持分钟、类型专区修图、订单类型按本店上架

This commit is contained in:
XingQue committed 2026-08-10 08:58:10 +08:00
1 parent 0e890257bf
commit 7a3db29d01
8 files changed
+259 -33

No files matched your search

+1
View File
@@ -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 '',
@@ -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='禁派单超时分钟'),
),
]
+13 -3
View File
@@ -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
+30 -7
View File
@@ -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
+15 -1
View File
@@ -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)
+161 -21
View File
@@ -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
+1 -1
View File
@@ -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):
+12
View File
@@ -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()