Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
522
scripts/probe_jasmin_quotation_invoice_api.py
Normal file
522
scripts/probe_jasmin_quotation_invoice_api.py
Normal file
@@ -0,0 +1,522 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Probe Jasmin quotation -> invoice API using a specific quotation.
|
||||
|
||||
Safe by default: it only reads local DB/Jasmin, fetches PDFs, and prints a
|
||||
recommended experiment plan. It will only create an invoice in Jasmin when the
|
||||
operator passes --execute-convert plus --confirm-document matching the quotation.
|
||||
|
||||
Use case: understand whether fromQuotation can be parameterized so ClientFlow can
|
||||
convert ORC -> FA without using the Jasmin UI and still obtain an A4 invoice PDF.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import settings
|
||||
from app.db import engine
|
||||
from app.jasmin_client import JasminClient, JasminError
|
||||
|
||||
A4_PORTRAIT_PT = (595.0, 842.0)
|
||||
POINT_TO_MM = 25.4 / 72.0
|
||||
OUT_PREFIX = "/tmp/clientflow_jasmin_quotation_invoice_api_probe"
|
||||
|
||||
|
||||
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 _payload(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(value)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _first(data: dict[str, Any], keys: Iterable[str]) -> Any:
|
||||
for key in keys:
|
||||
if data.get(key) not in (None, ""):
|
||||
return data.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _money(value: Any) -> str:
|
||||
if isinstance(value, dict):
|
||||
for k in ("amount", "value", "baseAmount", "reportingAmount"):
|
||||
if value.get(k) is not None:
|
||||
return _s(value.get(k))
|
||||
return _s(value)
|
||||
|
||||
|
||||
def _interesting_fields(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
rx = re.compile(r"print|report|layout|template|format|paper|serie|series|type|fiscal|operation", re.I)
|
||||
return {k: raw.get(k) for k in sorted(raw.keys()) if rx.search(k)}
|
||||
|
||||
|
||||
def _selected_doc_fields(raw: Any) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
return {"raw_type": type(raw).__name__, "raw_preview": _s(raw)[:500]}
|
||||
return {
|
||||
"id": _first(raw, ["id", "key"]),
|
||||
"naturalKey": raw.get("naturalKey"),
|
||||
"documentNumber": raw.get("documentNumber"),
|
||||
"documentType": _first(raw, ["documentType", "documentTypeKey"]),
|
||||
"documentTypeDescription": raw.get("documentTypeDescription"),
|
||||
"serie": _first(raw, ["serie", "serieKey"]),
|
||||
"serieDescription": raw.get("serieDescription"),
|
||||
"seriesNumber": _first(raw, ["seriesNumber", "number"]),
|
||||
"company": _first(raw, ["company", "companyKey"]),
|
||||
"documentDate": raw.get("documentDate"),
|
||||
"postingDate": raw.get("postingDate"),
|
||||
"currency": _first(raw, ["currency", "currencyKey"]),
|
||||
"totalAmount": _money(raw.get("totalAmount")),
|
||||
"payableAmount": _money(raw.get("payableAmount")),
|
||||
"taxExclusiveAmount": _money(raw.get("taxExclusiveAmount")),
|
||||
"buyerCustomerParty": _first(raw, ["buyerCustomerParty", "buyerCustomerPartyKey"]),
|
||||
"buyerCustomerPartyName": raw.get("buyerCustomerPartyName"),
|
||||
"paymentTerm": _first(raw, ["paymentTerm", "paymentTermKey"]),
|
||||
"paymentMethod": _first(raw, ["paymentMethod", "paymentMethodKey"]),
|
||||
"priceList": _first(raw, ["priceList", "priceListKey"]),
|
||||
"printLayout": _first(raw, ["printLayout", "report", "reportName", "reportKey", "documentReport", "printTemplate"]),
|
||||
"printedReportName": raw.get("printedReportName"),
|
||||
"isPrinted": raw.get("isPrinted"),
|
||||
"isReprinted": raw.get("isReprinted"),
|
||||
"operationType": raw.get("operationType"),
|
||||
"fiscalDocumentType": raw.get("fiscalDocumentType"),
|
||||
"line_count": len(raw.get("documentLines") or raw.get("lines") or []) if isinstance(raw.get("documentLines") or raw.get("lines") or [], list) else None,
|
||||
"interesting_fields": _interesting_fields(raw),
|
||||
}
|
||||
|
||||
|
||||
def _looks_a4(w: float, h: float, tolerance_pt: float = 20.0) -> bool:
|
||||
pairs = [(w, h), (h, w)]
|
||||
return any(abs(a - A4_PORTRAIT_PT[0]) <= tolerance_pt and abs(b - A4_PORTRAIT_PT[1]) <= tolerance_pt for a, b in pairs)
|
||||
|
||||
|
||||
def _extract_pdf_boxes(data: bytes) -> list[dict[str, Any]]:
|
||||
latin = data.decode("latin-1", errors="ignore")
|
||||
boxes: list[dict[str, Any]] = []
|
||||
pattern = re.compile(r"/(MediaBox|CropBox)\s*\[\s*([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s+([-+0-9.]+)\s*\]", re.I)
|
||||
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 = abs(x1 - x0)
|
||||
h = abs(y1 - y0)
|
||||
boxes.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 boxes
|
||||
|
||||
|
||||
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-",
|
||||
"page_count_hint": None,
|
||||
"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[:300].decode("utf-8", errors="replace")
|
||||
return info
|
||||
latin = data.decode("latin-1", errors="ignore")
|
||||
info["page_count_hint"] = len(re.findall(r"/Type\s*/Page(?!s)\b", latin)) or None
|
||||
boxes = _extract_pdf_boxes(data)
|
||||
info["boxes"] = boxes
|
||||
if boxes and not any(bool(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 _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")
|
||||
|
||||
q = result.get("quotation") or {}
|
||||
qp = result.get("quotation_pdf") or {}
|
||||
linked = result.get("linked_invoice") or {}
|
||||
cp = result.get("conversion_probe") or {}
|
||||
lines = [
|
||||
"# Probe Jasmin ORC → FA via API",
|
||||
"",
|
||||
f"- Quotation: `{q.get('local_number') or q.get('remote', {}).get('naturalKey') or result.get('requested_quotation')}`",
|
||||
f"- External ID: `{q.get('external_id') or q.get('remote', {}).get('id')}`",
|
||||
f"- Quotation PDF: `{_pdf_box_summary(qp)}`",
|
||||
f"- Linked invoice: `{linked.get('invoice_number') or '—'}`",
|
||||
f"- API fromQuotation OPTIONS/POST surface: `{cp.get('summary') or 'ver JSON'}`",
|
||||
"",
|
||||
"## Recomendação",
|
||||
result.get("recommendation") or "Sem recomendação automática.",
|
||||
"",
|
||||
"## Body candidates",
|
||||
"```json",
|
||||
json.dumps(result.get("body_candidates") or {}, ensure_ascii=False, indent=2, default=_json_default),
|
||||
"```",
|
||||
]
|
||||
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=[
|
||||
"quotation", "quotation_external_id", "quotation_pdf", "linked_invoice", "linked_invoice_pdf",
|
||||
"recommendation_short",
|
||||
])
|
||||
writer.writeheader()
|
||||
writer.writerow({
|
||||
"quotation": q.get("local_number") or result.get("requested_quotation"),
|
||||
"quotation_external_id": q.get("external_id") or q.get("remote", {}).get("id"),
|
||||
"quotation_pdf": _pdf_box_summary(qp),
|
||||
"linked_invoice": linked.get("invoice_number") or "",
|
||||
"linked_invoice_pdf": _pdf_box_summary(linked.get("invoice_pdf") or {}),
|
||||
"recommendation_short": _s(result.get("recommendation")).split("\n")[0][:300],
|
||||
})
|
||||
print(f"JSON: {json_path}")
|
||||
print(f"Markdown: {md_path}")
|
||||
print(f"CSV: {csv_path}")
|
||||
|
||||
|
||||
def _pdf_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')}"
|
||||
box = boxes[0]
|
||||
return f"{box.get('kind')} {box.get('width_mm')}x{box.get('height_mm')}mm a4={box.get('looks_a4')} warning={info.get('format_warning') or ''}"
|
||||
|
||||
|
||||
def _parse_orc_number(value: str) -> dict[str, Any]:
|
||||
m = re.match(r"^([A-Z]+)\.([A-Z]+\d{4})\.(\d+)$", value.strip(), re.I)
|
||||
if not m:
|
||||
return {}
|
||||
return {"document_type": m.group(1).upper(), "serie": m.group(2).upper(), "series_number": int(m.group(3))}
|
||||
|
||||
|
||||
def _query_local_quotation(number_or_id: str) -> dict[str, Any] | None:
|
||||
with engine.begin() as conn:
|
||||
row = conn.execute(text("""
|
||||
SELECT
|
||||
q.id::text AS local_doc_id,
|
||||
q.opportunity_id::text AS opportunity_id,
|
||||
q.external_id,
|
||||
q.document_number,
|
||||
q.document_type,
|
||||
q.serie,
|
||||
q.series_number,
|
||||
q.status,
|
||||
q.total_amount,
|
||||
q.payload,
|
||||
q.created_at,
|
||||
o.title AS opportunity_title,
|
||||
o.customer_name AS opportunity_customer_name,
|
||||
c.name AS fiscal_customer_name,
|
||||
c.tax_id AS fiscal_customer_tax_id,
|
||||
inv.id::text AS linked_invoice_doc_id,
|
||||
inv.external_id AS linked_invoice_external_id,
|
||||
inv.document_number AS linked_invoice_number
|
||||
FROM commercial_documents q
|
||||
LEFT JOIN commercial_documents inv ON inv.parent_document_id = q.id AND inv.document_kind = 'invoice'
|
||||
LEFT JOIN opportunities o ON o.id = q.opportunity_id
|
||||
LEFT JOIN customers c ON c.id = q.customer_id
|
||||
WHERE q.system = 'jasmin'
|
||||
AND q.document_kind = 'quotation'
|
||||
AND (q.document_number = :key OR q.external_id = :key OR q.id::text = :key)
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT 1
|
||||
"""), {"key": number_or_id}).mappings().first()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
async def _resolve_remote_quotation(client: JasminClient, key: str, local: dict[str, Any] | None) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
||||
if local and _s(local.get("external_id")):
|
||||
qid = _s(local.get("external_id"))
|
||||
return qid, await client.get_quotation(qid), {"source": "local_external_id"}
|
||||
|
||||
parsed = _parse_orc_number(key)
|
||||
filters = [f"naturalKey eq '{key}'"]
|
||||
if parsed:
|
||||
filters.append(
|
||||
"documentType eq '{document_type}' and serie eq '{serie}' and seriesNumber eq {series_number}".format(**parsed)
|
||||
)
|
||||
errors: list[str] = []
|
||||
for flt in filters:
|
||||
try:
|
||||
data = await client.list_quotations(top=5, filter=flt, orderby="documentDate desc")
|
||||
values = data.get("value") if isinstance(data, dict) else []
|
||||
if values:
|
||||
remote = values[0]
|
||||
qid = _s(remote.get("id") or remote.get("key"))
|
||||
if qid:
|
||||
return qid, await client.get_quotation(qid), {"source": "odata", "filter": flt, "odata_hit": _selected_doc_fields(remote)}
|
||||
except Exception as exc:
|
||||
errors.append(f"{flt}: {exc}")
|
||||
raise JasminError(f"Não consegui resolver orçamento remoto {key}. Erros OData: {errors}")
|
||||
|
||||
|
||||
async def _raw_request_with_meta(client: JasminClient, method: str, path: str, *, params: dict[str, Any] | None = None, json_body: Any = None, accept: str = "application/json") -> dict[str, Any]:
|
||||
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 http:
|
||||
resp = await http.request(method.upper(), url, params=params, json=json_body, headers=headers)
|
||||
content_type = resp.headers.get("content-type", "")
|
||||
body: Any
|
||||
try:
|
||||
body = resp.json()
|
||||
except Exception:
|
||||
body = resp.text[:2000]
|
||||
return {
|
||||
"method": method.upper(),
|
||||
"path": path,
|
||||
"params": params or {},
|
||||
"status_code": resp.status_code,
|
||||
"content_type": content_type,
|
||||
"headers_subset": {k: v for k, v in resp.headers.items() if k.lower() in {"allow", "content-type", "location"}},
|
||||
"body_preview": body,
|
||||
}
|
||||
|
||||
|
||||
def _body_candidates(remote_quote: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
|
||||
company = _first(remote_quote, ["company", "companyKey"]) or settings.jasmin_company_key
|
||||
document_date = args.document_date or date.today().isoformat()
|
||||
invoice_type = args.invoice_type
|
||||
invoice_serie = args.invoice_serie
|
||||
base = {
|
||||
"documentType": invoice_type,
|
||||
"serie": invoice_serie,
|
||||
"company": company,
|
||||
"documentDate": document_date,
|
||||
"postingDate": document_date,
|
||||
}
|
||||
inherited_print: dict[str, Any] = {}
|
||||
for key in ("printLayout", "printedReportName", "printAllDiscounts", "printAllPaymentMethods"):
|
||||
if remote_quote.get(key) not in (None, ""):
|
||||
inherited_print[key] = remote_quote.get(key)
|
||||
if args.print_layout:
|
||||
inherited_print["printLayout"] = args.print_layout
|
||||
if args.printed_report_name:
|
||||
inherited_print["printedReportName"] = args.printed_report_name
|
||||
return {
|
||||
"current_empty": {},
|
||||
"explicit_invoice_type_serie": base,
|
||||
"explicit_invoice_type_serie_plus_print_fields": {**base, **inherited_print},
|
||||
"notes": [
|
||||
"Não executar todos os candidatos no mesmo orçamento: cada POST pode criar uma fatura real.",
|
||||
"Se o endpoint ignorar estes campos, a correção tem de ser no print endpoint ou na configuração Jasmin da série FA.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _body_for_mode(candidates: dict[str, Any], args: argparse.Namespace) -> dict[str, Any]:
|
||||
if args.body_json:
|
||||
parsed = json.loads(args.body_json)
|
||||
if not isinstance(parsed, dict):
|
||||
raise SystemExit("--body-json tem de ser um objeto JSON")
|
||||
return parsed
|
||||
key_map = {
|
||||
"empty": "current_empty",
|
||||
"explicit-fa": "explicit_invoice_type_serie",
|
||||
"explicit-fa-print": "explicit_invoice_type_serie_plus_print_fields",
|
||||
}
|
||||
return dict(candidates.get(key_map[args.body_mode]) or {})
|
||||
|
||||
|
||||
async def _fetch_and_save_pdf(client: JasminClient, kind: str, external_id: str, save_dir: Path | None, label: str) -> dict[str, Any]:
|
||||
if kind == "quotation":
|
||||
data, content_type = await client.print_quotation_pdf(external_id)
|
||||
elif kind == "invoice":
|
||||
data, content_type = await client.print_invoice_pdf(external_id)
|
||||
else:
|
||||
raise ValueError(kind)
|
||||
if save_dir:
|
||||
save_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", label).strip("_") or external_id
|
||||
(save_dir / f"{safe}.pdf").write_bytes(data)
|
||||
return _pdf_info(data, content_type)
|
||||
|
||||
|
||||
async def _main_async(args: argparse.Namespace) -> int:
|
||||
client = JasminClient()
|
||||
local = _query_local_quotation(args.quotation)
|
||||
qid, remote_quote, resolve_meta = await _resolve_remote_quotation(client, args.quotation, local)
|
||||
save_dir = Path(args.save_pdf_dir) if args.save_pdf_dir else None
|
||||
body_candidates = _body_candidates(remote_quote, args)
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"requested_quotation": args.quotation,
|
||||
"resolved_at": datetime.utcnow().isoformat() + "Z",
|
||||
"quotation": {
|
||||
"local_number": _s(local.get("document_number")) if local else "",
|
||||
"local_doc_id": _s(local.get("local_doc_id")) if local else "",
|
||||
"opportunity_id": _s(local.get("opportunity_id")) if local else "",
|
||||
"external_id": qid,
|
||||
"resolve_meta": resolve_meta,
|
||||
"remote": _selected_doc_fields(remote_quote),
|
||||
"local_payload_keys": sorted(_payload(local.get("payload") if local else None).keys()),
|
||||
},
|
||||
"quotation_pdf": await _fetch_and_save_pdf(client, "quotation", qid, save_dir, f"{args.quotation}_quotation"),
|
||||
"linked_invoice": {},
|
||||
"api_surface": {},
|
||||
"body_candidates": body_candidates,
|
||||
"conversion_probe": {"executed": False},
|
||||
}
|
||||
|
||||
if local and _s(local.get("linked_invoice_external_id")):
|
||||
inv_id = _s(local.get("linked_invoice_external_id"))
|
||||
remote_invoice = await client.get_invoice(inv_id)
|
||||
result["linked_invoice"] = {
|
||||
"invoice_number": _s(local.get("linked_invoice_number")),
|
||||
"external_id": inv_id,
|
||||
"remote": _selected_doc_fields(remote_invoice),
|
||||
"invoice_pdf": await _fetch_and_save_pdf(client, "invoice", inv_id, save_dir, f"{local.get('linked_invoice_number') or inv_id}_invoice"),
|
||||
}
|
||||
|
||||
if args.probe_options:
|
||||
surface: dict[str, Any] = {}
|
||||
for method, path in [
|
||||
("OPTIONS", f"/billing/invoices/fromQuotation/{qid}"),
|
||||
("GET", f"/billing/invoices/fromQuotation/{qid}"),
|
||||
]:
|
||||
try:
|
||||
surface[f"{method} {path}"] = await _raw_request_with_meta(client, method, path)
|
||||
except Exception as exc:
|
||||
surface[f"{method} {path}"] = {"error": str(exc)}
|
||||
result["api_surface"] = surface
|
||||
|
||||
if args.execute_convert:
|
||||
if args.confirm_document != args.quotation:
|
||||
raise SystemExit("Segurança: para criar fatura real, passa --confirm-document exatamente igual a --quotation")
|
||||
body = _body_for_mode(body_candidates, args)
|
||||
path = f"/billing/invoices/fromQuotation/{qid}"
|
||||
conv = await _raw_request_with_meta(client, "POST", path, json_body=body)
|
||||
conv["body_sent"] = body
|
||||
conv["warning"] = "Este POST pode ter criado uma fatura fiscal real no Jasmin. Importar/sincronizar no ClientFlow se for documento válido."
|
||||
new_id = _s(conv.get("body_preview")).strip('"') if conv.get("status_code", 0) < 400 else ""
|
||||
if isinstance(conv.get("body_preview"), str):
|
||||
new_id = _s(conv.get("body_preview")).strip('"')
|
||||
elif isinstance(conv.get("body_preview"), dict):
|
||||
new_id = _s(conv["body_preview"].get("id") or conv["body_preview"].get("key") or conv["body_preview"].get("value"))
|
||||
conv["created_invoice_external_id_guess"] = new_id
|
||||
if new_id:
|
||||
try:
|
||||
new_invoice = await client.get_invoice(new_id)
|
||||
conv["created_invoice_remote"] = _selected_doc_fields(new_invoice)
|
||||
natural = _s(new_invoice.get("naturalKey") or new_invoice.get("documentNumber") or new_id)
|
||||
conv["created_invoice_pdf"] = await _fetch_and_save_pdf(client, "invoice", new_id, save_dir, f"{natural}_invoice")
|
||||
except Exception as exc:
|
||||
conv["created_invoice_fetch_error"] = str(exc)
|
||||
result["conversion_probe"] = {"executed": True, **conv}
|
||||
|
||||
result["recommendation"] = _recommend(result)
|
||||
_print_summary(result)
|
||||
_write_outputs(result)
|
||||
return 0
|
||||
|
||||
|
||||
def _recommend(result: dict[str, Any]) -> str:
|
||||
qpdf = result.get("quotation_pdf") or {}
|
||||
linked = result.get("linked_invoice") or {}
|
||||
ipdf = linked.get("invoice_pdf") or {}
|
||||
conv = result.get("conversion_probe") or {}
|
||||
cpdf = conv.get("created_invoice_pdf") or {}
|
||||
|
||||
if cpdf:
|
||||
if not cpdf.get("format_warning"):
|
||||
return "A variante executada criou fatura com PDF A4. Adaptar ClientFlow para usar esse body/estratégia e sincronizar a fatura criada."
|
||||
return "A variante executada também devolveu PDF não-A4. O body de conversão não resolveu; testar print endpoint/layout ou corrigir configuração Jasmin da série FA."
|
||||
if qpdf and not qpdf.get("format_warning") and ipdf and ipdf.get("format_warning"):
|
||||
return (
|
||||
"O ORC imprime A4, mas a FA ligada imprime em formato estranho. Antes de assumir erro de configuração, "
|
||||
"usar este script com --execute-convert num orçamento de teste/real autorizado e body-mode explicit-fa-print. "
|
||||
"Se continuar não-A4, a correção é no endpoint de print/layout da FA ou na configuração da série FA2026."
|
||||
)
|
||||
return (
|
||||
"Probe read-only concluído. Verifica campos interesting_fields e body_candidates. "
|
||||
"Só executar conversão com --execute-convert se este ORC puder gerar uma fatura real."
|
||||
)
|
||||
|
||||
|
||||
def _print_summary(result: dict[str, Any]) -> None:
|
||||
q = result.get("quotation") or {}
|
||||
print(f"QUOTATION={result.get('requested_quotation')} external_id={q.get('external_id')}")
|
||||
print(f"quotation_pdf={_pdf_box_summary(result.get('quotation_pdf') or {})}")
|
||||
linked = result.get("linked_invoice") or {}
|
||||
if linked:
|
||||
print(f"linked_invoice={linked.get('invoice_number')} external_id={linked.get('external_id')}")
|
||||
print(f"linked_invoice_pdf={_pdf_box_summary(linked.get('invoice_pdf') or {})}")
|
||||
else:
|
||||
print("linked_invoice=none")
|
||||
conv = result.get("conversion_probe") or {}
|
||||
if conv.get("executed"):
|
||||
print(f"conversion_status={conv.get('status_code')} created_invoice={conv.get('created_invoice_external_id_guess')}")
|
||||
print(f"created_invoice_pdf={_pdf_box_summary(conv.get('created_invoice_pdf') or {})}")
|
||||
else:
|
||||
print("conversion_executed=False")
|
||||
print("RECOMMENDATION:")
|
||||
print(result.get("recommendation") or "")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(description="Probe Jasmin ORC->FA conversion API safely.")
|
||||
p.add_argument("--quotation", required=True, help="Quotation document number/external id, e.g. ORC.ORC2026.185")
|
||||
p.add_argument("--save-pdf-dir", default="", help="Directory where fetched PDFs are saved for inspection")
|
||||
p.add_argument("--probe-options", action=argparse.BooleanOptionalAction, default=True, help="Probe safe OPTIONS/GET surface for fromQuotation endpoint")
|
||||
p.add_argument("--invoice-type", default=os.getenv("JASMIN_INVOICE_TYPE", "FA"))
|
||||
p.add_argument("--invoice-serie", default=os.getenv("JASMIN_INVOICE_SERIE", "FA2026"))
|
||||
p.add_argument("--document-date", default="", help="YYYY-MM-DD used in body candidates; defaults to today")
|
||||
p.add_argument("--print-layout", default=os.getenv("JASMIN_INVOICE_PRINT_LAYOUT", ""))
|
||||
p.add_argument("--printed-report-name", default=os.getenv("JASMIN_INVOICE_PRINTED_REPORT_NAME", ""))
|
||||
p.add_argument("--execute-convert", action="store_true", help="DANGEROUS: performs POST fromQuotation and can create a real invoice")
|
||||
p.add_argument("--confirm-document", default="", help="Required with --execute-convert; must exactly match --quotation")
|
||||
p.add_argument("--body-mode", choices=["empty", "explicit-fa", "explicit-fa-print"], default="explicit-fa-print")
|
||||
p.add_argument("--body-json", default="", help="Custom JSON object body for conversion; overrides --body-mode")
|
||||
return p
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
return asyncio.run(_main_async(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user