Files
clientflow_backend/scripts/probe_jasmin_print_layout_catalog.py

563 lines
22 KiB
Python

#!/usr/bin/env python3
"""probe_jasmin_print_layout_catalog
Probe Jasmin API for print-layout/report catalog endpoints.
Read-only by default. It searches likely Jasmin REST/OData endpoints for the
print model labels visible in the UI, e.g. "Fatura de Mercadorias" and
"Talão de Fatura". The goal is to discover whether the API exposes an internal
layout/report identifier that ClientFlow can use when printing invoices.
Optional --probe-print-post tests POST /billing/invoices/{id}/print with JSON
bodies containing the candidate labels. This should not create documents, but it
may mark the invoice as printed/reprinted in Jasmin, so it requires
--confirm-invoice.
"""
from __future__ import annotations
import argparse
import asyncio
import csv
import hashlib
import json
import re
from datetime import datetime
from pathlib import Path
from typing import Any
import httpx
from sqlalchemy import text
from app.db import engine
from app.jasmin_client import JasminClient
OUT_PREFIX = "/tmp/clientflow_jasmin_print_layout_catalog_probe"
POINT_TO_MM = 25.4 / 72.0
A4_PT = (595.0, 842.0)
DEFAULT_LABELS = [
"Fatura de Mercadorias",
"Fatura de Mercadorias (totais de linha sem imposto)",
"Fatura de Serviços",
"Fatura de Serviços (totais de linha sem imposto)",
"Talão de Fatura",
"Talão de Fatura com Dados de Entrega",
]
CATALOG_ENDPOINTS = [
# Metadata roots.
"/$metadata",
"/businessCore/$metadata",
"/billing/$metadata",
"/billingCore/$metadata",
"/sales/$metadata",
"/salesCore/$metadata",
"/platform/$metadata",
# Likely print/report catalogs.
"/businessCore/printLayouts",
"/businessCore/printLayouts/odata",
"/businessCore/printFormats",
"/businessCore/printFormats/odata",
"/businessCore/printSettings",
"/businessCore/printSettings/odata",
"/businessCore/printingSettings",
"/businessCore/printingSettings/odata",
"/businessCore/printingPreferences",
"/businessCore/printingPreferences/odata",
"/businessCore/reportSettings",
"/businessCore/reportSettings/odata",
"/businessCore/reportTemplates",
"/businessCore/reportTemplates/odata",
"/businessCore/reports",
"/businessCore/reports/odata",
"/businessCore/documentLayouts",
"/businessCore/documentLayouts/odata",
"/businessCore/documentPrintLayouts",
"/businessCore/documentPrintLayouts/odata",
"/businessCore/printLayoutSelections",
"/businessCore/printLayoutSelections/odata",
"/businessCore/reporting/printLayouts",
"/businessCore/reporting/printLayouts/odata",
"/businessCore/reporting/reports",
"/businessCore/reporting/reports/odata",
# Platform/reporting guesses.
"/platform/printLayouts",
"/platform/printLayouts/odata",
"/platform/reports",
"/platform/reports/odata",
"/platform/reportTemplates",
"/platform/reportTemplates/odata",
"/platform/reporting/printLayouts",
"/platform/reporting/printLayouts/odata",
# Billing-specific guesses.
"/billing/printLayouts",
"/billing/printLayouts/odata",
"/billing/reportTemplates",
"/billing/reportTemplates/odata",
"/billing/reports",
"/billing/reports/odata",
"/billing/invoicePrintLayouts",
"/billing/invoicePrintLayouts/odata",
"/billing/invoiceReports",
"/billing/invoiceReports/odata",
"/billing/documentPrintLayouts",
"/billing/documentPrintLayouts/odata",
"/billing/documentTypes",
"/billing/documentTypes/odata",
"/billing/invoiceTypes",
"/billing/invoiceTypes/odata",
"/billingCore/printLayouts",
"/billingCore/printLayouts/odata",
"/billingCore/reports",
"/billingCore/reports/odata",
"/billingCore/documentTypes",
"/billingCore/documentTypes/odata",
"/billingCore/invoiceTypes",
"/billingCore/invoiceTypes/odata",
# Sales catalogs may include report names seen in ORC payloads.
"/sales/printLayouts",
"/sales/printLayouts/odata",
"/sales/reports",
"/sales/reports/odata",
"/sales/documentPrintLayouts",
"/sales/documentPrintLayouts/odata",
"/salesCore/printLayouts",
"/salesCore/printLayouts/odata",
"/salesCore/reports",
"/salesCore/reports/odata",
]
def _s(value: Any) -> str:
return str(value or "").strip()
def _json_default(value: Any) -> str:
try:
return value.isoformat() # type: ignore[attr-defined]
except Exception:
return str(value)
def _looks_a4(w: float, h: float, tolerance_pt: float = 20.0) -> bool:
return any(abs(a - A4_PT[0]) <= tolerance_pt and abs(b - A4_PT[1]) <= tolerance_pt for a, b in ((w, h), (h, w)))
def _extract_pdf_boxes(data: bytes) -> list[dict[str, Any]]:
latin = data.decode("latin-1", errors="ignore")
pattern = re.compile(r"/(MediaBox|CropBox)\s*\[\s*([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s*\]", re.I)
out: list[dict[str, Any]] = []
seen: set[tuple[str, float, float, float, float]] = set()
for m in pattern.finditer(latin):
kind = m.group(1)
x0, y0, x1, y1 = (float(m.group(i)) for i in range(2, 6))
key = (kind.lower(), x0, y0, x1, y1)
if key in seen:
continue
seen.add(key)
w, h = abs(x1 - x0), abs(y1 - y0)
out.append({
"kind": kind,
"width_pt": round(w, 2),
"height_pt": round(h, 2),
"width_mm": round(w * POINT_TO_MM, 1),
"height_mm": round(h * POINT_TO_MM, 1),
"looks_a4": _looks_a4(w, h),
})
return out
def _pdf_info(data: bytes, content_type: str) -> dict[str, Any]:
info: dict[str, Any] = {
"content_type": content_type,
"bytes": len(data),
"sha256_12": hashlib.sha256(data).hexdigest()[:12] if data else "",
"is_pdf_header": data[:5] == b"%PDF-",
"boxes": [],
"format_warning": "",
}
if not data:
info["format_warning"] = "empty_response"
return info
if not info["is_pdf_header"]:
info["format_warning"] = "not_pdf_header"
info["body_preview"] = data[:500].decode("utf-8", errors="replace")
return info
boxes = _extract_pdf_boxes(data)
info["boxes"] = boxes
if boxes and not any(b.get("looks_a4") for b in boxes if _s(b.get("kind")).lower() == "mediabox"):
info["format_warning"] = "non_a4_or_unexpected_mediabox"
return info
def _box_summary(info: dict[str, Any]) -> str:
boxes = info.get("boxes") or []
if not boxes:
return f"content_type={info.get('content_type')} bytes={info.get('bytes')} warning={info.get('format_warning')}"
b = boxes[0]
return f"{b.get('kind')} {b.get('width_mm')}x{b.get('height_mm')}mm a4={b.get('looks_a4')} warning={info.get('format_warning') or ''}"
def _query_local_invoice(value: str) -> dict[str, Any] | None:
if not value:
return None
with engine.begin() as conn:
row = conn.execute(text("""
SELECT id::text AS local_doc_id, external_id, document_number, document_type,
serie, series_number, payload, created_at
FROM commercial_documents
WHERE system = 'jasmin'
AND document_kind = 'invoice'
AND (document_number = :v OR external_id = :v OR id::text = :v)
ORDER BY created_at DESC
LIMIT 1
"""), {"v": value}).mappings().first()
return dict(row) if row else None
async def _raw_request(
client: JasminClient,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: Any = None,
accept: str = "application/json",
) -> tuple[int, bytes, str, dict[str, str]]:
token = await client.get_token()
url = f"{client.api_root}/{path.lstrip('/')}"
headers = {"Authorization": f"Bearer {token}", "Accept": accept}
if json_body is not None:
headers["Content-Type"] = "application/json"
async with httpx.AsyncClient(timeout=client.timeout, follow_redirects=True) as hc:
response = await hc.request(method.upper(), url, params=params or {}, json=json_body, headers=headers)
return response.status_code, response.content, response.headers.get("content-type", ""), {
"allow": response.headers.get("allow", ""),
"content-disposition": response.headers.get("content-disposition", ""),
"location": response.headers.get("location", ""),
}
def _decode_json_or_text(data: bytes, content_type: str) -> tuple[Any, str]:
text_body = data.decode("utf-8", errors="replace")
if "json" in (content_type or "").lower() or text_body.strip().startswith(("{", "[")):
try:
return json.loads(text_body), text_body
except Exception:
pass
return None, text_body
def _flatten_strings(obj: Any, *, limit: int = 3000) -> list[str]:
out: list[str] = []
def walk(x: Any) -> None:
if len(out) >= limit:
return
if isinstance(x, dict):
for k, v in x.items():
if isinstance(k, str):
out.append(k)
walk(v)
elif isinstance(x, list):
for v in x:
walk(v)
elif isinstance(x, str):
out.append(x)
walk(obj)
return out
def _label_matches(obj: Any, text_body: str, labels: list[str]) -> list[str]:
haystack = "\n".join(_flatten_strings(obj)) if obj is not None else text_body
haystack_l = haystack.lower()
hits = []
for label in labels:
l = label.lower()
# Exact display label or relaxed key words.
if l in haystack_l:
hits.append(label)
return hits
def _extract_interesting_records(obj: Any, labels: list[str], *, max_records: int = 20) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
wanted = [x.lower() for x in labels] + ["fatura", "mercadorias", "serviços", "servicos", "talão", "talao", "invoice", "goods", "services", "receipt"]
def interesting_text(x: Any) -> bool:
if isinstance(x, dict):
txt = json.dumps(x, ensure_ascii=False, default=_json_default).lower()
else:
txt = _s(x).lower()
return any(w in txt for w in wanted)
def compact_record(d: dict[str, Any]) -> dict[str, Any]:
rx = re.compile(r"id|key|name|description|report|layout|print|template|document|serie|type|fiscal", re.I)
out = {k: v for k, v in d.items() if rx.search(str(k)) and not isinstance(v, (list, dict))}
if not out:
out = {k: v for k, v in list(d.items())[:12] if not isinstance(v, (list, dict))}
return out
def walk(x: Any) -> None:
if len(records) >= max_records:
return
if isinstance(x, dict):
if interesting_text(x):
records.append(compact_record(x))
for v in x.values():
walk(v)
elif isinstance(x, list):
for v in x:
walk(v)
walk(obj)
return records
def _endpoint_params(path: str) -> dict[str, Any]:
if "odata" in path.lower():
return {"$top": 100}
return {}
async def _probe_catalog(client: JasminClient, endpoints: list[str], labels: list[str], max_endpoints: int) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for i, path in enumerate(endpoints[:max_endpoints], start=1):
params = _endpoint_params(path)
try:
status, content, content_type, headers_subset = await _raw_request(client, "GET", path, params=params, accept="application/json")
obj, text_body = _decode_json_or_text(content, content_type)
hits = _label_matches(obj, text_body, labels)
records = _extract_interesting_records(obj, labels) if obj is not None and status < 500 else []
row = {
"path": path,
"method": "GET",
"params": params,
"status_code": status,
"content_type": content_type,
"bytes": len(content),
"headers_subset": headers_subset,
"label_hits": hits,
"interesting_records": records,
"body_preview": text_body[:1500] if (hits or status in {200, 400, 405}) else text_body[:300],
}
rows.append(row)
hit_txt = f" hits={hits}" if hits else ""
print(f"{i:02d}/{min(max_endpoints, len(endpoints))} GET {path}: status={status} bytes={len(content)}{hit_txt}")
except Exception as exc:
rows.append({"path": path, "method": "GET", "error": str(exc)})
print(f"{i:02d}/{min(max_endpoints, len(endpoints))} GET {path}: ERROR {exc}")
return rows
def _print_post_bodies(labels: list[str]) -> list[dict[str, Any]]:
out: list[dict[str, Any]] = []
keys = ["printLayout", "layout", "layoutKey", "template", "templateKey", "reportName", "printedReportName"]
for label in labels:
for key in keys:
out.append({"name": f"{key}={label}", "body": {key: label}})
return out
async def _probe_print_post(client: JasminClient, invoice_id: str, labels: list[str], save_dir: str, max_variants: int) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
out_dir = Path(save_dir) if save_dir else None
if out_dir:
out_dir.mkdir(parents=True, exist_ok=True)
variants = _print_post_bodies(labels)[:max_variants]
for i, v in enumerate(variants, start=1):
try:
status, content, content_type, headers_subset = await _raw_request(
client,
"POST",
f"/billing/invoices/{invoice_id}/print",
json_body=v["body"],
accept="application/pdf,application/json",
)
pdf = _pdf_info(content, content_type)
if out_dir and status < 400 and pdf.get("is_pdf_header"):
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", f"post_{i:02d}_{v['name']}").strip("_")
(out_dir / f"{safe}.pdf").write_bytes(content)
rows.append({
"name": v["name"],
"body": v["body"],
"status_code": status,
"content_type": content_type,
"headers_subset": headers_subset,
"pdf": pdf,
})
print(f"POST {i:02d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}")
except Exception as exc:
rows.append({"name": v["name"], "body": v["body"], "error": str(exc)})
print(f"POST {i:02d}/{len(variants)} {v['name']}: ERROR {exc}")
return rows
def _write_outputs(result: dict[str, Any]) -> None:
json_path = Path(OUT_PREFIX + ".json")
md_path = Path(OUT_PREFIX + ".md")
csv_path = Path(OUT_PREFIX + ".csv")
json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8")
endpoints = result.get("catalog_endpoints") or []
hits = [r for r in endpoints if r.get("label_hits")]
readable = [r for r in endpoints if int(r.get("status_code") or 0) == 200]
post_rows = result.get("print_post_probe") or []
post_ok = [r for r in post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")]
lines = [
"# Probe Jasmin print-layout/catalog API",
"",
f"- Resolved at: `{result.get('resolved_at')}`",
f"- Labels procurados: `{', '.join(result.get('labels') or [])}`",
f"- Endpoints testados: `{len(endpoints)}`",
f"- Endpoints 200: `{len(readable)}`",
f"- Endpoints com labels exatos: `{len(hits)}`",
"",
"## Resultado",
]
if hits:
lines.append("Foram encontrados labels visíveis em endpoints da API:")
for r in hits[:20]:
lines.append(f"- `{r.get('path')}` hits={r.get('label_hits')} status={r.get('status_code')}")
for rec in (r.get("interesting_records") or [])[:5]:
lines.append(f" - `{json.dumps(rec, ensure_ascii=False, default=_json_default)}`")
else:
lines.append("Não foi encontrado catálogo público/readable com estes labels exatos. Pode existir endpoint interno não exposto, ou o seletor da UI não estar disponível na API pública usada pelo ClientFlow.")
if post_rows:
lines.extend(["", "## POST /print com body JSON", ""])
if post_ok:
lines.append("Encontrada variante POST que devolve A4/sem warning:")
for r in post_ok[:10]:
lines.append(f"- `{r.get('name')}` body=`{r.get('body')}` → `{_box_summary(r.get('pdf') or {})}`")
else:
lines.append("Nenhuma variante POST /print testada devolveu A4.")
for r in post_rows[:12]:
lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`")
lines.extend(["", "## Endpoints 200/405 mais relevantes", ""])
for r in [x for x in endpoints if int(x.get("status_code") or 0) in {200, 400, 405}][:30]:
lines.append(f"- `{r.get('method')} {r.get('path')}` status={r.get('status_code')} type={r.get('content_type')} bytes={r.get('bytes')} hits={r.get('label_hits')}")
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
with csv_path.open("w", encoding="utf-8", newline="") as fh:
writer = csv.DictWriter(fh, fieldnames=["kind", "path_or_name", "status_code", "content_type", "bytes", "label_hits", "warning", "summary"])
writer.writeheader()
for r in endpoints:
writer.writerow({
"kind": "endpoint",
"path_or_name": r.get("path"),
"status_code": r.get("status_code"),
"content_type": r.get("content_type"),
"bytes": r.get("bytes"),
"label_hits": "; ".join(r.get("label_hits") or []),
"warning": "",
"summary": (r.get("body_preview") or "")[:300].replace("\n", " "),
})
for r in post_rows:
p = r.get("pdf") or {}
writer.writerow({
"kind": "print_post",
"path_or_name": r.get("name"),
"status_code": r.get("status_code"),
"content_type": p.get("content_type") or r.get("content_type"),
"bytes": p.get("bytes"),
"label_hits": "",
"warning": p.get("format_warning"),
"summary": _box_summary(p),
})
print(f"JSON: {json_path}")
print(f"Markdown: {md_path}")
print(f"CSV: {csv_path}")
async def _main_async(args: argparse.Namespace) -> int:
labels = [x.strip() for x in (args.labels.split("|") if args.labels else DEFAULT_LABELS) if x.strip()]
client = JasminClient()
local = _query_local_invoice(args.invoice) if args.invoice else None
invoice_id = _s(args.invoice_external_id or (local or {}).get("external_id") or args.invoice)
remote_invoice: dict[str, Any] = {}
if invoice_id:
try:
remote_raw = await client.get_invoice(invoice_id)
remote_invoice = {
"id": remote_raw.get("id"),
"naturalKey": remote_raw.get("naturalKey"),
"documentType": remote_raw.get("documentType"),
"serie": remote_raw.get("serie"),
"printedReportName": remote_raw.get("printedReportName"),
"printLayout": remote_raw.get("printLayout"),
"isPrinted": remote_raw.get("isPrinted"),
"isReprinted": remote_raw.get("isReprinted"),
}
invoice_id = _s(remote_invoice.get("id") or invoice_id)
except Exception as exc:
remote_invoice = {"fetch_error": str(exc)}
endpoints = list(dict.fromkeys(CATALOG_ENDPOINTS + [
*( [
f"/billing/invoices/{invoice_id}/printLayouts",
f"/billing/invoices/{invoice_id}/printOptions",
f"/billing/invoices/{invoice_id}/printSettings",
f"/billing/invoices/{invoice_id}/availableReports",
f"/billing/invoices/{invoice_id}/reports",
] if invoice_id else [] )
]))
catalog = await _probe_catalog(client, endpoints, labels, args.max_endpoints)
post_rows: list[dict[str, Any]] = []
if args.probe_print_post:
if not invoice_id:
raise SystemExit("--probe-print-post exige --invoice ou --invoice-external-id")
if args.confirm_invoice != args.invoice:
raise SystemExit("Para testar POST /print, passa --confirm-invoice igual ao valor de --invoice.")
post_rows = await _probe_print_post(client, invoice_id, labels, args.save_pdf_dir, args.max_print_post_variants)
hits = [r for r in catalog if r.get("label_hits")]
result = {
"resolved_at": datetime.utcnow().isoformat() + "Z",
"labels": labels,
"invoice_input": args.invoice,
"invoice_external_id": invoice_id,
"local_invoice": local or {},
"remote_invoice": remote_invoice,
"catalog_endpoints": catalog,
"print_post_probe": post_rows,
"recommendation": (
"Encontrado endpoint com labels/modelos de impressão. Usar os records no JSON para identificar a chave/id interna e fazer novo teste de impressão com esse id."
if hits else
"Não foi encontrado catálogo público com os labels. O seletor de modelo pode estar apenas na UI Jasmin ou noutro endpoint não documentado; manter bloqueio PDF não-A4 e corrigir configuração Jasmin, salvo se o suporte fornecer endpoint/id de layout."
),
}
print("SUMMARY")
print(f"catalog_endpoints={len(catalog)} label_hits={len(hits)} print_post_variants={len(post_rows)}")
print(result["recommendation"])
_write_outputs(result)
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="Probe Jasmin API for print-layout/report catalog endpoints.")
p.add_argument("--invoice", default="", help="Optional invoice natural key/local id/external id, e.g. FA.FA2026.137")
p.add_argument("--invoice-external-id", default="", help="Explicit Jasmin invoice GUID")
p.add_argument("--labels", default="|".join(DEFAULT_LABELS), help="Pipe-separated labels to search for")
p.add_argument("--max-endpoints", type=int, default=120)
p.add_argument("--probe-print-post", action="store_true", help="Also POST JSON bodies to /billing/invoices/{id}/print; requires --confirm-invoice")
p.add_argument("--confirm-invoice", default="", help="Must match --invoice for --probe-print-post")
p.add_argument("--max-print-post-variants", type=int, default=30)
p.add_argument("--save-pdf-dir", default="", help="Save PDFs returned by optional POST /print probe")
return p
def main() -> int:
return asyncio.run(_main_async(build_parser().parse_args()))
if __name__ == "__main__":
raise SystemExit(main())