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:
gti
2026-09-24 22:46:05 -03:00
co-authored by Claude Sonnet 5
parent 3c10422e81
commit 42dc172763
50 changed files with 2948 additions and 915 deletions
+5 -6
View File
@@ -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")
+32 -6
View File
@@ -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:
+72 -31
View File
@@ -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())
+31 -1
View File
@@ -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())
+17 -17
View File
@@ -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")}
+82 -15
View File
@@ -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
+22 -10
View File
@@ -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
+6 -4
View File
@@ -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"]}
+1 -1
View File
@@ -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
+102 -21
View File
@@ -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")