fix: REPROCESS_PRODUCTS resolved company=None for unified_fiscal/RTC

REPROCESS_PRODUCTS is not in SYNC_JOB_KINDS, so it fell to the else branch of execute_job and hardcoded company=None
for _unified_fiscal - a real gap, not a missing feature: the same deterministic, fail-closed company_context.resolve()
every sync job already uses is cheap (no full-table scan, confirmed against the real database) and applies just as
well to a one-off reprocess job. Now resolves the real company the same way; falls back to None only when the
company genuinely cannot be resolved yet, never a silent guess. Does not touch RTC rule logic.

Verified end-to-end against the real production database (dry-run, zero writes) that this was the last domain still
carrying the old bug - field_contract's post-NCM fiscal completion already bypassed it entirely by reading
Installation.company_uf/crt/company_contribuinte_ipi directly (populated via the 1.3.7 heartbeat fix).

1123 tests passed (tests + central/tests + agent/tests), 0 failed. Version 1.3.8 (single GTI_FISCAL_VERSION).
This commit is contained in:
gti
2026-09-27 21:32:28 -03:00
parent 7e83d17533
commit 4701ff8724
10 changed files with 90 additions and 18 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "gti-fiscal-agent"
version = "1.3.7"
version = "1.3.8"
requires-python = ">=3.10"
dependencies = ["httpx>=0.27,<1"]
+11 -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.7")
self.agent_version = env.get("AGENT_VERSION", "1.3.8")
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
@@ -338,7 +338,14 @@ def execute_job(job, config, executor, state, env):
operational = summarize_operational_states(product_states)
fiscal_context = {**fiscal["context"], "evaluated_all": kind != "REPROCESS_PRODUCTS"}
else:
fiscal = _unified_fiscal(env, None, {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(to_process, decisions)})
# REPROCESS_PRODUCTS (and any other non-sync kind) used to hardcode company=None here - not a missing feature, a real gap: the SAME
# deterministic, fail-closed company_context.resolve() every sync job already uses is cheap (no full-table scan) and applies just as
# well to a one-off reprocess job. `company` stays None only when the company genuinely cannot be resolved yet - never a silent guess.
try:
company = {"crt": _firebird_reader.read_company_context(env).get("crt")}
except Exception:
company = None
fiscal = _unified_fiscal(env, company, {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(to_process, decisions)})
fiscal_blocked = _fiscal_blocked_products(products, fiscal["findings"])
fiscal_codes = {b["product_code"] for b in fiscal_blocked}
summary["blocked_products"] = summary["blocked_products"] + fiscal_blocked
@@ -349,7 +356,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.7",
"release_version": env.get("AGENT_VERSION") or "1.3.8",
"products_total": len(products),
"products_changed": len(to_process),
"products_processed": summary["processed"],
@@ -543,7 +550,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.7")
report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.3.8")
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":
+1 -1
View File
@@ -9,7 +9,7 @@ import os
import sqlite3
from pathlib import Path
AGENT_VERSION_DEFAULT = "1.3.7"
AGENT_VERSION_DEFAULT = "1.3.8"
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")
+65
View File
@@ -261,6 +261,71 @@ def test_execute_job_success_summarizes_pipeline(monkeypatch):
assert result["writes_attempted"] == 0 # firebird_writes disabled by default in _config()
def test_reprocess_products_resolves_real_company_context_not_none(monkeypatch):
"""REPROCESS_PRODUCTS used to hardcode company=None for _unified_fiscal (it isn't in SYNC_JOB_KINDS). Company resolution is cheap and
deterministic (company_context.resolve, same as every sync job already uses) - it must never silently fall back to None when the company
genuinely resolves."""
config = _config()
products = [{"CODIGO": "000026", "PRODUTO": "x", "CLASSIFICACAO_FISCAL": "03055100", "IPI": None}]
decisions = [{"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED", "classification": {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.25}}, "dataset_versions": {"tipi_ipi": "v1"}}]
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: products)
class FakeClient:
def __init__(self, *a, **k): pass
def classify_batch(self, items): return decisions
monkeypatch.setattr(service, "HubClient", FakeClient)
monkeypatch.setattr(service._firebird_reader, "read_company_context", lambda env: {"uf": "BA", "crt": "1", "company_code": "000001"})
captured = {}
real_unified = service._unified_fiscal
def spy_unified_fiscal(env, company, ncm_valid=None):
captured["company"] = company
return real_unified(env, company, ncm_valid)
monkeypatch.setattr(service, "_unified_fiscal", spy_unified_fiscal)
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
result, error = service.execute_job(job, config, executor, state, {})
assert error is None
assert captured["company"] == {"crt": "1"} # real CRT, never None, when the company genuinely resolves
def test_reprocess_products_falls_back_to_none_only_when_company_unresolved(monkeypatch):
"""No guessing: when the company genuinely cannot be resolved (e.g. Firebird unreachable), company stays None - same as before."""
config = _config()
products = [{"CODIGO": "000026", "PRODUTO": "x", "CLASSIFICACAO_FISCAL": "03055100", "IPI": None}]
decisions = [{"status": "EXACT_NCM", "ipi_status": "IPI_RESOLVED", "classification": {"ipi": {"rate_kind": "NUMERIC", "rate_numeric": 3.25}}, "dataset_versions": {"tipi_ipi": "v1"}}]
monkeypatch.setattr(service, "read_products", lambda env, codigos=None: products)
class FakeClient:
def __init__(self, *a, **k): pass
def classify_batch(self, items): return decisions
monkeypatch.setattr(service, "HubClient", FakeClient)
def boom(env):
raise RuntimeError("firebird unavailable")
monkeypatch.setattr(service._firebird_reader, "read_company_context", boom)
captured = {}
real_unified = service._unified_fiscal
def spy_unified_fiscal(env, company, ncm_valid=None):
captured["company"] = company
return real_unified(env, company, ncm_valid)
monkeypatch.setattr(service, "_unified_fiscal", spy_unified_fiscal)
executor = _FakeExecutor()
state = _FakeState()
job = {"kind": "REPROCESS_PRODUCTS", "agent_id": 1, "payload": {"codigos": ["000026"]}}
result, error = service.execute_job(job, config, executor, state, {})
assert error is None
assert captured["company"] is None
class _FakeHubForLoop:
"""Simulates: heartbeat flaky twice then OK, one job then nothing, and captures completions."""