Files
wyndham-ARR/company_reports/core.py
2026-07-29 16:38:05 +08:00

441 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Pure company-report business rules with no filesystem or database access."""
from __future__ import annotations
import calendar
import unicodedata
from collections import Counter, defaultdict
from dataclasses import replace
from datetime import date
from decimal import Decimal, InvalidOperation
from typing import Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Tuple
from company_reports.contracts import (
COMPANY_NAMES,
COMPANY_RULES,
ENGLISH_MONTH_ABBREVIATIONS,
ENGLISH_MONTH_NAMES,
BatchSnapshot,
BookingRoomItem,
CompanyReport,
DailyVersionPin,
ErrorCode,
FinanceFact,
PeriodReport,
ReportProblem,
ReportRow,
WarningCode,
)
def normalize_group_code(value: Optional[str]) -> str:
"""Normalize the complete RES_COMMENT value; never extract a substring."""
if value is None:
return ""
return unicodedata.normalize("NFKC", str(value)).strip().upper()
def report_month_bounds(year: int, month: int) -> Tuple[date, date]:
if year < 1900 or year > 9999 or month < 1 or month > 12:
raise ValueError("report month is invalid")
last_day = calendar.monthrange(year, month)[1]
return date(year, month, 1), date(year, month, last_day)
def validate_as_of_date(year: int, month: int, as_of_date: date) -> None:
month_start, month_end = report_month_bounds(year, month)
allowed = {date(year, month, 10), date(year, month, 20), month_end}
if as_of_date < month_start or as_of_date > month_end or as_of_date not in allowed:
raise ValueError("as-of date must be the 10th, 20th, or natural month end")
def report_periods(year: int, month: int, as_of_date: date, company: str) -> Tuple[PeriodReport, ...]:
_, month_end = report_month_bounds(year, month)
month_label = ENGLISH_MONTH_ABBREVIATIONS[month]
specs = ((1, 10), (11, 20), (21, month_end.day))
periods: List[PeriodReport] = []
for start_day, end_day in specs:
end_date = date(year, month, end_day)
sheet_name = f"{company} {start_day:02d}-{end_day:02d} {month_label} {year}"
if len(sheet_name) > 31:
raise ValueError("worksheet name exceeds Excel's 31-character limit")
periods.append(
PeriodReport(
start_day=start_day,
end_day=end_day,
active=as_of_date >= end_date,
sheet_name=sheet_name,
rows=tuple(),
)
)
return tuple(periods)
def company_for_fact(fact: FinanceFact) -> Optional[str]:
"""Map one fact to exactly one requested company; the merged channel wins first."""
if fact.channel_key.strip().upper() == "DY-AI-EASY-KB":
return "DY-AI-Easy-KB"
normalized_company = fact.company_key.strip().upper()
for rule in COMPANY_RULES:
if rule.match_field == "company_key" and normalized_company == rule.match_value:
return rule.display_name
return None
def format_decimal(value: Decimal) -> str:
"""Use grouping separators and at most two meaningful decimal places."""
quantized = value.quantize(Decimal("0.01"))
rendered = f"{quantized:,.2f}"
if "." in rendered:
rendered = rendered.rstrip("0").rstrip(".")
return rendered
def _decimal_or_none(value: Optional[Decimal]) -> Optional[Decimal]:
if value is None:
return None
try:
decimal_value = value if isinstance(value, Decimal) else Decimal(str(value))
except (InvalidOperation, ValueError, TypeError):
return None
if not decimal_value.is_finite() or decimal_value < 0:
return None
return decimal_value
def _problem(
code: str,
stage: str,
company: str,
period: str,
message: str,
facts: Sequence[FinanceFact],
) -> ReportProblem:
return ReportProblem(
code=code,
stage=stage,
company=company,
period=period,
message=message,
record_ids=tuple(sorted({fact.daily_record_id for fact in facts})),
)
def _period_key_for_departure(departure: date, month_end_day: int) -> str:
if departure.day <= 10:
return "01-10"
if departure.day <= 20:
return "11-20"
return f"21-{month_end_day:02d}"
def _distinct_join(values: Iterable[Optional[str]]) -> str:
seen = set()
ordered: List[str] = []
for value in values:
text = unicodedata.normalize("NFKC", str(value or "")).strip()
if text and text not in seen:
seen.add(text)
ordered.append(text)
return "".join(ordered)
def _booking_room_text(items: Sequence[BookingRoomItem]) -> str:
quantities: MutableMapping[str, int] = {}
for item in sorted(items, key=lambda current: (current.segment_no, current.item_no)):
room_type = unicodedata.normalize("NFKC", item.room_type_raw).strip()
quantities[room_type] = quantities.get(room_type, 0) + item.quantity
return "".join(f"{room_type}{quantity}" for room_type, quantity in quantities.items())
def _price_text(facts: Sequence[FinanceFact]) -> Tuple[str, bool]:
grouped: MutableMapping[Tuple[str, Decimal], int] = {}
prices_by_room: Dict[str, set] = defaultdict(set)
total = Decimal("0")
for fact in sorted(facts, key=lambda current: current.daily_record_id):
room_label = unicodedata.normalize("NFKC", str(fact.room_category_label or "")).strip()
price = _decimal_or_none(fact.total_price)
if not room_label or price is None:
raise ValueError("invalid price detail")
key = (room_label, price)
grouped[key] = grouped.get(key, 0) + 1
prices_by_room[room_label].add(price)
total += price
terms = [
f"{room_label}{format_decimal(price)}×{count}"
for (room_label, price), count in grouped.items()
]
multi_price = any(len(prices) > 1 for prices in prices_by_room.values())
return f"{' + '.join(terms)} = {format_decimal(total)}", multi_price
def build_company_report(
company: str,
year: int,
month: int,
as_of_date: date,
snapshot: BatchSnapshot,
) -> CompanyReport:
"""Build one deterministic company-month payload or a privacy-minimized error set."""
if company not in COMPANY_NAMES:
raise ValueError(f"unsupported company: {company}")
validate_as_of_date(year, month, as_of_date)
month_start, month_end = report_month_bounds(year, month)
periods = report_periods(year, month, as_of_date, company)
facts = sorted(
(fact for fact in snapshot.facts if company_for_fact(fact) == company),
key=lambda current: current.daily_record_id,
)
errors: List[ReportProblem] = []
eligible: List[Tuple[FinanceFact, str]] = []
for fact in facts:
generic_period = f"{year:04d}-{month:02d}"
if not isinstance(fact.arrival, date) or not isinstance(fact.departure, date):
errors.append(
_problem(
ErrorCode.STAY_DATE_INVALID,
"source",
company,
generic_period,
"source stay dates are missing or invalid",
[fact],
)
)
continue
if fact.departure < fact.arrival:
errors.append(
_problem(
ErrorCode.STAY_DATE_INVALID,
"source",
company,
generic_period,
"source stay dates are inconsistent",
[fact],
)
)
continue
if fact.departure < month_start or fact.departure > month_end or fact.departure > as_of_date:
continue
period_key = _period_key_for_departure(fact.departure, month_end.day)
expected_nights = (fact.departure - fact.arrival).days
if not isinstance(fact.nights, int) or fact.nights != expected_nights:
errors.append(
_problem(
ErrorCode.NIGHTS_CONFLICT,
"source",
company,
period_key,
"source NIGHTS does not agree within the stay segment",
[fact],
)
)
continue
group_code = normalize_group_code(fact.res_comment)
stored_group = normalize_group_code(fact.group_code_key)
if not group_code or not stored_group:
errors.append(
_problem(
ErrorCode.GROUP_CODE_MISSING,
"group_lookup",
company,
period_key,
"the source record has no usable Group Code",
[fact],
)
)
continue
if stored_group != group_code:
errors.append(
_problem(
ErrorCode.GROUP_CODE_NOT_FOUND,
"group_lookup",
company,
period_key,
"the stored Group Code does not match normalized RES_COMMENT",
[fact],
)
)
continue
if not str(fact.room_category_label or "").strip() or _decimal_or_none(fact.total_price) is None:
errors.append(
_problem(
ErrorCode.TOTAL_PRICE_INVALID,
"pricing",
company,
period_key,
"the static price detail is missing or invalid",
[fact],
)
)
continue
eligible.append((fact, group_code))
grouped_facts: Dict[Tuple[str, date, date], List[FinanceFact]] = defaultdict(list)
for fact, group_code in eligible:
grouped_facts[(group_code, fact.arrival, fact.departure)].append(fact) # type: ignore[arg-type]
items_by_segment: Dict[Tuple[str, date, date], List[BookingRoomItem]] = defaultdict(list)
for item in snapshot.room_items:
group_code = normalize_group_code(item.group_code_key)
current_version = snapshot.group_parse_versions.get(group_code)
if current_version is None or item.parse_version_id != current_version:
continue
items_by_segment[(group_code, item.arrival, item.departure)].append(item)
warnings: List[ReportProblem] = []
rows: List[ReportRow] = []
used_booking_versions: Dict[str, int] = {}
for (group_code, arrival, departure), segment_facts in sorted(
grouped_facts.items(), key=lambda item: (item[0][2], item[0][1], item[0][0])
):
period_key = _period_key_for_departure(departure, month_end.day)
nights = {fact.nights for fact in segment_facts}
if len(nights) != 1:
errors.append(
_problem(
ErrorCode.NIGHTS_CONFLICT,
"aggregation",
company,
period_key,
"multiple NIGHTS values exist for one output stay segment",
segment_facts,
)
)
continue
parse_version_id = snapshot.group_parse_versions.get(group_code)
if parse_version_id is None:
errors.append(
_problem(
ErrorCode.BOOKING_PARSE_FAILED,
"group_lookup",
company,
period_key,
"no accepted current booking parse exists for the Group Code",
segment_facts,
)
)
continue
room_items = items_by_segment.get((group_code, arrival, departure), [])
valid_room_items = [
item
for item in room_items
if item.quantity > 0
and bool(item.room_type_raw.strip())
and item.nights == (departure - arrival).days
]
if not valid_room_items:
errors.append(
_problem(
ErrorCode.ROOM_ITEMS_MISSING,
"group_lookup",
company,
period_key,
"the Group Code has no room items for this output stay segment",
segment_facts,
)
)
continue
try:
total_booking_price, multi_price = _price_text(segment_facts)
except ValueError:
errors.append(
_problem(
ErrorCode.TOTAL_PRICE_INVALID,
"pricing",
company,
period_key,
"the static price detail cannot be formatted",
segment_facts,
)
)
continue
if multi_price:
warnings.append(
_problem(
WarningCode.MULTI_PRICE_REVIEW,
"pricing",
company,
period_key,
"one room category has multiple static prices and requires review",
segment_facts,
)
)
used_booking_versions[group_code] = parse_version_id
rows.append(
ReportRow(
arrival=arrival,
departure=departure,
nights=next(iter(nights)), # type: ignore[arg-type]
block_code=_distinct_join(fact.block_code for fact in segment_facts),
res_comment=unicodedata.normalize(
"NFKC", str(segment_facts[0].res_comment or "")
).strip(),
booking_room=_booking_room_text(valid_room_items),
total_booking_price=total_booking_price,
normalized_group_code=group_code,
record_ids=tuple(sorted(fact.daily_record_id for fact in segment_facts)),
multi_price_review=multi_price,
)
)
duplicate_counts = Counter(row.normalized_group_code for row in rows)
rows = [
replace(row, duplicate_group=duplicate_counts[row.normalized_group_code] > 1)
for row in rows
]
rows_by_period: Dict[str, List[ReportRow]] = defaultdict(list)
for row in rows:
rows_by_period[_period_key_for_departure(row.departure, month_end.day)].append(row)
populated_periods: List[PeriodReport] = []
for period in periods:
period_rows = tuple(
sorted(
rows_by_period.get(period.key, []),
key=lambda row: (row.departure, row.arrival, row.normalized_group_code),
)
)
populated_periods.append(replace(period, rows=period_rows if period.active else tuple()))
filename = f"{company}-{ENGLISH_MONTH_NAMES[month]}-{year}.xlsx"
used_daily_ids = {fact.daily_version_id for fact, _ in eligible}
daily_versions = tuple(
pin
for pin in sorted(snapshot.daily_versions, key=lambda current: current.business_date)
if pin.daily_version_id in used_daily_ids
)
return CompanyReport(
company=company,
report_year=year,
report_month=month,
as_of_date=as_of_date,
filename=filename,
periods=tuple(populated_periods),
warnings=tuple(warnings),
errors=tuple(errors),
daily_versions=daily_versions,
booking_versions=dict(sorted(used_booking_versions.items())),
)
def build_all_company_reports(
year: int,
month: int,
as_of_date: date,
snapshot: BatchSnapshot,
companies: Optional[Sequence[str]] = None,
) -> Tuple[CompanyReport, ...]:
selected = tuple(companies or COMPANY_NAMES)
if len(set(selected)) != len(selected):
raise ValueError("company selection contains duplicates")
return tuple(
build_company_report(company, year, month, as_of_date, snapshot)
for company in selected
)