"""Suggest a country's public holidays for a year, from an offline dataset. Every December HR has to build next year's calendar for 22 countries. Typing it is the wrong job for a human when the dates are public record, so this pulls a first draft that HR reviews and adjusts. **Offline, not an API.** Backed by the ``holidays`` package (vacanza/holidays, MIT), which *computes* each country's rules locally — including the lunar and Islamic calendars behind Chinese New Year, Songkran and Eid. Chosen over a hosted holiday API deliberately: no key to rotate, no egress from production, no vendor availability in the path of an HR task, and any year works — including years no API has published yet. **A suggestion, never an answer.** Nothing here writes to the calendar. The rows go back to the console as candidates on the same review-then-confirm path as the AI text parse, and land through the ordinary bulk endpoint. Three reasons that matters: * PingCAP's calendar is not the statutory one. The source document carries company choices — Thailand's "Additional Special holiday", several "(observed)" shifts — that no dataset knows about. * An EOR provider's observed set can differ from the statutory set. * 调休 makeup workdays are announced by China's State Council each year and are not computable. They *are* recoverable for the years the dataset covers (see :func:`_derive_makeup_days`), but a year it has not been updated for will come back without them, which the API reports rather than hides. Verified against the hand-transcribed 2026 baseline: the six derived Chinese makeup workdays match the HR document exactly, which is two independent sources agreeing rather than one being trusted. """ from __future__ import annotations import re from dataclasses import dataclass, field from datetime import date, timedelta from app.core.safe_logging import get_safe_logger from app.domains.hr.holiday_models import MAKEUP_WORKDAY, PUBLIC_HOLIDAY logger = get_safe_logger(__name__) #: Local-language name per country, for the countries where a local name is #: what employees actually read. Everywhere else ``name_local`` stays empty #: rather than being filled with a language nobody in that office uses. _LOCAL_LANGUAGE: dict[str, str] = { "CN": "zh_CN", "HK": "zh_HK", "TW": "zh_TW", "JP": "ja", } #: "休息日(由 2026-01-04 调休)" / "Day off (substituted from 01/04/2026)" — a #: rest day swapped *from* that date, which means that date is a working day. #: The dataset renders the referenced date as ISO in Chinese and as US-style #: MM/DD/YYYY in English, so both orders are matched and disambiguated by value. _SWAP_ZH = re.compile(r"由\s*(\d{4})-(\d{1,2})-(\d{1,2})\s*调休") _SWAP_EN_ISO = re.compile( r"(?:in lieu of|swapped from|substituted from)\s*(\d{4})-(\d{1,2})-(\d{1,2})", re.IGNORECASE, ) _SWAP_EN_SLASH = re.compile( r"(?:in lieu of|swapped from|substituted from)\s*(\d{1,2})/(\d{1,2})/(\d{4})", re.IGNORECASE, ) #: How far to look for the holiday a swapped rest day belongs to. Chinese long #: holidays run at most 8 days, so 4 either side covers every real case without #: reaching into a neighbouring festival. _NAME_BACKFILL_WINDOW = 4 @dataclass class SuggestedDay: """One suggested row, in the shape the console's review table already takes.""" day: date name_en: str name_local: str | None day_type: str = PUBLIC_HOLIDAY def to_payload(self) -> dict[str, object]: return { "day": self.day.isoformat(), "name_en": self.name_en, "name_local": self.name_local, "day_type": self.day_type, } @dataclass class SuggestionResult: country: str year: int supported: bool = True rows: list[SuggestedDay] = field(default_factory=list) #: Stable caveat codes the console localises — never prose. The console is #: trilingual, so an English sentence from the server would render as mixed #: script on the Chinese and Japanese UIs. notes: list[str] = field(default_factory=list) provider: str = "holidays" provider_version: str | None = None def payload(self) -> dict[str, object]: return { "country": self.country, "year": self.year, "supported": self.supported, "provider": self.provider, "provider_version": self.provider_version, "notes": self.notes, "rows": [r.to_payload() for r in self.rows], "counts": { "holidays": sum(1 for r in self.rows if r.day_type == PUBLIC_HOLIDAY), "makeup": sum(1 for r in self.rows if r.day_type == MAKEUP_WORKDAY), }, } def is_country_supported(country: str) -> bool: """Whether the dataset knows this country at all.""" try: import holidays except ImportError: # pragma: no cover — the dependency is pinned return False # Key presence, NOT truthiness: the mapping is {code: [subdivision codes]}, # and a country with no subdivisions has an empty list. Testing the value # made China, Japan and Hong Kong all report "unsupported". return country.upper() in holidays.utils.list_supported_countries() def _entries(country: str, year: int, language: str | None) -> dict[date, str]: import holidays kwargs: dict[str, object] = {"years": year} if language: kwargs["language"] = language try: raw = holidays.country_holidays(country, **kwargs) # type: ignore[arg-type] # Only the requested year: a dataset may include an adjacent-year # observed date, which would silently land in the wrong calendar. return {d: str(n) for d, n in raw.items() if d.year == year} except (KeyError, NotImplementedError, ValueError) as exc: logger.info( "holiday dataset has no data for %s %s (language=%s): %s", country, year, language, exc, ) return {} def _swap_source(name: str, year: int) -> date | None: """The working date a rest-day entry was swapped from, if it names one.""" for match in _SWAP_ZH.finditer(name): try: found = date(int(match.group(1)), int(match.group(2)), int(match.group(3))) except ValueError: continue if found.year == year: return found for match in _SWAP_EN_ISO.finditer(name): try: found = date(int(match.group(1)), int(match.group(2)), int(match.group(3))) except ValueError: continue if found.year == year: return found for match in _SWAP_EN_SLASH.finditer(name): # MM/DD/YYYY as the dataset renders it. A day > 12 in the first position # would be unambiguous, but the dataset is consistent, so no guessing. try: found = date(int(match.group(3)), int(match.group(1)), int(match.group(2))) except ValueError: continue if found.year == year: return found return None def _derive_makeup_days( names_by_day: dict[date, str], year: int, ) -> dict[date, date]: """Map each 调休 working day to the rest day that referenced it. Keeping the link (rather than just the set of dates) is what lets the makeup row be named after the holiday it offsets: the rest day's own backfilled name *is* that holiday. Proximity guessing would get 2026-01-04 wrong, since the nearest holiday to it is two days away. This is the only computable form of the information — 调休 dates are a State Council announcement, not a rule. """ out: dict[date, date] = {} for rest_day, name in names_by_day.items(): source = _swap_source(name, year) if source is not None: out[source] = rest_day return out def _is_rest_day_wrapper(name: str, year: int) -> bool: """A name that only says "this is a day off swapped from X", not which holiday. Detected by the swap reference itself rather than by a phrase list: the dataset writes "休息日(由 …)" in Chinese and "Day off (substituted from …)" in English, and a marker list missed the English form entirely — the English names came back unbackfilled while the Chinese ones were fine. """ return _swap_source(name, year) is not None def _backfill_swapped_names( names_by_day: dict[date, str], year: int, ) -> dict[date, str]: """Name a swapped rest day after the holiday it extends. Raw, a Chinese long holiday comes back with entries like ``休息日(由 2026-02-14 调休)`` sitting next to ``春节``. Left alone, HR would have to rename each one by hand — which defeats the point of pulling a draft. The nearest named non-wrapper holiday within a few days is that rest day's holiday in every real case; when there is none, the raw name is kept rather than invented. """ named = {d: n for d, n in names_by_day.items() if not _is_rest_day_wrapper(n, year)} if not named: return dict(names_by_day) out: dict[date, str] = {} for day, name in names_by_day.items(): if not _is_rest_day_wrapper(name, year): out[day] = name continue best: str | None = None for distance in range(1, _NAME_BACKFILL_WINDOW + 1): # Prefer the preceding holiday: a rest day extends a holiday that # has already started far more often than it precedes one. for candidate in ( day - timedelta(days=distance), day + timedelta(days=distance), ): if candidate in named: best = named[candidate] break if best: break out[day] = best or name return out #: An "(observed)" / "(补假)" suffix belongs on the day off, not on the workday #: that offsets it — "Makeup Workday (Labor Day (observed))" reads badly and says #: nothing extra. # Shared with the grouping layer on purpose: naming a makeup row after the # stripped base while its rest days keep the marker put them in different groups. from app.domains.hr.holiday_keys import strip_observed as _strip_observed def suggest(country: str, year: int) -> SuggestionResult: """Suggested holidays (and derivable makeup workdays) for one country-year.""" code = country.strip().upper() result = SuggestionResult(country=code, year=year) try: import holidays except ImportError: # pragma: no cover result.supported = False result.notes.append("dataset_missing") return result result.provider_version = getattr(holidays, "__version__", None) if not is_country_supported(code): result.supported = False result.notes.append("country_unsupported") return result english = _entries(code, year, "en_US") or _entries(code, year, None) if not english: result.notes.append("year_unavailable") return result local_language = _LOCAL_LANGUAGE.get(code) local = _entries(code, year, local_language) if local_language else {} # Either rendering can carry the swap reference; merge both links. makeup_links = { **_derive_makeup_days(english, year), **_derive_makeup_days(local, year), } english_named = _backfill_swapped_names(english, year) local_named = _backfill_swapped_names(local, year) if local else {} for day in sorted(english_named): result.rows.append(SuggestedDay( day=day, name_en=english_named[day], name_local=local_named.get(day), day_type=PUBLIC_HOLIDAY, )) for day, rest_day in sorted(makeup_links.items()): # A swap target must not also be listed as a day off. if day in english_named: continue # Name it after the holiday the rest day belongs to — that is what the # link is for, and it is exact where proximity would not be. base_en = _strip_observed(english_named.get(rest_day) or "Holiday") base_local = _strip_observed(local_named.get(rest_day) or "") or None result.rows.append(SuggestedDay( day=day, name_en=f"Makeup Workday ({base_en})", name_local=f"{base_local}调休上班" if base_local else None, day_type=MAKEUP_WORKDAY, )) result.notes.append("statutory_only") if code == "CN" and not makeup_links: result.notes.append("cn_makeup_unavailable") return result