Funcionalidades (Enriquecimento de produtos novos)
Decididas no Hub para esta instalação — o Agent não tem chave local. O Enriquecimento de produtos novos NÃO é o Preenchimento cClassTrib (RTC), que tem controle próprio em Controle e candidatos cClassTrib.
{% if features.waiting %}
Aguardando configuração de funcionalidades: esta instalação ainda não processa nada.
{% endif %}
Corretor fiscal
{{ "Ativo" if features.corrector_enabled else "Desativado" }}
Enriquecimento de produtos novos
{{ features.state_label }}
{% if is_admin %}{% else %}
Somente administradores alteram as funcionalidades.
{% endif %}
Enriquecimento
Produtos novos cadastrados no SGV; o catálogo histórico nunca é enviado a fontes externas.
Evidência local, histórica e externa; nada vira verdade fiscal sem regra de promoção.
{{ kb.products }}Produtos conhecidos
{{ kb.verified }}GTINs verificados
{{ kb.corroborated }}Corroborados
{{ kb.conflicts }}Conflitos
{{ kb.hits_today }}Resolvidos pela base hoje
+
+{% for c in kb.search %}
{{ c.gtin }} {{ c.description }} · {{ c.status }}
+{% for x in c.candidates %}NCM {{ x.ncm }} — {{ x.official_text or "fora do dataset oficial vigente" }} ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}{% if x.attested %}, decidido por {{ x.attested }}{% endif %}){% if is_admin and x.official_text and c.status != "VERIFIED_KB" %}
+{% endif %} {% endfor %}
{% endfor %}
{% for c in kb.recent_conflicts %}
{{ c.gtin }} {{ c.description }}NCMs divergentes, nada é preenchido: {% for x in c.candidates %}{{ x.ncm }} ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}){% if not loop.last %} × {% endif %}{% endfor %}
{% endfor %}
Enriquecimento de produtos novos
Cobertura medida sobre os produtos novos acompanhados (o catálogo histórico não entra).
{% if enrich_metrics.tracked %}
{% for key,label in [("GTIN_VALID_RATE","Com GTIN válido"),("KB_HIT_RATE","Resolvidos pela base"),("PRODUCT_FOUND_RATE","Produto identificado"),("NCM_AUTO_FILL_SAFE_RATE","NCM preenchível com segurança"),("AMBIGUOUS_RATE","Ambíguos"),("CONFLICT_RATE","Em conflito")] %}
{{ enrich_metrics[key] if enrich_metrics[key] is not none else "—" }}{% if enrich_metrics[key] is not none %}%{% endif %}{{ label }}
{% endfor %}
{% else %}
Nenhum produto novo acompanhado ainda.
{% endif %}
diff --git a/central/tests/test_enrichment_reeval.py b/central/tests/test_enrichment_reeval.py
new file mode 100644
index 0000000..496d7c7
--- /dev/null
+++ b/central/tests/test_enrichment_reeval.py
@@ -0,0 +1,141 @@
+"""Visible status, re-evaluation (panel / Reprocessar / Sincronizar / KB decision), operator attestation, deterministic name-only match, RTC != Enrichment."""
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from central.app.enrichment import api as enrich_api, history, kb, status
+from central.app.enrichment.quota import ProviderGateway
+from central.app.enrichment.resolver import resolve
+from central.app.jobs import create_job
+from central.app.models import Agent, AuditEvent, Installation, KbKnowledge, KbProduct, KbPromotionAudit, ProductEnrichment
+from central.tests.test_control_panel_pages import _panel_client
+from central.tests.test_enrichment_history import G, run as seed_history, staging
+from central.tests.test_product_enrichment_api import H, _second_agent, resolve as api_resolve, setup
+from central.tests.test_product_enrichment_resolver import COSMOS, Fake, G1, env, gtin # noqa: F401
+from central.tests.test_rtc_security import _become, _post
+
+
+def _kb_conflict(env):
+ seed_history(env, [staging("RINCAO", G1, "19059090", years={2024: 900, 2025: 900}, desc="COCA LATA 350 ML"), staging("UNIAO", G1, "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA LATA 350 ML")])
+
+
+def test_attestation_resolves_a_conflict_with_audit_and_only_among_candidates(env):
+ _kb_conflict(env)
+ cur = history.official_sets(env)
+ p = env.scalar(select(KbProduct).where(KbProduct.gtin == G))
+ assert kb.evaluate(env, p, "ncm", lambda v: v in cur)["status"] == kb.CONFLICT
+ for bad, why in (("21069090", "NCM_IS_NOT_A_CANDIDATE_OF_THIS_GTIN"), ("22030000x", "NCM_IS_NOT_A_CANDIDATE_OF_THIS_GTIN")):
+ try:
+ kb.attest(env, G, bad, "Rafael", lambda v: v in cur)
+ assert False
+ except ValueError as error:
+ assert str(error) == why
+ res = kb.attest(env, G, "22021000", "Rafael", lambda v: v in cur, note="lata 350ml = refrigerante")
+ assert res["status"] == kb.VERIFIED_KB and res["value"] == "22021000" and set(res["families"]) == {"OPERATOR_ATTESTATION", "CLIENT_DATA"}
+ audit = env.scalars(select(KbPromotionAudit)).one()
+ assert audit.sources["operator"] == "Rafael" and audit.new_status == "VERIFIED_KB"
+ rejected = env.scalar(select(KbKnowledge).where(KbKnowledge.knowledge_key == "ncm", KbKnowledge.value == "19059090"))
+ assert rejected.status == kb.STALE and rejected.evidence["info"]["historical_state"] == "REJECTED_BY_OPERATOR"
+
+
+def test_name_only_match_is_deterministic_and_conservative(env):
+ _kb_conflict(env)
+ cur = history.official_sets(env)
+ kb.attest(env, G, "22021000", "Rafael", lambda v: v in cur)
+ zero = gtin("789490070001")
+ seed_history(env, [staging("UNIAO", zero, "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA ZERO LATA 350 ML", code="9")])
+ env.commit()
+ r = resolve(env, ProviderGateway(env, []), gtin_raw="", description="COCA-COLA ORIGINAL LATA 350 ML", current={"CLASSIFICACAO_FISCAL": "00000000"})
+ assert r["state"] == "EXACT_KB_NAME_VERIFIED" and r["fields"]["CLASSIFICACAO_FISCAL"]["status"] == "AUTO_FILL_SAFE" and r["fields"]["CLASSIFICACAO_FISCAL"]["value"] == "22021000"
+ ambiguous = resolve(env, ProviderGateway(env, []), gtin_raw="", description="COCA COLA ZERO LATA 350 ML", current={})
+ assert ambiguous["fields"]["CLASSIFICACAO_FISCAL"]["status"] != "AUTO_FILL_SAFE" # ZERO is not a neutral extra word: no cross-match
+ generic = resolve(env, ProviderGateway(env, []), gtin_raw="", description="REFRIGERANTE", current={})
+ assert generic["state"] == "AMBIGUOUS"
+ assert not kb.alias_match(frozenset({"COCA", "COLA", "LATA", "350", "ML"}), frozenset({"COCA", "COLA", "ZERO", "LATA", "350", "ML"}))
+ assert kb.alias_match(frozenset({"COCA", "COLA", "LATA", "350", "ML"}), frozenset({"REFRIGERANTE", "COCA", "COLA", "LATA", "350", "ML"}))
+
+
+def test_every_tracked_product_has_a_visible_status(monkeypatch):
+ client, engine = setup(monkeypatch)
+ out = api_resolve(client, "N1", "", "COCA-COLA ORIGINAL LATA 350 ML", {"CLASSIFICACAO_FISCAL": "00000000"})
+ with Session(engine) as s:
+ row = s.scalars(select(ProductEnrichment)).one()
+ assert row.ui_status == "EAN_AUSENTE" and row.reason_code == "NAME_ONLY_NO_VERIFIED_MATCH" and row.last_attempt_at is not None
+ assert out["fields"]["CLASSIFICACAO_FISCAL"]["technical_placeholder"] == "00000000"
+ generic = api_resolve(client, "N2", "", "REFRIGERANTE", {})
+ invalid = api_resolve(client, "N3", "7891000100104", "PRODUTO X", {})
+ with Session(engine) as s:
+ st = {r.product_code: r.ui_status for r in s.scalars(select(ProductEnrichment))}
+ assert st["N2"] == "NOME_AMBIGUO" and st["N3"] == "EAN_INVALIDO"
+ assert all(status.LABELS.get(v) for v in st.values())
+ from central.tests.test_product_enrichment_resolver import G1 as g
+ done = api_resolve(client, "N4", g, "REFRIGERANTE COLA LATA 350ML", {})
+ with Session(engine) as s:
+ assert s.scalars(select(ProductEnrichment).where(ProductEnrichment.product_code == "N4")).one().ui_status == "PRONTO_PARA_PREENCHER"
+ client.post("/v1/products/enrichment/applied", json={"product_code": "N4", "fields": [{"field": "CLASSIFICACAO_FISCAL", "outcome": "APPLIED", "old": "00000000", "new": "22021000"}]}, headers=H)
+ client.post("/v1/products/enrichment/applied", json={"product_code": "N1", "fields": [{"field": "CLASSIFICACAO_FISCAL", "outcome": "ABORTED", "reason": "STALE_CONFLICT"}]}, headers=H)
+ with Session(engine) as s:
+ st = {r.product_code: (r.ui_status, r.reason_code) for r in s.scalars(select(ProductEnrichment))}
+ assert st["N4"][0] == "ENRIQUECIDO" and st["N1"] == ("WRITE_FAILED", "STALE_CONFLICT")
+
+
+def test_reprocess_sync_and_panel_flag_only_unresolved_products_for_reevaluation(monkeypatch):
+ client, engine = setup(monkeypatch)
+ api_resolve(client, "U1", "", "COCA-COLA ORIGINAL LATA 350 ML", {"CLASSIFICACAO_FISCAL": "00000000"})
+ api_resolve(client, "E1", G1[:0] or gtin("789100010010"), "REFRIGERANTE COLA LATA 350ML", {})
+ client.post("/v1/products/enrichment/applied", json={"product_code": "E1", "fields": [{"field": "CLASSIFICACAO_FISCAL", "outcome": "APPLIED", "old": None, "new": "22021000"}]}, headers=H)
+ assert client.get("/v1/products/enrichment/pending", headers=H).json() == {"codes": []}
+ with Session(engine) as s:
+ agent = s.scalars(select(Agent)).first()
+ for kind in ("REPROCESS_ALL", "SYNC_INCREMENTAL"):
+ create_job(s, agent, kind, {})
+ assert s.scalars(select(ProductEnrichment).where(ProductEnrichment.reevaluate.is_(True))).all()[0].product_code == "U1"
+ for r in s.scalars(select(ProductEnrichment)):
+ r.reevaluate = False
+ s.commit()
+ for j in s.scalars(select(__import__("central.app.models", fromlist=["Job"]).Job)):
+ j.status = "COMPLETED"
+ s.commit()
+ with Session(engine) as s:
+ create_job(s, s.scalars(select(Agent)).first(), "REPROCESS_PRODUCTS", {"codigos": ["U1"]})
+ assert client.get("/v1/products/enrichment/pending", headers=H).json() == {"codes": ["U1"]} # the enriched product E1 is never re-evaluated
+ api_resolve(client, "U1", "", "COCA-COLA ORIGINAL LATA 350 ML", {"CLASSIFICACAO_FISCAL": "00000000"})
+ assert client.get("/v1/products/enrichment/pending", headers=H).json() == {"codes": []} # consumed by the new evaluation
+
+
+def test_panel_reevaluate_and_attest_routes_are_csrf_admin_and_audited(monkeypatch):
+ client, agent_id, _ = _panel_client()
+ with client._session_factory() as s:
+ seed_history(s, [staging("RINCAO", G1, "19059090", years={2024: 900, 2025: 900}, desc="COCA LATA 350 ML"), staging("UNIAO", G1, "22021000", years={2024: 20, 2025: 13}, desc="REFRIGERANTE COCA COLA LATA 350 ML")])
+ from central.tests.test_product_enrichment_resolver import seed
+ seed(s)
+ i = s.scalars(select(Installation)).one()
+ i.enrichment_mode, i.features_configured = "ENABLED", True
+ s.commit()
+ _become(client, "PADRAO")
+ assert _post(client, "/kb/attest", gtin="0" + G1, ncm="22021000").status_code == 403
+ _become(client, "ADMIN")
+ assert client.post("/kb/attest", data={"csrf_token": "bad", "gtin": "0" + G1, "ncm": "22021000"}, follow_redirects=False).status_code in (400, 403)
+ assert _post(client, "/kb/attest", gtin="0" + G1, ncm="21069090").status_code == 422
+ assert _post(client, "/kb/attest", gtin="0" + G1, ncm="22021000").status_code == 303
+ assert _post(client, f"/agents/{agent_id}/enrichment/reevaluate", code="").status_code == 303
+ with client._session_factory() as s:
+ acts = [e.action for e in s.scalars(select(AuditEvent))]
+ assert "KB_NCM_ATTESTED" in acts and "KB_ATTEST_DENIED" in acts and "ENRICHMENT_REEVALUATE" in acts
+ page = client.get("/?kb_q=COCA COLA LATA 350 ML").text
+ assert "VERIFIED_KB" in page and "decidido por" in page
+
+
+def test_rtc_mode_and_enrichment_mode_are_independent_switches_with_distinct_labels():
+ client, agent_id, _ = _panel_client()
+ _become(client, "ADMIN")
+ assert _post(client, f"/agents/{agent_id}/features", enrichment_mode="DRY_RUN", corrector_enabled="1").status_code == 303
+ with client._session_factory() as s:
+ i = s.scalars(select(Installation)).one()
+ assert i.enrichment_mode == "DRY_RUN" and i.rtc_write_mode == "DISABLED"
+ assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="DRY_RUN").status_code == 303
+ assert _post(client, f"/agents/{agent_id}/rtc-mode", mode="ENABLED", confirm="PREENCHER").status_code == 303
+ with client._session_factory() as s:
+ i = s.scalars(select(Installation)).one()
+ assert i.rtc_write_mode == "ENABLED" and i.enrichment_mode == "DRY_RUN" # turning cClassTrib to Automatic never touches the enrichment
+ page = client.get(f"/agents/{agent_id}").text
+ assert "Preenchimento cClassTrib (RTC)" in page and "Enriquecimento de produtos novos" in page
diff --git a/central/tests/test_health.py b/central/tests/test_health.py
index 9c97b10..b77cafc 100644
--- a/central/tests/test_health.py
+++ b/central/tests/test_health.py
@@ -17,7 +17,7 @@ def test_health_is_unavailable_when_database_is_down():
def test_version_endpoint_reports_release_and_fiscal_versions():
body = TestClient(create_app(database_probe=lambda: True)).get("/version").json()
- assert body["version"] == "1.3.4" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
+ assert body["version"] == "1.3.5" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"]
def test_production_refuses_the_dev_token(monkeypatch):
diff --git a/deploy/agent-windows/agent.env.template b/deploy/agent-windows/agent.env.template
index d507aec..9845763 100644
--- a/deploy/agent-windows/agent.env.template
+++ b/deploy/agent-windows/agent.env.template
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.3.4
+AGENT_VERSION=1.3.5
diff --git a/deploy/agent-windows/gti-agent.ps1 b/deploy/agent-windows/gti-agent.ps1
index 9f4acd6..07b697d 100644
--- a/deploy/agent-windows/gti-agent.ps1
+++ b/deploy/agent-windows/gti-agent.ps1
@@ -164,7 +164,7 @@ switch ($Command) {
"GTI_HUB_URL=$hub", "AGENT_TOKEN=$token", "AGENT_ID=$agentId",
"COMPANY_CODE=$code", "COMPANY_CNPJ=$cnpj", "COMPANY_CRT_SOURCE=$crtSource",
"FIREBIRD_DATABASE_PATH=$db", "FB_USER=$fbUser", "FB_PASSWORD=$fbPass",
- 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.3.4'
+ 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.3.5'
)
Set-Content -Path $EnvFile -Value $lines -Encoding UTF8
}
diff --git a/deploy/agent-windows/pilot-construcao.env.example b/deploy/agent-windows/pilot-construcao.env.example
index 595fab1..f65d269 100644
--- a/deploy/agent-windows/pilot-construcao.env.example
+++ b/deploy/agent-windows/pilot-construcao.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.3.4
+AGENT_VERSION=1.3.5
diff --git a/deploy/agent-windows/profiles/mmmodas.env.example b/deploy/agent-windows/profiles/mmmodas.env.example
index ff37fbe..532d132 100644
--- a/deploy/agent-windows/profiles/mmmodas.env.example
+++ b/deploy/agent-windows/profiles/mmmodas.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.3.4
+AGENT_VERSION=1.3.5
diff --git a/deploy/agent-windows/profiles/rincao.env.example b/deploy/agent-windows/profiles/rincao.env.example
index 8cfacbe..d38de38 100644
--- a/deploy/agent-windows/profiles/rincao.env.example
+++ b/deploy/agent-windows/profiles/rincao.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.3.4
+AGENT_VERSION=1.3.5
diff --git a/deploy/agent-windows/profiles/uniao.env.example b/deploy/agent-windows/profiles/uniao.env.example
index 1afb70f..fd44620 100644
--- a/deploy/agent-windows/profiles/uniao.env.example
+++ b/deploy/agent-windows/profiles/uniao.env.example
@@ -19,4 +19,4 @@ FB_PASSWORD=
FISCAL_MODE=DRY_RUN
FIREBIRD_WRITES=0
POLL_INTERVAL_SECONDS=5
-AGENT_VERSION=1.3.4
+AGENT_VERSION=1.3.5
diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example
index 3c117b9..fe0474d 100644
--- a/deploy/hub/.env.example
+++ b/deploy/hub/.env.example
@@ -1,5 +1,5 @@
# GTI Fiscal Hub — production environment (copy to .env, never commit .env)
-GTI_FISCAL_VERSION=1.3.4
+GTI_FISCAL_VERSION=1.3.5
# REQUIRED. URL-safe secret (used inside a connection URL): openssl rand -hex 24
POSTGRES_PASSWORD=
diff --git a/deploy/hub/docker-compose.prod.yml b/deploy/hub/docker-compose.prod.yml
index 401587e..b56b7c1 100644
--- a/deploy/hub/docker-compose.prod.yml
+++ b/deploy/hub/docker-compose.prod.yml
@@ -36,7 +36,7 @@ services:
build:
context: ../..
dockerfile: central/Dockerfile
- image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.3.4}
+ image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.3.5}
restart: unless-stopped
depends_on:
postgres:
diff --git a/docs/product_enrichment.md b/docs/product_enrichment.md
index 994207e..29ed7d2 100644
--- a/docs/product_enrichment.md
+++ b/docs/product_enrichment.md
@@ -117,3 +117,19 @@ The SGV refuses to save a product without NCM, so operators type a filler. An em
Both tables are identical (parity test). The audit records `old = 00000000`.
- Scope reminders: products created while the function was DISABLED / WAITING stay history (re-baseline); REPROCESS_ALL/SYNC do not push products through the enrichment (only NEW products after the baseline do);
a product WITHOUT EAN is resolved by name only from a unique VERIFIED_KB match.
+
+
+## Product-visible pipeline, re-evaluation and operator decision (1.3.5, migration 0031)
+- **No silent product**: every tracked product has `ui_status` + `reason_code` (Agent detail, section *Funcionalidades*): ENRIQUECIDO, PRONTO_PARA_PREENCHER, SIMULADO, AGUARDANDO_EVIDENCIA, EAN_AUSENTE, EAN_INVALIDO, NOME_AMBIGUO, SEM_MATCH_DE_PRODUTO,
+ NCM_NAO_VERIFICADO, CONFLITO_DE_NCM, PROVIDER_SEM_QUOTA, AGUARDANDO_RETRY, HUMAN_OVERRIDE, TECHNICAL_PLACEHOLDER (note), WRITE_FAILED, VERIFICACAO_POS_WRITE_FALHOU, NADA_A_PREENCHER, with EAN, detection time, KB status, candidates, last attempt and next retry.
+- **Re-evaluation** (`product_enrichments.reevaluate`): set by the panel (*Reavaliar* / *Reavaliar todos os pendentes*), by **Reprocessar / Sincronizar** (`create_job`, even when an equivalent job is already queued) and by a KB decision; the Agent polls
+ `GET /v1/products/enrichment/pending` every 10 s and re-runs the normal resolve for those tracked, not-yet-enriched products (KB/cache first; the Hub decides about providers; enriched / human-overridden products are never flagged).
+ Price edits are NOT a new product; *Reavaliar* is the explicit new evaluation. REPROCESS_ALL does not push the whole catalogue through the enrichment: only the tracked unresolved set.
+- **Stale-write guard** (Agent): a write is refused (`STALE_CONFLICT`) when the field no longer holds the value the Hub decided on (a person typed something meanwhile).
+- **Name-only** (`kb.alias_match`): the query token set equals an alias token set, or one contains the other and the difference is only neutral category nouns (REFRIGERANTE, BEBIDA...). ZERO / DIET / a flavour / another size are never neutral, so
+ "COCA COLA LATA 350 ML" = "REFRIGERANTE COCA COLA LATA 350 ML" but != "... ZERO ...". Writing by name still requires ONE matching KB product whose NCM is VERIFIED_KB.
+- **Operator decision (ADMIN)**: `POST /kb/attest` (dashboard, *Base de conhecimento*: search by EAN or name, official NCM text next to each candidate, button *Confirmar este NCM*). Only an existing, vigente candidate can be chosen; the others become `STALE (REJECTED_BY_OPERATOR)`;
+ the family `OPERATOR_ATTESTATION` + the local evidence of that value = VERIFIED_KB with a promotion audit naming the operator; every affected tracked product is flagged for re-evaluation, so the write happens automatically right after the click.
+- **RTC != Enrichment**: distinct labels on the Agent detail and a test that each switch never changes the other.
+- **Supplier NF-e**: investigated, no independent supplier evidence exists in the five databases (entry-item tables C000022/C000075 are empty; C000061/C000062/NFCE are documents ISSUED by the client from its own master); no `SUPPLIER_NFE` family was created.
+- **Free NCM sources re-checked (2026-09-26)**: Kodebar returns NCM only for medicines (CMED) and needs a key; DotCompany API is prepaid; Pesquisa GTIN has a free plan of 5 queries/MONTH (not integrated); OSCBR V3 NCM is unproven. The official GTIN->NCM source is the SEFAZ CCG web service, which needs the client's ICP-Brasil certificate (not used).
diff --git a/docs/runs/enrichment_acceptance_dev.json b/docs/runs/enrichment_acceptance_dev.json
new file mode 100644
index 0000000..b6eb9b2
--- /dev/null
+++ b/docs/runs/enrichment_acceptance_dev.json
@@ -0,0 +1,34 @@
+{
+ "dev_rehearsal": true,
+ "info": {
+ "kb_before": "ROWS [('22021000', 'OBSERVED'), ('22030000', 'OBSERVED')]",
+ "status_before_decision": "EAN_AUSENTE|NAME_ONLY_NO_VERIFIED_MATCH",
+ "attest": "ATTEST VERIFIED_KB ['CLIENT_DATA', 'OPERATOR_ATTESTATION'] flagged 15",
+ "status_dry_run_after_decision": "SIMULADO|DRY_RUN_NO_WRITE",
+ "AC001_after": [
+ "22021000",
+ null,
+ null,
+ "200004",
+ "COCA-COLA ORIGINAL LATA 350 ML"
+ ],
+ "audit": "CLASSIFICACAO_FISCAL:00000000>22021000:APPLIED:VERIFIED_KB",
+ "ean_latency_s": 2.39
+ },
+ "checks": {
+ "SILENT_FAILURE_FIXED_STATUS_VISIBLE": true,
+ "NCM_UNCHANGED_UNTIL_DECISION": true,
+ "ATTESTED_TO_VERIFIED_KB": true,
+ "DRY_RUN_SHOWS_CANDIDATE_ZERO_WRITES": true,
+ "NAME_ONLY_NCM_WRITTEN_BY_AGENT": true,
+ "CCLASSTRIB_PRESERVED": true,
+ "AUDIT_OLD_PLACEHOLDER_NEW_NCM": true,
+ "FINAL_STATUS_ENRIQUECIDO": true,
+ "SECOND_RUN_WRITES_0": true,
+ "ZERO_PROVIDER_REQUESTS_ON_KB_RESOLUTION": true,
+ "EAN_PATH_KB_HIT_WRITE": true,
+ "EXISTING_REAL_NCM_PRESERVED": true,
+ "HUMAN_OVERRIDE_PRESERVED": true
+ },
+ "ALL_PASS": true
+}
\ No newline at end of file
diff --git a/release/RELEASE_MANIFEST.json b/release/RELEASE_MANIFEST.json
index 52107d9..63f5bb8 100644
--- a/release/RELEASE_MANIFEST.json
+++ b/release/RELEASE_MANIFEST.json
@@ -1,14 +1,14 @@
{
"artifact": "RELEASE_MANIFEST",
- "GTI_FISCAL_VERSION": "1.3.4",
+ "GTI_FISCAL_VERSION": "1.3.5",
"generated_at": "2026-09-25T15:43:40+00:00",
"hub": {
- "version": "1.3.4",
+ "version": "1.3.5",
"api_compat": "1.0",
"engine_version": "0.3.0",
"decision_version": "mission-04c-ipi-ex-split"
},
- "agent_version": "1.3.4",
+ "agent_version": "1.3.5",
"fiscal_engine": {
"ENGINE_VERSION": "0.3.0",
"DECISION_VERSION": "mission-04c-ipi-ex-split",
@@ -17,10 +17,10 @@
"ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1"
},
"migrations": {
- "alembic_head_in_repo": "0030_kb_evidence_model",
- "applied_revision": "0030_kb_evidence_model",
- "applied_revision_source": "fresh + upgrade(0028->0030) + downgrade/upgrade verified on the dev PostgreSQL (schema parity by pg_dump -s); production applies 0030 at deploy (alembic upgrade head at container start)",
- "count": 30
+ "alembic_head_in_repo": "0031_enrichment_reevaluate",
+ "applied_revision": "0031_enrichment_reevaluate",
+ "applied_revision_source": "fresh + upgrade(0030->0031) + downgrade/upgrade verified on the dev PostgreSQL (schema parity by pg_dump -s); production applies 0031 at deploy",
+ "count": 31
},
"datasets_active": [
{
@@ -89,5 +89,5 @@
"record_count": 11103
}
],
- "note": "1.3.4: Agent + Hub. Agent 1.3.4 replaces only an approved TECHNICAL_PLACEHOLDER (NCM 00000000) besides empty fields; real values are never overwritten."
+ "note": "1.3.5: Hub + Agent (re-evaluation polling, stale-write guard). Placeholder semantics arrived in 1.3.4."
}
\ No newline at end of file
diff --git a/release/VERSION b/release/VERSION
index d0149fe..80e78df 100644
--- a/release/VERSION
+++ b/release/VERSION
@@ -1 +1 @@
-1.3.4
+1.3.5
diff --git a/scripts/enrichment_acceptance_dev.py b/scripts/enrichment_acceptance_dev.py
new file mode 100644
index 0000000..030c558
--- /dev/null
+++ b/scripts/enrichment_acceptance_dev.py
@@ -0,0 +1,142 @@
+"""Acceptance rehearsal of the production scenario on DEV (real HTTP Hub + real Agent worker + Firebird COPY):
+
+ product WITHOUT EAN, NCM=00000000, cClassTrib typed by the operator -> visible status (no silent failure) -> an ADMIN decides the NCM of the conflicting GTIN in the KB ->
+ the flagged re-evaluation finds the unique verified product by NAME -> the Agent writes the NCM (typed write) -> read-back + audit -> second run writes 0.
+ Also: DRY_RUN shows the candidate without writing; EAN path (KB hit, zero provider requests) with latency; human override is preserved.
+
+ docker compose up -d --build fiscal-hub && python scripts/enrichment_acceptance_dev.py
+The ADMIN decision is applied through the same code as the panel route (kb.attest + mark_for_reevaluation), with operator name 'E2E-OPERATOR'. Writes go only to the disposable COPY.
+"""
+import json
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts")); sys.path.insert(0, str(ROOT / "agent" / "src"))
+from dotenv import load_dotenv # noqa: E402
+
+load_dotenv(ROOT / ".env")
+import enrichment_kb_e2e as ke # noqa: E402
+import enrichment_local_hub_e2e as lh # noqa: E402
+from gti_fiscal_agent.client import HubClient # noqa: E402
+
+base, ce = ke.base, ke.ce
+GTIN = "7894900010015"
+
+
+def insert(code, name, ncm, ean, cclasstrib):
+ conn = base.connect(True); cur = conn.cursor()
+ cur.execute("INSERT INTO C000025 (CODIGO, PRODUTO, CODBARRA, CLASSIFICACAO_FISCAL, CCLASSTRIB) VALUES (?, ?, ?, ?, ?)", (code, name, ean, ncm, cclasstrib))
+ conn.commit(); conn.close()
+
+
+def row(code):
+ conn = base.connect(False); cur = conn.cursor()
+ cur.execute("SELECT CLASSIFICACAO_FISCAL, CEST, IPI, CCLASSTRIB, PRODUTO FROM C000025 WHERE CODIGO=?", (code,))
+ r = cur.fetchone(); conn.close()
+ return tuple(None if v is None else (v if isinstance(v, float) else str(v).strip()) for v in r) if r else None
+
+
+def ui(code, inst):
+ return lh.sql(f"select ui_status||'|'||coalesce(reason_code,'') from product_enrichments where product_code='{code}' and installation_id=(select id from installations where installation_id='{inst}')")
+
+
+def main():
+ checks, info = {}, {}
+ base.restore_copy()
+ c1 = HubClient(lh.HUB, lh.TOKEN)
+ inst = c1.identity()["installation_id"]
+ lh.set_features(inst, "DISABLED")
+ lh.sql(f"delete from product_field_audits where installation_id=(select id from installations where installation_id='{inst}')")
+ lh.sql(f"delete from product_enrichments where installation_id=(select id from installations where installation_id='{inst}')")
+ # start from the real situation: the KB has a CONFLICT for the Coca-Cola can (RINCAO x UNIAO), nothing verified
+ state = ke.exec_hub(f'''
+from sqlalchemy import select
+from central.app.db import SessionLocal
+from central.app.enrichment import kb
+from central.app.models import KbKnowledge, KbProduct
+with SessionLocal() as s:
+ p = s.scalar(select(KbProduct).where(KbProduct.gtin == "0{GTIN}"))
+ rows = s.scalars(select(KbKnowledge).where(KbKnowledge.kb_product_id == p.id, KbKnowledge.knowledge_key == "ncm")).all()
+ for r in rows: # reset any earlier rehearsal: drop attestation
+ ev = dict(r.evidence or {{}}); fam = dict(ev.get("families") or {{}}); fam.pop("OPERATOR_ATTESTATION", None); ev["families"] = fam; r.evidence = ev; r.status = "OBSERVED"; r.validated_at = None
+ s.commit()
+ print("ROWS", sorted((r.value, r.status) for r in rows))
+''')
+ info["kb_before"] = state
+ w, stop = ke.worker_for(c1, "acc_dev")
+ lh.set_features(inst, "DRY_RUN"); w._features_at = -999; time.sleep(6)
+ # 1) DRY_RUN: product without EAN, placeholder NCM
+ insert("AC001", "COCA-COLA ORIGINAL LATA 350 ML", "00000000", None, "200004")
+ time.sleep(12)
+ info["status_before_decision"] = ui("AC001", inst)
+ checks["SILENT_FAILURE_FIXED_STATUS_VISIBLE"] = info["status_before_decision"].startswith("EAN_AUSENTE")
+ checks["NCM_UNCHANGED_UNTIL_DECISION"] = row("AC001")[0] == "00000000"
+ # 2) ADMIN decision in the KB (same code path as the panel route) -> flagged re-evaluation, still DRY_RUN
+ out = ke.exec_hub(f'''
+from central.app.db import SessionLocal
+from central.app.enrichment import history, kb, status
+from central.app.models import Installation
+from sqlalchemy import select
+with SessionLocal() as s:
+ cur = history.official_sets(s)
+ r = kb.attest(s, "0{GTIN}", "22021000", "E2E-OPERATOR", lambda v: v in cur, note="E2E rehearsal")
+ n = 0
+ for i in s.scalars(select(Installation).where(Installation.enrichment_mode != "DISABLED", Installation.features_configured.is_(True))):
+ n += status.mark_for_reevaluation(s, i.id)
+ s.commit()
+ print("ATTEST", r["status"], r["families"], "flagged", n)
+''')
+ info["attest"] = out
+ checks["ATTESTED_TO_VERIFIED_KB"] = "VERIFIED_KB" in out
+ t0 = time.monotonic()
+ while time.monotonic() - t0 < 40 and not ui("AC001", inst).startswith("SIMULADO"):
+ time.sleep(1)
+ info["status_dry_run_after_decision"] = ui("AC001", inst)
+ checks["DRY_RUN_SHOWS_CANDIDATE_ZERO_WRITES"] = ui("AC001", inst).startswith("SIMULADO") and row("AC001")[0] == "00000000"
+ # 3) AUTOMATIC: reevaluate (like the panel button) -> written by the Agent
+ lh.set_features(inst, "ENABLED"); w._features_at = -999; w._auth_cache = (None, 0.0); time.sleep(3)
+ lh.sql(f"update product_enrichments set reevaluate=true where product_code='AC001' and installation_id=(select id from installations where installation_id='{inst}')")
+ t0 = time.monotonic()
+ while time.monotonic() - t0 < 45 and row("AC001")[0] == "00000000":
+ time.sleep(0.5)
+ time.sleep(4)
+ r = row("AC001")
+ info["AC001_after"] = r
+ checks["NAME_ONLY_NCM_WRITTEN_BY_AGENT"] = r[0] == "22021000"
+ checks["CCLASSTRIB_PRESERVED"] = r[3] == "200004"
+ audit = lh.sql(f"select field||':'||coalesce(old_value,'')||'>'||coalesce(new_value,'')||':'||outcome||':'||coalesce(kb_status,'') from product_field_audits where product_code='AC001' and outcome='APPLIED' order by id")
+ info["audit"] = audit
+ checks["AUDIT_OLD_PLACEHOLDER_NEW_NCM"] = "CLASSIFICACAO_FISCAL:00000000>22021000:APPLIED" in audit
+ checks["FINAL_STATUS_ENRIQUECIDO"] = ui("AC001", inst).startswith("ENRIQUECIDO")
+ # 4) second run writes 0, zero provider requests
+ before_req, before_writes = ke.requests_total(), int(lh.sql("select count(*) from product_field_audits where outcome='APPLIED'"))
+ lh.sql(f"update product_enrichments set reevaluate=true where product_code='AC001' and installation_id=(select id from installations where installation_id='{inst}')")
+ time.sleep(25)
+ checks["SECOND_RUN_WRITES_0"] = int(lh.sql("select count(*) from product_field_audits where outcome='APPLIED'")) == before_writes and row("AC001")[0] == "22021000"
+ checks["ZERO_PROVIDER_REQUESTS_ON_KB_RESOLUTION"] = ke.requests_total() == before_req
+ # 5) EAN path on the now-verified GTIN: latency + zero provider requests
+ t0 = time.monotonic()
+ insert("AC002", "COCA-COLA ORIGINAL LATA 350 ML", "00000000", GTIN, "200004")
+ while time.monotonic() - t0 < 25 and row("AC002")[0] == "00000000":
+ time.sleep(0.2)
+ info["ean_latency_s"] = round(time.monotonic() - t0, 2)
+ checks["EAN_PATH_KB_HIT_WRITE"] = row("AC002")[0] == "22021000" and ke.requests_total() == before_req
+ # 6) real NCM preserved + human override preserved
+ insert("AC003", "COCA-COLA ORIGINAL LATA 350 ML", "19059090", GTIN, "200004"); time.sleep(10)
+ checks["EXISTING_REAL_NCM_PRESERVED"] = row("AC003")[0] == "19059090"
+ conn = base.connect(True); cur = conn.cursor(); cur.execute("UPDATE C000025 SET CLASSIFICACAO_FISCAL='19059090' WHERE CODIGO='AC001'"); conn.commit(); conn.close()
+ time.sleep(E_OVERRIDE_WAIT := 28)
+ lh.sql(f"update product_enrichments set reevaluate=true where product_code='AC001' and installation_id=(select id from installations where installation_id='{inst}')")
+ time.sleep(20)
+ checks["HUMAN_OVERRIDE_PRESERVED"] = row("AC001")[0] == "19059090"
+ stop.stop = True
+ result = {"dev_rehearsal": True, "info": info, "checks": checks, "ALL_PASS": all(checks.values())}
+ (ROOT / "docs" / "runs" / "enrichment_acceptance_dev.json").write_text(json.dumps(result, indent=1, default=str), encoding="utf-8")
+ print(json.dumps(result, indent=1, default=str))
+ sys.exit(0 if result["ALL_PASS"] else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py
index c6fcaf1..9db798a 100644
--- a/tests/test_release_candidate.py
+++ b/tests/test_release_candidate.py
@@ -14,11 +14,11 @@ sys.path.insert(0, str(ROOT))
def test_versions_are_coherent_across_hub_agent_and_packages():
from central.app.version import GTI_FISCAL_VERSION
v = (ROOT / "release" / "VERSION").read_text(encoding="utf-8").strip()
- assert v == GTI_FISCAL_VERSION == "1.3.4"
+ assert v == GTI_FISCAL_VERSION == "1.3.5"
pep = v.replace("-rc", "rc")
for pp in ("central/pyproject.toml", "agent/pyproject.toml"):
assert f'version = "{pep}"' in (ROOT / pp).read_text(encoding="utf-8")
- assert '"AGENT_VERSION", "1.3.4"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
+ assert '"AGENT_VERSION", "1.3.5"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8")
manifest = ROOT / "release" / "RELEASE_MANIFEST.json"
if manifest.exists():
m = json.loads(manifest.read_text(encoding="utf-8"))