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