441 lines
16 KiB
Python
441 lines
16 KiB
Python
"""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
|
||
)
|