feat: preview inbound purchase invoices

This commit is contained in:
Julien Denizot
2026-06-19 13:01:38 +00:00
parent 320dd80d0e
commit 78acf3a578
5 changed files with 1169 additions and 1 deletions
@@ -11,6 +11,12 @@ frappe.ui.form.on(
set_review_indicator(frm);
frm.add_custom_button(
__("Prévisualiser limport"),
() => preview_purchase_invoice(frm),
__("Réception fournisseur")
);
if (!frm.doc.supplier) {
frm.add_custom_button(
__("Rapprochement automatique"),
@@ -187,3 +193,236 @@ function clear_supplier_match(frm) {
}
);
}
function preview_purchase_invoice(frm) {
frappe.call({
method:
"enuxia_einvoice.purchase_preview."
+ "preview_purchase_invoice",
args: {
exchange_name: frm.doc.name,
},
freeze: true,
freeze_message: __(
"Construction de la prévisualisation…"
),
callback(response) {
const preview = response.message || {};
show_purchase_preview_dialog(
preview
);
},
});
}
function show_purchase_preview_dialog(
preview
) {
const escape = (value) =>
frappe.utils.escape_html(
String(value ?? "")
);
const lineRows = (
preview.lines || []
).map((line) => `
<tr>
<td>${escape(line.line_number)}</td>
<td>
<strong>${escape(line.item_name)}</strong>
${
line.description
? `<br><small>${escape(line.description)}</small>`
: ""
}
</td>
<td>${escape(line.quantity)}</td>
<td>
${escape(line.unit_code)}
<br>
<small>
ERPNext :
${escape(line.resolved_uom || "Non rapprochée")}
</small>
</td>
<td>
${escape(
line.suggested_item_code
|| "Non rapproché"
)}
</td>
<td style="text-align:right">
${escape(line.unit_price)}
</td>
<td style="text-align:right">
${escape(line.net_amount)}
</td>
<td style="text-align:right">
${escape(line.vat_rate)} %
</td>
</tr>
`).join("");
const checks = (
preview.checks || []
).map((check) => `
<li>
<span class="indicator-pill ${
check.ok ? "green" : "red"
}">
${check.ok ? __("OK") : __("Écart")}
</span>
${escape(check.label)}
</li>
`).join("");
const blockers = (
preview.blockers || []
).map(
(message) =>
`<li>${escape(message)}</li>`
).join("");
const warnings = (
preview.warnings || []
).map(
(message) =>
`<li>${escape(message)}</li>`
).join("");
const readiness = preview.can_create_draft
? `
<div class="alert alert-success">
${__(
"La facture peut techniquement être préparée en brouillon."
)}
</div>
`
: `
<div class="alert alert-warning">
${__(
"La création du brouillon reste bloquée."
)}
</div>
`;
const html = `
${readiness}
<div class="mb-3">
<strong>${__(
"Facture fournisseur"
)} :</strong>
${escape(preview.invoice_number)}
<br>
<strong>${__(
"Fournisseur"
)} :</strong>
${escape(
preview.supplier || "Non rapproché"
)}
<br>
<strong>${__("Date")} :</strong>
${escape(preview.issue_date)}
&nbsp;—&nbsp;
<strong>${__(
"Échéance"
)} :</strong>
${escape(preview.payment_due_date)}
</div>
<div class="table-responsive">
<table class="table table-bordered">
<thead>
<tr>
<th>#</th>
<th>${__("Désignation")}</th>
<th>${__("Quantité")}</th>
<th>${__("Unité")}</th>
<th>${__("Article proposé")}</th>
<th>${__("Prix unitaire")}</th>
<th>${__("Montant HT")}</th>
<th>${__("TVA")}</th>
</tr>
</thead>
<tbody>
${lineRows}
</tbody>
</table>
</div>
<div class="row">
<div class="col-sm-6">
<h5>${__("Contrôles")}</h5>
<ul>${checks}</ul>
${
blockers
? `
<h5>${__("Blocages")}</h5>
<ul>${blockers}</ul>
`
: ""
}
${
warnings
? `
<h5>${__("Avertissements")}</h5>
<ul>${warnings}</ul>
`
: ""
}
</div>
<div class="col-sm-6 text-right">
<p>
<strong>${__("Total HT")} :</strong>
${escape(preview.totals?.net_total)}
${escape(preview.currency)}
</p>
<p>
<strong>${__("TVA")} :</strong>
${escape(preview.totals?.vat_total)}
${escape(preview.currency)}
</p>
<p>
<strong>${__("Total TTC")} :</strong>
${escape(preview.totals?.grand_total)}
${escape(preview.currency)}
</p>
<p>
<strong>${__("Net à payer")} :</strong>
${escape(preview.totals?.amount_due)}
${escape(preview.currency)}
</p>
</div>
</div>
`;
const dialog = new frappe.ui.Dialog({
title: __(
"Prévisualisation de la facture dachat"
),
size: "extra-large",
fields: [
{
fieldname: "preview",
fieldtype: "HTML",
options: html,
},
],
primary_action_label: __("Fermer"),
primary_action() {
dialog.hide();
},
});
dialog.show();
}
@@ -6,6 +6,7 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"display_title",
"identity_section",
"direction",
"company",
@@ -53,6 +54,13 @@
"last_error"
],
"fields": [
{
"fieldname": "display_title",
"label": "Titre",
"fieldtype": "Data",
"read_only": 1,
"hidden": 1
},
{
"fieldname": "identity_section",
"label": "Identification",
@@ -381,7 +389,7 @@
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "source_document",
"title_field": "display_title",
"track_changes": 1,
"search_fields": "source_document,external_id,remote_id,party",
"allow_rename": 0,
@@ -42,11 +42,28 @@ class ElectronicInvoiceExchange(Document):
def before_validate(self) -> None:
if self.direction == "Inbound":
invoice_number = (
self.remote_invoice_number
or self.remote_id
or self.external_id
or _("Facture fournisseur")
)
self.display_title = _(
"Facture fournisseur {0}"
).format(invoice_number)
if not self.review_status:
self.review_status = "To Review"
return
self.display_title = (
self.source_document
or self.external_id
or _("Facture électronique sortante")
)
# Les statuts dexamen et de rapprochement fournisseur
# ne concernent jamais les échanges sortants.
self.review_status = None
+719
View File
@@ -0,0 +1,719 @@
# Copyright (c) 2026, Enuxia and contributors
# For license information, please see license.txt
from __future__ import annotations
import json
from decimal import Decimal, InvalidOperation
from typing import Any
import frappe
from frappe import _
AMOUNT_TOLERANCE = Decimal("0.01")
UNIT_CODE_CANDIDATES = {
"C62": ["Nos", "Unit"],
"KGM": ["Kg", "Kilogram"],
"GRM": ["Gram"],
"LTR": ["Litre", "Liter"],
"MLT": ["Millilitre", "Milliliter"],
"HUR": ["Hour"],
"DAY": ["Day"],
"MTR": ["Meter", "Metre"],
"MTK": ["Square Meter"],
"MTQ": ["Cubic Meter"],
}
class PurchasePreviewError(ValueError):
"""La prévisualisation de la facture dachat a échoué."""
def _dictionary(
value: Any,
) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _list(
value: Any,
) -> list[Any]:
return value if isinstance(value, list) else []
def _decimal(
value: Any,
*,
fieldname: str,
) -> Decimal:
if isinstance(value, dict):
value = value.get("value")
try:
return Decimal(
str(
value
if value not in (None, "")
else "0"
)
)
except (
InvalidOperation,
TypeError,
ValueError,
) as error:
raise PurchasePreviewError(
f"Valeur numérique invalide pour {fieldname}."
) from error
def _decimal_text(
value: Decimal,
places: str = "0.01",
) -> str:
return format(
value.quantize(Decimal(places)),
"f",
)
def _identifier_value(
value: Any,
) -> str:
if isinstance(value, dict):
return str(
value.get("value")
or value.get("identifier")
or ""
).strip()
return str(value or "").strip()
def _first_identifier(
line: dict[str, Any],
item_information: dict[str, Any],
) -> str:
keys = (
"seller_item_identifier",
"sellers_item_identifier",
"standard_item_identifier",
"item_identifier",
)
for container in (
item_information,
line,
):
for key in keys:
identifier = _identifier_value(
container.get(key)
)
if identifier:
return identifier
return ""
def build_purchase_invoice_preview(
remote_invoice: dict[str, Any],
*,
supplier: str = "",
) -> dict[str, Any]:
"""Construire une prévisualisation sans accès à la base."""
if not isinstance(remote_invoice, dict):
raise PurchasePreviewError(
"Le document EN16931 est invalide."
)
en_invoice = _dictionary(
remote_invoice.get("en_invoice")
)
if not en_invoice:
en_invoice = remote_invoice
raw_lines = _list(
en_invoice.get("lines")
)
if not raw_lines:
raise PurchasePreviewError(
"La facture reçue ne contient aucune ligne."
)
totals = _dictionary(
en_invoice.get("totals")
)
preview_lines: list[dict[str, Any]] = []
line_total = Decimal("0")
calculation_warnings: list[str] = []
for index, raw_line in enumerate(
raw_lines,
start=1,
):
line = _dictionary(raw_line)
item_information = _dictionary(
line.get("item_information")
)
price_details = _dictionary(
line.get("price_details")
)
vat_information = _dictionary(
line.get("vat_information")
)
quantity = _decimal(
line.get("invoiced_quantity"),
fieldname=f"quantité ligne {index}",
)
unit_price = _decimal(
price_details.get("item_net_price"),
fieldname=f"prix unitaire ligne {index}",
)
net_amount = _decimal(
line.get("net_amount"),
fieldname=f"montant net ligne {index}",
)
calculated_amount = (
quantity * unit_price
)
amount_difference = (
net_amount - calculated_amount
)
if abs(amount_difference) > AMOUNT_TOLERANCE:
calculation_warnings.append(
(
f"Ligne {index} : quantité × prix "
f"diffère du montant net de "
f"{_decimal_text(amount_difference)}."
)
)
vat_rate = _decimal(
vat_information.get(
"invoiced_item_vat_rate"
),
fieldname=f"taux de TVA ligne {index}",
)
line_total += net_amount
unit_code = str(
line.get(
"invoiced_quantity_code"
)
or ""
).strip()
item_name = str(
item_information.get("name")
or ""
).strip()
description = str(
item_information.get("description")
or ""
).strip()
preview_lines.append(
{
"line_number": index,
"identifier": str(
line.get("identifier")
or index
),
"supplier_item_identifier": (
_first_identifier(
line,
item_information,
)
),
"item_name": item_name,
"description": description,
"quantity": _decimal_text(
quantity,
"0.0001",
),
"unit_code": unit_code,
"uom_candidates": (
UNIT_CODE_CANDIDATES.get(
unit_code,
[],
)
),
"unit_price": _decimal_text(
unit_price,
"0.000001",
),
"net_amount": _decimal_text(
net_amount,
),
"calculated_amount": (
_decimal_text(
calculated_amount,
)
),
"amount_difference": (
_decimal_text(
amount_difference,
)
),
"vat_category": str(
vat_information.get(
"invoiced_item_vat_category_code"
)
or ""
),
"vat_rate": _decimal_text(
vat_rate,
),
}
)
net_total = _decimal(
totals.get("total_without_vat"),
fieldname="total HT",
)
vat_total = _decimal(
totals.get("total_vat_amount"),
fieldname="total TVA",
)
grand_total = _decimal(
totals.get("total_with_vat"),
fieldname="total TTC",
)
amount_due = _decimal(
totals.get("amount_due_for_payment"),
fieldname="net à payer",
)
vat_breakdown_total = sum(
(
_decimal(
breakdown.get(
"vat_category_tax_amount"
),
fieldname="ventilation TVA",
)
for breakdown in _list(
en_invoice.get(
"vat_break_down"
)
)
if isinstance(
breakdown,
dict,
)
),
Decimal("0"),
)
checks = [
{
"code": "LINE_TOTAL",
"label": (
"Somme des lignes égale au total HT"
),
"ok": (
abs(line_total - net_total)
<= AMOUNT_TOLERANCE
),
"expected": _decimal_text(
net_total
),
"actual": _decimal_text(
line_total
),
},
{
"code": "VAT_TOTAL",
"label": (
"Ventilation TVA égale au total TVA"
),
"ok": (
abs(
vat_breakdown_total
- vat_total
)
<= AMOUNT_TOLERANCE
),
"expected": _decimal_text(
vat_total
),
"actual": _decimal_text(
vat_breakdown_total
),
},
{
"code": "GRAND_TOTAL",
"label": (
"Total HT + TVA égal au total TTC"
),
"ok": (
abs(
net_total
+ vat_total
- grand_total
)
<= AMOUNT_TOLERANCE
),
"expected": _decimal_text(
grand_total
),
"actual": _decimal_text(
net_total + vat_total
),
},
]
return {
"remote_id": remote_invoice.get("id"),
"invoice_number": str(
en_invoice.get("number")
or ""
),
"supplier": supplier,
"issue_date": str(
en_invoice.get("issue_date")
or ""
),
"payment_due_date": str(
en_invoice.get(
"payment_due_date"
)
or ""
),
"currency": str(
en_invoice.get(
"currency_code"
)
or ""
),
"line_count": len(
preview_lines
),
"lines": preview_lines,
"totals": {
"net_total": _decimal_text(
net_total
),
"vat_total": _decimal_text(
vat_total
),
"grand_total": _decimal_text(
grand_total
),
"amount_due": _decimal_text(
amount_due
),
},
"checks": checks,
"calculation_warnings": (
calculation_warnings
),
}
def _load_semantic_payload(
file_url: str,
) -> dict[str, Any]:
file_name = frappe.db.get_value(
"File",
{
"file_url": file_url,
},
"name",
)
if not file_name:
frappe.throw(
_(
"Le fichier EN16931 archivé "
"est introuvable."
)
)
file_document = frappe.get_doc(
"File",
file_name,
)
content = file_document.get_content()
if isinstance(content, bytes):
content = content.decode("utf-8")
try:
result = json.loads(content)
except (
TypeError,
ValueError,
json.JSONDecodeError,
):
frappe.throw(
_(
"Le fichier EN16931 archivé "
"ne contient pas un JSON valide."
)
)
if not isinstance(result, dict):
frappe.throw(
_("Le contenu EN16931 est invalide.")
)
return result
def _resolve_uom(
unit_code: str,
) -> str:
candidates = [
unit_code,
*UNIT_CODE_CANDIDATES.get(
unit_code,
[],
),
]
for candidate in candidates:
if (
candidate
and frappe.db.exists(
"UOM",
candidate,
)
):
return candidate
return ""
def _suggest_item(
item_name: str,
) -> tuple[str, str]:
item_name = str(
item_name or ""
).strip()
if not item_name:
return "", ""
if frappe.db.exists(
"Item",
item_name,
):
return item_name, "Exact Item Code"
matches = frappe.get_all(
"Item",
filters={
"item_name": item_name,
"disabled": 0,
},
pluck="name",
limit=2,
)
if len(matches) == 1:
return (
matches[0],
"Exact Item Name",
)
return "", ""
def _find_existing_purchase_invoice(
*,
supplier: str,
bill_no: str,
) -> str:
if not supplier or not bill_no:
return ""
matches = frappe.get_all(
"Purchase Invoice",
filters=[
[
"supplier",
"=",
supplier,
],
[
"bill_no",
"=",
bill_no,
],
[
"docstatus",
"!=",
2,
],
],
pluck="name",
limit=1,
)
return (
str(matches[0])
if matches
else ""
)
@frappe.whitelist()
def preview_purchase_invoice(
exchange_name: str,
) -> dict[str, Any]:
"""Prévisualiser une facture dachat sans la créer."""
exchange = frappe.get_doc(
"Electronic Invoice Exchange",
exchange_name,
)
exchange.check_permission("read")
if exchange.direction != "Inbound":
frappe.throw(
_(
"La prévisualisation dachat est "
"réservée aux échanges entrants."
)
)
if not exchange.semantic_payload_file:
frappe.throw(
_(
"Aucune donnée EN16931 nest "
"archivée sur cet échange."
)
)
payload = _load_semantic_payload(
exchange.semantic_payload_file
)
try:
preview = build_purchase_invoice_preview(
payload,
supplier=exchange.supplier or "",
)
except PurchasePreviewError as error:
frappe.throw(str(error))
blockers: list[str] = []
warnings = list(
preview["calculation_warnings"]
)
if not exchange.supplier:
blockers.append(
"Aucun fournisseur ERPNext nest rapproché."
)
for line in preview["lines"]:
resolved_uom = _resolve_uom(
line["unit_code"]
)
item_code, item_match_method = (
_suggest_item(
line["item_name"]
)
)
line["resolved_uom"] = resolved_uom
line["suggested_item_code"] = (
item_code
)
line["item_match_method"] = (
item_match_method
)
if not resolved_uom:
blockers.append(
(
f"Ligne {line['line_number']} : "
f"aucune UOM ERPNext ne correspond "
f"au code {line['unit_code'] or 'vide'}."
)
)
if not item_code:
blockers.append(
(
f"Ligne {line['line_number']} : "
f"aucun article ERPNext nest "
f"rapproché avec « {line['item_name']} »."
)
)
for check in preview["checks"]:
if not check["ok"]:
blockers.append(
(
f"{check['label']} : "
f"attendu {check['expected']}, "
f"obtenu {check['actual']}."
)
)
existing_purchase_invoice = (
_find_existing_purchase_invoice(
supplier=exchange.supplier or "",
bill_no=preview["invoice_number"],
)
)
if existing_purchase_invoice:
blockers.append(
(
"Une facture dachat ERPNext possède "
"déjà ce fournisseur et ce numéro."
)
)
preview.update(
{
"exchange": exchange.name,
"review_status": (
exchange.review_status
),
"original_file": (
exchange.payload_file
),
"semantic_file": (
exchange.semantic_payload_file
),
"existing_purchase_invoice": (
existing_purchase_invoice
),
"blockers": blockers,
"warnings": warnings,
"can_create_draft": (
not blockers
),
}
)
return preview
@@ -0,0 +1,185 @@
from __future__ import annotations
import copy
import unittest
from enuxia_einvoice.purchase_preview import (
build_purchase_invoice_preview,
)
class TestPurchaseInvoicePreview(
unittest.TestCase
):
def remote_invoice(self):
return {
"id": 79712,
"direction": "in",
"en_invoice": {
"number": (
"F20260619_123507_609"
),
"issue_date": "2025-06-30",
"payment_due_date": (
"2025-07-30"
),
"currency_code": "EUR",
"lines": [
{
"identifier": "001",
"invoiced_quantity": (
"28.5200"
),
"invoiced_quantity_code": (
"KGM"
),
"item_information": {
"name": (
"Poulet aux hormones"
),
},
"net_amount": "60.46",
"price_details": {
"item_net_price": (
"2.120000"
),
},
"vat_information": {
"invoiced_item_vat_category_code": (
"S"
),
"invoiced_item_vat_rate": (
"5.50"
),
},
},
{
"identifier": "002",
"invoiced_quantity": (
"1.0000"
),
"invoiced_quantity_code": (
"C62"
),
"item_information": {
"name": (
"Conseil en stratégie"
),
},
"net_amount": "1500.00",
"price_details": {
"item_net_price": (
"1500.000000"
),
},
"vat_information": {
"invoiced_item_vat_category_code": (
"S"
),
"invoiced_item_vat_rate": (
"20.00"
),
},
},
],
"totals": {
"total_without_vat": (
"1560.46"
),
"total_vat_amount": {
"value": "303.33",
"currency_code": "EUR",
},
"total_with_vat": (
"1863.79"
),
"amount_due_for_payment": (
"1863.79"
),
},
"vat_break_down": [
{
"vat_category_tax_amount": (
"3.33"
),
},
{
"vat_category_tax_amount": (
"300.00"
),
},
],
},
}
def test_build_preview(self) -> None:
preview = (
build_purchase_invoice_preview(
self.remote_invoice(),
supplier="Tricatel",
)
)
self.assertEqual(
preview["invoice_number"],
"F20260619_123507_609",
)
self.assertEqual(
preview["line_count"],
2,
)
self.assertEqual(
preview["totals"]["grand_total"],
"1863.79",
)
self.assertTrue(
all(
check["ok"]
for check in preview["checks"]
)
)
self.assertEqual(
preview["lines"][0][
"unit_code"
],
"KGM",
)
def test_detect_header_total_mismatch(
self,
) -> None:
invoice = copy.deepcopy(
self.remote_invoice()
)
invoice["en_invoice"]["totals"][
"total_without_vat"
] = "1600.00"
preview = (
build_purchase_invoice_preview(
invoice,
supplier="Tricatel",
)
)
checks = {
check["code"]: check["ok"]
for check in preview["checks"]
}
self.assertFalse(
checks["LINE_TOTAL"]
)
self.assertFalse(
checks["GRAND_TOTAL"]
)
if __name__ == "__main__":
unittest.main()