feat: wire field_contract into product enrichment; auto-discover CRT/CONTRIBUINTE_IPI at heartbeat; targeted per-product ERP evidence

field_contract.resolve_product was only exercised by its own unit tests. /v1/products/resolve now calls it for every product whose NCM
resolves, merging CFOP/CST/CSOSN/PIS-COFINS/CST_IPI/SITUACAO_TRIBUTARIA/IAT/IPPT decisions alongside CLASSIFICACAO_FISCAL/CEST/IPI - reusing
the same engine, the same FiscalContextEntry table the blocker-resolution workflow already writes to, no duplicated rules.

Closes three real transport/discovery gaps found while wiring it in:
- Agent heartbeat never sent company_crt (env-var UF only) or company_contribuinte_ipi, so Installation.company_crt stayed permanently empty
  even though company_context.resolve() already proves both from C000004/EMISSOR for every coverage job. Resolved once, cached, retried only
  while unresolved. Migration 0032 adds Installation.company_contribuinte_ipi (NULL=unknown, never coerced to "N").
- The Agent's enrichment payload only ever reported 3 old fields (CLASSIFICACAO_FISCAL/CEST/IPI) as "current", so the Hub could never see the
  product's own CFOP/CST/CSOSN/... nor the classification columns (TIPO/PROD_COZINHA/PRODUTO_SERVICO/CODGRUPO) infer_role needs - separated
  into a read-only CURRENT_REPORT_FIELDS list, the write allowlist (PRODUCT_ENRICHMENT_WRITE_V1) untouched.
- Added a targeted, product_code-scoped evidence query (firebird_reader.read_product_evidence: recipes/composed/purchases/stock, indexed WHERE,
  never a full-table scan) so st_withheld/product_role can be proven for a single ad-hoc resolve, not only during a full sync job.

Verified read-only against the real database (D:\PROJETOS\GTISISTEMAS\Banco\GTI.FDB): CRT=1, CONTRIBUINTE_IPI=N, company_context.resolve()
reaches HIGH confidence; infer_role correctly resolved RESALE/OWN_PRODUCTION for 5/8 sample real products with zero operator questions.

1121 tests passed (tests + central/tests + agent/tests), 0 failed. Version 1.3.7 (single GTI_FISCAL_VERSION shared by Hub and Agent per
central/app/version.py's own design - never independently versioned).
This commit is contained in:
gti
2026-09-27 18:26:20 -03:00
parent 72edff0510
commit 7e83d17533
27 changed files with 776 additions and 45 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-agent"
version = "1.3.6"
version = "1.3.7"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]
+27 -4
View File
@@ -107,7 +107,7 @@ class Config:
self.poll_interval_seconds = float(env.get("POLL_INTERVAL_SECONDS", "5"))
# RTC auto-apply is a SEPARATE switch (default OFF): FIREBIRD_WRITES alone never lets the Agent fill cClassTrib.
self.rtc_auto_apply_max_per_job = int(env.get("RTC_AUTO_APPLY_MAX_PER_JOB", str(_rtc_apply.DEFAULT_MAX_PER_JOB)))
self.agent_version = env.get("AGENT_VERSION", "1.3.6")
self.agent_version = env.get("AGENT_VERSION", "1.3.7")
self.schema_fingerprint = env.get("SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
self.state_db = env.get("AGENT_STATE_DB", str(AGENT_ROOT / "agent_state.sqlite"))
# Pilot write authorization: both must be explicitly set by the installer for
@@ -349,7 +349,7 @@ def execute_job(job, config, executor, state, env):
"fiscal_context": fiscal_context,
"rtc_auto_apply": rtc_apply,
"fiscal_mode": getattr(config, "fiscal_mode", None),
"release_version": env.get("AGENT_VERSION") or "1.3.6",
"release_version": env.get("AGENT_VERSION") or "1.3.7",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
@@ -410,6 +410,24 @@ def resolve_installation_id(config, env, state, client):
return state.installation_id(str(installation_id))
def _company_context_for_heartbeat(env, cached):
"""Resolves company UF/CRT via the SAME deterministic, fail-closed resolver already used for coverage/RTC (`company_context.resolve`,
called through `preflight_company_context`) - never a second, separate guess. Previously the heartbeat only ever sent `COMPANY_UF` from an
operator-set env var and never sent CRT at all, so `Installation.company_crt` stayed permanently empty in the Hub even though the Agent
already proves CRT from C000004/EMISSOR for every coverage job. Resolves once and caches: never re-queries Firebird once solid, retries only
while still unresolved (e.g. the database was not yet reachable when the service started)."""
if cached is not None:
return cached, None
try:
from gti_fiscal_agent.company_context import preflight_company_context
result = preflight_company_context(env)
except Exception as error:
return None, f"company context not resolved yet: {error}"
if result.get("status") != "PASS":
return None, f"company context not resolved yet: {result.get('classification')} ({'; '.join(result.get('reasons', []))})"
return result, None
def _start_enrichment(config, env, client, stopper):
"""New-product enrichment worker (own thread, own SQLite file). It gates ITSELF on the Hub's per-installation features: DISABLED / not configured = zero Firebird activity."""
if not hasattr(client, "resolve_product"):
@@ -439,17 +457,22 @@ def run(config, env, *, max_iterations=None, stopper=None):
was_down = False
iterations = 0
last_job_id = None
company_ctx = None
while not stopper.stop:
if max_iterations is not None and iterations >= max_iterations:
break
iterations += 1
try:
company_ctx, reason = _company_context_for_heartbeat(env, company_ctx)
if reason:
logger.debug(reason)
heartbeat_payload = {
"installation_id": installation_id, "status": "IDLE", "schema_fingerprint": config.schema_fingerprint,
"agent_version": config.agent_version, "last_job_id": last_job_id, "display_name": env.get("AGENT_DISPLAY_NAME", ""),
"customer_code": env.get("CUSTOMER_CODE"), "hostname": env.get("AGENT_HOSTNAME") or os.environ.get("COMPUTERNAME"),
"windows_version": env.get("WINDOWS_VERSION"), "company_code": config.company_id, "company_uf": config.company_uf,
"windows_version": env.get("WINDOWS_VERSION"), "company_code": config.company_id, "company_uf": (company_ctx or {}).get("company_uf") or config.company_uf,
"company_crt": (company_ctx or {}).get("company_crt"), "company_contribuinte_ipi": (company_ctx or {}).get("company_contribuinte_ipi"),
"database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "RTC_CCLASSTRIB_WRITE_V1": True, "PRODUCT_ENRICHMENT_WRITE_V1": True},
"fiscal_engine_version": env.get("FISCAL_ENGINE_VERSION", config.agent_version), "dataset_versions": {},
}
@@ -520,7 +543,7 @@ def run(config, env, *, max_iterations=None, stopper=None):
def enforce_startup_preflight(env):
"""Full read-only preflight at startup (Hub, auth, Firebird, capabilities, company context, guards). Fail closed."""
from gti_fiscal_agent.preflight import run_preflight
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.6")
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.7")
logger.info("startup preflight: %s", json.dumps({k: report[k] for k in ("PREFLIGHT", "blockers", "fiscal_mode", "effective_firebird_writes")}, ensure_ascii=False))
print(json.dumps({"PREFLIGHT": report["PREFLIGHT"], "blockers": report["blockers"]}, ensure_ascii=False))
if report["PREFLIGHT"] != "PASS":
@@ -59,7 +59,7 @@ def resolve(evidence, config=None):
"nfce_keys": [{"cuf", "cnpj", "n"}], "nf_by_filial": {code: n}}. Pure function."""
cfg = config or {}
out = {"status": "FAIL", "classification": "UNRESOLVED", "company_code": None, "company_cnpj": None, "company_cnpj_fingerprint": None, "company_uf": None, "company_crt": None,
"source_of_truth": {}, "confidence": "NONE", "evidence": [], "warnings": [], "reasons": [], "configured": False}
"company_contribuinte_ipi": None, "source_of_truth": {}, "confidence": "NONE", "evidence": [], "warnings": [], "reasons": [], "configured": False}
def fail(cls, reason):
out["classification"] = cls; out["reasons"].append(reason); return out
@@ -93,6 +93,10 @@ def resolve(evidence, config=None):
out["evidence"].append(f"company selected by {basis}")
out.update(company_code=selected["code"], company_cnpj=mask_cnpj(selected["cnpj"]), company_cnpj_fingerprint=cnpj_fingerprint(selected["cnpj"]))
out["source_of_truth"]["company"] = basis
contribuinte_ipi = _s(selected.get("contribuinte_ipi"))
if contribuinte_ipi and contribuinte_ipi.upper() in ("S", "N"): # same row already proven to be THE company; no extra query, no extra trust needed
out["company_contribuinte_ipi"] = contribuinte_ipi.upper()
out["source_of_truth"]["contribuinte_ipi"] = "C000004"
# ---- emitter (EMISSOR) must describe the same company --------------------------------------
emissor = None
@@ -175,7 +179,7 @@ def collect_evidence(cursor):
cols.setdefault(t, set()).add(f)
ev = {"c000004": [], "emissor": [], "nfce_keys": [], "nf_by_filial": {}}
c4 = cols.get("C000004", set())
want = [("CODIGO", "code"), ("CNPJ", "cnpj"), ("IE", "ie"), ("UF", "uf"), ("CRT", "crt"), ("OPTANTE_SIMPLES", "optante"), ("FILIAL", "name")]
want = [("CODIGO", "code"), ("CNPJ", "cnpj"), ("IE", "ie"), ("UF", "uf"), ("CRT", "crt"), ("OPTANTE_SIMPLES", "optante"), ("FILIAL", "name"), ("CONTRIBUINTE_IPI", "contribuinte_ipi")]
use = [(c, k) for c, k in want if c in c4]
if use:
cursor.execute(f"SELECT {','.join(c for c, _ in use)} FROM C000004")
@@ -214,5 +218,5 @@ def preflight_company_context(env=None):
def preflight_report(result):
keys = ("company_code", "company_cnpj", "company_uf", "company_crt", "source_of_truth", "confidence", "classification", "warnings", "reasons")
keys = ("company_code", "company_cnpj", "company_uf", "company_crt", "company_contribuinte_ipi", "source_of_truth", "confidence", "classification", "warnings", "reasons")
return {"COMPANY_CONTEXT": "PASS" if result["status"] == "PASS" else "FAIL", **{k: result.get(k) for k in keys}}
+20 -4
View File
@@ -25,7 +25,17 @@ ALLOWED_FIELDS = ("CLASSIFICACAO_FISCAL", "CEST", "IPI")
# TECHNICAL_PLACEHOLDER: the SGV refuses a product without NCM, so operators type a filler. It means "not classified yet" (not a human decision) and may be replaced.
# Explicit per-field allowlist, identical to central/app/enrichment/placeholders.py (parity test). Nothing else is ever overwritten.
PLACEHOLDERS = {"CLASSIFICACAO_FISCAL": frozenset({"00000000"})} # explicit allowlist; anything else raises before touching SQL
READ_FIELDS = ("CODIGO", "PRODUTO", "GTIN", "CODBARRA") + ALLOWED_FIELDS # the SGV operator types the EAN in CODBARRA; GTIN (fiscal) is mostly empty
# READ-ONLY reporting to the Hub for field_contract.resolve_product's post-NCM fiscal-completion decisions (CFOP/CST/CSOSN/PIS-COFINS/...).
# This is NOT a write allowlist - PRODUCT_ENRICHMENT_WRITE_V1 still only ever writes ALLOWED_FIELDS above. Sending the CURRENT value of these
# fields (and the classification columns below) just lets the Hub's field_contract engine see what's already filled and never guess.
FISCAL_COMPLETION_FIELDS = ("SITUACAO_TRIBUTARIA", "CFOP", "CFOP_ENTRADA", "CST", "CST_ENTRADA", "CSOSN", "ALIQUOTA", "CST_IPI",
"COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS",
"INCIDENCIA_PISCOFINS", "PISCOFINS", "IAT", "IPPT")
# Classification metadata field_contract.infer_role reads to auto-discover product_role (RESALE/OWN_PRODUCTION/INPUT/SERVICE) from real ERP
# facts instead of asking the operator. Read-only here too.
CLASSIFICATION_CONTEXT_FIELDS = ("CODGRUPO", "TIPO", "PROD_COZINHA", "PRODUTO_SERVICO")
CURRENT_REPORT_FIELDS = ALLOWED_FIELDS + FISCAL_COMPLETION_FIELDS + CLASSIFICATION_CONTEXT_FIELDS
READ_FIELDS = ("CODIGO", "PRODUTO", "GTIN", "CODBARRA") + CURRENT_REPORT_FIELDS # the SGV operator types the EAN in CODBARRA; GTIN (fiscal) is mostly empty
POLL_SECONDS = 2.0
SAFETY_DIFF_SECONDS = 60.0
OVERRIDE_CHECK_SECONDS = 20.0
@@ -327,9 +337,14 @@ class EnrichmentWorker:
conn = self._open(self.read_conn)
try:
rows = read_rows(conn, [code])
row = rows.get(code)
if row is None or mode not in ("DRY_RUN", "ENABLED"):
ev = None
else:
from .firebird_reader import read_product_evidence # targeted, indexed by product_code - never a full-table scan (see docstring)
ev = read_product_evidence(conn, code)
finally:
conn.close()
row = rows.get(code)
if row is None:
self.store.update(code, state="GONE")
return
@@ -338,9 +353,10 @@ class EnrichmentWorker:
self.store.update(code, state="SKIPPED_FEATURE", snapshot=snapshot)
return
attempts = int(item["attempts"] or 0) + 1
current = {f: row[f] for f in ALLOWED_FIELDS}
current = {f: row[f] for f in CURRENT_REPORT_FIELDS}
try:
result = self.hub.resolve_product({"product_code": code, "gtin": pick_gtin(row), "description": _text(row["PRODUTO"]), "current": {k: (None if v is None else str(v)) for k, v in current.items()}})
result = self.hub.resolve_product({"product_code": code, "gtin": pick_gtin(row), "description": _text(row["PRODUTO"]), "ev": {k: (sorted(v) if isinstance(v, set) else v) for k, v in ev.items()},
"current": {k: (None if v is None else str(v)) for k, v in current.items()}})
except Exception as error: # noqa: BLE001 - Hub offline / slow: the product stays pending, the SGV is unaffected
delay = RETRY_BACKOFF[min(attempts - 1, len(RETRY_BACKOFF) - 1)]
self.store.update(code, state="RETRY", attempts=attempts, next_attempt_at=self.clock() + delay, last_state=f"HUB_ERROR:{type(error).__name__}")
+50 -1
View File
@@ -75,7 +75,56 @@ def read_company_context(env=None):
result = preflight_company_context(env or os.environ)
if result["status"] != "PASS":
raise CompanyContextError(result)
return {"uf": result["company_uf"], "crt": result["company_crt"], "company_code": result["company_code"], "source_of_truth": result["source_of_truth"], "confidence": result["confidence"]}
return {"uf": result["company_uf"], "crt": result["company_crt"], "company_code": result["company_code"], "contribuinte_ipi": result.get("company_contribuinte_ipi"),
"source_of_truth": result["source_of_truth"], "confidence": result["confidence"]}
def read_product_evidence(conn, code):
"""Targeted, single-product evidence for an ad-hoc /v1/products/resolve call (new-product enrichment) - the same facts `read_evidence`
already gathers for a full sync job (recipes/composed/purchases/stock), but scoped to ONE product_code via an indexed WHERE, never a
full-table scan. Measured on a real production-equivalent database: read_evidence's full scan took ~40ms total for the whole catalog,
so this is not a correctness-driven optimization - it is what the mission explicitly asked for (never transport/scan the whole dataset
for one ad-hoc product). Every table/column is optional: a missing one just means less evidence (capability-based), matching
`read_evidence`. Takes an already-open connection (the SAME one the caller already opened for `read_rows`) - never opens its own,
so it works with the EnrichmentWorker's injected `read_conn` (including test fakes; a non-Firebird connection has no RDB$RELATION_FIELDS,
so the capability probe below degrades to "no evidence" rather than raising)."""
from collections import defaultdict
cur = conn.cursor()
cols = defaultdict(set)
try:
cur.execute("SELECT TRIM(RDB$RELATION_NAME), TRIM(RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME IN ('C000032','C000088','C000100','RECEITA_INGREDIENTES')")
for t, f in cur.fetchall():
cols[t].add(f)
except Exception:
return {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
has = lambda t, *c: t in cols and all(x in cols[t] for x in c) # noqa: E731
s = lambda v: None if v is None else str(v).strip() # noqa: E731
ev = {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
code_padded = str(code).zfill(6)
if has("RECEITA_INGREDIENTES", "CODRECEITA"):
cur.execute("SELECT FIRST 1 CODRECEITA FROM RECEITA_INGREDIENTES WHERE CODRECEITA = ?", (code_padded,))
if cur.fetchone():
ev["recipes"] = {code_padded}
if has("C000032", "CODPRODUTO", "TIPO"):
cur.execute("SELECT FIRST 1 CODPRODUTO FROM C000032 WHERE CODPRODUTO = ? AND TIPO = 'COMPOSICAO'", (code,))
if cur.fetchone():
ev["composed"] = {s(code)}
if has("C000088", "CODPRODUTO", "ICMS_RETIDO", "ICMS_VALORRETIDO", "SUB_VALOR"):
cur.execute("SELECT ICMS_RETIDO, ICMS_VALORRETIDO, SUB_VALOR FROM C000088 WHERE CODPRODUTO = ?", (code,))
rows = [{"icms_retido": s(ret), "valor_retido": vr, "sub_valor": sv} for ret, vr, sv in cur.fetchall()]
if rows:
ev["purchases"][s(code)] = rows
if has("C000100", "CODPRODUTO", "ULTIMA_ENTRADA", "NOTA_FISCAL"):
cur.execute("SELECT FIRST 1 ULTIMA_ENTRADA, NOTA_FISCAL FROM C000100 WHERE CODPRODUTO = ?", (code,))
row = cur.fetchone()
if row:
ev["stock_entry"][s(code)] = row[0] is not None or s(row[1]) not in (None, "", "000000")
if has("C000100", "CODPRODUTO", "ENTRADAS"):
cur.execute("SELECT FIRST 1 ENTRADAS FROM C000100 WHERE CODPRODUTO = ?", (code,))
row = cur.fetchone()
if row:
ev["stock_in"][s(code)] = bool(row[0] and float(row[0]) > 0)
return ev
def read_evidence(env=None, selected_company_code=None):
+1 -1
View File
@@ -9,7 +9,7 @@ import os
import sqlite3
from pathlib import Path
AGENT_VERSION_DEFAULT = "1.3.6"
AGENT_VERSION_DEFAULT = "1.3.7"
REQUIRED_ENV = ("GTI_HUB_URL", "AGENT_TOKEN", "FIREBIRD_DATABASE_PATH", "FB_USER", "FB_PASSWORD")
REQUIRED_CAPABILITIES = ("supports_ncm", "supports_ipi", "supports_cest")
FISCAL_MODES = ("DRY_RUN", "AUTO_APPLY")
+17
View File
@@ -83,6 +83,23 @@ def test_optante_inconsistency_is_a_warning_not_a_guess():
assert r["status"] == "PASS" and any("OPTANTE_SIMPLES" in w for w in r["warnings"])
def test_contribuinte_ipi_is_auto_discovered_from_the_same_selected_c000004_row():
"""C000004.CONTRIBUINTE_IPI (real column, same row already proven to be THE company by CRT/UF resolution) closes the fiscal-completion gap
where CST_IPI could not be resolved without asking an operator for a fact the ERP already proves."""
row = co("000001", CN_A)
row["contribuinte_ipi"] = "N"
r = resolve(ev([row], [emis()], DOC_A))
assert r["company_contribuinte_ipi"] == "N" and r["source_of_truth"]["contribuinte_ipi"] == "C000004"
def test_contribuinte_ipi_absent_or_invalid_stays_unresolved_never_guessed():
r = resolve(ev([co("000001", CN_A)], [emis()], DOC_A)) # co() never sets contribuinte_ipi
assert r["company_contribuinte_ipi"] is None
row = co("000002", CN_B); row["contribuinte_ipi"] = "X" # garbage value: never trusted
r2 = resolve(ev([row], [emis(cnpj=CN_B)], [{"cuf": "29", "cnpj": CN_B, "n": 1}]))
assert r2["company_contribuinte_ipi"] is None
def test_full_cnpj_is_never_exposed():
r = resolve(ev([co("1", CN_A)], [emis()], DOC_A))
dump = str(preflight_report(r)) + str(r)
+26 -1
View File
@@ -70,7 +70,8 @@ DECISIONS = {"CLASSIFICACAO_FISCAL": {"status": "AUTO_FILL_SAFE", "value": "2202
@pytest.fixture()
def env(tmp_path):
db = sqlite3.connect(":memory:", check_same_thread=False)
db.execute("CREATE TABLE C000025(CODIGO TEXT PRIMARY KEY, PRODUTO TEXT, GTIN TEXT, CLASSIFICACAO_FISCAL TEXT, CEST TEXT, IPI REAL, OTHER TEXT, CODBARRA TEXT)")
db.execute("CREATE TABLE C000025(CODIGO TEXT PRIMARY KEY, PRODUTO TEXT, GTIN TEXT, CLASSIFICACAO_FISCAL TEXT, CEST TEXT, IPI REAL, OTHER TEXT, CODBARRA TEXT, "
+ ", ".join(f"{f} TEXT" for f in E.FISCAL_COMPLETION_FIELDS + E.CLASSIFICATION_CONTEXT_FIELDS) + ")")
db.executemany("INSERT INTO C000025(CODIGO,PRODUTO,GTIN,OTHER) VALUES(?,?,?,?)", [(f"H{i}", f"HIST {i}", "", "keep") for i in range(50)])
db.commit()
hub = Hub(dict(ENABLED), DECISIONS)
@@ -114,6 +115,30 @@ def test_new_product_is_enriched_only_in_empty_fields_then_second_run_zero_write
assert db.total_changes == before and len(hub.resolve_calls) == 1 # nothing re-processed, 0 writes
def test_current_report_includes_fiscal_completion_and_classification_columns(tmp_path):
"""`current` sent to the Hub used to carry only CLASSIFICACAO_FISCAL/CEST/IPI. field_contract.resolve_product (now wired into
/v1/products/resolve) also needs the current CFOP/CST/CSOSN/... values and the classification columns infer_role reads (TIPO/
PROD_COZINHA/PRODUTO_SERVICO/CODGRUPO) - otherwise it can never see what's already filled or auto-discover product_role."""
db = sqlite3.connect(":memory:", check_same_thread=False)
db.execute("CREATE TABLE C000025(CODIGO TEXT PRIMARY KEY, PRODUTO TEXT, GTIN TEXT, CLASSIFICACAO_FISCAL TEXT, CEST TEXT, IPI REAL, OTHER TEXT, CODBARRA TEXT, "
+ ", ".join(f"{f} TEXT" for f in E.FISCAL_COMPLETION_FIELDS + E.CLASSIFICATION_CONTEXT_FIELDS) + ")")
db.commit()
hub = Hub(dict(ENABLED), DECISIONS)
store = E.EnrichmentStore(tmp_path / "e.db")
now = [1000.0]
worker = E.EnrichmentWorker(hub=hub, store=store, read_conn=lambda: Conn(db), write_conn=lambda: Conn(db), create_reprocess=lambda c: None, clock=lambda: now[0], sleep=lambda s: None)
worker.step() # baseline
db.execute("INSERT INTO C000025(CODIGO,PRODUTO,GTIN,OTHER,CSOSN,TIPO,PRODUTO_SERVICO) VALUES(?,?,?,?,?,?,?)", ("N9", "PRODUTO X", "7891000100103", "keep", "102", "VENDA", None))
db.commit()
now[0] += 5
worker.step()
sent = hub.resolve_calls[0]["current"]
assert sent["CSOSN"] == "102" and sent["TIPO"] == "VENDA"
for f in E.FISCAL_COMPLETION_FIELDS + E.CLASSIFICATION_CONTEXT_FIELDS:
assert f in sent, f
assert hub.resolve_calls[0]["ev"] == {"recipes": [], "composed": [], "purchases": {}, "stock_entry": {}, "stock_in": {}} # targeted evidence rides along (empty here: sqlite has no RDB$RELATION_FIELDS)
def test_existing_value_is_never_overwritten(env):
db, hub, store, worker, now, _ = env
worker.step()
@@ -0,0 +1,85 @@
"""read_product_evidence: targeted, single-product evidence for the ad-hoc /v1/products/resolve call - the same recipes/composed/purchases/
stock facts read_evidence already gathers for a full sync job, scoped to ONE product_code, never a full-table scan. Measured against a real
production-equivalent database (D:\\PROJETOS\\GTISISTEMAS\\Banco\\GTI.FDB, read-only): a full read_evidence() scan took ~40ms for the whole
catalog, and a targeted per-product lookup correctly drove field_contract.infer_role to RESALE/OWN_PRODUCTION for 5 of 8 real products with
zero operator questions - this test locks in the behavior with a portable (sqlite) fixture."""
import sqlite3
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
from gti_fiscal_agent.firebird_reader import read_product_evidence
def _conn():
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE RECEITA_INGREDIENTES(CODRECEITA TEXT)")
db.execute("CREATE TABLE C000032(CODPRODUTO TEXT, TIPO TEXT)")
db.execute("CREATE TABLE C000088(CODPRODUTO TEXT, ICMS_RETIDO TEXT, ICMS_VALORRETIDO REAL, SUB_VALOR REAL)")
db.execute("CREATE TABLE C000100(CODPRODUTO TEXT, ULTIMA_ENTRADA TEXT, NOTA_FISCAL TEXT, ENTRADAS REAL)")
return db
def test_no_firebird_system_table_degrades_to_no_evidence_never_raises():
"""A non-Firebird connection (this project's own sqlite test fakes) has no RDB$RELATION_FIELDS: the capability probe must degrade
gracefully to "no evidence", exactly like a missing table would, never raise."""
db = sqlite3.connect(":memory:")
ev = read_product_evidence(db, "000001")
assert ev == {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
def test_targeted_lookup_is_scoped_to_the_one_product_code():
db = _conn()
db.executemany("INSERT INTO RECEITA_INGREDIENTES VALUES (?)", [("000009",), ("000010",)])
db.execute("INSERT INTO C000032 VALUES ('000011', 'COMPOSICAO')")
db.execute("INSERT INTO C000088 VALUES ('000012', 'S', 0, 0)")
db.execute("INSERT INTO C000100 VALUES ('000013', '2026-01-01', '000001', 5)")
db.commit()
def has_relation_fields():
cur = db.cursor()
cur.execute("SELECT TRIM(RDB$RELATION_NAME), TRIM(RDB$FIELD_NAME) FROM RDB$RELATION_FIELDS WHERE RDB$RELATION_NAME IN ('C000032','C000088','C000100','RECEITA_INGREDIENTES')")
return cur.fetchall()
# sqlite has no RDB$RELATION_FIELDS either; monkeypatch the cursor to answer the capability probe like a real Firebird database would
class ProbedConn:
def __init__(self, real):
self.real = real
def cursor(self):
return ProbedCursor(self.real.cursor())
class ProbedCursor:
def __init__(self, real):
self.real = real
self._probe_result = None
def execute(self, sql, params=()):
if "RDB$RELATION_FIELDS" in sql:
self._probe_result = [
("RECEITA_INGREDIENTES", "CODRECEITA"), ("C000032", "CODPRODUTO"), ("C000032", "TIPO"),
("C000088", "CODPRODUTO"), ("C000088", "ICMS_RETIDO"), ("C000088", "ICMS_VALORRETIDO"), ("C000088", "SUB_VALOR"),
("C000100", "CODPRODUTO"), ("C000100", "ULTIMA_ENTRADA"), ("C000100", "NOTA_FISCAL"), ("C000100", "ENTRADAS"),
]
return
self._probe_result = None
sqlite_sql = sql.replace("SELECT FIRST 1 ", "SELECT ") + (" LIMIT 1" if "FIRST 1" in sql else "")
self.real.execute(sqlite_sql, params)
def fetchall(self):
return self._probe_result if self._probe_result is not None else self.real.fetchall()
def fetchone(self):
return self.real.fetchone()
conn = ProbedConn(db)
ev = read_product_evidence(conn, "000009")
assert ev["recipes"] == {"000009"} and ev["composed"] == set() and ev["purchases"] == {} and ev["stock_entry"] == {} and ev["stock_in"] == {}
ev = read_product_evidence(conn, "000011")
assert ev["composed"] == {"000011"} and ev["recipes"] == set()
ev = read_product_evidence(conn, "000012")
assert ev["purchases"] == {"000012": [{"icms_retido": "S", "valor_retido": 0, "sub_valor": 0}]}
ev = read_product_evidence(conn, "000013")
assert ev["stock_entry"] == {"000013": True} and ev["stock_in"] == {"000013": True}
ev = read_product_evidence(conn, "999999")
assert ev == {"recipes": set(), "composed": set(), "purchases": {}, "stock_entry": {}, "stock_in": {}}
+58
View File
@@ -346,6 +346,64 @@ def test_run_sends_heartbeat_but_never_polls_when_paused_centrally(monkeypatch,
assert service.AgentState(config.state_db).stored_installation_id() == installation_id
def test_company_context_for_heartbeat_resolves_once_and_caches(monkeypatch):
"""CRT was never sent at heartbeat: the Agent already proves it (company_context.resolve, via preflight_company_context) for coverage/RTC
jobs, but that value never reached the Hub. This locks in the fix: resolved once, cached, never re-queried once solid."""
calls = {"n": 0}
def fake_preflight(env):
calls["n"] += 1
return {"status": "PASS", "company_uf": "BA", "company_crt": "1", "company_code": "000001"}
monkeypatch.setattr("gti_fiscal_agent.company_context.preflight_company_context", fake_preflight)
result, reason = service._company_context_for_heartbeat({}, None)
assert result == {"status": "PASS", "company_uf": "BA", "company_crt": "1", "company_code": "000001"} and reason is None and calls["n"] == 1
result2, reason2 = service._company_context_for_heartbeat({}, result) # already cached: never re-queries Firebird
assert result2 is result and reason2 is None and calls["n"] == 1
def test_company_context_for_heartbeat_retries_while_unresolved_never_guesses(monkeypatch):
def fake_preflight(env):
return {"status": "FAIL", "classification": "UNRESOLVED", "reasons": ["UF_SOURCE_UNKNOWN"]}
monkeypatch.setattr("gti_fiscal_agent.company_context.preflight_company_context", fake_preflight)
result, reason = service._company_context_for_heartbeat({}, None)
assert result is None and "UNRESOLVED" in reason # never a guessed uf/crt - explicitly unresolved, retried next tick
def test_run_heartbeat_carries_the_auto_discovered_company_crt(monkeypatch, tmp_path):
"""End-to-end: once company context resolves, the CRT rides the heartbeat payload the Hub already knows how to store
(Installation.company_crt) - closing the transport gap without any Hub change."""
stopper = _ExternalStopFlag()
fake_hub = _FakeHubForLoop()
installation_id = "00000000-0000-4000-8000-000000000333"
sent = {}
def identity():
return {"agent_id": 2, "installation_id": installation_id}
def heartbeat_metadata(payload):
sent.update(payload)
stopper.stop = True
return {"agent_id": 2, "processing_enabled": False}
fake_hub.identity = identity
fake_hub.heartbeat_metadata = heartbeat_metadata
fake_hub.next_job = lambda: (_ for _ in ()).throw(AssertionError("paused centrally; next_job must not be called"))
monkeypatch.setattr(service, "HubClient", lambda *a, **k: fake_hub)
monkeypatch.setattr(service, "load_executor", lambda: _FakeExecutor())
monkeypatch.setattr(service.time, "sleep", lambda _seconds: None)
monkeypatch.setattr("gti_fiscal_agent.company_context.preflight_company_context",
lambda env: {"status": "PASS", "company_uf": "BA", "company_crt": "1", "company_code": "000001", "company_contribuinte_ipi": "N"})
config = _config(agent_id="2", poll_interval_seconds=0, state_db=str(tmp_path / "agent.db"))
env = {"AGENT_ID": "2", "FIREBIRD_DATABASE_PATH": AUTHORIZED, "FIREBIRD_WRITES": "0", "FISCAL_MODE": "DRY_RUN"}
service.run(config, env, stopper=stopper)
assert sent["company_uf"] == "BA" and sent["company_crt"] == "1" and sent["company_contribuinte_ipi"] == "N"
def test_run_honors_an_externally_supplied_stopper(monkeypatch):
# This is exactly the pattern the Windows Service wrapper uses: it never
# constructs GracefulStop() (which would crash off the main thread), it