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:
+5
-6
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user