feat: unified fiscal coverage - PIS/COFINS applicability + conditional RTC in one definition, one queue, one panel
- central/app/unified_fiscal_coverage.py: pure rules (PIS/COFINS NOT_APPLICABLE/REQUIRED/OPTIONAL/NEEDS_REVIEW, RTC only when the ERP switch is on) - agent: read-only loader (unified_fiscal.py) replaces the legacy/expanded module; findings feed the single operational state, coverage and blocker queue - hub: BLOCKED_FISCAL_DATA blockers (PIS/COFINS/RTC) share the queue; stale ones close on the next full pass; dashboard breakdown gains CEST/PIS/COFINS/RTC - panel: legacy/expanded coverage block removed; discreet 'Reforma Tributaria: Ativa/Desativada' - docs: unified_fiscal_completeness, pis_cofins_applicability, rtc_pis_cofins_model + official source manifests; read-only E2E script Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -11,7 +11,7 @@ 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 .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, upsert_blocker
|
||||
from .blocked_products import list_blockers, resolve_blocker, resolve_product_blockers, resolve_stale_fiscal_blockers, upsert_blocker
|
||||
from .context_keys import validate_entry
|
||||
from .models import AgentSnapshot, BlockedProduct, FiscalContextEntry, ClassificationCache, ClassificationRequest, ClassificationResult, Dataset, Installation, Job
|
||||
from .events import record_event
|
||||
@@ -422,10 +422,13 @@ def router(session_factory, bootstrap_token):
|
||||
if job is None or job.agent_id != agent.id: raise HTTPException(404, "job not found")
|
||||
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)
|
||||
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 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")
|
||||
if installation is not None:
|
||||
_apply_job_outcome(session, agent, installation, job, result_summary, failed=False)
|
||||
|
||||
@@ -18,7 +18,7 @@ def upsert_blocker(session, tenant_id: int, payload: dict, *, installation_id: i
|
||||
row = 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)
|
||||
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)
|
||||
else:
|
||||
row.last_seen_at = now
|
||||
@@ -73,3 +73,20 @@ def resolve_product_blockers(session, tenant_id: int, product_code: str, source:
|
||||
if rows:
|
||||
session.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
FISCAL_BLOCKER_FIELDS = ("PIS", "COFINS", "RTC")
|
||||
|
||||
|
||||
def resolve_stale_fiscal_blockers(session, tenant_id: int, installation_id: int, since: datetime) -> int:
|
||||
"""After a FULL evaluation of the unified fiscal fields, every open PIS/COFINS/RTC blocker of the installation that this pass did not
|
||||
re-report (last_seen_at < since) no longer holds: the ERP data changed, or RTC was switched off. Other blocker fields are untouched."""
|
||||
rows = session.scalars(select(BlockedProduct).where(BlockedProduct.tenant_id == tenant_id, BlockedProduct.installation_id == installation_id,
|
||||
BlockedProduct.resolved_at.is_(None), BlockedProduct.field.in_(FISCAL_BLOCKER_FIELDS), BlockedProduct.last_seen_at < since)).all()
|
||||
now = datetime.now(timezone.utc)
|
||||
for row in rows:
|
||||
row.resolved_at = now
|
||||
row.resolution_source = "AUTO_REEVALUATION"
|
||||
if rows:
|
||||
session.commit()
|
||||
return len(rows)
|
||||
|
||||
@@ -16,7 +16,7 @@ from .panel_presentation import health_reason_label, blocker_reason_label, block
|
||||
|
||||
|
||||
HEALTH_STATES = ("ONLINE", "DEGRADED", "OFFLINE", "FAILED", "UNKNOWN")
|
||||
BLOCKER_FIELDS = ("CFOP", "ST", "ICMS", "CSOSN", "NCM", "OTHER")
|
||||
BLOCKER_FIELDS = ("NCM", "CEST", "CFOP", "ST", "ICMS", "CSOSN", "PIS", "COFINS", "RTC", "OTHER")
|
||||
ATTENTION_CANDIDATE_LIMIT = 10
|
||||
INACTIVE_ENROLLMENT_STATES = ("REVOKED", "FAILED", "EXPIRED")
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
|
||||
STATES = {
|
||||
"COMPLETE", "CORRECTED", "BLOCKED_MISSING_EVIDENCE", "BLOCKED_CONFLICT",
|
||||
"BLOCKED_COMPANY_CONTEXT", "BLOCKED_SCHEMA", "FAILED_TECHNICAL",
|
||||
"BLOCKED_COMPANY_CONTEXT", "BLOCKED_SCHEMA", "BLOCKED_FISCAL_DATA", "FAILED_TECHNICAL", "NEEDS_REVIEW",
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ def blocker_fingerprint(tenant: str, company: str, product_code: str, field: str
|
||||
return hashlib.sha256(json.dumps(raw, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def classify_product_state(product: dict, decision: dict, *, write_status: str | None = None) -> dict:
|
||||
def classify_product_state(product: dict, decision: dict, *, write_status: str | None = None, fiscal: dict | None = None) -> dict:
|
||||
"""`fiscal` = unified_fiscal_coverage.evaluate_product(...): PIS/COFINS + (only when the ERP has RTC on) cClassTrib. One definition of COMPLETE."""
|
||||
status = str(decision.get("status") or "").upper()
|
||||
if write_status == "FAILED":
|
||||
state = "FAILED_TECHNICAL"
|
||||
@@ -33,7 +34,13 @@ def classify_product_state(product: dict, decision: dict, *, write_status: str |
|
||||
state = "CORRECTED"
|
||||
else:
|
||||
state = "COMPLETE"
|
||||
return {"product_code": str(product.get("CODIGO") or ""), "state": state, "decision_status": status}
|
||||
fiscal = fiscal or {}
|
||||
if state in ("COMPLETE", "CORRECTED"):
|
||||
if fiscal.get("blockers"):
|
||||
state = "BLOCKED_FISCAL_DATA"
|
||||
elif fiscal.get("needs_review"):
|
||||
state = "NEEDS_REVIEW"
|
||||
return {"product_code": str(product.get("CODIGO") or ""), "state": state, "decision_status": status, "fiscal_blockers": list(fiscal.get("blockers") or [])}
|
||||
|
||||
|
||||
def should_retry_blocker(previous_input_fingerprint: str | None, current_input_fingerprint: str) -> bool:
|
||||
@@ -44,6 +51,8 @@ def summarize_operational_states(states: list[dict]) -> dict:
|
||||
counts = Counter(item.get("state") for item in states)
|
||||
result = {state: counts.get(state, 0) for state in sorted(STATES)}
|
||||
result["TOTAL_PRODUCTS"] = len(states)
|
||||
result["UNCLASSIFIED"] = result["NEEDS_REVIEW"]
|
||||
result["COMPLETE_PRODUCTS"] = result["COMPLETE"] + result["CORRECTED"]
|
||||
result["SAFE_BLOCKED"] = sum(result[state] for state in STATES if state.startswith("BLOCKED_"))
|
||||
result["AUTONOMOUSLY_PROCESSABLE_PRODUCTS"] = result["TOTAL_PRODUCTS"] - result["FAILED_TECHNICAL"]
|
||||
result["AGENT_HEALTH"] = "HEALTHY" if result["FAILED_TECHNICAL"] == 0 else "DEGRADED"
|
||||
|
||||
@@ -80,7 +80,7 @@ def operational_priority(blocker) -> str:
|
||||
return "CRITICAL"
|
||||
if blocker_type in {"BLOCKED_MISSING_EVIDENCE", "MISSING_EVIDENCE"}:
|
||||
return "HIGH"
|
||||
if field in {"CFOP", "ST", "ICMS", "CSOSN", "NCM"} or reason in {"INSUFFICIENT_EVIDENCE", "NO_EVIDENCE"}:
|
||||
if field in {"CFOP", "ST", "ICMS", "CSOSN", "NCM", "CEST", "PIS", "COFINS", "RTC"} or reason in {"INSUFFICIENT_EVIDENCE", "NO_EVIDENCE"}:
|
||||
return "NORMAL"
|
||||
return "LOW"
|
||||
|
||||
@@ -120,16 +120,17 @@ def job_kind_label(value):
|
||||
return _human_label(value, {"SYNC_INCREMENTAL": "Sincronização incremental", "INITIAL_SYNC": "Sincronização inicial", "REPROCESS_ALL": "Reprocessar tudo", "REPROCESS_PRODUCTS": "Reprocessar produtos"})
|
||||
|
||||
|
||||
_RTC_LABELS = {
|
||||
"CCLASSTRIB_MISSING": "cClassTrib ausente", "CCLASSTRIB_INVALID": "cClassTrib inválido", "CCLASSTRIB_NOT_IN_CATALOG": "cClassTrib fora do catálogo local",
|
||||
"RTC_CST_INVALID": "CST IBS/CBS inválido", "PIS_CST_MISSING": "CST PIS ausente", "PIS_CST_INVALID": "CST PIS inválido", "PIS_RATE_INVALID": "Alíquota PIS inválida",
|
||||
"COFINS_CST_MISSING": "CST COFINS ausente", "COFINS_CST_INVALID": "CST COFINS inválido", "COFINS_RATE_INVALID": "Alíquota COFINS inválida",
|
||||
"PIS_COFINS_FLAG_INCONSISTENT": "Flag PIS/COFINS inconsistente",
|
||||
_FISCAL_LABELS = {
|
||||
"CCLASSTRIB_MISSING": "cClassTrib ausente", "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",
|
||||
"PIS_COFINS_FLAG_INCONSISTENT": "Flag PIS/COFINS inconsistente", "BLOCKED_FISCAL_DATA": "Dado fiscal do cadastro",
|
||||
}
|
||||
|
||||
|
||||
def blocker_type_label(value):
|
||||
return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão", "OTHER": "Outro", **_RTC_LABELS})
|
||||
return _human_label(value, {"BLOCKED_MISSING_EVIDENCE": "Evidência fiscal ausente", "MISSING_EVIDENCE": "Evidência fiscal ausente", "TECHNICAL_FAILURE": "Falha técnica", "CONNECTION_ERROR": "Erro de conexão", "OTHER": "Outro", **_FISCAL_LABELS})
|
||||
|
||||
|
||||
_REASON_LABELS = {
|
||||
@@ -143,7 +144,7 @@ _REASON_LABELS = {
|
||||
|
||||
|
||||
def blocker_reason_label(value):
|
||||
return _human_label(value, _REASON_LABELS)
|
||||
return _human_label(value, {**_REASON_LABELS, **_FISCAL_LABELS})
|
||||
|
||||
|
||||
def health_reason_label(value):
|
||||
@@ -160,12 +161,10 @@ def priority_label(value):
|
||||
return _human_label(value, {"CRITICAL": "Crítica", "HIGH": "Alta", "NORMAL": "Média", "MEDIUM": "Média", "LOW": "Baixa"})
|
||||
|
||||
|
||||
def expanded_scope_view(summary):
|
||||
"""Panel-ready view of the Agent's `expanded_scope` job summary (legacy and expanded coverage side by side). None when absent/unavailable."""
|
||||
if not isinstance(summary, dict) or "expanded_scope" not in summary:
|
||||
def rtc_status_view(summary):
|
||||
"""Discreet Reforma Tributária status from the last completed job's `fiscal_context`. None when the Agent did not report it (older Agents)."""
|
||||
context = (summary or {}).get("fiscal_context") if isinstance(summary, dict) else None
|
||||
if not isinstance(context, dict) or "rtc_enabled" not in context:
|
||||
return None
|
||||
legacy, expanded, total = summary.get("legacy_scope") or {}, summary["expanded_scope"] or {}, summary.get("total_products") or 0
|
||||
percent = lambda part: round(100 * part / total, 1) if total else None
|
||||
return {"total": total, "legacy": legacy, "expanded": expanded, "legacy_percent": percent(legacy.get("complete", 0)), "expanded_percent": percent(expanded.get("complete", 0)),
|
||||
"blockers": [{"type": key, "label": blocker_type_label(key), "count": count} for key, count in (summary.get("blockers_by_type") or {}).items()],
|
||||
"capabilities": summary.get("capabilities") or {}, "local_catalog_only": bool(summary.get("local_catalog_only"))}
|
||||
return {"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"))}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
"""UNIFIED fiscal completeness: ONE definition of "complete" for a product (pure, no I/O).
|
||||
|
||||
mandatory(product, company, rtc) = base fiscal fields (decided elsewhere: NCM/CEST/CFOP/ICMS/IPI ...)
|
||||
+ PIS/COFINS when they APPLY to the product
|
||||
+ RTC (cClassTrib -> CST IBS/CBS) ONLY when the ERP has the tax reform switched on.
|
||||
|
||||
There is no "legacy scope" and no "expanded scope": every finding here becomes a normal blocker in the single queue and
|
||||
changes the single coverage number. Nothing here writes to the ERP or invents a fiscal value.
|
||||
|
||||
Sources (see docs/pis_cofins_applicability.md): SPED EFD-Contribuicoes table 4.3.3/4.3.4 (CST domain), 4.3.10-4.3.15 (special regimes),
|
||||
LC 123/2006 art. 13/18 (PIS/Cofins inside the Simples DAS), Leis 9.718/98, 10.637/2002, 10.833/2003 (cumulative / non-cumulative),
|
||||
NT 2025.002-RTC v1.51 (IBS/CBS fields), LC 214/2025 art. 348.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date
|
||||
|
||||
# Official CST PIS / CST COFINS domain (SPED tables 4.3.3 / 4.3.4). Membership only - never used to pick a value.
|
||||
CST_DOMAIN = frozenset([f"{n:02d}" for n in (*range(1, 10), 49, *range(50, 57), *range(60, 68), *range(70, 76), 98, 99)])
|
||||
RATE_REQUIRED_CST = frozenset({"01", "02"}) # taxable by percentage: a rate is needed
|
||||
RATE_NOT_APPLICABLE_CST = frozenset({"03", "04", "05", "06", "07", "08", "09"}) # per-unit amount / monophasic / ST / zero / exempt / no incidence / suspended
|
||||
INCIDENCE_CST = {"MONOFASICA": frozenset({"02", "03", "04"}), "SUBSTITUICAO": frozenset({"05"})} # 4.3.10/4.3.11 (02,03,04) and 4.3.12 (05)
|
||||
SIMPLES_FAMILY = frozenset({"1", "2", "4"}) # CRT 1 Simples, 2 Simples excess sublimit, 4 MEI (LC 123/2006): PIS/Cofins are collected inside the DAS
|
||||
|
||||
PIS_COFINS_FIELDS = ("COD_ENTRADA_PIS", "COD_SAIDA_PIS", "ALIQ_PIS", "COD_ENTRADA_COFINS", "COD_SAIDA_COFINS", "ALIQ_COFINS")
|
||||
|
||||
FIELD_BY_BLOCKER_PREFIX = (("PIS", "PIS"), ("COFINS", "COFINS"), ("CCLASSTRIB", "RTC"), ("RTC", "RTC"))
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
return "" if value is None else str(value).strip()
|
||||
|
||||
|
||||
def blocker_category(blocker_type: str) -> str:
|
||||
"""Dashboard category of a blocker type: PIS | COFINS | RTC | OTHER."""
|
||||
for prefix, category in FIELD_BY_BLOCKER_PREFIX:
|
||||
if blocker_type.startswith(prefix):
|
||||
return category
|
||||
return "OTHER"
|
||||
|
||||
|
||||
def incidence_kind(value) -> str:
|
||||
"""NONE | MONOFASICA | SUBSTITUICAO | UNKNOWN (accent/case-insensitive, encoding tolerant). Anything the SGV does not define is UNKNOWN, never NONE."""
|
||||
raw = _text(value)
|
||||
text = re.sub(r"[^A-Z]", "", unicodedata.normalize("NFKD", raw).encode("ascii", "ignore").decode().upper())
|
||||
if not raw or text == "NENHUMA":
|
||||
return "NONE"
|
||||
if text.startswith("MONOF"):
|
||||
return "MONOFASICA"
|
||||
if text.startswith("SUBST"):
|
||||
return "SUBSTITUICAO"
|
||||
return "UNKNOWN"
|
||||
|
||||
|
||||
def validate_cst(value) -> str:
|
||||
code = _text(value)
|
||||
if not code:
|
||||
return "EMPTY"
|
||||
return "VALID" if code in CST_DOMAIN else "INVALID"
|
||||
|
||||
|
||||
def cst_suggestion(value) -> str | None:
|
||||
"""A short numeric code that becomes an official CST once zero-padded to two digits ('4' -> '04'). Only a hint for the operator; never applied."""
|
||||
code = _text(value)
|
||||
return code.zfill(2) if code.isdigit() and len(code) == 1 and code.zfill(2) in CST_DOMAIN else None
|
||||
|
||||
|
||||
def validate_rate(value) -> str:
|
||||
text = _text(value)
|
||||
if not text:
|
||||
return "EMPTY"
|
||||
if not re.fullmatch(r"\d{1,3}([.,]\d{1,4})?", text):
|
||||
return "INVALID"
|
||||
return "VALID" if float(text.replace(",", ".")) <= 100 else "INVALID"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- PIS / COFINS
|
||||
|
||||
|
||||
def pis_cofins_applicability(product: dict, crt) -> dict:
|
||||
"""{"state": NOT_APPLICABLE | REQUIRED | OPTIONAL | NEEDS_REVIEW, "basis": ...}.
|
||||
|
||||
NOT_APPLICABLE the product carries no product-level PIS/COFINS treatment at all (flag N/blank, incidence NENHUMA/blank, all six fields blank).
|
||||
The tax is then settled elsewhere: inside the DAS (Simples family) or from the company default (regime normal).
|
||||
REQUIRED the product itself claims a special treatment (flag S, or incidence MONOFASICA/SUBSTITUICAO): the exit CST (and a rate when the CST needs one) must be valid.
|
||||
OPTIONAL no special claim, but some CST/rate was typed: it is not mandatory, yet whatever is there must be valid.
|
||||
NEEDS_REVIEW the incidence text is not one the SGV defines (NENHUMA / MONOFASICA / SUBST.TRIBUTARIA): applicability cannot be decided.
|
||||
"""
|
||||
flag = _text(product.get("PISCOFINS")).upper()
|
||||
kind = incidence_kind(product.get("INCIDENCIA_PISCOFINS"))
|
||||
has_values = any(_text(product.get(c)) for c in PIS_COFINS_FIELDS)
|
||||
if kind == "UNKNOWN":
|
||||
return {"state": "NEEDS_REVIEW", "basis": "INCIDENCE_NOT_DEFINED_BY_SGV"}
|
||||
if flag == "S" or kind in ("MONOFASICA", "SUBSTITUICAO"):
|
||||
return {"state": "REQUIRED", "basis": "PRODUCT_DECLARES_SPECIAL_TREATMENT"}
|
||||
if has_values:
|
||||
return {"state": "OPTIONAL", "basis": "VALUES_TYPED_WITHOUT_SPECIAL_CLAIM"}
|
||||
return {"state": "NOT_APPLICABLE", "basis": "SIMPLES_DAS" if _text(crt) in SIMPLES_FAMILY else "COMPANY_DEFAULT"}
|
||||
|
||||
|
||||
def evaluate_pis_cofins(product: dict, crt) -> dict:
|
||||
"""{"applicability": {...}, "blockers": [(type, reason)]}. Entry and exit are separate; a missing OPTIONAL value never blocks."""
|
||||
app = pis_cofins_applicability(product, crt)
|
||||
blockers: list[tuple[str, str]] = []
|
||||
if app["state"] in ("NOT_APPLICABLE", "NEEDS_REVIEW"):
|
||||
return {"applicability": app, "blockers": blockers}
|
||||
kind = incidence_kind(product.get("INCIDENCIA_PISCOFINS"))
|
||||
flag = _text(product.get("PISCOFINS")).upper()
|
||||
for tax in ("PIS", "COFINS"):
|
||||
entry, exit_, rate = product.get(f"COD_ENTRADA_{tax}"), product.get(f"COD_SAIDA_{tax}"), product.get(f"ALIQ_{tax}")
|
||||
entry_state, exit_state, rate_state = validate_cst(entry), validate_cst(exit_), validate_rate(rate)
|
||||
if entry_state == "INVALID":
|
||||
blockers.append((f"{tax}_CST_INVALID", "ENTRY" + (f":UNPADDED_USE_{cst_suggestion(entry)}" if cst_suggestion(entry) else "")))
|
||||
if exit_state == "INVALID":
|
||||
blockers.append((f"{tax}_CST_INVALID", "EXIT" + (f":UNPADDED_USE_{cst_suggestion(exit_)}" if cst_suggestion(exit_) else "")))
|
||||
if app["state"] == "REQUIRED" and exit_state == "EMPTY":
|
||||
blockers.append((f"{tax}_CST_MISSING", "EXIT"))
|
||||
if rate_state == "INVALID":
|
||||
blockers.append((f"{tax}_RATE_INVALID", "RATE"))
|
||||
if exit_state == "VALID":
|
||||
code = _text(exit_)
|
||||
if code in RATE_REQUIRED_CST and rate_state == "EMPTY":
|
||||
blockers.append((f"{tax}_RATE_MISSING", f"CST_{code}_NEEDS_RATE"))
|
||||
if kind in INCIDENCE_CST and code not in INCIDENCE_CST[kind]:
|
||||
blockers.append((f"{tax}_CST_INCIDENCE_MISMATCH", f"{kind}:CST_{code}"))
|
||||
if kind == "NONE" and app["state"] == "REQUIRED" and code in RATE_NOT_APPLICABLE_CST - {"03"} and flag == "S":
|
||||
blockers.append((f"{tax}_CST_INCIDENCE_MISMATCH", f"NENHUMA:CST_{code}"))
|
||||
if flag == "N" and kind != "NONE":
|
||||
blockers.append(("PIS_COFINS_FLAG_INCONSISTENT", "FLAG_N_WITH_SPECIAL_INCIDENCE"))
|
||||
if flag == "S" and kind == "NONE" and not any(_text(product.get(c)) for c in PIS_COFINS_FIELDS):
|
||||
blockers.append(("PIS_COFINS_FLAG_INCONSISTENT", "FLAG_S_WITHOUT_INCIDENCE_OR_CODES"))
|
||||
return {"applicability": app, "blockers": _dedupe(blockers)}
|
||||
|
||||
|
||||
def _dedupe(items):
|
||||
seen, out = set(), []
|
||||
for item in items:
|
||||
if item not in seen:
|
||||
seen.add(item)
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- RTC
|
||||
|
||||
|
||||
def rtc_enabled(caps: dict) -> bool:
|
||||
"""RTC is in scope ONLY when the ERP is switched to the reform (PARAMETROS.USAR_REFORMA_TRIBUTARIA truthy) AND its schema can hold it."""
|
||||
return bool(caps.get("HAS_PRODUCT_CCLASSTRIB") and caps.get("HAS_RTC_RELATIONSHIP") and caps.get("RTC_ENABLED_BY_PARAM"))
|
||||
|
||||
|
||||
def catalog_integrity(catalog: dict) -> dict:
|
||||
"""catalog: {"classes": {cClassTrib: cst_or_None}, "cst": {code, ...}, "duplicates": int}."""
|
||||
classes, cst = catalog.get("classes", {}), set(catalog.get("cst", ()))
|
||||
bad_format = [c for c in classes if not re.fullmatch(r"\d{6}", str(c))]
|
||||
bad_cst_format = [c for c in cst if not re.fullmatch(r"\d{3}", str(c))]
|
||||
orphans = [c for c, s in classes.items() if s is None or str(s) not in cst]
|
||||
return {"classes": len(classes), "cst": len(cst), "orphan_classes": len(orphans), "bad_class_format": len(bad_format), "bad_cst_format": len(bad_cst_format),
|
||||
"duplicates": int(catalog.get("duplicates", 0)), "valid": bool(classes and cst and not orphans and not bad_format and not bad_cst_format and not catalog.get("duplicates"))}
|
||||
|
||||
|
||||
def validate_cclasstrib(value, catalog: dict | None, today: date | None = None) -> str:
|
||||
"""EMPTY | INVALID_FORMAT | NOT_IN_CATALOG | EXPIRED | CST_MISSING | VALID (CST IBS/CBS derived through the catalog, never stored on the product)."""
|
||||
code = _text(value)
|
||||
if not code:
|
||||
return "EMPTY"
|
||||
if not re.fullmatch(r"\d{6}", code):
|
||||
return "INVALID_FORMAT"
|
||||
if not catalog or code not in catalog.get("classes", {}):
|
||||
return "NOT_IN_CATALOG"
|
||||
validity = (catalog.get("validity") or {}).get(code)
|
||||
if validity and today is not None:
|
||||
start, end = validity
|
||||
if (start and start > today) or (end and end < today):
|
||||
return "EXPIRED"
|
||||
cst = catalog["classes"][code]
|
||||
return "VALID" if cst is not None and str(cst) in set(catalog.get("cst", ())) else "CST_MISSING"
|
||||
|
||||
|
||||
def evaluate_rtc(product: dict, rtc: dict | None, today: date | None = None) -> dict:
|
||||
"""rtc: {"enabled": bool, "catalog": {...}|None, "catalog_valid": bool}. Disabled => NOT_APPLICABLE and NO blocker, whatever CCLASSTRIB holds."""
|
||||
if not rtc or not rtc.get("enabled"):
|
||||
return {"applicability": {"state": "NOT_APPLICABLE", "basis": "RTC_DISABLED_IN_ERP"}, "blockers": []}
|
||||
app = {"state": "REQUIRED", "basis": "RTC_ENABLED_IN_ERP"}
|
||||
if not rtc.get("catalog_valid"):
|
||||
return {"applicability": app, "blockers": [("RTC_CATALOG_INVALID", "LOCAL_CATALOG_FAILED_INTEGRITY")]}
|
||||
state = validate_cclasstrib(product.get("CCLASSTRIB"), rtc.get("catalog"), today)
|
||||
mapping = {"EMPTY": "CCLASSTRIB_MISSING", "INVALID_FORMAT": "CCLASSTRIB_INVALID_FORMAT", "NOT_IN_CATALOG": "CCLASSTRIB_NOT_IN_CATALOG", "EXPIRED": "CCLASSTRIB_NOT_IN_CATALOG", "CST_MISSING": "RTC_CST_MISSING"}
|
||||
return {"applicability": app, "blockers": [(mapping[state], state)] if state in mapping else []}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------------------------- unified
|
||||
|
||||
|
||||
def evaluate_product(product: dict, company: dict | None, rtc: dict | None, today: date | None = None) -> dict:
|
||||
"""The single fiscal-completeness verdict for the fields this module owns.
|
||||
Returns {"blockers": [{"type","category","reason"}], "applicability": {"PIS_COFINS": state, "RTC": state}, "mandatory_fields": [...], "needs_review": bool}."""
|
||||
crt = (company or {}).get("crt")
|
||||
pis = evaluate_pis_cofins(product, crt)
|
||||
rtc_result = evaluate_rtc(product, rtc, today)
|
||||
blockers = [{"type": kind, "category": blocker_category(kind), "reason": reason} for kind, reason in pis["blockers"] + rtc_result["blockers"]]
|
||||
mandatory = []
|
||||
if pis["applicability"]["state"] == "REQUIRED":
|
||||
mandatory += ["COD_SAIDA_PIS", "COD_SAIDA_COFINS"]
|
||||
if rtc_result["applicability"]["state"] == "REQUIRED":
|
||||
mandatory.append("CCLASSTRIB")
|
||||
return {"blockers": blockers, "applicability": {"PIS_COFINS": pis["applicability"]["state"], "RTC": rtc_result["applicability"]["state"]},
|
||||
"basis": {"PIS_COFINS": pis["applicability"]["basis"], "RTC": rtc_result["applicability"]["basis"]}, "mandatory_fields": mandatory,
|
||||
"needs_review": pis["applicability"]["state"] == "NEEDS_REVIEW"}
|
||||
+2
-2
@@ -15,7 +15,7 @@ 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 expanded_scope_view, job_kind_label, job_status_label
|
||||
from .panel_presentation import 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
|
||||
@@ -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 {"expanded": expanded_scope_view(last_summary), "enabled": bool(installation.processing_enabled), "interval_minutes": installation.sync_interval_seconds // 60,
|
||||
return {"rtc": rtc_status_view(last_summary), "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}
|
||||
|
||||
|
||||
@@ -8,11 +8,7 @@
|
||||
<form method="post" action="/agents/{{ detail.agent_id }}/sync-now" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" {% if not ops.enabled %}disabled title="Processamento pausado: retome para sincronizar"{% else %}title="Verifica apenas as alterações desde a última sincronização"{% endif %}>Sincronizar agora</button></form>
|
||||
<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><dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&job_id={{ ops.last_job.id }}">{{ ops.last_job.kind_label }}</a> · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}</dd></dl></section>
|
||||
{% if ops.expanded %}<section class="panel"><div class="panel-title"><div><h2>Cobertura fiscal</h2><p class="muted">Escopo anterior e escopo ampliado (cClassTrib e PIS/COFINS quando aplicáveis). Catálogo RTC local do ERP.</p></div></div>
|
||||
<dl class="facts"><dt>Escopo anterior</dt><dd>{{ ops.expanded.legacy.complete }} completos · {{ ops.expanded.legacy.safe_blocked }} bloqueados · {{ ops.expanded.legacy.failed }} falhas{% if ops.expanded.legacy_percent is not none %} · {{ ops.expanded.legacy_percent|string|replace(".", ",") }}%{% endif %}</dd>
|
||||
<dt>Escopo ampliado</dt><dd>{{ ops.expanded.expanded.complete }} completos · {{ ops.expanded.expanded.safe_blocked }} bloqueados · {{ ops.expanded.expanded.failed }} falhas · {{ ops.expanded.expanded.unknown_applicability }} com aplicabilidade desconhecida</dd>
|
||||
<dt>Novos bloqueios</dt><dd>{% for b in ops.expanded.blockers %}<span class="badge blocked">{{ b.label }} · {{ b.count }}</span> {% else %}nenhum{% endfor %}</dd></dl></section>{% 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" }}">{{ ops.rtc.label }}</span></dd>{% endif %}<dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&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 }}&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>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{% if reason_code_filter %}<input type="hidden" name="reason_code" value="{{ reason_code_filter }}">{% endif %}
|
||||
<label>Cliente<input name="client" value="{{ client_filter }}"></label>
|
||||
<label>Instalação / servidor<input name="installation" value="{{ installation_filter }}"></label>
|
||||
<label>Campo<select name="field"><option value="">Todos</option>{% for value in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value if value!="OTHER" else "Outros" }}</option>{% endfor %}</select></label>
|
||||
<label>Campo<select name="field"><option value="">Todos</option>{% for value in ["NCM","CEST","CFOP","ST","ICMS","CSOSN","PIS","COFINS","RTC","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value if value!="OTHER" else "Outros" }}</option>{% endfor %}</select></label>
|
||||
<label>Tipo de blocker<input name="blocker_type" value="{{ blocker_type_filter }}" placeholder="Tipo técnico ou nome"></label>
|
||||
<label>Status<select name="status"><option value="OPEN" {% if status_filter=="OPEN" %}selected{% endif %}>Abertos</option><option value="RESOLVED" {% if status_filter=="RESOLVED" %}selected{% endif %}>Resolvidos</option><option value="ALL" {% if status_filter=="ALL" %}selected{% endif %}>Todos</option></select></label>
|
||||
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
|
||||
@@ -26,7 +26,7 @@
|
||||
</form>
|
||||
{% if field_filter or reason_code_filter or scope_filter=="all" or status_filter!="OPEN" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if field_filter %}<span class="filter-chip">Campo: {{ field_filter }}</span>{% endif %}{% if reason_code_filter %}<span class="filter-chip">Motivo: {{ reason_code_filter }}</span>{% endif %}{% if status_filter!="OPEN" %}<span class="filter-chip">Status: {{ "Resolvidos" if status_filter=="RESOLVED" else "Todos" }}</span>{% endif %}</p>{% endif %}
|
||||
</section>
|
||||
<section class="panel"><div class="panel-title"><h2>Distribuição dos blockers filtrados</h2></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}{% if summary.blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?{{ field_query_base|e }}&field={{ key }}"><span>{{ key }}</span><b>{{ summary.blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}{% endfor %}</div></section>
|
||||
<section class="panel"><div class="panel-title"><h2>Distribuição dos blockers filtrados</h2></div><div class="bar-grid">{% for key in ["NCM","CEST","CFOP","ST","ICMS","CSOSN","PIS","COFINS","RTC","OTHER"] %}{% if summary.blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?{{ field_query_base|e }}&field={{ key }}"><span>{{ key }}</span><b>{{ summary.blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}{% endfor %}</div></section>
|
||||
<div class="panel table-wrap blockers-table-wrap"><table class="blockers-table"><caption>Fila de blockers · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Produto</th><th>Campo / tipo</th><th>Prioridade</th><th>Problema</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>
|
||||
{% for item in result["items"] %}<tr><td data-label="Cliente / servidor"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Instalação não informada" }}</small></td><td data-label="Produto"><a class="row-primary-link" href="{{ item.link }}"><b>{{ item.product_code }}</b><small>{{ item.description or "Sem descrição" }}</small></a></td><td data-label="Campo / tipo">{{ item.field or "Outros" }}<small>{{ item.blocker_type_label }}</small></td><td data-label="Prioridade"><span class="badge priority-{{ item.priority|lower }}">{{ item.priority_label }}</span></td><td data-label="Problema">{{ item.reason_label }}<small>{{ item.age }}</small></td><td data-label="Última ocorrência" title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td data-label="Ação"><a class="secondary" href="{{ item.link }}">Ver detalhes</a></td></tr>{% else %}<tr><td colspan="7" class="empty-state">Nenhum blocker corresponde aos filtros. <a href="/blockers?scope=all&status=ALL">Consultar histórico</a></td></tr>{% endfor %}
|
||||
</tbody></table></div>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</article>
|
||||
<article class="panel">
|
||||
<div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&status=OPEN">Ver blockers</a></div>
|
||||
{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}
|
||||
{% for key in ["NCM","CEST","CFOP","ST","ICMS","CSOSN","PIS","COFINS","RTC","OTHER"] %}
|
||||
{% if summary.open_blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?scope=active&status=OPEN&field={{ key }}" aria-label="{{ key }}: {{ summary.open_blocker_breakdown[key] }} blockers abertos"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>
|
||||
{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
@@ -536,3 +536,42 @@ def test_agent_detail_processing_hierarchy_and_status_badges():
|
||||
assert "Mantém o heartbeat, mas não executa jobs" in page and "Recalcula todos os produtos" in page
|
||||
assert "Saúde técnica: " not in page and "Status fiscal: " not in page # context lives in the small label, not in the badge
|
||||
assert '<small class="status-label">Saúde técnica</small>' in page
|
||||
|
||||
|
||||
def _complete_sync(client, agent_id, fiscal_context):
|
||||
with client._session_factory() as session:
|
||||
session.add(Job(agent_id=agent_id, kind="SYNC_INCREMENTAL", status="COMPLETED", payload={}, result_summary={"fiscal_context": fiscal_context}, created_at=datetime(2026, 9, 22, 19, 0, tzinfo=timezone.utc),
|
||||
finished_at=datetime(2026, 9, 22, 19, 5, tzinfo=timezone.utc)))
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_agent_detail_has_one_coverage_and_shows_rtc_status_only_when_reported():
|
||||
client, agent_id, _ = _panel_client()
|
||||
plain = client.get(f"/agents/{agent_id}").text
|
||||
assert "Reforma Tributária" not in plain # older Agents do not report it
|
||||
for text in (plain,):
|
||||
assert "Escopo anterior" not in text and "Escopo ampliado" not in text and "aplicabilidade desconhecida" not in text
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": False, "rtc_supported": True, "rtc_catalog_valid": True})
|
||||
off = client.get(f"/agents/{agent_id}").text
|
||||
assert re.search(r"Reforma Tributária</dt><dd><span[^>]*>Desativada</span>", off) and "Escopo anterior" not in off and "Escopo ampliado" not in off
|
||||
assert "cClassTrib" not in off # RTC off never surfaces RTC as a problem
|
||||
_complete_sync(client, agent_id, {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True})
|
||||
on = client.get(f"/agents/{agent_id}").text
|
||||
assert re.search(r"Reforma Tributária</dt><dd><span[^>]*>Ativa</span>", on)
|
||||
|
||||
|
||||
def test_pis_cofins_and_rtc_blockers_appear_in_the_same_queue_and_dashboard_breakdown():
|
||||
client, agent_id, _ = _panel_client()
|
||||
with client._session_factory() as session:
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent_id))
|
||||
for code, field, reason in (("P-010", "PIS", "PIS_CST_INVALID"), ("P-011", "COFINS", "COFINS_CST_MISSING"), ("P-012", "RTC", "CCLASSTRIB_MISSING")):
|
||||
upsert_blocker(session, installation.tenant_id, {"company": "Acme", "product_code": code, "description": "x", "blocker_type": "BLOCKED_FISCAL_DATA", "field": field, "reason_code": reason, "input_fingerprint": code},
|
||||
installation_id=installation.id)
|
||||
dash = client.get("/").text
|
||||
for key in ("PIS", "COFINS", "RTC", "CEST"):
|
||||
assert f"<span>{key}</span>" in dash
|
||||
queue = client.get("/blockers?scope=active&status=OPEN").text
|
||||
assert "CST PIS inválido" in queue and "CST COFINS ausente" in queue and "cClassTrib ausente" in queue
|
||||
assert "fila RTC" not in queue.lower()
|
||||
filtered = client.get("/blockers?scope=active&status=OPEN&field=RTC").text
|
||||
assert "cClassTrib ausente" in filtered and "CST PIS inválido" not in filtered
|
||||
|
||||
@@ -108,7 +108,7 @@ def test_dashboard_reconciles_five_health_states_and_counts_distinct_safe_produc
|
||||
assert summary["agents_total"] == sum(summary["health_counts"].values())
|
||||
assert summary["safe_blocked_products"] == 2
|
||||
assert summary["open_blockers"] == 3
|
||||
assert summary["open_blocker_breakdown"] == {"CFOP": 1, "ST": 0, "ICMS": 1, "CSOSN": 0, "NCM": 1, "OTHER": 0}
|
||||
assert summary["open_blocker_breakdown"] == {"NCM": 1, "CEST": 0, "CFOP": 1, "ST": 0, "ICMS": 1, "CSOSN": 0, "PIS": 0, "COFINS": 0, "RTC": 0, "OTHER": 0}
|
||||
assert summary["online"] == 1 and summary["safe_blocked"] == 2
|
||||
|
||||
|
||||
@@ -576,12 +576,11 @@ def test_remaining_technical_enums_have_human_labels():
|
||||
assert blocker_reason_label("SOMETHING_NEW") == "Something new" # unknown codes degrade readably, never crash
|
||||
|
||||
|
||||
def test_expanded_scope_view_labels_and_absence():
|
||||
from central.app.panel_presentation import expanded_scope_view
|
||||
assert expanded_scope_view(None) is None and expanded_scope_view({"coverage": {}}) is None
|
||||
view = expanded_scope_view({"total_products": 10, "legacy_scope": {"complete": 8, "safe_blocked": 2, "failed": 0},
|
||||
"expanded_scope": {"complete": 1, "safe_blocked": 3, "failed": 0, "unknown_applicability": 6},
|
||||
"blockers_by_type": {"PIS_CST_INVALID": 1, "CCLASSTRIB_MISSING": 2}, "local_catalog_only": True})
|
||||
assert view["legacy_percent"] == 80.0 and view["expanded_percent"] == 10.0 and view["local_catalog_only"]
|
||||
assert {b["label"] for b in view["blockers"]} == {"CST PIS inválido", "cClassTrib ausente"}
|
||||
assert blocker_type_label("RTC_CST_INVALID") == "CST IBS/CBS inválido" and blocker_type_label("COFINS_RATE_INVALID") == "Alíquota COFINS inválida"
|
||||
def test_rtc_status_view_is_discreet_and_optional():
|
||||
from central.app.panel_presentation import rtc_status_view
|
||||
assert rtc_status_view(None) is None and rtc_status_view({"coverage": {}}) is None
|
||||
assert rtc_status_view({"fiscal_context": {"rtc_enabled": False}})["label"] == "Desativada"
|
||||
on = rtc_status_view({"fiscal_context": {"rtc_enabled": True, "rtc_supported": True, "rtc_catalog_valid": True}})
|
||||
assert on["label"] == "Ativa" and on["enabled"] and on["catalog_valid"]
|
||||
assert blocker_type_label("RTC_CST_MISSING") == "CST IBS/CBS ausente no catálogo" and blocker_type_label("COFINS_RATE_INVALID") == "Alíquota COFINS inválida"
|
||||
assert blocker_type_label("PIS_CST_INCIDENCE_MISMATCH") == "CST PIS incompatível com a incidência"
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
from datetime import date
|
||||
|
||||
from central.app.operational_state import classify_product_state, summarize_operational_states
|
||||
from central.app.unified_fiscal_coverage import (blocker_category, evaluate_product, incidence_kind, rtc_enabled, validate_cclasstrib)
|
||||
|
||||
CATALOG = {"classes": {"000001": "000", "200003": "200", "410004": "410", "999999": None, "555555": "555"}, "cst": {"000", "200", "410"}, "duplicates": 0}
|
||||
ON = {"enabled": True, "catalog": CATALOG, "catalog_valid": True}
|
||||
OFF = {"enabled": False, "catalog": CATALOG, "catalog_valid": True}
|
||||
SIMPLES, NORMAL = {"crt": "1"}, {"crt": "3"}
|
||||
EMPTY = {"CODIGO": "1", "PISCOFINS": "N", "INCIDENCIA_PISCOFINS": "NENHUMA"}
|
||||
|
||||
|
||||
def kinds(result):
|
||||
return {b["type"] for b in result["blockers"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- RTC on / off
|
||||
def test_rtc_off_ignores_cclasstrib_completely():
|
||||
for value in (None, "", "12x", "999999", "000001"):
|
||||
r = evaluate_product({**EMPTY, "CCLASSTRIB": value}, SIMPLES, OFF)
|
||||
assert r["blockers"] == [] and r["applicability"]["RTC"] == "NOT_APPLICABLE" and "CCLASSTRIB" not in r["mandatory_fields"]
|
||||
assert evaluate_product(EMPTY, SIMPLES, None)["blockers"] == []
|
||||
|
||||
|
||||
def test_rtc_on_requires_and_validates_cclasstrib():
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": None}, SIMPLES, ON)) == {"CCLASSTRIB_MISSING"}
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "12x"}, SIMPLES, ON)) == {"CCLASSTRIB_INVALID_FORMAT"}
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "123456"}, SIMPLES, ON)) == {"CCLASSTRIB_NOT_IN_CATALOG"}
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "999999"}, SIMPLES, ON)) == {"RTC_CST_MISSING"} # class without CST
|
||||
assert kinds(evaluate_product({**EMPTY, "CCLASSTRIB": "555555"}, SIMPLES, ON)) == {"RTC_CST_MISSING"} # class points to a CST that is not in RTC_CST (orphan)
|
||||
ok = evaluate_product({**EMPTY, "CCLASSTRIB": "200003"}, SIMPLES, ON)
|
||||
assert ok["blockers"] == [] and ok["applicability"]["RTC"] == "REQUIRED" and "CCLASSTRIB" in ok["mandatory_fields"]
|
||||
|
||||
|
||||
def test_rtc_on_with_invalid_catalog_blocks_with_a_single_clear_reason():
|
||||
r = evaluate_product({**EMPTY, "CCLASSTRIB": "200003"}, SIMPLES, {"enabled": True, "catalog": CATALOG, "catalog_valid": False})
|
||||
assert kinds(r) == {"RTC_CATALOG_INVALID"}
|
||||
|
||||
|
||||
def test_rtc_vigencia_is_used_only_when_the_catalog_carries_it():
|
||||
cat = {**CATALOG, "validity": {"200003": (date(2027, 1, 1), None), "410004": (date(2025, 1, 1), date(2025, 12, 31))}}
|
||||
assert validate_cclasstrib("200003", cat, date(2026, 9, 24)) == "EXPIRED" and validate_cclasstrib("410004", cat, date(2026, 9, 24)) == "EXPIRED"
|
||||
assert validate_cclasstrib("000001", cat, date(2026, 9, 24)) == "VALID" and validate_cclasstrib("200003", CATALOG, date(2026, 9, 24)) == "VALID"
|
||||
|
||||
|
||||
def test_rtc_enabled_needs_schema_and_erp_switch():
|
||||
caps = {"HAS_PRODUCT_CCLASSTRIB": True, "HAS_RTC_RELATIONSHIP": True, "RTC_ENABLED_BY_PARAM": True}
|
||||
assert rtc_enabled(caps) and not rtc_enabled({**caps, "RTC_ENABLED_BY_PARAM": False}) and not rtc_enabled({**caps, "HAS_RTC_RELATIONSHIP": False}) and not rtc_enabled({})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- PIS / COFINS applicability
|
||||
def test_empty_product_is_not_applicable_in_every_regime_and_never_blocks():
|
||||
for company, basis in ((SIMPLES, "SIMPLES_DAS"), ({"crt": "2"}, "SIMPLES_DAS"), ({"crt": "4"}, "SIMPLES_DAS"), (NORMAL, "COMPANY_DEFAULT"), ({"crt": None}, "COMPANY_DEFAULT"), (None, "COMPANY_DEFAULT")):
|
||||
r = evaluate_product(EMPTY, company, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "NOT_APPLICABLE" and r["basis"]["PIS_COFINS"] == basis and r["blockers"] == []
|
||||
assert evaluate_product({"CODIGO": "1"}, SIMPLES, OFF)["blockers"] == [] # columns absent in an old schema: same answer
|
||||
|
||||
|
||||
def test_declared_monophasic_product_requires_a_valid_exit_cst():
|
||||
good = {**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "MONOFÁSICA", "COD_SAIDA_PIS": "04", "COD_SAIDA_COFINS": "04"}
|
||||
r = evaluate_product(good, SIMPLES, OFF)
|
||||
assert r["blockers"] == [] and r["applicability"]["PIS_COFINS"] == "REQUIRED" and set(r["mandatory_fields"]) == {"COD_SAIDA_PIS", "COD_SAIDA_COFINS"}
|
||||
assert kinds(evaluate_product({**good, "COD_SAIDA_PIS": None}, SIMPLES, OFF)) == {"PIS_CST_MISSING"}
|
||||
assert kinds(evaluate_product({**good, "COD_SAIDA_COFINS": "01", "ALIQ_COFINS": "7,6"}, SIMPLES, OFF)) == {"COFINS_CST_INCIDENCE_MISMATCH"}
|
||||
|
||||
|
||||
def test_st_incidence_with_taxable_cst_is_a_mismatch_and_pis_cofins_are_independent():
|
||||
row = {**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "SUBST.TRIBUTÁRIA", "COD_ENTRADA_PIS": "73", "COD_SAIDA_PIS": "01", "ALIQ_PIS": "0,65", "COD_ENTRADA_COFINS": "73", "COD_SAIDA_COFINS": "05"}
|
||||
assert kinds(evaluate_product(row, NORMAL, OFF)) == {"PIS_CST_INCIDENCE_MISMATCH"} # only PIS is wrong; COFINS 05 is right
|
||||
|
||||
|
||||
def test_value_2_is_invalid_format_not_a_test_product_and_not_silently_ignored():
|
||||
row = {**EMPTY, "COD_ENTRADA_PIS": "2", "COD_SAIDA_PIS": "2", "ALIQ_PIS": "2", "COD_ENTRADA_COFINS": "2", "COD_SAIDA_COFINS": "2", "ALIQ_COFINS": "2"}
|
||||
r = evaluate_product(row, SIMPLES, OFF)
|
||||
assert r["applicability"]["PIS_COFINS"] == "OPTIONAL" and kinds(r) == {"PIS_CST_INVALID", "COFINS_CST_INVALID"}
|
||||
assert "PIS_RATE_INVALID" not in kinds(r) # rate 2 is a valid number; only the CST codes are malformed
|
||||
|
||||
|
||||
def test_rate_rules_follow_the_cst():
|
||||
base = {**EMPTY, "PISCOFINS": "S", "INCIDENCIA_PISCOFINS": "NENHUMA"}
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "01", "COD_SAIDA_COFINS": "01"}, NORMAL, OFF)) == {"PIS_RATE_MISSING", "COFINS_RATE_MISSING"}
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "01", "ALIQ_PIS": "1,65", "COD_SAIDA_COFINS": "01", "ALIQ_COFINS": "7,6"}, NORMAL, OFF)) == set()
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "49", "COD_SAIDA_COFINS": "49"}, SIMPLES, OFF)) == set()
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "49", "ALIQ_PIS": "abc", "COD_SAIDA_COFINS": "49"}, SIMPLES, OFF)) == {"PIS_RATE_INVALID"}
|
||||
assert kinds(evaluate_product({**base, "COD_SAIDA_PIS": "49", "ALIQ_PIS": "150", "COD_SAIDA_COFINS": "49"}, SIMPLES, OFF)) == {"PIS_RATE_INVALID"}
|
||||
|
||||
|
||||
def test_inconsistent_flags_and_unknown_incidence():
|
||||
assert "PIS_COFINS_FLAG_INCONSISTENT" in kinds(evaluate_product({**EMPTY, "PISCOFINS": "N", "INCIDENCIA_PISCOFINS": "MONOFÁSICA", "COD_SAIDA_PIS": "04", "COD_SAIDA_COFINS": "04"}, SIMPLES, OFF))
|
||||
assert "PIS_COFINS_FLAG_INCONSISTENT" in kinds(evaluate_product({**EMPTY, "PISCOFINS": "S"}, SIMPLES, OFF))
|
||||
unknown = evaluate_product({**EMPTY, "INCIDENCIA_PISCOFINS": "OUTRA COISA"}, SIMPLES, OFF)
|
||||
assert unknown["needs_review"] and unknown["blockers"] == [] and unknown["applicability"]["PIS_COFINS"] == "NEEDS_REVIEW"
|
||||
|
||||
|
||||
def test_incidence_text_is_encoding_and_accent_tolerant():
|
||||
for text in ("MONOFÁSICA", "MONOFASICA", "MONOF�SICA", "monofásica"):
|
||||
assert incidence_kind(text) == "MONOFASICA"
|
||||
assert incidence_kind("SUBST.TRIBUTÁRIA") == "SUBSTITUICAO" and incidence_kind(None) == "NONE" and incidence_kind(" nenhuma ") == "NONE"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ONE definition of complete, ONE queue
|
||||
def test_blocker_categories_feed_the_single_dashboard_breakdown():
|
||||
assert {blocker_category(t) for t in ("PIS_CST_INVALID", "PIS_COFINS_FLAG_INCONSISTENT")} == {"PIS"} and blocker_category("COFINS_RATE_MISSING") == "COFINS"
|
||||
assert {blocker_category(t) for t in ("CCLASSTRIB_MISSING", "RTC_CST_MISSING", "RTC_CATALOG_INVALID")} == {"RTC"} and blocker_category("XYZ") == "OTHER"
|
||||
|
||||
|
||||
def test_unified_coverage_invariant_and_rtc_toggle_changes_only_rtc_products():
|
||||
products = [{**EMPTY, "CODIGO": "1"}, {**EMPTY, "CODIGO": "2", "COD_SAIDA_PIS": "2"}, {**EMPTY, "CODIGO": "3", "CCLASSTRIB": "200003"}, {**EMPTY, "CODIGO": "4", "INCIDENCIA_PISCOFINS": "???"}]
|
||||
|
||||
def run(rtc):
|
||||
states = [classify_product_state(p, {"status": "EXACT_NCM"}, fiscal=evaluate_product(p, SIMPLES, rtc)) for p in products]
|
||||
return summarize_operational_states(states)
|
||||
off, on = run(OFF), run(ON)
|
||||
for s in (off, on):
|
||||
assert s["TOTAL_PRODUCTS"] == s["COMPLETE_PRODUCTS"] + s["SAFE_BLOCKED"] + s["FAILED_TECHNICAL"] + s["UNCLASSIFIED"] == 4
|
||||
assert off["COMPLETE_PRODUCTS"] == 2 and off["BLOCKED_FISCAL_DATA"] == 1 and off["UNCLASSIFIED"] == 1 # RTC off: cClassTrib never counts
|
||||
assert on["COMPLETE_PRODUCTS"] == 1 and on["BLOCKED_FISCAL_DATA"] == 3 and on["UNCLASSIFIED"] == 0 # RTC on: only product 3 has a valid cClassTrib; a real blocker outranks NEEDS_REVIEW
|
||||
|
||||
|
||||
def test_fiscal_blockers_never_hide_or_override_a_decision_blocker():
|
||||
fiscal = evaluate_product({**EMPTY, "COD_SAIDA_PIS": "2"}, SIMPLES, OFF)
|
||||
state = classify_product_state({"CODIGO": "1"}, {"status": "INVALID_NCM"}, fiscal=fiscal)
|
||||
assert state["state"] == "BLOCKED_MISSING_EVIDENCE" # the NCM blocker stays the primary state
|
||||
assert classify_product_state({"CODIGO": "1"}, {"status": "EXACT_NCM"}, fiscal=None)["state"] == "COMPLETE"
|
||||
|
||||
|
||||
def test_unpadded_single_digit_cst_is_flagged_with_the_official_code_as_a_hint_only():
|
||||
row = {**EMPTY, "COD_SAIDA_PIS": "4", "COD_SAIDA_COFINS": "4"}
|
||||
r = evaluate_product(row, NORMAL, OFF)
|
||||
assert kinds(r) == {"PIS_CST_INVALID", "COFINS_CST_INVALID"} and any("UNPADDED_USE_04" in b["reason"] for b in r["blockers"])
|
||||
assert evaluate_product({**EMPTY, "COD_SAIDA_PIS": "049"}, NORMAL, OFF)["blockers"][0]["reason"] == "EXIT" # 3 digits: ambiguous, no hint
|
||||
@@ -194,3 +194,45 @@ def test_panel_controls_require_login():
|
||||
anon = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
for action in ("sync-now", "pause", "resume"):
|
||||
assert anon.post(f"/agents/{agent_id}/{action}", data={}, follow_redirects=False).status_code == 303 # bounced to login, nothing executed
|
||||
|
||||
|
||||
def _fiscal_blocker(code, field, reason, fp):
|
||||
return {"company": "", "product_code": code, "gtin": None, "description": "P" + code, "blocker_type": "BLOCKED_FISCAL_DATA", "field": field, "reason_code": reason, "evidence_summary": {}, "input_fingerprint": fp}
|
||||
|
||||
|
||||
def test_fiscal_blockers_share_the_single_queue_and_are_resolved_by_the_next_full_pass():
|
||||
"""PIS/COFINS/RTC blockers are ordinary blockers; when a later FULL pass no longer reports them (data fixed / RTC switched off) they close automatically."""
|
||||
from central.app.models import BlockedProduct
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
operational = {**OPERATIONAL, "COMPLETE": 519, "BLOCKED_MISSING_EVIDENCE": 0, "BLOCKED_CONFLICT": 0, "BLOCKED_FISCAL_DATA": 2, "SAFE_BLOCKED": 2, "TOTAL_PRODUCTS": 521}
|
||||
first = {"operational": operational, "blocked_products": [_fiscal_blocker("10", "PIS", "PIS_CST_INVALID", "a"), _fiscal_blocker("11", "RTC", "CCLASSTRIB_MISSING", "b")], "resolved_products": [],
|
||||
"fiscal_context": {"rtc_enabled": True, "evaluated_all": True}}
|
||||
finish(client, engine, first)
|
||||
with Session(engine) as s:
|
||||
rows = s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_(None))).all()
|
||||
assert sorted((r.product_code, r.field) for r in rows) == [("10", "PIS"), ("11", "RTC")]
|
||||
snap = s.scalars(select(AgentSnapshot)).one()
|
||||
assert (snap.fiscally_complete, snap.safe_blocked, snap.total_products) == (519, 2, 521)
|
||||
age_last_sync(engine, 4000)
|
||||
hb(client)
|
||||
second = {"operational": {**OPERATIONAL, "COMPLETE": 520, "BLOCKED_MISSING_EVIDENCE": 0, "BLOCKED_CONFLICT": 0, "BLOCKED_FISCAL_DATA": 1, "SAFE_BLOCKED": 1},
|
||||
"blocked_products": [_fiscal_blocker("10", "PIS", "PIS_CST_INVALID", "a")], "resolved_products": [], "fiscal_context": {"rtc_enabled": False, "evaluated_all": True}}
|
||||
finish(client, engine, second)
|
||||
with Session(engine) as s:
|
||||
open_rows = s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_(None))).all()
|
||||
assert [(r.product_code, r.field) for r in open_rows] == [("10", "PIS")] # RTC blocker closed because RTC is off now
|
||||
closed = s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_not(None))).all()
|
||||
assert [(r.product_code, r.resolution_source) for r in closed] == [("11", "AUTO_REEVALUATION")]
|
||||
|
||||
|
||||
def test_partial_reprocess_never_closes_fiscal_blockers_of_other_products():
|
||||
from central.app.models import BlockedProduct
|
||||
client, engine = _client()
|
||||
hb(client)
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [_fiscal_blocker("10", "PIS", "PIS_CST_INVALID", "a")], "resolved_products": [], "fiscal_context": {"evaluated_all": True}})
|
||||
age_last_sync(engine, 4000)
|
||||
hb(client)
|
||||
finish(client, engine, {"operational": OPERATIONAL, "blocked_products": [], "resolved_products": [], "fiscal_context": {"evaluated_all": False}})
|
||||
with Session(engine) as s:
|
||||
assert len(s.scalars(select(BlockedProduct).where(BlockedProduct.resolved_at.is_(None))).all()) == 1
|
||||
|
||||
Reference in New Issue
Block a user