feat: product enrichment foundation (GTIN, KB evidence, providers, quota gateway, resolver, migration 0029)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,140 @@
|
|||||||
|
"""Per-installation functions (corrector / enrichment mode), KB field status, enrichment state + audit, provider state/usage/cache, official source refresh log.
|
||||||
|
|
||||||
|
Revision ID: 0029_product_enrichment
|
||||||
|
Revises: 0028_rtc_write_modes
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0029_product_enrichment"
|
||||||
|
down_revision = "0028_rtc_write_modes"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _idx(table, *cols):
|
||||||
|
for col in cols:
|
||||||
|
op.create_index(f"ix_{table}_{col}", table, [col])
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# existing installations keep behaving exactly as before: corrector on, functions already configured
|
||||||
|
op.add_column("installations", sa.Column("corrector_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")))
|
||||||
|
op.add_column("installations", sa.Column("enrichment_mode", sa.String(16), nullable=False, server_default="DISABLED"))
|
||||||
|
op.add_column("installations", sa.Column("features_configured", sa.Boolean(), nullable=False, server_default=sa.text("true")))
|
||||||
|
op.add_column("agent_enrollments", sa.Column("features_selected", sa.Boolean(), nullable=False, server_default=sa.text("false")))
|
||||||
|
op.add_column("agent_enrollments", sa.Column("feature_corrector", sa.Boolean(), nullable=False, server_default=sa.text("false")))
|
||||||
|
op.add_column("agent_enrollments", sa.Column("feature_enrichment_mode", sa.String(16), nullable=False, server_default="DISABLED"))
|
||||||
|
op.add_column("kb_knowledge", sa.Column("status", sa.String(16), nullable=False, server_default="EVIDENCE_ONLY"))
|
||||||
|
op.create_index("ix_kb_knowledge_status", "kb_knowledge", ["status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"product_enrichments",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
|
||||||
|
sa.Column("product_code", sa.String(64), nullable=False),
|
||||||
|
sa.Column("gtin", sa.String(16), nullable=True),
|
||||||
|
sa.Column("description", sa.String(255), nullable=True),
|
||||||
|
sa.Column("status", sa.String(32), nullable=False, server_default="PENDING"),
|
||||||
|
sa.Column("decisions", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("fields_applied", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("human_overridden", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("attempts", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("next_attempt_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("latency_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("detected_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("installation_id", "product_code", name="uq_product_enrichment"),
|
||||||
|
)
|
||||||
|
_idx("product_enrichments", "tenant_id", "installation_id", "gtin", "status", "next_attempt_at")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"product_field_audits",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False),
|
||||||
|
sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False),
|
||||||
|
sa.Column("agent_id", sa.Integer(), sa.ForeignKey("agents.id"), nullable=False),
|
||||||
|
sa.Column("product_code", sa.String(64), nullable=False),
|
||||||
|
sa.Column("gtin", sa.String(16), nullable=True),
|
||||||
|
sa.Column("description_snapshot", sa.String(255), nullable=True),
|
||||||
|
sa.Column("field", sa.String(48), nullable=False),
|
||||||
|
sa.Column("old_value", sa.String(64), nullable=True),
|
||||||
|
sa.Column("new_value", sa.String(64), nullable=True),
|
||||||
|
sa.Column("rule", sa.String(64), nullable=True),
|
||||||
|
sa.Column("kb_status", sa.String(16), nullable=True),
|
||||||
|
sa.Column("evidence", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("dataset_versions", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("authorization_origin", sa.String(48), nullable=True),
|
||||||
|
sa.Column("job_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("outcome", sa.String(24), nullable=False, server_default="APPLIED"),
|
||||||
|
sa.Column("reason", sa.String(96), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
_idx("product_field_audits", "tenant_id", "installation_id", "agent_id", "product_code")
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"provider_states",
|
||||||
|
sa.Column("provider", sa.String(32), primary_key=True),
|
||||||
|
sa.Column("configured", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="NOT_CONFIGURED"),
|
||||||
|
sa.Column("quota_kind", sa.String(16), nullable=False, server_default="DOCUMENTED_RATE"),
|
||||||
|
sa.Column("daily_limit", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("remaining", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("reset_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("rate_per_minute", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("circuit_open_until", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("consecutive_failures", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_request_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_success_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_error", sa.String(160), nullable=True),
|
||||||
|
sa.Column("last_error_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_latency_ms", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"provider_usage_daily",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("provider", sa.String(32), nullable=False),
|
||||||
|
sa.Column("day", sa.String(10), nullable=False),
|
||||||
|
*[sa.Column(name, sa.Integer(), nullable=False, server_default="0") for name in ("requests", "success", "not_found", "errors", "rate_limited", "timeouts", "cache_hits", "cache_misses")],
|
||||||
|
sa.UniqueConstraint("provider", "day", name="uq_provider_usage_day"),
|
||||||
|
)
|
||||||
|
_idx("provider_usage_daily", "provider", "day")
|
||||||
|
op.create_table(
|
||||||
|
"gtin_lookup_cache",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("gtin", sa.String(16), nullable=False),
|
||||||
|
sa.Column("provider", sa.String(32), nullable=False),
|
||||||
|
sa.Column("found", sa.Boolean(), nullable=False),
|
||||||
|
sa.Column("data", sa.JSON(), nullable=False),
|
||||||
|
sa.Column("fetched_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.UniqueConstraint("gtin", "provider", name="uq_gtin_lookup_cache"),
|
||||||
|
)
|
||||||
|
_idx("gtin_lookup_cache", "gtin", "expires_at")
|
||||||
|
op.create_table(
|
||||||
|
"source_refresh_runs",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("source", sa.String(32), nullable=False),
|
||||||
|
sa.Column("source_url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("status", sa.String(16), nullable=False, server_default="RUNNING"),
|
||||||
|
sa.Column("sha256", sa.String(64), nullable=True),
|
||||||
|
sa.Column("version_label", sa.String(64), nullable=True),
|
||||||
|
sa.Column("record_count", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("error", sa.String(300), nullable=True),
|
||||||
|
)
|
||||||
|
_idx("source_refresh_runs", "source")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
for table in ("source_refresh_runs", "gtin_lookup_cache", "provider_usage_daily", "provider_states", "product_field_audits", "product_enrichments"):
|
||||||
|
op.drop_table(table)
|
||||||
|
op.drop_index("ix_kb_knowledge_status", table_name="kb_knowledge")
|
||||||
|
op.drop_column("kb_knowledge", "status")
|
||||||
|
for name in ("feature_enrichment_mode", "feature_corrector", "features_selected"):
|
||||||
|
op.drop_column("agent_enrollments", name)
|
||||||
|
for name in ("features_configured", "enrichment_mode", "corrector_enabled"):
|
||||||
|
op.drop_column("installations", name)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Product enrichment (new product -> GTIN/name -> fiscal profile -> safe fill). Deterministic only: no AI, no LLM, no embeddings, no paid providers.
|
||||||
|
Everything lives inside the existing Hub/FastAPI/PostgreSQL; providers are hidden behind the Hub (Agents never call them)."""
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Deterministic GTIN normalization and validation (GTIN-8, UPC/GTIN-12, EAN/GTIN-13, GTIN-14)."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
VALID, INVALID, MISSING, RESTRICTED = "VALID_GTIN", "INVALID_GTIN", "MISSING_GTIN", "RESTRICTED_GTIN"
|
||||||
|
|
||||||
|
|
||||||
|
def check_digit_ok(digits: str) -> bool:
|
||||||
|
body, check = digits[:-1], int(digits[-1])
|
||||||
|
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body)))
|
||||||
|
return (10 - total % 10) % 10 == check
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_gtin(raw) -> dict:
|
||||||
|
"""Returns {status, gtin (digits as typed), key (zero-padded to 14: the KB identity), reason}. Never calls anything external.
|
||||||
|
RESTRICTED: EAN prefixes 02, 04 and 20-29 are store-internal / restricted circulation (not a global product identity), so no provider is asked."""
|
||||||
|
text = "" if raw is None else str(raw).strip()
|
||||||
|
if not text or text.upper() in ("SEM GTIN", "SEM EAN", "0", "NULL"):
|
||||||
|
return {"status": MISSING, "gtin": None, "key": None, "reason": "EMPTY"}
|
||||||
|
digits = re.sub(r"\D", "", text)
|
||||||
|
if not digits or len(digits) not in (8, 12, 13, 14):
|
||||||
|
return {"status": INVALID, "gtin": digits or None, "key": None, "reason": "BAD_LENGTH"}
|
||||||
|
if len(set(digits)) == 1:
|
||||||
|
return {"status": INVALID, "gtin": digits, "key": None, "reason": "REPEATED_DIGITS"}
|
||||||
|
if not check_digit_ok(digits):
|
||||||
|
return {"status": INVALID, "gtin": digits, "key": None, "reason": "BAD_CHECK_DIGIT"}
|
||||||
|
key = digits.zfill(14)
|
||||||
|
prefix = key[1:3] if len(digits) == 14 else key[1:3] # key = 0 + EAN-13 layout for 12/13; GTIN-14 keeps its indicator digit in key[0]
|
||||||
|
ean13 = key[1:]
|
||||||
|
if ean13[:2] in ("02", "04") or "20" <= ean13[:2] <= "29":
|
||||||
|
return {"status": RESTRICTED, "gtin": digits, "key": key, "reason": "STORE_INTERNAL_PREFIX"}
|
||||||
|
return {"status": VALID, "gtin": digits, "key": key, "reason": None}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Central product Knowledge Base access (same PostgreSQL, existing kb_* tables) with per-field status.
|
||||||
|
|
||||||
|
Trust model (docs/product_enrichment.md):
|
||||||
|
- A provider value or a client observation is EVIDENCE. A field becomes VERIFIED_KB only when the official dataset confirms it AND at least two INDEPENDENT evidence
|
||||||
|
families agree with zero conflict (distinct providers each count once; client observations count once and only with >= 2 distinct clients).
|
||||||
|
- Human edits never enter the KB (HUMAN_OVERRIDDEN is per-installation protection, not knowledge). Company-specific fiscal decisions (CSOSN, CFOP, ...) never live here.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..models import KbKnowledge, KbProduct
|
||||||
|
from . import textnorm
|
||||||
|
|
||||||
|
UNVERIFIED, EVIDENCE_ONLY, VERIFIED_KB, CONFLICT, STALE = "UNVERIFIED", "EVIDENCE_ONLY", "VERIFIED_KB", "CONFLICT", "STALE"
|
||||||
|
CLIENT_FAMILY = "CLIENT_OBSERVATION"
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def gtin_fingerprint(key: str) -> str:
|
||||||
|
return hashlib.sha256(f"gtin:{key}".encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def find_by_gtin(session, key: str | None) -> KbProduct | None:
|
||||||
|
return session.scalar(select(KbProduct).where(KbProduct.gtin == key)) if key else None
|
||||||
|
|
||||||
|
|
||||||
|
def name_candidates(session, description) -> list[KbProduct]:
|
||||||
|
"""Exact-fingerprint matches (0, 1 or several)."""
|
||||||
|
fp = textnorm.fingerprint(description)
|
||||||
|
if not fp:
|
||||||
|
return []
|
||||||
|
ids = list(session.scalars(select(KbKnowledge.kb_product_id).where(KbKnowledge.knowledge_key == "name_fingerprint", KbKnowledge.value == fp).distinct()))
|
||||||
|
return [p for p in (session.get(KbProduct, i) for i in ids) if p]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_product(session, key: str | None, description, *, brand=None, quantity=None) -> KbProduct:
|
||||||
|
product = find_by_gtin(session, key)
|
||||||
|
if product is None:
|
||||||
|
fp = gtin_fingerprint(key) if key else hashlib.sha256(f"name:{textnorm.fingerprint(description)}".encode()).hexdigest()
|
||||||
|
product = session.scalar(select(KbProduct).where(KbProduct.fingerprint == fp))
|
||||||
|
if product is None:
|
||||||
|
product = KbProduct(fingerprint=fp, gtin=key, identity_kind="GTIN" if key else "FINGERPRINT", normalized_description=textnorm.normalized(description))
|
||||||
|
session.add(product)
|
||||||
|
session.flush()
|
||||||
|
if description and not product.normalized_description:
|
||||||
|
product.normalized_description = textnorm.normalized(description)
|
||||||
|
if brand and not product.brand:
|
||||||
|
product.brand = brand
|
||||||
|
if quantity and not product.package_quantity:
|
||||||
|
product.package_quantity = str(quantity)[:32]
|
||||||
|
fp = textnorm.fingerprint(description)
|
||||||
|
if fp:
|
||||||
|
add_evidence(session, product, "name_fingerprint", fp, "IDENTITY", family="IDENTITY", status=EVIDENCE_ONLY)
|
||||||
|
return product
|
||||||
|
|
||||||
|
|
||||||
|
def add_evidence(session, product: KbProduct, key: str, value: str, source: str, *, family: str, client_label: str | None = None, status: str = EVIDENCE_ONLY, info: dict | None = None) -> KbKnowledge:
|
||||||
|
row = session.scalar(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key, KbKnowledge.value == value))
|
||||||
|
if row is None:
|
||||||
|
row = KbKnowledge(kb_product_id=product.id, knowledge_key=key, value=value, source=source[:32], status=status, evidence={"families": {}, "clients": [], "info": info or {}})
|
||||||
|
session.add(row)
|
||||||
|
session.flush()
|
||||||
|
evidence = dict(row.evidence or {})
|
||||||
|
families = dict(evidence.get("families") or {})
|
||||||
|
families[family] = _now().isoformat()
|
||||||
|
clients = list(evidence.get("clients") or [])
|
||||||
|
if client_label and client_label not in clients:
|
||||||
|
clients.append(client_label)
|
||||||
|
row.evidence = {**evidence, "families": families, "clients": clients, "info": {**(evidence.get("info") or {}), **(info or {})}}
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def independent_count(rows) -> tuple[int, list[str]]:
|
||||||
|
families = {}
|
||||||
|
clients: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
ev = row.evidence or {}
|
||||||
|
for fam in (ev.get("families") or {}):
|
||||||
|
if fam.startswith("PROVIDER:"):
|
||||||
|
families[fam] = True
|
||||||
|
elif fam == CLIENT_FAMILY:
|
||||||
|
clients.update(ev.get("clients") or [])
|
||||||
|
names = sorted(families)
|
||||||
|
if len(clients) >= 2:
|
||||||
|
names.append(f"{CLIENT_FAMILY}(x{len(clients)})")
|
||||||
|
return len(names), names
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(session, product: KbProduct, key: str, is_official) -> dict:
|
||||||
|
"""Recomputes and PERSISTS the status of one field. Returns {value, status, families, candidates, rejected}."""
|
||||||
|
rows = list(session.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == product.id, KbKnowledge.knowledge_key == key)))
|
||||||
|
if not rows:
|
||||||
|
return {"value": None, "status": UNVERIFIED, "families": [], "candidates": [], "rejected": []}
|
||||||
|
valid = [r for r in rows if is_official(r.value)]
|
||||||
|
rejected = [r.value for r in rows if not is_official(r.value)]
|
||||||
|
values = sorted({r.value for r in valid})
|
||||||
|
if len(values) > 1:
|
||||||
|
for r in rows:
|
||||||
|
r.status = CONFLICT
|
||||||
|
return {"value": None, "status": CONFLICT, "families": [], "candidates": values, "rejected": rejected}
|
||||||
|
if not values:
|
||||||
|
for r in rows:
|
||||||
|
r.status = EVIDENCE_ONLY
|
||||||
|
return {"value": None, "status": EVIDENCE_ONLY, "families": [], "candidates": [], "rejected": rejected}
|
||||||
|
chosen = [r for r in valid if r.value == values[0]]
|
||||||
|
count, names = independent_count(chosen)
|
||||||
|
already = any(r.status == VERIFIED_KB and r.validated_at for r in chosen)
|
||||||
|
status = VERIFIED_KB if (count >= 2 or already) else EVIDENCE_ONLY
|
||||||
|
for r in rows:
|
||||||
|
r.status = status if r.value == values[0] else EVIDENCE_ONLY
|
||||||
|
if status == VERIFIED_KB and r.value == values[0] and not r.validated_at:
|
||||||
|
r.validated_at = _now()
|
||||||
|
return {"value": values[0], "status": status, "families": names, "candidates": values, "rejected": rejected}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Free product-identity providers behind the Hub. Agents never talk to these; the Hub does, through the quota manager.
|
||||||
|
|
||||||
|
Documentation snapshots consulted 2026-09-25 (see docs/product_enrichment.md):
|
||||||
|
- UPCitemdb Explorer/trial: https://api.upcitemdb.com/prod/trial/lookup?upc=... - no key, 100 combined requests/day, burst 15 lookup / 5 search per 30 s,
|
||||||
|
headers X-RateLimit-Limit / -Remaining / -Reset. Product IDENTITY only (title/brand/category); no fiscal data.
|
||||||
|
- Open Food Facts: https://world.openfoodfacts.org/api/v2/product/{barcode} - 15 read req/min/IP, 10 search req/min/IP, identifiable User-Agent, ODbL data. Foods only; no fiscal data.
|
||||||
|
- Cosmos / Bluesoft: https://api.cosmos.bluesoft.com.br/gtins/{gtin} - X-Cosmos-Token (free token, 25 queries/day), returns NCM/CEST/brand/description. Optional: without a token it is NOT_CONFIGURED.
|
||||||
|
A provider result is EVIDENCE. Only the fiscal engine + official datasets decide what is written.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
USER_AGENT = "GTIFiscalHub/1.0 (+https://gtifiscal.gtigrupo.com.br; product identity lookup)"
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderError(Exception):
|
||||||
|
"""kind: RATE_LIMITED | AUTH_ERROR | TIMEOUT | SERVER_ERROR | NETWORK | BAD_RESPONSE"""
|
||||||
|
|
||||||
|
def __init__(self, kind: str, detail: str = "", headers: dict | None = None, retry_after: int | None = None):
|
||||||
|
super().__init__(f"{kind}: {detail}")
|
||||||
|
self.kind, self.detail, self.headers, self.retry_after = kind, detail, headers or {}, retry_after
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderProduct:
|
||||||
|
provider: str
|
||||||
|
gtin: str
|
||||||
|
found: bool
|
||||||
|
title: str | None = None
|
||||||
|
brand: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
quantity: str | None = None
|
||||||
|
ncm: str | None = None
|
||||||
|
cest: str | None = None
|
||||||
|
raw_hash: str = ""
|
||||||
|
latency_ms: int = 0
|
||||||
|
headers: dict = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_data(self) -> dict:
|
||||||
|
return {k: v for k, v in {"title": self.title, "brand": self.brand, "category": self.category, "quantity": self.quantity, "ncm": self.ncm, "cest": self.cest}.items() if v}
|
||||||
|
|
||||||
|
|
||||||
|
def _digits(value) -> str | None:
|
||||||
|
text = "".join(ch for ch in str(value or "") if ch.isdigit())
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
name = "base"
|
||||||
|
paid = False
|
||||||
|
supports_search = False
|
||||||
|
daily_limit: int | None = None
|
||||||
|
rate_per_minute: int | None = None
|
||||||
|
burst: tuple[int, int] | None = None # (requests, seconds)
|
||||||
|
provides_ncm = False
|
||||||
|
|
||||||
|
def __init__(self, client: httpx.Client | None = None, token: str | None = None):
|
||||||
|
self.client = client or httpx.Client(timeout=4.0, headers={"User-Agent": USER_AGENT})
|
||||||
|
self._token = token
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _get(self, url: str, **kwargs) -> httpx.Response:
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
response = self.client.get(url, **kwargs)
|
||||||
|
except httpx.TimeoutException as error:
|
||||||
|
raise ProviderError("TIMEOUT", type(error).__name__) from error
|
||||||
|
except httpx.HTTPError as error:
|
||||||
|
raise ProviderError("NETWORK", type(error).__name__) from error
|
||||||
|
response.latency_ms = int((time.monotonic() - started) * 1000) # type: ignore[attr-defined]
|
||||||
|
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||||
|
if response.status_code == 429:
|
||||||
|
retry = headers.get("retry-after")
|
||||||
|
raise ProviderError("RATE_LIMITED", "HTTP 429", headers, int(retry) if retry and retry.isdigit() else None)
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
raise ProviderError("AUTH_ERROR", f"HTTP {response.status_code}", headers)
|
||||||
|
if response.status_code >= 500:
|
||||||
|
raise ProviderError("SERVER_ERROR", f"HTTP {response.status_code}", headers)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def lookup_gtin(self, gtin: str) -> ProviderProduct: # pragma: no cover - interface
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
def health(self) -> dict:
|
||||||
|
return {"configured": self.configured}
|
||||||
|
|
||||||
|
|
||||||
|
class UpcItemDb(Provider):
|
||||||
|
name = "upcitemdb"
|
||||||
|
supports_search = True
|
||||||
|
daily_limit = 100
|
||||||
|
burst = (15, 30)
|
||||||
|
|
||||||
|
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||||
|
response = self._get("https://api.upcitemdb.com/prod/trial/lookup", params={"upc": gtin})
|
||||||
|
if response.status_code == 404:
|
||||||
|
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=dict(response.headers))
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as error:
|
||||||
|
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||||
|
items = body.get("items") or []
|
||||||
|
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||||
|
if not items:
|
||||||
|
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||||
|
item = items[0]
|
||||||
|
raw = hashlib.sha256(json.dumps(item, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
||||||
|
return ProviderProduct(self.name, gtin, True, title=item.get("title"), brand=item.get("brand"), category=item.get("category"),
|
||||||
|
quantity=item.get("size") or item.get("weight") or None, raw_hash=raw, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||||
|
|
||||||
|
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||||
|
response = self._get("https://api.upcitemdb.com/prod/trial/search", params={"s": text, "match_mode": "1", "type": "product"})
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for item in (response.json().get("items") or [])[:5]:
|
||||||
|
code = _digits(item.get("ean") or item.get("upc")) or ""
|
||||||
|
out.append(ProviderProduct(self.name, code, True, title=item.get("title"), brand=item.get("brand"), category=item.get("category"),
|
||||||
|
raw_hash=hashlib.sha256(json.dumps(item, sort_keys=True, ensure_ascii=False).encode()).hexdigest()))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class OpenFoodFacts(Provider):
|
||||||
|
name = "openfoodfacts"
|
||||||
|
supports_search = True
|
||||||
|
rate_per_minute = 15
|
||||||
|
|
||||||
|
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||||
|
response = self._get(f"https://world.openfoodfacts.org/api/v2/product/{gtin}", params={"fields": "code,product_name,brands,quantity,categories,generic_name"})
|
||||||
|
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||||
|
if response.status_code == 404:
|
||||||
|
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as error:
|
||||||
|
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||||
|
product = body.get("product") or {}
|
||||||
|
if body.get("status") != 1 or not product:
|
||||||
|
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||||
|
title = product.get("product_name") or product.get("generic_name")
|
||||||
|
return ProviderProduct(self.name, gtin, bool(title), title=title, brand=(product.get("brands") or "").split(",")[0].strip() or None, category=(product.get("categories") or "").split(",")[0].strip() or None,
|
||||||
|
quantity=product.get("quantity"), raw_hash=hashlib.sha256(json.dumps(product, sort_keys=True, ensure_ascii=False).encode()).hexdigest(),
|
||||||
|
latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||||
|
|
||||||
|
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||||
|
response = self._get("https://world.openfoodfacts.org/cgi/search.pl", params={"search_terms": text, "json": "1", "page_size": "5", "fields": "code,product_name,brands,quantity"})
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
return [ProviderProduct(self.name, _digits(p.get("code")) or "", True, title=p.get("product_name"), brand=(p.get("brands") or "").split(",")[0].strip() or None, quantity=p.get("quantity"))
|
||||||
|
for p in (response.json().get("products") or [])[:5] if p.get("product_name")]
|
||||||
|
|
||||||
|
|
||||||
|
class Cosmos(Provider):
|
||||||
|
name = "cosmos"
|
||||||
|
supports_search = True
|
||||||
|
daily_limit = 25
|
||||||
|
provides_ncm = True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def configured(self) -> bool:
|
||||||
|
return bool(self._token)
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {"X-Cosmos-Token": self._token or "", "User-Agent": USER_AGENT, "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
def lookup_gtin(self, gtin: str) -> ProviderProduct:
|
||||||
|
if not self.configured:
|
||||||
|
raise ProviderError("AUTH_ERROR", "NOT_CONFIGURED")
|
||||||
|
response = self._get(f"https://api.cosmos.bluesoft.com.br/gtins/{gtin}", headers=self._headers())
|
||||||
|
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||||
|
if response.status_code == 404:
|
||||||
|
return ProviderProduct(self.name, gtin, False, latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise ProviderError("BAD_RESPONSE", f"HTTP {response.status_code}")
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError as error:
|
||||||
|
raise ProviderError("BAD_RESPONSE", "not JSON") from error
|
||||||
|
ncm = _digits((body.get("ncm") or {}).get("code"))
|
||||||
|
cest = _digits((body.get("cest") or {}).get("code"))
|
||||||
|
brand = (body.get("brand") or {}).get("name") if isinstance(body.get("brand"), dict) else body.get("brand")
|
||||||
|
return ProviderProduct(self.name, gtin, True, title=body.get("description"), brand=brand, category=(body.get("gpc") or {}).get("description"), quantity=body.get("net_weight") and str(body.get("net_weight")),
|
||||||
|
ncm=ncm, cest=cest, raw_hash=hashlib.sha256(json.dumps(body, sort_keys=True, ensure_ascii=False).encode()).hexdigest(), latency_ms=getattr(response, "latency_ms", 0), headers=headers)
|
||||||
|
|
||||||
|
def search_description(self, text: str) -> list[ProviderProduct]:
|
||||||
|
if not self.configured:
|
||||||
|
return []
|
||||||
|
response = self._get("https://api.cosmos.bluesoft.com.br/products", params={"query": text}, headers=self._headers())
|
||||||
|
if response.status_code != 200:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for item in (response.json().get("products") or [])[:5]:
|
||||||
|
out.append(ProviderProduct(self.name, _digits(item.get("gtin")) or "", True, title=item.get("description"), brand=(item.get("brand") or {}).get("name") if isinstance(item.get("brand"), dict) else None,
|
||||||
|
ncm=_digits((item.get("ncm") or {}).get("code")) if isinstance(item.get("ncm"), dict) else None))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_providers(settings, client: httpx.Client | None = None) -> list[Provider]:
|
||||||
|
"""Fixed order chosen from the benchmark (docs/product_enrichment.md): Cosmos (fiscal-capable) first when configured, then UPCitemdb, then Open Food Facts. Paid providers do not exist here."""
|
||||||
|
providers: list[Provider] = [Cosmos(client, getattr(settings, "cosmos_token", None)), UpcItemDb(client), OpenFoodFacts(client)]
|
||||||
|
if any(p.paid for p in providers) and not getattr(settings, "allow_paid_providers", False):
|
||||||
|
raise RuntimeError("paid providers are not allowed (ALLOW_PAID_PROVIDERS=false)")
|
||||||
|
return providers
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""Central quota / circuit breaker / cache / single-flight for external product providers. Quota belongs to the HUB: 70 Agents never spend it uncoordinated."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..models import GtinLookupCache, ProviderState, ProviderUsageDaily
|
||||||
|
from .providers import Provider, ProviderError, ProviderProduct
|
||||||
|
|
||||||
|
POSITIVE_TTL = timedelta(days=30)
|
||||||
|
NEGATIVE_TTL = timedelta(hours=24)
|
||||||
|
CIRCUIT_BASE = timedelta(seconds=60)
|
||||||
|
CIRCUIT_MAX = timedelta(minutes=15)
|
||||||
|
FAILURES_TO_OPEN = 3
|
||||||
|
|
||||||
|
_windows: dict[str, deque] = defaultdict(deque) # per-process sliding windows (burst / per-minute governors)
|
||||||
|
_flights: dict[tuple, threading.Lock] = {}
|
||||||
|
_flights_guard = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _aware(value):
|
||||||
|
if value is not None and value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=timezone.utc)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def next_utc_midnight(now: datetime) -> datetime:
|
||||||
|
return (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Outcome:
|
||||||
|
provider: str
|
||||||
|
state: str # CACHE_HIT | FOUND | NOT_FOUND | SKIPPED | ERROR
|
||||||
|
product: ProviderProduct | None = None
|
||||||
|
reason: str | None = None # NOT_CONFIGURED | CIRCUIT_OPEN | RATE_LIMITED | RATE_WINDOW | TIMEOUT | AUTH_ERROR | NETWORK | SERVER_ERROR | ...
|
||||||
|
retry_at: datetime | None = None
|
||||||
|
from_cache: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaManager:
|
||||||
|
def __init__(self, session, now=utcnow):
|
||||||
|
self.session, self.now = session, now
|
||||||
|
|
||||||
|
# ---- state -------------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
def state(self, provider: Provider) -> ProviderState:
|
||||||
|
row = self.session.get(ProviderState, provider.name)
|
||||||
|
if row is None:
|
||||||
|
row = ProviderState(provider=provider.name, configured=provider.configured, status="ONLINE" if provider.configured else "NOT_CONFIGURED",
|
||||||
|
daily_limit=provider.daily_limit, rate_per_minute=provider.rate_per_minute,
|
||||||
|
quota_kind="LOCAL_ESTIMATE" if provider.daily_limit else "DOCUMENTED_RATE")
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
row.configured = provider.configured
|
||||||
|
if not provider.configured:
|
||||||
|
row.status = "NOT_CONFIGURED"
|
||||||
|
elif row.status == "NOT_CONFIGURED":
|
||||||
|
row.status = "ONLINE"
|
||||||
|
row.daily_limit = provider.daily_limit
|
||||||
|
row.rate_per_minute = provider.rate_per_minute
|
||||||
|
return row
|
||||||
|
|
||||||
|
def usage(self, provider_name: str, day: str | None = None) -> ProviderUsageDaily:
|
||||||
|
day = day or self.now().strftime("%Y-%m-%d")
|
||||||
|
row = self.session.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == provider_name, ProviderUsageDaily.day == day))
|
||||||
|
if row is None:
|
||||||
|
row = ProviderUsageDaily(provider=provider_name, day=day)
|
||||||
|
self.session.add(row)
|
||||||
|
self.session.flush()
|
||||||
|
return row
|
||||||
|
|
||||||
|
# ---- decision ----------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
def allow(self, provider: Provider) -> tuple[bool, str | None, datetime | None]:
|
||||||
|
state, now = self.state(provider), self.now()
|
||||||
|
if not provider.configured:
|
||||||
|
return False, "NOT_CONFIGURED", None
|
||||||
|
if state.circuit_open_until and _aware(state.circuit_open_until) > now:
|
||||||
|
return False, "CIRCUIT_OPEN", _aware(state.circuit_open_until)
|
||||||
|
usage = self.usage(provider.name)
|
||||||
|
if provider.daily_limit and state.quota_kind == "REMOTE_QUOTA" and state.remaining is not None and state.remaining <= 0 and state.reset_at and _aware(state.reset_at) > now:
|
||||||
|
return False, "RATE_LIMITED", _aware(state.reset_at)
|
||||||
|
if provider.daily_limit and usage.requests >= provider.daily_limit and state.quota_kind != "REMOTE_QUOTA":
|
||||||
|
state.status = "RATE_LIMITED"
|
||||||
|
return False, "RATE_LIMITED", state.reset_at and _aware(state.reset_at) or next_utc_midnight(now)
|
||||||
|
window = (provider.burst[1] if provider.burst else 60)
|
||||||
|
limit = (provider.burst[0] if provider.burst else provider.rate_per_minute)
|
||||||
|
if limit:
|
||||||
|
q, cutoff = _windows[provider.name], time.monotonic() - window
|
||||||
|
while q and q[0] < cutoff:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= limit:
|
||||||
|
return False, "RATE_WINDOW", now + timedelta(seconds=max(1, int(window - (time.monotonic() - q[0]))))
|
||||||
|
return True, None, None
|
||||||
|
|
||||||
|
# ---- accounting --------------------------------------------------------------------------------------------------------------------------------
|
||||||
|
def _remote_quota(self, state: ProviderState, headers: dict):
|
||||||
|
remaining, limit, reset = headers.get("x-ratelimit-remaining"), headers.get("x-ratelimit-limit"), headers.get("x-ratelimit-reset")
|
||||||
|
if remaining is not None and str(remaining).isdigit():
|
||||||
|
state.remaining = int(remaining)
|
||||||
|
state.quota_kind = "REMOTE_QUOTA"
|
||||||
|
if limit and str(limit).isdigit():
|
||||||
|
state.daily_limit = int(limit)
|
||||||
|
if reset and str(reset).isdigit():
|
||||||
|
state.reset_at = datetime.fromtimestamp(int(reset), tz=timezone.utc)
|
||||||
|
|
||||||
|
def _sent(self, provider: Provider):
|
||||||
|
q = _windows[provider.name]
|
||||||
|
q.append(time.monotonic())
|
||||||
|
|
||||||
|
def record_ok(self, provider: Provider, result: ProviderProduct):
|
||||||
|
state, usage, now = self.state(provider), self.usage(provider.name), self.now()
|
||||||
|
self._sent(provider)
|
||||||
|
usage.requests += 1
|
||||||
|
usage.success += 1
|
||||||
|
if not result.found:
|
||||||
|
usage.not_found += 1
|
||||||
|
state.consecutive_failures = 0
|
||||||
|
state.circuit_open_until = None
|
||||||
|
state.status = "ONLINE"
|
||||||
|
state.last_request_at = state.last_success_at = now
|
||||||
|
state.last_latency_ms = result.latency_ms or None
|
||||||
|
self._remote_quota(state, {k.lower(): v for k, v in (result.headers or {}).items()})
|
||||||
|
if provider.daily_limit and state.quota_kind != "REMOTE_QUOTA":
|
||||||
|
state.remaining = max(0, provider.daily_limit - usage.requests)
|
||||||
|
state.reset_at = next_utc_midnight(now)
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def record_error(self, provider: Provider, error: ProviderError):
|
||||||
|
state, usage, now = self.state(provider), self.usage(provider.name), self.now()
|
||||||
|
self._sent(provider)
|
||||||
|
usage.requests += 1
|
||||||
|
state.last_request_at, state.last_error_at = now, now
|
||||||
|
state.last_error = f"{error.kind}: {error.detail}"[:160]
|
||||||
|
state.consecutive_failures += 1
|
||||||
|
headers = {k.lower(): v for k, v in (error.headers or {}).items()}
|
||||||
|
self._remote_quota(state, headers)
|
||||||
|
if error.kind == "RATE_LIMITED":
|
||||||
|
usage.rate_limited += 1
|
||||||
|
state.status = "RATE_LIMITED"
|
||||||
|
wait = timedelta(seconds=error.retry_after) if error.retry_after else None
|
||||||
|
state.circuit_open_until = now + (wait or (state.reset_at and max(timedelta(minutes=5), _aware(state.reset_at) - now) or timedelta(minutes=15)))
|
||||||
|
if state.reset_at is None:
|
||||||
|
state.reset_at = state.circuit_open_until
|
||||||
|
elif error.kind == "AUTH_ERROR":
|
||||||
|
usage.errors += 1
|
||||||
|
state.status = "AUTH_ERROR"
|
||||||
|
state.circuit_open_until = now + timedelta(hours=1)
|
||||||
|
else:
|
||||||
|
if error.kind == "TIMEOUT":
|
||||||
|
usage.timeouts += 1
|
||||||
|
usage.errors += 1
|
||||||
|
state.status = "DEGRADED"
|
||||||
|
if state.consecutive_failures >= FAILURES_TO_OPEN:
|
||||||
|
delay = min(CIRCUIT_MAX, CIRCUIT_BASE * (2 ** (state.consecutive_failures - FAILURES_TO_OPEN)))
|
||||||
|
state.circuit_open_until = now + delay
|
||||||
|
state.status = "OFFLINE"
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def record_cache(self, provider_name: str, hit: bool):
|
||||||
|
usage = self.usage(provider_name)
|
||||||
|
if hit:
|
||||||
|
usage.cache_hits += 1
|
||||||
|
else:
|
||||||
|
usage.cache_misses += 1
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderGateway:
|
||||||
|
"""lookup(provider, gtin): cache -> single-flight -> quota/circuit -> HTTP -> account -> cache."""
|
||||||
|
|
||||||
|
def __init__(self, session, providers: list[Provider], now=utcnow):
|
||||||
|
self.session, self.providers, self.now = session, providers, now
|
||||||
|
self.quota = QuotaManager(session, now)
|
||||||
|
|
||||||
|
def _cached(self, provider: Provider, gtin: str) -> ProviderProduct | None | bool:
|
||||||
|
row = self.session.scalar(select(GtinLookupCache).where(GtinLookupCache.gtin == gtin, GtinLookupCache.provider == provider.name))
|
||||||
|
if row is None or _aware(row.expires_at) <= self.now():
|
||||||
|
return False
|
||||||
|
if not row.found:
|
||||||
|
return None
|
||||||
|
d = row.data or {}
|
||||||
|
return ProviderProduct(provider.name, gtin, True, title=d.get("title"), brand=d.get("brand"), category=d.get("category"), quantity=d.get("quantity"), ncm=d.get("ncm"), cest=d.get("cest"), raw_hash=d.get("raw_hash", ""))
|
||||||
|
|
||||||
|
def _store(self, provider: Provider, gtin: str, product: ProviderProduct):
|
||||||
|
now = self.now()
|
||||||
|
ttl = POSITIVE_TTL if product.found else NEGATIVE_TTL
|
||||||
|
row = self.session.scalar(select(GtinLookupCache).where(GtinLookupCache.gtin == gtin, GtinLookupCache.provider == provider.name))
|
||||||
|
data = {**product.as_data(), "raw_hash": product.raw_hash}
|
||||||
|
if row is None:
|
||||||
|
self.session.add(GtinLookupCache(gtin=gtin, provider=provider.name, found=product.found, data=data, fetched_at=now, expires_at=now + ttl))
|
||||||
|
else:
|
||||||
|
row.found, row.data, row.fetched_at, row.expires_at = product.found, data, now, now + ttl
|
||||||
|
self.session.commit()
|
||||||
|
|
||||||
|
def lookup(self, provider: Provider, gtin: str) -> Outcome:
|
||||||
|
hit = self._cached(provider, gtin)
|
||||||
|
if hit is not False:
|
||||||
|
self.quota.record_cache(provider.name, True)
|
||||||
|
self.session.commit()
|
||||||
|
return Outcome(provider.name, "CACHE_HIT", hit or None, from_cache=True) if hit else Outcome(provider.name, "NOT_FOUND", None, from_cache=True)
|
||||||
|
with _flights_guard:
|
||||||
|
lock = _flights.setdefault((provider.name, gtin), threading.Lock())
|
||||||
|
with lock: # 10 installations, same EAN -> 1 external call, the rest wait for the same answer
|
||||||
|
self.session.expire_all()
|
||||||
|
hit = self._cached(provider, gtin)
|
||||||
|
if hit is not False:
|
||||||
|
self.quota.record_cache(provider.name, True)
|
||||||
|
self.session.commit()
|
||||||
|
return Outcome(provider.name, "CACHE_HIT", hit or None, from_cache=True) if hit else Outcome(provider.name, "NOT_FOUND", None, from_cache=True)
|
||||||
|
self.quota.record_cache(provider.name, False)
|
||||||
|
ok, reason, retry_at = self.quota.allow(provider)
|
||||||
|
if not ok:
|
||||||
|
self.session.commit()
|
||||||
|
return Outcome(provider.name, "SKIPPED", None, reason, retry_at)
|
||||||
|
try:
|
||||||
|
product = provider.lookup_gtin(gtin)
|
||||||
|
except ProviderError as error:
|
||||||
|
self.quota.record_error(provider, error)
|
||||||
|
return Outcome(provider.name, "ERROR", None, error.kind, self.quota.state(provider).circuit_open_until)
|
||||||
|
self.quota.record_ok(provider, product)
|
||||||
|
self._store(provider, gtin, product)
|
||||||
|
return Outcome(provider.name, "FOUND" if product.found else "NOT_FOUND", product if product.found else None)
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Product resolver: GTIN/name -> KB -> providers -> official NCM validation -> existing fiscal engine, decided FIELD BY FIELD.
|
||||||
|
|
||||||
|
Deterministic. No AI, no scores: discrete states only.
|
||||||
|
Resolution states: EXACT_GTIN_VERIFIED, EXACT_KB_NAME_VERIFIED, MULTI_SOURCE_MATCH, SINGLE_WEAK_SOURCE, AMBIGUOUS, CONFLICT, NOT_FOUND
|
||||||
|
(+ operational: INVALID_GTIN, PENDING_PROVIDER_QUOTA, PENDING_PROVIDERS_OFFLINE).
|
||||||
|
Field decisions: AUTO_FILL_SAFE | SUGGESTION_ONLY | CONFLICT | INSUFFICIENT_EVIDENCE | ALREADY_SET | NOT_APPLICABLE.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from ..classification import Classifier, normalize_code
|
||||||
|
from ..datasets.service import DatasetService
|
||||||
|
from ..models import Dataset, NcmEntry
|
||||||
|
from . import kb, textnorm
|
||||||
|
from .gtin import normalize_gtin
|
||||||
|
from .quota import ProviderGateway
|
||||||
|
|
||||||
|
AUTO, SUGGEST, CONFLICT, INSUFFICIENT, ALREADY, NA = "AUTO_FILL_SAFE", "SUGGESTION_ONLY", "CONFLICT", "INSUFFICIENT_EVIDENCE", "ALREADY_SET", "NOT_APPLICABLE"
|
||||||
|
FIELDS = ("CLASSIFICACAO_FISCAL", "CEST", "IPI")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_br(text: str | None) -> date | None:
|
||||||
|
try:
|
||||||
|
d, m, y = str(text).split("/")
|
||||||
|
return date(int(y), int(m), int(d))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ncm_is_official(session, code: str, today: date | None = None) -> bool:
|
||||||
|
"""8-digit code present in the ACTIVE official NCM dataset and vigente today (dd/mm/yyyy, 31/12/9999 = open)."""
|
||||||
|
code = normalize_code(code)
|
||||||
|
if len(code) != 8:
|
||||||
|
return False
|
||||||
|
today = today or date.today()
|
||||||
|
rows = session.scalars(select(NcmEntry).join(Dataset).where(Dataset.kind == "NCM", Dataset.status == "ACTIVE", NcmEntry.code == code)).all()
|
||||||
|
for row in rows:
|
||||||
|
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):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def dataset_versions(session) -> dict:
|
||||||
|
return {d.kind: {"sha256": (d.sha256 or "")[:12], "version": d.version_label} for d in session.scalars(select(Dataset).where(Dataset.status == "ACTIVE"))}
|
||||||
|
|
||||||
|
|
||||||
|
def _field(status, value=None, rule=None, kb_status=None, evidence=None, reason=None):
|
||||||
|
return {"status": status, "value": value, "rule": rule, "kb_status": kb_status, "evidence": evidence or [], "reason": reason}
|
||||||
|
|
||||||
|
|
||||||
|
def _empty(current: dict, name: str) -> bool:
|
||||||
|
return not str(current.get(name) or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _fiscal_decisions(session, ncm_eval: dict, gtin_kb_verified: bool, current: dict, description: str, *, name_verified=False) -> dict:
|
||||||
|
out = {}
|
||||||
|
if not _empty(current, "CLASSIFICACAO_FISCAL"):
|
||||||
|
out["CLASSIFICACAO_FISCAL"] = _field(ALREADY, reason="campo já preenchido")
|
||||||
|
elif ncm_eval["status"] == kb.CONFLICT:
|
||||||
|
out["CLASSIFICACAO_FISCAL"] = _field(CONFLICT, kb_status=kb.CONFLICT, evidence=ncm_eval["candidates"], reason="fontes divergem no NCM")
|
||||||
|
elif ncm_eval["value"] and ncm_eval["status"] == kb.VERIFIED_KB:
|
||||||
|
out["CLASSIFICACAO_FISCAL"] = _field(AUTO, ncm_eval["value"], "NCM_KB_VERIFIED" if gtin_kb_verified or name_verified else "NCM_TWO_INDEPENDENT_SOURCES", kb.VERIFIED_KB, ncm_eval["families"])
|
||||||
|
elif ncm_eval["value"]:
|
||||||
|
out["CLASSIFICACAO_FISCAL"] = _field(SUGGEST, ncm_eval["value"], "NCM_SINGLE_SOURCE", kb.EVIDENCE_ONLY, ncm_eval["families"], "apenas uma fonte independente")
|
||||||
|
else:
|
||||||
|
out["CLASSIFICACAO_FISCAL"] = _field(INSUFFICIENT, reason="nenhuma fonte com NCM válido no dataset oficial")
|
||||||
|
ncm = out["CLASSIFICACAO_FISCAL"]
|
||||||
|
ncm_for_engine = ncm["value"] if ncm["status"] == AUTO else (normalize_code(current.get("CLASSIFICACAO_FISCAL")) or None)
|
||||||
|
if not ncm_for_engine or (ncm["status"] == ALREADY and not ncm_is_official(session, ncm_for_engine)):
|
||||||
|
for name in ("CEST", "IPI"):
|
||||||
|
out[name] = _field(ALREADY if not _empty(current, name) else INSUFFICIENT, reason="depende de um NCM oficial válido")
|
||||||
|
return out
|
||||||
|
decision = Classifier(DatasetService(session)).classify({"current_ncm": ncm_for_engine, "description": description})
|
||||||
|
cest = (decision["classification"].get("cest") or [])
|
||||||
|
if not _empty(current, "CEST"):
|
||||||
|
out["CEST"] = _field(ALREADY, reason="campo já preenchido")
|
||||||
|
elif decision["cest_status"] == "CEST_CONFIRMED" and len(cest) == 1 and ncm["status"] == AUTO:
|
||||||
|
out["CEST"] = _field(AUTO, cest[0], "CEST_OFFICIAL_UNIQUE_MAPPING", ncm["kb_status"], [f"NCM {ncm_for_engine}"])
|
||||||
|
elif decision["cest_status"] == "NO_CEST_MAPPING_FOUND":
|
||||||
|
out["CEST"] = _field(NA, reason="NCM sem mapeamento de CEST no dataset oficial")
|
||||||
|
else:
|
||||||
|
out["CEST"] = _field(INSUFFICIENT, reason=f"CEST não determinístico ({decision['cest_status']})")
|
||||||
|
ipi = decision["classification"].get("ipi") or {}
|
||||||
|
if not _empty(current, "IPI"):
|
||||||
|
out["IPI"] = _field(ALREADY, reason="campo já preenchido")
|
||||||
|
elif decision["ipi_status"] == "IPI_RESOLVED" and ipi.get("rate_kind") == "NUMERIC" and ncm["status"] == AUTO:
|
||||||
|
out["IPI"] = _field(AUTO, str(ipi["rate_numeric"]), "IPI_TIPI_NUMERIC", ncm["kb_status"], [f"TIPI NCM {ncm_for_engine}"])
|
||||||
|
elif decision["ipi_status"] in {"IPI_RESOLVED"}:
|
||||||
|
out["IPI"] = _field(NA, reason="TIPI sem alíquota numérica (NT/branca)")
|
||||||
|
else:
|
||||||
|
out["IPI"] = _field(INSUFFICIENT, reason=f"IPI não determinístico ({decision['ipi_status']})")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(session, gateway: ProviderGateway, *, gtin_raw, description, current: dict | None = None, client_label: str | None = None, allow_external: bool = True) -> dict:
|
||||||
|
"""Returns {state, gtin_status, gtin, fields:{...}, providers:[...], retry_at, dataset_versions}. Never raises for provider trouble."""
|
||||||
|
current = {k: v for k, v in (current or {}).items()}
|
||||||
|
g = normalize_gtin(gtin_raw)
|
||||||
|
result = {"state": "NOT_FOUND", "gtin_status": g["status"], "gtin": g.get("key"), "fields": {}, "providers": [], "retry_at": None, "dataset_versions": dataset_versions(session)}
|
||||||
|
session.info["is_official"] = lambda v: ncm_is_official(session, v)
|
||||||
|
is_official = session.info["is_official"]
|
||||||
|
product, name_verified, key = None, False, g.get("key")
|
||||||
|
|
||||||
|
if g["status"] == "VALID_GTIN": # GTIN has absolute priority over the description
|
||||||
|
product = kb.find_by_gtin(session, key)
|
||||||
|
ev = kb.evaluate(session, product, "ncm", is_official) if product else None
|
||||||
|
if ev and ev["status"] == kb.VERIFIED_KB:
|
||||||
|
result["state"] = "EXACT_GTIN_VERIFIED"
|
||||||
|
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "")
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
query = g["gtin"] if len(g["gtin"]) in (8, 14) else key[1:] # providers/caches use the canonical EAN-13 (UPC-12 is zero-padded to it)
|
||||||
|
found, pending = [], []
|
||||||
|
if allow_external:
|
||||||
|
for provider in gateway.providers:
|
||||||
|
out = gateway.lookup(provider, query)
|
||||||
|
result["providers"].append({"provider": out.provider, "state": out.state, "reason": out.reason, "from_cache": out.from_cache})
|
||||||
|
if out.state in ("FOUND", "CACHE_HIT") and out.product:
|
||||||
|
found.append(out.product)
|
||||||
|
product = kb.upsert_product(session, key, out.product.title or description, brand=out.product.brand, quantity=out.product.quantity)
|
||||||
|
if out.product.ncm:
|
||||||
|
kb.add_evidence(session, product, "ncm", out.product.ncm, f"provider:{out.provider}"[:32], family=f"PROVIDER:{out.provider}", info={"raw_hash": out.product.raw_hash})
|
||||||
|
ev = kb.evaluate(session, product, "ncm", is_official)
|
||||||
|
if ev["status"] == kb.VERIFIED_KB:
|
||||||
|
break # enough evidence: spare the remaining quota
|
||||||
|
elif out.state in ("SKIPPED", "ERROR"):
|
||||||
|
pending.append(out)
|
||||||
|
if product is None and description:
|
||||||
|
product = kb.upsert_product(session, key, description)
|
||||||
|
ev = kb.evaluate(session, product, "ncm", is_official) if product else {"value": None, "status": kb.UNVERIFIED, "families": [], "candidates": [], "rejected": []}
|
||||||
|
result["fields"] = _fiscal_decisions(session, ev, False, current, description or "")
|
||||||
|
if ev["status"] == kb.CONFLICT:
|
||||||
|
result["state"] = "CONFLICT"
|
||||||
|
elif ev["status"] == kb.VERIFIED_KB:
|
||||||
|
result["state"] = "MULTI_SOURCE_MATCH" if found else "EXACT_GTIN_VERIFIED"
|
||||||
|
elif len(found) >= 2:
|
||||||
|
result["state"] = "MULTI_SOURCE_MATCH"
|
||||||
|
elif found:
|
||||||
|
result["state"] = "SINGLE_WEAK_SOURCE"
|
||||||
|
elif pending and not found:
|
||||||
|
quota = any(p.reason in ("RATE_LIMITED", "RATE_WINDOW") for p in pending)
|
||||||
|
offline = [p for p in pending if p.reason not in ("NOT_CONFIGURED",)]
|
||||||
|
if offline:
|
||||||
|
result["state"] = "PENDING_PROVIDER_QUOTA" if quota else "PENDING_PROVIDERS_OFFLINE"
|
||||||
|
result["retry_at"] = min((p.retry_at for p in offline if p.retry_at), default=None)
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
if g["status"] == "INVALID_GTIN":
|
||||||
|
result["state"] = "INVALID_GTIN" # never spends provider quota on an invalid GTIN
|
||||||
|
|
||||||
|
# ---- name only: only a UNIQUE, specific description with a VERIFIED_KB fingerprint may fill anything
|
||||||
|
if not textnorm.is_specific(description):
|
||||||
|
if result["state"] != "INVALID_GTIN":
|
||||||
|
result["state"] = "AMBIGUOUS"
|
||||||
|
result["fields"] = {f: _field(INSUFFICIENT, reason="descrição genérica demais e sem GTIN válido") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
|
||||||
|
return result
|
||||||
|
candidates = kb.name_candidates(session, description)
|
||||||
|
verified = []
|
||||||
|
for cand in candidates:
|
||||||
|
ev = kb.evaluate(session, cand, "ncm", is_official)
|
||||||
|
if ev["status"] == kb.VERIFIED_KB:
|
||||||
|
verified.append((cand, ev))
|
||||||
|
if candidates and len(verified) == len(candidates) and len({ev["value"] for _, ev in verified}) == 1:
|
||||||
|
_, ev = verified[0]
|
||||||
|
result["state"] = "EXACT_KB_NAME_VERIFIED"
|
||||||
|
result["fields"] = _fiscal_decisions(session, ev, True, current, description or "", name_verified=True)
|
||||||
|
elif len({ev["value"] for _, ev in verified}) > 1:
|
||||||
|
result["state"] = "CONFLICT"
|
||||||
|
result["fields"] = {f: _field(CONFLICT, reason="descrição corresponde a produtos com NCM divergentes") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
|
||||||
|
else:
|
||||||
|
if result["state"] != "INVALID_GTIN":
|
||||||
|
result["state"] = "AMBIGUOUS" if len(candidates) > 1 else "NOT_FOUND"
|
||||||
|
result["fields"] = {f: _field(INSUFFICIENT, reason="sem correspondência verificada na base de conhecimento") if _empty(current, f) else _field(ALREADY) for f in FIELDS}
|
||||||
|
session.commit()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Deterministic product-description normalization and fingerprint (no ML). Used to FIND candidates; never authorizes a fiscal value on its own."""
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
UNIT_MAP = {"LT": "L", "LTS": "L", "LITRO": "L", "LITROS": "L", "L": "L", "ML": "ML", "KG": "KG", "KGS": "KG", "QUILO": "KG", "G": "G", "GR": "G", "GRS": "G", "GRAMAS": "G", "MG": "MG", "UN": "UN", "UND": "UN", "UNID": "UN", "UNIDADE": "UN", "PCT": "PCT", "CX": "CX"}
|
||||||
|
ABBREVIATIONS = {"REFRIG": "REFRIGERANTE", "REFRI": "REFRIGERANTE", "CHOC": "CHOCOLATE", "BISC": "BISCOITO", "SAB": "SABONETE", "DET": "DETERGENTE", "SH": "SHAMPOO", "COND": "CONDICIONADOR",
|
||||||
|
"MACARR": "MACARRAO", "MAC": "MACARRAO", "AMAC": "AMACIANTE", "DESOD": "DESODORANTE", "PAP": "PAPEL", "HIG": "HIGIENICO", "ORIG": "ORIGINAL", "TRAD": "TRADICIONAL"}
|
||||||
|
STOP = {"DE", "DA", "DO", "DAS", "DOS", "E", "COM", "C", "PARA", "P", "EM", "A", "O"}
|
||||||
|
_QTY = re.compile(r"^(\d+(?:[.,]\d+)?)(ML|L|KG|G|MG|UN|PCT|CX|LT|LTS|GR)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _ascii_upper(text) -> str:
|
||||||
|
return unicodedata.normalize("NFKD", str(text or "")).encode("ascii", "ignore").decode().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def tokens(description) -> list[str]:
|
||||||
|
text = _ascii_upper(description)
|
||||||
|
text = re.sub(r"(\d)([A-Z])", r"\1 \2", text) # 350ML -> 350 ML
|
||||||
|
text = re.sub(r"([A-Z])(\d)", r"\1 \2", text)
|
||||||
|
text = re.sub(r"(\d)[,.](\d)", r"\1.\2", text) # decimal comma -> dot (kept inside the number)
|
||||||
|
text = re.sub(r"[^A-Z0-9.]+", " ", text)
|
||||||
|
out = []
|
||||||
|
for tok in text.split():
|
||||||
|
tok = tok.strip(".")
|
||||||
|
if not tok or tok in STOP:
|
||||||
|
continue
|
||||||
|
tok = UNIT_MAP.get(tok, ABBREVIATIONS.get(tok, tok))
|
||||||
|
out.append(tok)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalized(description) -> str:
|
||||||
|
return " ".join(tokens(description))
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(description) -> str | None:
|
||||||
|
toks = tokens(description)
|
||||||
|
if not toks:
|
||||||
|
return None
|
||||||
|
return hashlib.sha256(" ".join(sorted(toks)).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def is_specific(description) -> bool:
|
||||||
|
"""A description strong enough to be used as an identity key: at least 3 informative tokens AND a quantity (number + unit), or >= 4 tokens.
|
||||||
|
Generic names (PIPOCA, OLEO, REFRIGERANTE, SABONETE) are NEVER specific."""
|
||||||
|
toks = tokens(description)
|
||||||
|
has_qty = any(re.fullmatch(r"\d+(?:\.\d+)?", t) and i + 1 < len(toks) and toks[i + 1] in UNIT_MAP.values() for i, t in enumerate(toks))
|
||||||
|
informative = [t for t in toks if not re.fullmatch(r"\d+(?:\.\d+)?", t) and t not in UNIT_MAP.values()]
|
||||||
|
return (len(informative) >= 2 and has_qty and len(toks) >= 3) or len(informative) >= 4
|
||||||
@@ -285,6 +285,11 @@ class Installation(Base):
|
|||||||
# RTC auto-apply is authorized centrally per installation and is OFF until an operator explicitly turns it on (the Agent has its own second switch).
|
# RTC auto-apply is authorized centrally per installation and is OFF until an operator explicitly turns it on (the Agent has its own second switch).
|
||||||
rtc_auto_apply_authorized: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # legacy (0027); rtc_write_mode is the source of truth
|
rtc_auto_apply_authorized: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") # legacy (0027); rtc_write_mode is the source of truth
|
||||||
rtc_write_mode: Mapped[str] = mapped_column(String(24), default="DISABLED", server_default="DISABLED") # DISABLED | DRY_RUN | APPROVAL_REQUIRED | ENABLED
|
rtc_write_mode: Mapped[str] = mapped_column(String(24), default="DISABLED", server_default="DISABLED") # DISABLED | DRY_RUN | APPROVAL_REQUIRED | ENABLED
|
||||||
|
# Per-installation FUNCTIONS (central, no local switch). Existing installations keep working exactly as before (corrector on, features configured);
|
||||||
|
# a NEW installation starts WAITING_FEATURE_CONFIGURATION (features_configured=False): it heartbeats but processes nothing until an admin chooses.
|
||||||
|
corrector_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="true")
|
||||||
|
enrichment_mode: Mapped[str] = mapped_column(String(16), default="DISABLED", server_default="DISABLED") # DISABLED | DRY_RUN | ENABLED
|
||||||
|
features_configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="true")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
@@ -447,6 +452,10 @@ class AgentEnrollment(Base):
|
|||||||
hostname_seen: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
hostname_seen: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
hostname_mismatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
hostname_mismatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
agent_id: Mapped[int | None] = mapped_column(ForeignKey("agents.id"), nullable=True, index=True)
|
agent_id: Mapped[int | None] = mapped_column(ForeignKey("agents.id"), nullable=True, index=True)
|
||||||
|
# Functions chosen when the installation was created (copied to the Installation at enrollment). Nothing chosen => WAITING_FEATURE_CONFIGURATION.
|
||||||
|
features_selected: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
feature_corrector: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
feature_enrichment_mode: Mapped[str] = mapped_column(String(16), default="DISABLED", server_default="DISABLED")
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
@@ -620,6 +629,7 @@ class KbKnowledge(Base):
|
|||||||
dataset_versions: Mapped[dict] = mapped_column(JSON, default=dict)
|
dataset_versions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
validated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
validated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="EVIDENCE_ONLY", server_default="EVIDENCE_ONLY", index=True) # UNVERIFIED | EVIDENCE_ONLY | VERIFIED_KB | CONFLICT | STALE
|
||||||
|
|
||||||
|
|
||||||
class KbObservation(Base):
|
class KbObservation(Base):
|
||||||
@@ -786,3 +796,114 @@ class RtcCandidate(Base):
|
|||||||
basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
basis: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(16), default="PENDING", server_default="PENDING", index=True) # PENDING | APPROVED | REJECTED | APPLIED | STALE
|
status: Mapped[str] = mapped_column(String(16), default="PENDING", server_default="PENDING", index=True) # PENDING | APPROVED | REJECTED | APPLIED | STALE
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProductEnrichment(Base):
|
||||||
|
"""Per (installation, product) state of the new-product enrichment. Historical products never get a row (no mass provider use)."""
|
||||||
|
__tablename__ = "product_enrichments"
|
||||||
|
__table_args__ = (UniqueConstraint("installation_id", "product_code", name="uq_product_enrichment"),)
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||||
|
installation_id: Mapped[int] = mapped_column(ForeignKey("installations.id"), index=True)
|
||||||
|
product_code: Mapped[str] = mapped_column(String(64))
|
||||||
|
gtin: Mapped[str | None] = mapped_column(String(16), nullable=True, index=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="PENDING", server_default="PENDING", index=True)
|
||||||
|
# PENDING | RESOLVED | APPLIED | PARTIAL | NOT_RESOLVED | CONFLICT | PENDING_PROVIDER_QUOTA | PENDING_PROVIDERS_OFFLINE | INVALID_GTIN
|
||||||
|
decisions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
fields_applied: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
human_overridden: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
|
||||||
|
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProductFieldAudit(Base):
|
||||||
|
"""Append-only audit of every field the enrichment wrote (or refused to). Never holds secrets."""
|
||||||
|
__tablename__ = "product_field_audits"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
|
||||||
|
installation_id: Mapped[int] = mapped_column(ForeignKey("installations.id"), index=True)
|
||||||
|
agent_id: Mapped[int] = mapped_column(ForeignKey("agents.id"), index=True)
|
||||||
|
product_code: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
gtin: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
description_snapshot: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
field: Mapped[str] = mapped_column(String(48))
|
||||||
|
old_value: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
new_value: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
rule: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
kb_status: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
evidence: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
dataset_versions: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
authorization_origin: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||||
|
job_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
outcome: Mapped[str] = mapped_column(String(24), default="APPLIED", server_default="APPLIED") # APPLIED | NO_WRITE_REQUIRED | ABORTED | HUMAN_OVERRIDDEN
|
||||||
|
reason: Mapped[str | None] = mapped_column(String(96), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderState(Base):
|
||||||
|
"""Live state of each external product provider (owned by the Hub: quota belongs to the Hub, never to Agents)."""
|
||||||
|
__tablename__ = "provider_states"
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
configured: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="NOT_CONFIGURED", server_default="NOT_CONFIGURED") # ONLINE | DEGRADED | RATE_LIMITED | AUTH_ERROR | OFFLINE | NOT_CONFIGURED
|
||||||
|
quota_kind: Mapped[str] = mapped_column(String(16), default="DOCUMENTED_RATE", server_default="DOCUMENTED_RATE") # REMOTE_QUOTA | LOCAL_ESTIMATE | DOCUMENTED_RATE
|
||||||
|
daily_limit: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
remaining: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
rate_per_minute: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
circuit_open_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
consecutive_failures: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
last_request_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||||
|
last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderUsageDaily(Base):
|
||||||
|
__tablename__ = "provider_usage_daily"
|
||||||
|
__table_args__ = (UniqueConstraint("provider", "day", name="uq_provider_usage_day"),)
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), index=True)
|
||||||
|
day: Mapped[str] = mapped_column(String(10), index=True)
|
||||||
|
requests: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
success: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
not_found: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
errors: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
rate_limited: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
timeouts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
cache_hits: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
cache_misses: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
|
|
||||||
|
|
||||||
|
class GtinLookupCache(Base):
|
||||||
|
"""Positive and negative (NOT_FOUND) provider results per GTIN, with TTL. Single place that spares quota across all installations."""
|
||||||
|
__tablename__ = "gtin_lookup_cache"
|
||||||
|
__table_args__ = (UniqueConstraint("gtin", "provider", name="uq_gtin_lookup_cache"),)
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
gtin: Mapped[str] = mapped_column(String(16), index=True)
|
||||||
|
provider: Mapped[str] = mapped_column(String(32))
|
||||||
|
found: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||||
|
fetched_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRefreshRun(Base):
|
||||||
|
"""Observability of official fiscal sources (NCM Siscomex, ...): one row per refresh attempt."""
|
||||||
|
__tablename__ = "source_refresh_runs"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(32), index=True)
|
||||||
|
source_url: Mapped[str] = mapped_column(Text)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
status: Mapped[str] = mapped_column(String(16), default="RUNNING", server_default="RUNNING") # RUNNING | UPDATED | UNCHANGED | FAILED
|
||||||
|
sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
version_label: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
record_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
error: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""Product enrichment resolver: 12 resolution scenarios, quota/cache/circuit, provider fixtures. No internet."""
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from central.app.enrichment import kb
|
||||||
|
from central.app.enrichment.gtin import check_digit_ok
|
||||||
|
from central.app.enrichment.providers import Cosmos, OpenFoodFacts, Provider, ProviderError, ProviderProduct, UpcItemDb, build_providers
|
||||||
|
from central.app.enrichment.quota import ProviderGateway
|
||||||
|
from central.app.enrichment.resolver import resolve
|
||||||
|
from central.app.models import (Base, CestEntry, CestNcmEntry, Dataset, GtinLookupCache, KbKnowledge, NcmEntry, ProviderUsageDaily, TipiEntry)
|
||||||
|
|
||||||
|
|
||||||
|
def gtin(body12: str) -> str:
|
||||||
|
total = sum(int(d) * (3 if i % 2 == 0 else 1) for i, d in enumerate(reversed(body12)))
|
||||||
|
return body12 + str((10 - total % 10) % 10)
|
||||||
|
|
||||||
|
|
||||||
|
G1, G2, G3, G4 = gtin("789100010010"), gtin("789100010020"), gtin("789100010030"), gtin("789100010040")
|
||||||
|
|
||||||
|
|
||||||
|
class Fake(Provider):
|
||||||
|
def __init__(self, name, data=None, ncm_provider=False, error=None, daily_limit=None):
|
||||||
|
super().__init__(client=httpx.Client())
|
||||||
|
self.name, self.data, self.provides_ncm, self.error, self.calls = name, data or {}, ncm_provider, error, 0
|
||||||
|
self.daily_limit = daily_limit
|
||||||
|
|
||||||
|
def lookup_gtin(self, g):
|
||||||
|
self.calls += 1
|
||||||
|
if self.error:
|
||||||
|
raise self.error
|
||||||
|
d = self.data.get(g) or self.data.get(g.lstrip('0'))
|
||||||
|
return ProviderProduct(self.name, g, True, **d) if d else ProviderProduct(self.name, g, False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env():
|
||||||
|
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
session = Session(engine)
|
||||||
|
ncm = Dataset(kind="NCM", provider="Siscomex", sha256="a" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=3)
|
||||||
|
session.add(ncm)
|
||||||
|
session.flush()
|
||||||
|
for code in ("22021000", "19059090", "21069090"):
|
||||||
|
session.add(NcmEntry(dataset_id=ncm.id, code=code, description="x", valid_from="01/04/2022", valid_to="31/12/9999"))
|
||||||
|
session.add(NcmEntry(dataset_id=ncm.id, code="11111111", description="expired", valid_from="01/01/2000", valid_to="31/12/2001"))
|
||||||
|
tipi = Dataset(kind="TIPI_IPI", provider="RFB", sha256="b" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||||
|
cest = Dataset(kind="CEST", provider="CONFAZ", sha256="c" * 64, version_label="v", act="a", source_url="u", status="ACTIVE", record_count=1)
|
||||||
|
session.add_all([tipi, cest])
|
||||||
|
session.flush()
|
||||||
|
session.add(TipiEntry(dataset_id=tipi.id, ncm="22021000", ex=None, description="x", ipi_rate=0.0, ipi_rate_text="0", source_row=1))
|
||||||
|
entry = CestEntry(dataset_id=cest.id, code="03.007.00", description="REFRIGERANTE COLA LATA 350ML refrigerantes", annex="I")
|
||||||
|
session.add(entry)
|
||||||
|
session.flush()
|
||||||
|
session.add(CestNcmEntry(cest_entry_id=entry.id, ncm_code="22021000"))
|
||||||
|
session.commit()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run(session, providers, g, desc, current=None, **kw):
|
||||||
|
return resolve(session, ProviderGateway(session, providers), gtin_raw=g, description=desc, current=current or {}, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
COSMOS = lambda ncm="22021000": {G1: dict(title="REFRIGERANTE COLA LATA 350ML", brand="X", ncm=ncm)}
|
||||||
|
UPC = {G1: dict(title="Refrigerante Cola Lata 350ml", brand="X")}
|
||||||
|
|
||||||
|
|
||||||
|
def test_1_gtin_verified_kb_no_provider_call(env):
|
||||||
|
p = kb.upsert_product(env, kb.gtin_fingerprint("0" + G1), "COLA", brand=None) if False else kb.upsert_product(env, "0" + G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "provider:cosmos", family="PROVIDER:cosmos")
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "provider:other", family="PROVIDER:other")
|
||||||
|
env.commit()
|
||||||
|
fake = Fake("cosmos", COSMOS(), True)
|
||||||
|
r = run(env, [fake], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r["state"] == "EXACT_GTIN_VERIFIED" and fake.calls == 0
|
||||||
|
f = r["fields"]
|
||||||
|
assert f["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE" and f["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||||
|
assert f["CEST"]["value"] == "03.007.00" and f["IPI"]["value"] == "0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def test_2_two_independent_providers_agree_multi_source(env):
|
||||||
|
a, b = Fake("cosmos", COSMOS(), True), Fake("other", {G1: dict(title="REFRIGERANTE COLA LATA 350ML", ncm="22021000")}, True)
|
||||||
|
r = run(env, [a, b], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r["state"] == "MULTI_SOURCE_MATCH"
|
||||||
|
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE"
|
||||||
|
# promoted: a second installation is served from the KB alone
|
||||||
|
a2 = Fake("cosmos", COSMOS(), True)
|
||||||
|
r2 = run(env, [a2], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r2["state"] == "EXACT_GTIN_VERIFIED" and a2.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_3_single_source_ncm_is_suggestion_only(env):
|
||||||
|
r = run(env, [Fake("cosmos", COSMOS(), True)], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "SUGGESTION_ONLY"
|
||||||
|
assert r["fields"]["CEST"]["status"] == "INSUFFICIENT_EVIDENCE" and r["fields"]["IPI"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_4_identity_only_sources_never_give_ncm(env):
|
||||||
|
r = run(env, [Fake("upcitemdb", UPC), Fake("openfoodfacts", UPC)], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r["state"] == "MULTI_SOURCE_MATCH"
|
||||||
|
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_5_conflicting_ncm_zero_write(env):
|
||||||
|
a, b = Fake("cosmos", COSMOS("22021000"), True), Fake("other", COSMOS("19059090"), True)
|
||||||
|
r = run(env, [a, b], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r["state"] == "CONFLICT"
|
||||||
|
assert all(v["status"] != "AUTO_FILL_SAFE" for v in r["fields"].values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_6_ncm_not_in_official_dataset_rejected(env):
|
||||||
|
a, b = Fake("cosmos", COSMOS("99999999"), True), Fake("other", COSMOS("99999999"), True)
|
||||||
|
r = run(env, [a, b], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||||
|
a, b = Fake("cosmos", COSMOS("11111111"), True), Fake("other", COSMOS("11111111"), True) # expired NCM is not vigente
|
||||||
|
assert run(env, [a, b], gtin("789100010050"), "X")["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_7_invalid_gtin_never_calls_providers(env):
|
||||||
|
fake = Fake("cosmos", COSMOS(), True)
|
||||||
|
r = run(env, [fake], G1[:-1] + str((int(G1[-1]) + 1) % 10), "PIPOCA")
|
||||||
|
assert r["gtin_status"] == "INVALID_GTIN" and r["state"] == "INVALID_GTIN" and fake.calls == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_8_gtin_not_found_anywhere(env):
|
||||||
|
r = run(env, [Fake("cosmos", {}, True), Fake("upcitemdb")], G2, "PRODUTO QUALQUER")
|
||||||
|
assert r["state"] == "NOT_FOUND" and r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "INSUFFICIENT_EVIDENCE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_9_name_only_exact_verified_fingerprint(env):
|
||||||
|
p = kb.upsert_product(env, "0" + G1, "REFRIGERANTE COLA ORIGINAL LATA 350 ML")
|
||||||
|
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "x", family=fam)
|
||||||
|
env.commit()
|
||||||
|
r = run(env, [Fake("cosmos")], "", "REFRIGERANTE COLA ORIGINAL LATA 350ML")
|
||||||
|
assert r["state"] == "EXACT_KB_NAME_VERIFIED" and r["fields"]["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_10_generic_name_never_fills(env):
|
||||||
|
p = kb.upsert_product(env, "0" + G1, "REFRIGERANTE")
|
||||||
|
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "x", family=fam)
|
||||||
|
env.commit()
|
||||||
|
r = run(env, [], "", "REFRIGERANTE")
|
||||||
|
assert r["state"] == "AMBIGUOUS" and all(v["status"] != "AUTO_FILL_SAFE" for v in r["fields"].values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_11_name_matching_different_ncms_is_conflict(env):
|
||||||
|
for g, ncm in ((G1, "22021000"), (G3, "21069090")):
|
||||||
|
p = kb.upsert_product(env, "0" + g, "SUCO NATURAL LARANJA GARRAFA 1L")
|
||||||
|
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||||
|
kb.add_evidence(env, p, "ncm", ncm, "x", family=fam)
|
||||||
|
env.commit()
|
||||||
|
r = run(env, [], "", "SUCO NATURAL LARANJA GARRAFA 1L")
|
||||||
|
assert r["state"] == "CONFLICT" and all(v["status"] == "CONFLICT" for v in r["fields"].values())
|
||||||
|
|
||||||
|
|
||||||
|
def test_12_already_filled_fields_are_never_touched(env):
|
||||||
|
p = kb.upsert_product(env, "0" + G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
for fam in ("PROVIDER:a", "PROVIDER:b"):
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "x", family=fam)
|
||||||
|
env.commit()
|
||||||
|
r = run(env, [], G1, "REFRIGERANTE COLA LATA 350ML", current={"CLASSIFICACAO_FISCAL": "19059090", "CEST": "", "IPI": None})
|
||||||
|
assert r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "ALREADY_SET"
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_consensus_needs_two_distinct_clients(env):
|
||||||
|
p = kb.upsert_product(env, "0" + G1, "X")
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "client", family=kb.CLIENT_FAMILY, client_label="A")
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "client", family=kb.CLIENT_FAMILY, client_label="A")
|
||||||
|
assert kb.evaluate(env, p, "ncm", lambda v: True)["status"] == kb.EVIDENCE_ONLY
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "client", family=kb.CLIENT_FAMILY, client_label="B")
|
||||||
|
assert kb.evaluate(env, p, "ncm", lambda v: True)["status"] == kb.EVIDENCE_ONLY # consensus alone is one family, never authority
|
||||||
|
kb.add_evidence(env, p, "ncm", "22021000", "provider", family="PROVIDER:cosmos")
|
||||||
|
assert kb.evaluate(env, p, "ncm", lambda v: True)["status"] == kb.VERIFIED_KB
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_spares_quota_across_installations(env):
|
||||||
|
fake = Fake("upcitemdb", UPC)
|
||||||
|
for _ in range(5):
|
||||||
|
run(env, [fake], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert fake.calls == 1
|
||||||
|
usage = env.scalar(select(ProviderUsageDaily).where(ProviderUsageDaily.provider == "upcitemdb"))
|
||||||
|
assert usage.requests == 1 and usage.cache_hits == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative_cache(env):
|
||||||
|
fake = Fake("upcitemdb")
|
||||||
|
for _ in range(3):
|
||||||
|
run(env, [fake], G2, "PRODUTO X")
|
||||||
|
assert fake.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_quota_exhausted_pending_with_retry(env):
|
||||||
|
fake = Fake("cosmos", COSMOS(), True, daily_limit=1)
|
||||||
|
run(env, [fake], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
r = run(env, [fake], G3, "OUTRO PRODUTO")
|
||||||
|
assert r["state"] == "PENDING_PROVIDER_QUOTA" and r["retry_at"] is not None and fake.calls == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_circuit_breaker_and_timeout_is_not_a_crash(env):
|
||||||
|
fake = Fake("upcitemdb", error=ProviderError("TIMEOUT", "x"))
|
||||||
|
states = [run(env, [fake], gtin(f"78910001{i:04d}"), "P")["state"] for i in range(6)]
|
||||||
|
assert fake.calls == 3 # circuit opened after 3 failures, no more calls
|
||||||
|
assert set(states) == {"PENDING_PROVIDERS_OFFLINE"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cosmos_without_token_is_not_configured_and_costs_nothing(env):
|
||||||
|
cosmos = Cosmos(httpx.Client(transport=httpx.MockTransport(lambda r: pytest.fail("no call expected"))), None)
|
||||||
|
r = run(env, [cosmos], G1, "REFRIGERANTE COLA LATA 350ML")
|
||||||
|
assert r["providers"][0]["reason"] == "NOT_CONFIGURED" and r["state"] == "NOT_FOUND"
|
||||||
|
|
||||||
|
|
||||||
|
def test_paid_provider_guard():
|
||||||
|
class S: allow_paid_providers = False; cosmos_token = None
|
||||||
|
assert [p.name for p in build_providers(S())] == ["cosmos", "upcitemdb", "openfoodfacts"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---- provider contract tests against recorded fixtures (httpx.MockTransport, no network) ----------------------------------------------------------
|
||||||
|
def _client(handler):
|
||||||
|
return httpx.Client(transport=httpx.MockTransport(handler))
|
||||||
|
|
||||||
|
|
||||||
|
def test_upcitemdb_contract():
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen["url"] = str(request.url)
|
||||||
|
return httpx.Response(200, json={"code": "OK", "total": 1, "items": [{"ean": G1, "title": "Cola 350ml", "brand": "X", "category": "Beverages", "size": "350ml"}]},
|
||||||
|
headers={"X-RateLimit-Limit": "100", "X-RateLimit-Remaining": "99", "X-RateLimit-Reset": "1900000000"})
|
||||||
|
p = UpcItemDb(_client(handler)).lookup_gtin(G1)
|
||||||
|
assert p.found and p.title == "Cola 350ml" and p.ncm is None and "trial/lookup" in seen["url"]
|
||||||
|
assert p.headers["x-ratelimit-remaining"] == "99"
|
||||||
|
assert not UpcItemDb(_client(lambda r: httpx.Response(200, json={"code": "OK", "total": 0, "items": []}))).lookup_gtin(G1).found
|
||||||
|
|
||||||
|
|
||||||
|
def test_openfoodfacts_contract_and_user_agent():
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen["ua"] = request.headers.get("user-agent")
|
||||||
|
return httpx.Response(200, json={"status": 1, "product": {"product_name": "Biscoito", "brands": "Marca, Outra", "quantity": "130 g"}})
|
||||||
|
off = OpenFoodFacts(httpx.Client(transport=httpx.MockTransport(handler), headers={"User-Agent": "GTIFiscalHub/1.0 (+contact)"}))
|
||||||
|
p = off.lookup_gtin(G1)
|
||||||
|
assert p.found and p.brand == "Marca" and "GTIFiscalHub" in seen["ua"]
|
||||||
|
assert not OpenFoodFacts(_client(lambda r: httpx.Response(200, json={"status": 0, "status_verbose": "product not found"}))).lookup_gtin(G1).found
|
||||||
|
|
||||||
|
|
||||||
|
def test_cosmos_contract_and_errors():
|
||||||
|
body = {"description": "COCA COLA 350ML", "brand": {"name": "Coca"}, "ncm": {"code": "2202.10.00"}, "cest": {"code": "03.007.00"}}
|
||||||
|
p = Cosmos(_client(lambda r: httpx.Response(200, json=body)), "tok").lookup_gtin(G1)
|
||||||
|
assert p.ncm == "22021000" and p.cest == "0300700" and p.brand == "Coca"
|
||||||
|
assert not Cosmos(_client(lambda r: httpx.Response(404, json={})), "tok").lookup_gtin(G1).found
|
||||||
|
for status, kind in ((429, "RATE_LIMITED"), (401, "AUTH_ERROR"), (503, "SERVER_ERROR")):
|
||||||
|
with pytest.raises(ProviderError) as e:
|
||||||
|
Cosmos(_client(lambda r, s=status: httpx.Response(s)), "tok").lookup_gtin(G1)
|
||||||
|
assert e.value.kind == kind
|
||||||
|
def boom(request): raise httpx.ReadTimeout("t")
|
||||||
|
with pytest.raises(ProviderError) as e:
|
||||||
|
Cosmos(_client(boom), "tok").lookup_gtin(G1)
|
||||||
|
assert e.value.kind == "TIMEOUT"
|
||||||
Reference in New Issue
Block a user