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 <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.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")
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user