392 lines
14 KiB
Python
392 lines
14 KiB
Python
"""Read-only PostgreSQL snapshot provider for generated company reports."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import re
|
|
import hashlib
|
|
from dataclasses import dataclass
|
|
from datetime import date
|
|
from decimal import Decimal
|
|
from pathlib import PurePosixPath
|
|
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Protocol, Sequence, Tuple
|
|
|
|
from company_reports.contracts import (
|
|
BatchSnapshot,
|
|
BookingRoomItem,
|
|
CompanyReport,
|
|
DailyVersionPin,
|
|
ErrorCode,
|
|
FinanceFact,
|
|
PROCESSOR_VERSION,
|
|
RESULT_SCHEMA_VERSION,
|
|
rule_set_sha256,
|
|
)
|
|
from company_reports.core import normalize_group_code, report_month_bounds
|
|
|
|
|
|
SOURCE_FACTS_SQL = """
|
|
SELECT
|
|
business_date,
|
|
daily_version_id,
|
|
id AS daily_record_id,
|
|
channel_key,
|
|
company_key,
|
|
company_name,
|
|
block_code,
|
|
group_code_key,
|
|
res_comment,
|
|
room_category_label AS opera_room_category_label,
|
|
arrival,
|
|
departure,
|
|
nights,
|
|
total_price,
|
|
booking_source_match_status
|
|
FROM finance.v_active_daily_facts
|
|
WHERE departure BETWEEN %s AND %s
|
|
AND (
|
|
UPPER(BTRIM(channel_key)) = 'DY-AI-EASY-KB'
|
|
OR UPPER(BTRIM(company_key)) = ANY(%s)
|
|
)
|
|
ORDER BY daily_record_id
|
|
""".strip()
|
|
|
|
CURRENT_PARSE_SQL = """
|
|
SELECT group_code_key, max(parse_version_id) AS group_snapshot_id
|
|
FROM booking.v_current_room_items
|
|
WHERE group_code_key = ANY(%s)
|
|
GROUP BY group_code_key
|
|
ORDER BY group_code_key
|
|
""".strip()
|
|
|
|
ROOM_ITEMS_SQL = """
|
|
SELECT
|
|
group_code_key,
|
|
booking_room_type,
|
|
quantity
|
|
FROM booking.v_group_room_item_summary
|
|
WHERE group_code_key = ANY(%s)
|
|
ORDER BY group_code_key, booking_room_type
|
|
""".strip()
|
|
|
|
COMPANY_KEYS: Tuple[str, ...] = ("LIAN TAI", "QBD", "FENGRUN", "HANA TOUR")
|
|
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
|
|
|
|
|
|
class RepositoryError(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 DatabaseConfig:
|
|
dsn: str
|
|
|
|
@classmethod
|
|
def from_environment(cls) -> "DatabaseConfig":
|
|
dsn = os.environ.get("COMPANY_REPORT_DATABASE_URL", "").strip()
|
|
if not dsn:
|
|
dsn = os.environ.get("ARR_DATABASE_URL", "").strip()
|
|
if not dsn:
|
|
raise RepositoryError(
|
|
ErrorCode.REQUEST_INVALID,
|
|
"COMPANY_REPORT_DATABASE_URL or ARR_DATABASE_URL is required",
|
|
)
|
|
return cls(dsn=dsn)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReservedReport:
|
|
report_version_id: int
|
|
version_no: int
|
|
company: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FileMetadata:
|
|
file_kind: str
|
|
original_filename: str
|
|
storage_key: str
|
|
sha256: str
|
|
byte_size: int
|
|
mime_type: str
|
|
|
|
def validate(self) -> None:
|
|
storage = PurePosixPath(self.storage_key)
|
|
if (
|
|
not self.storage_key
|
|
or storage.is_absolute()
|
|
or ".." in storage.parts
|
|
or self.original_filename != PurePosixPath(self.original_filename).name
|
|
or not SHA256_RE.fullmatch(self.sha256)
|
|
or self.byte_size < 0
|
|
):
|
|
raise RepositoryError(
|
|
ErrorCode.PUBLISH_FAILED,
|
|
"generated artifact metadata is invalid",
|
|
)
|
|
|
|
|
|
class ReportRepository(Protocol):
|
|
def load_snapshot(self, year: int, month: int, as_of_date: date) -> BatchSnapshot:
|
|
...
|
|
|
|
def reserve_report(self, report: CompanyReport) -> ReservedReport:
|
|
...
|
|
|
|
def activate_report(
|
|
self,
|
|
reservation: ReservedReport,
|
|
artifact: FileMetadata,
|
|
result_json: FileMetadata,
|
|
) -> None:
|
|
...
|
|
|
|
def mark_failed(self, reservation: ReservedReport, code: str, safe_message: str) -> None:
|
|
...
|
|
|
|
|
|
def _default_connect(dsn: str) -> Any:
|
|
try:
|
|
import psycopg # type: ignore[import-not-found]
|
|
except ImportError:
|
|
raise RepositoryError(
|
|
ErrorCode.INTERNAL_ERROR,
|
|
"PostgreSQL driver is unavailable; install requirements-company-reports.txt",
|
|
) from None
|
|
try:
|
|
return psycopg.connect(dsn, autocommit=True)
|
|
except Exception:
|
|
raise RepositoryError(
|
|
ErrorCode.INTERNAL_ERROR,
|
|
"database connection failed",
|
|
) from None
|
|
|
|
|
|
def _rows(cursor: Any) -> List[Tuple[Any, ...]]:
|
|
return list(cursor.fetchall())
|
|
|
|
|
|
class PostgresReportRepository:
|
|
"""Loads one immutable source snapshot without persisting report rows."""
|
|
|
|
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 RepositoryError:
|
|
raise
|
|
except Exception:
|
|
raise RepositoryError(
|
|
ErrorCode.INTERNAL_ERROR,
|
|
"database connection failed",
|
|
) from None
|
|
|
|
def load_snapshot(self, year: int, month: int, as_of_date: date) -> BatchSnapshot:
|
|
month_start, _ = report_month_bounds(year, month)
|
|
connection = self._open()
|
|
try:
|
|
with connection.transaction():
|
|
with connection.cursor() as cursor:
|
|
cursor.execute(
|
|
"SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY"
|
|
)
|
|
cursor.execute(
|
|
SOURCE_FACTS_SQL,
|
|
(month_start, as_of_date, list(COMPANY_KEYS)),
|
|
)
|
|
fact_rows = _rows(cursor)
|
|
|
|
facts = tuple(
|
|
FinanceFact(
|
|
business_date=row[0],
|
|
daily_version_id=int(row[1]),
|
|
daily_record_id=int(row[2]),
|
|
channel_key=str(row[3] or ""),
|
|
company_key=str(row[4] or ""),
|
|
company_name=str(row[5] or ""),
|
|
block_code=row[6],
|
|
group_code_key=row[7],
|
|
res_comment=row[8],
|
|
room_category_label=row[9],
|
|
arrival=row[10],
|
|
departure=row[11],
|
|
nights=row[12],
|
|
total_price=(
|
|
row[13]
|
|
if row[13] is None or isinstance(row[13], Decimal)
|
|
else Decimal(str(row[13]))
|
|
),
|
|
booking_source_match_status=str(row[14] or "not_checked"),
|
|
)
|
|
for row in fact_rows
|
|
)
|
|
|
|
group_codes = sorted(
|
|
{
|
|
normalized
|
|
for normalized in (
|
|
normalize_group_code(fact.res_comment) for fact in facts
|
|
)
|
|
if normalized
|
|
}
|
|
)
|
|
parse_versions: Dict[str, int] = {}
|
|
room_items: Tuple[BookingRoomItem, ...] = tuple()
|
|
if group_codes:
|
|
cursor.execute(CURRENT_PARSE_SQL, (group_codes,))
|
|
for group_code, parse_version_id in _rows(cursor):
|
|
normalized = normalize_group_code(group_code)
|
|
if normalized in parse_versions:
|
|
raise RepositoryError(
|
|
ErrorCode.BOOKING_PARSE_FAILED,
|
|
"multiple current booking parses exist for one Group Code",
|
|
)
|
|
parse_versions[normalized] = int(parse_version_id)
|
|
|
|
cursor.execute(ROOM_ITEMS_SQL, (group_codes,))
|
|
summaries: Dict[str, List[Tuple[str, int]]] = {}
|
|
for group_code, room_type, quantity in _rows(cursor):
|
|
normalized = normalize_group_code(group_code)
|
|
summaries.setdefault(normalized, []).append(
|
|
(str(room_type or ""), int(quantity))
|
|
)
|
|
segments: Dict[
|
|
str,
|
|
set[Tuple[date, date, int]],
|
|
] = {}
|
|
for fact in facts:
|
|
normalized = normalize_group_code(fact.group_code_key)
|
|
if (
|
|
normalized
|
|
and isinstance(fact.arrival, date)
|
|
and isinstance(fact.departure, date)
|
|
and isinstance(fact.nights, int)
|
|
):
|
|
segments.setdefault(normalized, set()).add(
|
|
(fact.arrival, fact.departure, fact.nights)
|
|
)
|
|
expanded: List[BookingRoomItem] = []
|
|
for group_code in sorted(summaries):
|
|
parse_version_id = parse_versions.get(group_code)
|
|
if parse_version_id is None:
|
|
continue
|
|
for segment_no, segment in enumerate(
|
|
sorted(segments.get(group_code, set())),
|
|
1,
|
|
):
|
|
arrival, departure, nights = segment
|
|
for item_no, item in enumerate(
|
|
summaries[group_code],
|
|
1,
|
|
):
|
|
room_type, quantity = item
|
|
expanded.append(
|
|
BookingRoomItem(
|
|
group_code_key=group_code,
|
|
parse_version_id=parse_version_id,
|
|
segment_no=segment_no,
|
|
arrival=arrival,
|
|
departure=departure,
|
|
nights=nights,
|
|
item_no=item_no,
|
|
room_type_raw=room_type,
|
|
quantity=quantity,
|
|
)
|
|
)
|
|
room_items = tuple(expanded)
|
|
|
|
pins_by_date: Dict[date, int] = {}
|
|
for fact in facts:
|
|
existing = pins_by_date.get(fact.business_date)
|
|
if existing is not None and existing != fact.daily_version_id:
|
|
raise RepositoryError(
|
|
ErrorCode.SOURCE_VERSION_MISSING,
|
|
"the source snapshot contains conflicting daily versions",
|
|
)
|
|
pins_by_date[fact.business_date] = fact.daily_version_id
|
|
pins = tuple(
|
|
DailyVersionPin(business_date=current_date, daily_version_id=version_id)
|
|
for current_date, version_id in sorted(pins_by_date.items())
|
|
)
|
|
return BatchSnapshot(
|
|
facts=facts,
|
|
room_items=room_items,
|
|
daily_versions=pins,
|
|
group_parse_versions=dict(sorted(parse_versions.items())),
|
|
)
|
|
except RepositoryError:
|
|
raise
|
|
except Exception:
|
|
raise RepositoryError(
|
|
ErrorCode.INTERNAL_ERROR,
|
|
"database snapshot query failed",
|
|
) from None
|
|
finally:
|
|
connection.close()
|
|
|
|
def reserve_report(self, report: CompanyReport) -> ReservedReport:
|
|
if not report.valid:
|
|
raise RepositoryError(
|
|
ErrorCode.OUTPUT_VALIDATION_FAILED,
|
|
"an invalid company report cannot be reserved",
|
|
)
|
|
period_start, period_end = report_month_bounds(
|
|
report.report_year, report.report_month
|
|
)
|
|
identity = (
|
|
report.company,
|
|
period_start.isoformat(),
|
|
period_end.isoformat(),
|
|
report.as_of_date.isoformat(),
|
|
PROCESSOR_VERSION,
|
|
rule_set_sha256(),
|
|
RESULT_SCHEMA_VERSION,
|
|
tuple(
|
|
(pin.business_date.isoformat(), pin.daily_version_id)
|
|
for pin in report.daily_versions
|
|
),
|
|
tuple(sorted(report.booking_versions.items())),
|
|
)
|
|
generation_id = int(
|
|
hashlib.sha256(repr(identity).encode("utf-8")).hexdigest()[:12],
|
|
16,
|
|
)
|
|
return ReservedReport(
|
|
report_version_id=generation_id,
|
|
version_no=generation_id,
|
|
company=report.company,
|
|
)
|
|
|
|
def activate_report(
|
|
self,
|
|
reservation: ReservedReport,
|
|
artifact: FileMetadata,
|
|
result_json: FileMetadata,
|
|
) -> None:
|
|
if not reservation.company:
|
|
raise RepositoryError(
|
|
ErrorCode.PUBLISH_FAILED,
|
|
"company report source reservation is invalid",
|
|
)
|
|
artifact.validate()
|
|
result_json.validate()
|
|
|
|
def mark_failed(
|
|
self,
|
|
reservation: ReservedReport,
|
|
code: str,
|
|
safe_message: str,
|
|
) -> None:
|
|
# Company report rows and versions are intentionally not persisted.
|
|
_ = (reservation, code, safe_message)
|