#!/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())