feat: improve invoicing UX and supplier visibility

This commit is contained in:
Julien Denizot
2026-06-21 11:41:38 +00:00
parent ce85d7765b
commit 09cd65412f
9 changed files with 1013 additions and 27 deletions
@@ -29,6 +29,13 @@
"sent_at",
"last_sync_at",
"inbound_section",
"supplier_summary_html",
"supplier_identity_details_section",
"remote_supplier_name",
"remote_supplier_vat_id",
"remote_supplier_column",
"remote_supplier_electronic_scheme",
"remote_supplier_electronic_value",
"review_status",
"supplier",
"supplier_match_method",
@@ -254,6 +261,55 @@
"fieldtype": "Section Break",
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "supplier_summary_html",
"label": "Résumé du fournisseur",
"fieldtype": "HTML",
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "supplier_identity_details_section",
"label": "Données didentification reçues",
"fieldtype": "Section Break",
"depends_on": "eval:doc.direction == 'Inbound'",
"collapsible": 1
},
{
"fieldname": "remote_supplier_name",
"label": "Émetteur reçu",
"fieldtype": "Data",
"read_only": 1,
"in_list_view": 1,
"in_standard_filter": 1,
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "remote_supplier_vat_id",
"label": "N° TVA reçu",
"fieldtype": "Data",
"read_only": 1,
"in_standard_filter": 1,
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "remote_supplier_column",
"fieldtype": "Column Break",
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "remote_supplier_electronic_scheme",
"label": "Schéma dadresse électronique",
"fieldtype": "Data",
"read_only": 1,
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "remote_supplier_electronic_value",
"label": "Adresse électronique reçue",
"fieldtype": "Data",
"read_only": 1,
"depends_on": "eval:doc.direction == 'Inbound'"
},
{
"fieldname": "review_status",
"label": "Statut dexamen",
@@ -269,13 +325,16 @@
"label": "Fournisseur rapproché",
"fieldtype": "Link",
"options": "Supplier",
"read_only": 1
"read_only": 1,
"in_list_view": 1,
"in_standard_filter": 1
},
{
"fieldname": "supplier_match_method",
"label": "Méthode de rapprochement",
"fieldtype": "Data",
"read_only": 1
"read_only": 1,
"in_standard_filter": 1
},
{
"fieldname": "remote_invoice_number",
@@ -49,9 +49,21 @@ class ElectronicInvoiceExchange(Document):
or _("Facture fournisseur")
)
self.display_title = _(
"Facture fournisseur {0}"
).format(invoice_number)
supplier_name = str(
self.remote_supplier_name
or ""
).strip()
self.display_title = (
_("{0}{1}").format(
supplier_name,
invoice_number,
)
if supplier_name
else _(
"Facture fournisseur {0}"
).format(invoice_number)
)
if not self.review_status:
self.review_status = "To Review"
+6
View File
@@ -265,6 +265,9 @@ doctype_js = {
"Customer": "public/js/customer.js",
"Sales Invoice": "public/js/sales_invoice.js",
"Purchase Invoice": "public/js/purchase_invoice.js",
"Electronic Invoice Exchange": (
"public/js/electronic_invoice_exchange.js"
),
}
# ERPNext document validation
@@ -298,6 +301,9 @@ doctype_list_js = {
"Electronic Invoice Exchange": (
"public/js/electronic_invoice_exchange_list.js"
),
"Sales Invoice": (
"public/js/sales_invoice_list.js"
),
}
+106
View File
@@ -251,6 +251,27 @@ def build_inbound_exchange_values(
"party": supplier or "",
"supplier": supplier or "",
"supplier_match_method": match_method,
"remote_supplier_name": str(
seller.get("name")
or seller.get("trading_name")
or ""
).strip(),
"remote_supplier_vat_id": str(
seller.get("vat_identifier")
or ""
).strip(),
"remote_supplier_electronic_scheme": str(
seller.get(
"electronic_address_scheme"
)
or ""
).strip(),
"remote_supplier_electronic_value": str(
seller.get(
"electronic_address_value"
)
or ""
).strip(),
"review_status": (
"Supplier Matched"
if supplier
@@ -598,6 +619,91 @@ def clear_supplier_match(
}
def backfill_inbound_supplier_identity() -> int:
"""Matérialiser lidentité des émetteurs déjà archivés."""
exchange_names = frappe.get_all(
"Electronic Invoice Exchange",
filters={
"direction": "Inbound",
},
pluck="name",
limit_page_length=0,
)
updated = 0
for exchange_name in exchange_names:
exchange = frappe.get_doc(
"Electronic Invoice Exchange",
exchange_name,
)
seller = _get_seller_snapshot(
exchange
)
supplier_name = str(
seller.get("name")
or seller.get("trading_name")
or ""
).strip()
invoice_number = str(
exchange.remote_invoice_number
or exchange.remote_id
or exchange.external_id
or ""
).strip()
display_title = (
f"{supplier_name}{invoice_number}"
if supplier_name
else f"Facture fournisseur {invoice_number}"
)
values = {
"remote_supplier_name": supplier_name,
"remote_supplier_vat_id": str(
seller.get("vat_identifier")
or ""
).strip(),
"remote_supplier_electronic_scheme": str(
seller.get(
"electronic_address_scheme"
)
or ""
).strip(),
"remote_supplier_electronic_value": str(
seller.get(
"electronic_address_value"
)
or ""
).strip(),
"display_title": display_title,
}
changed = any(
str(exchange.get(fieldname) or "")
!= str(value or "")
for fieldname, value in values.items()
)
if not changed:
continue
frappe.db.set_value(
"Electronic Invoice Exchange",
exchange.name,
values,
update_modified=False,
)
updated += 1
return updated
def archive_incoming_invoice(
company: str,
invoice_id: int,
@@ -0,0 +1,367 @@
// Copyright (c) 2026, Enuxia and contributors
// For license information, please see license.txt
frappe.ui.form.on("Electronic Invoice Exchange", {
refresh(frm) {
if (frm.doc.direction !== "Inbound") {
return;
}
render_supplier_summary(frm);
add_supplier_actions(frm);
},
});
function escape_value(value) {
return frappe.utils.escape_html(
String(value || "")
);
}
function display_value(value) {
const normalized = String(
value || ""
).trim();
return normalized
? escape_value(normalized)
: `<span class="text-muted">${__("Non renseigné")}</span>`;
}
function supplier_link(supplier) {
if (!supplier) {
return `
<span class="text-muted">
${__("Aucun fournisseur associé")}
</span>
`;
}
const route = frappe.utils.get_form_link(
"Supplier",
supplier
);
return `
<a href="${route}">
${escape_value(supplier)}
</a>
`;
}
function get_match_label(method) {
const labels = {
"Exact VAT ID": __("TVA exacte"),
"Normalized VAT ID": __("TVA normalisée"),
"Manual selection VAT confirmed": __(
"Sélection manuelle — TVA confirmée"
),
"Manual selection": __("Sélection manuelle"),
};
return labels[method] || method || __("Aucune");
}
function get_review_label(status) {
const labels = {
"To Review": __("À vérifier"),
"Supplier Matched": __("Fournisseur identifié"),
"Ready for Import": __("Prête pour limport"),
"Imported": __("Importée"),
"Ignored": __("Ignorée"),
"Error": __("Erreur"),
};
return labels[status] || status || __("À vérifier");
}
function get_review_color(status) {
const colors = {
"To Review": "orange",
"Supplier Matched": "blue",
"Ready for Import": "blue",
"Imported": "green",
"Ignored": "grey",
"Error": "red",
};
return colors[status] || "grey";
}
function render_supplier_summary(frm) {
const field = frm.get_field(
"supplier_summary_html"
);
if (!field || !field.$wrapper) {
return;
}
const electronicAddress = [
frm.doc.remote_supplier_electronic_scheme,
frm.doc.remote_supplier_electronic_value,
]
.filter(Boolean)
.join(" / ");
const locked = Boolean(
frm.doc.source_document
);
const lockedNotice = locked
? `
<div
class="alert alert-info"
style="margin: 14px 0 0;"
>
${__(
"Le rapprochement est verrouillé car une facture dachat ERPNext a déjà été créée."
)}
</div>
`
: "";
field.$wrapper.html(`
<div
style="
display: grid;
grid-template-columns:
repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
margin: 4px 0 18px;
"
>
<div class="frappe-card" style="padding: 18px;">
<div class="text-muted small">
${__("Émetteur de la facture")}
</div>
<div
style="
font-size: 18px;
font-weight: 600;
margin: 6px 0 14px;
"
>
${display_value(
frm.doc.remote_supplier_name
)}
</div>
<div style="margin-bottom: 7px;">
<strong>${__("N° TVA reçu")} :</strong>
${display_value(
frm.doc.remote_supplier_vat_id
)}
</div>
<div>
<strong>${__("Adresse électronique")} :</strong>
${display_value(electronicAddress)}
</div>
</div>
<div class="frappe-card" style="padding: 18px;">
<div class="text-muted small">
${__("Correspondance ERPNext")}
</div>
<div
style="
font-size: 18px;
font-weight: 600;
margin: 6px 0 14px;
"
>
${supplier_link(frm.doc.supplier)}
</div>
<div style="margin-bottom: 7px;">
<strong>${__("Méthode")} :</strong>
${escape_value(
get_match_label(
frm.doc.supplier_match_method
)
)}
</div>
<div>
<span
class="indicator-pill ${get_review_color(
frm.doc.review_status
)}"
>
${escape_value(
get_review_label(
frm.doc.review_status
)
)}
</span>
</div>
</div>
</div>
${lockedNotice}
`);
}
function add_supplier_actions(frm) {
if (frm.doc.source_document) {
return;
}
const group = __("Fournisseur");
frm.add_custom_button(
__("Relancer le rapprochement"),
() => refresh_supplier_match(frm),
group
);
frm.add_custom_button(
frm.doc.supplier
? __("Changer de fournisseur")
: __("Associer un fournisseur existant"),
() => select_supplier(frm),
group
);
if (frm.doc.supplier) {
frm.add_custom_button(
__("Supprimer lassociation"),
() => clear_supplier_match(frm),
group
);
}
}
async function refresh_supplier_match(frm) {
const response = await frappe.call({
method:
"enuxia_einvoice.inbound_import."
+ "refresh_supplier_match",
args: {
exchange_name: frm.doc.name,
},
freeze: true,
freeze_message: __(
"Recherche du fournisseur…"
),
});
const result = response.message || {};
await frm.reload_doc();
frappe.msgprint({
title: __("Rapprochement fournisseur"),
indicator: result.matched
? "green"
: "orange",
message:
result.message
|| __("Traitement terminé."),
});
}
function select_supplier(frm) {
const dialog = new frappe.ui.Dialog({
title: frm.doc.supplier
? __("Changer de fournisseur")
: __("Associer un fournisseur"),
fields: [
{
fieldname: "supplier",
label: __("Fournisseur ERPNext"),
fieldtype: "Link",
options: "Supplier",
reqd: 1,
default: frm.doc.supplier || "",
},
],
primary_action_label: __("Associer"),
async primary_action(values) {
dialog.hide();
const response = await frappe.call({
method:
"enuxia_einvoice.inbound_import."
+ "assign_supplier",
args: {
exchange_name: frm.doc.name,
supplier: values.supplier,
},
freeze: true,
freeze_message: __(
"Association du fournisseur…"
),
});
const result =
response.message || {};
await frm.reload_doc();
frappe.msgprint({
title: __("Fournisseur associé"),
indicator: result.vat_matches
? "green"
: "orange",
message: result.vat_matches
? __(
"Le fournisseur a été associé et son numéro de TVA correspond à celui reçu."
)
: __(
"Le fournisseur a été associé, mais son numéro de TVA ne correspond pas exactement à celui reçu."
),
});
},
});
dialog.show();
}
function clear_supplier_match(frm) {
frappe.confirm(
__(
"Supprimer lassociation avec le fournisseur ERPNext ? Le fournisseur lui-même ne sera pas supprimé."
),
async () => {
const response = await frappe.call({
method:
"enuxia_einvoice.inbound_import."
+ "clear_supplier_match",
args: {
exchange_name: frm.doc.name,
},
freeze: true,
freeze_message: __(
"Suppression de lassociation…"
),
});
const result =
response.message || {};
await frm.reload_doc();
frappe.show_alert({
message:
result.message
|| __("Association supprimée."),
indicator: "green",
});
}
);
}
+158 -20
View File
@@ -3,7 +3,7 @@
frappe.ui.form.on("Sales Invoice", {
refresh(frm) {
add_status_indicator(frm);
render_einvoice_summary(frm);
if (frm.doc.docstatus !== 1) {
return;
@@ -14,30 +14,140 @@ frappe.ui.form.on("Sales Invoice", {
});
function add_status_indicator(frm) {
const status =
frm.doc.einvoice_status || "Not Checked";
function get_payment_status(frm) {
const storedStatus =
frm.doc.einvoice_payment_status
|| "Not Applicable";
if (
storedStatus === "Reported"
|| storedStatus === "Error"
) {
return storedStatus;
}
if (!frm.doc.einvoice_remote_id) {
return "Not Applicable";
}
const outstandingAmount = Number(
frm.doc.outstanding_amount || 0
);
if (Math.abs(outstandingAmount) < 0.000001) {
return "To Report";
}
return "Waiting for Payment";
}
function render_einvoice_summary(frm) {
const field = frm.get_field(
"einvoice_summary_html"
);
if (!field || !field.$wrapper) {
return;
}
const transportStatus =
frm.doc.einvoice_status
|| "Not Checked";
const paymentStatus =
get_payment_status(frm);
const transportLabels = {
"Not Checked": __("Non vérifiée"),
"Blocked": __("Bloquée"),
"Ready": __("Prête"),
"Queued": __("En attente"),
"Sent": __("Envoyée"),
"Delivered": __("Livrée"),
"Accepted": __("Acceptée"),
"Rejected": __("Rejetée"),
"Error": __("Erreur"),
"Cancelled": __("Annulée"),
};
const paymentLabels = {
"Not Applicable": __("Non applicable"),
"Waiting for Payment": __(
"En attente de paiement"
),
"To Report": __("À déclarer"),
"Reported": __("Encaissée"),
"Error": __("Erreur"),
};
const colors = {
"Not Checked": "grey",
"Not Applicable": "grey",
"Blocked": "red",
"Ready": "blue",
"Queued": "orange",
"Sent": "blue",
"Delivered": "green",
"Accepted": "green",
"Rejected": "red",
"Error": "red",
"Ready": "blue",
"Sent": "blue",
"To Report": "blue",
"Queued": "orange",
"Waiting for Payment": "orange",
"Delivered": "green",
"Accepted": "green",
"Reported": "green",
"Cancelled": "grey",
};
frm.dashboard.add_indicator(
__(
"Facturation électronique : {0}",
[status]
),
colors[status] || "grey"
);
const renderCard = (
title,
label,
color
) => `
<div
class="frappe-card"
style="
padding: 16px;
min-height: 82px;
"
>
<div
class="text-muted small"
style="margin-bottom: 10px;"
>
${frappe.utils.escape_html(title)}
</div>
<span class="indicator-pill ${color}">
${frappe.utils.escape_html(label)}
</span>
</div>
`;
field.$wrapper.html(`
<div
style="
display: grid;
grid-template-columns:
repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
margin: 4px 0 16px;
"
>
${renderCard(
__("Transmission électronique"),
transportLabels[transportStatus]
|| transportStatus,
colors[transportStatus] || "grey"
)}
${renderCard(
__("Encaissement électronique"),
paymentLabels[paymentStatus]
|| paymentStatus,
colors[paymentStatus] || "grey"
)}
</div>
`);
}
@@ -72,18 +182,27 @@ function add_einvoice_buttons(frm) {
group
);
const paymentStatus =
get_payment_status(frm);
if (
Number(frm.doc.outstanding_amount || 0)
=== 0
paymentStatus === "To Report"
|| paymentStatus === "Error"
) {
const paymentButtonLabel = (
paymentStatus === "Error"
? __("Réessayer la déclaration")
: __("Déclarer lencaissement")
);
frm.add_custom_button(
__("Déclarer lencaissement"),
paymentButtonLabel,
() => confirm_payment_received(frm),
group
);
frm.change_custom_button_type(
__("Déclarer lencaissement"),
paymentButtonLabel,
group,
"primary"
);
@@ -324,6 +443,25 @@ function show_result(result, title) {
);
}
if (result.payment_status) {
const labels = {
"To Report": __("À déclarer"),
"Reported": __("Encaissée"),
"Error": __("Erreur"),
};
parts.push(
"<p><strong>"
+ __("Statut dencaissement")
+ " :</strong> "
+ frappe.utils.escape_html(
labels[result.payment_status]
|| result.payment_status
)
+ "</p>"
);
}
if (result.provider_status) {
parts.push(
"<p><strong>"
@@ -0,0 +1,264 @@
// Copyright (c) 2026, Enuxia and contributors
// For license information, please see license.txt
function get_einvoice_payment_status(doc) {
const storedStatus =
doc.einvoice_payment_status
|| "Not Applicable";
if (
storedStatus === "Reported"
|| storedStatus === "Error"
) {
return storedStatus;
}
if (!doc.einvoice_remote_id) {
return "Not Applicable";
}
const outstandingAmount = Number(
doc.outstanding_amount || 0
);
if (Math.abs(outstandingAmount) < 0.000001) {
return "To Report";
}
return "Waiting for Payment";
}
function render_status_badge(
status,
labels,
colors
) {
const label = labels[status] || status || "-";
const color = colors[status] || "grey";
return `
<span class="indicator-pill ${color}">
${frappe.utils.escape_html(label)}
</span>
`;
}
async function apply_einvoice_filters(
listview,
filters
) {
await listview.filter_area.clear(false);
await listview.filter_area.set(filters);
await listview.refresh();
}
frappe.listview_settings["Sales Invoice"] = {
add_fields: [
"einvoice_status",
"einvoice_payment_status",
"einvoice_remote_id",
"outstanding_amount",
"docstatus",
],
onload(listview) {
const group = __("Facturation électronique");
listview.page.add_inner_button(
__("Toutes les factures"),
() => apply_einvoice_filters(
listview,
[]
),
group
);
listview.page.add_inner_button(
__("Prêtes à envoyer"),
() => apply_einvoice_filters(
listview,
[
[
"Sales Invoice",
"docstatus",
"=",
1,
],
[
"Sales Invoice",
"einvoice_status",
"=",
"Ready",
],
]
),
group
);
listview.page.add_inner_button(
__("En attente de paiement"),
() => apply_einvoice_filters(
listview,
[
[
"Sales Invoice",
"docstatus",
"=",
1,
],
[
"Sales Invoice",
"einvoice_remote_id",
"is",
"set",
],
[
"Sales Invoice",
"outstanding_amount",
">",
0,
],
]
),
group
);
listview.page.add_inner_button(
__("Encaissements à déclarer"),
() => apply_einvoice_filters(
listview,
[
[
"Sales Invoice",
"docstatus",
"=",
1,
],
[
"Sales Invoice",
"einvoice_remote_id",
"is",
"set",
],
[
"Sales Invoice",
"outstanding_amount",
"=",
0,
],
[
"Sales Invoice",
"einvoice_payment_status",
"!=",
"Reported",
],
]
),
group
);
listview.page.add_inner_button(
__("Encaissements déclarés"),
() => apply_einvoice_filters(
listview,
[
[
"Sales Invoice",
"einvoice_payment_status",
"=",
"Reported",
],
]
),
group
);
listview.page.add_inner_button(
__("Erreurs dencaissement"),
() => apply_einvoice_filters(
listview,
[
[
"Sales Invoice",
"einvoice_payment_status",
"=",
"Error",
],
]
),
group
);
},
formatters: {
einvoice_status(value) {
const labels = {
"Not Checked": __("Non vérifiée"),
"Blocked": __("Bloquée"),
"Ready": __("Prête"),
"Queued": __("En attente"),
"Sent": __("Envoyée"),
"Delivered": __("Livrée"),
"Accepted": __("Acceptée"),
"Rejected": __("Rejetée"),
"Error": __("Erreur"),
"Cancelled": __("Annulée"),
};
const colors = {
"Not Checked": "grey",
"Blocked": "red",
"Ready": "blue",
"Queued": "orange",
"Sent": "blue",
"Delivered": "green",
"Accepted": "green",
"Rejected": "red",
"Error": "red",
"Cancelled": "grey",
};
return render_status_badge(
value || "Not Checked",
labels,
colors
);
},
einvoice_payment_status(
value,
df,
doc
) {
const status =
get_einvoice_payment_status(doc);
const labels = {
"Not Applicable": __("Non applicable"),
"Waiting for Payment": __(
"En attente de paiement"
),
"To Report": __("À déclarer"),
"Reported": __("Encaissée"),
"Error": __("Erreur"),
};
const colors = {
"Not Applicable": "grey",
"Waiting for Payment": "orange",
"To Report": "blue",
"Reported": "green",
"Error": "red",
};
return render_status_badge(
status,
labels,
colors
);
},
},
};
+15 -2
View File
@@ -14,7 +14,7 @@ CUSTOM_FIELDS = {
"fieldname": "einvoice_section",
"label": "Facturation électronique",
"fieldtype": "Section Break",
"insert_after": "tax_id",
"insert_after": "territory",
"collapsible": 1,
},
{
@@ -152,11 +152,22 @@ CUSTOM_FIELDS = {
"fieldtype": "Tab Break",
"insert_after": "loyalty_amount",
},
{
"fieldname": "einvoice_summary_section",
"label": "Résumé",
"fieldtype": "Section Break",
"insert_after": "einvoice_tab",
},
{
"fieldname": "einvoice_summary_html",
"fieldtype": "HTML",
"insert_after": "einvoice_summary_section",
},
{
"fieldname": "einvoice_invoice_section",
"label": "Configuration de la facture",
"fieldtype": "Section Break",
"insert_after": "einvoice_tab",
"insert_after": "einvoice_summary_html",
},
{
"fieldname": "einvoice_operation_category",
@@ -199,6 +210,7 @@ CUSTOM_FIELDS = {
"default": "Not Checked",
"read_only": 1,
"in_standard_filter": 1,
"in_list_view": 1,
},
{
"fieldname": "einvoice_external_id",
@@ -261,6 +273,7 @@ CUSTOM_FIELDS = {
"read_only": 1,
"allow_on_submit": 1,
"in_standard_filter": 1,
"in_list_view": 1,
},
{
"fieldname": "einvoice_payment_reported_at",
@@ -109,6 +109,27 @@ class TestInboundInvoiceImport(unittest.TestCase):
"Delivered",
)
self.assertEqual(
values["remote_supplier_name"],
"Tricatel",
)
self.assertEqual(
values["remote_supplier_vat_id"],
"FR15000000001",
)
self.assertEqual(
values[
"remote_supplier_electronic_scheme"
],
"0225",
)
self.assertEqual(
values[
"remote_supplier_electronic_value"
],
"315143296_4919",
)
def test_normalize_tax_identifier(self) -> None:
self.assertEqual(