feat: prepare ARR for controlled public deployment
This commit is contained in:
23
channel_analytics/README.md
Normal file
23
channel_analytics/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# Channel analytics database provider
|
||||
|
||||
`channel_analytics` implements BI Schema 1.2 and paginated channel detail directly from the current ARR daily facts. It does not read a monthly workbook, `dashboard.json`, `report_versions`, or a frozen report manifest.
|
||||
|
||||
Read model:
|
||||
|
||||
- available months and source pins: `finance.current_daily_versions`;
|
||||
- channels/order/counts: `finance.daily_channel_metrics` for those current versions;
|
||||
- aggregates and detail: `finance.v_active_daily_facts` (`current + retained` only);
|
||||
- projection identity: deterministic SHA-256 over the selected business date, current daily version ID and source artifact SHA-256.
|
||||
|
||||
Dashboard rules:
|
||||
|
||||
- sold rooms = `sum(NO_OF_ROOMS)`;
|
||||
- total price = `sum(TOTAL PRICE)` without multiplying again;
|
||||
- room nights = `sum(NIGHTS * NO_OF_ROOMS)`;
|
||||
- room types sort by sold rooms descending, then name;
|
||||
- channels follow first current business date/order, then channel name;
|
||||
- the channel × room-type matrix and company totals use the same current facts.
|
||||
|
||||
The detail response contains only arrival, departure, nights, room count, company, rate code, room type, real price, and total price. It never returns guest name, confirmation number, displayed room number, comments, traces, products, source coordinates, credentials, or object keys.
|
||||
|
||||
`DASHBOARD_DATABASE_URL` may use a dedicated read-only role; otherwise it falls back to `ARR_DATABASE_URL`. Transactions are `REPEATABLE READ READ ONLY`, and this validation build rejects any database other than `booking_test`.
|
||||
5
channel_analytics/__init__.py
Normal file
5
channel_analytics/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Privacy-minimized database analytics for channel BI and detail views."""
|
||||
|
||||
from channel_analytics.contracts import DASHBOARD_VERSION
|
||||
|
||||
__all__ = ["DASHBOARD_VERSION"]
|
||||
223
channel_analytics/contracts.py
Normal file
223
channel_analytics/contracts.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Pure aggregation rules for the frozen channel BI contract 1.2."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
|
||||
DASHBOARD_VERSION = "1.2"
|
||||
DETAIL_VERSION = "1.0"
|
||||
UNLABELED_ROOM_TYPE = "未标注房型"
|
||||
MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class AnalyticsError(RuntimeError):
|
||||
def __init__(self, code: str, safe_message: str):
|
||||
super().__init__(safe_message)
|
||||
self.code = code
|
||||
self.safe_message = safe_message
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AggregatedRoomFact:
|
||||
worksheet: str
|
||||
room_type: str
|
||||
rooms_sold: int
|
||||
total_price: Decimal
|
||||
room_nights: int
|
||||
reservation_rows: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RoomMetrics:
|
||||
rooms_sold: int = 0
|
||||
total_price: Decimal = Decimal(0)
|
||||
room_nights: int = 0
|
||||
reservation_rows: int = 0
|
||||
|
||||
def add(self, fact: AggregatedRoomFact) -> None:
|
||||
self.rooms_sold += fact.rooms_sold
|
||||
self.total_price += fact.total_price
|
||||
self.room_nights += fact.room_nights
|
||||
self.reservation_rows += fact.reservation_rows
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ScopeMetrics:
|
||||
rooms: Dict[str, _RoomMetrics] = field(default_factory=dict)
|
||||
|
||||
def add(self, fact: AggregatedRoomFact) -> None:
|
||||
room_type = fact.room_type.strip() or UNLABELED_ROOM_TYPE
|
||||
self.rooms.setdefault(room_type, _RoomMetrics()).add(fact)
|
||||
|
||||
@property
|
||||
def rooms_sold(self) -> int:
|
||||
return sum(item.rooms_sold for item in self.rooms.values())
|
||||
|
||||
@property
|
||||
def total_price(self) -> Decimal:
|
||||
return sum((item.total_price for item in self.rooms.values()), Decimal(0))
|
||||
|
||||
@property
|
||||
def room_nights(self) -> int:
|
||||
return sum(item.room_nights for item in self.rooms.values())
|
||||
|
||||
@property
|
||||
def reservation_rows(self) -> int:
|
||||
return sum(item.reservation_rows for item in self.rooms.values())
|
||||
|
||||
|
||||
def validate_month_key(month_key: str) -> Tuple[int, int]:
|
||||
match = MONTH_RE.fullmatch(month_key)
|
||||
if not match:
|
||||
raise AnalyticsError("ANALYTICS_MONTH_INVALID", "month must use YYYY-MM")
|
||||
year, month = int(match.group(1)), int(match.group(2))
|
||||
if year < 1900 or month < 1 or month > 12:
|
||||
raise AnalyticsError("ANALYTICS_MONTH_INVALID", "month is invalid")
|
||||
return year, month
|
||||
|
||||
|
||||
def _integer(value: Any, field_name: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid") from None
|
||||
if not number.is_finite() or number < 0 or number != number.to_integral_value():
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
|
||||
return int(number)
|
||||
|
||||
|
||||
def _decimal(value: Any, field_name: str) -> Decimal:
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid") from None
|
||||
if not number.is_finite() or number < 0:
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", f"{field_name} is invalid")
|
||||
return number
|
||||
|
||||
|
||||
def json_number(value: Decimal) -> int | float:
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
|
||||
|
||||
def normalized_fact(fact: AggregatedRoomFact) -> AggregatedRoomFact:
|
||||
if not isinstance(fact.worksheet, str) or not fact.worksheet.strip():
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "worksheet is invalid")
|
||||
if not isinstance(fact.room_type, str):
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "room type is invalid")
|
||||
normalized = AggregatedRoomFact(
|
||||
worksheet=fact.worksheet,
|
||||
room_type=fact.room_type.strip() or UNLABELED_ROOM_TYPE,
|
||||
rooms_sold=_integer(fact.rooms_sold, "rooms_sold"),
|
||||
total_price=_decimal(fact.total_price, "total_price"),
|
||||
room_nights=_integer(fact.room_nights, "room_nights"),
|
||||
reservation_rows=_integer(fact.reservation_rows, "reservation_rows"),
|
||||
)
|
||||
if normalized.reservation_rows == 0 and (
|
||||
normalized.rooms_sold or normalized.room_nights or normalized.total_price
|
||||
):
|
||||
raise AnalyticsError(
|
||||
"ANALYTICS_SOURCE_INVALID",
|
||||
"aggregate row count does not match its values",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _room_rows(scope: _ScopeMetrics) -> List[Dict[str, Any]]:
|
||||
denominator = scope.rooms_sold
|
||||
return [
|
||||
{
|
||||
"room_type": room_type,
|
||||
"rooms_sold": metrics.rooms_sold,
|
||||
"rooms_share": metrics.rooms_sold / denominator if denominator else 0,
|
||||
"total_price": json_number(metrics.total_price),
|
||||
"room_nights": metrics.room_nights,
|
||||
"reservation_rows": metrics.reservation_rows,
|
||||
}
|
||||
for room_type, metrics in sorted(
|
||||
scope.rooms.items(),
|
||||
key=lambda item: (-item[1].rooms_sold, item[0]),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _totals(scope: _ScopeMetrics, channel_count: Optional[int] = None) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"rooms_sold": scope.rooms_sold,
|
||||
"total_price": json_number(scope.total_price),
|
||||
"room_nights": scope.room_nights,
|
||||
"reservation_rows": scope.reservation_rows,
|
||||
"room_type_count": len(scope.rooms),
|
||||
}
|
||||
if channel_count is not None:
|
||||
payload["channel_count"] = channel_count
|
||||
return payload
|
||||
|
||||
|
||||
def build_dashboard(
|
||||
month_key: str,
|
||||
updated_at: str,
|
||||
max_arrival_date: Optional[str],
|
||||
source_monthly_sha256: str,
|
||||
channel_names: Iterable[str],
|
||||
facts: Iterable[AggregatedRoomFact],
|
||||
) -> Dict[str, Any]:
|
||||
validate_month_key(month_key)
|
||||
names = tuple(channel_names)
|
||||
if (
|
||||
not names
|
||||
or len(set(names)) != len(names)
|
||||
or any(not isinstance(name, str) or not name.strip() for name in names)
|
||||
or SHA256_RE.fullmatch(source_monthly_sha256) is None
|
||||
):
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "dashboard manifest is invalid")
|
||||
channels = {name: _ScopeMetrics() for name in names}
|
||||
overall = _ScopeMetrics()
|
||||
for raw_fact in facts:
|
||||
fact = normalized_fact(raw_fact)
|
||||
if fact.worksheet not in channels:
|
||||
raise AnalyticsError(
|
||||
"ANALYTICS_SOURCE_INVALID",
|
||||
"aggregate contains a channel outside the report manifest",
|
||||
)
|
||||
channels[fact.worksheet].add(fact)
|
||||
overall.add(fact)
|
||||
channel_payload = [
|
||||
{
|
||||
"worksheet": name,
|
||||
"totals": _totals(channels[name]),
|
||||
"room_types": _room_rows(channels[name]),
|
||||
}
|
||||
for name in names
|
||||
]
|
||||
if (
|
||||
overall.rooms_sold != sum(item["totals"]["rooms_sold"] for item in channel_payload)
|
||||
or overall.room_nights != sum(item["totals"]["room_nights"] for item in channel_payload)
|
||||
or overall.reservation_rows
|
||||
!= sum(item["totals"]["reservation_rows"] for item in channel_payload)
|
||||
or overall.total_price
|
||||
!= sum(
|
||||
(Decimal(str(item["totals"]["total_price"])) for item in channel_payload),
|
||||
Decimal(0),
|
||||
)
|
||||
):
|
||||
raise AnalyticsError("ANALYTICS_SOURCE_INVALID", "dashboard totals do not balance")
|
||||
return {
|
||||
"version": DASHBOARD_VERSION,
|
||||
"month_key": month_key,
|
||||
"updated_at": updated_at,
|
||||
"max_arrival_date": max_arrival_date,
|
||||
"source_monthly_sha256": source_monthly_sha256,
|
||||
"overall": {
|
||||
"totals": _totals(overall, channel_count=len(names)),
|
||||
"room_types": _room_rows(overall),
|
||||
},
|
||||
"channels": channel_payload,
|
||||
}
|
||||
584
channel_analytics/postgres.py
Normal file
584
channel_analytics/postgres.py
Normal file
@@ -0,0 +1,584 @@
|
||||
"""Read-only PostgreSQL provider for live channel analytics and details."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import hashlib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from channel_analytics.contracts import (
|
||||
DETAIL_VERSION,
|
||||
UNLABELED_ROOM_TYPE,
|
||||
AggregatedRoomFact,
|
||||
AnalyticsError,
|
||||
build_dashboard,
|
||||
json_number,
|
||||
validate_month_key,
|
||||
)
|
||||
|
||||
|
||||
TARGET_DATABASE = "booking_test"
|
||||
DATABASE_ENV = "DASHBOARD_DATABASE_URL"
|
||||
FALLBACK_DATABASE_ENV = "ARR_DATABASE_URL"
|
||||
|
||||
|
||||
CURRENT_REPORT_SQL = """
|
||||
SELECT
|
||||
max(current_version.business_date) AS as_of_date,
|
||||
max(current_version.activated_at) AS updated_at,
|
||||
count(*) AS daily_version_count
|
||||
FROM finance.current_daily_versions AS current_version
|
||||
WHERE current_version.business_date BETWEEN %s AND %s
|
||||
""".strip()
|
||||
|
||||
REPORT_MANIFEST_SQL = """
|
||||
SELECT
|
||||
metrics.channel_key,
|
||||
sum(metrics.row_count) AS row_count
|
||||
FROM finance.current_daily_versions AS current_version
|
||||
JOIN finance.daily_channel_metrics AS metrics
|
||||
ON metrics.daily_version_id = current_version.daily_version_id
|
||||
WHERE current_version.business_date BETWEEN %s AND %s
|
||||
GROUP BY metrics.channel_key
|
||||
ORDER BY
|
||||
min(current_version.business_date),
|
||||
min(metrics.channel_order),
|
||||
metrics.channel_key
|
||||
""".strip()
|
||||
|
||||
REPORT_PIN_COUNT_SQL = """
|
||||
SELECT
|
||||
current_version.business_date,
|
||||
current_version.daily_version_id,
|
||||
source.sha256
|
||||
FROM finance.current_daily_versions AS current_version
|
||||
JOIN finance.daily_versions AS version
|
||||
ON version.id = current_version.daily_version_id
|
||||
JOIN ingestion.artifacts AS source
|
||||
ON source.id = version.source_artifact_id
|
||||
WHERE current_version.business_date BETWEEN %s AND %s
|
||||
ORDER BY current_version.business_date, current_version.daily_version_id
|
||||
""".strip()
|
||||
|
||||
ROOM_AGGREGATES_SQL = """
|
||||
SELECT
|
||||
facts.channel_key,
|
||||
coalesce(nullif(btrim(facts.room_category_label), ''), %s) AS room_type,
|
||||
count(*) AS reservation_rows,
|
||||
sum(facts.no_of_rooms) AS rooms_sold,
|
||||
sum(facts.no_of_rooms * facts.nights) AS room_nights,
|
||||
sum(facts.total_price) AS total_price
|
||||
FROM finance.v_active_daily_facts AS facts
|
||||
WHERE facts.business_date BETWEEN %s AND %s
|
||||
GROUP BY facts.channel_key, room_type
|
||||
ORDER BY facts.channel_key, room_type
|
||||
""".strip()
|
||||
|
||||
CHANNEL_DETAIL_SQL = """
|
||||
SELECT
|
||||
facts.arrival,
|
||||
facts.departure,
|
||||
facts.nights,
|
||||
facts.no_of_rooms,
|
||||
facts.company_name,
|
||||
facts.rate_code,
|
||||
coalesce(nullif(btrim(facts.room_category_label), ''), %s) AS room_type,
|
||||
facts.real_price,
|
||||
facts.total_price
|
||||
FROM finance.v_active_daily_facts AS facts
|
||||
WHERE facts.business_date BETWEEN %s AND %s
|
||||
AND facts.channel_key = %s
|
||||
ORDER BY facts.arrival, facts.id
|
||||
LIMIT %s OFFSET %s
|
||||
""".strip()
|
||||
|
||||
MONTHS_SQL = """
|
||||
SELECT DISTINCT date_trunc('month', business_date)::date AS period_start
|
||||
FROM finance.current_daily_versions
|
||||
ORDER BY period_start DESC
|
||||
""".strip()
|
||||
|
||||
|
||||
class AnalyticsRepositoryError(AnalyticsError):
|
||||
pass
|
||||
|
||||
|
||||
class AnalyticsMonthNotFound(AnalyticsRepositoryError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"ANALYTICS_MONTH_NOT_FOUND",
|
||||
"current monthly database projection was not found",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatabaseConfig:
|
||||
dsn: str
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "DatabaseConfig":
|
||||
dsn = os.environ.get(DATABASE_ENV, "").strip()
|
||||
if not dsn:
|
||||
dsn = os.environ.get(FALLBACK_DATABASE_ENV, "").strip()
|
||||
if not dsn:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_CONFIG_MISSING",
|
||||
f"{DATABASE_ENV} or {FALLBACK_DATABASE_ENV} is required",
|
||||
)
|
||||
return cls(dsn)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChannelManifestItem:
|
||||
worksheet: str
|
||||
worksheet_order: int
|
||||
row_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReportContext:
|
||||
period_start: date
|
||||
period_end: date
|
||||
as_of_date: date
|
||||
activated_at: datetime
|
||||
source_projection_sha256: str
|
||||
daily_version_count: int
|
||||
channels: Tuple[ChannelManifestItem, ...]
|
||||
|
||||
@property
|
||||
def month_key(self) -> str:
|
||||
return f"{self.period_start.year:04d}-{self.period_start.month:02d}"
|
||||
|
||||
@property
|
||||
def filename(self) -> str:
|
||||
return f"database-projection-{self.month_key}.json"
|
||||
|
||||
|
||||
def _default_connect(dsn: str) -> Any:
|
||||
try:
|
||||
import psycopg # type: ignore[import-not-found]
|
||||
except ImportError:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_DRIVER_UNAVAILABLE",
|
||||
"PostgreSQL driver is unavailable",
|
||||
) from None
|
||||
try:
|
||||
return psycopg.connect(dsn, autocommit=False)
|
||||
except Exception:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_UNAVAILABLE",
|
||||
"database connection failed",
|
||||
) from None
|
||||
|
||||
|
||||
def _integer(value: Any, field_name: str) -> int:
|
||||
if isinstance(value, bool):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
f"database {field_name} is invalid",
|
||||
)
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
f"database {field_name} is invalid",
|
||||
) from None
|
||||
if (
|
||||
not number.is_finite()
|
||||
or number < 0
|
||||
or number != number.to_integral_value()
|
||||
):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
f"database {field_name} is invalid",
|
||||
)
|
||||
return int(number)
|
||||
|
||||
|
||||
def _decimal(value: Any, field_name: str) -> Decimal:
|
||||
try:
|
||||
number = Decimal(str(value))
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
f"database {field_name} is invalid",
|
||||
) from None
|
||||
if not number.is_finite() or number < 0:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
f"database {field_name} is invalid",
|
||||
)
|
||||
return number
|
||||
|
||||
|
||||
def _date(value: Any, field_name: str) -> date:
|
||||
if isinstance(value, datetime):
|
||||
value = value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
try:
|
||||
return date.fromisoformat(str(value))
|
||||
except ValueError:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
f"database {field_name} is invalid",
|
||||
) from None
|
||||
|
||||
|
||||
def _datetime(value: Any) -> datetime:
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
"database activated_at is invalid",
|
||||
) from None
|
||||
|
||||
|
||||
def _projection_sha256(rows: List[Tuple[Any, ...]]) -> str:
|
||||
identity = "\n".join(
|
||||
f"{_date(row[0], 'business_date').isoformat()}:{_integer(row[1], 'daily_version_id')}:{str(row[2]).lower()}"
|
||||
for row in rows
|
||||
)
|
||||
return hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class PostgresAnalyticsRepository:
|
||||
def __init__(
|
||||
self,
|
||||
config: DatabaseConfig,
|
||||
connect: Optional[Callable[[str], Any]] = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._connect = connect or _default_connect
|
||||
|
||||
def _open(self) -> Any:
|
||||
try:
|
||||
return self._connect(self._config.dsn)
|
||||
except AnalyticsRepositoryError:
|
||||
raise
|
||||
except Exception:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_UNAVAILABLE",
|
||||
"database connection failed",
|
||||
) from None
|
||||
|
||||
@staticmethod
|
||||
def _begin(cursor: Any) -> None:
|
||||
cursor.execute(
|
||||
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
|
||||
)
|
||||
cursor.execute("SET LOCAL statement_timeout = '10s'")
|
||||
cursor.execute("SET LOCAL lock_timeout = '3s'")
|
||||
cursor.execute(
|
||||
"SELECT current_database(), current_setting('transaction_read_only')"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or row[0] != TARGET_DATABASE or row[1] != "on":
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_TARGET_INVALID",
|
||||
"database target or read-only mode is invalid",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _period(month_key: str) -> Tuple[date, date]:
|
||||
year, month = validate_month_key(month_key)
|
||||
return (
|
||||
date(year, month, 1),
|
||||
date(year, month, calendar.monthrange(year, month)[1]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _load_context(
|
||||
cursor: Any,
|
||||
period_start: date,
|
||||
period_end: date,
|
||||
) -> ReportContext:
|
||||
cursor.execute(CURRENT_REPORT_SQL, (period_start, period_end))
|
||||
row = cursor.fetchone()
|
||||
if not row or row[0] is None or _integer(row[2], "daily_version_count") == 0:
|
||||
raise AnalyticsMonthNotFound()
|
||||
as_of_date = _date(row[0], "as_of_date")
|
||||
activated_at = _datetime(row[1])
|
||||
daily_version_count = _integer(row[2], "daily_version_count")
|
||||
|
||||
cursor.execute(REPORT_PIN_COUNT_SQL, (period_start, period_end))
|
||||
identity_rows = list(cursor.fetchall())
|
||||
if len(identity_rows) != daily_version_count:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
"current daily version identity is incomplete",
|
||||
)
|
||||
for identity_row in identity_rows:
|
||||
digest = str(identity_row[2] or "").lower()
|
||||
if len(digest) != 64 or any(
|
||||
character not in "0123456789abcdef" for character in digest
|
||||
):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_RESULT_INVALID",
|
||||
"current source artifact identity is invalid",
|
||||
)
|
||||
|
||||
cursor.execute(REPORT_MANIFEST_SQL, (period_start, period_end))
|
||||
manifest_rows = list(cursor.fetchall())
|
||||
channels = tuple(
|
||||
ChannelManifestItem(
|
||||
worksheet=str(item[0] or ""),
|
||||
worksheet_order=order,
|
||||
row_count=_integer(item[1], "row_count"),
|
||||
)
|
||||
for order, item in enumerate(manifest_rows, 1)
|
||||
)
|
||||
if (
|
||||
not channels
|
||||
or len({item.worksheet for item in channels}) != len(channels)
|
||||
or any(not item.worksheet.strip() for item in channels)
|
||||
):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_CHANNEL_MANIFEST_INVALID",
|
||||
"monthly channel projection is invalid",
|
||||
)
|
||||
return ReportContext(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
as_of_date=as_of_date,
|
||||
activated_at=activated_at,
|
||||
source_projection_sha256=_projection_sha256(identity_rows),
|
||||
daily_version_count=daily_version_count,
|
||||
channels=channels,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _aggregates(
|
||||
cursor: Any,
|
||||
context: ReportContext,
|
||||
) -> Tuple[AggregatedRoomFact, ...]:
|
||||
cursor.execute(
|
||||
ROOM_AGGREGATES_SQL,
|
||||
(
|
||||
UNLABELED_ROOM_TYPE,
|
||||
context.period_start,
|
||||
context.period_end,
|
||||
),
|
||||
)
|
||||
facts = tuple(
|
||||
AggregatedRoomFact(
|
||||
worksheet=str(row[0] or ""),
|
||||
room_type=str(row[1] or UNLABELED_ROOM_TYPE),
|
||||
reservation_rows=_integer(row[2], "reservation_rows"),
|
||||
rooms_sold=_integer(row[3], "rooms_sold"),
|
||||
room_nights=_integer(row[4], "room_nights"),
|
||||
total_price=_decimal(row[5], "total_price"),
|
||||
)
|
||||
for row in cursor.fetchall()
|
||||
)
|
||||
actual_counts: Dict[str, int] = {}
|
||||
for fact in facts:
|
||||
actual_counts[fact.worksheet] = (
|
||||
actual_counts.get(fact.worksheet, 0)
|
||||
+ fact.reservation_rows
|
||||
)
|
||||
expected_names = {item.worksheet for item in context.channels}
|
||||
if (
|
||||
not set(actual_counts).issubset(expected_names)
|
||||
or any(
|
||||
item.row_count != actual_counts.get(item.worksheet, 0)
|
||||
for item in context.channels
|
||||
)
|
||||
):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_CHANNEL_MANIFEST_INVALID",
|
||||
"monthly channel projection does not match active facts",
|
||||
)
|
||||
return facts
|
||||
|
||||
def read_dashboard(self, month_key: str) -> Dict[str, Any]:
|
||||
period_start, period_end = self._period(month_key)
|
||||
connection = self._open()
|
||||
try:
|
||||
with connection.transaction():
|
||||
with connection.cursor() as cursor:
|
||||
self._begin(cursor)
|
||||
context = self._load_context(
|
||||
cursor,
|
||||
period_start,
|
||||
period_end,
|
||||
)
|
||||
facts = self._aggregates(cursor, context)
|
||||
return build_dashboard(
|
||||
month_key=context.month_key,
|
||||
updated_at=context.activated_at.isoformat(),
|
||||
max_arrival_date=context.as_of_date.isoformat(),
|
||||
source_monthly_sha256=context.source_projection_sha256,
|
||||
channel_names=(item.worksheet for item in context.channels),
|
||||
facts=facts,
|
||||
)
|
||||
except AnalyticsError:
|
||||
raise
|
||||
except Exception:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_QUERY_FAILED",
|
||||
"database analytics query failed",
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def read_channel_detail(
|
||||
self,
|
||||
month_key: str,
|
||||
worksheet: str,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> Dict[str, Any]:
|
||||
if (
|
||||
not isinstance(worksheet, str)
|
||||
or not worksheet.strip()
|
||||
or worksheet != worksheet.strip()
|
||||
or isinstance(limit, bool)
|
||||
or isinstance(offset, bool)
|
||||
or limit < 1
|
||||
or limit > 500
|
||||
or offset < 0
|
||||
):
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DETAIL_REQUEST_INVALID",
|
||||
"channel detail request is invalid",
|
||||
)
|
||||
period_start, period_end = self._period(month_key)
|
||||
connection = self._open()
|
||||
try:
|
||||
with connection.transaction():
|
||||
with connection.cursor() as cursor:
|
||||
self._begin(cursor)
|
||||
context = self._load_context(
|
||||
cursor,
|
||||
period_start,
|
||||
period_end,
|
||||
)
|
||||
manifest = {
|
||||
item.worksheet: item for item in context.channels
|
||||
}
|
||||
if worksheet not in manifest:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_CHANNEL_NOT_FOUND",
|
||||
"channel is not present in the current month",
|
||||
)
|
||||
cursor.execute(
|
||||
CHANNEL_DETAIL_SQL,
|
||||
(
|
||||
UNLABELED_ROOM_TYPE,
|
||||
context.period_start,
|
||||
context.period_end,
|
||||
worksheet,
|
||||
limit,
|
||||
offset,
|
||||
),
|
||||
)
|
||||
rows = [
|
||||
{
|
||||
"arrival": _date(row[0], "arrival").isoformat(),
|
||||
"departure": _date(
|
||||
row[1],
|
||||
"departure",
|
||||
).isoformat(),
|
||||
"nights": _integer(row[2], "nights"),
|
||||
"no_of_rooms": _integer(
|
||||
row[3],
|
||||
"no_of_rooms",
|
||||
),
|
||||
"company_name": str(row[4] or ""),
|
||||
"rate_code": str(row[5] or ""),
|
||||
"room_type": str(
|
||||
row[6] or UNLABELED_ROOM_TYPE
|
||||
),
|
||||
"real_price": json_number(
|
||||
_decimal(row[7], "real_price")
|
||||
),
|
||||
"total_price": json_number(
|
||||
_decimal(row[8], "total_price")
|
||||
),
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
return {
|
||||
"version": DETAIL_VERSION,
|
||||
"month_key": context.month_key,
|
||||
"worksheet": worksheet,
|
||||
"updated_at": context.activated_at.isoformat(),
|
||||
"max_arrival_date": context.as_of_date.isoformat(),
|
||||
"source_monthly_sha256": (
|
||||
context.source_projection_sha256
|
||||
),
|
||||
"total_rows": manifest[worksheet].row_count,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"rows": rows,
|
||||
}
|
||||
except AnalyticsError:
|
||||
raise
|
||||
except Exception:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_QUERY_FAILED",
|
||||
"database channel detail query failed",
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def list_months(self) -> List[Dict[str, Any]]:
|
||||
connection = self._open()
|
||||
try:
|
||||
with connection.transaction():
|
||||
with connection.cursor() as cursor:
|
||||
self._begin(cursor)
|
||||
cursor.execute(MONTHS_SQL)
|
||||
period_rows = list(cursor.fetchall())
|
||||
contexts = [
|
||||
self._load_context(
|
||||
cursor,
|
||||
period_start,
|
||||
date(
|
||||
period_start.year,
|
||||
period_start.month,
|
||||
calendar.monthrange(
|
||||
period_start.year,
|
||||
period_start.month,
|
||||
)[1],
|
||||
),
|
||||
)
|
||||
for period_start in (
|
||||
_date(row[0], "period_start")
|
||||
for row in period_rows
|
||||
)
|
||||
]
|
||||
return [
|
||||
{
|
||||
"month_key": context.month_key,
|
||||
"max_arrival_date": context.as_of_date.isoformat(),
|
||||
"updated_at": context.activated_at.isoformat(),
|
||||
"filename": context.filename,
|
||||
"source_monthly_sha256": (
|
||||
context.source_projection_sha256
|
||||
),
|
||||
"channel_count": len(context.channels),
|
||||
"row_count": sum(
|
||||
item.row_count for item in context.channels
|
||||
),
|
||||
}
|
||||
for context in contexts
|
||||
]
|
||||
except AnalyticsError:
|
||||
raise
|
||||
except Exception:
|
||||
raise AnalyticsRepositoryError(
|
||||
"ANALYTICS_DATABASE_QUERY_FAILED",
|
||||
"database monthly list query failed",
|
||||
) from None
|
||||
finally:
|
||||
connection.close()
|
||||
Reference in New Issue
Block a user