# 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