"""Product identity: stable fingerprints and GTIN validation. Global identity uses ONLY stable attributes (normalized description, brand, unit, package quantity, GTIN). Fiscal/company context (CFOP, CSOSN, CST, rate, ST, tenant) never participates: those vary per context and must not merge distinct knowledge. """ import hashlib import re import unicodedata from ..classification import ENGINE_VERSION # noqa: F401 (re-exported for the AI layer) def normalize_description(value) -> str: """NFKD -> ascii -> A-Z0-9 tokens, space-joined (same canonical form as classification).""" text = unicodedata.normalize("NFKD", str(value or "")).encode("ascii", "ignore").decode().upper() return " ".join(re.sub(r"[^A-Z0-9]+", " ", text).split()) def _normalize_token(value) -> str: return normalize_description(value) def _normalize_quantity(value) -> str: digits = re.sub(r"[^0-9.,]", "", str(value or "")) if not digits: return "" try: number = float(digits.replace(",", ".")) except ValueError: return digits return f"{number:g}" def product_fingerprint(description, brand=None, unit=None, package_quantity=None) -> str: parts = [_normalize_token(description), _normalize_token(brand), _normalize_token(unit), _normalize_quantity(package_quantity)] return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest() def _stem(token: str) -> str: """Minimal Portuguese plural stemming so AGUAS matches AGUA / MINERAIS matches MINERAL.""" if len(token) > 4 and token.endswith("AIS"): return token[:-3] + "AL" if len(token) > 4 and token.endswith("ES"): return token[:-2] if len(token) > 3 and token.endswith("S"): return token[:-1] return token def token_stems(value) -> set: return {_stem(token) for token in normalize_description(value).split()} def overlap_score(query, official) -> float: """Fraction of query description stems present in the official description stems.""" stems = token_stems(query) if not stems: return 0.0 return len(stems & token_stems(official)) / len(stems) def validate_gtin(value) -> str | None: """Returns the canonical GTIN when the checksum is valid, else None. Accepts GTIN-8/12/13/14 (EAN-8/UPC-12/EAN-13/GTIN-14).""" digits = re.sub(r"\D", "", str(value or "")) if len(digits) not in (8, 12, 13, 14): return None total = 0 for index, char in enumerate(reversed(digits)): digit = int(char) total += digit * (1 if index % 2 == 0 else 3) if total % 10 != 0: return None return digits