diff.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import asyncio
  2. import time
  3. from datetime import date, timedelta
  4. from typing import TYPE_CHECKING
  5. from room_cal.store import get_backend
  6. from room_cal.closed import Closed
  7. from room_cal.discount import Discount
  8. from room_cal.pricing.price_calendar import CalPrice
  9. from room_cal.reservations import Reservation
  10. from room_cal._utils import (
  11. safe_json_loads, iter_dates, filter_events, services as _services,
  12. get_config, event_dates, registered_domains, local_today,
  13. )
  14. import room_cal._api_source # ensures 'api' is registered
  15. import room_cal.rules # ensures 'rules' is registered
  16. if TYPE_CHECKING:
  17. from room_cal.service_api import ServiceAPI
  18. ACTION_SPEC = ('diff', [])
  19. _PRICE_PREFIX = 'Price: $'
  20. def _clamp_future(start_date: date) -> date:
  21. """Past dates are not compared — only reservation data has historical value."""
  22. return max(start_date, local_today())
  23. async def _timed(key: str, coro, timings: dict):
  24. t0 = time.perf_counter()
  25. result = await coro
  26. timings[key] = round(time.perf_counter() - t0, 2)
  27. return result
  28. async def diff(
  29. start_date: date,
  30. end_date: date,
  31. *,
  32. api: 'ServiceAPI | None' = None,
  33. ) -> dict:
  34. """Compare the Room calendar to all data sources.
  35. Returns:
  36. prices — Room Price events vs service_api list prices
  37. closed — Room Closed events vs service_api availability
  38. reservations — Room Guest events vs service_api reservations
  39. user_cal_closed — Room Closed (user_cal-sourced) vs user_cal Room Closed events
  40. ical_feeds_closed — {feed_name: [...]} Room Closed (feed-sourced) vs iCal feed content
  41. rule_discounts — {pipeline_name: [...]} Room Discount (rule-sourced) vs rule output
  42. rule_closed — {pipeline_name: [...]} Room Closed (rule-sourced) vs rule output
  43. orphans — Room Closed/Discount events with missing or unrecognized source
  44. No writes are performed. Past dates are compared only for reservation data
  45. (reservations, double_bookings); prices, closed, and rule categories clamp
  46. to today-forward — historical values there are meaningless to users.
  47. """
  48. from room_cal.service_api import ServiceAPI as _ServiceAPI
  49. if api is None:
  50. api = _ServiceAPI()
  51. timings: dict[str, float] = {}
  52. t0 = time.perf_counter()
  53. results = await asyncio.gather(
  54. _timed('prices', _diff_prices(start_date, end_date, api), timings),
  55. _timed('closed', _diff_closed(start_date, end_date, api), timings),
  56. _timed('reservations', _diff_reservations(start_date, end_date, api), timings),
  57. _timed('double_bookings', _diff_double_bookings(start_date, end_date), timings),
  58. _timed('user_cal_closed', _diff_user_cal_closed(start_date, end_date), timings),
  59. _timed('ical_feeds_closed', _diff_ical_feeds_closed(start_date, end_date), timings),
  60. _timed('rule_discounts', _diff_rule_discounts(start_date, end_date), timings),
  61. _timed('rule_closed', _diff_rule_closed(start_date, end_date), timings),
  62. _timed('rule_prices', _diff_rule_prices(start_date, end_date), timings),
  63. _timed('orphans', _diff_orphans(start_date, end_date), timings),
  64. _timed('remote_sync', api.diff_remote_sync(start_date, end_date), timings),
  65. )
  66. timings['_total'] = round(time.perf_counter() - t0, 2)
  67. keys = ['prices', 'closed', 'reservations', 'double_bookings', 'user_cal_closed', 'ical_feeds_closed', 'rule_discounts', 'rule_closed', 'rule_prices', 'orphans', 'remote_sync']
  68. return {**dict(zip(keys, results)), '_timings': timings}
  69. # ── prices ────────────────────────────────────────────────────────────────────
  70. async def _diff_prices(
  71. start_date: date,
  72. end_date: date,
  73. api: 'ServiceAPI',
  74. ) -> list[dict]:
  75. """Room Price: events vs service_api list prices.
  76. Each row: {'date', 'net': float|None, 'per_service': {svc: {'local', 'remote'}}}
  77. """
  78. from room_cal.reservations import Reservation
  79. start_date = _clamp_future(start_date)
  80. if start_date > end_date:
  81. return []
  82. from datetime import datetime
  83. backend = get_backend()
  84. start_dt = datetime(start_date.year, start_date.month, start_date.day)
  85. end_dt = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
  86. events, api_prices, res_events = await asyncio.gather(
  87. backend.fetch_range(start_dt, end_dt),
  88. api.get_prices_range(start_date, end_date),
  89. Reservation().list(start_date, end_date),
  90. )
  91. from room_cal.pricing.price_calendar import PriceData
  92. svc_keys = _services()
  93. cal_prices: dict[str, PriceData] = {}
  94. for e in filter_events(events, _PRICE_PREFIX, all_day=True):
  95. d = safe_json_loads(e.x_data)
  96. if d is not None:
  97. cal_prices[e.start.date().isoformat()] = PriceData.from_dict(d, svc_keys)
  98. booked_svc_by_date: dict[date, str] = {}
  99. for e in res_events:
  100. xd = safe_json_loads(e.x_data)
  101. if xd:
  102. svc = xd.get('service')
  103. if svc:
  104. last_night = e.end.date() - timedelta(days=1)
  105. for d in iter_dates(e.start.date(), last_night):
  106. booked_svc_by_date[d] = svc
  107. results: list[dict] = []
  108. for d in iter_dates(start_date, end_date):
  109. ds = d.isoformat()
  110. local = cal_prices.get(ds)
  111. remote = api_prices.get(ds, {})
  112. has_diff = False
  113. per_service: dict[str, dict] = {}
  114. for svc in svc_keys:
  115. lp = local.service_prices.get(svc) if local else None
  116. rp = remote.get(svc)
  117. if lp is None and rp is None:
  118. continue
  119. booked_svc = booked_svc_by_date.get(d)
  120. if booked_svc is not None and svc != booked_svc:
  121. continue
  122. per_service[svc] = {'local': lp, 'remote': rp}
  123. if lp is None or rp is None or lp != rp:
  124. has_diff = True
  125. if has_diff:
  126. results.append({
  127. 'date': d,
  128. 'net': local.net if local else None,
  129. 'per_service': per_service,
  130. })
  131. return results
  132. # ── closed (service_api) ──────────────────────────────────────────────────────
  133. async def _diff_closed(
  134. start_date: date,
  135. end_date: date,
  136. api: 'ServiceAPI',
  137. ) -> list[dict]:
  138. """Room Closed events vs service_api availability.
  139. Each row: {'date', 'local': list[str]|None, 'remote': list[str]|None}
  140. """
  141. start_date = _clamp_future(start_date)
  142. if start_date > end_date:
  143. return []
  144. closed_cal = Closed()
  145. cal_events = await closed_cal.list(start_date, end_date)
  146. cal_by_date: dict[str, list[str]] = {}
  147. for e in cal_events:
  148. d = safe_json_loads(e.x_data)
  149. if d is not None:
  150. cal_by_date[e.start.date().isoformat()] = sorted(d.get('closed_services', []))
  151. api_by_date = await api.get_closed_by_date(start_date, end_date)
  152. api_by_date = {ds: sorted(svcs) for ds, svcs in api_by_date.items()}
  153. all_dates = sorted(set(cal_by_date) | set(api_by_date))
  154. results: list[dict] = []
  155. for ds in all_dates:
  156. local = cal_by_date.get(ds)
  157. remote = api_by_date.get(ds)
  158. if local != remote:
  159. results.append({'date': date.fromisoformat(ds), 'local': local, 'remote': remote})
  160. return results
  161. # ── reservations ──────────────────────────────────────────────────────────────
  162. async def _diff_reservations(
  163. start_date: date,
  164. end_date: date,
  165. api: 'ServiceAPI',
  166. ) -> list[dict]:
  167. """Room Guest events vs service_api reservations.
  168. Each row: {'status': 'added'|'removed'|'changed', ...}
  169. """
  170. res_cal = Reservation()
  171. cal_events = await res_cal.list(start_date, end_date)
  172. cal_by_id: dict[str, dict] = {}
  173. for e in cal_events:
  174. if e.start.date() < start_date:
  175. continue # checkin before range — belongs to a prior period's scope
  176. d = safe_json_loads(e.x_data)
  177. if d:
  178. rid = d.get('reservation_id')
  179. if rid:
  180. cal_by_id[rid] = {
  181. 'reservation_id': rid,
  182. 'guest_name': d.get('guest_name', ''),
  183. 'service': d.get('service', ''),
  184. 'checkin_date': e.start.date(),
  185. 'checkout_date': e.end.date(),
  186. }
  187. service_reservations = await api.get_reservations(start_date, end_date)
  188. api_by_id: dict[str, dict] = {
  189. r['id']: {
  190. 'reservation_id': r['id'],
  191. 'guest_name': r['guest_name'] or '<None>',
  192. 'service': r['service'],
  193. 'checkin_date': r['start_date'],
  194. 'checkout_date': r['end_date'],
  195. }
  196. for r in service_reservations
  197. }
  198. diffs: list[dict] = []
  199. for rid, api_r in sorted(api_by_id.items(), key=lambda x: x[1]['checkin_date']):
  200. if rid not in cal_by_id:
  201. diffs.append({'status': 'added', **api_r})
  202. else:
  203. cal_r = cal_by_id[rid]
  204. changes = []
  205. for field in ('checkin_date', 'checkout_date', 'guest_name', 'service'):
  206. if cal_r[field] != api_r[field]:
  207. changes.append({'field': field, 'local': cal_r[field], 'remote': api_r[field]})
  208. if changes:
  209. diffs.append({'status': 'changed', 'reservation_id': rid, 'changes': changes})
  210. for rid, cal_r in sorted(cal_by_id.items(), key=lambda x: x[1]['checkin_date']):
  211. if rid not in api_by_id:
  212. diffs.append({'status': 'removed', **cal_r})
  213. return diffs
  214. # ── double bookings ──────────────────────────────────────────────────────────
  215. async def _diff_double_bookings(
  216. start_date: date,
  217. end_date: date,
  218. ) -> list[dict]:
  219. """Local reservation store entries that overlap in date range (double bookings).
  220. Each row: {'a': {id, guest_name, service, checkin_date, checkout_date},
  221. 'b': {id, guest_name, service, checkin_date, checkout_date},
  222. 'overlap_start', 'overlap_end'}
  223. Sorted by overlap_start.
  224. """
  225. res_cal = Reservation()
  226. cal_events = await res_cal.list(start_date, end_date)
  227. reservations = []
  228. for e in cal_events:
  229. if e.start.date() < start_date:
  230. continue
  231. d = safe_json_loads(e.x_data)
  232. if d and d.get('reservation_id'):
  233. reservations.append({
  234. 'id': d['reservation_id'],
  235. 'guest_name': d.get('guest_name', ''),
  236. 'service': d.get('service', ''),
  237. 'start_date': e.start.date(),
  238. 'end_date': e.end.date(),
  239. })
  240. results: list[dict] = []
  241. for i, a in enumerate(reservations):
  242. for b in reservations[i + 1:]:
  243. overlap_start = max(a['start_date'], b['start_date'])
  244. overlap_end = min(a['end_date'], b['end_date'])
  245. if overlap_start < overlap_end:
  246. results.append({
  247. 'a': {
  248. 'id': a['id'],
  249. 'guest_name': a['guest_name'],
  250. 'service': a['service'],
  251. 'checkin_date': a['start_date'],
  252. 'checkout_date': a['end_date'],
  253. },
  254. 'b': {
  255. 'id': b['id'],
  256. 'guest_name': b['guest_name'],
  257. 'service': b['service'],
  258. 'checkin_date': b['start_date'],
  259. 'checkout_date': b['end_date'],
  260. },
  261. 'overlap_start': overlap_start,
  262. 'overlap_end': overlap_end,
  263. })
  264. results.sort(key=lambda x: x['overlap_start'])
  265. return results
  266. # ── closed (user_cal) ─────────────────────────────────────────────────────────
  267. async def _diff_user_cal_closed(
  268. start_date: date,
  269. end_date: date,
  270. ) -> list[dict]:
  271. """Room Closed events (user_cal-sourced) vs current user_cal Room Closed events.
  272. Each row: {'date', 'in_room': bool, 'in_user_cal': bool}
  273. """
  274. closed_cal = Closed()
  275. try:
  276. from room_cal.user_cal import UserCal
  277. user_cal = UserCal()
  278. room_events, user_cal_events = await asyncio.gather(
  279. closed_cal.list(start_date, end_date),
  280. user_cal.find_closed(start_date, end_date),
  281. )
  282. except (ImportError, ValueError):
  283. room_events = await closed_cal.list(start_date, end_date)
  284. user_cal_events = []
  285. room_dates: set[date] = set()
  286. for e in room_events:
  287. xd = safe_json_loads(e.x_data)
  288. if xd and xd.get('source', {}).get('domain') == 'user_cal':
  289. room_dates.add(e.start.date())
  290. user_cal_dates: set[date] = set()
  291. for ev in user_cal_events:
  292. for d in event_dates(ev, start_date, end_date):
  293. user_cal_dates.add(d)
  294. all_dates = sorted(room_dates | user_cal_dates)
  295. return [
  296. {'date': d, 'in_room': d in room_dates, 'in_user_cal': d in user_cal_dates}
  297. for d in all_dates
  298. if (d in room_dates) != (d in user_cal_dates)
  299. ]
  300. # ── closed (iCal feeds) ───────────────────────────────────────────────────────
  301. async def _diff_ical_feeds_closed(
  302. start_date: date,
  303. end_date: date,
  304. ) -> dict[str, list[dict]]:
  305. """Room Closed events (feed-sourced) vs current iCal feed content.
  306. Returns {feed_name: [{'date', 'in_room': bool, 'in_feed': bool}]}
  307. Only includes feeds with at least one difference.
  308. """
  309. from room_cal.ical_feeds import fetch_calendar
  310. entries = get_config().ical_feeds
  311. if not entries:
  312. return {}
  313. closed_cal = Closed()
  314. room_events = await closed_cal.list(start_date, end_date)
  315. async def _check_feed(entry) -> tuple[str, list[dict]]:
  316. feed_name = entry.name
  317. room_dates: set[date] = set()
  318. for e in room_events:
  319. xd = safe_json_loads(e.x_data)
  320. if xd:
  321. src = xd.get('source', {})
  322. if src.get('domain') == 'ical_feeds' and src.get('id') == feed_name:
  323. room_dates.add(e.start.date())
  324. feed_events = await fetch_calendar(entry)
  325. feed_dates: set[date] = set()
  326. for ev in feed_events:
  327. for d in event_dates(ev, start_date, end_date):
  328. feed_dates.add(d)
  329. all_dates = sorted(room_dates | feed_dates)
  330. diffs = [
  331. {'date': d, 'in_room': d in room_dates, 'in_ical_feeds': d in feed_dates}
  332. for d in all_dates
  333. if (d in room_dates) != (d in feed_dates)
  334. ]
  335. return feed_name, diffs
  336. results = await asyncio.gather(*[_check_feed(e) for e in entries])
  337. return {name: diffs for name, diffs in results if diffs}
  338. # ── discounts (rules) ────────────────────────────────────────────────────────
  339. async def _diff_rule_discounts(
  340. start_date: date,
  341. end_date: date,
  342. ) -> dict[str, list[dict]]:
  343. """Room Discount events (rule-sourced) vs what each discount pipeline generates.
  344. Returns {pipeline_name: [{'date', 'local', 'rule'}]}, only pipelines with diffs.
  345. """
  346. start_date = _clamp_future(start_date)
  347. if start_date > end_date:
  348. return {}
  349. disc_cal = Discount()
  350. room_events = await disc_cal.list(start_date, end_date)
  351. results: dict[str, list[dict]] = {}
  352. for name in room_cal.rules.list_pipelines(type_='discount'):
  353. room_by_date: dict[date, dict] = {}
  354. for e in room_events:
  355. xd = safe_json_loads(e.x_data)
  356. if xd:
  357. src = xd.get('source', {})
  358. if src.get('domain') == 'rules' and src.get('id') == name:
  359. room_by_date[e.start.date()] = {'type': xd['type'], 'value': xd['value']}
  360. pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
  361. rule_by_date = {d: {'type': rec.type, 'value': rec.value} for d, rec in pipeline_out.items()}
  362. all_dates = sorted(set(room_by_date) | set(rule_by_date))
  363. diffs = [
  364. {'date': d, 'local': room_by_date.get(d), 'rule': rule_by_date.get(d)}
  365. for d in all_dates
  366. if room_by_date.get(d) != rule_by_date.get(d)
  367. ]
  368. if diffs:
  369. results[name] = diffs
  370. return results
  371. # ── closed (rules) ───────────────────────────────────────────────────────────
  372. async def _diff_rule_closed(
  373. start_date: date,
  374. end_date: date,
  375. ) -> dict[str, list[dict]]:
  376. """Room Closed events (rule-sourced) vs what each closed pipeline generates.
  377. Returns {pipeline_name: [{'date', 'in_room': bool, 'in_rule': bool}]}, only pipelines with diffs.
  378. """
  379. start_date = _clamp_future(start_date)
  380. if start_date > end_date:
  381. return {}
  382. closed_cal = Closed()
  383. room_events = await closed_cal.list(start_date, end_date)
  384. results: dict[str, list[dict]] = {}
  385. for name in room_cal.rules.list_pipelines(type_='closed'):
  386. room_dates: set[date] = set()
  387. for e in room_events:
  388. xd = safe_json_loads(e.x_data)
  389. if xd:
  390. src = xd.get('source', {})
  391. if src.get('domain') == 'rules' and src.get('id') == name:
  392. room_dates.add(e.start.date())
  393. pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
  394. rule_dates: set[date] = {d for d, rec in pipeline_out.items() if rec.services}
  395. all_dates = sorted(room_dates | rule_dates)
  396. diffs = [
  397. {'date': d, 'in_room': d in room_dates, 'in_rule': d in rule_dates}
  398. for d in all_dates
  399. if (d in room_dates) != (d in rule_dates)
  400. ]
  401. if diffs:
  402. results[name] = diffs
  403. return results
  404. # ── prices (rules) ───────────────────────────────────────────────────────────
  405. async def _diff_rule_prices(
  406. start_date: date,
  407. end_date: date,
  408. ) -> dict[str, list[dict]]:
  409. """Room Price events vs minimum floors required by each price pipeline.
  410. Returns {pipeline_name: [{'date', 'local_base', 'rule_min'}]}, only pipelines with diffs.
  411. As-booked dates are excluded: the booking price is always authoritative.
  412. """
  413. start_date = _clamp_future(start_date)
  414. if start_date > end_date:
  415. return {}
  416. price_events, res_events = await asyncio.gather(
  417. CalPrice().list(start_date, end_date),
  418. Reservation().list(start_date, end_date),
  419. )
  420. booked: set[date] = set()
  421. for e in res_events:
  422. last_night = e.end.date() - timedelta(days=1)
  423. for d in iter_dates(e.start.date(), last_night):
  424. booked.add(d)
  425. local_base_by_date: dict[date, int] = {}
  426. for e in price_events:
  427. xd = safe_json_loads(e.x_data)
  428. if xd:
  429. base = xd.get('base') or xd.get('net')
  430. if base is not None:
  431. local_base_by_date[e.start.date()] = base
  432. results: dict[str, list[dict]] = {}
  433. for name in room_cal.rules.list_pipelines(type_='price'):
  434. pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
  435. diffs = []
  436. for d in sorted(pipeline_out):
  437. if d in booked:
  438. continue
  439. rule_min = pipeline_out[d].min_base
  440. local_base = local_base_by_date.get(d)
  441. if local_base is None or local_base < rule_min:
  442. diffs.append({'date': d, 'local_base': local_base, 'rule_min': rule_min})
  443. if diffs:
  444. results[name] = diffs
  445. return results
  446. # ── orphans ───────────────────────────────────────────────────────────────────
  447. async def _diff_orphans(
  448. start_date: date,
  449. end_date: date,
  450. ) -> list[dict]:
  451. """Room Closed and Discount events with missing or unrecognized source.
  452. Each row: {'date', 'event_type': 'closed'|'discount', 'title', 'source': dict|None}
  453. """
  454. from datetime import datetime
  455. backend = get_backend()
  456. start_dt = datetime(start_date.year, start_date.month, start_date.day)
  457. end_dt = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
  458. events = await backend.fetch_range(start_dt, end_dt)
  459. closed_events = filter_events(events, 'Closed: ', all_day=True, require_x_data=False)
  460. discount_events = filter_events(events, 'Discount: ', all_day=True, require_x_data=False)
  461. orphans: list[dict] = []
  462. for e in closed_events:
  463. xd = safe_json_loads(e.x_data)
  464. domain = xd.get('source', {}).get('domain') if xd else None
  465. if domain not in registered_domains():
  466. orphans.append({
  467. 'date': e.start.date(),
  468. 'event_type': 'closed',
  469. 'title': e.title,
  470. 'source': (xd.get('source') if xd else None),
  471. })
  472. for e in discount_events:
  473. xd = safe_json_loads(e.x_data)
  474. domain = xd.get('source', {}).get('domain') if xd else None
  475. if domain not in registered_domains():
  476. orphans.append({
  477. 'date': e.start.date(),
  478. 'event_type': 'discount',
  479. 'title': e.title,
  480. 'source': (xd.get('source') if xd else None),
  481. })
  482. orphans.sort(key=lambda x: x['date'])
  483. return orphans