Add "Changed Since" rate movement indicators to Market View matrix
A new toolbar row on the Rate Matrix lets users pick a reference datetime (defaults to 24h ago, with 24h/3d/7d presets) and see ▲/▼ triangles next to each competitor's BAR where the rate has moved ≥50p since then. Two backend endpoints: - GET /competitors/rate-changes?since= — static datetime comparison - GET /competitors/rate-changes-vs-own — dynamic: diffs against the timestamp our own Newbook rate last changed per date (highlights competitor moves made in response to our own pricing decisions) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8ec066caa8
commit
e355833f1c
2 changed files with 249 additions and 3 deletions
|
|
@ -1484,3 +1484,135 @@ async def get_scrape_history(
|
|||
}
|
||||
for row in result.fetchall()
|
||||
]
|
||||
|
||||
|
||||
# ============================================
|
||||
# RATE CHANGE INDICATORS
|
||||
# ============================================
|
||||
|
||||
@router.get("/rate-changes")
|
||||
async def get_rate_changes(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
since: str,
|
||||
include_market: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
For each hotel+date in the range, return the best 2-adult available rate
|
||||
at the most recent scrape before `since`. The frontend diffs this against
|
||||
the current rate to show ▲/▼ movement indicators.
|
||||
"""
|
||||
try:
|
||||
start = date.fromisoformat(from_date)
|
||||
end = date.fromisoformat(to_date)
|
||||
since_dt = datetime.fromisoformat(since.replace('Z', '+00:00'))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
|
||||
|
||||
if (end - start).days > 90:
|
||||
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
|
||||
|
||||
tier_filter = "h.tier IN ('own', 'competitor')"
|
||||
if include_market:
|
||||
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
|
||||
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
SELECT DISTINCT ON (r.hotel_id, r.rate_date)
|
||||
r.hotel_id,
|
||||
r.rate_date,
|
||||
r.rate_gross AS prev_rate,
|
||||
r.scraped_at AS prev_scraped_at
|
||||
FROM booking_com_rates r
|
||||
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||
WHERE {tier_filter}
|
||||
AND h.is_active = TRUE
|
||||
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
|
||||
AND r.scraped_at <= :since
|
||||
AND r.availability_status = 'available'
|
||||
AND r.rate_gross IS NOT NULL
|
||||
AND (r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2)
|
||||
ORDER BY r.hotel_id, r.rate_date,
|
||||
CASE WHEN r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2 THEN 0 ELSE 1 END,
|
||||
r.scraped_at DESC,
|
||||
r.rate_gross ASC NULLS LAST
|
||||
"""),
|
||||
{'from_date': start, 'to_date': end, 'since': since_dt},
|
||||
)
|
||||
|
||||
out: Dict[int, Dict[str, dict]] = {}
|
||||
for row in result.fetchall():
|
||||
out.setdefault(row.hotel_id, {})[row.rate_date.isoformat()] = {
|
||||
'prev_rate': float(row.prev_rate),
|
||||
'prev_scraped_at': row.prev_scraped_at.isoformat() if row.prev_scraped_at else None,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/rate-changes-vs-own")
|
||||
async def get_rate_changes_vs_own(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
include_market: bool = False,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
For each competitor hotel+date, return the best 2-adult available rate
|
||||
at the time our own Newbook rate last changed for that date. Shows whether
|
||||
competitors moved their rates after we last updated ours.
|
||||
"""
|
||||
try:
|
||||
start = date.fromisoformat(from_date)
|
||||
end = date.fromisoformat(to_date)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid date format: {e}")
|
||||
|
||||
if (end - start).days > 90:
|
||||
raise HTTPException(status_code=400, detail="Date range cannot exceed 90 days")
|
||||
|
||||
tier_filter = "h.tier IN ('own', 'competitor')"
|
||||
if include_market:
|
||||
tier_filter = "h.tier IN ('own', 'competitor', 'market')"
|
||||
|
||||
result = await db.execute(
|
||||
text(f"""
|
||||
SELECT DISTINCT ON (r.hotel_id, r.rate_date)
|
||||
r.hotel_id,
|
||||
r.rate_date,
|
||||
r.rate_gross AS prev_rate,
|
||||
r.scraped_at AS prev_scraped_at,
|
||||
own.own_last_changed
|
||||
FROM booking_com_rates r
|
||||
JOIN booking_com_hotels h ON r.hotel_id = h.id
|
||||
JOIN (
|
||||
SELECT rate_date, MAX(valid_from) AS own_last_changed
|
||||
FROM newbook_current_rates
|
||||
WHERE rate_date >= :from_date AND rate_date <= :to_date
|
||||
GROUP BY rate_date
|
||||
) own ON r.rate_date = own.rate_date
|
||||
WHERE {tier_filter}
|
||||
AND h.is_active = TRUE
|
||||
AND r.rate_date >= :from_date AND r.rate_date <= :to_date
|
||||
AND r.scraped_at <= own.own_last_changed
|
||||
AND r.availability_status = 'available'
|
||||
AND r.rate_gross IS NOT NULL
|
||||
AND (r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2)
|
||||
ORDER BY r.hotel_id, r.rate_date,
|
||||
CASE WHEN r.max_persons IS NULL OR r.max_persons = 0 OR r.max_persons = 2 THEN 0 ELSE 1 END,
|
||||
r.scraped_at DESC,
|
||||
r.rate_gross ASC NULLS LAST
|
||||
"""),
|
||||
{'from_date': start, 'to_date': end},
|
||||
)
|
||||
|
||||
out: Dict[int, Dict[str, dict]] = {}
|
||||
for row in result.fetchall():
|
||||
out.setdefault(row.hotel_id, {})[row.rate_date.isoformat()] = {
|
||||
'prev_rate': float(row.prev_rate),
|
||||
'prev_scraped_at': row.prev_scraped_at.isoformat() if row.prev_scraped_at else None,
|
||||
'own_last_changed': row.own_last_changed.isoformat() if row.own_last_changed else None,
|
||||
}
|
||||
return out
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue