Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
"""COPY-ONLY write E2E for the centrally controlled RTC auto-apply (C000025.CCLASSTRIB).
|
||||
|
||||
python scripts/rtc_copy_write_e2e.py
|
||||
|
||||
Runs ONLY against scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its own .fbk at the start, so every run is reproducible). It refuses any other path and every
|
||||
real database. Everything goes through the Agent's own code path `run_agent_service._rtc_auto_apply`, driven ONLY by the authenticated job `authorizations` (no env switches):
|
||||
0 restore + company -> regime normal (documented experiment on the copy); 1 whole-database baseline hashes;
|
||||
2 DISABLED (0 writes, no preview) / DRY_RUN (preview, 0 writes) / stale decision (RTC off between decision and write => 0 writes);
|
||||
3 APPROVAL_REQUIRED with 5 approved products (only those 5 written) / then ENABLED until nothing is left;
|
||||
4 reread, FK + CST integrity, coverage; 5 second run = 0 writes;
|
||||
6 rollback of 3 audit records (+1 conflict: value changed by an operator => refused); 7 reread, reapply, idempotency;
|
||||
8 invariants: NON_TARGET_COLUMNS_CHANGED, CATALOG_CHANGED, PARAMETERS_CHANGED, DOCUMENT_TABLES_CHANGED, OTHER_TABLES_CHANGED all 0.
|
||||
Writes go to the copy and nowhere else.
|
||||
"""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT)); sys.path.insert(0, str(ROOT / "scripts")); sys.path.insert(0, str(ROOT / "agent" / "src"))
|
||||
from dotenv import load_dotenv # noqa: E402
|
||||
|
||||
import fdb # noqa: E402
|
||||
import local_completion as lc # noqa: E402
|
||||
from central.app.operational_state import classify_product_state, summarize_operational_states # noqa: E402
|
||||
from gti_fiscal_agent import firebird_reader, unified_fiscal # noqa: E402
|
||||
|
||||
COPY = str((ROOT / "scratch_rtc_e2e" / "TESTESOFICIAIS_RTC_COPY.FDB").resolve())
|
||||
FBK = str((ROOT / "scratch_rtc_e2e" / "TESTESOFICIAIS_RTC_COPY.fbk").resolve())
|
||||
NCM_OK = set((ROOT / "docs" / "runs" / "ncm_valid_codes.txt").read_text().split())
|
||||
DOC_TABLE_HINTS = ("NFCE", "NFE", "NF_", "CUPOM", "VENDA", "CTE", "MDFE")
|
||||
|
||||
|
||||
def guard(path):
|
||||
canon = os.path.normcase(os.path.abspath(path))
|
||||
if canon != os.path.normcase(COPY):
|
||||
raise SystemExit(f"REFUSED: {path} is not the disposable copy")
|
||||
real = {os.path.normcase(os.path.abspath(p)) for p in lc.allowlist().values()}
|
||||
if canon in real or "scratch_rtc_e2e" not in canon:
|
||||
raise SystemExit("REFUSED: real database")
|
||||
|
||||
|
||||
def connect(rw):
|
||||
kw = dict(database=COPY, user=os.environ["FB_USER"], password=os.environ["FB_PASSWORD"], fb_library_name=os.environ.get("FIREBIRD_CLIENT_LIBRARY"))
|
||||
if not rw:
|
||||
kw["isolation_level"] = fdb.ISOLATION_LEVEL_READ_COMMITED_RO
|
||||
return fdb.connect(**kw)
|
||||
|
||||
|
||||
def load_service():
|
||||
spec = importlib.util.spec_from_file_location("run_agent_service", ROOT / "agent" / "scripts" / "run_agent_service.py")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def digits(v):
|
||||
return "".join(ch for ch in str(v or "") if ch.isdigit())
|
||||
|
||||
|
||||
def restore_copy():
|
||||
guard(COPY)
|
||||
gbak = Path(os.environ["FIREBIRD_CLIENT_LIBRARY"]).parent / "gbak.exe"
|
||||
done = subprocess.run([str(gbak), "-c", "-rep", "-n", "-user", os.environ["FB_USER"], "-pas", os.environ["FB_PASSWORD"], FBK, COPY], capture_output=True, text=True, timeout=600)
|
||||
if done.returncode != 0:
|
||||
raise SystemExit(f"restore failed: {done.stderr[-300:]}")
|
||||
|
||||
|
||||
def tables(cur):
|
||||
cur.execute("SELECT TRIM(RDB$RELATION_NAME) FROM RDB$RELATIONS WHERE COALESCE(RDB$SYSTEM_FLAG, 0) = 0 AND RDB$VIEW_BLR IS NULL AND RDB$EXTERNAL_FILE IS NULL ORDER BY 1")
|
||||
return [r[0] for r in cur.fetchall()]
|
||||
|
||||
|
||||
def table_hash(cur, table, skip_cols=()):
|
||||
cur.execute(f"SELECT * FROM {table}")
|
||||
cols = [d[0] for d in cur.description]
|
||||
keep = [i for i, c in enumerate(cols) if c not in skip_cols]
|
||||
digest = hashlib.sha256()
|
||||
rows = 0
|
||||
for row in cur.fetchall():
|
||||
digest.update(repr([row[i] for i in keep]).encode("utf-8", "replace"))
|
||||
rows += 1
|
||||
return f"{rows}:{digest.hexdigest()[:20]}"
|
||||
|
||||
|
||||
def baseline():
|
||||
conn = connect(False); cur = conn.cursor()
|
||||
out = {t: table_hash(cur, t, skip_cols=("CCLASSTRIB",) if t == "C000025" else ()) for t in tables(cur)}
|
||||
cur.execute("SELECT CODIGO, CCLASSTRIB FROM C000025")
|
||||
values = {str(r[0]).strip(): (r[1] or "").strip() for r in cur.fetchall()}
|
||||
conn.close()
|
||||
return out, values
|
||||
|
||||
|
||||
def diff_tables(before, after):
|
||||
return sorted(t for t in before if before[t] != after.get(t))
|
||||
|
||||
|
||||
def coverage(env, company, ncm_valid):
|
||||
conn = firebird_reader._connect(env)
|
||||
try:
|
||||
out = unified_fiscal.assess_from_connection(conn, company, ncm_valid=ncm_valid)
|
||||
finally:
|
||||
conn.close()
|
||||
products = firebird_reader.read_products(env)
|
||||
states = [classify_product_state(p, {"status": "EXACT_NCM"}, fiscal=out["findings"].get(str(p["CODIGO"]))) for p in products]
|
||||
s = summarize_operational_states(states)
|
||||
return out, products, {"total": s["TOTAL_PRODUCTS"], "complete": s["COMPLETE_PRODUCTS"], "blocked": s["SAFE_BLOCKED"], "unclassified": s["UNCLASSIFIED"], "coverage_percent": round(100 * s["COMPLETE_PRODUCTS"] / s["TOTAL_PRODUCTS"], 2)}
|
||||
|
||||
|
||||
def main():
|
||||
load_dotenv(ROOT / ".env") # credentials only when the script runs, never as an import side effect
|
||||
guard(COPY)
|
||||
service = load_service()
|
||||
restore_copy()
|
||||
env = {"FIREBIRD_DATABASE_PATH": COPY, "FB_USER": os.environ["FB_USER"], "FB_PASSWORD": os.environ["FB_PASSWORD"], "FIREBIRD_CLIENT_LIBRARY": os.environ.get("FIREBIRD_CLIENT_LIBRARY")}
|
||||
report = {"database": "scratch_rtc_e2e/TESTESOFICIAIS_RTC_COPY.FDB (restored from its .fbk at start)", "real_database_writes": 0, "real_database_write_attempts": 0, "mechanism": "job authorizations only (no env switches)"}
|
||||
rw = connect(True); cur = rw.cursor()
|
||||
cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004"); report["copy_original_crt"] = [r[0] for r in cur.fetchall()]
|
||||
cur.execute("UPDATE C000004 SET CRT = '3'"); rw.commit()
|
||||
cur.execute("SELECT DISTINCT TRIM(CRT) FROM C000004"); company = {"crt": cur.fetchone()[0]}
|
||||
cur.execute("SELECT USAR_REFORMA_TRIBUTARIA FROM PARAMETROS"); report["copy_rtc_param"] = cur.fetchone()[0]
|
||||
cur.execute("SELECT CODIGO, CLASSIFICACAO_FISCAL FROM C000025"); ncm_valid = {str(c).strip(): digits(n) in NCM_OK for c, n in cur.fetchall()}
|
||||
rw.commit(); rw.close()
|
||||
report["copy_crt_for_experiment"] = company["crt"]
|
||||
base, base_values = baseline()
|
||||
report["tables_hashed"] = len(base)
|
||||
config = SimpleNamespace(rtc_auto_apply_max_per_job=500, database_path=COPY) # NOTE: no firebird_writes, no rtc switch: the Agent has none
|
||||
|
||||
def run_job(mode, approved=(), rollbacks=(), job_id=1):
|
||||
out, products, _ = coverage(env, company, ncm_valid)
|
||||
job = {"job_id": job_id, "authorizations": {"rtc_write_mode": mode, "rtc_approved_codes": list(approved), "rtc_rollbacks": list(rollbacks)}}
|
||||
return service._rtc_auto_apply(env, config, job, products, out, company), out
|
||||
|
||||
_, _, cov_before = coverage(env, company, ncm_valid)
|
||||
report["coverage_before"] = cov_before
|
||||
# ---- 2. DISABLED / DRY_RUN / stale decision: zero writes
|
||||
disabled, _ = run_job("DISABLED")
|
||||
dry, out0 = run_job("DRY_RUN")
|
||||
report["disabled_run"] = {"write_attempts": disabled["write_attempts"], "preview": len(disabled["preview"]), "ready_total": disabled["ready_total"]}
|
||||
report["dry_run"] = {"write_attempts": dry["write_attempts"], "preview": len(dry["preview"]), "ready_total": dry["ready_total"], "rules": sorted({c["rule_id"] for c in dry["preview"]}), "sample": dry["preview"][:3]}
|
||||
report["resolution_before"] = {k: v for k, v in out0["context"]["rtc_resolution"].items() if k != "by_reason"}
|
||||
assert base == baseline()[0], "DISABLED/DRY_RUN wrote something"
|
||||
rw = connect(True); c = rw.cursor(); c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 0"); rw.commit()
|
||||
stale, _ = run_job("ENABLED")
|
||||
c.execute("UPDATE PARAMETROS SET USAR_REFORMA_TRIBUTARIA = 1"); rw.commit(); rw.close()
|
||||
report["stale_decision_run"] = {"write_attempts": stale["write_attempts"], "write_successes": stale["write_successes"]}
|
||||
# ---- 3. APPROVAL_REQUIRED (5 approved) then ENABLED
|
||||
preview_codes = [c["product_code"] for c in dry["preview"]]
|
||||
approved = preview_codes[:5]
|
||||
unapproved = run_job("APPROVAL_REQUIRED", approved=[])[0]
|
||||
part, _ = run_job("APPROVAL_REQUIRED", approved=approved, job_id=2)
|
||||
report["approval_run"] = {"without_approval_attempts": unapproved["write_attempts"], "approved": len(approved), "write_successes": part["write_successes"], "only_approved": sorted(a["product_code"] for a in part["applied"]) == sorted(approved)}
|
||||
applied = list(part["applied"])
|
||||
jobs = 0
|
||||
while True:
|
||||
jobs += 1
|
||||
step, _ = run_job("ENABLED", job_id=100 + jobs)
|
||||
applied += step["applied"]
|
||||
report.setdefault("enabled_jobs", []).append({"write_attempts": step["write_attempts"], "write_successes": step["write_successes"], "aborted": step["aborted_reasons"]})
|
||||
if not step["write_attempts"] or jobs >= 200:
|
||||
break
|
||||
report["total_applied"] = len(applied)
|
||||
report["audit_sample"] = applied[:2]
|
||||
report["audit_fields_complete"] = all(a.get(k) is not None for a in applied for k in ("rule_id", "rule_version", "company_crt", "authorization_origin", "description_snapshot", "timestamp", "job_id", "local_catalog_hash", "official_source_version"))
|
||||
# ---- 4. reread + integrity + coverage
|
||||
mid, mid_values = baseline()
|
||||
report["rows_changed"] = sum(1 for k, v in mid_values.items() if base_values.get(k) != v)
|
||||
ro = connect(False); cur = ro.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM C000025 p WHERE p.CCLASSTRIB IS NOT NULL AND NOT EXISTS (SELECT 1 FROM RTC_CCLASSTRIB c WHERE c.CCLASSTRIB = p.CCLASSTRIB)"); report["fk_orphans"] = cur.fetchone()[0]
|
||||
cur.execute("SELECT COUNT(*) FROM C000025 p JOIN RTC_CCLASSTRIB c ON c.CCLASSTRIB = p.CCLASSTRIB WHERE NOT EXISTS (SELECT 1 FROM RTC_CST s WHERE s.CSTIBSCBS = c.CSTIBSCBS)"); report["cst_orphans"] = cur.fetchone()[0]
|
||||
ro.close()
|
||||
_, _, cov_after = coverage(env, company, ncm_valid)
|
||||
report["coverage_after"] = cov_after
|
||||
# ---- 5. second run
|
||||
second, _ = run_job("ENABLED", job_id=300)
|
||||
report["second_run"] = {"write_attempts": second["write_attempts"], "write_successes": second["write_successes"], "ready_total": second["ready_total"]}
|
||||
# ---- 6. rollback of 3 records + 1 conflict
|
||||
print(json.dumps({k: report[k] for k in ("disabled_run","dry_run","stale_decision_run","approval_run","enabled_jobs","coverage_before","resolution_before")}, default=str)[:2500]) if len(applied) < 4 else None
|
||||
victims = applied[:4]
|
||||
rw = connect(True); c = rw.cursor(); c.execute("UPDATE C000025 SET CCLASSTRIB = '200035' WHERE CODIGO = ?", (victims[3]["product_code"],)); rw.commit(); rw.close() # an operator changed it afterwards
|
||||
requests = [{"audit_id": 900 + i, "product_code": v["product_code"], "written_value": v["new_value"], "rule_id": v["rule_id"]} for i, v in enumerate(victims)]
|
||||
roll, _ = run_job("DISABLED", rollbacks=requests, job_id=400) # rollback needs the central request, not a write mode
|
||||
outcomes = {r["product_code"]: (r["outcome"], r.get("reason")) for r in roll["rollbacks"]}
|
||||
report["rollback"] = {"rolled_back": sum(1 for o, _ in outcomes.values() if o == "ROLLED_BACK"), "conflict": outcomes[victims[3]["product_code"]], "records": [outcomes[v["product_code"]][0] for v in victims]}
|
||||
ro = connect(False); cur = ro.cursor()
|
||||
cur.execute("SELECT CODIGO, CCLASSTRIB FROM C000025 WHERE CODIGO IN (%s)" % ",".join("?" * 4), tuple(v["product_code"] for v in victims))
|
||||
after_roll = {str(r[0]).strip(): (r[1] or "").strip() for r in cur.fetchall()}
|
||||
ro.close()
|
||||
report["rollback"]["reread"] = {k: v for k, v in after_roll.items()}
|
||||
# ---- 7. reapply + idempotency
|
||||
again, _ = run_job("ENABLED", job_id=500)
|
||||
report["reapply"] = {"write_successes": again["write_successes"], "codes": sorted(a["product_code"] for a in again["applied"]), "expected": sorted(v["product_code"] for v in victims[:3])}
|
||||
third, _ = run_job("ENABLED", job_id=501)
|
||||
report["idempotency_after_reapply"] = {"write_attempts": third["write_attempts"], "write_successes": third["write_successes"]}
|
||||
# ---- 8. invariants
|
||||
final, final_values = baseline()
|
||||
changed_tables = diff_tables(base, final)
|
||||
report["NON_TARGET_COLUMNS_CHANGED"] = 1 if "C000025" in changed_tables else 0
|
||||
report["CATALOG_CHANGED"] = len([t for t in changed_tables if t.startswith("RTC_")])
|
||||
report["PARAMETERS_CHANGED"] = len([t for t in changed_tables if t == "PARAMETROS"])
|
||||
report["DOCUMENT_TABLES_CHANGED"] = len([t for t in changed_tables if any(h in t for h in DOC_TABLE_HINTS)])
|
||||
report["OTHER_TABLES_CHANGED"] = len(changed_tables)
|
||||
report["changed_tables"] = changed_tables
|
||||
report["IDEMPOTENT"] = second["write_attempts"] == 0 and third["write_attempts"] == 0
|
||||
report["PASS"] = bool(report["IDEMPOTENT"] and report["fk_orphans"] == 0 and report["cst_orphans"] == 0 and not changed_tables and report["total_applied"] > 5
|
||||
and report["disabled_run"]["write_attempts"] == 0 and report["dry_run"]["write_attempts"] == 0 and report["dry_run"]["rules"] == ["RTC-R3"]
|
||||
and report["stale_decision_run"]["write_successes"] == 0 and report["approval_run"]["without_approval_attempts"] == 0 and report["approval_run"]["only_approved"]
|
||||
and report["rows_changed"] == report["total_applied"] and report["rollback"]["rolled_back"] == 3 and report["rollback"]["conflict"][0] == "ABORTED"
|
||||
and report["rollback"]["reread"][victims[3]["product_code"]] == "200035" and all(report["rollback"]["reread"][v["product_code"]] == "" for v in victims[:3])
|
||||
and report["reapply"]["codes"] == report["reapply"]["expected"] and report["audit_fields_complete"])
|
||||
(ROOT / "docs" / "runs" / "rtc_copy_write_e2e.json").write_text(json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8")
|
||||
print(json.dumps({k: report[k] for k in report if k not in ("audit_sample", "coverage_before", "resolution_before")}, ensure_ascii=False, default=str)[:3500])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user