feat: match inbound invoices to suppliers
This commit is contained in:
+189
-2
@@ -1,2 +1,189 @@
|
||||
// Copyright (c) 2026, Enuxia and contributors
|
||||
// For license information, please see license.txt
|
||||
frappe.ui.form.on(
|
||||
"Electronic Invoice Exchange",
|
||||
{
|
||||
refresh(frm) {
|
||||
if (
|
||||
frm.is_new()
|
||||
|| frm.doc.direction !== "Inbound"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
set_review_indicator(frm);
|
||||
|
||||
if (!frm.doc.supplier) {
|
||||
frm.add_custom_button(
|
||||
__("Rapprochement automatique"),
|
||||
() => refresh_supplier_match(frm),
|
||||
__("Réception fournisseur")
|
||||
);
|
||||
}
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Choisir un fournisseur"),
|
||||
() => open_supplier_dialog(frm),
|
||||
__("Réception fournisseur")
|
||||
);
|
||||
|
||||
if (frm.doc.supplier) {
|
||||
frm.add_custom_button(
|
||||
__("Dissocier le fournisseur"),
|
||||
() => clear_supplier_match(frm),
|
||||
__("Réception fournisseur")
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function set_review_indicator(frm) {
|
||||
const indicators = {
|
||||
"To Review": ["À examiner", "orange"],
|
||||
"Supplier Matched": [
|
||||
"Fournisseur rapproché",
|
||||
"blue",
|
||||
],
|
||||
"Ready for Import": [
|
||||
"Prête pour l’import",
|
||||
"green",
|
||||
],
|
||||
Imported: ["Importée", "green"],
|
||||
Ignored: ["Ignorée", "gray"],
|
||||
Error: ["Erreur", "red"],
|
||||
};
|
||||
|
||||
const indicator = (
|
||||
indicators[frm.doc.review_status]
|
||||
|| [frm.doc.review_status || "À examiner", "gray"]
|
||||
);
|
||||
|
||||
frm.page.set_indicator(
|
||||
__(indicator[0]),
|
||||
indicator[1]
|
||||
);
|
||||
}
|
||||
|
||||
function refresh_supplier_match(frm) {
|
||||
frappe.call({
|
||||
method:
|
||||
"enuxia_einvoice.inbound_import."
|
||||
+ "refresh_supplier_match",
|
||||
args: {
|
||||
exchange_name: frm.doc.name,
|
||||
},
|
||||
freeze: true,
|
||||
freeze_message: __(
|
||||
"Recherche du fournisseur…"
|
||||
),
|
||||
callback(response) {
|
||||
const result = response.message || {};
|
||||
|
||||
if (result.matched) {
|
||||
frappe.show_alert({
|
||||
message: __(
|
||||
"Fournisseur rapproché : {0}",
|
||||
[result.supplier]
|
||||
),
|
||||
indicator: "green",
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: __("Aucun rapprochement"),
|
||||
message: result.message,
|
||||
indicator: "orange",
|
||||
});
|
||||
}
|
||||
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function open_supplier_dialog(frm) {
|
||||
const dialog = new frappe.ui.Dialog({
|
||||
title: __("Choisir un fournisseur"),
|
||||
fields: [
|
||||
{
|
||||
fieldname: "supplier",
|
||||
label: __("Fournisseur"),
|
||||
fieldtype: "Link",
|
||||
options: "Supplier",
|
||||
reqd: 1,
|
||||
default: frm.doc.supplier || "",
|
||||
},
|
||||
],
|
||||
primary_action_label: __("Associer"),
|
||||
primary_action(values) {
|
||||
frappe.call({
|
||||
method:
|
||||
"enuxia_einvoice.inbound_import."
|
||||
+ "assign_supplier",
|
||||
args: {
|
||||
exchange_name: frm.doc.name,
|
||||
supplier: values.supplier,
|
||||
},
|
||||
freeze: true,
|
||||
callback(response) {
|
||||
const result = response.message || {};
|
||||
|
||||
dialog.hide();
|
||||
|
||||
if (result.vat_matches) {
|
||||
frappe.show_alert({
|
||||
message: __(
|
||||
"Fournisseur associé et TVA confirmée."
|
||||
),
|
||||
indicator: "green",
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: __(
|
||||
"Fournisseur associé manuellement"
|
||||
),
|
||||
message: __(
|
||||
"Le fournisseur a été associé, "
|
||||
+ "mais son numéro de TVA ne correspond "
|
||||
+ "pas exactement à celui de la facture."
|
||||
),
|
||||
indicator: "orange",
|
||||
});
|
||||
}
|
||||
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
dialog.show();
|
||||
}
|
||||
|
||||
function clear_supplier_match(frm) {
|
||||
frappe.confirm(
|
||||
__(
|
||||
"Supprimer le rapprochement avec le fournisseur {0} ?",
|
||||
[frm.doc.supplier]
|
||||
),
|
||||
() => {
|
||||
frappe.call({
|
||||
method:
|
||||
"enuxia_einvoice.inbound_import."
|
||||
+ "clear_supplier_match",
|
||||
args: {
|
||||
exchange_name: frm.doc.name,
|
||||
},
|
||||
freeze: true,
|
||||
callback() {
|
||||
frappe.show_alert({
|
||||
message: __(
|
||||
"Rapprochement fournisseur supprimé."
|
||||
),
|
||||
indicator: "blue",
|
||||
});
|
||||
|
||||
frm.reload_doc();
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+140
-7
@@ -27,6 +27,24 @@
|
||||
"payload_hash",
|
||||
"sent_at",
|
||||
"last_sync_at",
|
||||
"inbound_section",
|
||||
"review_status",
|
||||
"supplier",
|
||||
"supplier_match_method",
|
||||
"remote_invoice_number",
|
||||
"received_at",
|
||||
"issue_date",
|
||||
"payment_due_date",
|
||||
"currency",
|
||||
"inbound_amounts_column",
|
||||
"net_total",
|
||||
"vat_total",
|
||||
"grand_total",
|
||||
"amount_due",
|
||||
"inbound_files_section",
|
||||
"semantic_payload_file",
|
||||
"semantic_payload_hash",
|
||||
"supplier_snapshot",
|
||||
"events_section",
|
||||
"events",
|
||||
"audit_section",
|
||||
@@ -85,15 +103,13 @@
|
||||
"label": "Type de document source",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"default": "Sales Invoice",
|
||||
"reqd": 1
|
||||
"default": "Sales Invoice"
|
||||
},
|
||||
{
|
||||
"fieldname": "source_document",
|
||||
"label": "Document source",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"options": "source_doctype",
|
||||
"reqd": 1,
|
||||
"in_list_view": 1
|
||||
},
|
||||
{
|
||||
@@ -101,15 +117,13 @@
|
||||
"label": "Type de tiers",
|
||||
"fieldtype": "Link",
|
||||
"options": "DocType",
|
||||
"default": "Customer",
|
||||
"reqd": 1
|
||||
"default": "Customer"
|
||||
},
|
||||
{
|
||||
"fieldname": "party",
|
||||
"label": "Tiers",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"options": "party_type",
|
||||
"reqd": 1
|
||||
"options": "party_type"
|
||||
},
|
||||
{
|
||||
"fieldname": "transport_section",
|
||||
@@ -223,6 +237,125 @@
|
||||
"read_only": 1,
|
||||
"cannot_add_rows": 1,
|
||||
"cannot_delete_rows": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "inbound_section",
|
||||
"label": "Réception fournisseur",
|
||||
"fieldtype": "Section Break",
|
||||
"depends_on": "eval:doc.direction == 'Inbound'"
|
||||
},
|
||||
{
|
||||
"fieldname": "review_status",
|
||||
"label": "Statut d’examen",
|
||||
"fieldtype": "Select",
|
||||
"options": "To Review\nSupplier Matched\nReady for Import\nImported\nIgnored\nError",
|
||||
"read_only": 1,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"depends_on": "eval:doc.direction == 'Inbound'"
|
||||
},
|
||||
{
|
||||
"fieldname": "supplier",
|
||||
"label": "Fournisseur rapproché",
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "supplier_match_method",
|
||||
"label": "Méthode de rapprochement",
|
||||
"fieldtype": "Data",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "remote_invoice_number",
|
||||
"label": "Numéro de facture fournisseur",
|
||||
"fieldtype": "Data",
|
||||
"read_only": 1,
|
||||
"in_list_view": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "received_at",
|
||||
"label": "Reçue le",
|
||||
"fieldtype": "Datetime",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "issue_date",
|
||||
"label": "Date de facture",
|
||||
"fieldtype": "Date",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_due_date",
|
||||
"label": "Date d’échéance",
|
||||
"fieldtype": "Date",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"label": "Devise",
|
||||
"fieldtype": "Link",
|
||||
"options": "Currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "inbound_amounts_column",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "net_total",
|
||||
"label": "Total HT",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "vat_total",
|
||||
"label": "Total TVA",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "grand_total",
|
||||
"label": "Total TTC",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "amount_due",
|
||||
"label": "Net à payer",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "inbound_files_section",
|
||||
"label": "Documents reçus",
|
||||
"fieldtype": "Section Break",
|
||||
"depends_on": "eval:doc.direction == 'Inbound'"
|
||||
},
|
||||
{
|
||||
"fieldname": "semantic_payload_file",
|
||||
"label": "Données EN16931",
|
||||
"fieldtype": "Attach",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "semantic_payload_hash",
|
||||
"label": "Empreinte EN16931",
|
||||
"fieldtype": "Data",
|
||||
"read_only": 1,
|
||||
"hidden": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "supplier_snapshot",
|
||||
"label": "Instantané du fournisseur",
|
||||
"fieldtype": "Code",
|
||||
"options": "JSON",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
|
||||
+41
-1
@@ -38,7 +38,20 @@ SUPPORTED_STATUSES = {
|
||||
|
||||
|
||||
class ElectronicInvoiceExchange(Document):
|
||||
"""Trace d’audit d’un document de facturation électronique."""
|
||||
"""Trace d’audit d’un document électronique."""
|
||||
|
||||
def before_validate(self) -> None:
|
||||
if self.direction == "Inbound":
|
||||
if not self.review_status:
|
||||
self.review_status = "To Review"
|
||||
|
||||
return
|
||||
|
||||
# Les statuts d’examen et de rapprochement fournisseur
|
||||
# ne concernent jamais les échanges sortants.
|
||||
self.review_status = None
|
||||
self.supplier = None
|
||||
self.supplier_match_method = None
|
||||
|
||||
def before_insert(self) -> None:
|
||||
if not self.external_id:
|
||||
@@ -49,6 +62,7 @@ class ElectronicInvoiceExchange(Document):
|
||||
self._validate_provider()
|
||||
self._validate_environment()
|
||||
self._validate_status()
|
||||
self._validate_directional_requirements()
|
||||
self._validate_source_document()
|
||||
|
||||
def _validate_direction(self) -> None:
|
||||
@@ -75,6 +89,32 @@ class ElectronicInvoiceExchange(Document):
|
||||
_("Statut interne d’échange invalide.")
|
||||
)
|
||||
|
||||
def _validate_directional_requirements(self) -> None:
|
||||
if self.direction == "Outbound":
|
||||
if not self.source_doctype or not self.source_document:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Un échange sortant doit être lié "
|
||||
"à un document source."
|
||||
)
|
||||
)
|
||||
|
||||
if not self.party_type or not self.party:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Un échange sortant doit être lié "
|
||||
"à un tiers."
|
||||
)
|
||||
)
|
||||
|
||||
if self.direction == "Inbound" and not self.remote_id:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Un échange entrant doit posséder "
|
||||
"un identifiant SuperPDP."
|
||||
)
|
||||
)
|
||||
|
||||
def _validate_source_document(self) -> None:
|
||||
if not self.source_doctype or not self.source_document:
|
||||
return
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
# Copyright (c) 2026, Enuxia and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import timezone
|
||||
from hashlib import sha256
|
||||
from typing import Any
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import get_datetime, now_datetime
|
||||
from frappe.utils.file_manager import save_file
|
||||
|
||||
from enuxia_einvoice.inbound import (
|
||||
summarize_incoming_invoice,
|
||||
)
|
||||
from enuxia_einvoice.sending import (
|
||||
build_event_row,
|
||||
map_provider_status,
|
||||
normalize_provider_events,
|
||||
)
|
||||
from enuxia_einvoice.validation import (
|
||||
get_authenticated_client,
|
||||
)
|
||||
|
||||
|
||||
class InboundImportError(ValueError):
|
||||
"""Le document entrant ne peut pas être archivé."""
|
||||
|
||||
|
||||
def normalize_remote_datetime(
|
||||
value: Any,
|
||||
):
|
||||
if not value:
|
||||
return None
|
||||
|
||||
result = get_datetime(value)
|
||||
|
||||
if result.tzinfo is not None:
|
||||
result = (
|
||||
result
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def detect_original_document(
|
||||
content: bytes,
|
||||
) -> dict[str, str]:
|
||||
"""Identifier le format du document original."""
|
||||
|
||||
if not isinstance(content, bytes) or not content.strip():
|
||||
raise InboundImportError(
|
||||
"Le document original reçu est vide."
|
||||
)
|
||||
|
||||
stripped = content.lstrip()
|
||||
|
||||
if stripped.startswith(b"%PDF"):
|
||||
return {
|
||||
"document_format": "Factur-X",
|
||||
"extension": "pdf",
|
||||
"content_type": "application/pdf",
|
||||
}
|
||||
|
||||
if stripped.startswith(b"<"):
|
||||
header = stripped[:2000].decode(
|
||||
"utf-8",
|
||||
errors="ignore",
|
||||
)
|
||||
|
||||
if (
|
||||
"Invoice-2" in header
|
||||
or "<Invoice" in header
|
||||
):
|
||||
return {
|
||||
"document_format": "UBL",
|
||||
"extension": "xml",
|
||||
"content_type": "application/xml",
|
||||
}
|
||||
|
||||
if "CrossIndustryInvoice" in header:
|
||||
return {
|
||||
"document_format": "CII",
|
||||
"extension": "xml",
|
||||
"content_type": "application/xml",
|
||||
}
|
||||
|
||||
raise InboundImportError(
|
||||
"Le format du document original n’est pas reconnu."
|
||||
)
|
||||
|
||||
|
||||
def find_supplier_by_vat(
|
||||
vat_identifier: str,
|
||||
) -> tuple[str | None, str]:
|
||||
"""Rapprocher uniquement sur une TVA unique."""
|
||||
|
||||
vat_identifier = str(
|
||||
vat_identifier or ""
|
||||
).strip()
|
||||
|
||||
normalized_vat = normalize_tax_identifier(
|
||||
vat_identifier
|
||||
)
|
||||
|
||||
if not normalized_vat:
|
||||
return None, ""
|
||||
|
||||
supplier_meta = frappe.get_meta(
|
||||
"Supplier"
|
||||
)
|
||||
|
||||
if not supplier_meta.has_field("tax_id"):
|
||||
return None, ""
|
||||
|
||||
exact_suppliers = frappe.get_all(
|
||||
"Supplier",
|
||||
filters={
|
||||
"tax_id": vat_identifier,
|
||||
},
|
||||
pluck="name",
|
||||
limit=2,
|
||||
)
|
||||
|
||||
if len(exact_suppliers) == 1:
|
||||
return exact_suppliers[0], "Exact VAT ID"
|
||||
|
||||
if len(exact_suppliers) > 1:
|
||||
return None, ""
|
||||
|
||||
candidates = frappe.get_all(
|
||||
"Supplier",
|
||||
filters=[
|
||||
[
|
||||
"tax_id",
|
||||
"is",
|
||||
"set",
|
||||
]
|
||||
],
|
||||
fields=[
|
||||
"name",
|
||||
"tax_id",
|
||||
],
|
||||
limit=5000,
|
||||
)
|
||||
|
||||
normalized_matches = [
|
||||
row["name"]
|
||||
for row in candidates
|
||||
if normalize_tax_identifier(
|
||||
row.get("tax_id")
|
||||
)
|
||||
== normalized_vat
|
||||
]
|
||||
|
||||
if len(normalized_matches) != 1:
|
||||
return None, ""
|
||||
|
||||
return (
|
||||
normalized_matches[0],
|
||||
"Normalized VAT ID",
|
||||
)
|
||||
|
||||
|
||||
def _extract_vat_total(summary: dict[str, Any]) -> Any:
|
||||
value = summary.get("total_vat_amount")
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value.get("value") or 0
|
||||
|
||||
return value or 0
|
||||
|
||||
|
||||
def _safe_filename(value: str) -> str:
|
||||
result = re.sub(
|
||||
r"[^A-Za-z0-9._-]+",
|
||||
"-",
|
||||
str(value or ""),
|
||||
).strip("-")
|
||||
|
||||
return result or "invoice"
|
||||
|
||||
|
||||
def build_inbound_exchange_values(
|
||||
remote_invoice: dict[str, Any],
|
||||
*,
|
||||
company: str,
|
||||
environment: str,
|
||||
supplier: str | None,
|
||||
match_method: str,
|
||||
) -> dict[str, Any]:
|
||||
summary = summarize_incoming_invoice(
|
||||
remote_invoice
|
||||
)
|
||||
|
||||
provider_status = str(
|
||||
summary.get("provider_status") or ""
|
||||
)
|
||||
|
||||
internal_status = map_provider_status(
|
||||
provider_status,
|
||||
current_status="Delivered",
|
||||
)
|
||||
|
||||
seller = summary.get("seller") or {}
|
||||
buyer = summary.get("buyer") or {}
|
||||
|
||||
routing_snapshot = {
|
||||
"seller": {
|
||||
"scheme": seller.get(
|
||||
"electronic_address_scheme"
|
||||
),
|
||||
"value": seller.get(
|
||||
"electronic_address_value"
|
||||
),
|
||||
},
|
||||
"buyer": {
|
||||
"scheme": buyer.get(
|
||||
"electronic_address_scheme"
|
||||
),
|
||||
"value": buyer.get(
|
||||
"electronic_address_value"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"doctype": "Electronic Invoice Exchange",
|
||||
"direction": "Inbound",
|
||||
"company": company,
|
||||
"provider": "SuperPDP",
|
||||
"environment": environment,
|
||||
"external_id": (
|
||||
f"superpdp-in-{summary['remote_id']}"
|
||||
),
|
||||
"remote_id": str(
|
||||
summary["remote_id"]
|
||||
),
|
||||
"document_format": "EN16931 JSON",
|
||||
"internal_status": internal_status,
|
||||
"provider_status": provider_status,
|
||||
"party_type": (
|
||||
"Supplier" if supplier else ""
|
||||
),
|
||||
"party": supplier or "",
|
||||
"supplier": supplier or "",
|
||||
"supplier_match_method": match_method,
|
||||
"review_status": (
|
||||
"Supplier Matched"
|
||||
if supplier
|
||||
else "To Review"
|
||||
),
|
||||
"remote_invoice_number": summary.get(
|
||||
"invoice_number"
|
||||
),
|
||||
"received_at": normalize_remote_datetime(
|
||||
summary.get("created_at")
|
||||
),
|
||||
"issue_date": summary.get(
|
||||
"issue_date"
|
||||
),
|
||||
"payment_due_date": summary.get(
|
||||
"payment_due_date"
|
||||
),
|
||||
"currency": summary.get(
|
||||
"currency_code"
|
||||
),
|
||||
"net_total": summary.get(
|
||||
"total_without_vat"
|
||||
) or 0,
|
||||
"vat_total": _extract_vat_total(
|
||||
summary
|
||||
),
|
||||
"grand_total": summary.get(
|
||||
"total_with_vat"
|
||||
) or 0,
|
||||
"amount_due": summary.get(
|
||||
"amount_due_for_payment"
|
||||
) or 0,
|
||||
"routing_snapshot": json.dumps(
|
||||
routing_snapshot,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
),
|
||||
"supplier_snapshot": json.dumps(
|
||||
seller,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
),
|
||||
"provider_response": json.dumps(
|
||||
{
|
||||
"id": remote_invoice.get("id"),
|
||||
"company_id": remote_invoice.get(
|
||||
"company_id"
|
||||
),
|
||||
"direction": remote_invoice.get(
|
||||
"direction"
|
||||
),
|
||||
"created_at": remote_invoice.get(
|
||||
"created_at"
|
||||
),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _assert_invoice_belongs_to_account(
|
||||
remote_invoice: dict[str, Any],
|
||||
account,
|
||||
) -> None:
|
||||
expected_company_id = str(
|
||||
account.remote_company_id or ""
|
||||
).strip()
|
||||
|
||||
actual_company_id = str(
|
||||
remote_invoice.get("company_id") or ""
|
||||
).strip()
|
||||
|
||||
if (
|
||||
expected_company_id
|
||||
and actual_company_id
|
||||
and expected_company_id != actual_company_id
|
||||
):
|
||||
frappe.throw(
|
||||
_(
|
||||
"La facture entrante n’appartient pas "
|
||||
"au compte SuperPDP configuré."
|
||||
)
|
||||
)
|
||||
|
||||
expected_number = str(
|
||||
account.remote_company_number or ""
|
||||
).strip()
|
||||
|
||||
buyer = (
|
||||
remote_invoice.get("en_invoice", {})
|
||||
.get("buyer", {})
|
||||
)
|
||||
|
||||
actual_number = str(
|
||||
(
|
||||
buyer.get(
|
||||
"legal_registration_identifier"
|
||||
)
|
||||
or {}
|
||||
).get("value")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
if (
|
||||
expected_number
|
||||
and actual_number
|
||||
and expected_number != actual_number
|
||||
):
|
||||
frappe.throw(
|
||||
_(
|
||||
"L’acheteur de la facture ne correspond pas "
|
||||
"à la société ERPNext configurée."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
def normalize_tax_identifier(
|
||||
value: Any,
|
||||
) -> str:
|
||||
"""Normaliser un identifiant fiscal pour la comparaison."""
|
||||
|
||||
return re.sub(
|
||||
r"[^A-Z0-9]",
|
||||
"",
|
||||
str(value or "").upper(),
|
||||
)
|
||||
|
||||
|
||||
def _get_inbound_exchange(
|
||||
exchange_name: str,
|
||||
):
|
||||
exchange = frappe.get_doc(
|
||||
"Electronic Invoice Exchange",
|
||||
exchange_name,
|
||||
)
|
||||
|
||||
exchange.check_permission("write")
|
||||
|
||||
if exchange.direction != "Inbound":
|
||||
frappe.throw(
|
||||
_(
|
||||
"Cette opération est réservée "
|
||||
"aux échanges entrants."
|
||||
)
|
||||
)
|
||||
|
||||
return exchange
|
||||
|
||||
|
||||
def _get_seller_snapshot(
|
||||
exchange,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(
|
||||
exchange.supplier_snapshot or "{}"
|
||||
)
|
||||
except (
|
||||
TypeError,
|
||||
ValueError,
|
||||
json.JSONDecodeError,
|
||||
):
|
||||
value = {}
|
||||
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _apply_supplier_match(
|
||||
exchange,
|
||||
*,
|
||||
supplier: str,
|
||||
match_method: str,
|
||||
) -> None:
|
||||
exchange.supplier = supplier
|
||||
exchange.party_type = "Supplier"
|
||||
exchange.party = supplier
|
||||
exchange.supplier_match_method = match_method
|
||||
exchange.review_status = "Supplier Matched"
|
||||
|
||||
exchange.save(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def refresh_supplier_match(
|
||||
exchange_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Relancer le rapprochement automatique par TVA."""
|
||||
|
||||
exchange = _get_inbound_exchange(
|
||||
exchange_name
|
||||
)
|
||||
|
||||
seller = _get_seller_snapshot(
|
||||
exchange
|
||||
)
|
||||
|
||||
vat_identifier = str(
|
||||
seller.get("vat_identifier") or ""
|
||||
).strip()
|
||||
|
||||
supplier, match_method = (
|
||||
find_supplier_by_vat(
|
||||
vat_identifier
|
||||
)
|
||||
)
|
||||
|
||||
if not supplier:
|
||||
return {
|
||||
"ok": True,
|
||||
"matched": False,
|
||||
"exchange": exchange.name,
|
||||
"vat_identifier": vat_identifier,
|
||||
"message": _(
|
||||
"Aucun fournisseur unique ne correspond "
|
||||
"au numéro de TVA reçu."
|
||||
),
|
||||
}
|
||||
|
||||
_apply_supplier_match(
|
||||
exchange,
|
||||
supplier=supplier,
|
||||
match_method=match_method,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"matched": True,
|
||||
"exchange": exchange.name,
|
||||
"supplier": supplier,
|
||||
"match_method": match_method,
|
||||
"vat_identifier": vat_identifier,
|
||||
"message": _(
|
||||
"Le fournisseur a été rapproché automatiquement."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def assign_supplier(
|
||||
exchange_name: str,
|
||||
supplier: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Associer manuellement un fournisseur existant."""
|
||||
|
||||
exchange = _get_inbound_exchange(
|
||||
exchange_name
|
||||
)
|
||||
|
||||
if not frappe.db.exists(
|
||||
"Supplier",
|
||||
supplier,
|
||||
):
|
||||
frappe.throw(
|
||||
_("Le fournisseur sélectionné n’existe pas.")
|
||||
)
|
||||
|
||||
supplier_tax_id = str(
|
||||
frappe.db.get_value(
|
||||
"Supplier",
|
||||
supplier,
|
||||
"tax_id",
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
seller = _get_seller_snapshot(
|
||||
exchange
|
||||
)
|
||||
|
||||
received_tax_id = str(
|
||||
seller.get("vat_identifier") or ""
|
||||
).strip()
|
||||
|
||||
vat_matches = bool(
|
||||
normalize_tax_identifier(
|
||||
supplier_tax_id
|
||||
)
|
||||
and normalize_tax_identifier(
|
||||
supplier_tax_id
|
||||
)
|
||||
== normalize_tax_identifier(
|
||||
received_tax_id
|
||||
)
|
||||
)
|
||||
|
||||
match_method = (
|
||||
"Manual selection – VAT confirmed"
|
||||
if vat_matches
|
||||
else "Manual selection"
|
||||
)
|
||||
|
||||
_apply_supplier_match(
|
||||
exchange,
|
||||
supplier=supplier,
|
||||
match_method=match_method,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"matched": True,
|
||||
"exchange": exchange.name,
|
||||
"supplier": supplier,
|
||||
"supplier_tax_id": supplier_tax_id,
|
||||
"received_tax_id": received_tax_id,
|
||||
"vat_matches": vat_matches,
|
||||
"match_method": match_method,
|
||||
"message": _(
|
||||
"Le fournisseur a été associé à la facture entrante."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def clear_supplier_match(
|
||||
exchange_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Supprimer un rapprochement sans supprimer le fournisseur."""
|
||||
|
||||
exchange = _get_inbound_exchange(
|
||||
exchange_name
|
||||
)
|
||||
|
||||
exchange.supplier = ""
|
||||
exchange.party_type = ""
|
||||
exchange.party = ""
|
||||
exchange.supplier_match_method = ""
|
||||
exchange.review_status = "To Review"
|
||||
|
||||
exchange.save(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"exchange": exchange.name,
|
||||
"message": _(
|
||||
"Le rapprochement fournisseur a été supprimé."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def import_incoming_invoice(
|
||||
company: str,
|
||||
invoice_id: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Archiver une facture reçue sans créer de Purchase Invoice."""
|
||||
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
remote_id = str(
|
||||
int(invoice_id)
|
||||
)
|
||||
|
||||
existing = frappe.db.get_value(
|
||||
"Electronic Invoice Exchange",
|
||||
{
|
||||
"direction": "Inbound",
|
||||
"provider": "SuperPDP",
|
||||
"remote_id": remote_id,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
|
||||
if existing:
|
||||
exchange = frappe.get_doc(
|
||||
"Electronic Invoice Exchange",
|
||||
existing,
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"created": False,
|
||||
"exchange": exchange.name,
|
||||
"remote_id": remote_id,
|
||||
"review_status": exchange.review_status,
|
||||
"supplier": exchange.supplier,
|
||||
"message": _(
|
||||
"Cette facture entrante est déjà archivée."
|
||||
),
|
||||
}
|
||||
|
||||
account = frappe.get_doc(
|
||||
"Electronic Invoicing Account",
|
||||
company,
|
||||
)
|
||||
|
||||
client, access_token = (
|
||||
get_authenticated_client(company)
|
||||
)
|
||||
|
||||
remote_invoice = client.get_invoice(
|
||||
access_token,
|
||||
invoice_id=int(remote_id),
|
||||
invoice_format="en16931",
|
||||
)
|
||||
|
||||
if remote_invoice.get("direction") != "in":
|
||||
frappe.throw(
|
||||
_("La facture demandée n’est pas entrante.")
|
||||
)
|
||||
|
||||
_assert_invoice_belongs_to_account(
|
||||
remote_invoice,
|
||||
account,
|
||||
)
|
||||
|
||||
seller = (
|
||||
remote_invoice.get("en_invoice", {})
|
||||
.get("seller", {})
|
||||
)
|
||||
|
||||
supplier, match_method = (
|
||||
find_supplier_by_vat(
|
||||
seller.get("vat_identifier") or ""
|
||||
)
|
||||
)
|
||||
|
||||
values = build_inbound_exchange_values(
|
||||
remote_invoice,
|
||||
company=company,
|
||||
environment=account.environment,
|
||||
supplier=supplier,
|
||||
match_method=match_method,
|
||||
)
|
||||
|
||||
# Cette valeur dépend du fuseau configuré dans Frappe.
|
||||
# Elle est donc ajoutée uniquement dans le contexte réel du site.
|
||||
values["last_sync_at"] = now_datetime()
|
||||
|
||||
exchange = frappe.get_doc(values)
|
||||
exchange.insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
semantic_content = json.dumps(
|
||||
remote_invoice,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
indent=2,
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
|
||||
semantic_hash = sha256(
|
||||
semantic_content
|
||||
).hexdigest()
|
||||
|
||||
invoice_number = (
|
||||
values.get("remote_invoice_number")
|
||||
or remote_id
|
||||
)
|
||||
|
||||
safe_number = _safe_filename(
|
||||
invoice_number
|
||||
)
|
||||
|
||||
semantic_file = save_file(
|
||||
(
|
||||
f"{safe_number}-"
|
||||
f"{semantic_hash[:12]}-en16931.json"
|
||||
),
|
||||
semantic_content,
|
||||
"Electronic Invoice Exchange",
|
||||
exchange.name,
|
||||
is_private=1,
|
||||
)
|
||||
|
||||
original = client.get_invoice_file(
|
||||
access_token,
|
||||
invoice_id=int(remote_id),
|
||||
invoice_format="original",
|
||||
)
|
||||
|
||||
original_content = original["content"]
|
||||
|
||||
detected = detect_original_document(
|
||||
original_content
|
||||
)
|
||||
|
||||
original_hash = sha256(
|
||||
original_content
|
||||
).hexdigest()
|
||||
|
||||
original_file = save_file(
|
||||
(
|
||||
f"{safe_number}-"
|
||||
f"{original_hash[:12]}-original."
|
||||
f"{detected['extension']}"
|
||||
),
|
||||
original_content,
|
||||
"Electronic Invoice Exchange",
|
||||
exchange.name,
|
||||
is_private=1,
|
||||
)
|
||||
|
||||
exchange.semantic_payload_file = (
|
||||
semantic_file.file_url
|
||||
)
|
||||
exchange.semantic_payload_hash = (
|
||||
semantic_hash
|
||||
)
|
||||
exchange.payload_file = (
|
||||
original_file.file_url
|
||||
)
|
||||
exchange.payload_hash = (
|
||||
original_hash
|
||||
)
|
||||
exchange.document_format = (
|
||||
detected["document_format"]
|
||||
)
|
||||
|
||||
for event in normalize_provider_events(
|
||||
{
|
||||
"data": (
|
||||
remote_invoice.get("events")
|
||||
or []
|
||||
)
|
||||
}
|
||||
):
|
||||
exchange.append(
|
||||
"events",
|
||||
build_event_row(event),
|
||||
)
|
||||
|
||||
exchange.save(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"created": True,
|
||||
"exchange": exchange.name,
|
||||
"remote_id": remote_id,
|
||||
"invoice_number": invoice_number,
|
||||
"review_status": exchange.review_status,
|
||||
"supplier": exchange.supplier,
|
||||
"supplier_match_method": (
|
||||
exchange.supplier_match_method
|
||||
),
|
||||
"semantic_file": (
|
||||
exchange.semantic_payload_file
|
||||
),
|
||||
"original_file": exchange.payload_file,
|
||||
"document_format": (
|
||||
exchange.document_format
|
||||
),
|
||||
"event_count": len(
|
||||
exchange.get("events") or []
|
||||
),
|
||||
"message": _(
|
||||
"La facture entrante a été archivée "
|
||||
"sans création comptable."
|
||||
),
|
||||
}
|
||||
@@ -357,6 +357,48 @@ class SuperPDPClient:
|
||||
|
||||
return self._read_response(response)
|
||||
|
||||
def get_invoice_file(
|
||||
self,
|
||||
access_token: str,
|
||||
*,
|
||||
invoice_id: int,
|
||||
invoice_format: str = "original",
|
||||
) -> dict[str, Any]:
|
||||
"""Télécharger le contenu binaire d'une facture."""
|
||||
|
||||
response = self.session.get(
|
||||
(
|
||||
f"{self.base_url}/v1.beta/invoices/"
|
||||
f"{invoice_id}"
|
||||
),
|
||||
headers=self._authorization_headers(
|
||||
access_token
|
||||
),
|
||||
params={
|
||||
"format": invoice_format,
|
||||
},
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
self._read_response(response)
|
||||
|
||||
raise RuntimeError(
|
||||
"Le téléchargement SuperPDP a échoué."
|
||||
)
|
||||
|
||||
content_type = str(
|
||||
response.headers.get(
|
||||
"Content-Type",
|
||||
"application/octet-stream",
|
||||
)
|
||||
).split(";", 1)[0].strip().lower()
|
||||
|
||||
return {
|
||||
"content": bytes(response.content),
|
||||
"content_type": content_type,
|
||||
}
|
||||
|
||||
def create_invoice(
|
||||
self,
|
||||
access_token: str,
|
||||
|
||||
@@ -636,5 +636,50 @@ class TestSuperPDPClient(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
def test_get_invoice_original_file(self) -> None:
|
||||
xml = (
|
||||
b'<?xml version="1.0"?>'
|
||||
b'<Invoice/>'
|
||||
)
|
||||
|
||||
response = Mock()
|
||||
response.ok = True
|
||||
response.status_code = 200
|
||||
response.content = xml
|
||||
response.headers = {
|
||||
"Content-Type": "application/xml; charset=utf-8",
|
||||
}
|
||||
|
||||
self.session.get.return_value = response
|
||||
|
||||
result = self.client.get_invoice_file(
|
||||
"secret-access-token",
|
||||
invoice_id=79712,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result["content"],
|
||||
xml,
|
||||
)
|
||||
self.assertEqual(
|
||||
result["content_type"],
|
||||
"application/xml",
|
||||
)
|
||||
|
||||
self.session.get.assert_called_once_with(
|
||||
(
|
||||
"https://api.superpdp.tech/"
|
||||
"v1.beta/invoices/79712"
|
||||
),
|
||||
headers={
|
||||
"Authorization": "Bearer secret-access-token",
|
||||
},
|
||||
params={
|
||||
"format": "original",
|
||||
},
|
||||
timeout=(5, 30),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from enuxia_einvoice.inbound_import import (
|
||||
build_inbound_exchange_values,
|
||||
detect_original_document,
|
||||
normalize_tax_identifier,
|
||||
)
|
||||
|
||||
|
||||
class TestInboundInvoiceImport(unittest.TestCase):
|
||||
def remote_invoice(self):
|
||||
return {
|
||||
"id": 79712,
|
||||
"company_id": 4920,
|
||||
"direction": "in",
|
||||
"created_at": "2026-06-19T12:35:14Z",
|
||||
"events": [
|
||||
{
|
||||
"id": 263426,
|
||||
"status_code": "fr:202",
|
||||
}
|
||||
],
|
||||
"en_invoice": {
|
||||
"number": "F20260619_123507_609",
|
||||
"issue_date": "2025-06-30",
|
||||
"payment_due_date": "2025-07-30",
|
||||
"currency_code": "EUR",
|
||||
"seller": {
|
||||
"name": "Tricatel",
|
||||
"vat_identifier": "FR15000000001",
|
||||
"electronic_address": {
|
||||
"scheme": "0225",
|
||||
"value": "315143296_4919",
|
||||
},
|
||||
},
|
||||
"buyer": {
|
||||
"name": "Burger Queen",
|
||||
"electronic_address": {
|
||||
"scheme": "0225",
|
||||
"value": "315143296_4920",
|
||||
},
|
||||
},
|
||||
"lines": [],
|
||||
"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",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def test_detect_ubl(self) -> None:
|
||||
result = detect_original_document(
|
||||
(
|
||||
b'<?xml version="1.0"?>'
|
||||
b'<Invoice xmlns="urn:oasis:names:'
|
||||
b'specification:ubl:schema:xsd:Invoice-2"/>'
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result["document_format"],
|
||||
"UBL",
|
||||
)
|
||||
|
||||
def test_detect_factur_x(self) -> None:
|
||||
result = detect_original_document(
|
||||
b"%PDF-1.7 test"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result["document_format"],
|
||||
"Factur-X",
|
||||
)
|
||||
|
||||
def test_build_exchange_values(self) -> None:
|
||||
values = build_inbound_exchange_values(
|
||||
self.remote_invoice(),
|
||||
company="Test",
|
||||
environment="Sandbox",
|
||||
supplier=None,
|
||||
match_method="",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
values["direction"],
|
||||
"Inbound",
|
||||
)
|
||||
self.assertEqual(
|
||||
values["remote_id"],
|
||||
"79712",
|
||||
)
|
||||
self.assertEqual(
|
||||
values["review_status"],
|
||||
"To Review",
|
||||
)
|
||||
self.assertEqual(
|
||||
values["grand_total"],
|
||||
"1863.79",
|
||||
)
|
||||
self.assertEqual(
|
||||
values["internal_status"],
|
||||
"Delivered",
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_tax_identifier(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_tax_identifier(
|
||||
" fr 15 000 000 001 "
|
||||
),
|
||||
"FR15000000001",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
normalize_tax_identifier(
|
||||
"FR15-000-000-001"
|
||||
),
|
||||
"FR15000000001",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user