git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
76 lines
2.6 KiB
Python
76 lines
2.6 KiB
Python
"""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
|