469 lines
17 KiB
Python
469 lines
17 KiB
Python
# coding=utf-8
|
||
"""当天排餐缓存:启动 / 每天早上拉取,供「小模版库」识别比对。
|
||
|
||
JSON 缓存:dao/mgmt_cache/today_meals.json(每次 refresh 覆盖写入)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import threading
|
||
from datetime import datetime, timedelta
|
||
|
||
from dao.settings import (
|
||
DISH_MEAL_CUTOFF_HOUR,
|
||
DISH_MEAL_PREFETCH_MINUTES,
|
||
DISH_MEAL_REFRESH_HOUR,
|
||
DISH_MEAL_REFRESH_MINUTE,
|
||
DISH_MGMT_MEAL_ON_START,
|
||
DISH_SQLITE_PATH,
|
||
)
|
||
import dao.settings as app_settings
|
||
from service.mgmt_client import MgmtApiError, extract_dish_names_from_meals, fetch_today_meals
|
||
|
||
_CACHE_DIR = os.path.join(os.path.dirname(DISH_SQLITE_PATH) or '.', 'mgmt_cache')
|
||
_CACHE_FILE = os.path.join(_CACHE_DIR, 'today_meals.json')
|
||
|
||
|
||
def _parse_hhmm(text):
|
||
"""'HH:mm' / 'H:mm' → 当天分钟数;失败返回 None。"""
|
||
try:
|
||
parts = str(text or '').strip().split(':')
|
||
if len(parts) < 2:
|
||
return None
|
||
h, m = int(parts[0]), int(parts[1])
|
||
if not (0 <= h <= 23 and 0 <= m <= 59):
|
||
return None
|
||
return h * 60 + m
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _in_period(now_minutes, start_text, end_text):
|
||
start = _parse_hhmm(start_text)
|
||
end = _parse_hhmm(end_text)
|
||
if start is None or end is None:
|
||
return False
|
||
if start <= end:
|
||
return start <= now_minutes < end
|
||
# 跨午夜(少见)
|
||
return now_minutes >= start or now_minutes < end
|
||
|
||
|
||
class MealScheduleStore:
|
||
"""内存 + 本地 JSON;按当前餐段提供小模版库菜名。"""
|
||
|
||
def __init__(self):
|
||
self._lock = threading.RLock()
|
||
self._raw = None
|
||
self._names = set() # 全天全部菜名(兼容旧逻辑)
|
||
self._date = None
|
||
self._loaded_at = None
|
||
self._error = None
|
||
self._refresh_thread = None
|
||
self._stop_refresh = threading.Event()
|
||
self._load_file()
|
||
|
||
def _load_file(self):
|
||
try:
|
||
if not os.path.isfile(_CACHE_FILE):
|
||
return
|
||
with open(_CACHE_FILE, 'r', encoding='utf-8') as f:
|
||
data = json.load(f)
|
||
self._raw = data.get('raw')
|
||
self._date = data.get('date')
|
||
self._names = set(data.get('names') or [])
|
||
self._loaded_at = data.get('loaded_at')
|
||
except Exception as exc:
|
||
print(f'[WARN] meal cache load failed: {exc}')
|
||
|
||
def _save_file(self):
|
||
try:
|
||
os.makedirs(_CACHE_DIR, exist_ok=True)
|
||
with open(_CACHE_FILE, 'w', encoding='utf-8') as f:
|
||
json.dump({
|
||
'date': self._date,
|
||
'names': sorted(self._names),
|
||
'loaded_at': self._loaded_at,
|
||
'raw': self._raw,
|
||
}, f, ensure_ascii=False, indent=2)
|
||
except Exception as exc:
|
||
print(f'[WARN] meal cache save failed: {exc}')
|
||
|
||
def refresh(self, base_name=None, base_code=None, base_id=None, force=False):
|
||
if not force and not DISH_MGMT_MEAL_ON_START and self._names:
|
||
return {'ok': True, 'skipped': True, 'names': len(self._names)}
|
||
base_name = (base_name or getattr(app_settings, 'DISH_BASE_NAME', '') or '').strip() or None
|
||
base_code = (base_code or getattr(app_settings, 'DISH_BASE_CODE', '') or '').strip() or None
|
||
# 有 name 时优先按 name 查(与管理系统约定一致);code 作补充
|
||
try:
|
||
data = fetch_today_meals(
|
||
base_name=base_name,
|
||
base_code=None if base_name else base_code,
|
||
base_id=base_id,
|
||
)
|
||
except MgmtApiError as exc:
|
||
self._error = str(exc)
|
||
print(f'[WARN] meal schedule fetch failed: {exc}')
|
||
return {'ok': False, 'error': str(exc)}
|
||
|
||
names = extract_dish_names_from_meals(data)
|
||
with self._lock:
|
||
self._raw = data
|
||
self._names = names
|
||
self._date = (data or {}).get('date') or datetime.now().strftime('%Y-%m-%d')
|
||
self._loaded_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||
self._error = None
|
||
self._save_file()
|
||
|
||
print(
|
||
f"[OK] meal schedule loaded: date={self._date} "
|
||
f"base={base_name or base_code} dishes={len(names)} -> {_CACHE_FILE}"
|
||
)
|
||
return {'ok': True, 'date': self._date, 'names': len(names)}
|
||
|
||
def has_today_schedule(self):
|
||
"""是否已拿到「今天」的排餐数据(含空菜品列表的成功响应)。"""
|
||
with self._lock:
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
return bool(self._date == today and self._raw is not None)
|
||
|
||
def today_dish_names(self):
|
||
with self._lock:
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
if self._date and self._date != today:
|
||
return set()
|
||
return set(self._names)
|
||
|
||
def has_schedule(self):
|
||
return bool(self.today_dish_names())
|
||
|
||
def is_in_today_menu(self, dish_name):
|
||
name = (dish_name or '').strip()
|
||
if not name:
|
||
return False
|
||
return name in self.today_dish_names()
|
||
|
||
def lookup_price(self, dish_name):
|
||
"""按菜名查今日排餐价格;未命中返回 None。"""
|
||
name = (dish_name or '').strip()
|
||
if not name:
|
||
return None
|
||
today = datetime.now().strftime('%Y-%m-%d')
|
||
with self._lock:
|
||
raw = self._raw
|
||
date = self._date
|
||
if not raw or date != today:
|
||
return None
|
||
for base in raw.get('bases') or []:
|
||
for meal in base.get('meals') or []:
|
||
for dish in meal.get('dishes') or []:
|
||
if (dish.get('name') or '').strip() == name:
|
||
return dish.get('price')
|
||
return None
|
||
|
||
def current_period(self, now=None):
|
||
"""
|
||
根据当前时间匹配餐段。
|
||
返回 dict: mealPeriodId/name/startTime/endTime;不在任何餐段则 None。
|
||
"""
|
||
now = now or datetime.now()
|
||
with self._lock:
|
||
raw = self._raw
|
||
date = self._date
|
||
today = now.strftime('%Y-%m-%d')
|
||
if not raw or date != today:
|
||
return None
|
||
|
||
minutes = now.hour * 60 + now.minute
|
||
# 优先用 bases[].meals 的时段(与菜品绑定);否则用顶层 mealPeriods
|
||
periods = []
|
||
for base in raw.get('bases') or []:
|
||
for meal in base.get('meals') or []:
|
||
periods.append({
|
||
'mealPeriodId': meal.get('mealPeriodId'),
|
||
'mealPeriodName': meal.get('mealPeriodName') or meal.get('name'),
|
||
'startTime': meal.get('startTime'),
|
||
'endTime': meal.get('endTime'),
|
||
})
|
||
if periods:
|
||
break
|
||
if not periods:
|
||
for p in raw.get('mealPeriods') or []:
|
||
periods.append({
|
||
'mealPeriodId': p.get('id'),
|
||
'mealPeriodName': p.get('name'),
|
||
'startTime': p.get('startTime'),
|
||
'endTime': p.get('endTime'),
|
||
})
|
||
|
||
for p in periods:
|
||
if _in_period(minutes, p.get('startTime'), p.get('endTime')):
|
||
return p
|
||
return None
|
||
|
||
def current_period_dish_names(self, now=None):
|
||
"""当前餐段菜名集合(小模版库)。不在餐段 / 无排餐 → 空集。"""
|
||
now = now or datetime.now()
|
||
period = self.current_period(now=now)
|
||
if not period:
|
||
return set()
|
||
|
||
period_id = period.get('mealPeriodId')
|
||
period_name = (period.get('mealPeriodName') or '').strip()
|
||
names = set()
|
||
with self._lock:
|
||
raw = self._raw
|
||
date = self._date
|
||
if not raw or date != now.strftime('%Y-%m-%d'):
|
||
return set()
|
||
|
||
for base in raw.get('bases') or []:
|
||
for meal in base.get('meals') or []:
|
||
mid = meal.get('mealPeriodId')
|
||
mname = (meal.get('mealPeriodName') or meal.get('name') or '').strip()
|
||
match = False
|
||
if period_id is not None and mid is not None and mid == period_id:
|
||
match = True
|
||
elif period_name and mname and period_name == mname:
|
||
match = True
|
||
if not match:
|
||
continue
|
||
for dish in meal.get('dishes') or []:
|
||
name = (dish.get('name') or '').strip()
|
||
if name:
|
||
names.add(name)
|
||
return names
|
||
|
||
def should_use_small_library(self, now=None):
|
||
"""
|
||
是否启用小模版库规则。
|
||
早上 cutoff 点前且尚未拿到当日排餐 → False(直接走大库)。
|
||
"""
|
||
now = now or datetime.now()
|
||
cutoff = int(DISH_MEAL_CUTOFF_HOUR)
|
||
if now.hour < cutoff and not self.has_today_schedule():
|
||
return False
|
||
return self.has_today_schedule()
|
||
|
||
def _meal_period_starts(self):
|
||
"""从缓存解析各餐段开始时间 → [(name, 'HH:mm'), ...]。"""
|
||
with self._lock:
|
||
raw = self._raw
|
||
starts = []
|
||
seen = set()
|
||
if isinstance(raw, dict):
|
||
for p in raw.get('mealPeriods') or []:
|
||
name = (p.get('name') or '').strip() or f"period_{p.get('id')}"
|
||
st = (p.get('startTime') or '').strip()
|
||
if st and _parse_hhmm(st) is not None and st not in seen:
|
||
starts.append((name, st))
|
||
seen.add(st)
|
||
if not starts:
|
||
for base in raw.get('bases') or []:
|
||
for meal in base.get('meals') or []:
|
||
name = (meal.get('mealPeriodName') or meal.get('name') or '').strip()
|
||
st = (meal.get('startTime') or '').strip()
|
||
if st and _parse_hhmm(st) is not None and st not in seen:
|
||
starts.append((name or st, st))
|
||
seen.add(st)
|
||
if not starts:
|
||
# 尚无排餐缓存时的常见餐段兜底
|
||
starts = [('早餐', '07:00'), ('中餐', '11:30'), ('晚餐', '17:30')]
|
||
return starts
|
||
|
||
def _next_prefetch_time(self, now=None):
|
||
"""下一餐「开始前 N 分钟」的拉取时刻。"""
|
||
now = now or datetime.now()
|
||
prefetch_min = max(1, int(DISH_MEAL_PREFETCH_MINUTES))
|
||
candidates = []
|
||
for name, start_hhmm in self._meal_period_starts():
|
||
minutes = _parse_hhmm(start_hhmm)
|
||
if minutes is None:
|
||
continue
|
||
start_dt = now.replace(
|
||
hour=minutes // 60, minute=minutes % 60, second=0, microsecond=0,
|
||
)
|
||
pull_dt = start_dt - timedelta(minutes=prefetch_min)
|
||
label = f"{name}前{prefetch_min}分钟"
|
||
if pull_dt > now:
|
||
candidates.append((pull_dt, label))
|
||
else:
|
||
# 今天的已过 → 排到明天同一餐
|
||
candidates.append((pull_dt + timedelta(days=1), label))
|
||
if not candidates:
|
||
# 最终兜底:配置的固定时刻
|
||
target = now.replace(
|
||
hour=int(DISH_MEAL_REFRESH_HOUR),
|
||
minute=int(DISH_MEAL_REFRESH_MINUTE),
|
||
second=0,
|
||
microsecond=0,
|
||
)
|
||
if target <= now:
|
||
target = target + timedelta(days=1)
|
||
return target, '兜底定时'
|
||
candidates.sort(key=lambda x: x[0])
|
||
return candidates[0]
|
||
|
||
def start_morning_refresh(self, base_name=None, base_code=None):
|
||
"""后台线程:软件外另在早/中/晚开餐前 1 小时各拉一次排餐。"""
|
||
if self._refresh_thread and self._refresh_thread.is_alive():
|
||
return
|
||
|
||
def _loop():
|
||
while not self._stop_refresh.is_set():
|
||
now = datetime.now()
|
||
target, label = self._next_prefetch_time(now)
|
||
wait_s = max(1.0, (target - now).total_seconds())
|
||
print(
|
||
f"[meal] next prefetch at {target.strftime('%Y-%m-%d %H:%M')} "
|
||
f"({label}), wait {int(wait_s)}s"
|
||
)
|
||
if self._stop_refresh.wait(wait_s):
|
||
break
|
||
try:
|
||
self.refresh(
|
||
base_name=base_name or getattr(app_settings, 'DISH_BASE_NAME', None),
|
||
base_code=base_code or getattr(app_settings, 'DISH_BASE_CODE', None),
|
||
force=True,
|
||
)
|
||
print(f"[OK] meal prefetch done ({label})")
|
||
except Exception as exc:
|
||
print(f'[WARN] meal prefetch failed: {exc}')
|
||
# 防止同一分钟内重复触发
|
||
self._stop_refresh.wait(60)
|
||
|
||
self._refresh_thread = threading.Thread(
|
||
target=_loop, name='meal-prefetch-refresh', daemon=True,
|
||
)
|
||
self._refresh_thread.start()
|
||
try:
|
||
nxt, label = self._next_prefetch_time()
|
||
print(
|
||
f'[OK] meal prefetch scheduled: 开餐前 '
|
||
f'{int(DISH_MEAL_PREFETCH_MINUTES)} 分钟;下次 {nxt.strftime("%H:%M")} ({label})'
|
||
)
|
||
except Exception:
|
||
print('[OK] meal prefetch scheduler started')
|
||
|
||
def stop_morning_refresh(self):
|
||
self._stop_refresh.set()
|
||
|
||
def periods_for_display(self, now=None):
|
||
"""供界面展示:按餐段返回今日菜单。
|
||
|
||
:return: {
|
||
date, loaded_at, error,
|
||
current_period: str|None,
|
||
periods: [{name, startTime, endTime, is_current, dishes:[{name, price}]}]
|
||
}
|
||
"""
|
||
now = now or datetime.now()
|
||
today = now.strftime('%Y-%m-%d')
|
||
with self._lock:
|
||
raw = self._raw
|
||
date = self._date
|
||
loaded_at = self._loaded_at
|
||
error = self._error
|
||
|
||
empty = {
|
||
'date': date,
|
||
'loaded_at': loaded_at,
|
||
'error': error,
|
||
'current_period': None,
|
||
'periods': [],
|
||
}
|
||
if not raw or date != today:
|
||
return empty
|
||
|
||
cur = self.current_period(now=now) or {}
|
||
cur_id = cur.get('mealPeriodId')
|
||
cur_name = (cur.get('mealPeriodName') or '').strip()
|
||
|
||
meals = []
|
||
for base in raw.get('bases') or []:
|
||
meals = list(base.get('meals') or [])
|
||
if meals:
|
||
break
|
||
if not meals:
|
||
# 只有 mealPeriods、无 dishes 时也展示空餐段
|
||
for p in raw.get('mealPeriods') or []:
|
||
meals.append({
|
||
'mealPeriodId': p.get('id'),
|
||
'mealPeriodName': p.get('name'),
|
||
'startTime': p.get('startTime'),
|
||
'endTime': p.get('endTime'),
|
||
'dishes': [],
|
||
})
|
||
|
||
periods = []
|
||
for meal in meals:
|
||
name = (meal.get('mealPeriodName') or meal.get('name') or '').strip() or '餐段'
|
||
mid = meal.get('mealPeriodId')
|
||
is_current = False
|
||
if cur_id is not None and mid is not None and mid == cur_id:
|
||
is_current = True
|
||
elif cur_name and name == cur_name:
|
||
is_current = True
|
||
dishes = []
|
||
for d in meal.get('dishes') or []:
|
||
dn = (d.get('name') or '').strip()
|
||
if not dn:
|
||
continue
|
||
dishes.append({
|
||
'name': dn,
|
||
'price': d.get('price'),
|
||
'learningStatus': d.get('learningStatus'),
|
||
})
|
||
periods.append({
|
||
'name': name,
|
||
'startTime': meal.get('startTime') or '',
|
||
'endTime': meal.get('endTime') or '',
|
||
'is_current': is_current,
|
||
'dishes': dishes,
|
||
})
|
||
|
||
return {
|
||
'date': date,
|
||
'loaded_at': loaded_at,
|
||
'error': error,
|
||
'current_period': cur_name or None,
|
||
'periods': periods,
|
||
}
|
||
|
||
def summary(self):
|
||
with self._lock:
|
||
period = None
|
||
try:
|
||
period = self.current_period()
|
||
except Exception:
|
||
period = None
|
||
small = set()
|
||
try:
|
||
small = self.current_period_dish_names()
|
||
except Exception:
|
||
pass
|
||
return {
|
||
'date': self._date,
|
||
'loaded_at': self._loaded_at,
|
||
'dish_count': len(self._names),
|
||
'period': (period or {}).get('mealPeriodName'),
|
||
'period_dish_count': len(small),
|
||
'use_small_lib': self.should_use_small_library(),
|
||
'error': self._error,
|
||
'cache_file': _CACHE_FILE,
|
||
}
|
||
|
||
|
||
# 进程内单例,供识别比对与界面展示使用
|
||
_store = None
|
||
_store_lock = threading.Lock()
|
||
|
||
|
||
def get_meal_schedule_store():
|
||
global _store
|
||
with _store_lock:
|
||
if _store is None:
|
||
_store = MealScheduleStore()
|
||
return _store
|