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
@@ -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")
+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")
+1 -1
View File
@@ -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",
+1 -2
View File
@@ -9,8 +9,7 @@
<a class="botao secondary" href="/agents/{{ detail.agent_id }}/reprocess-confirmation" title="Recalcula todos os produtos (pede confirmação)">Reprocessar tudo</a>
{% if ops.enabled %}<form method="post" action="/agents/{{ detail.agent_id }}/pause" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="caution" title="Mantém o heartbeat, mas não executa jobs">Pausar processamento</button></form>{% else %}<form method="post" action="/agents/{{ detail.agent_id }}/resume" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" title="Volta a executar jobs">Retomar processamento</button></form>{% endif %}
</div></div><dl class="facts"><dt>Processamento</dt><dd><span class="badge {{ '' if ops.enabled else 'critical' }}">{{ "Ativo" if ops.enabled else "Pausado" }}</span> · sincronização automática a cada {{ ops.interval_minutes }} min</dd><dt>Última sincronização</dt><dd>{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}</dd><dt>Sincronização agendada</dt><dd>{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}</dd>{% if ops.rtc %}<dt>Reforma Tributária</dt><dd><span class="badge {{ "" if ops.rtc.enabled else "muted" }}" title="{{ ops.rtc.title }}">{{ ops.rtc.label }}</span></dd>{% endif %}{% if ops.rtc and ops.rtc.enabled %}{% if ops.rtc.products %}<dt>Produtos RTC</dt><dd><b>{{ ops.rtc.products.headline }}</b><br><small>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 }}</small></dd>{% endif %}
<dt>Preenchimento automático</dt><dd>{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %}
<form method="post" action="/agents/{{ detail.agent_id }}/rtc-auto-apply" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">{% if ops.rtc_authorized %}<input type="hidden" name="decision" value="revoke"><button type="submit" class="caution">Revogar autorização</button>{% else %}<input type="hidden" name="decision" value="authorize"><input type="text" name="confirm" placeholder="digite PREENCHER" aria-label="Confirmar autorização" size="14"><button type="submit" class="secondary" title="Autoriza o Hub; o Agent ainda precisa ter a escrita e a chave RTC_AUTO_APPLY_ENABLED ligadas">Autorizar</button>{% endif %}</form></dd>{% endif %}<dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&amp;job_id={{ ops.last_job.id }}">{{ ops.last_job.kind_label }}</a> · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}</dd></dl></section>
<dt>Preenchimento automático</dt><dd>{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} · <a href="/agents/{{ detail.agent_id }}/rtc">Controle e candidatos cClassTrib</a></dd>{% endif %}<dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&amp;job_id={{ ops.last_job.id }}">{{ ops.last_job.kind_label }}</a> · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}</dd></dl></section>
<nav class="tabs" aria-label="Seções da instalação">{% for name,label in [("overview","Resumo"),("fiscal","Fiscal"),("blockers","Blockers"),("jobs","Jobs"),("events","Eventos")] %}<a class="{% if tab==name %}active{% endif %}" href="?tab={{ name }}">{{ label }}</a>{% endfor %}</nav>
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2></div><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "Não informado" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "Não informado" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "Não informado" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "Não informado" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "Não informado" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "Não informado" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version_display }}</dd><dt>Último heartbeat</dt><dd title="{{ detail.last_seen_at }}">{{ detail.last_seen_display }} · {{ detail.last_seen_age }}</dd><dt>Firebird</dt><dd>{{ detail.firebird_version or "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}</dd><dt>IDs técnicos</dt><dd>Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}</dd></dl></article>
<article class="panel"><div class="panel-title"><h2>Eventos recentes</h2><a href="#eventos">Ver eventos</a></div>{% for event in events[:8] %}<div class="event-row"><span class="event-time">{{ event.occurred_display }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.job_id %} · <a href="/jobs?job_id={{ event.job_id }}">Job {{ event.job_id }}</a>{% endif %}{% if event.product_code %} · <a href="/blockers?agent_id={{ detail.agent_id }}&amp;product_code={{ event.product_code|urlencode }}">Produto {{ event.product_code }}</a>{% endif %}</small></span><em class="severity {{ event.severity|lower }}">Severidade: {{ event.severity }}</em></div>{% else %}<p class="empty-state">Nenhum evento.</p>{% endfor %}</article></section>
+41
View File
@@ -0,0 +1,41 @@
{% extends "base.html" %}
{% block title %}cClassTrib | GTI Fiscal{% endblock %}
{% block content %}
<div class="detail-head"><div><p class="eyebrow"><a href="/agents/{{ agent_id }}">← Instalação</a> / REFORMA TRIBUTÁRIA</p><h1>Preenchimento de cClassTrib</h1>
<p class="muted">{{ explanation }}</p></div></div>
{% set rtc = ops.rtc %}
{% if rtc and rtc.products %}
<section class="cards" aria-label="Resumo cClassTrib">
<article class="metric"><span>Produtos</span><strong>{{ rtc.products.total }}</strong></article>
<article class="metric"><span>cClassTrib válido</span><strong>{{ rtc.products.valid }}</strong></article>
<article class="metric"><span>Auto-resolvíveis</span><strong>{{ rtc.products.ready }}</strong></article>
<article class="metric"><span>Bloqueados</span><strong>{{ rtc.products.blocked }}</strong></article>
<article class="metric"><span>Conflitos</span><strong>{{ rtc.products.conflicts }}</strong></article>
<article class="metric"><span>Catálogo incompatível</span><strong>{{ rtc.products.catalog_incompatible }}</strong></article>
</section>
{% endif %}
<section class="panel"><div class="panel-title"><div><h2>Modo de preenchimento</h2><p class="muted">Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.</p></div></div>
<form method="post" action="/agents/{{ agent_id }}/rtc-mode"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for key, label in modes.items() %}<label style="display:block"><input type="radio" name="mode" value="{{ key }}" {% if key == mode %}checked{% endif %}> {{ label }}
{% if key == "DISABLED" %}— apenas informa{% elif key == "DRY_RUN" %}— mostra os candidatos, não escreve{% elif key == "APPROVAL_REQUIRED" %}— escreve só o que for aprovado abaixo{% else %}— preenche os candidatos determinísticos{% endif %}</label>{% endfor %}
<p><input type="text" name="confirm" placeholder="digite PREENCHER (modo automático)" aria-label="Confirmar modo automático" size="30"> <button type="submit" class="primary-action">Salvar modo</button></p></form></section>
<section class="panel"><div class="panel-title"><div><h2>Candidatos</h2><p class="muted">{{ total }} produto(s) neste filtro.</p></div></div>
<nav class="tabs" aria-label="Filtro de candidatos">{% for st, label in [("PENDING","Pendentes"),("APPROVED","Aprovados"),("REJECTED","Rejeitados"),("APPLIED","Aplicados"),("STALE","Obsoletos")] %}<a class="{% if status==st %}active{% endif %}" href="?status={{ st }}">{{ label }} ({{ counts.get(st, 0) }})</a>{% endfor %}</nav>
<form method="post" action="/agents/{{ agent_id }}/rtc-candidates"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<table><thead><tr><th></th><th>Produto</th><th>NCM</th><th>Atual</th><th>Proposto</th><th>CST IBS/CBS</th><th>Regra</th><th>Base</th><th>Status</th></tr></thead><tbody>
{% for c in rows %}<tr><td><input type="checkbox" name="ids" value="{{ c.id }}" aria-label="Selecionar {{ c.product_code }}"></td><td>{{ c.product_code }} · {{ c.description or "" }}</td><td>{{ c.ncm }}</td><td>{{ c.current_value or "vazio" }}</td><td>{{ c.proposed }}</td><td>{{ c.cst_ibscbs }}</td><td>{{ c.rule_id }}</td><td>{{ c.basis }}</td><td>{{ c.status }}</td></tr>
{% else %}<tr><td colspan="9" class="muted">Nenhum candidato neste filtro.</td></tr>{% endfor %}</tbody></table>
<p>Página {{ page }} de {{ pages }}{% if page > 1 %} · <a href="?status={{ status }}&amp;page={{ page - 1 }}">anterior</a>{% endif %}{% if page < pages %} · <a href="?status={{ status }}&amp;page={{ page + 1 }}">próxima</a>{% endif %}</p>
<p><button type="submit" name="action" value="approve" class="secondary">Aprovar selecionados</button> <button type="submit" name="action" value="reject" class="caution">Rejeitar selecionados</button>
<button type="submit" name="action" value="approve" class="secondary" formaction="/agents/{{ agent_id }}/rtc-candidates?all=1">Aprovar todos os pendentes</button>
{% if mode == "APPROVAL_REQUIRED" %}<button type="submit" name="action" value="apply_approved" class="primary-action">Aplicar aprovados agora</button>{% endif %}</p></form></section>
<section class="panel"><div class="panel-title"><div><h2>Auditoria e reversão</h2><p class="muted">Cada preenchimento pode ser revertido individualmente, desde que o valor no ERP ainda seja o gravado.</p></div></div>
<table><thead><tr><th>#</th><th>Produto</th><th>Ação</th><th>Resultado</th><th>Antes → Depois</th><th>Regra</th><th>Origem</th><th>Reversão</th></tr></thead><tbody>
{% for a in audits %}<tr><td>{{ a.id }}</td><td>{{ a.product_code }} · {{ a.description_snapshot or "" }}</td><td>{{ a.action or "APPLY" }}</td><td>{{ a.outcome }}{% if a.reason %} ({{ a.reason }}){% endif %}</td><td>{{ a.old_value or "vazio" }} → {{ a.new_value or "vazio" }}</td><td>{{ a.rule_id }}</td><td>{{ a.authorization_origin }}</td>
<td>{% if a.action == "APPLY" and a.outcome == "APPLIED" and not a.rollback_state %}<form method="post" action="/agents/{{ agent_id }}/rtc-rollback" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="audit_id" value="{{ a.id }}"><input type="text" name="confirm" placeholder="digite REVERTER" size="12" aria-label="Confirmar reversão"><button type="submit" class="caution">Reverter</button></form>{% else %}{{ a.rollback_state or "" }}{% endif %}</td></tr>
{% else %}<tr><td colspan="8" class="muted">Nenhum preenchimento registrado.</td></tr>{% endfor %}</tbody></table></section>
{% endblock %}
+34 -10
View File
@@ -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():
+76 -16
View File
@@ -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
+1 -1
View File
@@ -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):
+87
View File
@@ -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)
@@ -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()
+61 -3
View File
@@ -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)]