#!/usr/bin/env python3 """probe_jasmin_invoice_print_template_models: test Jasmin invoice print with UI template model keys. The Jasmin UI route /reporting/templates/list?listname=templates shows template model keys such as Document, Slip, SlipWithShippingDetails and SummaryReport. Previous probes tested human labels and report names; this one tests the internal-looking model keys against /billing/invoices/{id}/print using GET query parameters by default, and can optionally test POST JSON bodies behind an explicit confirmation. Read-only by default: only GET /print and catalog/list routes are called. GET /print may mark a document as printed/reprinted in some ERPs, so use a test invoice when possible. POST /print is opt-in with --probe-post and --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 from urllib.parse import urlencode import httpx from sqlalchemy import text from app.db import engine from app.jasmin_client import JasminClient OUT_PREFIX = "/tmp/clientflow_jasmin_invoice_print_template_models_probe" POINT_TO_MM = 25.4 / 72.0 A4_PT = (595.0, 842.0) DEFAULT_MODELS = [ "Document", "DOCUMENT (1)", "Slip", "SlipWithShippingDetails", "SummaryReport", "List", ] PRINT_PARAM_KEYS = [ "model", "template", "templateKey", "templateModel", "printModel", "printTemplate", "printTemplateKey", "printTemplateModel", "layout", "layoutKey", "printLayout", "documentTemplate", "documentTemplateKey", "reportTemplate", "reportTemplateKey", "reportName", "printedReportName", ] CATALOG_API_PATHS = [ "/reporting/templates/list?listname=templates", "/reporting/templates/list", "/reporting/templates?listname=templates", "/reporting/templates", "/reporting/templates/odata?listname=templates", "/reporting/templates/odata", "/reporting/templateModels/list?listname=templates", "/reporting/templateModels", "/reporting/templates/Document", "/reporting/templates/Slip", "/reporting/templates/SlipWithShippingDetails", ] 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), "orientation": "landscape" if w > h else "portrait", "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[:800].decode("utf-8", errors="replace") return info boxes = _extract_pdf_boxes(data) info["boxes"] = boxes mediaboxes = [b for b in boxes if _s(b.get("kind")).lower() == "mediabox"] if mediaboxes and not any(b.get("looks_a4") for b in mediaboxes): 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 def _split_path_query(path: str) -> tuple[str, dict[str, str]]: if "?" not in path: return path, {} base, query = path.split("?", 1) params: dict[str, str] = {} for part in query.split("&"): if not part: continue if "=" in part: k, v = part.split("=", 1) else: k, v = part, "" params[k] = v return base, params async def _raw_request( client: JasminClient, method: str, path: str, *, params: dict[str, Any] | None = None, json_body: Any = None, accept: str = "application/json", scope: str = "api", ) -> tuple[int, bytes, str, dict[str, str], str]: token = await client.get_token() path_base, path_params = _split_path_query(path) merged = {**path_params, **(params or {})} if scope == "ui": url = f"{client.config.base_url.rstrip('/')}/{client.config.account}/{client.config.subscription}/{path_base.lstrip('/')}" else: url = f"{client.api_root}/{path_base.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=merged, 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", ""), }, str(response.url) 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 _search_hits(obj: Any, text_body: str, models: list[str]) -> list[str]: haystack = text_body if obj is not None: haystack = json.dumps(obj, ensure_ascii=False, default=_json_default) hl = haystack.lower() wanted = models + [ "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", ] return [x for x in wanted if x.lower() in hl] def _interesting_preview(obj: Any, text_body: str) -> str: if obj is None: return text_body[:1200] rx = re.compile(r"document|slip|summary|template|report|fatura|mercadorias|servi[cç]os|tal[aã]o|layout|print", re.I) records: list[Any] = [] def walk(x: Any) -> None: if len(records) >= 25: return if isinstance(x, dict): txt = json.dumps(x, ensure_ascii=False, default=_json_default) if rx.search(txt): compact = {k: v for k, v in x.items() if rx.search(str(k)) or (isinstance(v, str) and rx.search(v))} records.append(compact or {k: v for k, v in list(x.items())[:10] if not isinstance(v, (list, dict))}) for v in x.values(): walk(v) elif isinstance(x, list): for v in x: walk(v) walk(obj) if records: return json.dumps(records[:10], ensure_ascii=False, default=_json_default)[:2000] return json.dumps(obj, ensure_ascii=False, default=_json_default)[:1200] async def _probe_catalog_routes(client: JasminClient, models: list[str]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] route_specs: list[tuple[str, str]] = [] for p in CATALOG_API_PATHS: route_specs.append(("api", p)) # The exact browser route seen in the UI. This is likely a SPA route, but we test it because # it may still expose server-side JSON or useful hints in HTML/JS. route_specs.extend([ ("ui", "/reporting/templates/list?listname=templates"), ("ui", "/reporting/templates/list"), ("ui", "/reporting/templates/Document"), ("ui", "/reporting/templates/Slip"), ("ui", "/reporting/templates/SlipWithShippingDetails"), ]) for i, (scope, path) in enumerate(route_specs, start=1): try: status, content, content_type, headers, url = await _raw_request(client, "GET", path, accept="application/json,text/html", scope=scope) obj, text_body = _decode_json_or_text(content, content_type) hits = _search_hits(obj, text_body, models) row = { "scope": scope, "method": "GET", "path": path, "url": url, "status_code": status, "content_type": content_type, "bytes": len(content), "headers_subset": headers, "hits": hits, "preview": _interesting_preview(obj, text_body) 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"CAT {i:02d}/{len(route_specs)} {scope.upper()} {path}: status={status} bytes={len(content)}{hit_txt}") except Exception as exc: rows.append({"scope": scope, "method": "GET", "path": path, "error": str(exc)}) print(f"CAT {i:02d}/{len(route_specs)} {scope.upper()} {path}: ERROR {exc}") return rows def _get_variants(models: list[str]) -> list[dict[str, Any]]: variants: list[dict[str, Any]] = [] for model in models: for key in PRINT_PARAM_KEYS: variants.append({"name": f"GET {key}={model}", "params": {key: model}}) variants.append({"name": f"GET listname=templates&model={model}", "params": {"listname": "templates", "model": model}}) variants.append({"name": f"GET listname=templates&template={model}", "params": {"listname": "templates", "template": model}}) return variants def _post_variants(models: list[str]) -> list[dict[str, Any]]: keys = ["model", "template", "templateKey", "printModel", "printTemplate", "layout", "printLayout"] variants: list[dict[str, Any]] = [] for model in models: for key in keys: variants.append({"name": f"POST {key}={model}", "body": {key: model}}) variants.append({"name": f"POST model+listname={model}", "body": {"listname": "templates", "model": model}}) return variants async def _probe_print_gets(client: JasminClient, invoice_id: str, models: 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 = _get_variants(models)[:max_variants] for i, v in enumerate(variants, start=1): try: status, content, content_type, headers, url = await _raw_request( client, "GET", f"/billing/invoices/{invoice_id}/print", params=v["params"], 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_.-]+", "_", v["name"]).strip("_")[:120] (out_dir / f"get_{i:03d}_{safe}.pdf").write_bytes(content) rows.append({"name": v["name"], "params": v["params"], "status_code": status, "content_type": content_type, "headers_subset": headers, "url": url, "pdf": pdf}) ok = " OK_A4" if (pdf.get("is_pdf_header") and not pdf.get("format_warning")) else "" print(f"GET {i:03d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}{ok}") except Exception as exc: rows.append({"name": v["name"], "params": v["params"], "error": str(exc)}) print(f"GET {i:03d}/{len(variants)} {v['name']}: ERROR {exc}") return rows async def _probe_print_posts(client: JasminClient, invoice_id: str, models: 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 = _post_variants(models)[:max_variants] for i, v in enumerate(variants, start=1): try: status, content, content_type, headers, url = 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_.-]+", "_", v["name"]).strip("_")[:120] (out_dir / f"post_{i:03d}_{safe}.pdf").write_bytes(content) rows.append({"name": v["name"], "body": v["body"], "status_code": status, "content_type": content_type, "headers_subset": headers, "url": url, "pdf": pdf}) ok = " OK_A4" if (pdf.get("is_pdf_header") and not pdf.get("format_warning")) else "" print(f"POST {i:03d}/{len(variants)} {v['name']}: status={status} {_box_summary(pdf)}{ok}") except Exception as exc: rows.append({"name": v["name"], "body": v["body"], "error": str(exc)}) print(f"POST {i:03d}/{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") catalog = result.get("catalog_probe") or [] get_rows = result.get("print_get_probe") or [] post_rows = result.get("print_post_probe") or [] good = [r for r in get_rows + post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")] hits = [r for r in catalog if r.get("hits")] readable = [r for r in catalog if int(r.get("status_code") or 0) == 200] lines = [ "# Probe Jasmin invoice print template model keys", "", f"- Invoice input: `{result.get('invoice_input')}`", f"- Invoice external id: `{result.get('invoice_external_id')}`", f"- Model keys tested: `{', '.join(result.get('models') or [])}`", f"- Catalog routes 200: `{len(readable)}`", f"- Catalog/model hits: `{len(hits)}`", f"- Print GET variants: `{len(get_rows)}`", f"- Print POST variants: `{len(post_rows)}`", f"- A4/OK variants: `{len(good)}`", "", "## Recomendação", result.get("recommendation") or "", "", ] if good: lines.append("## Variantes que devolveram A4") for r in good[:20]: detail = r.get("params") or r.get("body") lines.append(f"- `{r.get('name')}` `{detail}` → `{_box_summary(r.get('pdf') or {})}`") lines.append("") if hits: lines.append("## Catalog routes com hits") for r in hits[:20]: lines.append(f"- `{r.get('scope')} {r.get('path')}` status={r.get('status_code')} hits={r.get('hits')}") prev = (r.get("preview") or "")[:700].replace("\n", " ") if prev: lines.append(f" - preview: `{prev}`") lines.append("") lines.append("## Primeiras variantes GET") for r in get_rows[:30]: lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`") if post_rows: lines.append("") lines.append("## Primeiras variantes POST") for r in post_rows[:30]: lines.append(f"- `{r.get('name')}` status={r.get('status_code')} `{_box_summary(r.get('pdf') or {})}`") 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", "name_or_path", "status_code", "content_type", "bytes", "warning", "a4", "hits", "summary"]) writer.writeheader() for r in catalog: writer.writerow({ "kind": "catalog", "name_or_path": f"{r.get('scope')} {r.get('path')}", "status_code": r.get("status_code"), "content_type": r.get("content_type"), "bytes": r.get("bytes"), "warning": r.get("error") or "", "a4": "", "hits": "; ".join(r.get("hits") or []), "summary": (r.get("preview") or "")[:300].replace("\n", " "), }) for kind, rows in (("print_get", get_rows), ("print_post", post_rows)): for r in rows: p = r.get("pdf") or {} boxes = p.get("boxes") or [] writer.writerow({ "kind": kind, "name_or_path": r.get("name"), "status_code": r.get("status_code"), "content_type": p.get("content_type") or r.get("content_type"), "bytes": p.get("bytes"), "warning": p.get("format_warning") or r.get("error") or "", "a4": any(b.get("looks_a4") for b in boxes), "hits": "", "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: models = [x.strip() for x in (args.models.split("|") if args.models else DEFAULT_MODELS) 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)} if not invoice_id: raise SystemExit("Indica --invoice ou --invoice-external-id") catalog_rows = [] if args.skip_catalog else await _probe_catalog_routes(client, models) get_rows = await _probe_print_gets(client, invoice_id, models, args.save_pdf_dir, args.max_get_variants) post_rows: list[dict[str, Any]] = [] if args.probe_post: if args.confirm_invoice != args.invoice: raise SystemExit("Para --probe-post passa --confirm-invoice igual ao valor de --invoice.") post_rows = await _probe_print_posts(client, invoice_id, models, args.save_pdf_dir, args.max_post_variants) good = [r for r in get_rows + post_rows if (r.get("pdf") or {}).get("is_pdf_header") and not (r.get("pdf") or {}).get("format_warning")] hits = [r for r in catalog_rows if r.get("hits")] recommendation = ( "Encontrada variante de impressão A4. Adaptar ClientFlow para usar os parâmetros/body indicados no JSON/Markdown." if good else "Os modelos visíveis na UI foram testados por chave interna (Document/Slip/SlipWithShippingDetails), mas nenhuma variante devolveu A4. A seleção de modelo parece depender da configuração Jasmin ou de endpoint interno não exposto. Mantém bloqueio de PDF não-A4 no ClientFlow e muda o modelo padrão da série/tipo FA no Jasmin." ) if hits and not good: recommendation += " Foram encontrados sinais de catálogo, mas ainda não houve parâmetro /print que selecionasse o modelo A4." result = { "resolved_at": datetime.utcnow().isoformat() + "Z", "invoice_input": args.invoice, "invoice_external_id": invoice_id, "models": models, "local_invoice": local or {}, "remote_invoice": remote_invoice, "catalog_probe": catalog_rows, "print_get_probe": get_rows, "print_post_probe": post_rows, "recommendation": recommendation, } print("SUMMARY") print(f"catalog_routes={len(catalog_rows)} catalog_hits={len(hits)} get_variants={len(get_rows)} post_variants={len(post_rows)} a4_ok={len(good)}") print(recommendation) _write_outputs(result) return 0 def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description="Probe Jasmin /print using UI template model keys such as Document and Slip.") p.add_argument("--invoice", default="", help="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("--models", default="|".join(DEFAULT_MODELS), help="Pipe-separated model keys to test") p.add_argument("--skip-catalog", action="store_true", help="Skip /reporting/templates catalog route probes") p.add_argument("--max-get-variants", type=int, default=140) p.add_argument("--probe-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-post") p.add_argument("--max-post-variants", type=int, default=60) p.add_argument("--save-pdf-dir", default="", help="Save PDFs returned by print probes") return p def main() -> int: return asyncio.run(_main_async(build_parser().parse_args())) if __name__ == "__main__": raise SystemExit(main())