feat: deterministic cClassTrib resolver + gated CCLASSTRIB auto-apply (default OFF), RTC panel summary; 1.1.9
- resolver (rules RTC-R0..R4/A1..A3/G1..G2) over the official cClassTrib table 2026-06-22 and LC 214/2025 annexes; local vs official catalog comparison - ONE root-cause RTC blocker per product (missing/unresolved/ambiguous/conflict/official mismatch/catalog outdated/evidence missing) - auto-apply: C000025.CCLASSTRIB only, three switches (FIREBIRD_WRITES + RTC_AUTO_APPLY_ENABLED + Hub authorization), per-product re-read and re-decision, never overwrites, rowcount 1, audit table rtc_write_audits (migration 0027) - copy-only write E2E (280/280 applied, only CCLASSTRIB changed, 2nd run 0 writes, stale decision 0 writes); real databases untouched - docs: cclasstrib_resolution, rtc_auto_apply, unified_fiscal_completeness Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,7 @@ import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
AGENT_VERSION_DEFAULT = "1.1.8"
|
||||
AGENT_VERSION_DEFAULT = "1.1.9"
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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.
|
||||
"""
|
||||
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.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
|
||||
|
||||
|
||||
class RtcApplyAborted(Exception):
|
||||
"""The product is left untouched; `reason` is reported in the audit."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
super().__init__(reason)
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
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 select_candidates(findings: dict, products: list[dict], *, limit: int = DEFAULT_MAX_PER_JOB) -> tuple[list[dict], int]:
|
||||
"""Products READY_TO_APPLY: empty CCLASSTRIB + a deterministic resolution, no other RTC problem. Returns (candidates up to `limit`, total ready)."""
|
||||
by_code = {str(p.get("CODIGO") or ""): p for p in products}
|
||||
ready = []
|
||||
for code, found in findings.items():
|
||||
res = found.get("rtc_resolution") or {}
|
||||
if not res.get("auto_apply_candidate"):
|
||||
continue
|
||||
rtc_types = {b["type"] for b in found.get("blockers", []) if b["category"] == "RTC"}
|
||||
if rtc_types != {"CCLASSTRIB_MISSING"}:
|
||||
continue
|
||||
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"]})
|
||||
return ready[:limit], len(ready)
|
||||
|
||||
|
||||
def apply_candidate(conn, candidate: dict, *, company: dict, decision: dict, now: str, job_id=None, today: date | None = None) -> dict:
|
||||
"""One product, one short transaction. `decision` = {"local_catalog_hash": ...} captured when the candidate was chosen. Raises RtcApplyAborted (nothing written)."""
|
||||
field = "CCLASSTRIB"
|
||||
assert field in ALLOWED_RTC_WRITE_FIELDS
|
||||
today = today or date.today()
|
||||
code, proposed = candidate["product_code"], candidate["proposed"]
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
schema, catalog, extras = load_rtc_context(cur) # fresh: the ERP is asked again, nothing cached
|
||||
caps = detect_rtc_capabilities(schema, param_value=extras["param_value"], catalog=catalog, physical_fk=extras["physical_fk"])
|
||||
if extras["param_conflict"] or not rtc_enabled(caps):
|
||||
raise RtcApplyAborted("RTC_NO_LONGER_ENABLED")
|
||||
if not caps["RTC_CATALOG_VALID"]:
|
||||
raise RtcApplyAborted("RTC_CATALOG_INVALID")
|
||||
local_rows = (catalog or {}).get("rows") or {}
|
||||
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,))
|
||||
rows = cur.fetchall()
|
||||
if len(rows) != 1:
|
||||
raise RtcApplyAborted("PRODUCT_ROW_NOT_UNIQUE" if rows else "PRODUCT_NOT_FOUND")
|
||||
current, ncm = _text(rows[0][0]), _digits(rows[0][1])
|
||||
if current:
|
||||
raise RtcApplyAborted("VALUE_ALREADY_SET_NEVER_OVERWRITTEN" if current != proposed else "NO_WRITE_REQUIRED")
|
||||
if ncm != candidate["ncm"]:
|
||||
raise RtcApplyAborted("PRODUCT_CHANGED_NCM")
|
||||
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"]:
|
||||
raise RtcApplyAborted("DECISION_CHANGED")
|
||||
cur.execute(f"UPDATE C000025 SET {field} = ? WHERE CODIGO = ? AND ({field} IS NULL OR TRIM({field}) = '')", (proposed, code))
|
||||
if cur.rowcount != 1:
|
||||
conn.rollback()
|
||||
raise RtcApplyAborted("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 != proposed:
|
||||
raise RuntimeError(f"post-write verification failed for {code}: {after!r} != {proposed!r}")
|
||||
official = compat["official_catalog"]
|
||||
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}
|
||||
except RtcApplyAborted:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
raise
|
||||
except Exception:
|
||||
try:
|
||||
conn.rollback()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def apply_candidates(conn, candidates: list[dict], *, company: dict, decision: dict, now: str, job_id=None, today: date | None = None) -> dict:
|
||||
"""Applies each candidate independently; a failure or an abort never stops the rest and never leaves a partial change."""
|
||||
applied, aborted, reasons = [], [], {}
|
||||
for candidate in candidates:
|
||||
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})
|
||||
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)}
|
||||
@@ -11,12 +11,14 @@ import json
|
||||
from collections import Counter
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from central.app.cclasstrib_resolver import compare_catalogs, local_catalog_hash, resolve_cclasstrib
|
||||
from central.app.rtc_findings import refine_rtc_finding, summarize_rtc
|
||||
from central.app.unified_fiscal_coverage import PIS_COFINS_FIELDS, catalog_integrity, evaluate_product, legal_effective_context, rtc_enabled
|
||||
|
||||
PRODUCT_TABLE = "C000025"
|
||||
RTC_CCLASSTRIB_TABLE = "RTC_CCLASSTRIB"
|
||||
RTC_CST_TABLE = "RTC_CST"
|
||||
PRODUCT_COLUMNS = ("CODIGO", "CCLASSTRIB") + PIS_COFINS_FIELDS + ("PISCOFINS", "INCIDENCIA_PISCOFINS")
|
||||
PRODUCT_COLUMNS = ("CODIGO", "CCLASSTRIB", "CLASSIFICACAO_FISCAL") + PIS_COFINS_FIELDS + ("PISCOFINS", "INCIDENCIA_PISCOFINS")
|
||||
TRUTHY = {"1", "S", "T", "TRUE"}
|
||||
|
||||
|
||||
@@ -67,13 +69,23 @@ def load_rtc_context(cursor) -> tuple[dict, dict | None, dict]:
|
||||
class_cols = schema.get(RTC_CCLASSTRIB_TABLE, set())
|
||||
if {"CCLASSTRIB", "CSTIBSCBS"} <= class_cols and "CSTIBSCBS" in schema.get(RTC_CST_TABLE, set()):
|
||||
validity = "INICIO_VIGENCIA" in class_cols and "FIM_VIGENCIA" in class_cols
|
||||
cursor.execute("SELECT TRIM(CCLASSTRIB), TRIM(CSTIBSCBS)" + (", INICIO_VIGENCIA, FIM_VIGENCIA" if validity else "") + " FROM RTC_CCLASSTRIB")
|
||||
extra = [c for c in ("TIPO_ALIQUOTA", "PRED_IBS", "PRED_CBS", "IND_NFE", "IND_NFCE") if c in class_cols]
|
||||
cursor.execute("SELECT TRIM(CCLASSTRIB), TRIM(CSTIBSCBS)" + (", INICIO_VIGENCIA, FIM_VIGENCIA" if validity else "") + "".join(f", {c}" for c in extra) + " FROM RTC_CCLASSTRIB")
|
||||
rows = cursor.fetchall()
|
||||
classes = {r[0]: (r[1] or None) for r in rows}
|
||||
cursor.execute("SELECT TRIM(CSTIBSCBS) FROM RTC_CST")
|
||||
catalog = {"classes": classes, "cst": {r[0] for r in cursor.fetchall()}, "duplicates": len(rows) - len(classes)}
|
||||
if validity:
|
||||
catalog["validity"] = {r[0]: (r[2], r[3]) for r in rows}
|
||||
offset = 4 if validity else 2
|
||||
catalog["rows"] = {r[0]: {"cst": r[1], "start": (r[2] if validity else None), "end": (r[3] if validity else None), **{c: r[offset + i] for i, c in enumerate(extra)}} for r in rows}
|
||||
for value in catalog["rows"].values():
|
||||
for key in ("TIPO_ALIQUOTA", "PRED_IBS", "PRED_CBS", "IND_NFE", "IND_NFCE"):
|
||||
if key in value:
|
||||
value[key.lower()] = value.pop(key)
|
||||
for flag in ("ind_nfe", "ind_nfce"):
|
||||
if flag in value:
|
||||
value[flag] = str(value[flag]).strip() in ("1", "True", "true")
|
||||
return schema, catalog, extras
|
||||
|
||||
|
||||
@@ -87,9 +99,10 @@ def read_products(cursor, schema: dict) -> list[dict]:
|
||||
return [dict(zip(columns, row)) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def assess_from_connection(conn, company: dict | None, *, today: date | None = None, rtc_override: bool | None = None) -> dict:
|
||||
def assess_from_connection(conn, company: dict | None, *, today: date | None = None, rtc_override: bool | None = None, ncm_valid: dict | None = None) -> dict:
|
||||
"""One read-only pass. Returns {"context": {...}, "findings": {product_code: evaluate_product(...)}}.
|
||||
rtc_override forces RTC on/off for simulations WITHOUT touching the ERP parameter (tests / what-if only)."""
|
||||
rtc_override forces RTC on/off for simulations WITHOUT touching the ERP parameter (tests / what-if only).
|
||||
ncm_valid: {product_code: bool} from the decision engine (EXACT_NCM); a product whose NCM is not confirmed never gets a cClassTrib proposal."""
|
||||
cursor = conn.cursor()
|
||||
schema, catalog, extras = load_rtc_context(cursor)
|
||||
caps = detect_rtc_capabilities(schema, param_value=extras["param_value"], catalog=catalog, physical_fk=extras["physical_fk"])
|
||||
@@ -102,8 +115,15 @@ def assess_from_connection(conn, company: dict | None, *, today: date | None = N
|
||||
warn_products: set = set()
|
||||
samples: list = []
|
||||
applicability: Counter = Counter()
|
||||
local_rows = (catalog or {}).get("rows") or {}
|
||||
compat = compare_catalogs(local_rows, today) if (enabled and local_rows) else None
|
||||
resolver_ctx = {"rtc_enabled": enabled, "local_catalog_valid": bool(caps["RTC_CATALOG_VALID"])}
|
||||
for p in products:
|
||||
found = evaluate_product(p, company, rtc, today)
|
||||
if enabled:
|
||||
code0 = str(p.get("CODIGO") or "")
|
||||
resolution = resolve_cclasstrib({**p, "ncm_valid": bool((ncm_valid or {}).get(code0, False))}, company, resolver_ctx, local_rows, compat=compat, today=today)
|
||||
refine_rtc_finding(found, p, resolution, compat["per_code"] if compat else None)
|
||||
found["input_fingerprint"] = hashlib.sha256(json.dumps({k: (None if v is None else str(v).strip()) for k, v in p.items()} | {"rtc": enabled}, sort_keys=True).encode()).hexdigest()
|
||||
code = str(p.get("CODIGO") or "")
|
||||
findings[code] = found
|
||||
@@ -119,5 +139,7 @@ def assess_from_connection(conn, company: dict | None, *, today: date | None = N
|
||||
"rtc_observed_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), "legal_effective_context": legal_effective_context((company or {}).get("crt")),
|
||||
"catalog_integrity": integrity, "crt": (company or {}).get("crt"), "capabilities": caps, "products_evaluated": len(products),
|
||||
"pis_cofins_applicability": dict(applicability),
|
||||
"rtc_resolution": summarize_rtc(findings, compat) if enabled else None, "local_catalog_hash": local_catalog_hash(local_rows) if local_rows else None,
|
||||
"official_catalog": (compat or {}).get("official_catalog"),
|
||||
"non_canonical_warnings": {"products": len(warn_products), "by_type": dict(sorted(warn_by_type.items())), "samples": samples}},
|
||||
"findings": findings}
|
||||
|
||||
Reference in New Issue
Block a user