From 42dc172763866b419ae90c8945967705fe223722 Mon Sep 17 00:00:00 2001 From: gti Date: Thu, 24 Sep 2026 22:46:05 -0300 Subject: [PATCH] feat: central RTC write modes (DISABLED/DRY_RUN/APPROVAL/ENABLED), R4 suggestion-only, preview/approval, per-record rollback, bulk blocker upsert; 1.2.0 Co-Authored-By: Claude Sonnet 5 --- agent/pyproject.toml | 2 +- agent/scripts/run_agent_service.py | 56 +- agent/src/gti_fiscal_agent/preflight.py | 2 +- agent/src/gti_fiscal_agent/rtc_auto_apply.py | 98 +++- agent/tests/test_rtc_auto_apply.py | 164 +++++- .../alembic/versions/0028_rtc_write_modes.py | 47 ++ central/app/api.py | 11 +- central/app/blocked_products.py | 38 +- central/app/cclasstrib_resolver.py | 103 ++-- central/app/models.py | 32 +- central/app/panel_presentation.py | 34 +- central/app/rtc_audit.py | 97 +++- central/app/rtc_findings.py | 32 +- central/app/unified_fiscal_coverage.py | 10 +- central/app/version.py | 2 +- central/app/web.py | 123 +++- central/pyproject.toml | 2 +- central/templates/agent_detail.html | 3 +- central/templates/rtc_control.html | 41 ++ central/tests/test_cclasstrib_resolver.py | 44 +- central/tests/test_control_panel_pages.py | 92 ++- central/tests/test_health.py | 2 +- central/tests/test_rtc_security.py | 87 +++ central/tests/test_unified_fiscal_coverage.py | 3 +- central/tests/test_zero_touch_hub.py | 64 ++- deploy/agent-windows/agent.env.template | 2 +- deploy/agent-windows/gti-agent.ps1 | 2 +- .../pilot-construcao.env.example | 2 +- .../profiles/mmmodas.env.example | 2 +- .../agent-windows/profiles/rincao.env.example | 2 +- .../agent-windows/profiles/uniao.env.example | 2 +- deploy/hub/.env.example | 2 +- deploy/hub/docker-compose.prod.yml | 2 +- docs/AI_PROJECT_STATE.md | 8 + docs/DEPLOY_HUB.md | 2 +- docs/cclasstrib_resolution.md | 6 +- docs/rtc_auto_apply.md | 47 +- docs/runs/rtc_copy_write_e2e.json | 410 ++++++++------ docs/runs/rtc_resolution_audit_sample.csv | 126 ++--- docs/runs/rtc_resolution_dryrun.json | 242 ++++---- docs/runs/scale_hub_test.json | 48 ++ docs/runs/unified_coverage_RINCAO.json | 462 +++++++++++++++ docs/runs/unified_coverage_UNIAO.json | 393 +++++++++++++ docs/runs/unified_coverage_e2e.json | 526 +++++++++--------- .../RTC_OFFICIAL_ACTS_MANIFEST_V2.json | 95 ++++ release/RELEASE_MANIFEST.json | 12 +- release/VERSION | 2 +- scripts/rtc_copy_write_e2e.py | 187 +++++-- scripts/scale_hub_test.py | 88 +++ tests/test_release_candidate.py | 4 +- 50 files changed, 2948 insertions(+), 915 deletions(-) create mode 100644 central/alembic/versions/0028_rtc_write_modes.py create mode 100644 central/templates/rtc_control.html create mode 100644 central/tests/test_rtc_security.py create mode 100644 docs/runs/scale_hub_test.json create mode 100644 docs/runs/unified_coverage_RINCAO.json create mode 100644 docs/runs/unified_coverage_UNIAO.json create mode 100644 docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json create mode 100644 scripts/scale_hub_test.py diff --git a/agent/pyproject.toml b/agent/pyproject.toml index fea607c..89c4abd 100644 --- a/agent/pyproject.toml +++ b/agent/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gti-fiscal-agent" -version = "1.1.9" +version = "1.2.0" requires-python = ">=3.10" dependencies = ["httpx>=0.27,<1"] diff --git a/agent/scripts/run_agent_service.py b/agent/scripts/run_agent_service.py index 43710fa..6f1c4cd 100644 --- a/agent/scripts/run_agent_service.py +++ b/agent/scripts/run_agent_service.py @@ -105,9 +105,8 @@ class Config: self.agent_jobs_enabled = True 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_enabled = env.get("RTC_AUTO_APPLY_ENABLED", "0").strip() == "1" 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.1.9") + self.agent_version = env.get("AGENT_VERSION", "1.2.0") 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 @@ -208,30 +207,47 @@ def _fiscal_blocked_products(products, findings): found = findings.get(code) or {} for blocker in found.get("blockers") or []: out.append({"company": "", "product_code": code, "gtin": product.get("GTIN") or product.get("EAN_GTIN"), "description": product.get("PRODUTO"), "blocker_type": "BLOCKED_FISCAL_DATA", - "field": blocker["category"], "reason_code": blocker["type"], "evidence_summary": {"reason": blocker["reason"], "applicability": found.get("applicability")}, + "field": blocker["category"], "reason_code": blocker["type"], "evidence_summary": {"reason": blocker["reason"], "applicability": found.get("applicability"), **({"human": blocker["human"]} if blocker.get("human") else {})}, "input_fingerprint": found.get("input_fingerprint", "")}) return out def _rtc_auto_apply(env, config, job, products, fiscal, company): - """Report (always) and, only when all three gates are open, fill CCLASSTRIB for products whose class is PROVEN by a deterministic rule. - Returns the summary stored in the job result; never raises for a single product.""" - gate = _rtc_apply.write_gate(firebird_writes=bool(getattr(config, "firebird_writes", False)), agent_enabled=bool(getattr(config, "rtc_auto_apply_enabled", False)), - hub_authorized=bool((job.get("authorizations") or {}).get("rtc_auto_apply"))) - limit = int(getattr(config, "rtc_auto_apply_max_per_job", _rtc_apply.DEFAULT_MAX_PER_JOB)) - candidates, ready_total = _rtc_apply.select_candidates(fiscal["findings"], products, limit=limit) - summary = {"gate": gate, "mode": "AUTO_APPLY" if gate["allowed"] else "REPORT_ONLY", "ready_total": ready_total, "max_per_job": limit, "write_attempts": 0, "write_successes": 0, "applied": [], "aborted": [], "aborted_reasons": {}, - "allowed_fields": list(_rtc_apply.ALLOWED_RTC_WRITE_FIELDS)} - if not (gate["allowed"] and candidates and fiscal["context"].get("rtc_enabled")): - return summary + """Preview (always) and, only when the Hub-delivered mode permits it, fill CCLASSTRIB for products whose class is PROVEN by an AUTO_APPLY_SAFE rule; also executes + Hub-requested rollbacks. Authorization arrives ONLY inside the authenticated job (`authorizations`); there is no local switch. Never raises for a single product.""" from datetime import datetime, timezone + auth = job.get("authorizations") or {} + mode = auth.get("rtc_write_mode") if auth.get("rtc_write_mode") in _rtc_apply.MODES else "DISABLED" + approved = {str(c) for c in (auth.get("rtc_approved_codes") or [])} + limit = int(getattr(config, "rtc_auto_apply_max_per_job", _rtc_apply.DEFAULT_MAX_PER_JOB)) + rtc_on = bool(fiscal["context"].get("rtc_enabled")) + all_candidates, ready_total = _rtc_apply.select_candidates(fiscal["findings"], products, limit=_rtc_apply.MAX_PREVIEW_CANDIDATES) + gate = _rtc_apply.write_gate(mode=mode, capability=True, approved=bool(approved)) + summary = {"gate": gate, "mode": mode if mode != "DISABLED" else "REPORT_ONLY", "hub_mode": mode, "capability": _rtc_apply.CAPABILITY, "ready_total": ready_total, "max_per_job": limit, "write_attempts": 0, "write_successes": 0, + "applied": [], "aborted": [], "aborted_reasons": {}, "rollbacks": [], "allowed_fields": list(_rtc_apply.ALLOWED_RTC_WRITE_FIELDS), + "preview": [{k: c[k] for k in ("product_code", "description", "ncm", "current", "proposed", "cst", "rule_id", "basis")} for c in all_candidates] if mode != "DISABLED" and rtc_on else [], + "preview_truncated": ready_total > len(all_candidates)} + if not rtc_on: + return summary + now = datetime.now(timezone.utc).isoformat(timespec="seconds") + rollbacks = [r for r in (auth.get("rtc_rollbacks") or []) if isinstance(r, dict)] + if mode == "ENABLED": + wanted = all_candidates[:limit] + elif mode == "APPROVAL_REQUIRED": + wanted = [c for c in all_candidates if c["product_code"] in approved][:limit] + else: + wanted = [] + if not (rollbacks or (gate["allowed"] and wanted)): + return summary connection = open_write_connection(env, config.database_path) try: - outcome = _rtc_apply.apply_candidates(connection, candidates, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash")}, - now=datetime.now(timezone.utc).isoformat(timespec="seconds"), job_id=job.get("job_id")) + if rollbacks: + summary["rollbacks"] = _rtc_apply.rollback_records(connection, rollbacks, now=now, job_id=job.get("job_id"))["rollbacks"] + if gate["allowed"] and wanted: + outcome = _rtc_apply.apply_candidates(connection, wanted, company=company or {}, decision={"local_catalog_hash": fiscal["context"].get("local_catalog_hash"), "origin": f"HUB_MODE:{mode}"}, now=now, job_id=job.get("job_id")) + summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")}) finally: connection.close() - summary.update({k: outcome[k] for k in ("applied", "aborted", "aborted_reasons", "write_attempts", "write_successes")}) return summary @@ -289,7 +305,7 @@ def execute_job(job, config, executor, state, env): ncm_ok = {str(p["CODIGO"]): (d or {}).get("status") == "EXACT_NCM" for p, d in zip(products, all_decisions)} fiscal = _unified_fiscal(env, company, ncm_ok) rtc_apply = _rtc_auto_apply(env, config, job, products, fiscal, company) - if rtc_apply["write_successes"]: + if rtc_apply["write_successes"] or any(r.get("outcome") == "ROLLED_BACK" for r in rtc_apply["rollbacks"]): fiscal = _unified_fiscal(env, company, ncm_ok) # coverage must reflect the fields that were just filled findings = fiscal["findings"] product_states = [classify_product_state(p, d, fiscal=findings.get(str(p["CODIGO"]))) for p, d in zip(products, all_decisions)] @@ -307,7 +323,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.1.9", + "release_version": env.get("AGENT_VERSION") or "1.2.0", "products_total": len(products), "products_changed": len(to_process), "products_processed": summary["processed"], @@ -395,7 +411,7 @@ def run(config, env, *, max_iterations=None, stopper=None): "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, - "database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "rtc_auto_apply": True, "rtc_auto_apply_enabled": bool(getattr(config, "rtc_auto_apply_enabled", False))}, + "database_path": config.database_path, "capabilities": {"heartbeat": True, "jobs": True, "local_logs": True, "RTC_CCLASSTRIB_WRITE_V1": True}, "fiscal_engine_version": env.get("FISCAL_ENGINE_VERSION", config.agent_version), "dataset_versions": {}, } if hasattr(client, "heartbeat_metadata"): @@ -465,7 +481,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.1.9") + report = run_preflight(env, agent_version=env.get("AGENT_VERSION") or "1.2.0") 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": diff --git a/agent/src/gti_fiscal_agent/preflight.py b/agent/src/gti_fiscal_agent/preflight.py index 51f63b1..ebacaa7 100644 --- a/agent/src/gti_fiscal_agent/preflight.py +++ b/agent/src/gti_fiscal_agent/preflight.py @@ -9,7 +9,7 @@ import os import sqlite3 from pathlib import Path -AGENT_VERSION_DEFAULT = "1.1.9" +AGENT_VERSION_DEFAULT = "1.2.0" 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") diff --git a/agent/src/gti_fiscal_agent/rtc_auto_apply.py b/agent/src/gti_fiscal_agent/rtc_auto_apply.py index f6ddd14..57e3f8f 100644 --- a/agent/src/gti_fiscal_agent/rtc_auto_apply.py +++ b/agent/src/gti_fiscal_agent/rtc_auto_apply.py @@ -1,23 +1,26 @@ """RTC auto-apply: fills C000025.CCLASSTRIB, and NOTHING else, when a deterministic rule proves the class. -Safety model (every gate must hold, default is OFF everywhere): - FIREBIRD_WRITES effective (FISCAL_MODE=AUTO_APPLY + path/schema authorization) AND RTC_AUTO_APPLY_ENABLED (this Agent) AND Hub authorization (per installation) -Write allowlist: ALLOWED_RTC_WRITE_FIELDS = ["CCLASSTRIB"]. One short transaction per product. Immediately before the UPDATE the product is re-read and -the whole decision is re-derived from fresh data; if ANYTHING changed (RTC switch, catalog, NCM, current value, decision) that product is ABORTED. -An existing value is never overwritten. Every applied change returns an audit record with the old value, so it can be reverted manually and auditably. +Safety model (defense in depth, every gate must hold, default is OFF): the Hub sets a per-installation mode DISABLED / DRY_RUN / APPROVAL_REQUIRED / ENABLED and +delivers it inside an authenticated job. The Agent only exposes the restricted capability RTC_CCLASSTRIB_WRITE_V1; there is no local switch to edit (no agent.env, no FIREBIRD_WRITES). +Write allowlist: ALLOWED_RTC_WRITE_FIELDS = ["CCLASSTRIB"] on C000025 (no free SQL exists). One short transaction per product. Immediately before the UPDATE the product, +company, RTC parameter and local catalog are re-read and the whole decision is re-derived; if ANYTHING changed that product is ABORTED and nothing is written. +An existing value is never overwritten. Every applied change returns an audit record with the old value; ROLLBACK restores it only if the value still equals what was written. """ from __future__ import annotations import re from datetime import date -from central.app.cclasstrib_resolver import compare_catalogs, local_catalog_hash, resolve_cclasstrib +from central.app.cclasstrib_resolver import RULE_VERSION, compare_catalogs, local_catalog_hash, resolve_cclasstrib from central.app.unified_fiscal_coverage import rtc_enabled from .unified_fiscal import detect_rtc_capabilities, load_rtc_context ALLOWED_RTC_WRITE_FIELDS = ("CCLASSTRIB",) DEFAULT_MAX_PER_JOB = 500 +CAPABILITY = "RTC_CCLASSTRIB_WRITE_V1" +MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED") +MAX_PREVIEW_CANDIDATES = 5000 class RtcApplyAborted(Exception): @@ -36,10 +39,22 @@ def _digits(value) -> str: return re.sub(r"\D", "", _text(value)) -def write_gate(*, firebird_writes: bool, agent_enabled: bool, hub_authorized: bool) -> dict: - """{"allowed": bool, "missing": [...]}: writes are possible only when all three switches are on.""" - missing = [name for name, on in (("FIREBIRD_WRITES", firebird_writes), ("RTC_AUTO_APPLY_ENABLED", agent_enabled), ("HUB_AUTHORIZATION", hub_authorized)) if not on] - return {"allowed": not missing, "missing": missing, "firebird_writes": bool(firebird_writes), "agent_enabled": bool(agent_enabled), "hub_authorized": bool(hub_authorized)} +def write_gate(*, mode: str, capability: bool = True, approved: bool = True) -> dict: + """{"allowed": bool, "missing": [...]}: a real write needs the capability, an authenticated Hub mode that permits writing (ENABLED, or APPROVAL_REQUIRED for approved products).""" + mode = mode if mode in MODES else "DISABLED" + missing = [] + if not capability: + missing.append("CAPABILITY") + if mode not in ("ENABLED", "APPROVAL_REQUIRED"): + missing.append("HUB_MODE_" + mode) + elif mode == "APPROVAL_REQUIRED" and not approved: + missing.append("HUB_APPROVAL") + return {"allowed": not missing, "missing": missing, "mode": mode, "capability": bool(capability)} + + +def candidate_fingerprint(code, ncm, description, current) -> str: + import hashlib + return hashlib.sha256("|".join((_text(code), _digits(ncm), _text(description), _text(current))).encode("utf-8")).hexdigest()[:24] def select_candidates(findings: dict, products: list[dict], *, limit: int = DEFAULT_MAX_PER_JOB) -> tuple[list[dict], int]: @@ -56,7 +71,8 @@ def select_candidates(findings: dict, products: list[dict], *, limit: int = DEFA p = by_code.get(code, {}) if _text(p.get("CCLASSTRIB")): continue - ready.append({"product_code": code, "proposed": res["resolved_cclasstrib"], "cst": res["resolved_cst_ibscbs"], "rule_id": res["rule_id"], "ncm": _digits(p.get("CLASSIFICACAO_FISCAL")), "confidence_type": res["confidence_type"]}) + ready.append({"product_code": code, "proposed": res["resolved_cclasstrib"], "cst": res["resolved_cst_ibscbs"], "rule_id": res["rule_id"], "ncm": _digits(p.get("CLASSIFICACAO_FISCAL")), "confidence_type": res["confidence_type"], "description": _text(p.get("PRODUTO"))[:255], "current": "", "basis": f'{res["rule_id"]}:{res["confidence_type"]}', + "fingerprint": candidate_fingerprint(code, p.get("CLASSIFICACAO_FISCAL"), p.get("PRODUTO"), "")}) return ready[:limit], len(ready) @@ -78,7 +94,14 @@ def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now fresh_hash = local_catalog_hash(local_rows) if decision.get("local_catalog_hash") and fresh_hash != decision["local_catalog_hash"]: raise RtcApplyAborted("LOCAL_CATALOG_CHANGED_SINCE_DECISION") - cur.execute(f"SELECT {field}, CLASSIFICACAO_FISCAL FROM C000025 WHERE CODIGO = ?", (code,)) + cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004") + crts = {_text(r[0]) for r in cur.fetchall()} + expected_crt = _text((company or {}).get("crt")) + if not expected_crt or crts != {expected_crt}: + raise RtcApplyAborted("COMPANY_CONTEXT_CHANGED") + if expected_crt != "3": + raise RtcApplyAborted("REGIME_NOT_NORMAL") + cur.execute(f"SELECT {field}, CLASSIFICACAO_FISCAL, PRODUTO FROM C000025 WHERE CODIGO = ?", (code,)) rows = cur.fetchall() if len(rows) != 1: raise RtcApplyAborted("PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND") @@ -87,6 +110,8 @@ def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now raise RtcApplyAborted("VALUE_ALREADY_SET_NEVER_OVERWRITTEN" if current != proposed else "NO_WRITE_REQUIRED") if ncm != candidate["ncm"]: raise RtcApplyAborted("PRODUCT_CHANGED_NCM") + if candidate.get("fingerprint") and candidate_fingerprint(code, ncm, rows[0][2], current) != candidate["fingerprint"]: + raise RtcApplyAborted("PRODUCT_CHANGED_SINCE_DECISION") compat = compare_catalogs(local_rows, today) resolution = resolve_cclasstrib({"CODIGO": code, "CLASSIFICACAO_FISCAL": ncm, "ncm_valid": True}, company, {"rtc_enabled": True, "local_catalog_valid": True}, local_rows, compat=compat, today=today) if not resolution.auto_apply_candidate or resolution.resolved_cclasstrib != proposed or resolution.rule_id != candidate["rule_id"]: @@ -106,7 +131,8 @@ def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now return {"outcome": "APPLIED", "product_code": code, "field": field, "old_value": None, "new_value": proposed, "cst_ibscbs": resolution.resolved_cst_ibscbs, "rule_id": resolution.rule_id, "confidence_type": resolution.confidence_type, "evidence": {"inputs_used": resolution.inputs_used, "evidence": resolution.evidence[:5], "reason": resolution.reason}, "official_source_version": {"cclasstrib_table": official["publication"], "cclasstrib_sha256": official["sha256"], "lc214_sha256": resolution.source_references[0]["sha256"]}, - "local_catalog_hash": fresh_hash, "timestamp": now, "job_id": job_id, "rowcount": 1, "verified_after": True} + "local_catalog_hash": fresh_hash, "timestamp": now, "job_id": job_id, "rowcount": 1, "verified_after": True, "action": "APPLY", "rule_version": RULE_VERSION, + "company_crt": expected_crt, "description_snapshot": _text(rows[0][2])[:255], "rtc_status": "ENABLED", "local_catalog_state": (compat.get("per_code") or {}).get(proposed), "authorization_origin": decision.get("origin")} except RtcApplyAborted: try: conn.rollback() @@ -128,9 +154,53 @@ def apply_candidates(conn, candidates: list[dict], *, company: dict, decision: d try: applied.append(apply_candidate(conn, candidate, company=company, decision=decision, now=now, job_id=job_id, today=today)) except RtcApplyAborted as stop: - aborted.append({"outcome": "ABORTED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": stop.reason, "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now}) + aborted.append({"outcome": "ABORTED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": stop.reason, "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now, "action": "APPLY", "authorization_origin": decision.get("origin")}) reasons[stop.reason] = reasons.get(stop.reason, 0) + 1 except Exception as error: # noqa: BLE001 - technical failure of ONE product must not abort the batch aborted.append({"outcome": "FAILED", "product_code": candidate["product_code"], "field": "CCLASSTRIB", "reason": f"{type(error).__name__}", "rule_id": candidate["rule_id"], "job_id": job_id, "timestamp": now}) reasons["TECHNICAL_FAILURE"] = reasons.get("TECHNICAL_FAILURE", 0) + 1 return {"applied": applied, "aborted": aborted, "aborted_reasons": reasons, "write_attempts": len(candidates), "write_successes": len(applied)} + + +def rollback_one(conn, item: dict, *, now: str, job_id=None) -> dict: + """Restores C000025.CCLASSTRIB to empty for ONE previously applied audit record, only if the value still equals what was written. No free SQL: the only statement is fixed.""" + field = "CCLASSTRIB" + assert field in ALLOWED_RTC_WRITE_FIELDS + code, written = _text(item.get("product_code")), _text(item.get("written_value")) + base = {"product_code": code, "field": field, "job_id": job_id, "timestamp": now, "action": "ROLLBACK", "rollback_of": item.get("audit_id"), "old_value": written, "new_value": None, "rule_id": item.get("rule_id")} + if not code or not written or item.get("restore_value"): + return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_REQUEST_INVALID"} + cur = conn.cursor() + try: + cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,)) + rows = cur.fetchall() + if len(rows) != 1: + conn.rollback() + return {**base, "outcome": "ABORTED", "reason": "PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND"} + current = _text(rows[0][0]) + if current != written: + conn.rollback() + return {**base, "outcome": "ABORTED", "reason": "ROLLBACK_CONFLICT_VALUE_CHANGED" if current else "ALREADY_EMPTY"} + cur.execute(f"UPDATE C000025 SET {field} = NULL WHERE CODIGO = ? AND TRIM({field}) = ?", (code, written)) + if cur.rowcount != 1: + conn.rollback() + return {**base, "outcome": "ABORTED", "reason": "UPDATE_ROWCOUNT_NOT_ONE"} + conn.commit() + cur = conn.cursor() + cur.execute(f"SELECT {field} FROM C000025 WHERE CODIGO = ?", (code,)) + after = _text(cur.fetchone()[0]) + conn.commit() + if after: + raise RuntimeError("post-rollback verification failed") + return {**base, "outcome": "ROLLED_BACK", "rowcount": 1, "verified_after": True} + except Exception as error: # noqa: BLE001 + try: + conn.rollback() + except Exception: # noqa: BLE001 + pass + return {**base, "outcome": "FAILED", "reason": type(error).__name__} + + +def rollback_records(conn, items: list[dict], *, now: str, job_id=None) -> dict: + results = [rollback_one(conn, item, now=now, job_id=job_id) for item in items] + return {"rollbacks": results, "rolled_back": sum(1 for r in results if r["outcome"] == "ROLLED_BACK")} diff --git a/agent/tests/test_rtc_auto_apply.py b/agent/tests/test_rtc_auto_apply.py index db5e7ad..e8f5dcf 100644 --- a/agent/tests/test_rtc_auto_apply.py +++ b/agent/tests/test_rtc_auto_apply.py @@ -27,6 +27,7 @@ class FakeFirebird: for code, change in (catalog_tweak or {}).items(): self.catalog[code] = {**self.catalog[code], **change} self.cst_table = {r["cst"] for r in ref.OFFICIAL_CCLASSTRIB} # RTC_CST is its own table: a class may point to a CST that is not in it + self.crt = ("3",) # C000004.CRT rows self.statements, self.commits, self.rollbacks = [], 0, 0 self._pending = {} @@ -71,14 +72,23 @@ class FakeCursor: self._rows = [(c,) for c in sorted(db.cst_table)] elif up.startswith("SELECT CODIGO, CCLASSTRIB, CLASSIFICACAO_FISCAL"): self._rows = [(c, v["CCLASSTRIB"], v["CLASSIFICACAO_FISCAL"], "N", "NENHUMA") for c, v in sorted(db.products.items())] - elif up.startswith("SELECT CCLASSTRIB, CLASSIFICACAO_FISCAL FROM C000025"): + elif up.startswith("SELECT DISTINCT TRIM(CRT) FROM C000004"): + self._rows = [(c,) for c in db.crt] + elif up.startswith("SELECT CCLASSTRIB, CLASSIFICACAO_FISCAL, PRODUTO FROM C000025"): code = params[0] - self._rows = [(db.products[code]["CCLASSTRIB"], db.products[code]["CLASSIFICACAO_FISCAL"])] if code in db.products else [] + self._rows = [(db.products[code]["CCLASSTRIB"], db.products[code]["CLASSIFICACAO_FISCAL"], db.products[code].get("PRODUTO", ""))] if code in db.products else [] if code in db.products and db.products[code].get("DUP"): self._rows = self._rows * 2 elif up.startswith("SELECT CCLASSTRIB FROM C000025"): code = params[0] self._rows = [(db.products[code]["CCLASSTRIB"],)] + elif up.startswith("UPDATE C000025 SET CCLASSTRIB = NULL WHERE CODIGO = ? AND TRIM(CCLASSTRIB) = ?"): + code, written = params + product = db.products.get(code) + hit = product is not None and (product["CCLASSTRIB"] or "").strip() == written + self.rowcount = 1 if hit else 0 + if hit: + db._pending[code] = None elif up.startswith("UPDATE C000025 SET CCLASSTRIB = ? WHERE CODIGO = ?"): value, code = params product = db.products.get(code) @@ -115,21 +125,22 @@ def run(db, cands, out, **kw): return apply_mod.apply_candidates(db, cands, company=NORMAL, decision={"local_catalog_hash": out["context"]["local_catalog_hash"]}, now="2026-09-24T20:00:00+00:00", job_id=7, today=TODAY, **kw) -PRODUCTS = {"000001": product("10063021"), "000002": product("39235000"), "000003": product("15171000"), "000004": product("30049099"), "000005": product("39235000", "000001")} +PRODUCTS = {"000001": product("10063021"), "000002": product("04090000"), "000003": product("15171000"), "000004": product("30049099"), "000005": product("39235000", "000001")} -def test_gate_needs_all_three_switches_and_defaults_to_closed(): - assert apply_mod.write_gate(firebird_writes=True, agent_enabled=True, hub_authorized=True)["allowed"] is True - for combo in ((False, True, True), (True, False, True), (True, True, False), (False, False, False)): - g = apply_mod.write_gate(firebird_writes=combo[0], agent_enabled=combo[1], hub_authorized=combo[2]) +def test_gate_is_driven_by_the_central_mode_only_and_defaults_to_closed(): + assert apply_mod.write_gate(mode="ENABLED")["allowed"] is True + assert apply_mod.write_gate(mode="APPROVAL_REQUIRED", approved=True)["allowed"] is True + for kwargs in ({"mode": "DISABLED"}, {"mode": "DRY_RUN"}, {"mode": "APPROVAL_REQUIRED", "approved": False}, {"mode": "ENABLED", "capability": False}, {"mode": "bogus"}, {"mode": ""}): + g = apply_mod.write_gate(**kwargs) assert g["allowed"] is False and g["missing"] - assert apply_mod.ALLOWED_RTC_WRITE_FIELDS == ("CCLASSTRIB",) + assert apply_mod.ALLOWED_RTC_WRITE_FIELDS == ("CCLASSTRIB",) and apply_mod.CAPABILITY == "RTC_CCLASSTRIB_WRITE_V1" def test_only_proven_products_become_candidates(): db = FakeFirebird(PRODUCTS) out, cands, total = candidates_of(db) - assert sorted((c["product_code"], c["proposed"], c["rule_id"]) for c in cands) == [("000001", "200003", "RTC-R3"), ("000002", "000001", "RTC-R4"), ("000003", "200003", "RTC-R3")] + assert sorted((c["product_code"], c["proposed"], c["rule_id"]) for c in cands) == [("000001", "200003", "RTC-R3"), ("000002", "200034", "RTC-R3"), ("000003", "200003", "RTC-R3")] assert total == 3 and out["context"]["rtc_resolution"]["ready_to_apply"] == 3 and out["context"]["rtc_resolution"]["ambiguous"] == 1 # medicine chapter is ambiguous assert out["context"]["rtc_resolution"]["valid_existing"] == 1 # 000005 already has the right class assert "000004" not in {c["product_code"] for c in cands} @@ -140,7 +151,7 @@ def test_apply_writes_only_cclasstrib_with_audit_and_second_run_writes_nothing() out, cands, _ = candidates_of(db) result = run(db, cands, out) assert result["write_successes"] == 3 and result["write_attempts"] == 3 and result["aborted"] == [] - assert {c: db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")} == {"000001": "200003", "000002": "000001", "000003": "200003"} + assert {c: db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")} == {"000001": "200003", "000002": "200034", "000003": "200003"} updates = [s for s, _ in db.statements if s.upper().startswith("UPDATE")] assert len(updates) == 3 and all(re.fullmatch(r"UPDATE C000025 SET CCLASSTRIB = \? WHERE CODIGO = \? AND \(CCLASSTRIB IS NULL OR TRIM\(CCLASSTRIB\) = ''\)", u) for u in updates) assert not [s for s, _ in db.statements if re.match(r"(INSERT|DELETE|ALTER|DROP)", s.strip(), re.I)] @@ -184,7 +195,7 @@ def test_product_changed_between_decision_and_write_is_left_alone(): db = FakeFirebird(PRODUCTS) out, cands, _ = candidates_of(db) db.products["000001"]["CLASSIFICACAO_FISCAL"] = "30049099" # NCM edited in the ERP meanwhile - db.products["000002"]["CCLASSTRIB"] = "000001" # operator filled it meanwhile (same value) + db.products["000002"]["CCLASSTRIB"] = "200034" # operator filled it meanwhile (same value) db.products["000003"]["CCLASSTRIB"] = "200034" # ... or with a DIFFERENT value: never overwritten result = run(db, cands, out) assert result["aborted_reasons"] == {"PRODUCT_CHANGED_NCM": 1, "NO_WRITE_REQUIRED": 1, "VALUE_ALREADY_SET_NEVER_OVERWRITTEN": 1} and result["write_successes"] == 0 @@ -192,7 +203,7 @@ def test_product_changed_between_decision_and_write_is_left_alone(): def test_duplicate_code_rowcount_protection_and_isolation_of_failures(): - products = {**PRODUCTS, "000002": product("39235000", DUP=True)} + products = {**PRODUCTS, "000002": product("04090000", DUP=True)} db = FakeFirebird(products) out, cands, _ = candidates_of(db) result = run(db, cands, out) @@ -216,7 +227,7 @@ def test_official_mismatch_of_an_existing_class_is_reported(): def test_batch_limit_bounds_the_blast_radius(): - db = FakeFirebird({f"{i:06d}": product("39235000") for i in range(1, 8)}) + db = FakeFirebird({f"{i:06d}": product("04090000") for i in range(1, 8)}) out, cands, total = candidates_of(db, limit=3) assert len(cands) == 3 and total == 7 @@ -252,40 +263,110 @@ def _fiscal_for(db): return assess(db) -def test_job_reports_ready_products_but_writes_nothing_with_default_switches(monkeypatch): +def _products_of(db): + return [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"], "PRODUTO": v.get("PRODUTO", "")} for c, v in db.products.items()] + + +def _no_write(service, monkeypatch): + monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no write connection may be opened"))) + + +def test_job_without_a_central_mode_or_with_disabled_or_dry_run_never_writes(monkeypatch): service, _config = _service() db = FakeFirebird(PRODUCTS) fiscal = _fiscal_for(db) - monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no write connection may be opened"))) - products = [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"]} for c, v in db.products.items()] - for cfg_kwargs, auth in (({}, False), ({"firebird_writes": True}, False), ({"firebird_writes": True, "rtc_auto_apply_enabled": True}, False), ({"rtc_auto_apply_enabled": True}, True), ({"firebird_writes": True}, True)): - config = _config(**cfg_kwargs) - summary = service._rtc_auto_apply({}, config, {"job_id": 1, "authorizations": {"rtc_auto_apply": auth}}, products, fiscal, NORMAL) - assert summary["mode"] == "REPORT_ONLY" and summary["gate"]["allowed"] is False and summary["ready_total"] == 3 and summary["write_attempts"] == 0 and summary["applied"] == [] + _no_write(service, monkeypatch) + for auth in ({}, {"rtc_write_mode": "DISABLED"}, {"rtc_write_mode": "DRY_RUN"}, {"rtc_write_mode": "bogus"}, {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": []}, {"rtc_auto_apply": True}): + summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": auth}, _products_of(db), fiscal, NORMAL) + assert summary["gate"]["allowed"] is False and summary["ready_total"] == 3 and summary["write_attempts"] == 0 and summary["applied"] == [] + off = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "DISABLED"}}, _products_of(db), fiscal, NORMAL) + dry = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "DRY_RUN"}}, _products_of(db), fiscal, NORMAL) + assert off["preview"] == [] and off["mode"] == "REPORT_ONLY" + assert [c["product_code"] for c in dry["preview"]] == ["000001", "000002", "000003"] and all(c["rule_id"] == "RTC-R3" for c in dry["preview"]) and dry["capability"] == "RTC_CCLASSTRIB_WRITE_V1" -def test_job_applies_only_when_all_three_switches_are_open(monkeypatch): +def test_r4_suggestions_are_never_in_the_preview_and_never_written(monkeypatch): + service, _config = _service() + db = FakeFirebird({"000001": product("39235000"), "000002": product("39235000")}) + fiscal = _fiscal_for(db) + _no_write(service, monkeypatch) + summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL) + assert summary["ready_total"] == 0 and summary["preview"] == [] and summary["write_attempts"] == 0 + + +def test_enabled_mode_applies_without_any_local_switch(monkeypatch): service, _config = _service() db = FakeFirebird(PRODUCTS) fiscal = _fiscal_for(db) monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db)) - products = [{"CODIGO": c, "CCLASSTRIB": v["CCLASSTRIB"], "CLASSIFICACAO_FISCAL": v["CLASSIFICACAO_FISCAL"]} for c, v in db.products.items()] - config = _config(firebird_writes=True, rtc_auto_apply_enabled=True) - summary = service._rtc_auto_apply({}, config, {"job_id": 9, "authorizations": {"rtc_auto_apply": True}}, products, fiscal, NORMAL) - assert summary["mode"] == "AUTO_APPLY" and summary["write_successes"] == 3 and summary["allowed_fields"] == ["CCLASSTRIB"] and all(a["job_id"] == 9 for a in summary["applied"]) - assert [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == ["200003", "000001", "200003"] + summary = service._rtc_auto_apply({}, _config(), {"job_id": 9, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL) # default config: no firebird_writes, no env switch + assert summary["mode"] == "ENABLED" and summary["write_successes"] == 3 and summary["allowed_fields"] == ["CCLASSTRIB"] and all(a["job_id"] == 9 and a["authorization_origin"] == "HUB_MODE:ENABLED" for a in summary["applied"]) + assert [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == ["200003", "200034", "200003"] + a = summary["applied"][0] + assert a["rule_version"] and a["company_crt"] == "3" and a["action"] == "APPLY" and a["rtc_status"] == "ENABLED" and a["local_catalog_state"] == "MATCH_SAFE" and a["old_value"] is None -def test_job_never_writes_when_the_erp_says_rtc_is_off_even_with_all_switches_open(monkeypatch): +def test_approval_required_writes_only_the_approved_products(monkeypatch): + service, _config = _service() + db = FakeFirebird(PRODUCTS) + fiscal = _fiscal_for(db) + monkeypatch.setattr(service, "open_write_connection", lambda env, path: _Closeable(db)) + summary = service._rtc_auto_apply({}, _config(), {"job_id": 9, "authorizations": {"rtc_write_mode": "APPROVAL_REQUIRED", "rtc_approved_codes": ["000002"]}}, _products_of(db), fiscal, NORMAL) + assert summary["write_successes"] == 1 and [db.products[c]["CCLASSTRIB"] for c in ("000001", "000002", "000003")] == [None, "200034", None] + + +def test_job_never_writes_when_the_erp_says_rtc_is_off_even_in_enabled_mode(monkeypatch): service, _config = _service() db = FakeFirebird(PRODUCTS, param=(0,)) fiscal = _fiscal_for(db) - monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(AssertionError("no write connection may be opened"))) - products = [{"CODIGO": c, "CCLASSTRIB": None, "CLASSIFICACAO_FISCAL": "39235000"} for c in db.products] - summary = service._rtc_auto_apply({}, _config(firebird_writes=True, rtc_auto_apply_enabled=True), {"job_id": 1, "authorizations": {"rtc_auto_apply": True}}, products, fiscal, NORMAL) + _no_write(service, monkeypatch) + products = [{"CODIGO": c, "CCLASSTRIB": None, "CLASSIFICACAO_FISCAL": "04090000"} for c in db.products] + summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, products, fiscal, NORMAL) assert summary["write_attempts"] == 0 and summary["ready_total"] == 0 +def test_company_regime_or_fingerprint_change_aborts_before_the_update(): + db = FakeFirebird(PRODUCTS) + out, cands, _ = candidates_of(db) + db.crt = ("1",) # the company was switched to Simples between decision and write + res = run(db, cands, out) + assert res["write_successes"] == 0 and res["aborted_reasons"] == {"COMPANY_CONTEXT_CHANGED": 3} + db.crt = ("3", "1") # ambiguous company context is also refused + assert run(db, cands, out)["aborted_reasons"] == {"COMPANY_CONTEXT_CHANGED": 3} + db.crt = ("3",) + db.products["000001"]["PRODUTO"] = "RENAMED AFTER THE DECISION" + res = run(db, cands, out) + assert res["aborted_reasons"] == {"PRODUCT_CHANGED_SINCE_DECISION": 1} and res["write_successes"] == 2 and db.products["000001"]["CCLASSTRIB"] is None + assert not any("UPDATE" in q.upper() and "C000004" in q.upper() for q, _ in db.statements) + + +def test_rollback_restores_only_when_the_value_still_equals_the_written_one_and_reapply_works(): + db = FakeFirebird(PRODUCTS) + out, cands, _ = candidates_of(db) + first = run(db, cands, out) + assert first["write_successes"] == 3 + items = [{"audit_id": i + 1, "product_code": a["product_code"], "written_value": a["new_value"], "rule_id": a["rule_id"]} for i, a in enumerate(first["applied"])] + db.products["000002"]["CCLASSTRIB"] = "200035" # an operator changed it afterwards + res = apply_mod.rollback_records(db, items, now="2026-09-24T21:00:00+00:00", job_id=8) + by = {r["product_code"]: r for r in res["rollbacks"]} + assert res["rolled_back"] == 2 and by["000002"]["outcome"] == "ABORTED" and by["000002"]["reason"] == "ROLLBACK_CONFLICT_VALUE_CHANGED" and db.products["000002"]["CCLASSTRIB"] == "200035" + assert by["000001"]["outcome"] == "ROLLED_BACK" and by["000001"]["rollback_of"] == 1 and by["000001"]["old_value"] == "200003" and by["000001"]["new_value"] is None and db.products["000001"]["CCLASSTRIB"] is None + again = apply_mod.rollback_records(db, items, now="2026-09-24T21:05:00+00:00", job_id=9)["rollbacks"] + assert [r["reason"] for r in again if r["product_code"] == "000001"] == ["ALREADY_EMPTY"] + out2, cands2, _ = candidates_of(db) # reapply: the emptied products are candidates again, the changed one is not + assert sorted(c["product_code"] for c in cands2) == ["000001", "000003"] and run(db, cands2, out2)["write_successes"] == 2 + + +def test_rollback_uses_one_fixed_statement_and_rejects_malformed_requests(): + db = FakeFirebird(PRODUCTS) + bad = apply_mod.rollback_records(db, [{"audit_id": 1, "product_code": "000001", "written_value": ""}, {"audit_id": 2, "product_code": "000001", "written_value": "200003", "restore_value": "999999"}], now="t") + assert [r["reason"] for r in bad["rollbacks"]] == ["ROLLBACK_REQUEST_INVALID"] * 2 and db.statements == [] + db.products["000001"]["CCLASSTRIB"] = "200003" + apply_mod.rollback_records(db, [{"audit_id": 3, "product_code": "000001", "written_value": "200003"}], now="t") + writes = [q for q, _ in db.statements if q.strip().upper().startswith(("UPDATE", "INSERT", "DELETE"))] + assert writes == ["UPDATE C000025 SET CCLASSTRIB = NULL WHERE CODIGO = ? AND TRIM(CCLASSTRIB) = ?"] + + class _Closeable: def __init__(self, db): self._db = db @@ -295,3 +376,26 @@ class _Closeable: def close(self): pass + + +def test_firebird_unavailable_for_the_write_connection_fails_the_job_closed_with_zero_writes(monkeypatch): + service, _config = _service() + db = FakeFirebird(PRODUCTS) + fiscal = _fiscal_for(db) + monkeypatch.setattr(service, "open_write_connection", lambda *a, **k: (_ for _ in ()).throw(OSError("Firebird unavailable"))) + import pytest as _pytest + with _pytest.raises(OSError): + service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL) + assert all(v["CCLASSTRIB"] is None for c, v in db.products.items() if c != "000005") and not [q for q, _ in db.statements if q.upper().startswith("UPDATE")] + + +def test_connection_is_closed_even_when_a_product_fails_mid_batch(monkeypatch): + service, _config = _service() + db = FakeFirebird({**PRODUCTS, "000002": product("04090000", DUP=True)}) + fiscal = _fiscal_for(db) + closed = [] + conn = _Closeable(db) + conn.close = lambda: closed.append(True) + monkeypatch.setattr(service, "open_write_connection", lambda env, path: conn) + summary = service._rtc_auto_apply({}, _config(), {"job_id": 1, "authorizations": {"rtc_write_mode": "ENABLED"}}, _products_of(db), fiscal, NORMAL) + assert closed == [True] and summary["write_successes"] == 2 and summary["aborted_reasons"] diff --git a/central/alembic/versions/0028_rtc_write_modes.py b/central/alembic/versions/0028_rtc_write_modes.py new file mode 100644 index 0000000..0f384da --- /dev/null +++ b/central/alembic/versions/0028_rtc_write_modes.py @@ -0,0 +1,47 @@ +"""RTC central write modes per installation, candidate preview/approval table, richer audit + rollback tracking. + +Revision ID: 0028_rtc_write_modes +Revises: 0027_rtc_auto_apply +""" +from alembic import op +import sqlalchemy as sa + +revision = "0028_rtc_write_modes" +down_revision = "0027_rtc_auto_apply" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column("installations", sa.Column("rtc_write_mode", sa.String(24), nullable=False, server_default="DISABLED")) + op.execute("UPDATE installations SET rtc_write_mode = 'ENABLED' WHERE rtc_auto_apply_authorized") # keeps an existing explicit authorization + for name, kind in (("rule_version", sa.String(32)), ("company_crt", sa.String(8)), ("authorization_origin", sa.String(48)), ("description_snapshot", sa.String(255)), + ("installation_uuid", sa.String(64)), ("rtc_status", sa.String(24)), ("action", sa.String(16)), ("rollback_state", sa.String(16)), ("rollback_of", sa.Integer()), + ("local_catalog_state", sa.String(32))): + op.add_column("rtc_write_audits", sa.Column(name, kind, nullable=True)) + op.create_index("ix_rtc_write_audits_rollback_of", "rtc_write_audits", ["rollback_of"]) + op.create_table( + "rtc_candidates", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id"), nullable=False, index=True), + sa.Column("installation_id", sa.Integer(), sa.ForeignKey("installations.id"), nullable=False, index=True), + sa.Column("product_code", sa.String(64), nullable=False), + sa.Column("description", sa.String(255), nullable=True), + sa.Column("ncm", sa.String(16), nullable=True), + sa.Column("current_value", sa.String(16), nullable=True), + sa.Column("proposed", sa.String(16), nullable=False), + sa.Column("cst_ibscbs", sa.String(8), nullable=True), + sa.Column("rule_id", sa.String(32), nullable=False), + sa.Column("basis", sa.String(255), nullable=True), + sa.Column("status", sa.String(16), nullable=False, server_default="PENDING", index=True), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()), + sa.UniqueConstraint("installation_id", "product_code", name="uq_rtc_candidate_product"), + ) + + +def downgrade(): + op.drop_table("rtc_candidates") + op.drop_index("ix_rtc_write_audits_rollback_of", table_name="rtc_write_audits") + for name in ("rule_version", "company_crt", "authorization_origin", "description_snapshot", "installation_uuid", "rtc_status", "action", "rollback_state", "rollback_of", "local_catalog_state"): + op.drop_column("rtc_write_audits", name) + op.drop_column("installations", "rtc_write_mode") diff --git a/central/app/api.py b/central/app/api.py index 1a4bbce..edea918 100644 --- a/central/app/api.py +++ b/central/app/api.py @@ -11,8 +11,8 @@ from .classification import ENGINE_VERSION, Classifier from .config import settings from .datasets.service import DatasetService from .jobs import SYNC_KINDS, claim_next_job, complete_job, create_job, fail_job, schedule_automatic_sync -from .rtc_audit import record_rtc_write_audits -from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, resolve_stale_fiscal_blockers, upsert_blocker +from .rtc_audit import job_authorizations, record_rtc_write_audits +from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, resolve_stale_fiscal_blockers, upsert_blocker, upsert_blockers_bulk from .context_keys import validate_entry from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job from .events import record_event @@ -395,7 +395,7 @@ def router(session_factory, bootstrap_token): session.commit() logger.info("job claimed job_id=%s agent_id=%s kind=%s", job.id, agent.id, job.kind) view = _job_view(job) - view["authorizations"] = {"rtc_auto_apply": bool(installation is not None and installation.rtc_auto_apply_authorized)} # decided centrally, per installation, default OFF + view["authorizations"] = job_authorizations(session, installation) if installation is not None else {"rtc_write_mode": "DISABLED"} # decided centrally, per installation, default OFF return view finally: session.close() @@ -426,12 +426,11 @@ def router(session_factory, bootstrap_token): result_summary = payload.get("result_summary", {}) installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id)) pass_started = datetime.now(timezone.utc) - for blocker in result_summary.get("blocked_products", []): - upsert_blocker(session, agent.tenant_id, blocker, installation_id=installation.id if installation else None) + upsert_blockers_bulk(session, agent.tenant_id, list(result_summary.get("blocked_products", [])), installation_id=installation.id if installation else None) for product_code in result_summary.get("resolved_products", []): resolve_product_blockers(session, agent.tenant_id, product_code, installation_id=installation.id if installation else None) if installation is not None: - record_rtc_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, rtc_auto_apply=result_summary.get("rtc_auto_apply")) + record_rtc_write_audits(session, tenant_id=agent.tenant_id, installation=installation, agent=agent, job_id=job_id, rtc_auto_apply=result_summary.get("rtc_auto_apply"), evaluated_all=bool((result_summary.get("fiscal_context") or {}).get("evaluated_all"))) if installation is not None and (result_summary.get("fiscal_context") or {}).get("evaluated_all"): resolve_stale_fiscal_blockers(session, agent.tenant_id, installation.id, pass_started) if not complete_job(session, job, result_summary): raise HTTPException(409, "job is not RUNNING") diff --git a/central/app/blocked_products.py b/central/app/blocked_products.py index 042a5fe..9ff389c 100644 --- a/central/app/blocked_products.py +++ b/central/app/blocked_products.py @@ -7,19 +7,26 @@ from .models import BlockedProduct from .operational_state import blocker_fingerprint -def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: int | None = None) -> BlockedProduct: +def _fingerprint_of(tenant_id: int, payload: dict, installation_id: int | None) -> str: supplied_fingerprint = payload.get("blocker_fingerprint") if installation_id is None: - fingerprint = supplied_fingerprint or blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", "")) + return supplied_fingerprint or blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", "")) elif supplied_fingerprint: - fingerprint = blocker_fingerprint(supplied_fingerprint, "", "", "", "", "", installation_id=installation_id) + return blocker_fingerprint(supplied_fingerprint, "", "", "", "", "", installation_id=installation_id) else: - fingerprint = blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""), installation_id=installation_id) - row = session.scalar(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint == fingerprint)) + return blocker_fingerprint(payload.get("tenant", str(tenant_id)), payload.get("company", ""), payload.get("product_code", ""), payload.get("field", ""), payload.get("reason_code", ""), payload.get("input_fingerprint", ""), installation_id=installation_id) + + +def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: int | None = None, commit: bool = True, existing: dict | None = None) -> BlockedProduct: + """`existing` ({fingerprint: row}) and `commit=False` let a caller upsert thousands of blockers with one lookup per chunk and one commit (see upsert_blockers_bulk).""" + fingerprint = _fingerprint_of(tenant_id, payload, installation_id) + row = existing.get(fingerprint) if existing is not None else session.scalar(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint == fingerprint)) now = datetime.now(timezone.utc) if row is None: row = BlockedProduct(tenant_id=tenant_id, installation_id=installation_id, company=payload.get("company", ""), product_code=str(payload.get("product_code", "")), gtin=payload.get("gtin"), description=payload.get("description"), blocker_type=payload.get("blocker_type", "BLOCKED_MISSING_EVIDENCE"), field=payload.get("field", ""), reason_code=payload.get("reason_code", ""), evidence_summary=payload.get("evidence_summary") or {}, input_fingerprint=payload.get("input_fingerprint", ""), blocker_fingerprint=fingerprint, first_seen_at=now, last_seen_at=now) session.add(row) + if existing is not None: + existing[fingerprint] = row else: row.last_seen_at = now row.evidence_summary = payload.get("evidence_summary") or row.evidence_summary @@ -27,10 +34,29 @@ def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: i if row.installation_id is None and installation_id is not None: row.installation_id = installation_id row.resolved_at = None - session.commit() + if commit: + session.commit() return row +BULK_CHUNK = 1000 + + +def upsert_blockers_bulk(session, tenant_id: int, payloads: list, *, installation_id: int | None = None) -> int: + """Same semantics as `upsert_blocker` per item, but O(chunks) statements instead of O(products): one IN-lookup per chunk, one commit at the end.""" + n = 0 + for start in range(0, len(payloads), BULK_CHUNK): + chunk = [p for p in payloads[start:start + BULK_CHUNK] if isinstance(p, dict)] + prints = {_fingerprint_of(tenant_id, p, installation_id) for p in chunk} + existing = {r.blocker_fingerprint: r for r in session.scalars(select(BlockedProduct).where(BlockedProduct.blocker_fingerprint.in_(prints)))} if prints else {} + for payload in chunk: + upsert_blocker(session, tenant_id, payload, installation_id=installation_id, commit=False, existing=existing) + n += 1 + session.flush() + session.commit() + return n + + def list_blockers(session, tenant_id: int, *, input_fingerprint: str | None = None, blocker_type: str | None = None, field: str | None = None, installation_id: int | None = None): query = select(BlockedProduct).where(BlockedProduct.tenant_id == tenant_id, BlockedProduct.resolved_at.is_(None)) if installation_id is not None: diff --git a/central/app/cclasstrib_resolver.py b/central/app/cclasstrib_resolver.py index 4093fde..22139e7 100644 --- a/central/app/cclasstrib_resolver.py +++ b/central/app/cclasstrib_resolver.py @@ -8,17 +8,27 @@ Nothing here guesses. A class is proposed only when an EXPLICIT rule, backed by Operation assumption (documented, product-level default): an ordinary domestic sale of the good. Operation-dependent classes (buyer is a public body, exports, ZFM/ALC, credit presumed, ...) cannot be carried by a product default; when the annex offers such alternatives the answer is AMBIGUOUS. +Rule classes (what a rule is ALLOWED to do) + AUTO_APPLY_SAFE positive, explicit evidence: may be written to the ERP (only when every runtime gate also holds) + VALIDATION_ONLY used to validate/explain, never proposes a value + SUGGESTION_ONLY diagnostic suggestion shown to a human; NEVER authorizes an UPDATE + BLOCKED_BY_EVIDENCE the evidence is missing/ambiguous: safe-block with a human reason + Rules - RTC-R0 RTC disabled in the ERP -> NOT_APPLICABLE - RTC-R1 Simples family (CRT 1/2/4) -> INSUFFICIENT_EVIDENCE (NT 2025.002 v1.51: orientation for these taxpayers pending) - RTC-R2 no valid 8-digit NCM (decision engine did not confirm) -> INSUFFICIENT_EVIDENCE - RTC-A1 NCM listed in a REVOKED annex or an annex without a unique product-default class, or under a conditional item text -> AMBIGUOUS - RTC-A2 NCM listed in more than one annex -> AMBIGUOUS - RTC-A3 NCM in a chapter whose special regime depends on the nature/operation of the good, not on a code list -> AMBIGUOUS - RTC-R3 NCM listed in exactly one annex whose product-default class is unique, item text has no condition -> RESOLVED_WITH_EXPLICIT_RULE - RTC-R4 NCM listed in NO annex and in no nature-based chapter -> RESOLVED_WITH_EXPLICIT_RULE 000001 (integral taxation, LC 214 art. 4 general rule) -A proposed class must additionally be present in the local ERP catalog, MATCH the official table, be in force today, be usable in NF-e/NFC-e (the SGV's own -lookup filter) and carry a CST that exists; otherwise the answer degrades to INSUFFICIENT_EVIDENCE with the exact reason. + RTC-R0 RTC disabled in the ERP VALIDATION_ONLY -> NOT_APPLICABLE + RTC-G1 local RTC catalog failed integrity BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE + RTC-R1 regime without an official rule (Simples family, unknown) BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE + RTC-R2 no valid 8-digit NCM (decision engine did not confirm) BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE + RTC-A1 annex without a unique product-default class / conditional text / revoked annex -> AMBIGUOUS + RTC-A2 NCM listed in more than one annex -> AMBIGUOUS + RTC-A3 chapter whose regime depends on the nature/operation of the good -> AMBIGUOUS + RTC-R3 NCM listed in EXACTLY ONE annex with a unique default class, condition-free item, regime normal (CRT 3) + AUTO_APPLY_SAFE -> RESOLVED_WITH_EXPLICIT_RULE + RTC-R4 NCM in NO annex and in no nature-based chapter: full taxation is the residual reading of LC 214 art. 4 + SUGGESTION_ONLY -> SUGGESTION_ONLY (000001). Absence of a special regime is NOT positive evidence: never written. + RTC-G2 proposed class missing/different in the local SGV catalog, out of force or unusable in NF-e/NFC-e BLOCKED_BY_EVIDENCE -> INSUFFICIENT_EVIDENCE (RTC_LOCAL_CATALOG_INCOMPATIBLE) +A proposed class must additionally be MATCH_SAFE against the official table (local SGV catalog confirms every attribute that matters), in force today, usable in +NF-e/NFC-e (the SGV's own lookup filter) and carry a CST that exists; otherwise the answer degrades with the exact reason. """ from __future__ import annotations @@ -32,7 +42,12 @@ from . import rtc_reference_data as ref SIMPLES_FAMILY = frozenset({"1", "2", "4"}) RESOLVED_STATUSES = ("RESOLVED_DETERMINISTIC", "RESOLVED_WITH_EXPLICIT_RULE") -STATUSES = RESOLVED_STATUSES + ("AMBIGUOUS", "INSUFFICIENT_EVIDENCE", "NOT_APPLICABLE") +STATUSES = RESOLVED_STATUSES + ("SUGGESTION_ONLY", "AMBIGUOUS", "INSUFFICIENT_EVIDENCE", "NOT_APPLICABLE") +RULE_VERSION = "rtc-rules-v2" +AUTO_APPLY_SAFE, VALIDATION_ONLY, SUGGESTION_ONLY, BLOCKED_BY_EVIDENCE = "AUTO_APPLY_SAFE", "VALIDATION_ONLY", "SUGGESTION_ONLY", "BLOCKED_BY_EVIDENCE" +RULE_CLASSES = {"RTC-R0": VALIDATION_ONLY, "RTC-G1": BLOCKED_BY_EVIDENCE, "RTC-R1": BLOCKED_BY_EVIDENCE, "RTC-R2": BLOCKED_BY_EVIDENCE, "RTC-A1": BLOCKED_BY_EVIDENCE, "RTC-A2": BLOCKED_BY_EVIDENCE, + "RTC-A3": BLOCKED_BY_EVIDENCE, "RTC-R3": AUTO_APPLY_SAFE, "RTC-R4": SUGGESTION_ONLY, "RTC-G2": BLOCKED_BY_EVIDENCE} +HUMAN_REASONS = {"SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": "Enquadramento RTC depende de regra ainda não determinável com segurança.", "COMPANY_REGIME_UNKNOWN": "Regime tributário da empresa não determinado."} # Goods annexes whose product-default class is unique in the official table (verified by `verify_annex_class_map`). ANNEX_DEFAULT_CLASS = {"I": "200003", "VII": "200034", "VIII": "200035", "XV": "200014"} # IX is out: 515001 (deferral with the same inputs) is a sibling class @@ -55,14 +70,23 @@ class Resolution: evidence: list = field(default_factory=list) source_references: list = field(default_factory=list) + @property + def rule_class(self) -> str: + return RULE_CLASSES.get(self.rule_id, BLOCKED_BY_EVIDENCE) + @property def auto_apply_candidate(self) -> bool: - return self.status in RESOLVED_STATUSES and bool(self.resolved_cclasstrib) + """Only an AUTO_APPLY_SAFE rule with a proven class may ever become a write. Suggestions never do.""" + return self.rule_class == AUTO_APPLY_SAFE and self.status in RESOLVED_STATUSES and bool(self.resolved_cclasstrib) + + @property + def suggested_cclasstrib(self) -> str | None: + return self.resolved_cclasstrib if self.status == "SUGGESTION_ONLY" else None def as_dict(self) -> dict: - return {"status": self.status, "rule_id": self.rule_id, "reason": self.reason, "resolved_cclasstrib": self.resolved_cclasstrib, "resolved_cst_ibscbs": self.resolved_cst_ibscbs, - "confidence_type": self.confidence_type, "inputs_used": self.inputs_used, "evidence": self.evidence, "source_references": self.source_references, - "auto_apply_candidate": self.auto_apply_candidate} + return {"status": self.status, "rule_id": self.rule_id, "rule_class": self.rule_class, "rule_version": RULE_VERSION, "reason": self.reason, "resolved_cclasstrib": self.resolved_cclasstrib, + "resolved_cst_ibscbs": self.resolved_cst_ibscbs, "confidence_type": self.confidence_type, "inputs_used": self.inputs_used, "evidence": self.evidence, + "source_references": self.source_references, "auto_apply_candidate": self.auto_apply_candidate} def _digits(value) -> str: @@ -85,34 +109,50 @@ def _num(value): def compare_catalogs(local_rows: dict, today: date | None = None) -> dict: """local_rows: {code: {cst, tipo_aliquota, p_red_ibs, p_red_cbs, ind_nfe, ind_nfce, start, end}} read from the ERP. Never writes anywhere. - Per code: MATCH | LOCAL_ONLY | OFFICIAL_ONLY | MISMATCH | EXPIRED (official table is the reference).""" + Per code (the official table is the reference): + MATCH_SAFE every attribute the local table carries that matters to a product default agrees (CST, reductions, NF-e/NFC-e usability) + MATCH_WITH_NON_MATERIAL_DIFFERENCE only a scheduled local end date (class still in force today) differs + MISMATCH CST, reductions or NF-e/NFC-e usability differ + EXPIRED the official class ended + MISSING_LOCAL official class absent from the SGV catalog (local outdated) + MISSING_OFFICIAL local class the official table does not know""" today = today or date.today() official = _official_by_code() result, details = {}, {} for code in sorted(set(local_rows) | set(official)): loc, off = local_rows.get(code), official.get(code) if off is None: - result[code] = "LOCAL_ONLY" + result[code] = "MISSING_OFFICIAL" elif off.get("end") and date.fromisoformat(off["end"]) < today: result[code] = "EXPIRED" details[code] = ["OFFICIAL_END_PASSED"] + (["PRESENT_LOCALLY"] if loc is not None else []) elif loc is None: - result[code] = "OFFICIAL_ONLY" + result[code] = "MISSING_LOCAL" else: - problems = [] + problems, soft, info = [], [], [] if str(loc.get("cst") or "").strip() != off["cst"]: problems.append("CST") if "p_red_ibs" in loc and "p_red_cbs" in loc and (_num(loc.get("p_red_ibs")) != _num(off["p_red_ibs"]) or _num(loc.get("p_red_cbs")) != _num(off["p_red_cbs"])): problems.append("REDUCTION") # only attributes the local table actually carries are compared if "ind_nfe" in loc and "ind_nfce" in loc and (bool(loc.get("ind_nfe")) != off["ind_nfe"] or bool(loc.get("ind_nfce")) != off["ind_nfce"]): problems.append("DOCUMENT_FLAGS") - result[code] = "MISMATCH" if problems else "MATCH" - if problems: - details[code] = problems - counts = {k: sum(1 for v in result.values() if v == k) for k in ("MATCH", "LOCAL_ONLY", "OFFICIAL_ONLY", "MISMATCH", "EXPIRED")} - material = counts["MISMATCH"] + counts["LOCAL_ONLY"] # a local class the official table contradicts or does not know - expired_present = sum(1 for c, d in details.items() if "PRESENT_LOCALLY" in d) - compat = "FULL" if counts["OFFICIAL_ONLY"] == 0 and expired_present == 0 and material == 0 else ("PARTIAL_LOCAL_OUTDATED" if material == 0 else "MATERIAL_MISMATCH") + start, end = str(loc.get("start") or "")[:10], str(loc.get("end") or "")[:10] + if start and date.fromisoformat(start) > today: + problems.append("LOCAL_NOT_IN_FORCE_YET") + elif start and off.get("start") and start != off["start"]: + info.append("VIGENCY_START_DIFFERS_BOTH_IN_FORCE") # informational: the class is in force today on both sides, so the emission effect is identical + if end and date.fromisoformat(end) < today: + problems.append("LOCAL_CLASS_ENDED") + elif end: + soft.append("LOCAL_END_SCHEDULED") # the class still works today; the local table schedules an end the official one does not + result[code] = "MISMATCH" if problems else ("MATCH_WITH_NON_MATERIAL_DIFFERENCE" if soft else "MATCH_SAFE") + if problems or soft or info: + details[code] = problems + soft + info + keys = ("MATCH_SAFE", "MATCH_WITH_NON_MATERIAL_DIFFERENCE", "MISMATCH", "EXPIRED", "MISSING_LOCAL", "MISSING_OFFICIAL") + counts = {k: sum(1 for v in result.values() if v == k) for k in keys} + material = counts["MISMATCH"] + counts["MISSING_OFFICIAL"] # a local class the official table contradicts or does not know + expired_present = sum(1 for d in details.values() if "PRESENT_LOCALLY" in d) + compat = "FULL" if counts["MISSING_LOCAL"] == 0 and expired_present == 0 and material == 0 else ("PARTIAL_LOCAL_OUTDATED" if material == 0 else "MATERIAL_MISMATCH") return {"per_code": result, "details": details, "counts": counts, "expired_present_locally": expired_present, "compatibility": compat, "official_catalog": {"sha256": ref.OFFICIAL_CATALOG["sha256"], "publication": ref.OFFICIAL_CATALOG["publication"], "file": ref.OFFICIAL_CATALOG["file"]}} @@ -174,7 +214,7 @@ def _validate_class(code: str, local_rows: dict, compat: dict, today: date): if loc is None: return "CLASS_NOT_IN_LOCAL_CATALOG" state = compat["per_code"].get(code) - if state != "MATCH": + if state != "MATCH_SAFE": return f"LOCAL_OFFICIAL_{state}" if official["start"] and date.fromisoformat(official["start"]) > today: return "CLASS_NOT_YET_IN_FORCE" @@ -198,8 +238,9 @@ def resolve_cclasstrib(product: dict, company: dict | None, ctx: dict, local_row return Resolution("INSUFFICIENT_EVIDENCE", "RTC-G1", "LOCAL_RTC_CATALOG_INVALID", inputs_used=used) if crt in SIMPLES_FAMILY: return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R1", "SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT", inputs_used=used, - source_references=_references([{"document": "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026)", "note": "IBS/CBS orientations for CRT 1/2/4 pending; taxation from 2027 (LC 214 art. 348)"}])) - if not crt: + source_references=_references([{"document": "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026)", "note": "IBS/CBS orientations for CRT 1/2/4 pending"}, + {"document": "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026)", "note": "art. 1 par. 1: DF-e obligation for Simples optantes starts 2027-01-01"}])) + if crt != "3": return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R1", "COMPANY_REGIME_UNKNOWN", inputs_used=used) if len(ncm) != 8 or product.get("ncm_valid") is not True: return Resolution("INSUFFICIENT_EVIDENCE", "RTC-R2", "NO_VALID_NCM", inputs_used=used) @@ -229,5 +270,5 @@ def resolve_cclasstrib(product: dict, company: dict | None, ctx: dict, local_row problem = _validate_class("000001", local_rows, compat, today) if problem: return Resolution("INSUFFICIENT_EVIDENCE", "RTC-G2", problem, inputs_used=used, source_references=_references()) - return Resolution("RESOLVED_WITH_EXPLICIT_RULE", "RTC-R4", "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER", "000001", "000", "EXCLUSION_OF_ALL_SPECIAL_REGIMES", used, - [{"annex_hits": 0, "chapter": ncm[:2]}], _references()) + return Resolution("SUGGESTION_ONLY", "RTC-R4", "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER", "000001", "000", "EXCLUSION_OF_ALL_SPECIAL_REGIMES", used, + [{"annex_hits": 0, "chapter": ncm[:2], "note": "absence of a special regime is not positive evidence: suggestion only, never written"}], _references()) diff --git a/central/app/models.py b/central/app/models.py index ec2debc..40a58fc 100644 --- a/central/app/models.py +++ b/central/app/models.py @@ -283,7 +283,8 @@ class Installation(Base): last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_sync_changed: Mapped[int | None] = mapped_column(Integer, nullable=True) # 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") + 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 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()) @@ -755,4 +756,33 @@ class RtcWriteAudit(Base): official_source_version: Mapped[dict] = mapped_column(JSON, default=dict) local_catalog_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) written_at: Mapped[str | None] = mapped_column(String(40), nullable=True) + rule_version: Mapped[str | None] = mapped_column(String(32), nullable=True) + company_crt: Mapped[str | None] = mapped_column(String(8), nullable=True) + authorization_origin: Mapped[str | None] = mapped_column(String(48), nullable=True) + description_snapshot: Mapped[str | None] = mapped_column(String(255), nullable=True) + installation_uuid: Mapped[str | None] = mapped_column(String(64), nullable=True) + rtc_status: Mapped[str | None] = mapped_column(String(24), nullable=True) + local_catalog_state: Mapped[str | None] = mapped_column(String(32), nullable=True) + action: Mapped[str | None] = mapped_column(String(16), nullable=True) # APPLY | ROLLBACK + rollback_state: Mapped[str | None] = mapped_column(String(16), nullable=True) # REQUESTED | DONE | BLOCKED (on the original APPLY record) + rollback_of: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) recorded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True) + + +class RtcCandidate(Base): + """Server-side preview of the products the Agent could fill (R3 only). Rows are proposals; only the Agent's re-derivation at write time decides.""" + __tablename__ = "rtc_candidates" + __table_args__ = (UniqueConstraint("installation_id", "product_code", name="uq_rtc_candidate_product"),) + 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)) + description: Mapped[str | None] = mapped_column(String(255), nullable=True) + ncm: Mapped[str | None] = mapped_column(String(16), nullable=True) + current_value: Mapped[str | None] = mapped_column(String(16), nullable=True) + proposed: Mapped[str] = mapped_column(String(16)) + cst_ibscbs: Mapped[str | None] = mapped_column(String(8), nullable=True) + rule_id: Mapped[str] = mapped_column(String(32)) + 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 + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/central/app/panel_presentation.py b/central/app/panel_presentation.py index 324f8a4..fe2d2d9 100644 --- a/central/app/panel_presentation.py +++ b/central/app/panel_presentation.py @@ -121,7 +121,8 @@ def job_kind_label(value): _FISCAL_LABELS = { - "CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_INVALID_FORMAT": "cClassTrib com formato inválido", "CCLASSTRIB_NOT_IN_CATALOG": "cClassTrib fora do catálogo local", + "CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_MISSING_UNRESOLVED": "cClassTrib não determinado", "CCLASSTRIB_AMBIGUOUS": "cClassTrib ambíguo", "CCLASSTRIB_CONFLICT": "cClassTrib conflitante", + "CCLASSTRIB_OFFICIAL_MISMATCH": "cClassTrib diverge da tabela oficial", "RTC_LOCAL_CATALOG_INCOMPATIBLE": "Catálogo RTC incompatível", "RTC_RULE_EVIDENCE_MISSING": "Regra fiscal insuficiente", "CCLASSTRIB_INVALID_FORMAT": "cClassTrib com formato inválido", "CCLASSTRIB_NOT_IN_CATALOG": "cClassTrib fora do catálogo local", "RTC_CST_MISSING": "CST IBS/CBS ausente no catálogo", "RTC_CATALOG_INVALID": "Catálogo RTC local inválido", "PIS_CST_MISSING": "CST PIS ausente", "PIS_CST_INVALID": "CST PIS inválido", "PIS_RATE_MISSING": "Alíquota PIS ausente", "PIS_RATE_INVALID": "Alíquota PIS inválida", "PIS_CST_INCIDENCE_MISMATCH": "CST PIS incompatível com a incidência", "COFINS_CST_MISSING": "CST COFINS ausente", "COFINS_CST_INVALID": "CST COFINS inválido", "COFINS_RATE_MISSING": "Alíquota COFINS ausente", "COFINS_RATE_INVALID": "Alíquota COFINS inválida", "COFINS_CST_INCIDENCE_MISMATCH": "CST COFINS incompatível com a incidência", @@ -161,7 +162,7 @@ def priority_label(value): return _human_label(value, {"CRITICAL": "Crítica", "HIGH": "Alta", "NORMAL": "Média", "MEDIUM": "Média", "LOW": "Baixa"}) -def rtc_status_view(summary, authorized=False): +def rtc_status_view(summary, mode="DISABLED"): """Discreet Reforma Tributaria status from the last completed job's `fiscal_context`, exactly as the Agent OBSERVED it in that installation's own database. None when the Agent did not report it (older Agents).""" context = (summary or {}).get("fiscal_context") if isinstance(summary, dict) else None @@ -175,7 +176,7 @@ def rtc_status_view(summary, authorized=False): observed = None source = context.get("rtc_source") or "PARAMETROS.USAR_REFORMA_TRIBUTARIA" products = _rtc_products_view(context.get("rtc_resolution"), bool(context["rtc_enabled"])) - return {"products": products, "apply": _rtc_apply_view(summary.get("rtc_auto_apply"), authorized), "enabled": bool(context["rtc_enabled"]), "label": "Ativa" if context["rtc_enabled"] else "Desativada", "supported": bool(context.get("rtc_supported")), + return {"products": products, "apply": _rtc_apply_view(summary.get("rtc_auto_apply"), mode), "enabled": bool(context["rtc_enabled"]), "label": "Ativa" if context["rtc_enabled"] else "Desativada", "supported": bool(context.get("rtc_supported")), "catalog_valid": bool(context.get("rtc_catalog_valid")), "observed_at": observed, "conflict": bool(context.get("rtc_param_conflict")), "title": f"Lido no banco do cliente ({source})" + (f" em {observed}" if observed else "") + (" - linhas de PARAMETROS divergentes: RTC tratada como desativada" if context.get("rtc_param_conflict") else "")} @@ -187,23 +188,22 @@ def _rtc_products_view(resolution, enabled): total = int(resolution["total"]) valid = int(resolution.get("valid_existing", 0)) ready = int(resolution.get("ready_to_apply", 0)) + conflicts = int(resolution.get("conflicts", 0)) + int(resolution.get("official_mismatch", 0)) + catalog = int(resolution.get("catalog_incompatible", 0)) blocked = total - valid - ready pending = total - valid - return {"total": total, "valid": valid, "ready": ready, "blocked": blocked, "pending": pending, + return {"total": total, "valid": valid, "ready": ready, "blocked": blocked, "pending": pending, "conflicts": conflicts, "catalog_incompatible": catalog, "suggestion_only": int(resolution.get("suggestion_only", 0)), "headline": f"Reforma Tributária ativa — cClassTrib pendente em {pending} produto{'s' if pending != 1 else ''}" if pending else "Reforma Tributária ativa — todos os produtos com cClassTrib válido", "catalog": (resolution.get("catalog_compatibility") or {}).get("compatibility")} -def _rtc_apply_view(report, authorized): - """State of the (default OFF) automatic fill, for the operator. Never claims a write happened unless the Agent reported it.""" - gate = (report or {}).get("gate") or {} - applied = len((report or {}).get("applied") or []) - missing = gate.get("missing") or [] - labels = {"FIREBIRD_WRITES": "escrita no Firebird desligada no Agent", "RTC_AUTO_APPLY_ENABLED": "chave RTC_AUTO_APPLY_ENABLED desligada no Agent", "HUB_AUTHORIZATION": "sem autorização no Hub"} - if gate.get("allowed"): - state = "Ativo" - elif authorized: - state = "Autorizado no Hub, aguardando o Agent (" + "; ".join(labels.get(m, m) for m in missing if m != "HUB_AUTHORIZATION") + ")" if any(m != "HUB_AUTHORIZATION" for m in missing) else "Autorizado no Hub" - else: - state = "Desativado" - return {"authorized": bool(authorized), "state": state, "applied_last_job": applied, "aborted_last_job": len((report or {}).get("aborted") or []), "mode": (report or {}).get("mode")} +RTC_MODE_LABELS = {"DISABLED": "Desativada", "DRY_RUN": "Dry-run", "APPROVAL_REQUIRED": "Aprovação manual", "ENABLED": "Automática"} +RTC_MODE_EXPLANATION = "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente." + + +def _rtc_apply_view(report, mode): + """State of the (default DISABLED) centrally controlled fill. Never claims a write happened unless the Agent reported it.""" + mode = mode if mode in RTC_MODE_LABELS else "DISABLED" + report = report or {} + return {"mode": mode, "state": RTC_MODE_LABELS[mode], "explanation": RTC_MODE_EXPLANATION, "applied_last_job": len(report.get("applied") or []), "aborted_last_job": len(report.get("aborted") or []), + "ready_total": report.get("ready_total"), "capability": report.get("capability"), "authorized": mode in ("ENABLED", "APPROVAL_REQUIRED")} diff --git a/central/app/rtc_audit.py b/central/app/rtc_audit.py index 3c5a217..ff30a0e 100644 --- a/central/app/rtc_audit.py +++ b/central/app/rtc_audit.py @@ -1,36 +1,103 @@ -"""Persists the Agent's RTC auto-apply audit records (append-only) and mirrors a summary event for the panel.""" +"""Persists the Agent's RTC write audit records (append-only), the candidate preview and the rollback bookkeeping; mirrors a summary event for the panel.""" from __future__ import annotations +from sqlalchemy import select, update + from .events import record_event -from .models import RtcWriteAudit +from .models import RtcCandidate, RtcWriteAudit MAX_AUDITS_PER_JOB = 2000 +MAX_CANDIDATES = 25000 +WRITE_MODES = ("DISABLED", "DRY_RUN", "APPROVAL_REQUIRED", "ENABLED") +MAX_APPROVED_PER_JOB = 500 +MAX_ROLLBACKS_PER_JOB = 500 -def record_rtc_write_audits(session, *, tenant_id: int, installation, agent, job_id: int | None, rtc_auto_apply: dict | None) -> int: - """Stores every applied/aborted write of the job. Never trusts the Agent for identity: tenant/installation/agent come from the authenticated context.""" +def _clip(value, size): + return None if value is None else str(value)[:size] + + +def job_authorizations(session, installation) -> dict: + """What the authenticated job carries to the Agent. Decided centrally per installation; the Agent has no local switch.""" + mode = installation.rtc_write_mode if installation.rtc_write_mode in WRITE_MODES else "DISABLED" + out = {"rtc_write_mode": mode, "rtc_approved_codes": [], "rtc_rollbacks": []} + if mode == "APPROVAL_REQUIRED": + out["rtc_approved_codes"] = list(session.scalars(select(RtcCandidate.product_code).where(RtcCandidate.installation_id == installation.id, RtcCandidate.status == "APPROVED").limit(MAX_APPROVED_PER_JOB))) + for audit in session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.action == "APPLY", RtcWriteAudit.outcome == "APPLIED", + RtcWriteAudit.rollback_state == "REQUESTED").limit(MAX_ROLLBACKS_PER_JOB)): + out["rtc_rollbacks"].append({"audit_id": audit.id, "product_code": audit.product_code, "written_value": audit.new_value, "rule_id": audit.rule_id}) + return out + + +def _store_preview(session, tenant_id, installation, summary, evaluated_all): + preview = summary.get("preview") + if not isinstance(preview, list) or installation.rtc_write_mode == "DISABLED": + return + existing = {c.product_code: c for c in session.scalars(select(RtcCandidate).where(RtcCandidate.installation_id == installation.id))} + seen = set() + for item in preview[:MAX_CANDIDATES]: + code = _clip(item.get("product_code"), 64) + if not code or not item.get("proposed"): + continue + seen.add(code) + row = existing.get(code) + if row is None: + session.add(RtcCandidate(tenant_id=tenant_id, installation_id=installation.id, product_code=code, description=_clip(item.get("description"), 255), ncm=_clip(item.get("ncm"), 16), + current_value=_clip(item.get("current") or None, 16), proposed=_clip(item["proposed"], 16), cst_ibscbs=_clip(item.get("cst"), 8), rule_id=_clip(item.get("rule_id"), 32), + basis=_clip(item.get("basis"), 255), status="PENDING")) + else: + if row.status in ("APPLIED", "STALE"): + row.status = "PENDING" # empty again (e.g. after a rollback) or newly eligible + if row.proposed != item["proposed"]: + row.proposed = _clip(item["proposed"], 16) + if row.status == "APPROVED": + row.status = "PENDING" # the proposal changed: the earlier approval does not carry over + row.description, row.ncm, row.cst_ibscbs = _clip(item.get("description"), 255), _clip(item.get("ncm"), 16), _clip(item.get("cst"), 8) + row.rule_id, row.basis = _clip(item.get("rule_id"), 32), _clip(item.get("basis"), 255) + if evaluated_all and not summary.get("preview_truncated"): + for code, row in existing.items(): + if code not in seen and row.status in ("PENDING", "APPROVED"): + row.status = "STALE" + + +def record_rtc_write_audits(session, *, tenant_id: int, installation, agent, job_id: int | None, rtc_auto_apply: dict | None, evaluated_all: bool = False) -> int: + """Stores every applied/aborted/rolled-back write of the job. Never trusts the Agent for identity: tenant/installation/agent come from the authenticated context.""" if not isinstance(rtc_auto_apply, dict): return 0 - records = list(rtc_auto_apply.get("applied") or []) + list(rtc_auto_apply.get("aborted") or []) - stored = applied = aborted = 0 + _store_preview(session, tenant_id, installation, rtc_auto_apply, evaluated_all) + records = list(rtc_auto_apply.get("applied") or []) + list(rtc_auto_apply.get("aborted") or []) + list(rtc_auto_apply.get("rollbacks") or []) + stored = applied = aborted = rolled = 0 for item in records[:MAX_AUDITS_PER_JOB]: if not isinstance(item, dict) or item.get("field") != "CCLASSTRIB": continue + action = "ROLLBACK" if item.get("action") == "ROLLBACK" else "APPLY" + outcome = str(item.get("outcome", ""))[:16] + rollback_of = item.get("rollback_of") if action == "ROLLBACK" else None + code = str(item.get("product_code", ""))[:64] session.add(RtcWriteAudit( tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, job_id=job_id, client=getattr(installation, "display_name", None), - product_code=str(item.get("product_code", ""))[:64], field="CCLASSTRIB", outcome=str(item.get("outcome", ""))[:16], reason=(item.get("reason") or None) and str(item["reason"])[:96], - old_value=(None if item.get("old_value") is None else str(item["old_value"])[:64]), new_value=(None if item.get("new_value") is None else str(item["new_value"])[:64]), + product_code=code, field="CCLASSTRIB", outcome=outcome, reason=(item.get("reason") or None) and str(item["reason"])[:96], + old_value=_clip(item.get("old_value"), 64), new_value=_clip(item.get("new_value"), 64), rule_id=(item.get("rule_id") or None) and str(item["rule_id"])[:32], evidence=item.get("evidence") or {}, official_source_version=item.get("official_source_version") or {}, - local_catalog_hash=item.get("local_catalog_hash"), written_at=item.get("timestamp"))) + local_catalog_hash=item.get("local_catalog_hash"), written_at=item.get("timestamp"), rule_version=_clip(item.get("rule_version"), 32), company_crt=_clip(item.get("company_crt"), 8), + authorization_origin=_clip(item.get("authorization_origin"), 48), description_snapshot=_clip(item.get("description_snapshot"), 255), installation_uuid=_clip(getattr(installation, "installation_id", None), 64), + rtc_status=_clip(item.get("rtc_status"), 24), local_catalog_state=_clip(item.get("local_catalog_state"), 32), action=action, rollback_of=rollback_of if isinstance(rollback_of, int) else None)) stored += 1 - if item.get("outcome") == "APPLIED": + candidate_of = (RtcCandidate.installation_id == installation.id) & (RtcCandidate.product_code == code) + if action == "ROLLBACK": + if isinstance(rollback_of, int): + session.execute(update(RtcWriteAudit).where(RtcWriteAudit.id == rollback_of, RtcWriteAudit.installation_id == installation.id).values(rollback_state="DONE" if outcome == "ROLLED_BACK" else "BLOCKED")) + if outcome == "ROLLED_BACK": + rolled += 1 + session.execute(update(RtcCandidate).where(candidate_of).values(status="PENDING")) + elif outcome == "APPLIED": applied += 1 + session.execute(update(RtcCandidate).where(candidate_of).values(status="APPLIED")) else: aborted += 1 if stored: - record_event(session, tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, severity="WARNING" if applied else "INFO", event_type="RTC_AUTO_APPLY", job_id=job_id, - message=f"cClassTrib preenchido em {applied} produto(s); {aborted} abortado(s)", - context={"applied": applied, "aborted": aborted, "reasons": rtc_auto_apply.get("aborted_reasons") or {}, "mode": rtc_auto_apply.get("mode")}) - if stored: - session.commit() + record_event(session, tenant_id=tenant_id, installation_id=installation.id, agent_id=agent.id, severity="WARNING" if (applied or rolled) else "INFO", event_type="RTC_AUTO_APPLY", job_id=job_id, + message=f"cClassTrib: {applied} preenchido(s), {rolled} revertido(s), {aborted} abortado(s)", + context={"applied": applied, "rolled_back": rolled, "aborted": aborted, "reasons": rtc_auto_apply.get("aborted_reasons") or {}, "mode": rtc_auto_apply.get("mode")}) + session.commit() return stored diff --git a/central/app/rtc_findings.py b/central/app/rtc_findings.py index 059aa4d..c2842e5 100644 --- a/central/app/rtc_findings.py +++ b/central/app/rtc_findings.py @@ -5,12 +5,13 @@ the single RTC blocker of a product is refined to the cause that matters, never """ from __future__ import annotations -from .cclasstrib_resolver import Resolution +from .cclasstrib_resolver import HUMAN_REASONS, Resolution RTC_CATALOG_REASONS = ("LOCAL_OFFICIAL_", "CLASS_NOT_IN_LOCAL_CATALOG", "CLASS_NOT_IN_OFFICIAL_TABLE", "CLASS_NOT_YET_IN_FORCE", "CLASS_NOT_USABLE_IN_NFE_NFCE", "LOCAL_RTC_CATALOG_INVALID") EVIDENCE_REASONS = ("NO_VALID_NCM", "COMPANY_REGIME_UNKNOWN") -RTC_TYPES = ("CCLASSTRIB_MISSING", "CCLASSTRIB_MISSING_UNRESOLVED", "CCLASSTRIB_AMBIGUOUS", "CCLASSTRIB_CONFLICT", "CCLASSTRIB_OFFICIAL_MISMATCH", "RTC_LOCAL_CATALOG_OUTDATED", +RTC_TYPES = ("CCLASSTRIB_MISSING", "CCLASSTRIB_MISSING_UNRESOLVED", "CCLASSTRIB_AMBIGUOUS", "CCLASSTRIB_CONFLICT", "CCLASSTRIB_OFFICIAL_MISMATCH", "RTC_LOCAL_CATALOG_INCOMPATIBLE", "RTC_RULE_EVIDENCE_MISSING", "CCLASSTRIB_INVALID_FORMAT", "CCLASSTRIB_NOT_IN_CATALOG", "RTC_CST_MISSING", "RTC_CATALOG_INVALID") +BAD_LOCAL_STATES = ("MISMATCH", "EXPIRED", "MISSING_OFFICIAL") def _text(value) -> str: @@ -19,8 +20,9 @@ def _text(value) -> str: def slim(resolution: Resolution) -> dict: """Small, audit-grade view kept in the job result (full evidence stays in the resolver output).""" - return {"status": resolution.status, "rule_id": resolution.rule_id, "reason": resolution.reason, "resolved_cclasstrib": resolution.resolved_cclasstrib, - "resolved_cst_ibscbs": resolution.resolved_cst_ibscbs, "confidence_type": resolution.confidence_type, "auto_apply_candidate": resolution.auto_apply_candidate} + return {"status": resolution.status, "rule_id": resolution.rule_id, "rule_class": resolution.rule_class, "reason": resolution.reason, "resolved_cclasstrib": resolution.resolved_cclasstrib, + "resolved_cst_ibscbs": resolution.resolved_cst_ibscbs, "confidence_type": resolution.confidence_type, "auto_apply_candidate": resolution.auto_apply_candidate, + "suggested_cclasstrib": resolution.suggested_cclasstrib} def refine_rtc_finding(found: dict, product: dict, resolution: Resolution | None, compat_state: dict | None) -> dict: @@ -37,19 +39,24 @@ def refine_rtc_finding(found: dict, product: dict, resolution: Resolution | None if "CCLASSTRIB_MISSING" in kinds: if resolution.auto_apply_candidate: new = ("CCLASSTRIB_MISSING", f"READY_TO_APPLY:{resolution.resolved_cclasstrib}:{resolution.rule_id}") + elif resolution.status == "SUGGESTION_ONLY": + new = ("CCLASSTRIB_MISSING_UNRESOLVED", f"{resolution.rule_id}:SUGGESTION_ONLY:{resolution.resolved_cclasstrib}") elif resolution.status == "AMBIGUOUS": new = ("CCLASSTRIB_AMBIGUOUS", f"{resolution.rule_id}:{resolution.reason}") elif resolution.reason.startswith(RTC_CATALOG_REASONS): - new = ("RTC_LOCAL_CATALOG_OUTDATED", resolution.reason) + new = ("RTC_LOCAL_CATALOG_INCOMPATIBLE", resolution.reason) elif resolution.reason in EVIDENCE_REASONS: new = ("RTC_RULE_EVIDENCE_MISSING", resolution.reason) else: new = ("CCLASSTRIB_MISSING_UNRESOLVED", f"{resolution.rule_id}:{resolution.reason}") - found["blockers"] = others + [{"type": new[0], "category": "RTC", "reason": new[1]}] + blocker = {"type": new[0], "category": "RTC", "reason": new[1]} + if resolution.reason in HUMAN_REASONS: + blocker["human"] = HUMAN_REASONS[resolution.reason] + found["blockers"] = others + [blocker] return found if not rtc_blockers and value: # a valid existing class: compare, never overwrite state = (compat_state or {}).get(value) - if state in ("MISMATCH", "EXPIRED", "LOCAL_ONLY"): + if state in BAD_LOCAL_STATES: found["blockers"] = others + [{"type": "CCLASSTRIB_OFFICIAL_MISMATCH", "category": "RTC", "reason": f"LOCAL_VS_OFFICIAL:{state}"}] elif resolution.auto_apply_candidate and resolution.resolved_cclasstrib != value: found["blockers"] = others + [{"type": "CCLASSTRIB_CONFLICT", "category": "RTC", "reason": f"HAS:{value};RULE:{resolution.rule_id}:{resolution.resolved_cclasstrib}"}] @@ -59,8 +66,9 @@ def refine_rtc_finding(found: dict, product: dict, resolution: Resolution | None def summarize_rtc(findings: dict, compat: dict | None = None) -> dict: - """Counts for the panel and the audit: how many products already have a valid class, how many are ready to be filled, how many are blocked and why.""" - out = {"total": 0, "valid_existing": 0, "ready_to_apply": 0, "ambiguous": 0, "insufficient": 0, "conflicts": 0, "official_mismatch": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": {}, "by_reason": {}} + """Counts for the panel and the audit: valid, auto-resolvable (safe), suggested (diagnostic only), blocked and conflicting, catalog-incompatible.""" + out = {"total": 0, "valid_existing": 0, "ready_to_apply": 0, "suggestion_only": 0, "ambiguous": 0, "insufficient": 0, "conflicts": 0, "official_mismatch": 0, "catalog_incompatible": 0, + "not_applicable": 0, "other_blocked": 0, "by_rule": {}, "by_reason": {}} for found in findings.values(): out["total"] += 1 if found.get("applicability", {}).get("RTC") != "REQUIRED": @@ -77,11 +85,15 @@ def summarize_rtc(findings: dict, compat: dict | None = None) -> dict: out["conflicts"] += 1 elif "CCLASSTRIB_OFFICIAL_MISMATCH" in types: out["official_mismatch"] += 1 + elif "RTC_LOCAL_CATALOG_INCOMPATIBLE" in types or "RTC_CATALOG_INVALID" in types: + out["catalog_incompatible"] += 1 elif res.get("auto_apply_candidate") and "CCLASSTRIB_MISSING" in types: out["ready_to_apply"] += 1 + elif res.get("status") == "SUGGESTION_ONLY": + out["suggestion_only"] += 1 elif "CCLASSTRIB_AMBIGUOUS" in types: out["ambiguous"] += 1 - elif types & {"CCLASSTRIB_MISSING_UNRESOLVED", "RTC_RULE_EVIDENCE_MISSING", "RTC_LOCAL_CATALOG_OUTDATED", "CCLASSTRIB_MISSING"}: + elif types & {"CCLASSTRIB_MISSING_UNRESOLVED", "RTC_RULE_EVIDENCE_MISSING", "CCLASSTRIB_MISSING"}: out["insufficient"] += 1 else: out["other_blocked"] += 1 diff --git a/central/app/unified_fiscal_coverage.py b/central/app/unified_fiscal_coverage.py index f113694..12b35b8 100644 --- a/central/app/unified_fiscal_coverage.py +++ b/central/app/unified_fiscal_coverage.py @@ -236,9 +236,11 @@ def evaluate_product(product: dict, company: dict | None, rtc: dict | None, toda def legal_effective_context(crt) -> dict: - """INFORMATIONAL ONLY (never a blocker, never part of completeness). The operational rule stays ERP_RTC_ENABLED; this records what the legislation - says about IBS/CBS in documents for the company regime so a difference in timing can be explained without inventing a rule.""" + """INFORMATIONAL ONLY (never a blocker, never part of completeness). The operational rule stays ERP_RTC_ENABLED; this records what the official acts say about the + obligation to issue DF-e with IBS/CBS for the company regime, so a timing difference can be explained without inventing a rule. + Source: Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1: NF-e (55) and NFC-e (65) from 2026-08-03; par. 1: Simples optantes from 2027-01-01.""" simples = _text(crt) in SIMPLES_FAMILY return {"crt": _text(crt) or None, "informational_only": True, - "ibs_cbs_in_documents": "FROM_2027_FOR_SIMPLES_ORIENTATION_PENDING" if simples else ("MANDATORY_FROM_2026-01-01" if _text(crt) == "3" else "UNKNOWN_REGIME"), - "sources": ["NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT; IBS/CBS taxation of these taxpayers only from 2027", "LC 214/2025 art. 348"]} + "ibs_cbs_in_documents": "MANDATORY_FROM_2027-01-01" if simples else ("MANDATORY_FROM_2026-08-03" if _text(crt) == "3" else "UNKNOWN_REGIME"), + "cclasstrib_orientation": "PENDING_OFFICIAL_NT" if simples else ("TABLE_AND_LC214_ANNEXES" if _text(crt) == "3" else "UNKNOWN_REGIME"), + "sources": ["Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1", "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT", "LC 214/2025 art. 348"]} diff --git a/central/app/version.py b/central/app/version.py index 6a1b769..827ccff 100644 --- a/central/app/version.py +++ b/central/app/version.py @@ -4,5 +4,5 @@ kept identical to this) and what an AgentRelease row's `version` column holds once published — never a second, independent version number. Fiscal semantics are versioned separately (ENGINE_VERSION/DECISION_VERSION in classification.py, DECISION_ENGINE_VERSION etc.) and are NOT changed by a release bump.""" -GTI_FISCAL_VERSION = "1.1.9" +GTI_FISCAL_VERSION = "1.2.0" API_COMPAT_MAJOR_MINOR = "1.0" # an Agent 1.0.x/1.1.x talks to any Hub 1.0.x diff --git a/central/app/web.py b/central/app/web.py index 4dadf6a..ff3cafd 100644 --- a/central/app/web.py +++ b/central/app/web.py @@ -8,18 +8,18 @@ from urllib.parse import parse_qs, urlencode from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from sqlalchemy import select +from sqlalchemy import func, select, update from . import agent_distribution, client_sync, config, enrollment, sso from .config import settings from .blocker_resolution import BlockerResolutionUnavailable, _company_key, resolve_blocker_with_context from .context_keys import ALLOWED_KEYS, ENUMS from .installation_identity import reference_agent_version -from .panel_presentation import job_kind_label, job_status_label, rtc_status_view +from .panel_presentation import RTC_MODE_EXPLANATION, RTC_MODE_LABELS, job_kind_label, job_status_label, rtc_status_view from .control_plane import blocker_detail, blocker_filter_summary, dashboard_summary, installation_view, jobs_status_summary, list_blockers, list_installations, list_jobs, list_panel_events from .human_auth import create_session, current_session, require_csrf, verify_password from .panel_presentation import format_ui_datetime -from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, Tenant +from .models import AdminUser, Agent, AgentEnrollment, AgentRelease, AgentSnapshot, BlockedProduct, FiscalContextEntry, Installation, Job, RtcCandidate, RtcWriteAudit, Tenant from .jobs import create_job from .audit import record_audit @@ -296,7 +296,7 @@ def router(session_factory): if installation.processing_enabled and last_sync is not None: next_sync = last_sync + timedelta(seconds=installation.sync_interval_seconds) last_summary = session.scalar(select(Job.result_summary).where(Job.agent_id == agent.id, Job.status == "COMPLETED", Job.kind.in_(("INITIAL_SYNC", "SYNC_INCREMENTAL", "REPROCESS_ALL"))).order_by(Job.id.desc()).limit(1)) - return {"rtc": rtc_status_view(last_summary, bool(installation.rtc_auto_apply_authorized)), "rtc_authorized": bool(installation.rtc_auto_apply_authorized), "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60, + return {"rtc": rtc_status_view(last_summary, installation.rtc_write_mode), "rtc_mode": installation.rtc_write_mode, "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60, "last_sync": format_ui_datetime(last_sync) if last_sync else None, "next_sync": format_ui_datetime(next_sync) if next_sync else None, "last_changed": installation.last_sync_changed, "last_job": {"kind": last_job.kind, "status": last_job.status, "id": last_job.id, "kind_label": job_kind_label(last_job.kind), "status_label": job_status_label(last_job.status)} if last_job else None} @@ -319,30 +319,111 @@ def router(session_factory): return RedirectResponse(f"/agents/{agent_id}?tab=jobs", status_code=303) finally: session.close() - @routes.post("/agents/{agent_id}/rtc-auto-apply") - async def rtc_auto_apply_authorization(request: Request, agent_id: int): - """Central authorization of the automatic cClassTrib fill (default OFF). Enabling requires an explicit typed confirmation; every change is audited.""" + def _rtc_target(session, agent_id): + agent = session.get(Agent, agent_id) + installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None + return agent, installation + + @routes.get("/agents/{agent_id}/rtc") + async def rtc_control_page(request: Request, agent_id: int): + """Central cClassTrib control: write mode, candidate preview (server-side pagination), approvals and audit/rollback.""" + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + agent, installation = _rtc_target(session, agent_id) + if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) + q = request.query_params + status = q.get("status") or "PENDING" + page = max(1, int(q.get("page", "1") or 1)) if str(q.get("page", "1")).isdigit() else 1 + size = 50 + base = select(RtcCandidate).where(RtcCandidate.installation_id == installation.id) + counts = dict(session.execute(select(RtcCandidate.status, func.count()).where(RtcCandidate.installation_id == installation.id).group_by(RtcCandidate.status)).all()) + shown = base.where(RtcCandidate.status == status) if status in ("PENDING", "APPROVED", "REJECTED", "APPLIED", "STALE") else base + total = int(session.scalar(select(func.count()).select_from(shown.subquery())) or 0) + rows = list(session.scalars(shown.order_by(RtcCandidate.product_code).offset((page - 1) * size).limit(size))) + audits = list(session.scalars(select(RtcWriteAudit).where(RtcWriteAudit.installation_id == installation.id, RtcWriteAudit.outcome.in_(("APPLIED", "ROLLED_BACK"))).order_by(RtcWriteAudit.id.desc()).limit(25))) + ops = _ops_view(session, installation, agent) + return render(request, "rtc_control.html", {"agent_id": agent_id, "installation": installation, "mode": installation.rtc_write_mode, "modes": RTC_MODE_LABELS, "explanation": RTC_MODE_EXPLANATION, "ops": ops, + "rows": rows, "counts": counts, "status": status, "page": page, "pages": max(1, -(-total // size)), "total": total, "audits": audits}) + finally: session.close() + + @routes.post("/agents/{agent_id}/rtc-mode") + async def rtc_write_mode(request: Request, agent_id: int): + """Central authorization of the cClassTrib fill, per installation. Default DISABLED; ENABLED needs a typed confirmation; every change is audited.""" session, auth_data, redirect = page_auth(request) if redirect: return redirect try: values = await form_values(request); web_session, admin = auth_data require_csrf(web_session, values.get("csrf_token")) - agent = session.get(Agent, agent_id) - installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id)) if agent else None + agent, installation = _rtc_target(session, agent_id) if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) - decision = values.get("decision") - if decision == "authorize": - if (values.get("confirm") or "").strip().upper() != "PREENCHER": - return HTMLResponse("Digite PREENCHER para confirmar a autorização", status_code=422) - installation.rtc_auto_apply_authorized = True - elif decision == "revoke": - installation.rtc_auto_apply_authorized = False - else: - return HTMLResponse("decisão inválida", status_code=422) - record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_AUTO_APPLY_{decision.upper()}", target_type="installation", target_id=installation.installation_id, - context={"authorized": installation.rtc_auto_apply_authorized}, severity="WARNING") + mode = (values.get("mode") or "").strip().upper() + if mode not in RTC_MODE_LABELS: + return HTMLResponse("modo inválido", status_code=422) + if mode == "ENABLED" and (values.get("confirm") or "").strip().upper() != "PREENCHER": + return HTMLResponse("Digite PREENCHER para confirmar o modo automático", status_code=422) + previous = installation.rtc_write_mode + installation.rtc_write_mode = mode + installation.rtc_auto_apply_authorized = mode == "ENABLED" + record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_WRITE_MODE", target_type="installation", target_id=installation.installation_id, + context={"from": previous, "to": mode}, severity="WARNING") session.commit() - return RedirectResponse(f"/agents/{agent_id}", status_code=303) + return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303) + finally: session.close() + + @routes.post("/agents/{agent_id}/rtc-candidates") + async def rtc_candidate_action(request: Request, agent_id: int): + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + values = await form_values(request); web_session, admin = auth_data + require_csrf(web_session, values.get("csrf_token")) + agent, installation = _rtc_target(session, agent_id) + if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) + action = values.get("action") + scope = RtcCandidate.installation_id == installation.id + ids = [int(x) for x in parse_qs((await request.body()).decode()).get("ids", []) if x.isdigit()] + select_all = values.get("all") == "1" or request.query_params.get("all") == "1" + changed = 0 + if action in ("approve", "reject"): + new, old = ("APPROVED", ("PENDING",)) if action == "approve" else ("REJECTED", ("PENDING", "APPROVED")) + cond = scope & RtcCandidate.status.in_(old) & (RtcCandidate.id.in_(ids) if not select_all else RtcCandidate.status.in_(old)) + changed = session.execute(update(RtcCandidate).where(cond).values(status=new)).rowcount + record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action=f"RTC_CANDIDATES_{action.upper()}", target_type="installation", target_id=installation.installation_id, context={"changed": changed, "all": select_all}, severity="WARNING") + elif action == "apply_approved": + if installation.rtc_write_mode != "APPROVAL_REQUIRED": + return HTMLResponse("Disponível apenas no modo Aprovação manual", status_code=409) + codes = list(session.scalars(select(RtcCandidate.product_code).where(scope, RtcCandidate.status == "APPROVED").order_by(RtcCandidate.product_code).limit(500))) + if codes: + job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": codes, "reason": "RTC_APPLY_APPROVED", "requested_by": admin.id}) + record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_APPLY_APPROVED", target_type="installation", target_id=installation.installation_id, context={"job_id": job.id, "products": len(codes)}, severity="WARNING") + else: + return HTMLResponse("ação inválida", status_code=422) + session.commit() + return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303) + finally: session.close() + + @routes.post("/agents/{agent_id}/rtc-rollback") + async def rtc_rollback_request(request: Request, agent_id: int): + """Requests the rollback of ONE audit record. The Agent executes it only if the value in the ERP still equals what was written; no free SQL exists.""" + session, auth_data, redirect = page_auth(request) + if redirect: return redirect + try: + values = await form_values(request); web_session, admin = auth_data + require_csrf(web_session, values.get("csrf_token")) + agent, installation = _rtc_target(session, agent_id) + if not agent or not installation: return HTMLResponse("Agent not found", status_code=404) + if (values.get("confirm") or "").strip().upper() != "REVERTER": + return HTMLResponse("Digite REVERTER para confirmar", status_code=422) + audit = session.get(RtcWriteAudit, int(values.get("audit_id") or 0)) + if audit is None or audit.installation_id != installation.id or audit.action != "APPLY" or audit.outcome != "APPLIED" or audit.rollback_state in ("REQUESTED", "DONE"): + return HTMLResponse("registro não reversível", status_code=409) + audit.rollback_state = "REQUESTED" + job = create_job(session, agent, "REPROCESS_PRODUCTS", {"codigos": [audit.product_code], "reason": "RTC_ROLLBACK", "requested_by": admin.id}) + record_audit(session, admin_user_id=admin.id, tenant_id=installation.tenant_id, action="RTC_ROLLBACK_REQUESTED", target_type="installation", target_id=installation.installation_id, + context={"audit_id": audit.id, "product_code": audit.product_code, "job_id": job.id}, severity="WARNING") + session.commit() + return RedirectResponse(f"/agents/{agent_id}/rtc", status_code=303) finally: session.close() @routes.post("/agents/{agent_id}/sync-now") diff --git a/central/pyproject.toml b/central/pyproject.toml index 6dd01ef..cc25c03 100644 --- a/central/pyproject.toml +++ b/central/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gti-fiscal-hub" -version = "1.1.9" +version = "1.2.0" requires-python = ">=3.10" dependencies = [ "fastapi>=0.115,<1", diff --git a/central/templates/agent_detail.html b/central/templates/agent_detail.html index 18ef3ee..e38873b 100644 --- a/central/templates/agent_detail.html +++ b/central/templates/agent_detail.html @@ -9,8 +9,7 @@ Reprocessar tudo {% if ops.enabled %}
{% else %}
{% endif %}
Processamento
{{ "Ativo" if ops.enabled else "Pausado" }} · sincronização automática a cada {{ ops.interval_minutes }} min
Última sincronização
{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}
Sincronização agendada
{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}
{% if ops.rtc %}
Reforma Tributária
{{ ops.rtc.label }}
{% endif %}{% if ops.rtc and ops.rtc.enabled %}{% if ops.rtc.products %}
Produtos RTC
{{ ops.rtc.products.headline }}
Total {{ ops.rtc.products.total }} · com cClassTrib válido {{ ops.rtc.products.valid }} · prontos para preenchimento {{ ops.rtc.products.ready }} · ambíguos/bloqueados {{ ops.rtc.products.blocked }}
{% endif %} -
Preenchimento automático
{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} -
{% if ops.rtc_authorized %}{% else %}{% endif %}
{% endif %}
Último job
{% if ops.last_job %}{{ ops.last_job.kind_label }} · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}
+
Preenchimento automático
{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} · Controle e candidatos cClassTrib
{% endif %}
Último job
{% if ops.last_job %}{{ ops.last_job.kind_label }} · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}

Contexto da empresa

Cliente
{{ detail.client or "Não informado" }}
Nome de exibição
{{ detail.display_name or "Não informado" }}
Hostname
{{ detail.hostname or "Não informado" }}
Empresa
{{ detail.company_name or detail.company_code or "Não informado" }}
UF
{{ detail.company_uf or "Não informado" }}
CRT
{{ detail.company_crt or "Não informado" }}
Versão do agent
{{ detail.agent_version_display }}
Último heartbeat
{{ detail.last_seen_display }} · {{ detail.last_seen_age }}
Firebird
{{ detail.firebird_version or "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}
IDs técnicos
Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}

Eventos recentes

Ver eventos
{% for event in events[:8] %}
{{ event.occurred_display }}{{ event.event_type }}{{ event.message }}{% if event.job_id %} · Job {{ event.job_id }}{% endif %}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}Severidade: {{ event.severity }}
{% else %}

Nenhum evento.

{% endfor %}
diff --git a/central/templates/rtc_control.html b/central/templates/rtc_control.html new file mode 100644 index 0000000..2f53aed --- /dev/null +++ b/central/templates/rtc_control.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}cClassTrib | GTI Fiscal{% endblock %} +{% block content %} +

← Instalação / REFORMA TRIBUTÁRIA

Preenchimento de cClassTrib

+

{{ explanation }}

+ +{% set rtc = ops.rtc %} +{% if rtc and rtc.products %} +
+
Produtos{{ rtc.products.total }}
+
cClassTrib válido{{ rtc.products.valid }}
+
Auto-resolvíveis{{ rtc.products.ready }}
+
Bloqueados{{ rtc.products.blocked }}
+
Conflitos{{ rtc.products.conflicts }}
+
Catálogo incompatível{{ rtc.products.catalog_incompatible }}
+
+{% endif %} + +

Modo de preenchimento

Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.

+
+{% for key, label in modes.items() %}{% endfor %} +

+ +

Candidatos

{{ total }} produto(s) neste filtro.

+ +
+ +{% for c in rows %} +{% else %}{% endfor %}
ProdutoNCMAtualPropostoCST IBS/CBSRegraBaseStatus
{{ c.product_code }} · {{ c.description or "" }}{{ c.ncm }}{{ c.current_value or "vazio" }}{{ c.proposed }}{{ c.cst_ibscbs }}{{ c.rule_id }}{{ c.basis }}{{ c.status }}
Nenhum candidato neste filtro.
+

Página {{ page }} de {{ pages }}{% if page > 1 %} · anterior{% endif %}{% if page < pages %} · próxima{% endif %}

+

+ +{% if mode == "APPROVAL_REQUIRED" %}{% endif %}

+ +

Auditoria e reversão

Cada preenchimento pode ser revertido individualmente, desde que o valor no ERP ainda seja o gravado.

+ +{% for a in audits %} + +{% else %}{% endfor %}
#ProdutoAçãoResultadoAntes → DepoisRegraOrigemReversão
{{ a.id }}{{ a.product_code }} · {{ a.description_snapshot or "" }}{{ a.action or "APPLY" }}{{ a.outcome }}{% if a.reason %} ({{ a.reason }}){% endif %}{{ a.old_value or "vazio" }} → {{ a.new_value or "vazio" }}{{ a.rule_id }}{{ a.authorization_origin }}{% if a.action == "APPLY" and a.outcome == "APPLIED" and not a.rollback_state %}
{% else %}{{ a.rollback_state or "" }}{% endif %}
Nenhum preenchimento registrado.
+{% endblock %} diff --git a/central/tests/test_cclasstrib_resolver.py b/central/tests/test_cclasstrib_resolver.py index 07cf4e4..40063b2 100644 --- a/central/tests/test_cclasstrib_resolver.py +++ b/central/tests/test_cclasstrib_resolver.py @@ -44,7 +44,7 @@ def test_rtc_off_is_not_applicable_and_never_a_candidate(): def test_simples_is_insufficient_until_the_official_orientation_exists(): r = resolve("10063021", company=SIMPLES) - assert r.status == "INSUFFICIENT_EVIDENCE" and r.rule_id == "RTC-R1" and not r.auto_apply_candidate and r.source_references[-1]["document"].startswith("NT 2025.002") + assert r.status == "INSUFFICIENT_EVIDENCE" and r.rule_id == "RTC-R1" and not r.auto_apply_candidate and r.source_references[-1]["document"].startswith("Ato Conjunto RFB/CGIBS") and r.rule_class == "BLOCKED_BY_EVIDENCE" assert resolve("10063021", company={"crt": None}).reason == "COMPANY_REGIME_UNKNOWN" @@ -88,35 +88,59 @@ def test_nature_based_chapters_are_ambiguous(): assert r.status == "AMBIGUOUS" and r.rule_id in ("RTC-A3", "RTC-A1", "RTC-A2"), ncm -def test_residual_ncm_outside_every_annex_and_sensitive_chapter_is_integral_taxation(): +def test_residual_ncm_outside_every_annex_is_only_a_suggestion_and_never_a_write_candidate(): r = resolve("39235000") # plastic closures: no annex, no nature-based regime - assert r.status == "RESOLVED_WITH_EXPLICIT_RULE" and r.resolved_cclasstrib == "000001" and r.resolved_cst_ibscbs == "000" and r.rule_id == "RTC-R4" and r.confidence_type == "EXCLUSION_OF_ALL_SPECIAL_REGIMES" - assert r.auto_apply_candidate and annex_matches("39235000") == [] + assert r.status == "SUGGESTION_ONLY" and r.resolved_cclasstrib == "000001" and r.suggested_cclasstrib == "000001" and r.rule_id == "RTC-R4" and r.rule_class == "SUGGESTION_ONLY" + assert r.auto_apply_candidate is False and annex_matches("39235000") == [] and r.confidence_type == "EXCLUSION_OF_ALL_SPECIAL_REGIMES" + assert "not positive evidence" in r.evidence[0]["note"] and r.as_dict()["auto_apply_candidate"] is False + + +def test_only_auto_apply_safe_rules_can_authorize_a_write(): + from central.app.cclasstrib_resolver import RULE_CLASSES + assert [rid for rid, cls in RULE_CLASSES.items() if cls == "AUTO_APPLY_SAFE"] == ["RTC-R3"] + assert RULE_CLASSES["RTC-R4"] == "SUGGESTION_ONLY" and RULE_CLASSES["RTC-R0"] == "VALIDATION_ONLY" + assert {c for c in RULE_CLASSES.values()} == {"AUTO_APPLY_SAFE", "VALIDATION_ONLY", "SUGGESTION_ONLY", "BLOCKED_BY_EVIDENCE"} + + +def test_regime_other_than_normal_never_reaches_a_positive_rule(): + for company in ({"crt": "2"}, {"crt": "4"}, {"crt": "9"}, {"crt": ""}): + assert not resolve("10063021", company=company).auto_apply_candidate def test_a_class_missing_or_different_in_the_local_catalog_is_never_proposed(): - r = resolve("39235000", local=local_from_official(skip=("000001",))) + r = resolve("10063021", local=local_from_official(skip=("200003",))) assert r.status == "INSUFFICIENT_EVIDENCE" and r.reason == "CLASS_NOT_IN_LOCAL_CATALOG" and not r.auto_apply_candidate - r2 = resolve("39235000", local=local_from_official(tweak={"000001": {"cst": "010"}})) + r2 = resolve("10063021", local=local_from_official(tweak={"200003": {"cst": "010"}})) assert r2.reason == "LOCAL_OFFICIAL_MISMATCH" and not r2.auto_apply_candidate r3 = resolve("10063021", local=local_from_official(tweak={"200003": {"ind_nfe": False, "ind_nfce": False}})) assert r3.reason == "LOCAL_OFFICIAL_MISMATCH" def test_invalid_local_catalog_blocks_every_proposal(): - assert resolve("39235000", ctx={"rtc_enabled": True, "local_catalog_valid": False}).reason == "LOCAL_RTC_CATALOG_INVALID" + assert resolve("10063021", ctx={"rtc_enabled": True, "local_catalog_valid": False}).reason == "LOCAL_RTC_CATALOG_INVALID" def test_catalog_comparison_classifies_every_code_and_flags_material_problems(): full = compare_catalogs(local_from_official(), TODAY) - assert full["counts"]["MATCH"] == 161 and full["counts"]["EXPIRED"] == 3 and full["compatibility"] == "PARTIAL_LOCAL_OUTDATED" # 220001-3 ended on 2026-01-01 + assert full["counts"]["MATCH_SAFE"] == 161 and full["counts"]["EXPIRED"] == 3 and full["compatibility"] == "PARTIAL_LOCAL_OUTDATED" # 220001-3 ended on 2026-01-01 assert compare_catalogs(local_from_official(skip=("220001", "220002", "220003")), TODAY)["compatibility"] == "FULL" outdated = compare_catalogs(local_from_official(skip=("000005", "200053")), TODAY) - assert outdated["counts"]["OFFICIAL_ONLY"] == 2 and outdated["compatibility"] == "PARTIAL_LOCAL_OUTDATED" and outdated["counts"]["MISMATCH"] == 0 + assert outdated["counts"]["MISSING_LOCAL"] == 2 and outdated["compatibility"] == "PARTIAL_LOCAL_OUTDATED" and outdated["counts"]["MISMATCH"] == 0 local = local_from_official(tweak={"200003": {"p_red_ibs": "60"}}) local["999999"] = {"cst": "000", "p_red_ibs": "0", "p_red_cbs": "0", "ind_nfe": True, "ind_nfce": True} bad = compare_catalogs(local, TODAY) - assert bad["per_code"]["200003"] == "MISMATCH" and bad["per_code"]["999999"] == "LOCAL_ONLY" and bad["compatibility"] == "MATERIAL_MISMATCH" + assert bad["per_code"]["200003"] == "MISMATCH" and bad["per_code"]["999999"] == "MISSING_OFFICIAL" and bad["compatibility"] == "MATERIAL_MISMATCH" + + +def test_non_material_differences_are_classified_but_not_treated_as_a_mismatch(): + earlier = compare_catalogs(local_from_official(tweak={"200003": {"start": "2025-05-05"}}), TODAY) # SGV imports an earlier start: both in force today => same emission effect + assert earlier["per_code"]["200003"] == "MATCH_SAFE" and earlier["details"]["200003"] == ["VIGENCY_START_DIFFERS_BOTH_IN_FORCE"] + assert compare_catalogs(local_from_official(tweak={"200003": {"start": "2027-01-01"}}), TODAY)["per_code"]["200003"] == "MISMATCH" + assert compare_catalogs(local_from_official(tweak={"200003": {"end": "2026-01-01"}}), TODAY)["per_code"]["200003"] == "MISMATCH" + local = local_from_official(tweak={"200003": {"end": "2027-12-31"}}) # a scheduled local end the official table does not have + c = compare_catalogs(local, TODAY) + assert c["per_code"]["200003"] == "MATCH_WITH_NON_MATERIAL_DIFFERENCE" and c["counts"]["MISMATCH"] == 0 + assert resolve("10063021", local=local).status == "INSUFFICIENT_EVIDENCE" # auto-apply requires MATCH_SAFE: a non-material difference still blocks the WRITE def test_local_catalog_hash_is_stable_and_sensitive(): diff --git a/central/tests/test_control_panel_pages.py b/central/tests/test_control_panel_pages.py index c69253f..98fd8d5 100644 --- a/central/tests/test_control_panel_pages.py +++ b/central/tests/test_control_panel_pages.py @@ -594,27 +594,87 @@ def _installation(client, agent_id): from central.app.models import Installation as Inst with client._session_factory() as session: row = session.scalar(select(Inst).where(Inst.agent_id == agent_id)) - return bool(row.rtc_auto_apply_authorized) + return row.rtc_write_mode -def test_rtc_auto_apply_authorization_is_off_by_default_and_needs_csrf_and_a_typed_confirmation(): +def test_rtc_write_mode_is_disabled_by_default_and_needs_csrf_valid_mode_and_a_typed_confirmation_for_automatic(): client, agent_id, _ = _panel_client() - assert _installation(client, agent_id) is False + assert _installation(client, agent_id) == "DISABLED" csrf = client.cookies.get("gti_csrf") - url = f"/agents/{agent_id}/rtc-auto-apply" - assert client.post(url, data={"csrf_token": "wrong", "decision": "authorize", "confirm": "PREENCHER"}).status_code == 403 - assert client.post(url, data={"csrf_token": csrf, "decision": "authorize"}).status_code == 422 - assert client.post(url, data={"csrf_token": csrf, "decision": "authorize", "confirm": "sim"}).status_code == 422 - assert _installation(client, agent_id) is False - assert client.post(url, data={"csrf_token": csrf, "decision": "authorize", "confirm": "preencher"}, follow_redirects=False).status_code == 303 - assert _installation(client, agent_id) is True - assert client.post(url, data={"csrf_token": csrf, "decision": "revoke"}, follow_redirects=False).status_code == 303 - assert _installation(client, agent_id) is False - assert client.post(url, data={"csrf_token": csrf, "decision": "other"}).status_code == 422 + url = f"/agents/{agent_id}/rtc-mode" + assert client.post(url, data={"csrf_token": "wrong", "mode": "DRY_RUN"}).status_code == 403 + assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED"}).status_code == 422 + assert client.post(url, data={"csrf_token": csrf, "mode": "ENABLED", "confirm": "sim"}).status_code == 422 + assert client.post(url, data={"csrf_token": csrf, "mode": "bogus"}).status_code == 422 + assert _installation(client, agent_id) == "DISABLED" + for mode, extra in (("DRY_RUN", {}), ("APPROVAL_REQUIRED", {}), ("ENABLED", {"confirm": "preencher"}), ("DISABLED", {})): + assert client.post(url, data={"csrf_token": csrf, "mode": mode, **extra}, follow_redirects=False).status_code == 303 + assert _installation(client, agent_id) == mode from central.app.models import AuditEvent with client._session_factory() as session: - actions = [a.action for a in session.scalars(select(AuditEvent).order_by(AuditEvent.id)).all() if a.action.startswith("RTC_AUTO_APPLY")] - assert actions == ["RTC_AUTO_APPLY_AUTHORIZE", "RTC_AUTO_APPLY_REVOKE"] + actions = [a.action for a in session.scalars(select(AuditEvent).order_by(AuditEvent.id)).all() if a.action.startswith("RTC_")] + assert actions == ["RTC_WRITE_MODE"] * 4 + + +def _seed_rtc(client, agent_id, n=120): + from central.app.models import Installation as Inst, RtcCandidate, RtcWriteAudit + with client._session_factory() as session: + inst = session.scalar(select(Inst).where(Inst.agent_id == agent_id)) + inst.rtc_write_mode = "APPROVAL_REQUIRED" + for i in range(n): + session.add(RtcCandidate(tenant_id=inst.tenant_id, installation_id=inst.id, product_code=f"C{i:04d}", description=f"PRODUTO {i}", ncm="10063021", proposed="200003", cst_ibscbs="200", rule_id="RTC-R3", basis="RTC-R3:ANNEX", status="PENDING")) + audit = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id, agent_id=agent_id, product_code="A1", field="CCLASSTRIB", outcome="APPLIED", old_value=None, new_value="200003", rule_id="RTC-R3", action="APPLY") + session.add(audit) + session.commit() + return audit.id + + +def test_rtc_control_page_paginates_candidates_on_the_server_and_shows_modes_counts_and_audit(): + client, agent_id, _ = _panel_client() + _seed_rtc(client, agent_id) + page1 = client.get(f"/agents/{agent_id}/rtc").text + assert "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente." in page1 + for label in ("Desativada", "Dry-run", "Aprovação manual", "Automática"): + assert label in page1 + assert "Pendentes (120)" in page1 and "C0000" in page1 and "C0049" in page1 and "C0050" not in page1 and "Página 1 de 3" in page1 and "Aplicar aprovados agora" in page1 + page3 = client.get(f"/agents/{agent_id}/rtc?page=3").text + assert "C0119" in page3 and "C0099" not in page3 and "Página 3 de 3" in page3 + assert "Reverter" in page3 + + +def test_rtc_candidate_actions_approve_reject_apply_and_rollback_are_csrf_protected_and_scoped(): + from central.app.models import Job, RtcCandidate, RtcWriteAudit + client, agent_id, _ = _panel_client() + audit_id = _seed_rtc(client, agent_id, n=10) + csrf = client.cookies.get("gti_csrf") + url = f"/agents/{agent_id}/rtc-candidates" + assert client.post(url, data={"csrf_token": "wrong", "action": "approve"}).status_code == 403 + with client._session_factory() as session: + ids = [c.id for c in session.scalars(select(RtcCandidate).order_by(RtcCandidate.id)).all()] + assert client.post(url, content=f"csrf_token={csrf}&action=approve&ids={ids[0]}&ids={ids[1]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303 + assert client.post(f"{url}?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False).status_code == 303 + assert client.post(url, content=f"csrf_token={csrf}&action=reject&ids={ids[2]}", headers={"content-type": "application/x-www-form-urlencoded"}, follow_redirects=False).status_code == 303 + with client._session_factory() as session: + counts = {c.product_code: c.status for c in session.scalars(select(RtcCandidate)).all()} + assert list(counts.values()).count("REJECTED") == 1 and list(counts.values()).count("APPROVED") == 9 + jobs_before = len(session.scalars(select(Job)).all()) + assert client.post(url, data={"csrf_token": csrf, "action": "apply_approved"}, follow_redirects=False).status_code == 303 + with client._session_factory() as session: + job = session.scalars(select(Job).order_by(Job.id.desc())).first() + assert len(session.scalars(select(Job)).all()) == jobs_before + 1 and job.kind == "REPROCESS_PRODUCTS" and job.payload["reason"] == "RTC_APPLY_APPROVED" and len(job.payload["codigos"]) == 9 + assert client.post(url, data={"csrf_token": csrf, "action": "nope"}).status_code == 422 + roll = f"/agents/{agent_id}/rtc-rollback" + assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id}).status_code == 422 # typed confirmation required + assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "reverter"}, follow_redirects=False).status_code == 303 + assert client.post(roll, data={"csrf_token": csrf, "audit_id": audit_id, "confirm": "REVERTER"}).status_code == 409 # already requested: no duplicate + with client._session_factory() as session: + assert session.get(RtcWriteAudit, audit_id).rollback_state == "REQUESTED" + + +def test_apply_approved_is_refused_outside_the_approval_mode(): + client, agent_id, _ = _panel_client() + csrf = client.cookies.get("gti_csrf") + assert client.post(f"/agents/{agent_id}/rtc-candidates", data={"csrf_token": csrf, "action": "apply_approved"}).status_code == 409 def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_is_on(): @@ -626,6 +686,6 @@ def test_panel_shows_rtc_products_summary_and_the_pending_message_only_when_rtc_ _complete_sync(client, agent_id, {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True, "rtc_resolution": resolution}) on = client.get(f"/agents/{agent_id}").text assert "Reforma Tributária ativa — cClassTrib pendente em 521 produtos" in on and "prontos para preenchimento 280" in on and "ambíguos/bloqueados 241" in on - assert "Preenchimento automático" in on and "Desativado" in on and "digite PREENCHER" in on + assert "Preenchimento automático" in on and "Desativada" in on and f"/agents/{agent_id}/rtc" in on for legacy in ("Escopo anterior", "Escopo ampliado"): assert legacy not in on diff --git a/central/tests/test_health.py b/central/tests/test_health.py index 978c1ad..2ce1aae 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.1.9" and body["api_compat"] == "1.0" and body["engine_version"] and body["decision_version"] + assert body["version"] == "1.2.0" 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/central/tests/test_rtc_security.py b/central/tests/test_rtc_security.py new file mode 100644 index 0000000..6cdae2f --- /dev/null +++ b/central/tests/test_rtc_security.py @@ -0,0 +1,87 @@ +"""Security review of the centrally controlled cClassTrib write: who can change the mode, what the Agent can and cannot decide, tenant scoping, no secrets in audits.""" +from sqlalchemy import select +from sqlalchemy.orm import Session + +from central.app.models import AuditEvent, Installation, RtcCandidate, RtcWriteAudit +from central.tests.test_control_panel_pages import _panel_client, _seed_rtc +from central.tests.test_zero_touch_hub import H, OPERATIONAL, _client, age_last_sync, finish, hb + + +def test_new_rtc_routes_require_an_operator_session(): + client, agent_id, _ = _panel_client() + client.cookies.clear() + for method, path in (("get", f"/agents/{agent_id}/rtc"), ("post", f"/agents/{agent_id}/rtc-mode"), ("post", f"/agents/{agent_id}/rtc-candidates"), ("post", f"/agents/{agent_id}/rtc-rollback")): + response = getattr(client, method)(path, follow_redirects=False) + assert response.status_code in (303, 401, 403), (path, response.status_code) + assert "login" in response.headers.get("location", "login") or response.status_code in (401, 403) + + +def test_default_mode_is_disabled_and_the_agent_cannot_choose_it(): + client, engine = _client() + hb(client) + with Session(engine) as s: + assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED" + job = client.get("/v1/jobs/next", headers=H).json() + assert job["authorizations"]["rtc_write_mode"] == "DISABLED" + # a forged result cannot change the mode, approve candidates or request rollbacks: none of those are read from the payload + forged = {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_write_mode": "ENABLED", "authorizations": {"rtc_write_mode": "ENABLED", "rtc_approved_codes": ["X"]}, + "rtc_auto_apply": {"mode": "ENABLED", "hub_mode": "ENABLED", "preview": [{"product_code": "X", "proposed": "200003", "rule_id": "RTC-R3", "status": "APPROVED"}]}} + client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": forged}, headers=H) + with Session(engine) as s: + assert s.scalars(select(Installation)).one().rtc_write_mode == "DISABLED" + assert s.scalars(select(RtcCandidate)).all() == [] # DISABLED stores no preview at all + + +def test_preview_from_the_agent_never_arrives_approved_and_never_approves_itself(): + client, engine = _client() + hb(client) + with Session(engine) as s: + s.scalars(select(Installation)).one().rtc_write_mode = "APPROVAL_REQUIRED" + s.commit() + item = {"product_code": "X1", "description": "D", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "b", "status": "APPROVED"} + finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": {"evaluated_all": True}, "rtc_auto_apply": {"preview": [item]}}) + with Session(engine) as s: + assert [c.status for c in s.scalars(select(RtcCandidate)).all()] == ["PENDING"] + age_last_sync(engine, 4000); hb(client) + assert client.get("/v1/jobs/next", headers=H).json()["authorizations"]["rtc_approved_codes"] == [] + + +def test_a_rollback_result_cannot_touch_another_installations_audit(): + client, engine = _client() + hb(client) + with Session(engine) as s: + inst = s.scalars(select(Installation)).one() + foreign = RtcWriteAudit(tenant_id=inst.tenant_id, installation_id=inst.id + 999, agent_id=inst.agent_id, product_code="F", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY", rollback_state="REQUESTED") + # the FK is not enforced by the SQLite test DB; the scoping is what is under test + s.add(foreign); s.commit(); foreign_id = foreign.id + done = {"outcome": "ROLLED_BACK", "product_code": "F", "field": "CCLASSTRIB", "old_value": "200003", "new_value": None, "action": "ROLLBACK", "rollback_of": foreign_id} + finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"rollbacks": [done]}}) + with Session(engine) as s: + assert s.get(RtcWriteAudit, foreign_id).rollback_state == "REQUESTED" # untouched + + +def test_rollback_and_candidate_actions_are_scoped_to_the_operators_installation_and_audited(): + client, agent_id, _ = _panel_client() + _seed_rtc(client, agent_id, n=3) + csrf = client.cookies.get("gti_csrf") + with client._session_factory() as s: + other = RtcWriteAudit(tenant_id=1, installation_id=987654, agent_id=agent_id, product_code="Z", field="CCLASSTRIB", outcome="APPLIED", new_value="200003", action="APPLY") + s.add(other); s.commit(); other_id = other.id + assert client.post(f"/agents/{agent_id}/rtc-rollback", data={"csrf_token": csrf, "audit_id": other_id, "confirm": "REVERTER"}).status_code == 409 + client.post(f"/agents/{agent_id}/rtc-candidates?all=1", data={"csrf_token": csrf, "action": "approve"}, follow_redirects=False) + with client._session_factory() as s: + actions = [a.action for a in s.scalars(select(AuditEvent)).all()] + assert "RTC_CANDIDATES_APPROVE" in actions + + +def test_audit_rows_never_carry_credentials_or_tokens(): + client, engine = _client() + hb(client) + applied = {"outcome": "APPLIED", "product_code": "10", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY", "password": "p", "token": "t", + "evidence": {"reason": "r"}} + finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"applied": [applied]}}) + with Session(engine) as s: + row = s.scalars(select(RtcWriteAudit)).one() + blob = repr([getattr(row, c.name) for c in RtcWriteAudit.__table__.columns]) + assert "password" not in blob.lower() and "'t'" not in blob and "Bearer" not in blob + assert not any("pass" in c.name or "token" in c.name or "secret" in c.name for c in RtcWriteAudit.__table__.columns) diff --git a/central/tests/test_unified_fiscal_coverage.py b/central/tests/test_unified_fiscal_coverage.py index 476184c..f6af987 100644 --- a/central/tests/test_unified_fiscal_coverage.py +++ b/central/tests/test_unified_fiscal_coverage.py @@ -56,7 +56,8 @@ def test_rtc_enabled_needs_schema_and_erp_switch(): def test_legal_context_is_informational_and_never_creates_a_blocker(): simples, normal = legal_effective_context("1"), legal_effective_context("3") - assert simples["informational_only"] and "2027" in simples["ibs_cbs_in_documents"] and normal["ibs_cbs_in_documents"] == "MANDATORY_FROM_2026-01-01" + assert simples["informational_only"] and simples["ibs_cbs_in_documents"] == "MANDATORY_FROM_2027-01-01" and normal["ibs_cbs_in_documents"] == "MANDATORY_FROM_2026-08-03" + assert simples["cclasstrib_orientation"] == "PENDING_OFFICIAL_NT" and any("Ato Conjunto RFB/CGIBS n 4/2026" in x for x in simples["sources"]) assert legal_effective_context(None)["ibs_cbs_in_documents"] == "UNKNOWN_REGIME" # the operational rule is the ERP switch only: a Simples company with RTC on is validated, one with RTC off is not, whatever the legal timing says assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, ON)) == {"CCLASSTRIB_MISSING"} and kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, OFF)) == set() diff --git a/central/tests/test_zero_touch_hub.py b/central/tests/test_zero_touch_hub.py index 1f91f17..f61c517 100644 --- a/central/tests/test_zero_touch_hub.py +++ b/central/tests/test_zero_touch_hub.py @@ -270,15 +270,15 @@ def test_job_carries_the_central_rtc_authorization_default_off_and_follows_the_i client, engine = _client() hb(client) job = client.get("/v1/jobs/next", headers=H).json() - assert job["authorizations"] == {"rtc_auto_apply": False} # never on by default + assert job["authorizations"] == {"rtc_write_mode": "DISABLED", "rtc_approved_codes": [], "rtc_rollbacks": []} # never on by default client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {}}, headers=H) with Session(engine) as s: inst = s.scalars(select(Installation)).one() - inst.rtc_auto_apply_authorized = True + inst.rtc_write_mode = "ENABLED" s.commit() age_last_sync(engine, 4000) hb(client) - assert client.get("/v1/jobs/next", headers=H).json()["authorizations"] == {"rtc_auto_apply": True} + assert client.get("/v1/jobs/next", headers=H).json()["authorizations"]["rtc_write_mode"] == "ENABLED" def test_rtc_write_audits_are_persisted_with_server_side_identity_and_never_duplicated_per_job(): @@ -307,3 +307,61 @@ def test_report_only_jobs_leave_no_audit_rows(): finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"mode": "REPORT_ONLY", "applied": [], "aborted": [], "ready_total": 280}}) with Session(engine) as s: assert s.scalars(select(RtcWriteAudit)).all() == [] + + +def _set_mode(engine, mode): + with Session(engine) as s: + s.scalars(select(Installation)).one().rtc_write_mode = mode + s.commit() + + +def test_preview_is_stored_only_when_the_central_mode_wants_it_and_approvals_travel_in_the_next_job(): + from central.app.models import RtcCandidate + client, engine = _client() + hb(client) + preview = [{"product_code": f"P{i}", "description": f"ITEM {i}", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "RTC-R3:ANNEX"} for i in range(3)] + ctx = {"rtc_enabled": True, "evaluated_all": True} + finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx, "rtc_auto_apply": {"mode": "REPORT_ONLY", "preview": preview, "preview_truncated": False}}) + with Session(engine) as s: + assert s.scalars(select(RtcCandidate)).all() == [] # DISABLED: nothing is stored + _set_mode(engine, "APPROVAL_REQUIRED") + age_last_sync(engine, 4000); hb(client) + finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx, "rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "preview": preview, "preview_truncated": False}}) + with Session(engine) as s: + rows = s.scalars(select(RtcCandidate).order_by(RtcCandidate.product_code)).all() + assert [(r.product_code, r.status, r.proposed) for r in rows] == [("P0", "PENDING", "200003"), ("P1", "PENDING", "200003"), ("P2", "PENDING", "200003")] + rows[1].status = "APPROVED" + s.commit() + age_last_sync(engine, 4000); hb(client) + job = client.get("/v1/jobs/next", headers=H).json() + assert job["authorizations"]["rtc_approved_codes"] == ["P1"] # only what a human approved + client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": ctx, + "rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "preview": [p for p in preview if p["product_code"] != "P2"], "preview_truncated": False, + "applied": [{"outcome": "APPLIED", "product_code": "P1", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY", "rule_version": "rtc-rules-v2", + "company_crt": "3", "authorization_origin": "HUB_MODE:APPROVAL_REQUIRED", "description_snapshot": "ITEM 1", "rtc_status": "ENABLED", "local_catalog_state": "MATCH_SAFE"}]}}}, headers=H) + with Session(engine) as s: + status = {r.product_code: r.status for r in s.scalars(select(RtcCandidate)).all()} + assert status == {"P0": "PENDING", "P1": "APPLIED", "P2": "STALE"} # applied is recorded; a product no longer eligible on a full evaluation becomes STALE + from central.app.models import RtcWriteAudit + audit = s.scalars(select(RtcWriteAudit)).one() + assert (audit.rule_version, audit.company_crt, audit.authorization_origin, audit.description_snapshot, audit.action, audit.local_catalog_state) == ("rtc-rules-v2", "3", "HUB_MODE:APPROVAL_REQUIRED", "ITEM 1", "APPLY", "MATCH_SAFE") + assert audit.installation_uuid + + +def test_rollback_request_travels_in_the_job_and_its_result_closes_the_original_audit(): + from central.app.models import RtcWriteAudit + client, engine = _client() + hb(client) + applied = {"outcome": "APPLIED", "product_code": "P1", "field": "CCLASSTRIB", "old_value": None, "new_value": "200003", "rule_id": "RTC-R3", "action": "APPLY"} + finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"applied": [applied]}}) + with Session(engine) as s: + audit = s.scalars(select(RtcWriteAudit)).one(); audit.rollback_state = "REQUESTED"; audit_id = audit.id + s.commit() + age_last_sync(engine, 4000); hb(client) + job = client.get("/v1/jobs/next", headers=H).json() + assert job["authorizations"]["rtc_rollbacks"] == [{"audit_id": audit_id, "product_code": "P1", "written_value": "200003", "rule_id": "RTC-R3"}] + done = {"outcome": "ROLLED_BACK", "product_code": "P1", "field": "CCLASSTRIB", "old_value": "200003", "new_value": None, "rule_id": "RTC-R3", "action": "ROLLBACK", "rollback_of": audit_id} + client.post(f"/v1/jobs/{job['job_id']}/complete", json={"result_summary": {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "rtc_auto_apply": {"rollbacks": [done]}}}, headers=H) + with Session(engine) as s: + rows = s.scalars(select(RtcWriteAudit).order_by(RtcWriteAudit.id)).all() + assert [(r.action, r.outcome, r.rollback_state, r.rollback_of) for r in rows] == [("APPLY", "APPLIED", "DONE", None), ("ROLLBACK", "ROLLED_BACK", None, audit_id)] diff --git a/deploy/agent-windows/agent.env.template b/deploy/agent-windows/agent.env.template index 1c403f7..8fbeb8c 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.1.9 +AGENT_VERSION=1.2.0 diff --git a/deploy/agent-windows/gti-agent.ps1 b/deploy/agent-windows/gti-agent.ps1 index 91a6b33..0d9367c 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.1.9' + 'FISCAL_MODE=DRY_RUN', 'FIREBIRD_WRITES=0', 'POLL_INTERVAL_SECONDS=5', 'AGENT_VERSION=1.2.0' ) 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 eb14d67..114e97c 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.1.9 +AGENT_VERSION=1.2.0 diff --git a/deploy/agent-windows/profiles/mmmodas.env.example b/deploy/agent-windows/profiles/mmmodas.env.example index 6b36fbd..fcd1faa 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.1.9 +AGENT_VERSION=1.2.0 diff --git a/deploy/agent-windows/profiles/rincao.env.example b/deploy/agent-windows/profiles/rincao.env.example index 1cffc9e..519c965 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.1.9 +AGENT_VERSION=1.2.0 diff --git a/deploy/agent-windows/profiles/uniao.env.example b/deploy/agent-windows/profiles/uniao.env.example index 5a9f85a..4b255e5 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.1.9 +AGENT_VERSION=1.2.0 diff --git a/deploy/hub/.env.example b/deploy/hub/.env.example index 4ffd04c..04e342b 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.1.9 +GTI_FISCAL_VERSION=1.2.0 # 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 af693b3..3edad53 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.1.9} + image: gti-fiscal-hub:${GTI_FISCAL_VERSION:-1.2.0} restart: unless-stopped depends_on: postgres: diff --git a/docs/AI_PROJECT_STATE.md b/docs/AI_PROJECT_STATE.md index 01db4d9..1501b1a 100644 --- a/docs/AI_PROJECT_STATE.md +++ b/docs/AI_PROJECT_STATE.md @@ -534,3 +534,11 @@ See `docs/PRE_AI_LOCAL_COMPLETION_V3_REPORT.md` for measured values. Remaining drift is naming, column order, extra DB defaults, redundant indexes. - `deploy/hub/*.sh` are forced to LF (`.gitattributes`); `backup.sh` + restore into a scratch database were exercised on production. + +## RTC / cClassTrib closure (2026-09-24/25) — see docs/rtc_auto_apply.md, docs/cclasstrib_resolution.md, docs/unified_fiscal_completeness.md +- Unified fiscal coverage (PIS/COFINS applicability + conditional RTC) is one definition/queue/panel; RTC is ON only if the ERP says so (`PARAMETROS.USAR_REFORMA_TRIBUTARIA`, read live). +- cClassTrib resolver (`central/app/cclasstrib_resolver.py`, `rtc-rules-v2`): every rule is AUTO_APPLY_SAFE / VALIDATION_ONLY / SUGGESTION_ONLY / BLOCKED_BY_EVIDENCE. Only **RTC-R3** (LC 214 annex I/VII/VIII/XV, clean item, CRT 3) authorizes a write; **RTC-R4 is suggestion-only**. Simples: SAFE_BLOCKED (no official orientation; Ato Conjunto RFB/CGIBS 4/2026: Simples from 2027-01-01). Catalog states per class: MATCH_SAFE / MATCH_WITH_NON_MATERIAL_DIFFERENCE / MISMATCH / EXPIRED / MISSING_LOCAL / MISSING_OFFICIAL; write only for MATCH_SAFE. +- Write is **centrally authorized per installation** (`installations.rtc_write_mode`: DISABLED default / DRY_RUN / APPROVAL_REQUIRED / ENABLED; migration 0028), delivered only inside the authenticated job (`authorizations`). The Agent has no local switch, only the capability `RTC_CCLASSTRIB_WRITE_V1`; allowlist = `C000025.CCLASSTRIB`. +- Preview/approval (`rtc_candidates`), audit (`rtc_write_audits`, append-only, rich fields) and per-record rollback (value must still equal the written one) live in the Hub; panel page `/agents/{id}/rtc`. +- Blocker persistence is bulk (`upsert_blockers_bulk`); 21k products persist in ~5 s (was 43 s). +- Proofs: `scripts/rtc_copy_write_e2e.py` (copy only; restores from .fbk; 324 tables hashed), `scripts/scale_hub_test.py`, `docs/runs/*`. Real client DBs: 0 writes. Agent 2 stays DISABLED. diff --git a/docs/DEPLOY_HUB.md b/docs/DEPLOY_HUB.md index 919eaf5..8d08d64 100644 --- a/docs/DEPLOY_HUB.md +++ b/docs/DEPLOY_HUB.md @@ -16,7 +16,7 @@ A variável de ambiente `GTI_SSH_PASSWORD`, se existir, tem prioridade. A senha ## Deploy ``` powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 -powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 -PublishAgent -Version 1.1.9 +powershell -ExecutionPolicy Bypass -File .\scripts\deploy_hub.ps1 -PublishAgent -Version 1.2.0 ``` Parâmetros: `-Version` (padrão `release/VERSION`), `-PublishAgent`, `-SkipTests`, `-DryRun`. diff --git a/docs/cclasstrib_resolution.md b/docs/cclasstrib_resolution.md index 16fc979..66b1e64 100644 --- a/docs/cclasstrib_resolution.md +++ b/docs/cclasstrib_resolution.md @@ -42,7 +42,7 @@ Premissa documentada: **venda ordinária no mercado interno** (default de produt | RTC-A2 | NCM listado em mais de um anexo | `AMBIGUOUS` | | RTC-A3 | capítulos 01–14, 27, 30, 49, 71, 87, 88, 89, 90 e posições 9619, 2207, 3826, 8703: regime definido pela natureza/operação do bem | `AMBIGUOUS` | | RTC-R3 | NCM em **exatamente um** anexo com classe-padrão única (I→`200003`, VII→`200034`, VIII→`200035`, XV→`200014`) e item sem condição | `RESOLVED_WITH_EXPLICIT_RULE` | -| RTC-R4 | NCM em **nenhum** anexo e fora dos capítulos acima ⇒ tributação integral (regra geral, LC 214 art. 4) | `RESOLVED_WITH_EXPLICIT_RULE` → `000001` | +| RTC-R4 | NCM em **nenhum** anexo e fora dos capítulos acima ⇒ tributação integral (regra geral, LC 214 art. 4). **Exclusão residual não é evidência positiva** | `SUGGESTION_ONLY` → `000001` (nunca candidato de escrita) | | RTC-G2 | classe proposta ausente/diferente no catálogo local, fora de vigência ou inutilizável em NF-e/NFC-e | `INSUFFICIENT_EVIDENCE` | `RESOLVED_DETERMINISTIC` (regra sem premissa de operação) não é usado: toda resolução carrega a premissa "venda ordinária" e por isso é `RESOLVED_WITH_EXPLICIT_RULE`. @@ -65,3 +65,7 @@ Estado como está hoje: TESTESOFICIAIS e CONSTRUCAO têm RTC ligada e regime Sim ## Limitações Simples fica bloqueado até haver orientação oficial (ou decisão explícita de empresa, não implementada); operações especiais não são cobertas pelo default de produto; a extração dos anexos é textual (itens condicionais viram ambíguos de propósito); o catálogo oficial usado é o de 22/06/2026; sem autorização, nada é escrito (ver `docs/rtc_auto_apply.md`). + + +## Classes de regra (rtc-rules-v2) +`AUTO_APPLY_SAFE` (única hoje: **RTC-R3**, CRT 3, anexo único, item limpo, classe `MATCH_SAFE`) · `VALIDATION_ONLY` (R0 RTC desligada, valida valor existente) · `SUGGESTION_ONLY` (**R4**) · `BLOCKED_BY_EVIDENCE` (R1 Simples/regime desconhecido, R2 NCM inválido, A1–A3 ambíguo, G1/G2 catálogo). Escrita só com evidência POSITIVA explícita: nunca por similaridade, frequência, LLM ou "não achei nada mais". Vigência local vs oficial: início diferente com a classe em vigor nos dois lados é informativo (`MATCH_SAFE`); fim local agendado = `MATCH_WITH_NON_MATERIAL_DIFFERENCE` (não escreve); início futuro ou fim vencido = `MISMATCH`. Fontes: `docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json`. Escrita/aprovação/rollback: `docs/rtc_auto_apply.md`. diff --git a/docs/rtc_auto_apply.md b/docs/rtc_auto_apply.md index 43c431f..48cd0e3 100644 --- a/docs/rtc_auto_apply.md +++ b/docs/rtc_auto_apply.md @@ -1,32 +1,37 @@ -# Preenchimento automático do cClassTrib (RTC auto-apply) +# cClassTrib — preenchimento controlado centralmente (RTC_CCLASSTRIB_WRITE_V1) -Código: `agent/src/gti_fiscal_agent/rtc_auto_apply.py` · integração `agent/scripts/run_agent_service.py::_rtc_auto_apply` · Hub `central/app/rtc_audit.py`, migração `0027_rtc_auto_apply`, rota `POST /agents/{id}/rtc-auto-apply`. -**Estado de entrega: mecanismo pronto, DESLIGADO em todo lugar. Nenhuma escrita em banco real foi feita ou é possível sem as três chaves abaixo.** +O Agent **nunca** decide sozinho escrever no ERP. Não há chave local (`agent.env`, `FIREBIRD_WRITES`, PowerShell, RDP): a autorização vem **somente** dentro do job autenticado enviado pelo Hub. O Agent expõe apenas a capability restrita `RTC_CCLASSTRIB_WRITE_V1` (heartbeat). -## Escopo de escrita -Uma coluna: `C000025.CCLASSTRIB`. `ALLOWED_RTC_WRITE_FIELDS = ["CCLASSTRIB"]`; o SQL é uma constante (`UPDATE C000025 SET CCLASSTRIB = ? WHERE CODIGO = ? AND (CCLASSTRIB IS NULL OR TRIM(CCLASSTRIB) = '')`). Não há write genérico de produto, nem escrita em NFCE/itens/catálogo RTC. -Só candidatos `RESOLVED_*` (ver `docs/cclasstrib_resolution.md`) com `CCLASSTRIB` vazio e nenhum outro problema RTC. Nunca sobrescreve: valor igual ⇒ `NO_WRITE_REQUIRED`; valor diferente ⇒ blocker `CCLASSTRIB_CONFLICT` (a política de correção precisa de aprovação futura). +## Modos (por instalação, painel: Agent → "Controle e candidatos cClassTrib") +| Modo | Efeito | +|---|---| +| `DISABLED` (padrão) | só informa; nada de preview, nada de escrita | +| `DRY_RUN` | calcula e envia o preview dos candidatos; zero escrita | +| `APPROVAL_REQUIRED` | escreve só os produtos aprovados por um humano no painel (lote de até 500 por job) | +| `ENABLED` | escreve os candidatos determinísticos (até `RTC_AUTO_APPLY_MAX_PER_JOB`, padrão 500, por job); exige digitar `PREENCHER` | -## Três chaves (todas necessárias; padrão = fechadas) -1. `FIREBIRD_WRITES` efetivo no Agent (`FISCAL_MODE=AUTO_APPLY` + caminho/esquema autorizados — as travas já existentes); -2. `RTC_AUTO_APPLY_ENABLED=1` no `agent.env` (chave própria; `RTC_AUTO_APPLY_MAX_PER_JOB`, padrão 500, limita o raio de ação); -3. autorização do **Hub** por instalação (`installations.rtc_auto_apply_authorized`, padrão `false`), entregue com cada job (`authorizations.rtc_auto_apply`) e alterada só pelo painel: botão "Autorizar" exige CSRF e digitar `PREENCHER`; toda mudança gera `audit_events` (`RTC_AUTO_APPLY_AUTHORIZE/REVOKE`). -Sem as três: modo `REPORT_ONLY` (conta candidatos, abre nenhuma conexão de escrita). O Agent reporta a capability `rtc_auto_apply` no heartbeat. +Texto do painel: "Somente cClassTrib com regra fiscal determinística e catálogo validado pode ser preenchido automaticamente." +Mudança de modo = evento de auditoria administrativa (`RTC_WRITE_MODE`). Migration `0028_rtc_write_modes` (padrão `DISABLED`; instalações que já tinham a autorização 0027 viram `ENABLED`). -## Proteção contra decisão velha (por produto, uma transação curta) -Imediatamente antes do UPDATE o Agent **relê tudo do banco**: parâmetro `USAR_REFORMA_TRIBUTARIA` (todas as linhas; divergência ⇒ aborta), integridade do catálogo, hash do catálogo (deve ser o da decisão), a linha do produto (única; NCM igual ao da decisão; `CCLASSTRIB` ainda vazio) e **re-deriva** a resolução com dados novos (mesma classe e mesma regra). Qualquer diferença ⇒ `ABORTED` daquele produto (`RTC_NO_LONGER_ENABLED`, `RTC_CATALOG_INVALID`, `LOCAL_CATALOG_CHANGED_SINCE_DECISION`, `PRODUCT_CHANGED_NCM`, `VALUE_ALREADY_SET_NEVER_OVERWRITTEN`, `NO_WRITE_REQUIRED`, `DECISION_CHANGED`, `PRODUCT_ROW_NOT_UNIQUE`, `UPDATE_ROWCOUNT_NOT_ONE`). `rowcount` deve ser 1; releitura confirma o valor; a FK física `C000025.CCLASSTRIB → RTC_CCLASSTRIB` é a segunda barreira. Falha de um produto não interrompe os demais. -Idempotência: com o valor correto gravado o produto deixa de ser candidato (`valid_existing`); segunda execução = 0 escritas. +## O que pode ser escrito +Somente `C000025.CCLASSTRIB` (allowlist `ALLOWED_RTC_WRITE_FIELDS`), somente quando **todos** valem: RTC ligada no ERP; regra `AUTO_APPLY_SAFE` (hoje só **RTC-R3**: NCM em exatamente um anexo I/VII/VIII/XV da LC 214, item limpo, CRT 3, uma única classe); classe `MATCH_SAFE` no catálogo local vs oficial; CST válido; empresa relida (`C000004.CRT` = único valor esperado e = 3); RTC/parâmetro/catálogo relidos; produto inalterado (NCM + descrição + valor, fingerprint); valor atual vazio. **R4 (exclusão residual → 000001) é só sugestão e nunca vira candidato.** Nunca sobrescreve: valor igual → `NO_WRITE_REQUIRED`; diferente → `CCLASSTRIB_CONFLICT` (bloqueio). Qualquer gate falho → zero escrita para aquele produto. -## Auditoria e rollback lógico -Cada tentativa vira uma linha em `rtc_write_audits` (append-only): `tenant/installation/agent` (do contexto autenticado, nunca do payload), `client`, `job_id`, `product_code`, `field`, `outcome` (`APPLIED|ABORTED|FAILED`), `reason`, **`old_value`** (sempre nulo hoje: só se preenche campo vazio), `new_value`, `rule_id`, `evidence` (regra, entradas, item do anexo), `official_source_version` (data + sha256 da tabela cClassTrib e sha256 da LC 214), `local_catalog_hash`, `written_at`. Um resumo vai como evento `RTC_AUTO_APPLY` (severidade WARNING quando houve escrita). Reverter = `UPDATE C000025 SET CCLASSTRIB = NULL` para os `product_code` do `job_id` (não há botão de rollback nesta versão). +Sequência por produto (uma transação curta): reler tudo → recalcular a decisão → conferir fingerprint → `UPDATE C000025 SET CCLASSTRIB = ? WHERE CODIGO = ? AND (CCLASSTRIB IS NULL OR TRIM(CCLASSTRIB)='')` → rowcount = 1 → reler → commit → auditoria. -## Painel -Mesmo painel, mesma fila. No detalhe do Agent, com RTC ativa: "Reforma Tributária ativa — cClassTrib pendente em N produtos", "Produtos RTC: total · válidos · prontos para preenchimento · ambíguos/bloqueados", estado do preenchimento automático ("Desativado / Autorizado no Hub, aguardando o Agent (…) / Ativo") e o formulário de autorização. RTC desligada não mostra nada disto. +## Preview e aprovação +O Agent envia os candidatos (até 5.000 por job; `preview_truncated` sinaliza excesso) → tabela `rtc_candidates` (PENDING/APPROVED/REJECTED/APPLIED/STALE). O painel pagina no servidor (50/página), filtra por status, aprova/rejeita selecionados ou todos os pendentes e dispara "Aplicar aprovados agora". Uma proposta que muda invalida a aprovação anterior; produto que deixa de ser elegível numa avaliação completa vira `STALE`. + +## Auditoria (append-only, `rtc_write_audits`) +cliente, agent, `installation_uuid`, produto, snapshot da descrição, campo, valor antigo/novo, regra + versão (`rtc-rules-v2`), evidência, hashes/versões das fontes oficiais, hash e estado (`MATCH_SAFE`) do catálogo local, status RTC, CRT da empresa, timestamp, job, origem da autorização (`HUB_MODE:`), resultado, ação (`APPLY`/`ROLLBACK`). Identidade vem do contexto autenticado, nunca do payload. Sem segredos. + +## Rollback por registro +Painel → "Reverter" (digitar `REVERTER`) marca o registro `REQUESTED` e cria um job; o Hub entrega `{audit_id, product_code, written_value}` no job. O Agent só restaura (`SET CCLASSTRIB = NULL`) se o valor atual **ainda for igual ao gravado**; senão `ROLLBACK_CONFLICT_VALUE_CHANGED` (nada muda). Um único statement fixo, sem SQL livre. O registro original vira `DONE`/`BLOCKED`; o produto volta a ser candidato. ## Validação -- Testes: `agent/tests/test_rtc_auto_apply.py` (gates, candidatos, escrita restrita, idempotência, RTC desligada entre decisão e escrita, catálogo alterado/inválido, NCM alterado, valor existente, duplicata, limite por job), `central/tests/test_zero_touch_hub.py`, `central/tests/test_control_panel_pages.py`, `tests/test_rtc_copy_write_guard.py`. -- **E2E de escrita em CÓPIA descartável** (`scripts/rtc_copy_write_e2e.py`; cópia `scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB` via gbak; o script recusa qualquer outro caminho): empresa da cópia trocada para regime normal (CRT 3) como experimento; 280 candidatos → 280 escritas verificadas (`000001`×203, `200034`×75, `200014`×2), somente a coluna `CCLASSTRIB` mudou (diff da tabela inteira), 0 órfãos de FK/CST, catálogo RTC local intacto (145 linhas antes e depois), cobertura da cópia 0% → 53,55%; decisão velha (RTC desligada na cópia entre decisão e escrita) → 280 abortos, 0 escritas; segunda execução → 0 candidatos, 0 escritas. Resultado em `docs/runs/rtc_copy_write_e2e.json`. +- Testes: `agent/tests/test_rtc_auto_apply.py`, `central/tests/test_zero_touch_hub.py`, `test_control_panel_pages.py`, `test_rtc_security.py`, `test_cclasstrib_resolver.py`. +- **E2E em CÓPIA** (`scripts/rtc_copy_write_e2e.py`, `docs/runs/rtc_copy_write_e2e.json`): restaura a cópia do `.fbk`; DISABLED/DRY_RUN/decisão obsoleta = 0 escrita; aprovação de 5 → exatamente 5; ENABLED → 77 (total); reler; FK/CST órfãos 0; segunda execução 0; rollback de 3 + 1 conflito recusado; reaplicação de 3; idempotência 0; hash de 324 tabelas: `NON_TARGET_COLUMNS_CHANGED=0`, `CATALOG_CHANGED=0`, `PARAMETERS_CHANGED=0`, `DOCUMENT_TABLES_CHANGED=0`, nenhuma outra tabela alterada. +- Escala (`scripts/scale_hub_test.py`, 21.000 produtos, payload 7,96 MB): persistência 43 s → 5,5 s (upsert em lote; regime estável 64 statements); painel < 0,6 s. - Bancos reais: 0 escritas, 0 tentativas. ## Limitações -Simples continua bloqueado (sem orientação oficial); `CCLASSTRIB_CONFLICT` só reporta; não há rollback automático; o texto dos anexos é interpretado de forma conservadora (condição ⇒ ambíguo). Para o piloto real: escolher uma instalação em regime normal com RTC ligada, ligar as três chaves e revisar a amostra de auditoria antes. +Simples continua bloqueado (sem orientação oficial: NT 2025.002 v1.51; Ato Conjunto RFB/CGIBS 4/2026 fixa a obrigação de Simples em 2027-01-01). Regras além de R3 exigem evidência positiva nova. O Agent 2 permanece em `DISABLED`. diff --git a/docs/runs/rtc_copy_write_e2e.json b/docs/runs/rtc_copy_write_e2e.json index 446d924..c9a51f5 100644 --- a/docs/runs/rtc_copy_write_e2e.json +++ b/docs/runs/rtc_copy_write_e2e.json @@ -1,12 +1,14 @@ { - "database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (gbak restore of TESTESOFICIAIS)", + "database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its .fbk at start)", "real_database_writes": 0, "real_database_write_attempts": 0, + "mechanism": "job authorizations only (no env switches)", "copy_original_crt": [ "1" ], - "copy_crt_for_experiment": "3", "copy_rtc_param": 1, + "copy_crt_for_experiment": "3", + "tables_hashed": 324, "coverage_before": { "total": 521, "complete": 0, @@ -14,14 +16,61 @@ "unclassified": 0, "coverage_percent": 0.0 }, + "disabled_run": { + "write_attempts": 0, + "preview": 0, + "ready_total": 77 + }, + "dry_run": { + "write_attempts": 0, + "preview": 77, + "ready_total": 77, + "rules": [ + "RTC-R3" + ], + "sample": [ + { + "product_code": "000024", + "description": "COXINHA ( 6 UNID )", + "ncm": "19022000", + "current": "", + "proposed": "200034", + "cst": "200", + "rule_id": "RTC-R3", + "basis": "RTC-R3:ANNEX_TEXT_MATCH" + }, + { + "product_code": "000027", + "description": "RISOLE DE CAMARAO", + "ncm": "19022000", + "current": "", + "proposed": "200034", + "cst": "200", + "rule_id": "RTC-R3", + "basis": "RTC-R3:ANNEX_TEXT_MATCH" + }, + { + "product_code": "000028", + "description": "SALGADOS (MIXTO 10 UND)", + "ncm": "19022000", + "current": "", + "proposed": "200034", + "cst": "200", + "rule_id": "RTC-R3", + "basis": "RTC-R3:ANNEX_TEXT_MATCH" + } + ] + }, "resolution_before": { "total": 521, "valid_existing": 0, - "ready_to_apply": 280, + "ready_to_apply": 77, + "suggestion_only": 203, "ambiguous": 215, "insufficient": 26, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -35,196 +84,189 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } }, "stale_decision_run": { - "write_attempts": 280, - "write_successes": 0, - "aborted_reasons": { - "RTC_NO_LONGER_ENABLED": 280 - } - }, - "first_run": { - "mode": "AUTO_APPLY", - "ready_total": 280, - "write_attempts": 280, - "write_successes": 280, - "aborted_reasons": {}, - "audit_sample": [ - { - "outcome": "APPLIED", - "product_code": "000023", - "field": "CCLASSTRIB", - "old_value": null, - "new_value": "000001", - "cst_ibscbs": "000", - "rule_id": "RTC-R4", - "confidence_type": "EXCLUSION_OF_ALL_SPECIAL_REGIMES", - "evidence": { - "inputs_used": { - "ncm": "16025000", - "crt": "3", - "rtc_enabled": true - }, - "evidence": [ - { - "annex_hits": 0, - "chapter": "16" - } - ], - "reason": "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER" - }, - "official_source_version": { - "cclasstrib_table": "2026-06-22", - "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", - "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680" - }, - "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", - "timestamp": "2026-09-25T00:07:47+00:00", - "job_id": 424242, - "rowcount": 1, - "verified_after": true - }, - { - "outcome": "APPLIED", - "product_code": "000024", - "field": "CCLASSTRIB", - "old_value": null, - "new_value": "200034", - "cst_ibscbs": "200", - "rule_id": "RTC-R3", - "confidence_type": "ANNEX_TEXT_MATCH", - "evidence": { - "inputs_used": { - "ncm": "19022000", - "crt": "3", - "rtc_enabled": true - }, - "evidence": [ - { - "annex": "VII", - "item": 9, - "prefix": "19022000", - "clean": true, - "revoked": false, - "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH" - } - ], - "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS" - }, - "official_source_version": { - "cclasstrib_table": "2026-06-22", - "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", - "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680" - }, - "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", - "timestamp": "2026-09-25T00:07:47+00:00", - "job_id": 424242, - "rowcount": 1, - "verified_after": true - }, - { - "outcome": "APPLIED", - "product_code": "000027", - "field": "CCLASSTRIB", - "old_value": null, - "new_value": "200034", - "cst_ibscbs": "200", - "rule_id": "RTC-R3", - "confidence_type": "ANNEX_TEXT_MATCH", - "evidence": { - "inputs_used": { - "ncm": "19022000", - "crt": "3", - "rtc_enabled": true - }, - "evidence": [ - { - "annex": "VII", - "item": 9, - "prefix": "19022000", - "clean": true, - "revoked": false, - "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH" - } - ], - "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS" - }, - "official_source_version": { - "cclasstrib_table": "2026-06-22", - "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", - "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680" - }, - "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", - "timestamp": "2026-09-25T00:07:47+00:00", - "job_id": 424242, - "rowcount": 1, - "verified_after": true - } - ] - }, - "rows_changed": 280, - "columns_changed": [ - "CCLASSTRIB" - ], - "fk_orphans": 0, - "cst_orphans": 0, - "written_by_class": { - "000001": 203, - "200014": 2, - "200034": 75 - }, - "catalog_rows_unchanged": 145, - "coverage_after": { - "total": 521, - "complete": 279, - "blocked": 242, - "unclassified": 0, - "coverage_percent": 53.55 - }, - "resolution_after": { - "total": 521, - "valid_existing": 280, - "ready_to_apply": 0, - "ambiguous": 215, - "insufficient": 26, - "conflicts": 0, - "official_mismatch": 0, - "not_applicable": 0, - "other_blocked": 0, - "by_rule": { - "RTC-A1": 41, - "RTC-A3": 10, - "RTC-R2": 26, - "RTC-R4": 203, - "RTC-R3": 77, - "RTC-A2": 164 - }, - "catalog_compatibility": { - "compatibility": "MATERIAL_MISMATCH", - "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, - "MISMATCH": 11, - "EXPIRED": 3 - }, - "expired_present_locally": 3 - } - }, - "second_run": { - "ready_total": 0, "write_attempts": 0, "write_successes": 0 }, + "approval_run": { + "without_approval_attempts": 0, + "approved": 5, + "write_successes": 5, + "only_approved": true + }, + "enabled_jobs": [ + { + "write_attempts": 72, + "write_successes": 72, + "aborted": {} + }, + { + "write_attempts": 0, + "write_successes": 0, + "aborted": {} + } + ], + "total_applied": 77, + "audit_sample": [ + { + "outcome": "APPLIED", + "product_code": "000024", + "field": "CCLASSTRIB", + "old_value": null, + "new_value": "200034", + "cst_ibscbs": "200", + "rule_id": "RTC-R3", + "confidence_type": "ANNEX_TEXT_MATCH", + "evidence": { + "inputs_used": { + "ncm": "19022000", + "crt": "3", + "rtc_enabled": true + }, + "evidence": [ + { + "annex": "VII", + "item": 9, + "prefix": "19022000", + "clean": true, + "revoked": false, + "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH" + } + ], + "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS" + }, + "official_source_version": { + "cclasstrib_table": "2026-06-22", + "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", + "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680" + }, + "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", + "timestamp": "2026-09-25T01:06:30+00:00", + "job_id": 2, + "rowcount": 1, + "verified_after": true, + "action": "APPLY", + "rule_version": "rtc-rules-v2", + "company_crt": "3", + "description_snapshot": "COXINHA ( 6 UNID )", + "rtc_status": "ENABLED", + "local_catalog_state": "MATCH_SAFE", + "authorization_origin": "HUB_MODE:APPROVAL_REQUIRED" + }, + { + "outcome": "APPLIED", + "product_code": "000027", + "field": "CCLASSTRIB", + "old_value": null, + "new_value": "200034", + "cst_ibscbs": "200", + "rule_id": "RTC-R3", + "confidence_type": "ANNEX_TEXT_MATCH", + "evidence": { + "inputs_used": { + "ncm": "19022000", + "crt": "3", + "rtc_enabled": true + }, + "evidence": [ + { + "annex": "VII", + "item": 9, + "prefix": "19022000", + "clean": true, + "revoked": false, + "text": "Massas alimentícias dos códigos 1902.20.00 e 1902.30.00 da NCM/SH" + } + ], + "reason": "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS" + }, + "official_source_version": { + "cclasstrib_table": "2026-06-22", + "cclasstrib_sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", + "lc214_sha256": "5c7d59b3c49e5b7c066c2ad28bedf32791193a676ec1a163bec14bea5fa2f680" + }, + "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", + "timestamp": "2026-09-25T01:06:30+00:00", + "job_id": 2, + "rowcount": 1, + "verified_after": true, + "action": "APPLY", + "rule_version": "rtc-rules-v2", + "company_crt": "3", + "description_snapshot": "RISOLE DE CAMARAO", + "rtc_status": "ENABLED", + "local_catalog_state": "MATCH_SAFE", + "authorization_origin": "HUB_MODE:APPROVAL_REQUIRED" + } + ], + "audit_fields_complete": true, + "rows_changed": 77, + "fk_orphans": 0, + "cst_orphans": 0, + "coverage_after": { + "total": 521, + "complete": 77, + "blocked": 444, + "unclassified": 0, + "coverage_percent": 14.78 + }, + "second_run": { + "write_attempts": 0, + "write_successes": 0, + "ready_total": 0 + }, + "rollback": { + "rolled_back": 3, + "conflict": [ + "ABORTED", + "ROLLBACK_CONFLICT_VALUE_CHANGED" + ], + "records": [ + "ROLLED_BACK", + "ROLLED_BACK", + "ROLLED_BACK", + "ABORTED" + ], + "reread": { + "000024": "", + "000027": "", + "000028": "", + "000032": "200035" + } + }, + "reapply": { + "write_successes": 3, + "codes": [ + "000024", + "000027", + "000028" + ], + "expected": [ + "000024", + "000027", + "000028" + ] + }, + "idempotency_after_reapply": { + "write_attempts": 0, + "write_successes": 0 + }, + "NON_TARGET_COLUMNS_CHANGED": 0, + "CATALOG_CHANGED": 0, + "PARAMETERS_CHANGED": 0, + "DOCUMENT_TABLES_CHANGED": 0, + "OTHER_TABLES_CHANGED": 0, + "changed_tables": [], "IDEMPOTENT": true, - "ONLY_CCLASSTRIB_CHANGED": true, "PASS": true } \ No newline at end of file diff --git a/docs/runs/rtc_resolution_audit_sample.csv b/docs/runs/rtc_resolution_audit_sample.csv index 6ac3e32..1ffde67 100644 --- a/docs/runs/rtc_resolution_audit_sample.csv +++ b/docs/runs/rtc_resolution_audit_sample.csv @@ -13,89 +13,89 @@ TESTESOFICIAIS,000018,02071100,,,AMBIGUOUS,RTC-A1,ANNEX_I_NEEDS_OPERATION_OR_CON TESTESOFICIAIS,000019,07133321,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" TESTESOFICIAIS,000021,07089000,,,AMBIGUOUS,RTC-A1,ANNEX_XV_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" TESTESOFICIAIS,000022,07089000,,,AMBIGUOUS,RTC-A1,ANNEX_XV_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" -TESTESOFICIAIS,000023,16025000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" TESTESOFICIAIS,000024,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" TESTESOFICIAIS,000025,04061010,,,AMBIGUOUS,RTC-A1,ANNEX_I_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" TESTESOFICIAIS,000026,03055100,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" TESTESOFICIAIS,000027,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" TESTESOFICIAIS,000028,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000001,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000002,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000003,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000004,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000005,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000006,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000007,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000008,25232910,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000009,25232910,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000010,25232910,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000011,68118200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000012,72142000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000013,68101100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000014,68101100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000015,40139000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000016,40139000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" +TESTESOFICIAIS,000029,04089100,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" CONSTRUCAO,000017,30045020,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000018,56075090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000019,56075090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -CONSTRUCAO,000020,56075090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000075,38089199,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000076,38089119,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000077,38089119,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000078,38089996,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000086,90303390,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000092,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000093,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000094,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000095,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000096,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000097,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000108,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000109,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000113,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000114,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000115,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000116,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000117,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +CONSTRUCAO,000118,87141000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" MMMODAS,000002,19022000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000003,61046300,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000006,61046300,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000008,61046200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000011,61091000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000013,61051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000014,61051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000016,61051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000017,62111100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000018,62111100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000019,62034200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000020,42021220,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000022,42021900,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000023,65040090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000024,61091000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000025,61091000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000026,61061000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000027,62064000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000028,62046200,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -MMMODAS,000029,62034300,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000001,21050090,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,000729,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,000730,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,000731,90031990,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,000732,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,000733,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,000735,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,000736,90041000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001248,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001259,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001260,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001261,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001262,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001263,71171900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001264,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001265,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001266,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001267,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001270,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +MMMODAS,001272,71132000,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000004,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000005,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000006,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000007,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000008,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000009,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000010,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000011,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000012,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000013,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000014,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000015,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000016,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000017,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000018,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000019,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" RINCAO,000020,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000021,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -RINCAO,000022,18063210,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000008,33059000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000009,21039021,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000026,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000027,21069090,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000032,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000033,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000034,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000035,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000036,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000037,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000038,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000039,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000040,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +RINCAO,000041,17041000,,,AMBIGUOUS,RTC-A1,ANNEX_X_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000014,19059010,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000015,95051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000016,21032010,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000017,95030099,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000018,38089999,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000019,38089999,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000020,38089999,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000021,95051000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000022,22021000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000023,19053100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000024,82041100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000025,04089900,,,AMBIGUOUS,RTC-A3,SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000026,15171000,,200003,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_I_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000027,82054000,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000028,04090000,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000029,19053100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" UNIAO,000030,04022120,,,AMBIGUOUS,RTC-A1,ANNEX_I_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" -UNIAO,000031,19053100,,000001,RESOLVED_WITH_EXPLICIT_RULE,RTC-R4,NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER,EXCLUSION_OF_ALL_SPECIAL_REGIMES,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000032,19019090,,200003,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_I_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000034,20081100,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000035,15121911,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000046,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000047,23023010,,,AMBIGUOUS,RTC-A1,ANNEX_IX_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000050,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000052,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000054,20057000,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000056,20081100,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000058,15121911,,,AMBIGUOUS,RTC-A1,ANNEX_VII_NEEDS_OPERATION_OR_CONDITION,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000064,02101200,,,AMBIGUOUS,RTC-A2,LISTED_IN_MORE_THAN_ONE_ANNEX,NONE,"WHATIF_NORMAL_ON (regime normal, RTC on)" +UNIAO,000077,20089900,,200034,RESOLVED_WITH_EXPLICIT_RULE,RTC-R3,ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS,ANNEX_TEXT_MATCH,"WHATIF_NORMAL_ON (regime normal, RTC on)" diff --git a/docs/runs/rtc_resolution_dryrun.json b/docs/runs/rtc_resolution_dryrun.json index 3137d97..f9cd935 100644 --- a/docs/runs/rtc_resolution_dryrun.json +++ b/docs/runs/rtc_resolution_dryrun.json @@ -16,10 +16,12 @@ "total": 521, "valid_existing": 0, "ready_to_apply": 0, + "suggestion_only": 0, "ambiguous": 0, "insufficient": 521, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -31,11 +33,12 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } @@ -50,7 +53,7 @@ "context": { "rtc_param": "1", "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", - "rtc_observed_at": "2026-09-25T00:11:38+00:00", + "rtc_observed_at": "2026-09-25T01:07:28+00:00", "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -76,10 +79,12 @@ "total": 521, "valid_existing": 0, "ready_to_apply": 0, + "suggestion_only": 0, "ambiguous": 0, "insufficient": 521, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -91,11 +96,12 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } @@ -110,7 +116,7 @@ "context": { "rtc_param": "1", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:38+00:00", + "rtc_observed_at": "2026-09-25T01:07:28+00:00", "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -135,11 +141,13 @@ "resolution_summary": { "total": 521, "valid_existing": 0, - "ready_to_apply": 280, + "ready_to_apply": 77, + "suggestion_only": 203, "ambiguous": 215, "insufficient": 26, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -165,11 +173,12 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } @@ -177,18 +186,20 @@ "by_status": { "AMBIGUOUS": 215, "INSUFFICIENT_EVIDENCE": 26, - "RESOLVED_WITH_EXPLICIT_RULE": 280 + "SUGGESTION_ONLY": 203, + "RESOLVED_WITH_EXPLICIT_RULE": 77 }, "rtc_blocker_types": { "CCLASSTRIB_AMBIGUOUS": 215, "RTC_RULE_EVIDENCE_MISSING": 26, - "CCLASSTRIB_MISSING": 280 + "CCLASSTRIB_MISSING_UNRESOLVED": 203, + "CCLASSTRIB_MISSING": 77 }, - "AUTO_APPLY_CANDIDATES": 280, + "AUTO_APPLY_CANDIDATES": 77, "context": { "rtc_param": "1", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:38+00:00", + "rtc_observed_at": "2026-09-25T01:07:29+00:00", "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -220,10 +231,12 @@ "total": 3712, "valid_existing": 0, "ready_to_apply": 0, + "suggestion_only": 0, "ambiguous": 0, "insufficient": 3712, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -235,11 +248,12 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } @@ -254,7 +268,7 @@ "context": { "rtc_param": "1", "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", - "rtc_observed_at": "2026-09-25T00:11:39+00:00", + "rtc_observed_at": "2026-09-25T01:07:29+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -280,10 +294,12 @@ "total": 3712, "valid_existing": 0, "ready_to_apply": 0, + "suggestion_only": 0, "ambiguous": 0, "insufficient": 3712, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -295,11 +311,12 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } @@ -314,7 +331,7 @@ "context": { "rtc_param": "1", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:39+00:00", + "rtc_observed_at": "2026-09-25T01:07:30+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -339,11 +356,13 @@ "resolution_summary": { "total": 3712, "valid_existing": 0, - "ready_to_apply": 2725, + "ready_to_apply": 0, + "suggestion_only": 2725, "ambiguous": 973, "insufficient": 14, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -367,30 +386,31 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } }, "by_status": { - "RESOLVED_WITH_EXPLICIT_RULE": 2725, + "SUGGESTION_ONLY": 2725, "AMBIGUOUS": 973, "INSUFFICIENT_EVIDENCE": 14 }, "rtc_blocker_types": { - "CCLASSTRIB_MISSING": 2725, + "CCLASSTRIB_MISSING_UNRESOLVED": 2725, "CCLASSTRIB_AMBIGUOUS": 973, "RTC_RULE_EVIDENCE_MISSING": 14 }, - "AUTO_APPLY_CANDIDATES": 2725, + "AUTO_APPLY_CANDIDATES": 0, "context": { "rtc_param": "1", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:40+00:00", + "rtc_observed_at": "2026-09-25T01:07:30+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -427,7 +447,7 @@ "context": { "rtc_param": "0", "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", - "rtc_observed_at": "2026-09-25T00:11:43+00:00", + "rtc_observed_at": "2026-09-25T01:07:33+00:00", "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", "official_catalog": null, "catalog_integrity": { @@ -449,10 +469,12 @@ "total": 20996, "valid_existing": 0, "ready_to_apply": 0, + "suggestion_only": 0, "ambiguous": 0, "insufficient": 20996, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -464,11 +486,12 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } @@ -483,7 +506,7 @@ "context": { "rtc_param": "0", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:44+00:00", + "rtc_observed_at": "2026-09-25T01:07:35+00:00", "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -508,54 +531,59 @@ "resolution_summary": { "total": 20996, "valid_existing": 0, - "ready_to_apply": 20831, - "ambiguous": 49, + "ready_to_apply": 1, + "suggestion_only": 20565, + "ambiguous": 314, "insufficient": 116, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { "RTC-R3": 1, - "RTC-R4": 20830, + "RTC-R4": 20565, "RTC-R2": 116, - "RTC-A3": 48, + "RTC-A3": 313, "RTC-A1": 1 }, "by_reason": { "ANNEX_VII_CLEAN_ITEM_UNIQUE_DEFAULT_CLASS": 1, - "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER": 20830, + "NCM_IN_NO_ANNEX_AND_NO_NATURE_BASED_CHAPTER": 20565, "NO_VALID_NCM": 116, - "SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD": 48, + "SPECIAL_REGIME_DEPENDS_ON_NATURE_OR_OPERATION_OF_THE_GOOD": 313, "ANNEX_XV_NEEDS_OPERATION_OR_CONDITION": 1 }, "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } }, "by_status": { - "RESOLVED_WITH_EXPLICIT_RULE": 20831, + "RESOLVED_WITH_EXPLICIT_RULE": 1, + "SUGGESTION_ONLY": 20565, "INSUFFICIENT_EVIDENCE": 116, - "AMBIGUOUS": 49 + "AMBIGUOUS": 314 }, "rtc_blocker_types": { - "CCLASSTRIB_MISSING": 20831, + "CCLASSTRIB_MISSING": 1, + "CCLASSTRIB_MISSING_UNRESOLVED": 20565, "RTC_RULE_EVIDENCE_MISSING": 116, - "CCLASSTRIB_AMBIGUOUS": 49 + "CCLASSTRIB_AMBIGUOUS": 314 }, - "AUTO_APPLY_CANDIDATES": 20831, + "AUTO_APPLY_CANDIDATES": 1, "context": { "rtc_param": "0", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:49+00:00", + "rtc_observed_at": "2026-09-25T01:07:39+00:00", "local_catalog_hash": "1da77aec27fbde88488d1edf562f3af315ab2b5d06efb2e0b2f1ed606585d459", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -592,7 +620,7 @@ "context": { "rtc_param": "0", "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", - "rtc_observed_at": "2026-09-25T00:11:50+00:00", + "rtc_observed_at": "2026-09-25T01:07:40+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": null, "catalog_integrity": { @@ -613,11 +641,13 @@ "resolution_summary": { "total": 894, "valid_existing": 0, - "ready_to_apply": 530, + "ready_to_apply": 63, + "suggestion_only": 467, "ambiguous": 364, "insufficient": 0, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -642,28 +672,31 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } }, "by_status": { - "RESOLVED_WITH_EXPLICIT_RULE": 530, - "AMBIGUOUS": 364 + "SUGGESTION_ONLY": 467, + "AMBIGUOUS": 364, + "RESOLVED_WITH_EXPLICIT_RULE": 63 }, "rtc_blocker_types": { - "CCLASSTRIB_MISSING": 530, - "CCLASSTRIB_AMBIGUOUS": 364 + "CCLASSTRIB_MISSING_UNRESOLVED": 467, + "CCLASSTRIB_AMBIGUOUS": 364, + "CCLASSTRIB_MISSING": 63 }, - "AUTO_APPLY_CANDIDATES": 530, + "AUTO_APPLY_CANDIDATES": 63, "context": { "rtc_param": "0", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:50+00:00", + "rtc_observed_at": "2026-09-25T01:07:41+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -688,11 +721,13 @@ "resolution_summary": { "total": 894, "valid_existing": 0, - "ready_to_apply": 530, + "ready_to_apply": 63, + "suggestion_only": 467, "ambiguous": 364, "insufficient": 0, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -717,28 +752,31 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } }, "by_status": { - "RESOLVED_WITH_EXPLICIT_RULE": 530, - "AMBIGUOUS": 364 + "SUGGESTION_ONLY": 467, + "AMBIGUOUS": 364, + "RESOLVED_WITH_EXPLICIT_RULE": 63 }, "rtc_blocker_types": { - "CCLASSTRIB_MISSING": 530, - "CCLASSTRIB_AMBIGUOUS": 364 + "CCLASSTRIB_MISSING_UNRESOLVED": 467, + "CCLASSTRIB_AMBIGUOUS": 364, + "CCLASSTRIB_MISSING": 63 }, - "AUTO_APPLY_CANDIDATES": 530, + "AUTO_APPLY_CANDIDATES": 63, "context": { "rtc_param": "0", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:50+00:00", + "rtc_observed_at": "2026-09-25T01:07:41+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -775,7 +813,7 @@ "context": { "rtc_param": "0", "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", - "rtc_observed_at": "2026-09-25T00:11:52+00:00", + "rtc_observed_at": "2026-09-25T01:07:42+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": null, "catalog_integrity": { @@ -797,10 +835,12 @@ "total": 8942, "valid_existing": 0, "ready_to_apply": 0, + "suggestion_only": 0, "ambiguous": 0, "insufficient": 8942, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -812,11 +852,12 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } @@ -831,7 +872,7 @@ "context": { "rtc_param": "0", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:52+00:00", + "rtc_observed_at": "2026-09-25T01:07:43+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", @@ -856,11 +897,13 @@ "resolution_summary": { "total": 8942, "valid_existing": 0, - "ready_to_apply": 7419, + "ready_to_apply": 989, + "suggestion_only": 6430, "ambiguous": 1432, "insufficient": 91, "conflicts": 0, "official_mismatch": 0, + "catalog_incompatible": 0, "not_applicable": 0, "other_blocked": 0, "by_rule": { @@ -894,30 +937,33 @@ "catalog_compatibility": { "compatibility": "MATERIAL_MISMATCH", "counts": { - "MATCH": 131, - "LOCAL_ONLY": 0, - "OFFICIAL_ONLY": 19, + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, "MISMATCH": 11, - "EXPIRED": 3 + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 }, "expired_present_locally": 3 } }, "by_status": { - "RESOLVED_WITH_EXPLICIT_RULE": 7419, + "SUGGESTION_ONLY": 6430, + "RESOLVED_WITH_EXPLICIT_RULE": 989, "AMBIGUOUS": 1432, "INSUFFICIENT_EVIDENCE": 91 }, "rtc_blocker_types": { - "CCLASSTRIB_MISSING": 7419, + "CCLASSTRIB_MISSING_UNRESOLVED": 6430, + "CCLASSTRIB_MISSING": 989, "CCLASSTRIB_AMBIGUOUS": 1432, "RTC_RULE_EVIDENCE_MISSING": 91 }, - "AUTO_APPLY_CANDIDATES": 7419, + "AUTO_APPLY_CANDIDATES": 989, "context": { "rtc_param": "0", "rtc_source": "SIMULATION", - "rtc_observed_at": "2026-09-25T00:11:54+00:00", + "rtc_observed_at": "2026-09-25T01:07:45+00:00", "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", "official_catalog": { "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", diff --git a/docs/runs/scale_hub_test.json b/docs/runs/scale_hub_test.json new file mode 100644 index 0000000..6551fa6 --- /dev/null +++ b/docs/runs/scale_hub_test.json @@ -0,0 +1,48 @@ +{ + "products": 21000, + "result_payload_bytes": 8342480, + "result_payload_mb": 7.96, + "persist_first_pass": { + "status": 200, + "seconds": 5.46, + "sql_statements": 26043, + "statements_per_product": 1.24 + }, + "persist_steady_state": { + "status": 200, + "seconds": 3.45, + "sql_statements": 64, + "statements_per_product": 0.0 + }, + "rows": { + "blocked": 21000, + "rtc_candidates": 5000 + }, + "panel_pages": { + "dashboard": { + "status": 200, + "seconds": 0.209, + "html_kb": 10.3 + }, + "agent_detail": { + "status": 200, + "seconds": 0.196, + "html_kb": 10.7 + }, + "rtc_control_p1": { + "status": 200, + "seconds": 0.036, + "html_kb": 18.6 + }, + "rtc_control_p50": { + "status": 200, + "seconds": 0.017, + "html_kb": 18.9 + }, + "blockers": { + "status": 200, + "seconds": 0.228, + "html_kb": 40.2 + } + } +} \ No newline at end of file diff --git a/docs/runs/unified_coverage_RINCAO.json b/docs/runs/unified_coverage_RINCAO.json new file mode 100644 index 0000000..a401dda --- /dev/null +++ b/docs/runs/unified_coverage_RINCAO.json @@ -0,0 +1,462 @@ +{ + "mode": "FULL (Hub decision + fiscal)", + "database": "RINCAO", + "crt": "3", + "products": 894, + "fiscal_products_read": 894, + "BEFORE": { + "COMPLETE_PRODUCTS": 894, + "SAFE_BLOCKED_PRODUCTS": 0, + "COVERAGE_PERCENT": 100.0, + "UNKNOWN_APPLICABILITY_previous_model": 815 + }, + "runs": { + "ERP_AS_FOUND": { + "rtc_enabled": false, + "TOTAL_PRODUCTS": 894, + "COMPLETE_PRODUCTS": 893, + "SAFE_BLOCKED_PRODUCTS": 1, + "FAILED_PRODUCTS": 0, + "UNCLASSIFIED_PRODUCTS": 0, + "COVERAGE_PERCENT": 99.89, + "INVARIANT_PASS": true, + "PIS_BLOCKERS": 1, + "COFINS_BLOCKERS": 1, + "RTC_BLOCKERS": 0, + "OTHER_BLOCKERS": 0, + "blockers_by_type": { + "COFINS_CST_INVALID": 1, + "PIS_CST_INVALID": 1 + }, + "applicability": { + "OPTIONAL": 894 + }, + "rtc_applicability": { + "NOT_APPLICABLE": 894 + }, + "changed_products": 1, + "examples": [ + { + "product": "000866", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "COFINS_CST_INVALID", + "PIS_CST_INVALID" + ] + } + ], + "non_canonical_warnings": 78, + "context": { + "rtc_enabled": false, + "erp_rtc_enabled": false, + "rtc_supported": true, + "rtc_catalog_valid": true, + "rtc_param": "0", + "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", + "rtc_param_rows": 1, + "rtc_param_conflict": false, + "rtc_observed_at": "2026-09-25T01:22:08+00:00", + "legal_effective_context": { + "crt": "3", + "informational_only": true, + "ibs_cbs_in_documents": "MANDATORY_FROM_2026-08-03", + "cclasstrib_orientation": "TABLE_AND_LC214_ANNEXES", + "sources": [ + "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1", + "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT", + "LC 214/2025 art. 348" + ] + }, + "catalog_integrity": { + "classes": 145, + "cst": 18, + "orphan_classes": 0, + "bad_class_format": 0, + "bad_cst_format": 0, + "duplicates": 0, + "valid": true + }, + "crt": "3", + "products_evaluated": 894, + "pis_cofins_applicability": { + "OPTIONAL": 894 + }, + "rtc_resolution": null, + "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", + "official_catalog": null, + "non_canonical_warnings": { + "products": 78, + "by_type": { + "CST_COFINS_NON_CANONICAL_FORMAT": 78, + "CST_PIS_NON_CANONICAL_FORMAT": 78 + }, + "samples": [ + { + "product_code": "000139", + "type": "CST_PIS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000139", + "type": "CST_COFINS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000140", + "type": "CST_PIS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000140", + "type": "CST_COFINS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000141", + "type": "CST_PIS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000141", + "type": "CST_COFINS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000142", + "type": "CST_PIS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000142", + "type": "CST_COFINS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000143", + "type": "CST_PIS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + }, + { + "product_code": "000143", + "type": "CST_COFINS_NON_CANONICAL_FORMAT", + "reason": "EXIT:4->04" + } + ] + } + } + }, + "RTC_OFF_SIMULATION": { + "rtc_enabled": false, + "TOTAL_PRODUCTS": 894, + "COMPLETE_PRODUCTS": 893, + "SAFE_BLOCKED_PRODUCTS": 1, + "FAILED_PRODUCTS": 0, + "UNCLASSIFIED_PRODUCTS": 0, + "COVERAGE_PERCENT": 99.89, + "INVARIANT_PASS": true, + "PIS_BLOCKERS": 1, + "COFINS_BLOCKERS": 1, + "RTC_BLOCKERS": 0, + "OTHER_BLOCKERS": 0, + "blockers_by_type": { + "COFINS_CST_INVALID": 1, + "PIS_CST_INVALID": 1 + }, + "applicability": { + "OPTIONAL": 894 + }, + "rtc_applicability": { + "NOT_APPLICABLE": 894 + }, + "changed_products": 1, + "examples": [ + { + "product": "000866", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "COFINS_CST_INVALID", + "PIS_CST_INVALID" + ] + } + ], + "non_canonical_warnings": 78 + }, + "RTC_ON_SIMULATION": { + "rtc_enabled": true, + "TOTAL_PRODUCTS": 894, + "COMPLETE_PRODUCTS": 0, + "SAFE_BLOCKED_PRODUCTS": 894, + "FAILED_PRODUCTS": 0, + "UNCLASSIFIED_PRODUCTS": 0, + "COVERAGE_PERCENT": 0.0, + "INVARIANT_PASS": true, + "PIS_BLOCKERS": 1, + "COFINS_BLOCKERS": 1, + "RTC_BLOCKERS": 894, + "OTHER_BLOCKERS": 0, + "blockers_by_type": { + "COFINS_CST_INVALID": 1, + "PIS_CST_INVALID": 1, + "RTC_RULE_EVIDENCE_MISSING": 894 + }, + "applicability": { + "OPTIONAL": 894 + }, + "rtc_applicability": { + "REQUIRED": 894 + }, + "changed_products": 894, + "examples": [ + { + "product": "000001", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000004", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000005", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000006", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000007", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000008", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000009", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000010", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000011", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000012", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000013", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000014", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000015", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000016", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000017", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000018", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000019", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000020", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000021", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000022", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000023", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000024", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000025", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000026", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000027", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000028", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000029", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000030", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000031", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + }, + { + "product": "000032", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "RTC_RULE_EVIDENCE_MISSING" + ] + } + ], + "non_canonical_warnings": 78 + } + }, + "SAFETY": { + "FIREBIRD_WRITES": 0, + "WRITE_ATTEMPTS": 0, + "WRITE_SUCCESSES": 0, + "connection_mode": "READ COMMITTED READ ONLY (Agent reader); no write-capable connection opened" + } +} \ No newline at end of file diff --git a/docs/runs/unified_coverage_UNIAO.json b/docs/runs/unified_coverage_UNIAO.json new file mode 100644 index 0000000..399b6ae --- /dev/null +++ b/docs/runs/unified_coverage_UNIAO.json @@ -0,0 +1,393 @@ +{ + "mode": "FULL (Hub decision + fiscal)", + "database": "UNIAO", + "crt": "1", + "products": 8942, + "fiscal_products_read": 8942, + "BEFORE": { + "COMPLETE_PRODUCTS": 8851, + "SAFE_BLOCKED_PRODUCTS": 91, + "COVERAGE_PERCENT": 98.98, + "UNKNOWN_APPLICABILITY_previous_model": 8170 + }, + "runs": { + "ERP_AS_FOUND": { + "rtc_enabled": false, + "TOTAL_PRODUCTS": 8942, + "COMPLETE_PRODUCTS": 8851, + "SAFE_BLOCKED_PRODUCTS": 91, + "FAILED_PRODUCTS": 0, + "UNCLASSIFIED_PRODUCTS": 0, + "COVERAGE_PERCENT": 98.98, + "INVARIANT_PASS": true, + "PIS_BLOCKERS": 0, + "COFINS_BLOCKERS": 0, + "RTC_BLOCKERS": 0, + "OTHER_BLOCKERS": 0, + "blockers_by_type": {}, + "applicability": { + "NOT_APPLICABLE": 8942 + }, + "rtc_applicability": { + "NOT_APPLICABLE": 8942 + }, + "changed_products": 0, + "examples": [], + "non_canonical_warnings": 2, + "context": { + "rtc_enabled": false, + "erp_rtc_enabled": false, + "rtc_supported": true, + "rtc_catalog_valid": true, + "rtc_param": "0", + "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", + "rtc_param_rows": 1, + "rtc_param_conflict": false, + "rtc_observed_at": "2026-09-25T01:27:07+00:00", + "legal_effective_context": { + "crt": "1", + "informational_only": true, + "ibs_cbs_in_documents": "MANDATORY_FROM_2027-01-01", + "cclasstrib_orientation": "PENDING_OFFICIAL_NT", + "sources": [ + "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1", + "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT", + "LC 214/2025 art. 348" + ] + }, + "catalog_integrity": { + "classes": 145, + "cst": 18, + "orphan_classes": 0, + "bad_class_format": 0, + "bad_cst_format": 0, + "duplicates": 0, + "valid": true + }, + "crt": "1", + "products_evaluated": 8942, + "pis_cofins_applicability": { + "NOT_APPLICABLE": 8942 + }, + "rtc_resolution": null, + "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", + "official_catalog": null, + "non_canonical_warnings": { + "products": 2, + "by_type": { + "CST_COFINS_INVALID_IGNORED": 2 + }, + "samples": [ + { + "product_code": "000304", + "type": "CST_COFINS_INVALID_IGNORED", + "reason": "ENTRY:000" + }, + { + "product_code": "003090", + "type": "CST_COFINS_INVALID_IGNORED", + "reason": "EXIT:060" + } + ] + } + } + }, + "RTC_OFF_SIMULATION": { + "rtc_enabled": false, + "TOTAL_PRODUCTS": 8942, + "COMPLETE_PRODUCTS": 8851, + "SAFE_BLOCKED_PRODUCTS": 91, + "FAILED_PRODUCTS": 0, + "UNCLASSIFIED_PRODUCTS": 0, + "COVERAGE_PERCENT": 98.98, + "INVARIANT_PASS": true, + "PIS_BLOCKERS": 0, + "COFINS_BLOCKERS": 0, + "RTC_BLOCKERS": 0, + "OTHER_BLOCKERS": 0, + "blockers_by_type": {}, + "applicability": { + "NOT_APPLICABLE": 8942 + }, + "rtc_applicability": { + "NOT_APPLICABLE": 8942 + }, + "changed_products": 0, + "examples": [], + "non_canonical_warnings": 2 + }, + "RTC_ON_SIMULATION": { + "rtc_enabled": true, + "TOTAL_PRODUCTS": 8942, + "COMPLETE_PRODUCTS": 0, + "SAFE_BLOCKED_PRODUCTS": 8942, + "FAILED_PRODUCTS": 0, + "UNCLASSIFIED_PRODUCTS": 0, + "COVERAGE_PERCENT": 0.0, + "INVARIANT_PASS": true, + "PIS_BLOCKERS": 0, + "COFINS_BLOCKERS": 0, + "RTC_BLOCKERS": 8942, + "OTHER_BLOCKERS": 0, + "blockers_by_type": { + "CCLASSTRIB_MISSING_UNRESOLVED": 8942 + }, + "applicability": { + "NOT_APPLICABLE": 8942 + }, + "rtc_applicability": { + "REQUIRED": 8942 + }, + "changed_products": 8851, + "examples": [ + { + "product": "000008", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000009", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000014", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000015", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000016", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000017", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000018", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000019", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000020", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000021", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000022", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000023", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000024", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000025", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000026", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000027", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000028", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000029", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000030", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000031", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000032", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000033", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000034", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000035", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000036", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000037", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000038", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000039", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000040", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000041", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + } + ], + "non_canonical_warnings": 2 + } + }, + "SAFETY": { + "FIREBIRD_WRITES": 0, + "WRITE_ATTEMPTS": 0, + "WRITE_SUCCESSES": 0, + "connection_mode": "READ COMMITTED READ ONLY (Agent reader); no write-capable connection opened" + } +} \ No newline at end of file diff --git a/docs/runs/unified_coverage_e2e.json b/docs/runs/unified_coverage_e2e.json index 1e402b4..a276657 100644 --- a/docs/runs/unified_coverage_e2e.json +++ b/docs/runs/unified_coverage_e2e.json @@ -1,49 +1,70 @@ { "mode": "FULL (Hub decision + fiscal)", - "database": "TESTESOFICIAIS", + "database": "CONSTRUCAO", "crt": "1", - "products": 521, - "fiscal_products_read": 521, + "products": 3712, + "fiscal_products_read": 3712, "BEFORE": { - "COMPLETE_PRODUCTS": 495, - "SAFE_BLOCKED_PRODUCTS": 26, - "COVERAGE_PERCENT": 95.01, - "UNKNOWN_APPLICABILITY_previous_model": 518 + "COMPLETE_PRODUCTS": 3698, + "SAFE_BLOCKED_PRODUCTS": 14, + "COVERAGE_PERCENT": 99.62, + "UNKNOWN_APPLICABILITY_previous_model": 3712 }, "runs": { "ERP_AS_FOUND": { "rtc_enabled": true, - "TOTAL_PRODUCTS": 521, + "TOTAL_PRODUCTS": 3712, "COMPLETE_PRODUCTS": 0, - "SAFE_BLOCKED_PRODUCTS": 521, + "SAFE_BLOCKED_PRODUCTS": 3712, "FAILED_PRODUCTS": 0, "UNCLASSIFIED_PRODUCTS": 0, "COVERAGE_PERCENT": 0.0, "INVARIANT_PASS": true, - "PIS_BLOCKERS": 1, - "COFINS_BLOCKERS": 1, - "RTC_BLOCKERS": 521, + "PIS_BLOCKERS": 0, + "COFINS_BLOCKERS": 0, + "RTC_BLOCKERS": 3712, "OTHER_BLOCKERS": 0, "blockers_by_type": { - "CCLASSTRIB_MISSING": 521, - "COFINS_CST_INCIDENCE_MISMATCH": 1, - "PIS_CST_INCIDENCE_MISMATCH": 1 + "CCLASSTRIB_MISSING_UNRESOLVED": 3712 }, "applicability": { - "NOT_APPLICABLE": 519, - "REQUIRED": 2 + "NOT_APPLICABLE": 3712 }, "rtc_applicability": { - "REQUIRED": 521 + "REQUIRED": 3712 }, - "changed_products": 495, + "changed_products": 3698, "examples": [ + { + "product": "000001", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000002", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000003", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, { "product": "000004", "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -51,7 +72,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -59,7 +80,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000007", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -67,7 +96,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -75,7 +104,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -83,7 +112,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -91,7 +120,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000012", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -99,7 +136,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000014", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -107,7 +152,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000016", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -115,7 +168,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -123,7 +176,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -131,7 +184,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000020", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -139,7 +200,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -147,7 +208,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -155,7 +216,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -163,7 +224,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -171,7 +232,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -179,7 +240,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -187,7 +248,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -195,7 +256,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -203,83 +264,19 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { - "product": "000032", + "product": "000030", "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000033", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000034", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000035", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000036", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000038", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000040", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000041", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000042", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] } ], - "non_canonical_warnings": 1, + "non_canonical_warnings": 0, "context": { "rtc_enabled": true, "erp_rtc_enabled": true, @@ -289,13 +286,15 @@ "rtc_source": "PARAMETROS.USAR_REFORMA_TRIBUTARIA", "rtc_param_rows": 1, "rtc_param_conflict": false, - "rtc_observed_at": "2026-09-24T20:40:30+00:00", + "rtc_observed_at": "2026-09-25T01:33:41+00:00", "legal_effective_context": { "crt": "1", "informational_only": true, - "ibs_cbs_in_documents": "FROM_2027_FOR_SIMPLES_ORIENTATION_PENDING", + "ibs_cbs_in_documents": "MANDATORY_FROM_2027-01-01", + "cclasstrib_orientation": "PENDING_OFFICIAL_NT", "sources": [ - "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT; IBS/CBS taxation of these taxpayers only from 2027", + "Ato Conjunto RFB/CGIBS n 4/2026 (DOU 31/07/2026) art. 1 I, II and par. 1", + "NT 2025.002-RTC v1.51 (Portal NF-e, 04/08/2026): CRT 1/2/4 orientations in a future NT", "LC 214/2025 art. 348" ] }, @@ -309,113 +308,132 @@ "valid": true }, "crt": "1", - "products_evaluated": 521, + "products_evaluated": 3712, "pis_cofins_applicability": { - "NOT_APPLICABLE": 519, - "REQUIRED": 2 + "NOT_APPLICABLE": 3712 + }, + "rtc_resolution": { + "total": 3712, + "valid_existing": 0, + "ready_to_apply": 0, + "suggestion_only": 0, + "ambiguous": 0, + "insufficient": 3712, + "conflicts": 0, + "official_mismatch": 0, + "catalog_incompatible": 0, + "not_applicable": 0, + "other_blocked": 0, + "by_rule": { + "RTC-R1": 3712 + }, + "by_reason": { + "SIMPLES_ORIENTATION_PENDING_OFFICIAL_NT": 3712 + }, + "catalog_compatibility": { + "compatibility": "MATERIAL_MISMATCH", + "counts": { + "MATCH_SAFE": 131, + "MATCH_WITH_NON_MATERIAL_DIFFERENCE": 0, + "MISMATCH": 11, + "EXPIRED": 3, + "MISSING_LOCAL": 19, + "MISSING_OFFICIAL": 0 + }, + "expired_present_locally": 3 + } + }, + "local_catalog_hash": "be8c5899fff3e236c6c77bbb887087c0a2db048aeff763a5823676c7b478a151", + "official_catalog": { + "sha256": "1448cb63a41bdb67ea30b4e11f4dc200d9568542c6b9335b6cff87a4fd664654", + "publication": "2026-06-22", + "file": "cClassTrib 2026-06-22.xlsx" }, "non_canonical_warnings": { - "products": 1, - "by_type": { - "CST_COFINS_NON_CANONICAL_FORMAT": 2, - "CST_PIS_NON_CANONICAL_FORMAT": 2 - }, - "samples": [ - { - "product_code": "001434", - "type": "CST_PIS_NON_CANONICAL_FORMAT", - "reason": "ENTRY:2->02" - }, - { - "product_code": "001434", - "type": "CST_PIS_NON_CANONICAL_FORMAT", - "reason": "EXIT:2->02" - }, - { - "product_code": "001434", - "type": "CST_COFINS_NON_CANONICAL_FORMAT", - "reason": "ENTRY:2->02" - }, - { - "product_code": "001434", - "type": "CST_COFINS_NON_CANONICAL_FORMAT", - "reason": "EXIT:2->02" - } - ] + "products": 0, + "by_type": {}, + "samples": [] } } }, "RTC_OFF_SIMULATION": { "rtc_enabled": false, - "TOTAL_PRODUCTS": 521, - "COMPLETE_PRODUCTS": 494, - "SAFE_BLOCKED_PRODUCTS": 27, + "TOTAL_PRODUCTS": 3712, + "COMPLETE_PRODUCTS": 3698, + "SAFE_BLOCKED_PRODUCTS": 14, "FAILED_PRODUCTS": 0, "UNCLASSIFIED_PRODUCTS": 0, - "COVERAGE_PERCENT": 94.82, + "COVERAGE_PERCENT": 99.62, "INVARIANT_PASS": true, - "PIS_BLOCKERS": 1, - "COFINS_BLOCKERS": 1, + "PIS_BLOCKERS": 0, + "COFINS_BLOCKERS": 0, "RTC_BLOCKERS": 0, "OTHER_BLOCKERS": 0, - "blockers_by_type": { - "COFINS_CST_INCIDENCE_MISMATCH": 1, - "PIS_CST_INCIDENCE_MISMATCH": 1 - }, + "blockers_by_type": {}, "applicability": { - "NOT_APPLICABLE": 519, - "REQUIRED": 2 + "NOT_APPLICABLE": 3712 }, "rtc_applicability": { - "NOT_APPLICABLE": 521 + "NOT_APPLICABLE": 3712 }, - "changed_products": 1, - "examples": [ - { - "product": "000700", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "COFINS_CST_INCIDENCE_MISMATCH", - "PIS_CST_INCIDENCE_MISMATCH" - ] - } - ], - "non_canonical_warnings": 1 + "changed_products": 0, + "examples": [], + "non_canonical_warnings": 0 }, "RTC_ON_SIMULATION": { "rtc_enabled": true, - "TOTAL_PRODUCTS": 521, + "TOTAL_PRODUCTS": 3712, "COMPLETE_PRODUCTS": 0, - "SAFE_BLOCKED_PRODUCTS": 521, + "SAFE_BLOCKED_PRODUCTS": 3712, "FAILED_PRODUCTS": 0, "UNCLASSIFIED_PRODUCTS": 0, "COVERAGE_PERCENT": 0.0, "INVARIANT_PASS": true, - "PIS_BLOCKERS": 1, - "COFINS_BLOCKERS": 1, - "RTC_BLOCKERS": 521, + "PIS_BLOCKERS": 0, + "COFINS_BLOCKERS": 0, + "RTC_BLOCKERS": 3712, "OTHER_BLOCKERS": 0, "blockers_by_type": { - "CCLASSTRIB_MISSING": 521, - "COFINS_CST_INCIDENCE_MISMATCH": 1, - "PIS_CST_INCIDENCE_MISMATCH": 1 + "CCLASSTRIB_MISSING_UNRESOLVED": 3712 }, "applicability": { - "NOT_APPLICABLE": 519, - "REQUIRED": 2 + "NOT_APPLICABLE": 3712 }, "rtc_applicability": { - "REQUIRED": 521 + "REQUIRED": 3712 }, - "changed_products": 495, + "changed_products": 3698, "examples": [ + { + "product": "000001", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000002", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000003", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, { "product": "000004", "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -423,7 +441,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -431,7 +449,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000007", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -439,7 +465,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -447,7 +473,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -455,7 +481,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -463,7 +489,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000012", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -471,7 +505,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000014", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -479,7 +521,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000016", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -487,7 +537,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -495,7 +545,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -503,7 +553,15 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" + ] + }, + { + "product": "000020", + "BEFORE_STATUS": "COMPLETE", + "AFTER_STATUS": "BLOCKED_FISCAL_DATA", + "REASON": [ + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -511,7 +569,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -519,7 +577,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -527,7 +585,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -535,7 +593,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -543,7 +601,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -551,7 +609,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -559,7 +617,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -567,7 +625,7 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { @@ -575,83 +633,19 @@ "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] }, { - "product": "000032", + "product": "000030", "BEFORE_STATUS": "COMPLETE", "AFTER_STATUS": "BLOCKED_FISCAL_DATA", "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000033", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000034", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000035", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000036", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000038", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000040", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000041", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" - ] - }, - { - "product": "000042", - "BEFORE_STATUS": "COMPLETE", - "AFTER_STATUS": "BLOCKED_FISCAL_DATA", - "REASON": [ - "CCLASSTRIB_MISSING" + "CCLASSTRIB_MISSING_UNRESOLVED" ] } ], - "non_canonical_warnings": 1 + "non_canonical_warnings": 0 } }, "SAFETY": { diff --git a/docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json b/docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json new file mode 100644 index 0000000..897faf2 --- /dev/null +++ b/docs/sources/RTC_OFFICIAL_ACTS_MANIFEST_V2.json @@ -0,0 +1,95 @@ +{ + "purpose": "Official acts consulted for the cClassTrib determination (closure mission). Authority order: legislation > Portal NF-e > Receita > SPED. Local files are ignored by git; hashes pin exactly what was read.", + "authority_order": [ + "LC 214/2025 and amendments (LC 227/2026)", + "Portal Nacional da NF-e (NT 2025.002-RTC v1.51, NT 2026.00x, cClassTrib table)", + "Receita Federal / CGIBS Atos Conjuntos (1/2025, 4/2026)", + "SPED" + ], + "findings": { + "simples_cclasstrib_orientation": "NONE_OFFICIAL_YET (NT 2025.002 v1.51 defers CRT 1/2/4; Ato Conjunto RFB/CGIBS 4/2026 sets the Simples obligation from 2027-01-01)", + "nfe_nfce_ibs_cbs_obligation": "from 2026-08-03 (Ato Conjunto RFB/CGIBS 4/2026 art. 1)" + }, + "files": [ + { + "local_file": "sources_cache/legal/ato_conj_1_2025.bin", + "sha256": "5354059c82c78902bd870ee241c068ecbe177cc36c3486867824d63195d257b7", + "bytes": 132565, + "published_name": "ATO CONJUNTO RFB_CGIBS Nº 1 DE 22 DE DEZEMBRO DE 2025.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Fri, 25 Sep 2026 00:41:10 GMT" + }, + { + "local_file": "sources_cache/legal/ato_conj_4_2026.bin", + "sha256": "64ab779b13ad3df47530bf9a77a07cd42402ebd5b2cff5ede403b0169c0f7b99", + "bytes": 119007, + "published_name": "ATO CONJUNTO RFB_CGIBS Nº 4 DE 30 DE julho DE 2026.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Fri, 25 Sep 2026 00:41:09 GMT" + }, + { + "local_file": "sources_cache/legal/ato_tec_1_2026.bin", + "sha256": "b67ba6228b7f9b4d55e982187d8d89ea680b56e4e97753f20f569ab29a71b67f", + "bytes": 107050, + "published_name": "ATO TÉCNICO CONJUNTO RFB_CGIBS Nº 1 DE 31 DE JULHO DE 2026.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Fri, 25 Sep 2026 00:41:08 GMT" + }, + { + "local_file": "sources_cache/legal/ato_tec_2_2026.bin", + "sha256": "11cb025e36ac93168a61f35dd47c469c344ff65dd88be6bf45cb34826dba73e1", + "bytes": 165111, + "published_name": "ATO TECNICO CONJUNTO RFB_SUFIS_CGIBS_DIRETORIA-EXECUTIVA N 2 DE 21 DE AGOSTO DE 2026.PDF", + "content_type": "application/pdf", + "accessed_http_date": "Fri, 25 Sep 2026 00:41:07 GMT" + }, + { + "local_file": "sources_cache/legal/nt2025_002_v151.bin", + "sha256": "a4aaaa181522b43cd90b502f8ccb9e4bdabea30fed838e2d0790be5ba77254a4", + "bytes": 2182361, + "published_name": "NT_2025.002_v1.51_RTC.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Thu, 24 Sep 2026 19:04:50 GMT" + }, + { + "local_file": "sources_cache/legal/nt2026_001.bin", + "sha256": "703310f95bff855aad9a4a7004515ce29ffff343334c53f0c45e9b32390296cd", + "bytes": 1010960, + "published_name": "NT_2026.001_v1.02b - PAA NFe.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Fri, 25 Sep 2026 00:41:41 GMT" + }, + { + "local_file": "sources_cache/legal/nt2026_002_v110a.bin", + "sha256": "705f771830565c209d815659347ac4ea251901c77064cc163ce5c93f311e6e64", + "bytes": 767698, + "published_name": "NT_2026.002_v1.10a_DANFE_Simpl_Tp2_Emi_Offline_Autoriz_Alerta_v2.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Thu, 24 Sep 2026 19:04:51 GMT" + }, + { + "local_file": "sources_cache/legal/nt2026_006.bin", + "sha256": "47a65abde315fed1e6a8c6085a67ca483935cb57877ae5715d365377a725e05d", + "bytes": 767681, + "published_name": "NT_2026.006_v1.00_RTC_VinculacaoPagamento[2775].pdf", + "content_type": "application/pdf", + "accessed_http_date": "Thu, 24 Sep 2026 19:04:52 GMT" + }, + { + "local_file": "sources_cache/legal/nt2026_007.bin", + "sha256": "559fcd7d1b495549099498ff5c1ba4165f65d4b5ad5b34dbe802869d4619d917", + "bytes": 733482, + "published_name": "NT2026.007_v1.00 - Emissão de NF-e sem IE e RV LCC.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Fri, 25 Sep 2026 00:41:40 GMT" + }, + { + "local_file": "sources_cache/legal/nt2026_009.bin", + "sha256": "50b7dec40bba8928b4d544feb4fdbd8c8bac16e37c99d926ddb2834cf246f15d", + "bytes": 310342, + "published_name": "NT2026.009_v1.00_Alteracao_RV_CFOP.pdf", + "content_type": "application/pdf", + "accessed_http_date": "Thu, 24 Sep 2026 19:04:53 GMT" + } + ] +} \ No newline at end of file diff --git a/release/RELEASE_MANIFEST.json b/release/RELEASE_MANIFEST.json index b584d7a..a4a0548 100644 --- a/release/RELEASE_MANIFEST.json +++ b/release/RELEASE_MANIFEST.json @@ -1,14 +1,14 @@ { "artifact": "RELEASE_MANIFEST", - "GTI_FISCAL_VERSION": "1.1.9", + "GTI_FISCAL_VERSION": "1.2.0", "generated_at": "2026-09-24T14:13:15+00:00", "hub": { - "version": "1.1.9", + "version": "1.2.0", "api_compat": "1.0", "engine_version": "0.3.0", "decision_version": "mission-04c-ipi-ex-split" }, - "agent_version": "1.1.9", + "agent_version": "1.2.0", "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": "0027_rtc_auto_apply", - "applied_revision": "0027_rtc_auto_apply", + "alembic_head_in_repo": "0028_rtc_write_modes", + "applied_revision": "0028_rtc_write_modes", "applied_revision_source": "production Hub 10.0.10.117 (gti-fiscal-hub-fiscal-hub-1), verified 2026-09-24", - "count": 27 + "count": 28 }, "datasets_active": [ { diff --git a/release/VERSION b/release/VERSION index 512a1fa..26aaba0 100644 --- a/release/VERSION +++ b/release/VERSION @@ -1 +1 @@ -1.1.9 +1.2.0 diff --git a/scripts/rtc_copy_write_e2e.py b/scripts/rtc_copy_write_e2e.py index c9764f3..561238c 100644 --- a/scripts/rtc_copy_write_e2e.py +++ b/scripts/rtc_copy_write_e2e.py @@ -1,19 +1,23 @@ -"""COPY-ONLY write E2E for the RTC auto-apply (C000025.CCLASSTRIB). +"""COPY-ONLY write E2E for the centrally controlled RTC auto-apply (C000025.CCLASSTRIB). python scripts/rtc_copy_write_e2e.py -Runs ONLY against scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (a gbak restore of TESTESOFICIAIS). It refuses any other path, and refuses every real database. -Scenario (all through the Agent's own code path `run_agent_service._rtc_auto_apply`): - 1. the copy's company is switched to regime normal (C000004.CRT='3'): a disposable experiment, so the rules can act (the real company is Simples => 0 candidates); - 2. assess -> candidates; 3. apply with all three gates open; 4. re-read, FK/CST checks, coverage; 5. ONLY CCLASSTRIB changed (whole-table diff); - 6. second run: 0 candidates, 0 writes; 7. stale-decision abort (RTC switched off on the copy between decision and write) writes nothing. +Runs ONLY against scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its own .fbk at the start, so every run is reproducible). It refuses any other path and every +real database. Everything goes through the Agent's own code path `run_agent_service._rtc_auto_apply`, driven ONLY by the authenticated job `authorizations` (no env switches): + 0 restore + company -> regime normal (documented experiment on the copy); 1 whole-database baseline hashes; + 2 DISABLED (0 writes, no preview) / DRY_RUN (preview, 0 writes) / stale decision (RTC off between decision and write => 0 writes); + 3 APPROVAL_REQUIRED with 5 approved products (only those 5 written) / then ENABLED until nothing is left; + 4 reread, FK + CST integrity, coverage; 5 second run = 0 writes; + 6 rollback of 3 audit records (+1 conflict: value changed by an operator => refused); 7 reread, reapply, idempotency; + 8 invariants: NON_TARGET_COLUMNS_CHANGED, CATALOG_CHANGED, PARAMETERS_CHANGED, DOCUMENT_TABLES_CHANGED, OTHER_TABLES_CHANGED all 0. Writes go to the copy and nowhere else. """ +import hashlib import importlib.util import json import os +import subprocess import sys -from datetime import date from pathlib import Path from types import SimpleNamespace @@ -27,7 +31,9 @@ from central.app.operational_state import classify_product_state, summarize_oper from gti_fiscal_agent import firebird_reader, unified_fiscal # noqa: E402 COPY = str((ROOT / "scratch_rtc_e2e" / "TESTESOFICIAIS_RTC_COPY.FDB").resolve()) +FBK = str((ROOT / "scratch_rtc_e2e" / "TESTESOFICIAIS_RTC_COPY.fbk").resolve()) NCM_OK = set((ROOT / "docs" / "runs" / "ncm_valid_codes.txt").read_text().split()) +DOC_TABLE_HINTS = ("NFCE", "NFE", "NF_", "CUPOM", "VENDA", "CTE", "MDFE") def guard(path): @@ -58,82 +64,159 @@ def digits(v): return "".join(ch for ch in str(v or "") if ch.isdigit()) -def snapshot_table(cur, table, key, skip_cols=()): +def restore_copy(): + guard(COPY) + gbak = Path(os.environ["FIREBIRD_CLIENT_LIBRARY"]).parent / "gbak.exe" + done = subprocess.run([str(gbak), "-c", "-rep", "-n", "-user", os.environ["FB_USER"], "-pas", os.environ["FB_PASSWORD"], FBK, COPY], capture_output=True, text=True, timeout=600) + if done.returncode != 0: + raise SystemExit(f"restore failed: {done.stderr[-300:]}") + + +def tables(cur): + cur.execute("SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0 AND RDB$VIEW_BLR IS NULL AND RDB$EXTERNAL_FILE IS NULL ORDER BY 1") + return [r[0] for r in cur.fetchall()] + + +def table_hash(cur, table, skip_cols=()): cur.execute(f"SELECT * FROM {table}") cols = [d[0] for d in cur.description] - ki = cols.index(key) - return cols, {r[ki]: {c: v for c, v in zip(cols, r) if c not in skip_cols} for r in cur.fetchall()} + keep = [i for i, c in enumerate(cols) if c not in skip_cols] + digest = hashlib.sha256() + rows = 0 + for row in cur.fetchall(): + digest.update(repr([row[i] for i in keep]).encode("utf-8", "replace")) + rows += 1 + return f"{rows}:{digest.hexdigest()[:20]}" -def coverage(env, company, ncm_valid, override=None): +def baseline(): + conn = connect(False); cur = conn.cursor() + out = {t: table_hash(cur, t, skip_cols=("CCLASSTRIB",) if t == "C000025" else ()) for t in tables(cur)} + cur.execute("SELECT CODIGO, CCLASSTRIB FROM C000025") + values = {str(r[0]).strip(): (r[1] or "").strip() for r in cur.fetchall()} + conn.close() + return out, values + + +def diff_tables(before, after): + return sorted(t for t in before if before[t] != after.get(t)) + + +def coverage(env, company, ncm_valid): conn = firebird_reader._connect(env) try: - out = unified_fiscal.assess_from_connection(conn, company, ncm_valid=ncm_valid, rtc_override=override) + out = unified_fiscal.assess_from_connection(conn, company, ncm_valid=ncm_valid) finally: conn.close() products = firebird_reader.read_products(env) states = [classify_product_state(p, {"status": "EXACT_NCM"}, fiscal=out["findings"].get(str(p["CODIGO"]))) for p in products] s = summarize_operational_states(states) - return out, {"total": s["TOTAL_PRODUCTS"], "complete": s["COMPLETE_PRODUCTS"], "blocked": s["SAFE_BLOCKED"], "unclassified": s["UNCLASSIFIED"], "coverage_percent": round(100 * s["COMPLETE_PRODUCTS"] / s["TOTAL_PRODUCTS"], 2)} + return out, products, {"total": s["TOTAL_PRODUCTS"], "complete": s["COMPLETE_PRODUCTS"], "blocked": s["SAFE_BLOCKED"], "unclassified": s["UNCLASSIFIED"], "coverage_percent": round(100 * s["COMPLETE_PRODUCTS"] / s["TOTAL_PRODUCTS"], 2)} def main(): load_dotenv(ROOT / ".env") # credentials only when the script runs, never as an import side effect guard(COPY) service = load_service() + restore_copy() env = {"FIREBIRD_DATABASE_PATH": COPY, "FB_USER": os.environ["FB_USER"], "FB_PASSWORD": os.environ["FB_PASSWORD"], "FIREBIRD_CLIENT_LIBRARY": os.environ.get("FIREBIRD_CLIENT_LIBRARY")} - report = {"database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (gbak restore of TESTESOFICIAIS)", "real_database_writes": 0, "real_database_write_attempts": 0} - # ---- 1. company -> regime normal on the COPY (documented experiment) + report = {"database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its .fbk at start)", "real_database_writes": 0, "real_database_write_attempts": 0, "mechanism": "job authorizations only (no env switches)"} rw = connect(True); cur = rw.cursor() cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004"); report["copy_original_crt"] = [r[0] for r in cur.fetchall()] cur.execute("UPDATE C000004 SET CRT = '3'"); rw.commit() cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004"); company = {"crt": cur.fetchone()[0]} - report["copy_crt_for_experiment"] = company["crt"] - _, before_c25 = snapshot_table(cur, "C000025", "CODIGO") cur.execute("SELECT USAR_REFORMA_TRIBUTARIA FROM PARAMETROS"); report["copy_rtc_param"] = cur.fetchone()[0] + cur.execute("SELECT CODIGO, CLASSIFICACAO_FISCAL FROM C000025"); ncm_valid = {str(c).strip(): digits(n) in NCM_OK for c, n in cur.fetchall()} rw.commit(); rw.close() - ncm_valid = {str(c).strip(): digits(v["CLASSIFICACAO_FISCAL"]) in NCM_OK for c, v in before_c25.items()} - # ---- 2. assess (as found on the copy) - out, cov_before = coverage(env, company, ncm_valid) + report["copy_crt_for_experiment"] = company["crt"] + base, base_values = baseline() + report["tables_hashed"] = len(base) + config = SimpleNamespace(rtc_auto_apply_max_per_job=500, database_path=COPY) # NOTE: no firebird_writes, no rtc switch: the Agent has none + + def run_job(mode, approved=(), rollbacks=(), job_id=1): + out, products, _ = coverage(env, company, ncm_valid) + job = {"job_id": job_id, "authorizations": {"rtc_write_mode": mode, "rtc_approved_codes": list(approved), "rtc_rollbacks": list(rollbacks)}} + return service._rtc_auto_apply(env, config, job, products, out, company), out + + _, _, cov_before = coverage(env, company, ncm_valid) report["coverage_before"] = cov_before - report["resolution_before"] = {k: v for k, v in out["context"]["rtc_resolution"].items() if k != "by_reason"} - products = firebird_reader.read_products(env) - config = SimpleNamespace(firebird_writes=True, rtc_auto_apply_enabled=True, rtc_auto_apply_max_per_job=500, database_path=COPY) - job = {"job_id": 424242, "authorizations": {"rtc_auto_apply": True}} - # ---- 7 (first, on purpose): stale decision - RTC switched off on the copy between decision and write => nothing is written - rw = connect(True); c = rw.cursor(); c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 0"); rw.commit(); rw.close() - stale = service._rtc_auto_apply(env, config, job, products, out, company) - report["stale_decision_run"] = {"write_attempts": stale["write_attempts"], "write_successes": stale["write_successes"], "aborted_reasons": stale["aborted_reasons"]} - rw = connect(True); c = rw.cursor(); c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 1"); rw.commit(); rw.close() - # ---- 3. apply - first = service._rtc_auto_apply(env, config, job, products, out, company) - report["first_run"] = {"mode": first["mode"], "ready_total": first["ready_total"], "write_attempts": first["write_attempts"], "write_successes": first["write_successes"], "aborted_reasons": first["aborted_reasons"], - "audit_sample": first["applied"][:3]} - # ---- 4/5. re-read, FK, whole-table diff + # ---- 2. DISABLED / DRY_RUN / stale decision: zero writes + disabled, _ = run_job("DISABLED") + dry, out0 = run_job("DRY_RUN") + report["disabled_run"] = {"write_attempts": disabled["write_attempts"], "preview": len(disabled["preview"]), "ready_total": disabled["ready_total"]} + report["dry_run"] = {"write_attempts": dry["write_attempts"], "preview": len(dry["preview"]), "ready_total": dry["ready_total"], "rules": sorted({c["rule_id"] for c in dry["preview"]}), "sample": dry["preview"][:3]} + report["resolution_before"] = {k: v for k, v in out0["context"]["rtc_resolution"].items() if k != "by_reason"} + assert base == baseline()[0], "DISABLED/DRY_RUN wrote something" + rw = connect(True); c = rw.cursor(); c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 0"); rw.commit() + stale, _ = run_job("ENABLED") + c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 1"); rw.commit(); rw.close() + report["stale_decision_run"] = {"write_attempts": stale["write_attempts"], "write_successes": stale["write_successes"]} + # ---- 3. APPROVAL_REQUIRED (5 approved) then ENABLED + preview_codes = [c["product_code"] for c in dry["preview"]] + approved = preview_codes[:5] + unapproved = run_job("APPROVAL_REQUIRED", approved=[])[0] + part, _ = run_job("APPROVAL_REQUIRED", approved=approved, job_id=2) + report["approval_run"] = {"without_approval_attempts": unapproved["write_attempts"], "approved": len(approved), "write_successes": part["write_successes"], "only_approved": sorted(a["product_code"] for a in part["applied"]) == sorted(approved)} + applied = list(part["applied"]) + jobs = 0 + while True: + jobs += 1 + step, _ = run_job("ENABLED", job_id=100 + jobs) + applied += step["applied"] + report.setdefault("enabled_jobs", []).append({"write_attempts": step["write_attempts"], "write_successes": step["write_successes"], "aborted": step["aborted_reasons"]}) + if not step["write_attempts"] or jobs >= 200: + break + report["total_applied"] = len(applied) + report["audit_sample"] = applied[:2] + report["audit_fields_complete"] = all(a.get(k) is not None for a in applied for k in ("rule_id", "rule_version", "company_crt", "authorization_origin", "description_snapshot", "timestamp", "job_id", "local_catalog_hash", "official_source_version")) + # ---- 4. reread + integrity + coverage + mid, mid_values = baseline() + report["rows_changed"] = sum(1 for k, v in mid_values.items() if base_values.get(k) != v) ro = connect(False); cur = ro.cursor() - cols, after_c25 = snapshot_table(cur, "C000025", "CODIGO") - changed = {code: [c for c in cols if before_c25[code][c] != row[c]] for code, row in after_c25.items() if any(before_c25[code][c] != row[c] for c in cols)} - report["rows_changed"] = len(changed) - report["columns_changed"] = sorted({c for v in changed.values() for c in v}) cur.execute("SELECT COUNT(*) FROM C000025 p WHERE p.CCLASSTRIB IS NOT NULL AND NOT EXISTS (SELECT 1 FROM RTC_CCLASSTRIB c WHERE c.CCLASSTRIB = p.CCLASSTRIB)"); report["fk_orphans"] = cur.fetchone()[0] cur.execute("SELECT COUNT(*) FROM C000025 p JOIN RTC_CCLASSTRIB c ON c.CCLASSTRIB = p.CCLASSTRIB WHERE NOT EXISTS (SELECT 1 FROM RTC_CST s WHERE s.CSTIBSCBS = c.CSTIBSCBS)"); report["cst_orphans"] = cur.fetchone()[0] - cur.execute("SELECT CCLASSTRIB, COUNT(*) FROM C000025 WHERE CCLASSTRIB IS NOT NULL GROUP BY 1"); report["written_by_class"] = {r[0].strip(): r[1] for r in cur.fetchall()} - cur.execute("SELECT COUNT(*) FROM RTC_CCLASSTRIB"); report["catalog_rows_unchanged"] = cur.fetchone()[0] ro.close() - out2, cov_after = coverage(env, company, ncm_valid) + _, _, cov_after = coverage(env, company, ncm_valid) report["coverage_after"] = cov_after - report["resolution_after"] = {k: v for k, v in out2["context"]["rtc_resolution"].items() if k != "by_reason"} - # ---- 6. second run - products2 = firebird_reader.read_products(env) - second = service._rtc_auto_apply(env, config, job, products2, out2, company) - report["second_run"] = {"ready_total": second["ready_total"], "write_attempts": second["write_attempts"], "write_successes": second["write_successes"]} - report["IDEMPOTENT"] = second["write_attempts"] == 0 and second["write_successes"] == 0 - report["ONLY_CCLASSTRIB_CHANGED"] = report["columns_changed"] in ([], ["CCLASSTRIB"]) - report["PASS"] = bool(report["IDEMPOTENT"] and report["ONLY_CCLASSTRIB_CHANGED"] and report["fk_orphans"] == 0 and report["cst_orphans"] == 0 and first["write_successes"] == first["write_attempts"] > 0 - and report["stale_decision_run"]["write_successes"] == 0 and report["rows_changed"] == first["write_successes"]) + # ---- 5. second run + second, _ = run_job("ENABLED", job_id=300) + report["second_run"] = {"write_attempts": second["write_attempts"], "write_successes": second["write_successes"], "ready_total": second["ready_total"]} + # ---- 6. rollback of 3 records + 1 conflict + print(json.dumps({k: report[k] for k in ("disabled_run","dry_run","stale_decision_run","approval_run","enabled_jobs","coverage_before","resolution_before")}, default=str)[:2500]) if len(applied) < 4 else None + victims = applied[:4] + rw = connect(True); c = rw.cursor(); c.execute("UPDATE C000025 SET CCLASSTRIB = '200035' WHERE CODIGO = ?", (victims[3]["product_code"],)); rw.commit(); rw.close() # an operator changed it afterwards + requests = [{"audit_id": 900 + i, "product_code": v["product_code"], "written_value": v["new_value"], "rule_id": v["rule_id"]} for i, v in enumerate(victims)] + roll, _ = run_job("DISABLED", rollbacks=requests, job_id=400) # rollback needs the central request, not a write mode + outcomes = {r["product_code"]: (r["outcome"], r.get("reason")) for r in roll["rollbacks"]} + report["rollback"] = {"rolled_back": sum(1 for o, _ in outcomes.values() if o == "ROLLED_BACK"), "conflict": outcomes[victims[3]["product_code"]], "records": [outcomes[v["product_code"]][0] for v in victims]} + ro = connect(False); cur = ro.cursor() + cur.execute("SELECT CODIGO, CCLASSTRIB FROM C000025 WHERE CODIGO IN (%s)" % ",".join("?" * 4), tuple(v["product_code"] for v in victims)) + after_roll = {str(r[0]).strip(): (r[1] or "").strip() for r in cur.fetchall()} + ro.close() + report["rollback"]["reread"] = {k: v for k, v in after_roll.items()} + # ---- 7. reapply + idempotency + again, _ = run_job("ENABLED", job_id=500) + report["reapply"] = {"write_successes": again["write_successes"], "codes": sorted(a["product_code"] for a in again["applied"]), "expected": sorted(v["product_code"] for v in victims[:3])} + third, _ = run_job("ENABLED", job_id=501) + report["idempotency_after_reapply"] = {"write_attempts": third["write_attempts"], "write_successes": third["write_successes"]} + # ---- 8. invariants + final, final_values = baseline() + changed_tables = diff_tables(base, final) + report["NON_TARGET_COLUMNS_CHANGED"] = 1 if "C000025" in changed_tables else 0 + report["CATALOG_CHANGED"] = len([t for t in changed_tables if t.startswith("RTC_")]) + report["PARAMETERS_CHANGED"] = len([t for t in changed_tables if t == "PARAMETROS"]) + report["DOCUMENT_TABLES_CHANGED"] = len([t for t in changed_tables if any(h in t for h in DOC_TABLE_HINTS)]) + report["OTHER_TABLES_CHANGED"] = len(changed_tables) + report["changed_tables"] = changed_tables + report["IDEMPOTENT"] = second["write_attempts"] == 0 and third["write_attempts"] == 0 + report["PASS"] = bool(report["IDEMPOTENT"] and report["fk_orphans"] == 0 and report["cst_orphans"] == 0 and not changed_tables and report["total_applied"] > 5 + and report["disabled_run"]["write_attempts"] == 0 and report["dry_run"]["write_attempts"] == 0 and report["dry_run"]["rules"] == ["RTC-R3"] + and report["stale_decision_run"]["write_successes"] == 0 and report["approval_run"]["without_approval_attempts"] == 0 and report["approval_run"]["only_approved"] + and report["rows_changed"] == report["total_applied"] and report["rollback"]["rolled_back"] == 3 and report["rollback"]["conflict"][0] == "ABORTED" + and report["rollback"]["reread"][victims[3]["product_code"]] == "200035" and all(report["rollback"]["reread"][v["product_code"]] == "" for v in victims[:3]) + and report["reapply"]["codes"] == report["reapply"]["expected"] and report["audit_fields_complete"]) (ROOT / "docs" / "runs" / "rtc_copy_write_e2e.json").write_text(json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8") - print(json.dumps({k: report[k] for k in ("copy_original_crt", "copy_rtc_param", "coverage_before", "coverage_after", "first_run", "stale_decision_run", "rows_changed", "columns_changed", "fk_orphans", "cst_orphans", "second_run", "IDEMPOTENT", "ONLY_CCLASSTRIB_CHANGED", "PASS")}, - ensure_ascii=False, default=str)[:3000]) + print(json.dumps({k: report[k] for k in report if k not in ("audit_sample", "coverage_before", "resolution_before")}, ensure_ascii=False, default=str)[:3500]) if __name__ == "__main__": diff --git a/scripts/scale_hub_test.py b/scripts/scale_hub_test.py new file mode 100644 index 0000000..52191ac --- /dev/null +++ b/scripts/scale_hub_test.py @@ -0,0 +1,88 @@ +"""SCALE test of the Hub side for a ~21k-product installation (synthetic payloads shaped like the real MMMODAS run). + + python scripts/scale_hub_test.py [--products 21000] [--out docs/runs/scale_hub_test.json] + +Measures: job-result payload size, Hub persistence (blockers upsert + RTC preview + audits) with SQL statement counts (N+1 check), +and the panel pages that read the data (dashboard, agent detail, RTC control, blockers). In-process SQLite: timings are a conservative +upper bound for the PostgreSQL production database only for CPU-bound work; statement counts are engine-independent. +""" +import argparse +import logging +import json +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from sqlalchemy import event, select # noqa: E402 +from sqlalchemy.orm import Session # noqa: E402 + +from central.tests import test_zero_touch_hub as zt # noqa: E402 + +logging.disable(logging.CRITICAL) # the Hub logs each job result; at 21k products that is megabytes of noise + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--products", type=int, default=21000) + parser.add_argument("--out", default=str(ROOT / "docs" / "runs" / "scale_hub_test.json")) + args = parser.parse_args() + n = args.products + client, engine = zt._client() + counter = {"n": 0} + event.listen(engine, "before_cursor_execute", lambda *a, **k: counter.__setitem__("n", counter["n"] + 1)) + zt.hb(client) + with Session(engine) as s: + from central.app.models import Installation + s.scalars(select(Installation)).one().rtc_write_mode = "APPROVAL_REQUIRED" + s.commit() + blocked = [{"company": "", "product_code": f"{i:06d}", "gtin": None, "description": f"PRODUTO DE TESTE {i}", "blocker_type": "BLOCKED_FISCAL_DATA", "field": "RTC", "reason_code": "CCLASSTRIB_MISSING_UNRESOLVED", + "evidence_summary": {"reason": "RTC-R4:SUGGESTION_ONLY:000001", "human": "cClassTrib sem regra determinística"}, "input_fingerprint": f"fp{i:08d}"} for i in range(n)] + preview = [{"product_code": f"{i:06d}", "description": f"PRODUTO DE TESTE {i}", "ncm": "10063021", "current": "", "proposed": "200003", "cst": "200", "rule_id": "RTC-R3", "basis": "RTC-R3:ANNEX"} for i in range(5000)] + operational = {**zt.OPERATIONAL, "TOTAL_PRODUCTS": n, "COMPLETE": 0, "SAFE_BLOCKED": n, "BLOCKED_MISSING_EVIDENCE": n} + summary = {"operational": operational, "products_processed": n, "products_changed": n, "blocked_products": blocked, "resolved_products": [], + "fiscal_context": {"rtc_enabled": True, "evaluated_all": True, "rtc_resolution": {"total": n, "valid_existing": 0, "ready_to_apply": 5000, "ambiguous": 100, "catalog_incompatible": 0}}, + "rtc_auto_apply": {"mode": "APPROVAL_REQUIRED", "ready_total": 5000, "preview": preview, "preview_truncated": False, "applied": [], "aborted": [], "rollbacks": []}} + payload = json.dumps({"result_summary": summary}) + report = {"products": n, "result_payload_bytes": len(payload), "result_payload_mb": round(len(payload) / 1048576, 2)} + job = client.get("/v1/jobs/next", headers=zt.H).json() + counter["n"] = 0 + t0 = time.perf_counter() + response = client.post(f"/v1/jobs/{job['job_id']}/complete", content=payload, headers={**zt.H, "content-type": "application/json"}) + report["persist_first_pass"] = {"status": response.status_code, "seconds": round(time.perf_counter() - t0, 2), "sql_statements": counter["n"], "statements_per_product": round(counter["n"] / n, 2)} + # a second full evaluation with the same content (steady state: every 30 min per installation) + zt.age_last_sync(engine, 4000); zt.hb(client) + job = client.get("/v1/jobs/next", headers=zt.H).json() + counter["n"] = 0 + t0 = time.perf_counter() + response = client.post(f"/v1/jobs/{job['job_id']}/complete", content=payload, headers={**zt.H, "content-type": "application/json"}) + report["persist_steady_state"] = {"status": response.status_code, "seconds": round(time.perf_counter() - t0, 2), "sql_statements": counter["n"], "statements_per_product": round(counter["n"] / n, 2)} + with Session(engine) as s: + from central.app.models import BlockedProduct, RtcCandidate + report["rows"] = {"blocked": len(s.scalars(select(BlockedProduct.id)).all()), "rtc_candidates": len(s.scalars(select(RtcCandidate.id)).all())} + # panel pages (authenticated operator session) + from central.tests.test_control_panel_pages import _panel_client + pc, agent_id, _ = _panel_client() + import central.tests.test_control_panel_pages as cp + cp._seed_rtc(pc, agent_id, n=5000) + from central.app.blocked_products import upsert_blocker + from central.app.models import Installation as Inst + with pc._session_factory() as s: + inst = s.scalar(select(Inst).where(Inst.agent_id == agent_id)) + for b in blocked: + upsert_blocker(s, inst.tenant_id, b, installation_id=inst.id) + s.commit() + pages = {} + for name, url in (("dashboard", "/"), ("agent_detail", f"/agents/{agent_id}"), ("rtc_control_p1", f"/agents/{agent_id}/rtc"), ("rtc_control_p50", f"/agents/{agent_id}/rtc?page=50"), ("blockers", "/blockers")): + counter["n"] = 0 + t0 = time.perf_counter() + r = pc.get(url) + pages[name] = {"status": r.status_code, "seconds": round(time.perf_counter() - t0, 3), "html_kb": round(len(r.content) / 1024, 1)} + report["panel_pages"] = pages + Path(args.out).write_text(json.dumps(report, indent=1), encoding="utf-8") + print(json.dumps(report)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_release_candidate.py b/tests/test_release_candidate.py index 71c0963..99c5f3c 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.1.9" + assert v == GTI_FISCAL_VERSION == "1.2.0" 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.1.9"' in (ROOT / "agent" / "scripts" / "run_agent_service.py").read_text(encoding="utf-8") + assert '"AGENT_VERSION", "1.2.0"' 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"))