feat: complete outbound electronic invoice workflow

This commit is contained in:
Julien Denizot
2026-06-19 11:44:30 +00:00
parent 8b7f2b7c6a
commit 2c4cc02b23
3 changed files with 712 additions and 72 deletions
+340 -72
View File
@@ -3,25 +3,110 @@
frappe.ui.form.on("Sales Invoice", {
refresh(frm) {
add_status_indicator(frm);
if (frm.doc.docstatus !== 1) {
return;
}
frm.add_custom_button(
__("Vérifier la facture"),
() => validate_invoice(frm),
__("Facturation électronique")
);
frm.add_custom_button(
__("Préparer lenvoi"),
() => prepare_exchange(frm),
__("Facturation électronique")
);
add_einvoice_buttons(frm);
},
});
function add_status_indicator(frm) {
const status =
frm.doc.einvoice_status || "Not Checked";
const colors = {
"Not Checked": "grey",
"Blocked": "red",
"Ready": "blue",
"Queued": "orange",
"Sent": "blue",
"Delivered": "green",
"Accepted": "green",
"Rejected": "red",
"Error": "red",
"Cancelled": "grey",
};
frm.dashboard.add_indicator(
__(
"Facturation électronique : {0}",
[status]
),
colors[status] || "grey"
);
}
function add_einvoice_buttons(frm) {
const group =
__("Facturation électronique");
frm.add_custom_button(
__("Vérifier la facture"),
() => validate_invoice(frm),
group
);
if (frm.doc.einvoice_exchange) {
frm.add_custom_button(
__("Ouvrir l’échange"),
() => {
frappe.set_route(
"Form",
"Electronic Invoice Exchange",
frm.doc.einvoice_exchange
);
},
group
);
}
if (frm.doc.einvoice_remote_id) {
frm.add_custom_button(
__("Synchroniser le statut"),
() => sync_status(frm),
group
);
frm.change_custom_button_type(
__("Synchroniser le statut"),
group,
"primary"
);
return;
}
frm.add_custom_button(
__("Préparer lenvoi"),
() => prepare_exchange(frm),
group
);
frm.add_custom_button(
__("Générer et valider lUBL"),
() => generate_and_validate(frm),
group
);
frm.add_custom_button(
__("Envoyer électroniquement"),
() => confirm_and_send(frm),
group
);
frm.change_custom_button_type(
__("Envoyer électroniquement"),
group,
"primary"
);
}
async function validate_invoice(frm) {
const response = await frappe.call({
method:
@@ -40,7 +125,10 @@ async function validate_invoice(frm) {
await frm.reload_doc();
show_validation_result(result);
show_result(
result,
__("Vérification de la facture")
);
}
@@ -62,74 +150,254 @@ async function prepare_exchange(frm) {
await frm.reload_doc();
if (!result.ok) {
show_validation_result(result);
return;
show_result(
result,
__("Préparation de lenvoi")
);
}
async function generate_and_validate(frm) {
const response = await frappe.call({
method:
"enuxia_einvoice.workflow."
+ "generate_and_validate_sales_invoice",
args: {
invoice_name: frm.doc.name,
},
freeze: true,
freeze_message: __(
"Génération et validation du document UBL…"
),
});
const result = response.message || {};
await frm.reload_doc();
show_result(
result,
__("Validation électronique")
);
}
function confirm_and_send(frm) {
frappe.confirm(
__(
"Cette action va transmettre réellement "
+ "la facture à SuperPDP. Continuer ?"
),
async () => {
const response = await frappe.call({
method:
"enuxia_einvoice.workflow."
+ "send_sales_invoice_workflow",
args: {
invoice_name: frm.doc.name,
},
freeze: true,
freeze_message: __(
"Validation et envoi de la facture…"
),
});
const result =
response.message || {};
await frm.reload_doc();
show_result(
result,
__("Envoi électronique")
);
}
);
}
async function sync_status(frm) {
const response = await frappe.call({
method:
"enuxia_einvoice.sending."
+ "sync_sales_invoice_status",
args: {
invoice_name: frm.doc.name,
},
freeze: true,
freeze_message: __(
"Synchronisation avec SuperPDP…"
),
});
const result = response.message || {};
await frm.reload_doc();
show_result(
result,
__("Statut SuperPDP")
);
}
function show_result(result, title) {
const parts = [];
if (result.message) {
parts.push(
"<p>"
+ frappe.utils.escape_html(
result.message
)
+ "</p>"
);
}
if (result.remote_id) {
parts.push(
"<p><strong>"
+ __("Identifiant SuperPDP")
+ " :</strong> "
+ frappe.utils.escape_html(
String(result.remote_id)
)
+ "</p>"
);
}
if (result.status) {
parts.push(
"<p><strong>"
+ __("Statut")
+ " :</strong> "
+ frappe.utils.escape_html(
result.status
)
+ "</p>"
);
}
if (result.provider_status) {
parts.push(
"<p><strong>"
+ __("Événement SuperPDP")
+ " :</strong> "
+ frappe.utils.escape_html(
result.provider_status
)
+ "</p>"
);
}
const latestEvent =
result.latest_event || {};
if (latestEvent.status_text) {
parts.push(
"<p><strong>"
+ __("Détail")
+ " :</strong> "
+ frappe.utils.escape_html(
latestEvent.status_text
)
+ "</p>"
);
}
append_string_list(
parts,
__("Erreurs"),
result.errors
);
append_object_list(
parts,
__("Erreurs de conformité"),
result.failures
);
append_string_list(
parts,
__("Avertissements"),
result.warnings
);
append_object_list(
parts,
__("Messages de validation"),
(
result.validation_messages
|| result.messages
)
);
frappe.msgprint({
title: result.created
? __("Échange préparé")
: __("Échange existant"),
indicator: "green",
message: frappe.utils.escape_html(
result.message
title,
indicator: result.ok
? "green"
: "red",
message: (
parts.join("")
|| __("Traitement terminé.")
),
});
}
function show_validation_result(result) {
const errors = result.errors || [];
const warnings = result.warnings || [];
const sections = [];
if (errors.length) {
sections.push(
"<strong>"
+ __("Erreurs")
+ "</strong><ul>"
+ errors.map(
(error) =>
"<li>"
+ frappe.utils.escape_html(error)
+ "</li>"
).join("")
+ "</ul>"
);
function append_string_list(
parts,
title,
values
) {
if (!Array.isArray(values) || !values.length) {
return;
}
if (warnings.length) {
sections.push(
"<strong>"
+ __("Avertissements")
+ "</strong><ul>"
+ warnings.map(
(warning) =>
"<li>"
+ frappe.utils.escape_html(warning)
+ "</li>"
).join("")
+ "</ul>"
);
}
if (!sections.length) {
sections.push(
frappe.utils.escape_html(
result.message
|| __("Vérification terminée.")
)
);
}
frappe.msgprint({
title: result.ok
? __("Facture prête")
: __("Facture non conforme"),
indicator: result.ok
? "green"
: "red",
message: sections.join("<br>"),
});
parts.push(
"<strong>"
+ title
+ "</strong><ul>"
+ values.map(
(value) =>
"<li>"
+ frappe.utils.escape_html(
String(value)
)
+ "</li>"
).join("")
+ "</ul>"
);
}
function append_object_list(
parts,
title,
values
) {
if (!Array.isArray(values) || !values.length) {
return;
}
const messages = values
.map((value) => {
if (typeof value === "string") {
return value;
}
return (
value.message
|| value.status_text
|| value.raw
|| ""
);
})
.filter(Boolean);
append_string_list(
parts,
title,
messages
);
}
+187
View File
@@ -0,0 +1,187 @@
from __future__ import annotations
import unittest
from unittest.mock import Mock, patch
from enuxia_einvoice.workflow import (
generate_and_validate_sales_invoice,
send_sales_invoice_workflow,
)
class TestElectronicInvoiceWorkflow(unittest.TestCase):
def setUp(self) -> None:
"""Neutraliser la traduction Frappe dans les tests unitaires purs."""
translation_patcher = patch(
"enuxia_einvoice.workflow._",
side_effect=lambda message, *args, **kwargs: message,
)
translation_patcher.start()
self.addCleanup(
translation_patcher.stop
)
def invoice(
self,
*,
remote_id: str = "",
) -> Mock:
invoice = Mock()
invoice.name = "ACC-SINV-2026-00002"
invoice.docstatus = 1
invoice.check_permission = Mock()
values = {
"einvoice_remote_id": remote_id,
}
invoice.get.side_effect = values.get
return invoice
@patch(
"enuxia_einvoice.workflow."
"validate_sales_invoice_ubl"
)
@patch(
"enuxia_einvoice.workflow."
"convert_sales_invoice_to_ubl"
)
@patch(
"enuxia_einvoice.workflow."
"prepare_sales_invoice_exchange"
)
@patch(
"enuxia_einvoice.workflow.frappe.get_doc"
)
def test_generate_and_validate(
self,
get_doc,
prepare,
convert,
validate,
) -> None:
get_doc.return_value = self.invoice()
prepare.return_value = {
"ok": True,
"exchange_name": "exchange-id",
"warnings": [],
}
convert.return_value = {
"ok": True,
"file_url": "/private/files/invoice.xml",
"sha256": "abc123",
}
validate.return_value = {
"ok": True,
"is_valid": True,
"format": "ubl",
"validators": [],
"messages": [],
}
result = generate_and_validate_sales_invoice(
"ACC-SINV-2026-00002"
)
self.assertTrue(result["ok"])
self.assertEqual(
result["status"],
"Ready",
)
self.assertEqual(
result["sha256"],
"abc123",
)
@patch(
"enuxia_einvoice.workflow."
"send_sales_invoice"
)
@patch(
"enuxia_einvoice.workflow."
"generate_and_validate_sales_invoice"
)
@patch(
"enuxia_einvoice.workflow.frappe.get_doc"
)
def test_full_send_workflow(
self,
get_doc,
preflight,
send,
) -> None:
get_doc.return_value = self.invoice()
preflight.return_value = {
"ok": True,
"sha256": "abc123",
}
send.return_value = {
"ok": True,
"created": True,
"remote_id": "12345",
"status": "Queued",
}
result = send_sales_invoice_workflow(
"ACC-SINV-2026-00002"
)
self.assertTrue(result["ok"])
self.assertEqual(
result["remote_id"],
"12345",
)
self.assertEqual(
result["stage"],
"sending",
)
@patch(
"enuxia_einvoice.workflow."
"send_sales_invoice"
)
@patch(
"enuxia_einvoice.workflow."
"generate_and_validate_sales_invoice"
)
@patch(
"enuxia_einvoice.workflow.frappe.get_doc"
)
def test_sent_invoice_is_not_regenerated(
self,
get_doc,
preflight,
send,
) -> None:
get_doc.return_value = self.invoice(
remote_id="79635"
)
send.return_value = {
"ok": True,
"already_sent": True,
"remote_id": "79635",
}
result = send_sales_invoice_workflow(
"ACC-SINV-2026-00001"
)
preflight.assert_not_called()
send.assert_called_once()
self.assertEqual(
result["stage"],
"already_sent",
)
if __name__ == "__main__":
unittest.main()
+185
View File
@@ -0,0 +1,185 @@
# Copyright (c) 2026, Enuxia and contributors
# For license information, please see license.txt
from __future__ import annotations
from typing import Any
import frappe
from frappe import _
from enuxia_einvoice.conversion import (
convert_sales_invoice_to_ubl,
)
from enuxia_einvoice.exchange import (
prepare_sales_invoice_exchange,
)
from enuxia_einvoice.sending import (
send_sales_invoice,
)
from enuxia_einvoice.validation import (
validate_sales_invoice_ubl,
)
def _failure(
stage: str,
result: dict[str, Any],
) -> dict[str, Any]:
return {
"ok": False,
"stage": stage,
"message": (
result.get("message")
or _("Le traitement a échoué.")
),
"errors": result.get("errors") or [],
"warnings": result.get("warnings") or [],
"failures": result.get("failures") or [],
"messages": result.get("messages") or [],
}
def _get_submitted_invoice(
invoice_name: str,
):
invoice = frappe.get_doc(
"Sales Invoice",
invoice_name,
)
invoice.check_permission("write")
if invoice.docstatus != 1:
frappe.throw(
_("La facture doit être soumise.")
)
return invoice
@frappe.whitelist()
def generate_and_validate_sales_invoice(
invoice_name: str,
) -> dict[str, Any]:
"""Préparer, convertir puis valider une facture sans lenvoyer."""
invoice = _get_submitted_invoice(
invoice_name
)
if invoice.get("einvoice_remote_id"):
frappe.throw(
_(
"Cette facture a déjà été envoyée. "
"Son document électronique ne doit plus "
"être régénéré."
),
title=_("Facture déjà envoyée"),
)
preparation = prepare_sales_invoice_exchange(
invoice.name
)
if not preparation.get("ok"):
return _failure(
"preparation",
preparation,
)
conversion = convert_sales_invoice_to_ubl(
invoice.name
)
if not conversion.get("ok"):
return _failure(
"conversion",
conversion,
)
validation = validate_sales_invoice_ubl(
invoice.name
)
if (
not validation.get("ok")
or not validation.get("is_valid")
):
return _failure(
"validation",
validation,
)
return {
"ok": True,
"stage": "validation",
"status": "Ready",
"invoice": invoice.name,
"exchange": preparation.get(
"exchange_name"
),
"file_url": conversion.get(
"file_url"
),
"sha256": conversion.get(
"sha256"
),
"format": validation.get(
"format"
),
"conformance_level": validation.get(
"conformance_level"
),
"validators": validation.get(
"validators"
) or [],
"warnings": preparation.get(
"warnings"
) or [],
"validation_messages": validation.get(
"messages"
) or [],
"message": _(
"La facture a été générée en UBL "
"et validée par SuperPDP."
),
}
@frappe.whitelist()
def send_sales_invoice_workflow(
invoice_name: str,
) -> dict[str, Any]:
"""Exécuter tout le workflow puis envoyer réellement la facture."""
invoice = _get_submitted_invoice(
invoice_name
)
# Ne jamais régénérer le document lorsquun identifiant
# distant existe déjà.
if invoice.get("einvoice_remote_id"):
result = send_sales_invoice(
invoice.name
)
result["stage"] = "already_sent"
return result
preflight = generate_and_validate_sales_invoice(
invoice.name
)
if not preflight.get("ok"):
return preflight
sending = send_sales_invoice(
invoice.name
)
sending["stage"] = "sending"
sending["validated"] = True
sending["sha256"] = preflight.get(
"sha256"
)
return sending