551 lines
24 KiB
Python
551 lines
24 KiB
Python
#!/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())
|