Procházet zdrojové kódy

Add double-booking detection to diff

Checks all service_api reservations for date overlaps. Reported in:
- diff CLI: new 'Double bookings' section with overlap window and both
  reservation details
- calendar UI: red badge in sync bar + a card in the no-selection panel
  showing each overlapping pair with dates and guest names
ZiB před 4 měsíci
rodič
revize
49aba3fc6f
3 změnil soubory, kde provedl 76 přidání a 3 odebrání
  1. 11 0
      room_cal/actions/_cli.py
  2. 45 1
      room_cal/actions/diff.py
  3. 20 2
      room_cal/vue/calendar.html

+ 11 - 0
room_cal/actions/_cli.py

@@ -218,6 +218,17 @@ async def _diff(start: date, end: date, args) -> None:
         for c in r['changes']:
           print(f"  [~] {r['reservation_id']}  {c['field']}: {c['local']!r} → {c['remote']!r}")
 
+  double_bookings = result['double_bookings']
+  if double_bookings:
+    any_diff = True
+    n = len(double_bookings)
+    print(f"\n  Double bookings  ({n} overlap{'s' if n != 1 else ''})")
+    for db in double_bookings:
+      a, b = db['a'], db['b']
+      print(f"  [!] {db['overlap_start']} – {db['overlap_end']}")
+      print(f"        {a['service']}  {a['checkin_date']} → {a['checkout_date']}  {a['guest_name']}  ({a['id']})")
+      print(f"        {b['service']}  {b['checkin_date']} → {b['checkout_date']}  {b['guest_name']}  ({b['id']})")
+
   uc_closed = result['user_cal_closed']
   if uc_closed:
     any_diff = True

+ 45 - 1
room_cal/actions/diff.py

@@ -60,6 +60,7 @@ async def diff(
     _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, api), 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),
@@ -68,7 +69,7 @@ async def diff(
     _timed('orphans',           _diff_orphans(start_date, end_date),           timings),
   )
   timings['_total'] = round(time.perf_counter() - t0, 2)
-  keys = ['prices', 'closed', 'reservations', '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']
   return {**dict(zip(keys, results)), '_timings': timings}
 
 
@@ -244,6 +245,49 @@ async def _diff_reservations(
   return diffs
 
 
+# ── double bookings ──────────────────────────────────────────────────────────
+
+async def _diff_double_bookings(
+  start_date: date,
+  end_date: date,
+  api: 'ServiceAPI',
+) -> list[dict]:
+  """Service API reservations 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.
+  """
+  reservations = await api.get_reservations(start_date, 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(

+ 20 - 2
room_cal/vue/calendar.html

@@ -350,6 +350,10 @@
       font-size: 11px;
       margin-right: 3px;
     }
+    .sync-bar-status .badge.is-error {
+      background: #dc2626;
+      color: #fff;
+    }
     .sync-bar-fix-groups {
       display: flex;
       align-items: center;
@@ -553,8 +557,9 @@
         <template v-if="diffLoading">Syncing…</template>
         <template v-else-if="diffError">{{ diffError }}</template>
         <template v-else-if="diffData">
+          <template v-if="doubleBookings.length > 0"><span class="badge is-error">{{ doubleBookings.length }}</span> double booking{{ doubleBookings.length !== 1 ? 's' : '' }}&ensp;</template>
           <template v-if="totalDaysWithIssues > 0"><span class="badge">{{ totalDaysWithIssues }}</span> {{ totalDaysWithIssues === 1 ? 'day' : 'days' }} with sync issues</template>
-          <template v-else>0 days with sync issues</template>
+          <template v-else-if="!doubleBookings.length">0 days with sync issues</template>
         </template>
       </span>
       <div v-if="monthCats.size > 0" class="sync-bar-fix-groups">
@@ -736,6 +741,17 @@
 
         <!-- no selection -->
         <template v-if="!selection">
+          <div v-if="syncEnabled && doubleBookings.length" class="res-changes-card">
+            <div class="res-changes-head">
+              <span class="cat-indicator" style="background:#dc2626"></span>
+              <span class="cat-label">Double Bookings</span>
+            </div>
+            <div v-for="(db, i) in doubleBookings" :key="i" class="res-change-row">
+              <div class="res-change-status changed" style="color:#dc2626">{{ db.overlap_start }} – {{ db.overlap_end }}</div>
+              <div class="res-change-meta">{{ db.a.service }}: {{ db.a.checkin_date }} → {{ db.a.checkout_date }} &nbsp;{{ db.a.guest_name }}</div>
+              <div class="res-change-meta">{{ db.b.service }}: {{ db.b.checkin_date }} → {{ db.b.checkout_date }} &nbsp;{{ db.b.guest_name }}</div>
+            </div>
+          </div>
           <div v-if="syncEnabled && resChanges.length" class="res-changes-card">
             <div class="res-changes-head">
               <span class="cat-indicator"></span>
@@ -1818,6 +1834,8 @@ createApp({
       (diffData.value?.reservations || []).filter(x => x.status === 'changed')
     );
 
+    const doubleBookings = computed(() => diffData.value?.double_bookings || []);
+
     function dayCategories(ds) {
       const m = byDay.value[ds];
       if (!m) return [];
@@ -2420,7 +2438,7 @@ createApp({
       selectDay, selectReservation, fmtBulkDates,
       reservationSpans, closureSpans,
       prevMonth, nextMonth,
-      byDay, resChanges, dayCategories, totalDaysWithIssues,
+      byDay, resChanges, doubleBookings, dayCategories, totalDaysWithIssues,
       selMismatches, selCategories, hasMissingLocalPrices,
     };
   },