Dish_Recognition/dao/meal_schedule.py

469 lines
17 KiB
Python
Raw 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.

# 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