Files

507 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""今日订单:真单统计 + 可选假数据混合(独立于抢单池假单)。"""
from __future__ import annotations
import logging
from datetime import datetime, time, timedelta
from decimal import Decimal
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.utils import timezone
from orders.models import Order, TodayOrderFake, TodayOrderFakeBatchState, TodayOrderShowConfig
logger = logging.getLogger(__name__)
# 进行中:已下单/待抢、进行中、指定中
DOING_STATUSES = {1, 2, 7}
# 不计入当日有效单:未支付、支付失败、已废弃
EXCLUDE_STATUSES = {9, 14, 25}
# 明细列表假单上限(角标仍用完整 show_count)
_FAKE_LIST_CAP = 3000
_FAKE_COUNT_CAP = 20000
def _now_naive():
"""项目 USE_TZ=False,MySQL 只接受 naive datetime。"""
now = timezone.now()
if timezone.is_aware(now):
return timezone.make_naive(now)
return now
def _today_range():
now = _now_naive()
start = datetime.combine(now.date(), time.min)
end = start + timedelta(days=1)
return start, end
def _abs_media(path: str) -> str:
p = (path or '').strip()
if not p:
return ''
if p.startswith('http://') or p.startswith('https://'):
return p
base = (getattr(settings, 'COS_DOMAIN', '') or '').rstrip('/')
return f'{base}/{p.lstrip("/")}' if base else p
def get_or_create_config(club_id: str) -> TodayOrderShowConfig:
cid = (club_id or '').strip()
row = TodayOrderShowConfig.query.filter(club_id=cid).first()
if row:
return row
row = TodayOrderShowConfig(club_id=cid, mix_enabled=False, batch_size=5, interval_seconds=120)
try:
row.save()
except IntegrityError:
row = TodayOrderShowConfig.query.filter(club_id=cid).first()
if not row:
raise
return row
def config_to_dict(row: TodayOrderShowConfig | None) -> dict:
if not row:
return {
'club_id': '',
'mix_enabled': False,
'batch_size': 5,
'interval_seconds': 120,
}
return {
'club_id': row.club_id,
'mix_enabled': bool(row.mix_enabled),
'batch_size': int(row.batch_size or 5),
'interval_seconds': int(row.interval_seconds or 120),
}
def save_config(club_id: str, data: dict) -> dict:
row = get_or_create_config(club_id)
def _as_bool(v):
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return bool(v)
if isinstance(v, (list, tuple)):
v = v[-1] if v else False
s = str(v or '').strip().lower()
if s in ('1', 'true', 'yes', 'on'):
return True
if s in ('0', 'false', 'no', 'off', ''):
return False
return bool(v)
def _as_int(v, default):
if isinstance(v, (list, tuple)):
v = v[-1] if v else default
if v is None or v == '':
return default
try:
return int(float(v))
except (TypeError, ValueError):
return default
if 'mix_enabled' in data:
row.mix_enabled = _as_bool(data.get('mix_enabled'))
if 'batch_size' in data and data.get('batch_size') is not None and data.get('batch_size') != '':
row.batch_size = max(0, min(500, _as_int(data.get('batch_size'), row.batch_size or 5)))
if 'interval_seconds' in data and data.get('interval_seconds') is not None and data.get('interval_seconds') != '':
row.interval_seconds = max(
1, min(86400, _as_int(data.get('interval_seconds'), row.interval_seconds or 120)),
)
row.save()
return config_to_dict(row)
def _leixing_icon_map(club_id: str, leixing_ids: set[int]) -> dict[int, str]:
if not leixing_ids:
return {}
out = {}
try:
from jituan.services.club_shangpin_leixing import build_club_leixing_list_for_api
for row in build_club_leixing_list_for_api(club_id):
lid = int(row.get('id') or 0)
if lid in leixing_ids:
out[lid] = _abs_media(row.get('tupian_url') or '')
except Exception as e:
logger.warning('leixing icon map failed: %s', e)
if len(out) < len(leixing_ids):
try:
from products.models import ShangpinLeixing
for lx in ShangpinLeixing.query.filter(id__in=list(leixing_ids)):
if lx.id not in out:
out[int(lx.id)] = _abs_media(lx.tupian_url or '')
except Exception as e:
logger.warning('fallback leixing icons failed: %s', e)
return out
def _real_qs(club_id: str):
start, end = _today_range()
return (
Order.query.filter(ClubID=club_id, CreateTime__gte=start, CreateTime__lt=end)
.exclude(Status__in=list(EXCLUDE_STATUSES))
)
def _parse_fake_ids(raw) -> list[int]:
if not isinstance(raw, list):
return []
out = []
for x in raw:
try:
if isinstance(x, bool):
continue
out.append(int(x))
except (TypeError, ValueError):
continue
return out
def _ensure_batch_state(club_id: str) -> TodayOrderFakeBatchState:
state = TodayOrderFakeBatchState.query.filter(club_id=club_id).first()
if state:
return state
state = TodayOrderFakeBatchState(
club_id=club_id, fake_ids=[], show_count=0, last_shuffle_at=None,
)
try:
state.save()
except IntegrityError:
state = TodayOrderFakeBatchState.query.filter(club_id=club_id).first()
if not state:
raise
return state
def _tick_fake_show_count(club_id: str, cfg: TodayOrderShowConfig) -> int:
"""
惰性累加「假单展示数量」(抢单池「今日」角标用这个):
- 当天首次有人请求:show_count = batch_size(如 35)
- 之后每过 interval 秒再有人请求:show_count += batch_size(526→561→596)
- 未到间隔:数字不变
- 无人请求:时间到了也不涨
- 跨自然日:清零重计
数量与假单池「行数」脱钩;明细从池子循环填充。
"""
if not cfg.mix_enabled or int(cfg.batch_size or 0) <= 0:
return 0
if not TodayOrderFake.query.filter(club_id=club_id, is_active=True).exists():
return 0
batch_n = max(1, int(cfg.batch_size))
interval = max(1, int(cfg.interval_seconds or 120))
now = _now_naive()
today = now.date()
with transaction.atomic():
state = (
TodayOrderFakeBatchState.objects
.select_for_update()
.filter(club_id=club_id)
.first()
)
if not state:
_ensure_batch_state(club_id)
state = (
TodayOrderFakeBatchState.objects
.select_for_update()
.filter(club_id=club_id)
.first()
)
if not state:
return 0
last = state.last_shuffle_at
if last and timezone.is_aware(last):
last = timezone.make_naive(last)
show_count = int(getattr(state, 'show_count', 0) or 0)
# 兼容旧逻辑:只有 fake_ids
if show_count <= 0:
legacy_ids = _parse_fake_ids(state.fake_ids)
if legacy_ids:
show_count = len(legacy_ids)
if last is None or last.date() != today:
show_count = 0
last = None
add_batches = 0
if show_count <= 0:
add_batches = 1
elif last is not None:
elapsed = (now - last).total_seconds()
if elapsed >= interval:
add_batches = int(elapsed // interval)
if add_batches > 0:
show_count = min(show_count + batch_n * add_batches, _FAKE_COUNT_CAP)
if last is None:
new_last = now
else:
new_last = last + timedelta(seconds=add_batches * interval)
if new_last > now:
new_last = now
state.show_count = show_count
state.last_shuffle_at = new_last
state.save(update_fields=['show_count', 'last_shuffle_at', 'UpdateTime'])
elif int(getattr(state, 'show_count', 0) or 0) != show_count:
state.show_count = show_count
state.save(update_fields=['show_count', 'UpdateTime'])
return int(state.show_count or 0)
def _load_fake_display_rows(club_id: str, show_count: int) -> list[tuple[TodayOrderFake, int]]:
"""按 show_count 从假单池循环取行。"""
if show_count <= 0:
return []
pool = list(
TodayOrderFake.query.filter(club_id=club_id, is_active=True).order_by('-sort_order', '-id')
)
if not pool:
return []
n = min(int(show_count), _FAKE_LIST_CAP)
return [(pool[i % len(pool)], i) for i in range(n)]
def _serialize_real(o: Order, icon_map: dict[int, str]) -> dict:
lid = int(o.ProductTypeID or 0) if o.ProductTypeID else 0
img = _abs_media(o.ImageURL or '')
if not img and lid:
img = icon_map.get(lid) or ''
st = int(o.Status or 0)
tab = 'doing' if st in DOING_STATUSES else 'done'
platform = int(o.Platform or 0)
source = 'pingtai' if platform == 1 else ('shangjia' if platform == 2 else 'pingtai')
return {
'id': f'r:{o.OrderID}',
'is_fake': False,
'order_id': o.OrderID,
'source': source,
'platform': platform,
'tab': tab,
'status': st,
'price': str(o.Amount if o.Amount is not None else '0.00'),
'jieshao': (o.Description or o.Remark or '').strip() or '订单',
'image_url': img,
'leixing_id': lid or None,
}
def _serialize_fake(f: TodayOrderFake, icon_map: dict[int, str], seq: int = 0) -> dict:
lid = int(f.leixing_id or 0) if f.leixing_id else 0
img = _abs_media(f.image_url or '')
if not img and lid:
img = icon_map.get(lid) or ''
platform = int(f.platform or 1)
source = 'pingtai' if platform == 1 else 'shangjia'
tab = 'doing' if int(f.status or 1) == TodayOrderFake.STATUS_DOING else 'done'
return {
'id': f'f:{f.id}:{seq}',
'is_fake': True,
'order_id': f'FAKE-{f.id}-{seq}',
'source': source,
'platform': platform,
'tab': tab,
'status': int(f.status or 1),
'price': str(f.price if f.price is not None else '0.00'),
'jieshao': (f.jieshao or '').strip() or '订单',
'image_url': img,
'leixing_id': lid or None,
}
def _merged_items(club_id: str) -> tuple[list[dict], int, int]:
"""返回 (明细items, 真单数, 假单展示数 show_count)。"""
cfg = get_or_create_config(club_id)
reals = list(_real_qs(club_id).order_by('-CreateTime')[:800])
show_count = _tick_fake_show_count(club_id, cfg)
fake_rows = _load_fake_display_rows(club_id, show_count)
lids = set()
for o in reals:
if o.ProductTypeID:
lids.add(int(o.ProductTypeID))
for f, _seq in fake_rows:
if f.leixing_id:
lids.add(int(f.leixing_id))
icon_map = _leixing_icon_map(club_id, lids)
items = [_serialize_real(o, icon_map) for o in reals]
items.extend(_serialize_fake(f, icon_map, seq=seq) for f, seq in fake_rows)
return items, len(reals), int(show_count)
def _filter_items(items: list[dict], source: str, tab: str) -> list[dict]:
src = (source or 'all').strip().lower()
tb = (tab or 'doing').strip().lower()
out = items
if src in ('pingtai', 'shangjia'):
out = [x for x in out if x.get('source') == src]
if tb in ('doing', 'done'):
out = [x for x in out if x.get('tab') == tb]
return out
def build_summary(items: list[dict], source: str = 'all', *, total_override: int | None = None) -> dict:
scoped = items
src = (source or 'all').strip().lower()
if src in ('pingtai', 'shangjia'):
scoped = [x for x in items if x.get('source') == src]
doing = sum(1 for x in scoped if x.get('tab') == 'doing')
done = sum(1 for x in scoped if x.get('tab') == 'done')
total = int(total_override) if total_override is not None and src == 'all' else len(scoped)
# source=all 且明细截断时,用 override 保证角标一致;doing/done 按已生成明细比例不够精确时仍用明细
if total_override is not None and src == 'all' and total > len(scoped):
# 把差额算进 done,避免 total != doing+done
done = max(done, total - doing)
return {
'total': total,
'doing': doing,
'done': done,
}
def today_order_count(club_id: str) -> dict:
"""抢单池角标:真单数 + 累加假单数(按间隔只增)。"""
cid = (club_id or '').strip()
if not cid:
return {'total': 0}
cfg = get_or_create_config(cid)
real_n = _real_qs(cid).count()
fake_n = _tick_fake_show_count(cid, cfg)
return {
'total': int(real_n) + int(fake_n),
'real': int(real_n),
'fake': int(fake_n),
}
def today_order_feed(
club_id: str,
source: str = 'all',
tab: str = 'doing',
page: int = 1,
limit: int = 15,
) -> dict:
cid = (club_id or '').strip()
page = max(1, int(page or 1))
limit = max(1, min(50, int(limit or 15)))
items, real_n, fake_n = _merged_items(cid)
summary = build_summary(
items, source=source, total_override=int(real_n) + int(fake_n),
)
filtered = _filter_items(items, source=source, tab=tab)
start = (page - 1) * limit
end = start + limit
chunk = filtered[start:end]
return {
'summary': summary,
'list': chunk,
'more': end < len(filtered),
'page': page,
'limit': limit,
}
def fake_to_admin_dict(row: TodayOrderFake) -> dict:
return {
'id': row.id,
'club_id': row.club_id,
'platform': int(row.platform or 1),
'leixing_id': row.leixing_id,
'jieshao': row.jieshao or '',
'price': str(row.price or '0.00'),
'image_url': row.image_url or '',
'image_full_url': _abs_media(row.image_url or ''),
'status': int(row.status or 1),
'is_active': bool(row.is_active),
'sort_order': int(row.sort_order or 0),
}
def list_fakes(club_id: str) -> list[dict]:
rows = TodayOrderFake.query.filter(club_id=club_id).order_by('-sort_order', '-id')
return [fake_to_admin_dict(r) for r in rows]
def _scalar(val, default=None):
"""QueryDict / multipart 偶发 list,取末项。"""
if isinstance(val, (list, tuple)):
val = val[-1] if val else default
return default if val is None else val
def upsert_fake(club_id: str, data: dict, fake_id=None) -> tuple[dict | None, str]:
cid = (club_id or '').strip()
if not cid:
return None, '缺少俱乐部'
try:
platform = int(_scalar(data.get('platform'), 1) or 1)
except (TypeError, ValueError):
return None, 'platform 无效'
if platform not in (1, 2):
return None, 'platform 须为 1 或 2'
try:
status = int(_scalar(data.get('status'), TodayOrderFake.STATUS_DOING) or TodayOrderFake.STATUS_DOING)
except (TypeError, ValueError):
return None, 'status 无效'
if status not in (TodayOrderFake.STATUS_DOING, TodayOrderFake.STATUS_DONE):
return None, 'status 仅支持进行中/已完成'
try:
price = Decimal(str(_scalar(data.get('price'), '0') if data.get('price') is not None else '0'))
except Exception:
return None, '价格无效'
leixing_id = _scalar(data.get('leixing_id'))
try:
leixing_id = int(leixing_id) if leixing_id not in (None, '') else None
except (TypeError, ValueError):
return None, '商品类型无效'
if fake_id:
if isinstance(fake_id, (list, tuple)):
try:
fake_id = int(fake_id[-1] if fake_id else 0)
except (TypeError, ValueError):
fake_id = None
row = TodayOrderFake.query.filter(id=fake_id, club_id=cid).first()
if not row:
return None, '假单不存在'
else:
row = TodayOrderFake(club_id=cid)
row.platform = platform
row.leixing_id = leixing_id
row.jieshao = str(_scalar(data.get('jieshao'), '') or '')[:2000]
row.price = price
if 'image_url' in data:
row.image_url = str(_scalar(data.get('image_url'), '') or '').strip()[:500]
row.status = status
if 'is_active' in data:
raw = _scalar(data.get('is_active'))
if isinstance(raw, str):
row.is_active = raw.strip().lower() in ('1', 'true', 'yes', 'on')
else:
row.is_active = bool(raw)
if 'sort_order' in data and data.get('sort_order') is not None:
try:
row.sort_order = int(_scalar(data.get('sort_order'), 0) or 0)
except (TypeError, ValueError):
row.sort_order = 0
row.save()
return fake_to_admin_dict(row), ''
def delete_fake(club_id: str, fake_id: int) -> tuple[bool, str]:
row = TodayOrderFake.query.filter(id=fake_id, club_id=club_id).first()
if not row:
return False, '假单不存在'
row.delete()
return True, ''