"""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]] = [] blank_booking_facts: List[FinanceFact] = [] 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 and not stored_group: 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 blank_booking_facts.append(fact) continue if not group_code or not stored_group: errors.append( _problem( ErrorCode.GROUP_CODE_MISSING, "group_lookup", company, period_key, "the source Group Code fields are inconsistent", [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 fact in blank_booking_facts: total_booking_price, multi_price = _price_text([fact]) rows.append( ReportRow( arrival=fact.arrival, # type: ignore[arg-type] departure=fact.departure, # type: ignore[arg-type] nights=fact.nights, # type: ignore[arg-type] block_code=_distinct_join([fact.block_code]), res_comment=unicodedata.normalize( "NFKC", str(fact.res_comment or "") ).strip(), booking_room="", total_booking_price=total_booking_price, normalized_group_code="", record_ids=(fact.daily_record_id,), multi_price_review=multi_price, ) ) 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 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, ) ) parse_version_id = snapshot.group_parse_versions.get(group_code) valid_room_items: List[BookingRoomItem] = [] if parse_version_id is not None: used_booking_versions[group_code] = parse_version_id 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 ] 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 if row.normalized_group_code ) rows = [ replace( row, duplicate_group=bool( row.normalized_group_code and 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, row.record_ids, ), ) ) 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 } | {fact.daily_version_id for fact in blank_booking_facts} 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 )