Przeglądaj źródła

Add remote-sync action and Booking.com rooms_to_sell drift correction

Booking.com occasionally sets rooms_to_sell to values inconsistent with
the date's availability state (e.g. rts=2 on an open date, or rts=1 on a
booked date), which can cause double-bookings. This adds detection and
correction at multiple levels:

- _BookingClient.set_availability(True): now always sets rooms_to_sell
  explicitly (0 for booked dates, 1 for open), instead of skipping the
  booked case
- _BookingClient.diff_remote_sync / remote_sync: detect and optionally
  fix rts drift via a shared _rts_issues helper
- ServiceAPI.diff_remote_sync / remote_sync: dispatch to any client that
  implements the optional methods (Airbnb is a silent no-op)
- actions/remote_sync.py: new 'remote-sync' action, auto-registered by
  the route loader
- actions/diff.py: adds remote_sync as an 11th parallel diff task
- calendar.html: remote_sync wired into CAT/FIX/MONTH_FIX, byDay
  indexing, dayCategories, monthCats, day panel, and month toolbar
- workflows/remote.py: short/long periodic wrappers added but commented
  out pending production testing; imported in _register_all_periodics
ZiB 4 miesięcy temu
rodzic
commit
2066279127

+ 1 - 0
room_cal/_periodic.py

@@ -37,6 +37,7 @@ def _register_all_periodics() -> None:
   import room_cal.workflows.prices        # noqa: F401
   import room_cal.workflows.prices        # noqa: F401
   import room_cal.workflows.closed        # noqa: F401
   import room_cal.workflows.closed        # noqa: F401
   import room_cal.workflows.local         # noqa: F401
   import room_cal.workflows.local         # noqa: F401
+  import room_cal.workflows.remote        # noqa: F401  # TODO: uncomment register_periodic calls in remote.py when ready
 
 
 
 
 async def run_due(api: ServiceAPI) -> None:
 async def run_due(api: ServiceAPI) -> None:

+ 2 - 1
room_cal/actions/diff.py

@@ -67,9 +67,10 @@ async def diff(
     _timed('rule_closed',       _diff_rule_closed(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('rule_prices',       _diff_rule_prices(start_date, end_date),       timings),
     _timed('orphans',           _diff_orphans(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)
   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']
+  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}
   return {**dict(zip(keys, results)), '_timings': timings}
 
 
 
 

+ 27 - 0
room_cal/actions/remote_sync.py

@@ -0,0 +1,27 @@
+from datetime import date
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+ACTION_SPEC = ('remote-sync', [])
+
+
+async def remote_sync(
+  start_date: date,
+  end_date: date,
+  *,
+  api: 'ServiceAPI | None' = None,
+) -> dict[str, list[dict]]:
+  """Integrity-check and repair service-side data for each connected service.
+
+  Each service implements its own checks; currently Booking.com corrects
+  rooms_to_sell drift (values other than 0 for closed/booked, 1 for open).
+  No comparison to local calendar data is performed.
+
+  Returns {service_key: [{date, ...}]} for each correction made.
+  """
+  from room_cal.service_api import ServiceAPI as _ServiceAPI
+  if api is None:
+    api = _ServiceAPI()
+  return await api.remote_sync(start_date, end_date)

+ 25 - 2
room_cal/service_api/_booking.py

@@ -747,8 +747,8 @@ class _BookingClient:
       await self.set_rooms_to_sell(d, 0)
       await self.set_rooms_to_sell(d, 0)
     else:
     else:
       cal = await self.get_calendar(d, d)
       cal = await self.get_calendar(d, d)
-      if not cal.get(d, DayInfo(date=d, price=None, available=True, booked=False, rooms_to_sell=None)).booked:
-        await self.set_rooms_to_sell(d, 1)
+      day = cal.get(d, DayInfo(date=d, price=None, available=True, booked=False, rooms_to_sell=None))
+      await self.set_rooms_to_sell(d, 0 if day.booked else 1)
     self.clear_cache()
     self.clear_cache()
     _log.info('booking set_availability date=%s available=%s', d, available)
     _log.info('booking set_availability date=%s available=%s', d, available)
 
 
@@ -775,3 +775,26 @@ class _BookingClient:
       'updateAvailability': [entry],
       'updateAvailability': [entry],
     }})
     }})
     self.clear_cache()
     self.clear_cache()
+
+  def _rts_issues(self, cal: 'dict[str, DayInfo]', date_from: str, date_to: str) -> list[dict]:
+    issues = []
+    for d, day in sorted(cal.items()):
+      if not (date_from <= d <= date_to) or day.rooms_to_sell is None:
+        continue
+      expected = 0 if (not day.available or day.booked) else 1
+      if day.rooms_to_sell != expected:
+        issues.append({'date': d, 'actual_rts': day.rooms_to_sell, 'expected_rts': expected})
+    return issues
+
+  async def diff_remote_sync(self, date_from: str, date_to: str) -> list[dict]:
+    cal = await self.get_calendar(date_from, date_to)
+    return self._rts_issues(cal, date_from, date_to)
+
+  async def remote_sync(self, date_from: str, date_to: str) -> list[dict]:
+    cal = await self.get_calendar(date_from, date_to)
+    issues = self._rts_issues(cal, date_from, date_to)
+    for issue in issues:
+      _log.warning('booking rooms_to_sell drift on %s: actual=%d expected=%d — fixing',
+                   issue['date'], issue['actual_rts'], issue['expected_rts'])
+      await self.set_rooms_to_sell(issue['date'], issue['expected_rts'])
+    return issues

+ 22 - 0
room_cal/service_api/client.py

@@ -231,3 +231,25 @@ class ServiceAPI:
         })
         })
     result.sort(key=lambda x: x['start_date'])
     result.sort(key=lambda x: x['start_date'])
     return result
     return result
+
+  async def diff_remote_sync(self, start_date: date, end_date: date) -> dict[str, list[dict]]:
+    """Return per-service integrity issues without making any changes."""
+    ds_from, ds_to = start_date.isoformat(), end_date.isoformat()
+    out: dict[str, list[dict]] = {}
+    for key, client in self._clients.items():
+      if hasattr(client, 'diff_remote_sync'):
+        result = await client.diff_remote_sync(ds_from, ds_to)
+        if result:
+          out[key] = result
+    return out
+
+  async def remote_sync(self, start_date: date, end_date: date) -> dict[str, list[dict]]:
+    """Fix per-service integrity issues. Returns per-service list of corrections made."""
+    ds_from, ds_to = start_date.isoformat(), end_date.isoformat()
+    out: dict[str, list[dict]] = {}
+    for key, client in self._clients.items():
+      if hasattr(client, 'remote_sync'):
+        result = await client.remote_sync(ds_from, ds_to)
+        if result:
+          out[key] = result
+    return out

+ 32 - 0
room_cal/vue/calendar.html

@@ -591,6 +591,10 @@
           <span class="month-fix-label">Rules</span>
           <span class="month-fix-label">Rules</span>
           <button class="fix-btn" :disabled="fixBusy['month-rules-down']" @click="fixMonthScripts()" title="Apply rules for whole month">↓</button>
           <button class="fix-btn" :disabled="fixBusy['month-rules-down']" @click="fixMonthScripts()" title="Apply rules for whole month">↓</button>
         </div>
         </div>
+        <div v-if="monthCats.has('remote_sync')" class="month-fix-group">
+          <span class="month-fix-label">Remote Sync</span>
+          <button class="fix-btn" :disabled="fixMonthBusy('remote_sync')" @click="fixMonth('up','remote_sync')" title="Fix service-side integrity issues for whole month">↑</button>
+        </div>
       </div>
       </div>
     </template>
     </template>
     <div class="sync-bar-tools">
     <div class="sync-bar-tools">
@@ -1125,6 +1129,26 @@
               </div>
               </div>
             </div>
             </div>
 
 
+            <template v-for="(item, svc) in selMismatches.remote_sync" :key="'rs-' + svc">
+              <div class="cat-section">
+                <div class="cat-head">
+                  <span class="cat-indicator"></span>
+                  <span class="cat-label">{{ CAT.remote_sync.label }}: {{ serviceName(svc) }}</span>
+                  <div class="fix-btns">
+                    <button class="fix-btn" title="Fix on service"
+                      :disabled="fixDayBusy('remote_sync', selection.key)"
+                      @click="fixDay('up', 'remote_sync', selection.key)">↑</button>
+                  </div>
+                </div>
+                <div class="cat-rows">
+                  <div class="cat-row">
+                    <span class="cat-row-label">rooms_to_sell</span>
+                    <span class="cat-row-val is-missing">{{ item.actual_rts }} (expected {{ item.expected_rts }})</span>
+                  </div>
+                </div>
+              </div>
+            </template>
+
           </div><!-- /sync error card -->
           </div><!-- /sync error card -->
 
 
         </template><!-- /day selected -->
         </template><!-- /day selected -->
@@ -1271,6 +1295,7 @@ const CAT = {
   rule_closed:       { label: 'Rule: Closed',    color: '#7c3aed' },
   rule_closed:       { label: 'Rule: Closed',    color: '#7c3aed' },
   rule_prices:       { label: 'Rule: Prices',    color: '#7c3aed' },
   rule_prices:       { label: 'Rule: Prices',    color: '#7c3aed' },
   orphans:           { label: 'Orphans',         color: '#64748b' },
   orphans:           { label: 'Orphans',         color: '#64748b' },
+  remote_sync:       { label: 'Remote Sync',     color: '#dc2626' },
 };
 };
 
 
 // null = not supported in that direction
 // null = not supported in that direction
@@ -1299,6 +1324,7 @@ const FIX = {
                        down: (d,x) => [`/action/apply-rules/${d}/${d}`, x],
                        down: (d,x) => [`/action/apply-rules/${d}/${d}`, x],
                        busyKey: d => `rules-down-${d}` },
                        busyKey: d => `rules-down-${d}` },
   orphans:           { up:   null, down: null },
   orphans:           { up:   null, down: null },
+  remote_sync:       { up:   d => [`/action/remote-sync/${d}/${d}`, {}], down: null },
 };
 };
 
 
 const MONTH_FIX = {
 const MONTH_FIX = {
@@ -1310,6 +1336,7 @@ const MONTH_FIX = {
   reservations: { down: (s,e) => [`/action/fetch-reservations/${s}/${e}`, {}] },
   reservations: { down: (s,e) => [`/action/fetch-reservations/${s}/${e}`, {}] },
   user_cal:     { up:   (s,e) => [`/workflow/user-cal/${s}/${e}`,         {}],
   user_cal:     { up:   (s,e) => [`/workflow/user-cal/${s}/${e}`,         {}],
                   down: (s,e) => [`/action/fetch-user-cal/${s}/${e}`,     {}] },
                   down: (s,e) => [`/action/fetch-user-cal/${s}/${e}`,     {}] },
+  remote_sync:  { up:   (s,e) => [`/action/remote-sync/${s}/${e}`,       {}] },
 };
 };
 
 
 async function apiFetch(url, opts) {
 async function apiFetch(url, opts) {
@@ -1809,6 +1836,7 @@ createApp({
       const get = ds => idx[ds] || (idx[ds] = {
       const get = ds => idx[ds] || (idx[ds] = {
         prices: null, closed: null, discounts: null,
         prices: null, closed: null, discounts: null,
         user_cal_closed: null, ical_feeds_closed: {}, rule_discounts: {}, rule_closed: {}, rule_prices: {}, orphans: [], reservations: [],
         user_cal_closed: null, ical_feeds_closed: {}, rule_discounts: {}, rule_closed: {}, rule_prices: {}, orphans: [], reservations: [],
+        remote_sync: {},
       });
       });
 
 
       for (const x of d.prices          || []) get(x.date).prices          = x;
       for (const x of d.prices          || []) get(x.date).prices          = x;
@@ -1826,6 +1854,8 @@ createApp({
         for (const x of items) get(x.date).rule_prices[sn] = x;
         for (const x of items) get(x.date).rule_prices[sn] = x;
       for (const x of d.reservations || [])
       for (const x of d.reservations || [])
         if (x.status !== 'changed' && x.checkin_date) get(x.checkin_date).reservations.push(x);
         if (x.status !== 'changed' && x.checkin_date) get(x.checkin_date).reservations.push(x);
+      for (const [svc, items] of Object.entries(d.remote_sync || {}))
+        for (const x of items) get(x.date).remote_sync[svc] = x;
 
 
       return idx;
       return idx;
     });
     });
@@ -1850,6 +1880,7 @@ createApp({
       if (Object.keys(m.rule_closed).length)        cats.push('rule_closed');
       if (Object.keys(m.rule_closed).length)        cats.push('rule_closed');
       if (Object.keys(m.rule_prices).length)        cats.push('rule_prices');
       if (Object.keys(m.rule_prices).length)        cats.push('rule_prices');
       if (m.orphans.length)                         cats.push('orphans');
       if (m.orphans.length)                         cats.push('orphans');
+      if (Object.keys(m.remote_sync).length)        cats.push('remote_sync');
       return cats;
       return cats;
     }
     }
 
 
@@ -1864,6 +1895,7 @@ createApp({
         if (m.discounts || m.user_cal_closed)                                     cats.add('user_cal');
         if (m.discounts || m.user_cal_closed)                                     cats.add('user_cal');
         if (Object.keys(m.ical_feeds_closed).length)                              cats.add('ical_feeds_closed');
         if (Object.keys(m.ical_feeds_closed).length)                              cats.add('ical_feeds_closed');
         if (Object.keys(m.rule_discounts).length || Object.keys(m.rule_closed).length || Object.keys(m.rule_prices).length) cats.add('rules');
         if (Object.keys(m.rule_discounts).length || Object.keys(m.rule_closed).length || Object.keys(m.rule_prices).length) cats.add('rules');
+        if (Object.keys(m.remote_sync).length)                                            cats.add('remote_sync');
       }
       }
       if (resChanges.value.length) cats.add('reservations');
       if (resChanges.value.length) cats.add('reservations');
       return cats;
       return cats;

+ 26 - 0
room_cal/workflows/remote.py

@@ -0,0 +1,26 @@
+from datetime import timedelta
+from typing import TYPE_CHECKING
+
+from room_cal.actions.remote_sync import remote_sync
+from room_cal._periodic import register_periodic
+from room_cal._utils import local_today, get_config
+
+if TYPE_CHECKING:
+  from room_cal.service_api import ServiceAPI
+
+
+async def _periodic_remote_sync_short(api: 'ServiceAPI') -> None:
+  start = local_today()
+  window = get_config().global_.reservation_window_days
+  await remote_sync(start, start + timedelta(days=window), api=api)
+
+
+async def _periodic_remote_sync_long(api: 'ServiceAPI') -> None:
+  start = local_today()
+  window = get_config().global_.long_window_days
+  await remote_sync(start, start + timedelta(days=window), api=api)
+
+
+# TODO: enable once remote-sync has been tested in production
+# register_periodic(_periodic_remote_sync_short, timedelta(hours=6))
+# register_periodic(_periodic_remote_sync_long, timedelta(days=1))