diff.py 17 KB

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