"""Key derivation for the public-holiday calendar — **stdlib imports only**. Split out of :mod:`app.domains.hr.holiday_models` on purpose. Migration 462 seeds 328 rows (322 holidays + 6 China makeup workdays) and needs the same ``name_key_for`` the ORM uses, but importing the models module pulls in ``app.core.database`` → ``app.core.config.Settings``, which requires runtime env vars. ``scripts/check_alembic_chain.py`` loads every migration file with no env configured, so that import chain makes the CI migration guard crash on file load alone — before any DB work. Keeping the key rules here means one implementation shared by the ORM, the service layer and the migration, with nothing for the chain checker to trip over. Do not add an ``app.*`` import to this module. """ from __future__ import annotations import re import unicodedata # Longest seeded group_key is 65 chars ("hk-2026-hong-kong-special-…"); the # generator caps the name slug at 72, so 96 leaves headroom without being lax. #: An "(observed)" / "(补假)" marker distinguishes two *dates* of the same #: holiday, not two holidays. It must therefore stay in ``name_key`` (which is #: part of the per-date unique key) and be stripped for *grouping*. _OBSERVED_SUFFIX = re.compile( r"[ \t]{0,4}(?:\((?:observed|substitute[d]?)\)|(补假)|(观察日))[ \t]{0,4}$", re.IGNORECASE, ) GROUP_KEY_LEN = 96 NAME_KEY_LEN = 128 def normalize_country(value: str | None) -> str: """Canonicalise a country/region code to upper case. HR types these by hand and pastes them from spreadsheets, so tolerate surrounding whitespace and lower case. Resolving a code to a real ISO-3166 country is :func:`app.core.geo.normalize_country_code`'s job; this only normalises the string shape. """ return (value or "").strip().upper() def name_key_for(name: str) -> str: """Stable slug of a holiday name, used by the uniqueness constraint. Two *different* holidays legitimately share a date — Hong Kong has both Ching Ming Festival and Holy Saturday on 2026-04-04, Romania has Children's Day and Orthodox Pentecost Monday on 2026-06-01 — so the natural key is (country, day, name), not (country, day). Slugging the name keeps that key stable against case and punctuation drift while staying short enough to index. Apostrophes are **deleted**, not turned into a separator. Treating them as a separator makes ``New Year's Day`` and ``New Years Day`` two different keys, which means an export → edit-in-Excel → re-import cycle silently creates a second row on the same date instead of matching the existing one. Non-ASCII names (a Chinese-only entry, say) would otherwise slug to the empty string, so their characters are preserved rather than stripped. """ text = unicodedata.normalize("NFKC", (name or "").strip()).casefold() text = text.replace("(", "(").replace(")", ")") text = re.sub(r"\(\s*observed\s*\)", " observed", text) # Drop intra-word punctuation outright so "New Year's" == "New Years". text = re.sub(r"['’ʼ`´.]", "", text) # Collapse anything that is not a letter or digit in ANY script. text = re.sub(r"[^\w]+", "-", text, flags=re.UNICODE).strip("-") return (text or "holiday")[:NAME_KEY_LEN] def strip_observed(name: str) -> str: """Drop a trailing "(observed)" / "(补假)" marker. Used for *grouping*, never for the unique key: two dates of one holiday may differ only by this marker, and they belong to the same group. """ return _OBSERVED_SUFFIX.sub("", name or "").strip() def group_base_key(name_en: str, *, is_makeup: bool = False) -> str: """The key that decides which holiday a row belongs to. Normalises the two decorations that describe a *date* rather than a holiday: the ``Makeup Workday (X)`` wrapper and a trailing ``(observed)``. Getting this wrong is subtle and was shipped once: the dataset pull named a makeup row after the stripped base ("Labor Day") while the rest days it offsets kept their marker ("Labor Day (observed)"), so the two landed in different groups — Labor Day split in half and each half reported ``observed_days = 0``, hidden by the ``max(0, …)`` floor. """ base = name_en or "" if is_makeup: text = base.strip() if text.lower().startswith("makeup workday (") and text.endswith(")"): base = text[len("makeup workday ("):-1].strip() or text return name_key_for(strip_observed(base))