Files
GTI_GERENCIADOR_FISCAL/central/app/enrichment/history.py
T

197 lines
9.7 KiB
Python

"""Historical ERP evidence (product master + NFC-e/NF-e items) -> central KB evidence. Local data only: NO provider is contacted here.
Evidence model
- One 'lineage' per client per (GTIN, value): master-data flag, fiscal-document count and documents per year. Master data and the fiscal documents of the SAME ERP are one family (CLIENT_DATA):
the documents were issued from that very master, so they are never counted as two independent sources.
- CLIENT_DATA alone can reach CORROBORATED (>= 20 documents over >= 2 years, or >= 2 distinct clients) but never VERIFIED_KB: that needs an independent NCM-bearing source (kb.evaluate).
- NCM validity is judged against the ACTIVE official dataset today: CURRENT_VALID / HISTORICAL_EXPIRED / HISTORICAL_INVALID; a different NCM at least as recent -> HISTORICAL_CHANGED; two current values -> CONFLICT.
- Company-specific tax data (CRT, CFOP, CSOSN, ...) is never read nor stored; only GTIN, descriptions/aliases, NCM, CEST and the source tenant label.
Idempotent: a client's lineage for a (GTIN, value) is REPLACED on every import, never accumulated.
"""
from __future__ import annotations
import re
from collections import defaultdict
from sqlalchemy import select
from ..models import KbKnowledge, KbProduct
from . import kb, textnorm
from .gtin import normalize_gtin
def _digits(v) -> str:
return re.sub(r"\D", "", str(v or ""))
def build_observations(stagings: list[dict]) -> dict:
"""stagings: miner output per client. Returns {"gtins": {key: {...}}, "stats": {...}}."""
gtins: dict[str, dict] = {}
stats = defaultdict(int)
for st in stagings:
client = st["client"]
product_gtin: dict[str, set] = defaultdict(set)
master_gtin: dict[str, str] = {}
master_desc: dict[str, str] = {}
for m in st["master"]:
stats["master_rows"] += 1
master_desc[m["code"]] = m["desc"]
for raw in (m["gtin"], m["codbarra"]):
g = normalize_gtin(raw)
if g["status"] == "VALID_GTIN":
master_gtin.setdefault(m["code"], g["key"])
break
# product<->EAN links seen in documents/movements are used ONLY when the master has none, and only when unambiguous
seen: dict[str, set] = defaultdict(set)
for row in st.get("nfe", []):
g = normalize_gtin(row["codbarra"])
if g["status"] == "VALID_GTIN":
seen[row["code"]].add(g["key"])
for row in st.get("barcodes", []):
g = normalize_gtin(row["codbarra"])
if g["status"] == "VALID_GTIN":
seen[row["code"]].add(g["key"])
link: dict[str, str] = dict(master_gtin)
for code, keys in seen.items():
if code in link:
continue
if len(keys) == 1:
link[code] = next(iter(keys))
stats["links_from_documents"] += 1
else:
stats["ambiguous_product_links_skipped"] += 1
def slot(key):
return gtins.setdefault(key, {"aliases": set(), "ncm": defaultdict(lambda: defaultdict(lambda: {"master": 0, "docs": 0, "years": {}})), "cest": defaultdict(lambda: defaultdict(lambda: {"master": 0, "docs": 0, "years": {}})), "clients": set()})
for m in st["master"]:
key = link.get(m["code"])
if not key:
continue
s = slot(key)
s["clients"].add(client)
if m["desc"]:
s["aliases"].add(textnorm.normalized(m["desc"]))
n = _digits(m["ncm"])
if len(n) == 8:
s["ncm"][n][client]["master"] = 1
c = _digits(m["cest"])
if len(c) == 7:
s["cest"][c][client]["master"] = 1
for row in st.get("nfce", []):
key = link.get(row["code"])
stats["nfce_groups"] += 1
if not key:
stats["nfce_groups_without_gtin"] += 1
continue
s = slot(key)
n = _digits(row["ncm"])
if len(n) == 8:
lin = s["ncm"][n][client]
lin["docs"] += row["n"]
if row["year"]:
lin["years"][str(row["year"])] = lin["years"].get(str(row["year"]), 0) + row["n"]
stats["nfce_documents"] += row["n"]
c = _digits(row["cest"])
if len(c) == 7:
lin = s["cest"][c][client]
lin["docs"] += row["n"]
if row["year"]:
lin["years"][str(row["year"])] = lin["years"].get(str(row["year"]), 0) + row["n"]
for row in st.get("nfe", []):
key = link.get(row["code"])
if not key:
continue
s = slot(key)
if row["desc"]:
s["aliases"].add(textnorm.normalized(row["desc"]))
n = _digits(row["ncm"])
if len(n) == 8:
s["ncm"][n][client]["docs"] += row["n"]
stats["nfe_documents"] += row["n"]
stats["gtins"] = len(gtins)
return {"gtins": gtins, "stats": dict(stats)}
def apply(session, observations: dict, is_official, *, dry_run: bool = True, chunk: int = 500) -> dict:
"""Bulk, idempotent import into kb_products / kb_knowledge. dry_run=True computes every status and rolls back."""
keys = sorted(observations["gtins"])
report = {"GTINS_FOUND": len(keys), "OBSERVATIONS": 0, "by_status": defaultdict(int), "CONFLICTS": 0, "WOULD_VERIFY": 0, "with_current_valid_ncm": 0, "stable_across_years": 0, "changed_over_time": 0,
"with_historical_ncm": 0, "multi_client": 0}
nested = session.begin_nested()
try:
for start in range(0, len(keys), chunk):
part = keys[start:start + chunk]
existing = {p.gtin: p for p in session.scalars(select(KbProduct).where(KbProduct.gtin.in_(part)))}
for key in part:
obs = observations["gtins"][key]
product = existing.get(key)
if product is None:
alias = next(iter(sorted(obs["aliases"])), "")
product = kb.upsert_product(session, key, alias)
existing[key] = product
for a in sorted(obs["aliases"])[:20]:
kb.add_alias(session, product, a)
fp = textnorm.fingerprint(a)
if fp:
kb.add_evidence(session, product, "name_fingerprint", fp, "IDENTITY", family="IDENTITY", status=kb.OBSERVED)
for field, values in (("ncm", obs["ncm"]), ("cest", obs["cest"])):
for value, per_client in values.items():
for client, lin in per_client.items():
kb.add_evidence(session, product, field, value, "client:history", family=kb.CLIENT_FAMILY, client_label=client, lineage=None)
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == field, KbKnowledge.value == value))
ev = dict(row.evidence or {})
lineages = dict(ev.get("lineages") or {})
lineages[client] = {"master": lin["master"], "docs": lin["docs"], "years": dict(lin["years"])} # REPLACE: idempotent
row.evidence = {**ev, "lineages": lineages}
report["OBSERVATIONS"] += 1
session.flush()
for key in part:
product = existing[key]
ev = kb.evaluate(session, product, "ncm", is_official)
report["by_status"][ev["status"]] += 1
if ev["status"] == kb.CONFLICT:
report["CONFLICTS"] += 1
if ev["status"] == kb.VERIFIED_KB:
report["WOULD_VERIFY"] += 1
if ev["value"]:
report["with_current_valid_ncm"] += 1
if observations["gtins"][key]["ncm"]:
report["with_historical_ncm"] += 1
if len(observations["gtins"][key]["clients"]) >= 2:
report["multi_client"] += 1
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == "ncm")))
states = {(r.evidence.get("info") or {}).get("historical_state") for r in rows}
if "HISTORICAL_STABLE_CURRENT" in states and ev["value"]:
years = set()
for r in rows:
for lin in (r.evidence.get("lineages") or {}).values():
years |= set(lin.get("years") or {})
if len(years) >= 2:
report["stable_across_years"] += 1
if "HISTORICAL_CHANGED" in states or ("HISTORICAL_EXPIRED" in states and ev["value"]):
report["changed_over_time"] += 1
if dry_run:
nested.rollback()
else:
nested.commit()
session.commit()
except Exception:
nested.rollback()
raise
report["by_status"] = dict(report["by_status"])
return report
def official_sets(session):
"""Precomputed 'vigente today' membership: one query instead of one per value."""
from datetime import date
from ..models import Dataset, NcmEntry
from .resolver import _parse_br
today = date.today()
current = set()
for row in session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE")):
start, end = _parse_br(row.valid_from), _parse_br(row.valid_to)
if (start is None or start <= today) and (end is None or end >= today):
current.add(row.code)
return current