feat: complete outbound electronic invoice workflow
This commit is contained in:
@@ -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 l’envoi"),
|
||||
() => 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 l’envoi"),
|
||||
() => prepare_exchange(frm),
|
||||
group
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Générer et valider l’UBL"),
|
||||
() => 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 l’envoi")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
@@ -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 l’envoyer."""
|
||||
|
||||
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 lorsqu’un 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
|
||||
Reference in New Issue
Block a user