| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576 |
- import asyncio
- import time
- from datetime import date, timedelta
- from typing import TYPE_CHECKING
- from room_cal.store import get_backend
- from room_cal.closed import Closed
- from room_cal.discount import Discount
- from room_cal.pricing.price_calendar import CalPrice
- from room_cal.reservations import Reservation
- from room_cal._utils import (
- safe_json_loads, iter_dates, filter_events, services as _services,
- get_config, event_dates, registered_domains, local_today,
- )
- import room_cal._api_source # ensures 'api' is registered
- import room_cal.rules # ensures 'rules' is registered
- if TYPE_CHECKING:
- from room_cal.service_api import ServiceAPI
- ACTION_SPEC = ('diff', [])
- _PRICE_PREFIX = 'Price: $'
- def _clamp_future(start_date: date) -> date:
- """Past dates are not compared — only reservation data has historical value."""
- return max(start_date, local_today())
- async def _timed(key: str, coro, timings: dict):
- t0 = time.perf_counter()
- result = await coro
- timings[key] = round(time.perf_counter() - t0, 2)
- return result
- async def diff(
- start_date: date,
- end_date: date,
- *,
- api: 'ServiceAPI | None' = None,
- ) -> dict:
- """Compare the Room calendar to all data sources.
- Returns:
- prices — Room Price events vs service_api list prices
- closed — Room Closed events vs service_api availability
- reservations — Room Guest events vs service_api reservations
- user_cal_closed — Room Closed (user_cal-sourced) vs user_cal Room Closed events
- ical_feeds_closed — {feed_name: [...]} Room Closed (feed-sourced) vs iCal feed content
- rule_discounts — {pipeline_name: [...]} Room Discount (rule-sourced) vs rule output
- rule_closed — {pipeline_name: [...]} Room Closed (rule-sourced) vs rule output
- orphans — Room Closed/Discount events with missing or unrecognized source
- No writes are performed. Past dates are compared only for reservation data
- (reservations, double_bookings); prices, closed, and rule categories clamp
- to today-forward — historical values there are meaningless to users.
- """
- from room_cal.service_api import ServiceAPI as _ServiceAPI
- if api is None:
- api = _ServiceAPI()
- timings: dict[str, float] = {}
- t0 = time.perf_counter()
- results = await asyncio.gather(
- _timed('prices', _diff_prices(start_date, end_date, api), timings),
- _timed('closed', _diff_closed(start_date, end_date, api), timings),
- _timed('reservations', _diff_reservations(start_date, end_date, api), timings),
- _timed('double_bookings', _diff_double_bookings(start_date, end_date), timings),
- _timed('user_cal_closed', _diff_user_cal_closed(start_date, end_date), timings),
- _timed('ical_feeds_closed', _diff_ical_feeds_closed(start_date, end_date), timings),
- _timed('rule_discounts', _diff_rule_discounts(start_date, end_date), timings),
- _timed('rule_closed', _diff_rule_closed(start_date, end_date), timings),
- _timed('rule_prices', _diff_rule_prices(start_date, end_date), timings),
- _timed('orphans', _diff_orphans(start_date, end_date), timings),
- _timed('remote_sync', api.diff_remote_sync(start_date, end_date), timings),
- )
- timings['_total'] = round(time.perf_counter() - t0, 2)
- keys = ['prices', 'closed', 'reservations', 'double_bookings', 'user_cal_closed', 'ical_feeds_closed', 'rule_discounts', 'rule_closed', 'rule_prices', 'orphans', 'remote_sync']
- return {**dict(zip(keys, results)), '_timings': timings}
- # ── prices ────────────────────────────────────────────────────────────────────
- async def _diff_prices(
- start_date: date,
- end_date: date,
- api: 'ServiceAPI',
- ) -> list[dict]:
- """Room Price: events vs service_api list prices.
- Each row: {'date', 'net': float|None, 'per_service': {svc: {'local', 'remote'}}}
- """
- from room_cal.reservations import Reservation
- start_date = _clamp_future(start_date)
- if start_date > end_date:
- return []
- from datetime import datetime
- backend = get_backend()
- start_dt = datetime(start_date.year, start_date.month, start_date.day)
- end_dt = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
- events, api_prices, res_events = await asyncio.gather(
- backend.fetch_range(start_dt, end_dt),
- api.get_prices_range(start_date, end_date),
- Reservation().list(start_date, end_date),
- )
- from room_cal.pricing.price_calendar import PriceData
- svc_keys = _services()
- cal_prices: dict[str, PriceData] = {}
- for e in filter_events(events, _PRICE_PREFIX, all_day=True):
- d = safe_json_loads(e.x_data)
- if d is not None:
- cal_prices[e.start.date().isoformat()] = PriceData.from_dict(d, svc_keys)
- booked_svc_by_date: dict[date, str] = {}
- for e in res_events:
- xd = safe_json_loads(e.x_data)
- if xd:
- svc = xd.get('service')
- if svc:
- last_night = e.end.date() - timedelta(days=1)
- for d in iter_dates(e.start.date(), last_night):
- booked_svc_by_date[d] = svc
- results: list[dict] = []
- for d in iter_dates(start_date, end_date):
- ds = d.isoformat()
- local = cal_prices.get(ds)
- remote = api_prices.get(ds, {})
- has_diff = False
- per_service: dict[str, dict] = {}
- for svc in svc_keys:
- lp = local.service_prices.get(svc) if local else None
- rp = remote.get(svc)
- if lp is None and rp is None:
- continue
- booked_svc = booked_svc_by_date.get(d)
- if booked_svc is not None and svc != booked_svc:
- continue
- per_service[svc] = {'local': lp, 'remote': rp}
- if lp is None or rp is None or lp != rp:
- has_diff = True
- if has_diff:
- results.append({
- 'date': d,
- 'net': local.net if local else None,
- 'per_service': per_service,
- })
- return results
- # ── closed (service_api) ──────────────────────────────────────────────────────
- async def _diff_closed(
- start_date: date,
- end_date: date,
- api: 'ServiceAPI',
- ) -> list[dict]:
- """Room Closed events vs service_api availability.
- Each row: {'date', 'local': list[str]|None, 'remote': list[str]|None}
- """
- start_date = _clamp_future(start_date)
- if start_date > end_date:
- return []
- closed_cal = Closed()
- cal_events = await closed_cal.list(start_date, end_date)
- cal_by_date: dict[str, list[str]] = {}
- for e in cal_events:
- d = safe_json_loads(e.x_data)
- if d is not None:
- cal_by_date[e.start.date().isoformat()] = sorted(d.get('closed_services', []))
- api_by_date = await api.get_closed_by_date(start_date, end_date)
- api_by_date = {ds: sorted(svcs) for ds, svcs in api_by_date.items()}
- all_dates = sorted(set(cal_by_date) | set(api_by_date))
- results: list[dict] = []
- for ds in all_dates:
- local = cal_by_date.get(ds)
- remote = api_by_date.get(ds)
- if local != remote:
- results.append({'date': date.fromisoformat(ds), 'local': local, 'remote': remote})
- return results
- # ── reservations ──────────────────────────────────────────────────────────────
- async def _diff_reservations(
- start_date: date,
- end_date: date,
- api: 'ServiceAPI',
- ) -> list[dict]:
- """Room Guest events vs service_api reservations.
- Each row: {'status': 'added'|'removed'|'changed', ...}
- """
- res_cal = Reservation()
- cal_events = await res_cal.list(start_date, end_date)
- cal_by_id: dict[str, dict] = {}
- for e in cal_events:
- if e.start.date() < start_date:
- continue # checkin before range — belongs to a prior period's scope
- d = safe_json_loads(e.x_data)
- if d:
- rid = d.get('reservation_id')
- if rid:
- cal_by_id[rid] = {
- 'reservation_id': rid,
- 'guest_name': d.get('guest_name', ''),
- 'service': d.get('service', ''),
- 'checkin_date': e.start.date(),
- 'checkout_date': e.end.date(),
- }
- service_reservations = await api.get_reservations(start_date, end_date)
- api_by_id: dict[str, dict] = {
- r['id']: {
- 'reservation_id': r['id'],
- 'guest_name': r['guest_name'] or '<None>',
- 'service': r['service'],
- 'checkin_date': r['start_date'],
- 'checkout_date': r['end_date'],
- }
- for r in service_reservations
- }
- diffs: list[dict] = []
- for rid, api_r in sorted(api_by_id.items(), key=lambda x: x[1]['checkin_date']):
- if rid not in cal_by_id:
- diffs.append({'status': 'added', **api_r})
- else:
- cal_r = cal_by_id[rid]
- changes = []
- for field in ('checkin_date', 'checkout_date', 'guest_name', 'service'):
- if cal_r[field] != api_r[field]:
- changes.append({'field': field, 'local': cal_r[field], 'remote': api_r[field]})
- if changes:
- diffs.append({'status': 'changed', 'reservation_id': rid, 'changes': changes})
- for rid, cal_r in sorted(cal_by_id.items(), key=lambda x: x[1]['checkin_date']):
- if rid not in api_by_id:
- diffs.append({'status': 'removed', **cal_r})
- return diffs
- # ── double bookings ──────────────────────────────────────────────────────────
- async def _diff_double_bookings(
- start_date: date,
- end_date: date,
- ) -> list[dict]:
- """Local reservation store entries that overlap in date range (double bookings).
- Each row: {'a': {id, guest_name, service, checkin_date, checkout_date},
- 'b': {id, guest_name, service, checkin_date, checkout_date},
- 'overlap_start', 'overlap_end'}
- Sorted by overlap_start.
- """
- res_cal = Reservation()
- cal_events = await res_cal.list(start_date, end_date)
- reservations = []
- for e in cal_events:
- if e.start.date() < start_date:
- continue
- d = safe_json_loads(e.x_data)
- if d and d.get('reservation_id'):
- reservations.append({
- 'id': d['reservation_id'],
- 'guest_name': d.get('guest_name', ''),
- 'service': d.get('service', ''),
- 'start_date': e.start.date(),
- 'end_date': e.end.date(),
- })
- results: list[dict] = []
- for i, a in enumerate(reservations):
- for b in reservations[i + 1:]:
- overlap_start = max(a['start_date'], b['start_date'])
- overlap_end = min(a['end_date'], b['end_date'])
- if overlap_start < overlap_end:
- results.append({
- 'a': {
- 'id': a['id'],
- 'guest_name': a['guest_name'],
- 'service': a['service'],
- 'checkin_date': a['start_date'],
- 'checkout_date': a['end_date'],
- },
- 'b': {
- 'id': b['id'],
- 'guest_name': b['guest_name'],
- 'service': b['service'],
- 'checkin_date': b['start_date'],
- 'checkout_date': b['end_date'],
- },
- 'overlap_start': overlap_start,
- 'overlap_end': overlap_end,
- })
- results.sort(key=lambda x: x['overlap_start'])
- return results
- # ── closed (user_cal) ─────────────────────────────────────────────────────────
- async def _diff_user_cal_closed(
- start_date: date,
- end_date: date,
- ) -> list[dict]:
- """Room Closed events (user_cal-sourced) vs current user_cal Room Closed events.
- Each row: {'date', 'in_room': bool, 'in_user_cal': bool}
- """
- closed_cal = Closed()
- try:
- from room_cal.user_cal import UserCal
- user_cal = UserCal()
- room_events, user_cal_events = await asyncio.gather(
- closed_cal.list(start_date, end_date),
- user_cal.find_closed(start_date, end_date),
- )
- except (ImportError, ValueError):
- room_events = await closed_cal.list(start_date, end_date)
- user_cal_events = []
- room_dates: set[date] = set()
- for e in room_events:
- xd = safe_json_loads(e.x_data)
- if xd and xd.get('source', {}).get('domain') == 'user_cal':
- room_dates.add(e.start.date())
- user_cal_dates: set[date] = set()
- for ev in user_cal_events:
- for d in event_dates(ev, start_date, end_date):
- user_cal_dates.add(d)
- all_dates = sorted(room_dates | user_cal_dates)
- return [
- {'date': d, 'in_room': d in room_dates, 'in_user_cal': d in user_cal_dates}
- for d in all_dates
- if (d in room_dates) != (d in user_cal_dates)
- ]
- # ── closed (iCal feeds) ───────────────────────────────────────────────────────
- async def _diff_ical_feeds_closed(
- start_date: date,
- end_date: date,
- ) -> dict[str, list[dict]]:
- """Room Closed events (feed-sourced) vs current iCal feed content.
- Returns {feed_name: [{'date', 'in_room': bool, 'in_feed': bool}]}
- Only includes feeds with at least one difference.
- """
- from room_cal.ical_feeds import fetch_calendar
- entries = get_config().ical_feeds
- if not entries:
- return {}
- closed_cal = Closed()
- room_events = await closed_cal.list(start_date, end_date)
- async def _check_feed(entry) -> tuple[str, list[dict]]:
- feed_name = entry.name
- room_dates: set[date] = set()
- for e in room_events:
- xd = safe_json_loads(e.x_data)
- if xd:
- src = xd.get('source', {})
- if src.get('domain') == 'ical_feeds' and src.get('id') == feed_name:
- room_dates.add(e.start.date())
- feed_events = await fetch_calendar(entry)
- feed_dates: set[date] = set()
- for ev in feed_events:
- for d in event_dates(ev, start_date, end_date):
- feed_dates.add(d)
- all_dates = sorted(room_dates | feed_dates)
- diffs = [
- {'date': d, 'in_room': d in room_dates, 'in_ical_feeds': d in feed_dates}
- for d in all_dates
- if (d in room_dates) != (d in feed_dates)
- ]
- return feed_name, diffs
- results = await asyncio.gather(*[_check_feed(e) for e in entries])
- return {name: diffs for name, diffs in results if diffs}
- # ── discounts (rules) ────────────────────────────────────────────────────────
- async def _diff_rule_discounts(
- start_date: date,
- end_date: date,
- ) -> dict[str, list[dict]]:
- """Room Discount events (rule-sourced) vs what each discount pipeline generates.
- Returns {pipeline_name: [{'date', 'local', 'rule'}]}, only pipelines with diffs.
- """
- start_date = _clamp_future(start_date)
- if start_date > end_date:
- return {}
- disc_cal = Discount()
- room_events = await disc_cal.list(start_date, end_date)
- results: dict[str, list[dict]] = {}
- for name in room_cal.rules.list_pipelines(type_='discount'):
- room_by_date: dict[date, dict] = {}
- for e in room_events:
- xd = safe_json_loads(e.x_data)
- if xd:
- src = xd.get('source', {})
- if src.get('domain') == 'rules' and src.get('id') == name:
- room_by_date[e.start.date()] = {'type': xd['type'], 'value': xd['value']}
- pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
- rule_by_date = {d: {'type': rec.type, 'value': rec.value} for d, rec in pipeline_out.items()}
- all_dates = sorted(set(room_by_date) | set(rule_by_date))
- diffs = [
- {'date': d, 'local': room_by_date.get(d), 'rule': rule_by_date.get(d)}
- for d in all_dates
- if room_by_date.get(d) != rule_by_date.get(d)
- ]
- if diffs:
- results[name] = diffs
- return results
- # ── closed (rules) ───────────────────────────────────────────────────────────
- async def _diff_rule_closed(
- start_date: date,
- end_date: date,
- ) -> dict[str, list[dict]]:
- """Room Closed events (rule-sourced) vs what each closed pipeline generates.
- Returns {pipeline_name: [{'date', 'in_room': bool, 'in_rule': bool}]}, only pipelines with diffs.
- """
- start_date = _clamp_future(start_date)
- if start_date > end_date:
- return {}
- closed_cal = Closed()
- room_events = await closed_cal.list(start_date, end_date)
- results: dict[str, list[dict]] = {}
- for name in room_cal.rules.list_pipelines(type_='closed'):
- room_dates: set[date] = set()
- for e in room_events:
- xd = safe_json_loads(e.x_data)
- if xd:
- src = xd.get('source', {})
- if src.get('domain') == 'rules' and src.get('id') == name:
- room_dates.add(e.start.date())
- pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
- rule_dates: set[date] = {d for d, rec in pipeline_out.items() if rec.services}
- all_dates = sorted(room_dates | rule_dates)
- diffs = [
- {'date': d, 'in_room': d in room_dates, 'in_rule': d in rule_dates}
- for d in all_dates
- if (d in room_dates) != (d in rule_dates)
- ]
- if diffs:
- results[name] = diffs
- return results
- # ── prices (rules) ───────────────────────────────────────────────────────────
- async def _diff_rule_prices(
- start_date: date,
- end_date: date,
- ) -> dict[str, list[dict]]:
- """Room Price events vs minimum floors required by each price pipeline.
- Returns {pipeline_name: [{'date', 'local_base', 'rule_min'}]}, only pipelines with diffs.
- As-booked dates are excluded: the booking price is always authoritative.
- """
- start_date = _clamp_future(start_date)
- if start_date > end_date:
- return {}
- price_events, res_events = await asyncio.gather(
- CalPrice().list(start_date, end_date),
- Reservation().list(start_date, end_date),
- )
- booked: set[date] = set()
- for e in res_events:
- last_night = e.end.date() - timedelta(days=1)
- for d in iter_dates(e.start.date(), last_night):
- booked.add(d)
- local_base_by_date: dict[date, int] = {}
- for e in price_events:
- xd = safe_json_loads(e.x_data)
- if xd:
- base = xd.get('base') or xd.get('net')
- if base is not None:
- local_base_by_date[e.start.date()] = base
- results: dict[str, list[dict]] = {}
- for name in room_cal.rules.list_pipelines(type_='price'):
- pipeline_out = await room_cal.rules.run_pipeline(name, start_date, end_date)
- diffs = []
- for d in sorted(pipeline_out):
- if d in booked:
- continue
- rule_min = pipeline_out[d].min_base
- local_base = local_base_by_date.get(d)
- if local_base is None or local_base < rule_min:
- diffs.append({'date': d, 'local_base': local_base, 'rule_min': rule_min})
- if diffs:
- results[name] = diffs
- return results
- # ── orphans ───────────────────────────────────────────────────────────────────
- async def _diff_orphans(
- start_date: date,
- end_date: date,
- ) -> list[dict]:
- """Room Closed and Discount events with missing or unrecognized source.
- Each row: {'date', 'event_type': 'closed'|'discount', 'title', 'source': dict|None}
- """
- from datetime import datetime
- backend = get_backend()
- start_dt = datetime(start_date.year, start_date.month, start_date.day)
- end_dt = datetime(end_date.year, end_date.month, end_date.day) + timedelta(days=1)
- events = await backend.fetch_range(start_dt, end_dt)
- closed_events = filter_events(events, 'Closed: ', all_day=True, require_x_data=False)
- discount_events = filter_events(events, 'Discount: ', all_day=True, require_x_data=False)
- orphans: list[dict] = []
- for e in closed_events:
- xd = safe_json_loads(e.x_data)
- domain = xd.get('source', {}).get('domain') if xd else None
- if domain not in registered_domains():
- orphans.append({
- 'date': e.start.date(),
- 'event_type': 'closed',
- 'title': e.title,
- 'source': (xd.get('source') if xd else None),
- })
- for e in discount_events:
- xd = safe_json_loads(e.x_data)
- domain = xd.get('source', {}).get('domain') if xd else None
- if domain not in registered_domains():
- orphans.append({
- 'date': e.start.date(),
- 'event_type': 'discount',
- 'title': e.title,
- 'source': (xd.get('source') if xd else None),
- })
- orphans.sort(key=lambda x: x['date'])
- return orphans
|