- 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>
239 lines
13 KiB
Python
239 lines
13 KiB
Python
"""Zero-touch control plane: central scheduler (INITIAL_SYNC once, SYNC_INCREMENTAL
|
|
per interval), single-job concurrency, central pause/resume, coverage snapshot from
|
|
the job result, idempotency. Everything is Hub-side — no local toggle involved."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from central.app.jobs import claim_next_job, create_job, recover_stale_running
|
|
from central.app.models import Agent, AgentSnapshot, AuditEvent, Installation, Job
|
|
from central.tests.test_jobs import _client
|
|
|
|
H = {"Authorization": "Bearer test-token"}
|
|
INST = "00000000-0000-4000-8000-0000000000aa"
|
|
OPERATIONAL = {"COMPLETE": 480, "CORRECTED": 0, "BLOCKED_MISSING_EVIDENCE": 31, "BLOCKED_CONFLICT": 10, "BLOCKED_COMPANY_CONTEXT": 0, "BLOCKED_SCHEMA": 0,
|
|
"FAILED_TECHNICAL": 0, "TOTAL_PRODUCTS": 521, "SAFE_BLOCKED": 41}
|
|
|
|
|
|
def hb(client):
|
|
return client.post("/v1/agents/heartbeat", json={"installation_id": INST, "status": "IDLE", "agent_version": "1.1.3"}, headers=H)
|
|
|
|
|
|
def jobs(engine, kind=None):
|
|
with Session(engine) as s:
|
|
q = select(Job).order_by(Job.id)
|
|
if kind: q = q.where(Job.kind == kind)
|
|
return s.scalars(q).all()
|
|
|
|
|
|
def finish(client, engine, summary=None):
|
|
job = client.get("/v1/jobs/next", headers=H).json()
|
|
assert job, "expected a claimable job"
|
|
body = {"result_summary": summary or {"operational": OPERATIONAL, "products_processed": 521, "products_changed": 521, "blocked_products": [], "resolved_products": []}}
|
|
assert client.post(f"/v1/jobs/{job['job_id']}/complete", json=body, headers=H).status_code == 200
|
|
return job
|
|
|
|
|
|
def age_last_sync(engine, seconds):
|
|
with Session(engine) as s:
|
|
for job in s.scalars(select(Job)).all():
|
|
job.finished_at = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
|
job.created_at = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
|
s.commit()
|
|
|
|
|
|
def test_first_heartbeat_creates_exactly_one_initial_sync():
|
|
client, engine = _client()
|
|
body = hb(client).json()
|
|
assert body["processing_enabled"] is True and body["sync_interval_seconds"] == 1800
|
|
for _ in range(4):
|
|
hb(client)
|
|
initial = jobs(engine, "INITIAL_SYNC")
|
|
assert len(initial) == 1 and initial[0].status == "PENDING" and initial[0].payload["reason"] == "INITIAL_SYNC"
|
|
|
|
|
|
def test_initial_sync_completion_sets_coverage_and_invariant():
|
|
client, engine = _client()
|
|
hb(client); finish(client, engine)
|
|
with Session(engine) as s:
|
|
snap = s.scalar(select(AgentSnapshot)); inst = s.scalar(select(Installation))
|
|
assert (snap.total_products, snap.fiscally_complete, snap.safe_blocked, snap.failed_products) == (521, 480, 41, 0)
|
|
assert snap.fiscally_complete + snap.safe_blocked + snap.failed_products == snap.total_products # coverage invariant closes
|
|
assert snap.blockers == {"BLOCKED_MISSING_EVIDENCE": 31, "BLOCKED_CONFLICT": 10}
|
|
assert inst.last_sync_at is not None and inst.last_sync_changed == 521
|
|
for _ in range(3):
|
|
hb(client)
|
|
assert len(jobs(engine)) == 1 # no second initial sync, and not due for an incremental yet
|
|
|
|
|
|
def test_incremental_sync_scheduled_once_per_interval():
|
|
client, engine = _client()
|
|
hb(client); finish(client, engine)
|
|
age_last_sync(engine, 1801)
|
|
hb(client); hb(client); hb(client)
|
|
inc = jobs(engine, "SYNC_INCREMENTAL")
|
|
assert len(inc) == 1 and inc[0].payload["reason"] == "SCHEDULED"
|
|
assert not jobs(engine, "REPROCESS_ALL") # full reprocess never runs automatically
|
|
|
|
|
|
def test_central_pause_stops_scheduling_and_claiming_but_heartbeat_continues():
|
|
client, engine = _client()
|
|
hb(client)
|
|
with Session(engine) as s:
|
|
s.scalar(select(Installation)).processing_enabled = False; s.commit()
|
|
body = hb(client).json()
|
|
assert body["processing_enabled"] is False and body["enabled"] is True # heartbeat keeps working
|
|
assert client.get("/v1/jobs/next", headers=H).json() is None # the PENDING initial sync is NOT handed out while paused
|
|
with Session(engine) as s:
|
|
s.scalar(select(Installation)).processing_enabled = True; s.commit()
|
|
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "INITIAL_SYNC" # resume: jobs flow again
|
|
|
|
|
|
def test_only_one_job_runs_at_a_time_and_queue_is_fifo():
|
|
client, engine = _client()
|
|
hb(client)
|
|
with Session(engine) as s:
|
|
agent = s.scalar(select(Agent))
|
|
create_job(s, agent, "REPROCESS_PRODUCTS", {"codigos": ["1"]})
|
|
first = client.get("/v1/jobs/next", headers=H).json()
|
|
assert first["kind"] == "INITIAL_SYNC"
|
|
assert client.get("/v1/jobs/next", headers=H).json() is None # second job waits: one fiscal job per Firebird
|
|
client.post(f"/v1/jobs/{first['job_id']}/complete", json={"result_summary": {}}, headers=H)
|
|
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "REPROCESS_PRODUCTS"
|
|
|
|
|
|
def test_reprocess_all_and_sync_now_are_not_duplicated():
|
|
client, engine = _client()
|
|
hb(client); finish(client, engine)
|
|
with Session(engine) as s:
|
|
agent = s.scalar(select(Agent))
|
|
a = create_job(s, agent, "SYNC_INCREMENTAL", {"reason": "MANUAL_SYNC_NOW"}); b = create_job(s, agent, "SYNC_INCREMENTAL")
|
|
assert a.id == b.id # double click = one job
|
|
full1 = create_job(s, agent, "REPROCESS_ALL"); full2 = create_job(s, agent, "REPROCESS_ALL")
|
|
assert full1.id == full2.id and full1.id != a.id # a full reprocess still queues behind the incremental, never twice
|
|
assert create_job(s, agent, "SYNC_INCREMENTAL").id == a.id
|
|
|
|
|
|
def test_stale_running_job_does_not_block_the_queue_forever():
|
|
client, engine = _client()
|
|
hb(client)
|
|
job = client.get("/v1/jobs/next", headers=H).json()
|
|
with Session(engine) as s:
|
|
row = s.get(Job, job["job_id"]); row.started_at = datetime.now(timezone.utc) - timedelta(hours=2); s.commit()
|
|
create_job(s, s.scalar(select(Agent)), "REPROCESS_PRODUCTS", {"codigos": ["1"]})
|
|
assert client.get("/v1/jobs/next", headers=H).json()["kind"] == "REPROCESS_PRODUCTS"
|
|
with Session(engine) as s:
|
|
assert s.get(Job, job["job_id"]).status == "FAILED"
|
|
|
|
|
|
def test_job_events_recorded_without_secrets():
|
|
client, engine = _client()
|
|
hb(client); finish(client, engine)
|
|
with Session(engine) as s:
|
|
from central.app.models import AgentEvent
|
|
types = {e.event_type for e in s.scalars(select(AgentEvent)).all()}
|
|
assert {"HEARTBEAT", "JOB_STARTED", "JOB_COMPLETED"} <= types
|
|
|
|
|
|
def test_failed_job_records_failure_and_does_not_reschedule_immediately():
|
|
client, engine = _client()
|
|
hb(client)
|
|
job = client.get("/v1/jobs/next", headers=H).json()
|
|
client.post(f"/v1/jobs/{job['job_id']}/fail", json={"error": "Firebird offline"}, headers=H)
|
|
for _ in range(3):
|
|
hb(client)
|
|
assert len(jobs(engine)) == 1 # backoff = the interval; no error loop against a broken Firebird
|
|
with Session(engine) as s:
|
|
assert s.scalar(select(AgentSnapshot)).last_job_status == "FAILED"
|
|
|
|
|
|
# ---------- panel: central control buttons + status ----------
|
|
|
|
def _panel():
|
|
from central.tests.test_control_panel_pages import _panel_client
|
|
client, agent_id, _ = _panel_client()
|
|
return client, agent_id, client._session_factory
|
|
|
|
|
|
def test_panel_shows_processing_status_and_the_operational_buttons():
|
|
client, agent_id, _ = _panel()
|
|
body = client.get(f"/agents/{agent_id}").text
|
|
for label in ("Sincronizar agora", "Reprocessar tudo", "Pausar processamento", "Última sincronização", "Sincronização agendada"):
|
|
assert label in body
|
|
assert "Ativo" in body and "a cada 30 min" in body and "Retomar processamento" not in body
|
|
|
|
|
|
def test_panel_sync_now_pause_resume_are_csrf_protected_audited_and_idempotent():
|
|
client, agent_id, factory = _panel()
|
|
csrf = client.cookies.get("gti_csrf")
|
|
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": "wrong"}).status_code == 403
|
|
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303
|
|
assert client.post(f"/agents/{agent_id}/sync-now", data={"csrf_token": csrf}, follow_redirects=False).status_code == 303 # double click
|
|
with factory() as s:
|
|
incs = s.scalars(select(Job).where(Job.kind == "SYNC_INCREMENTAL")).all()
|
|
assert len(incs) == 1 and incs[0].payload["reason"] == "MANUAL_SYNC_NOW"
|
|
|
|
client.post(f"/agents/{agent_id}/pause", data={"csrf_token": csrf})
|
|
with factory() as s:
|
|
assert s.scalar(select(Installation).where(Installation.agent_id == agent_id)).processing_enabled is False
|
|
paused = client.get(f"/agents/{agent_id}").text
|
|
assert "Pausado" in paused and "Retomar processamento" in paused and "disabled" in paused # Sync-now disabled while paused
|
|
client.post(f"/agents/{agent_id}/resume", data={"csrf_token": csrf})
|
|
with factory() as s:
|
|
assert s.scalar(select(Installation).where(Installation.agent_id == agent_id)).processing_enabled is True
|
|
actions = [a.action for a in s.scalars(select(AuditEvent)).all()]
|
|
assert {"SYNC_NOW", "PROCESSING_PAUSE", "PROCESSING_RESUME"} <= set(actions)
|
|
|
|
|
|
def test_panel_controls_require_login():
|
|
from fastapi.testclient import TestClient
|
|
from central.app.main import create_app
|
|
from central.tests.test_control_plane import seed, setup_db
|
|
factory = setup_db(); agent_id, _ = seed(factory)
|
|
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
|