feat: synchronize incoming invoices incrementally

This commit is contained in:
Julien Denizot
2026-06-19 14:31:51 +00:00
parent 0c2bfa6bf9
commit fbecd9df5a
4 changed files with 823 additions and 11 deletions
@@ -27,6 +27,10 @@
"identity_verification_status",
"last_connection_test",
"last_error",
"inbound_sync_section",
"inbound_last_remote_id",
"inbound_last_sync_at",
"inbound_last_error",
"payment_mentions_section",
"late_payment_penalties_note",
"recovery_costs_note",
@@ -167,6 +171,31 @@
"label": "Dernière erreur",
"read_only": 1
},
{
"fieldname": "inbound_sync_section",
"fieldtype": "Section Break",
"label": "Synchronisation des factures reçues",
"collapsible": 1
},
{
"fieldname": "inbound_last_remote_id",
"fieldtype": "Data",
"label": "Dernier identifiant entrant synchronisé",
"read_only": 1,
"description": "Curseur SuperPDP de la dernière facture entrante traitée sans erreur."
},
{
"fieldname": "inbound_last_sync_at",
"fieldtype": "Datetime",
"label": "Dernière synchronisation entrante",
"read_only": 1
},
{
"fieldname": "inbound_last_error",
"fieldtype": "Small Text",
"label": "Dernière erreur de synchronisation entrante",
"read_only": 1
},
{
"fieldname": "payment_mentions_section",
"label": "Mentions de paiement",
+35 -11
View File
@@ -598,14 +598,20 @@ def clear_supplier_match(
}
@frappe.whitelist()
def import_incoming_invoice(
def archive_incoming_invoice(
company: str,
invoice_id: int,
*,
account: Any | None = None,
client: Any | None = None,
access_token: str | None = None,
) -> dict[str, Any]:
"""Archiver une facture reçue sans créer de Purchase Invoice."""
"""
Archiver une facture reçue sans création comptable.
frappe.only_for("System Manager")
Cette fonction interne peut réutiliser un client et un jeton
déjà authentifiés lors d'une synchronisation par lot.
"""
remote_id = str(
int(invoice_id)
@@ -639,14 +645,16 @@ def import_incoming_invoice(
),
}
account = frappe.get_doc(
"Electronic Invoicing Account",
company,
)
if account is None:
account = frappe.get_doc(
"Electronic Invoicing Account",
company,
)
client, access_token = (
get_authenticated_client(company)
)
if client is None or not access_token:
client, access_token = (
get_authenticated_client(company)
)
remote_invoice = client.get_invoice(
access_token,
@@ -811,3 +819,19 @@ def import_incoming_invoice(
"sans création comptable."
),
}
@frappe.whitelist()
def import_incoming_invoice(
company: str,
invoice_id: int,
) -> dict[str, Any]:
"""Importer manuellement une facture entrante."""
frappe.only_for("System Manager")
return archive_incoming_invoice(
company,
invoice_id,
)
+522
View File
@@ -0,0 +1,522 @@
# 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.utils import now_datetime
from enuxia_einvoice.inbound_import import (
archive_incoming_invoice,
)
from enuxia_einvoice.validation import (
get_authenticated_client,
)
DEFAULT_PAGE_SIZE = 100
MAX_PAGE_SIZE = 1000
DEFAULT_MAX_PAGES = 10
MAX_MAX_PAGES = 100
def _parse_cursor(
value: Any,
) -> int | None:
raw_value = str(value or "").strip()
if not raw_value:
return None
try:
parsed = int(raw_value)
except (TypeError, ValueError):
return None
return parsed if parsed >= 0 else None
def _error_message(
error: Exception,
) -> str:
message = str(error).strip()
if not message:
message = error.__class__.__name__
return message[:2000]
def _save_account_state(
account_name: str,
*,
cursor: int | None,
error: str,
) -> None:
values: dict[str, Any] = {
"inbound_last_sync_at": (
now_datetime()
),
"inbound_last_error": error,
}
if cursor is not None:
values["inbound_last_remote_id"] = (
str(cursor)
)
frappe.db.set_value(
"Electronic Invoicing Account",
account_name,
values,
)
def _log_sync_error(
*,
company: str,
scope: str,
remote_id: int | None = None,
) -> None:
suffix = (
f" — facture {remote_id}"
if remote_id is not None
else ""
)
frappe.log_error(
message=frappe.get_traceback(),
title=(
"Synchronisation entrante SuperPDP "
f"{company}{scope}{suffix}"
),
)
def _sync_account(
account: Any,
*,
page_size: int,
max_pages: int,
) -> dict[str, Any]:
company = str(
account.company or account.name
)
cursor = _parse_cursor(
account.get(
"inbound_last_remote_id"
)
)
report: dict[str, Any] = {
"account": account.name,
"company": company,
"environment": (
account.environment
),
"ok": True,
"initial_remote_id": (
str(cursor)
if cursor is not None
else ""
),
"last_remote_id": (
str(cursor)
if cursor is not None
else ""
),
"pages": 0,
"listed": 0,
"processed": 0,
"created": 0,
"existing": 0,
"errors": [],
"invoices": [],
}
try:
client, access_token = (
get_authenticated_client(
company
)
)
except Exception as error:
message = _error_message(error)
report["ok"] = False
report["errors"].append(
{
"scope": "authentication",
"message": message,
}
)
_save_account_state(
account.name,
cursor=cursor,
error=message,
)
_log_sync_error(
company=company,
scope="authentication",
)
return report
for page_number in range(
1,
max_pages + 1,
):
try:
payload = client.list_invoices(
access_token,
direction="in",
order="asc",
starting_after_id=cursor,
limit=page_size,
)
except Exception as error:
message = _error_message(error)
report["ok"] = False
report["errors"].append(
{
"scope": "listing",
"page": page_number,
"message": message,
}
)
_save_account_state(
account.name,
cursor=cursor,
error=message,
)
_log_sync_error(
company=company,
scope="listing",
)
return report
report["pages"] += 1
raw_invoices = [
invoice
for invoice in (
payload.get("data") or []
)
if isinstance(invoice, dict)
]
report["listed"] += len(
raw_invoices
)
if not raw_invoices:
break
page_start_cursor = cursor
for overview in raw_invoices:
raw_remote_id = overview.get(
"id"
)
try:
remote_id = int(
raw_remote_id
)
except (TypeError, ValueError):
message = (
"Une facture retournée par "
"SuperPDP ne possède pas "
"didentifiant numérique valide."
)
report["ok"] = False
report["errors"].append(
{
"scope": "invoice",
"remote_id": (
raw_remote_id
),
"message": message,
}
)
_save_account_state(
account.name,
cursor=cursor,
error=message,
)
return report
savepoint = (
f"enuxia_inbound_{remote_id}"
)
frappe.db.savepoint(
savepoint
)
try:
result = (
archive_incoming_invoice(
company,
remote_id,
account=account,
client=client,
access_token=(
access_token
),
)
)
except Exception as error:
frappe.db.rollback(
save_point=savepoint
)
message = _error_message(
error
)
report["ok"] = False
report["errors"].append(
{
"scope": "invoice",
"remote_id": (
remote_id
),
"message": message,
}
)
_save_account_state(
account.name,
cursor=cursor,
error=message,
)
_log_sync_error(
company=company,
scope="invoice",
remote_id=remote_id,
)
# Le curseur ne dépasse jamais
# une facture ayant échoué.
return report
cursor = remote_id
report["processed"] += 1
if result.get("created"):
report["created"] += 1
else:
report["existing"] += 1
report["invoices"].append(
{
"remote_id": (
str(remote_id)
),
"created": bool(
result.get(
"created"
)
),
"exchange": (
result.get(
"exchange"
)
),
"review_status": (
result.get(
"review_status"
)
),
"supplier": (
result.get(
"supplier"
)
),
}
)
if (
cursor == page_start_cursor
and payload.get("has_after")
):
message = (
"La pagination SuperPDP "
"na pas progressé."
)
report["ok"] = False
report["errors"].append(
{
"scope": "pagination",
"page": page_number,
"message": message,
}
)
_save_account_state(
account.name,
cursor=cursor,
error=message,
)
return report
if not payload.get("has_after"):
break
report["last_remote_id"] = (
str(cursor)
if cursor is not None
else ""
)
_save_account_state(
account.name,
cursor=cursor,
error="",
)
return report
def run_incoming_sync(
*,
company: str | None = None,
page_size: int = DEFAULT_PAGE_SIZE,
max_pages: int = DEFAULT_MAX_PAGES,
) -> dict[str, Any]:
"""Synchroniser les factures entrantes des comptes actifs."""
normalized_company = str(
company or ""
).strip()
normalized_page_size = max(
1,
min(
int(page_size),
MAX_PAGE_SIZE,
),
)
normalized_max_pages = max(
1,
min(
int(max_pages),
MAX_MAX_PAGES,
),
)
filters: dict[str, Any] = {
"enabled": 1,
"provider": "SuperPDP",
}
if normalized_company:
filters["company"] = (
normalized_company
)
accounts = frappe.get_all(
"Electronic Invoicing Account",
filters=filters,
fields=[
"name",
"company",
"environment",
"remote_company_id",
"remote_company_number",
"inbound_last_remote_id",
],
order_by="company asc",
limit_page_length=0,
)
account_reports = [
_sync_account(
account,
page_size=(
normalized_page_size
),
max_pages=(
normalized_max_pages
),
)
for account in accounts
]
created = sum(
report["created"]
for report in account_reports
)
existing = sum(
report["existing"]
for report in account_reports
)
processed = sum(
report["processed"]
for report in account_reports
)
error_count = sum(
len(report["errors"])
for report in account_reports
)
return {
"ok": error_count == 0,
"company_filter": (
normalized_company
),
"page_size": (
normalized_page_size
),
"max_pages": (
normalized_max_pages
),
"account_count": len(
account_reports
),
"processed": processed,
"created": created,
"existing": existing,
"error_count": error_count,
"accounts": account_reports,
}
@frappe.whitelist()
def sync_incoming_invoices(
company: str | None = None,
page_size: int = DEFAULT_PAGE_SIZE,
max_pages: int = DEFAULT_MAX_PAGES,
) -> dict[str, Any]:
"""Lancer manuellement la synchronisation entrante."""
frappe.only_for(
"System Manager"
)
return run_incoming_sync(
company=company,
page_size=page_size,
max_pages=max_pages,
)
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
import unittest
from unittest.mock import Mock, patch
from enuxia_einvoice.inbound_sync import (
_sync_account,
)
class AttrDict(dict):
def __getattr__(self, name):
return self.get(name)
class TestInboundSync(
unittest.TestCase
):
def test_sync_creates_and_skips_existing(
self,
) -> None:
account = AttrDict(
{
"name": "Test",
"company": "Test",
"environment": "Sandbox",
"remote_company_id": "1",
"remote_company_number": (
"000000002"
),
"inbound_last_remote_id": None,
}
)
client = Mock()
client.list_invoices.return_value = {
"data": [
{"id": 79712},
{"id": 79713},
],
"has_after": False,
}
with (
patch(
"enuxia_einvoice.inbound_sync."
"get_authenticated_client",
return_value=(
client,
"token",
),
),
patch(
"enuxia_einvoice.inbound_sync."
"archive_incoming_invoice",
side_effect=[
{
"created": False,
"exchange": (
"superpdp-in-79712"
),
"review_status": (
"Imported"
),
"supplier": "Tricatel",
},
{
"created": True,
"exchange": (
"superpdp-in-79713"
),
"review_status": (
"To Review"
),
"supplier": "",
},
],
),
patch(
"enuxia_einvoice.inbound_sync."
"now_datetime",
return_value=(
"2026-06-19 14:30:00"
),
),
patch(
"enuxia_einvoice.inbound_sync."
"frappe"
) as frappe_mock,
):
result = _sync_account(
account,
page_size=100,
max_pages=5,
)
self.assertTrue(result["ok"])
self.assertEqual(
result["processed"],
2,
)
self.assertEqual(
result["created"],
1,
)
self.assertEqual(
result["existing"],
1,
)
self.assertEqual(
result["last_remote_id"],
"79713",
)
state = (
frappe_mock.db.set_value
.call_args.args[2]
)
self.assertEqual(
state[
"inbound_last_remote_id"
],
"79713",
)
self.assertEqual(
state[
"inbound_last_error"
],
"",
)
def test_failed_invoice_does_not_advance_cursor(
self,
) -> None:
account = AttrDict(
{
"name": "Test",
"company": "Test",
"environment": "Sandbox",
"remote_company_id": "1",
"remote_company_number": (
"000000002"
),
"inbound_last_remote_id": (
"79711"
),
}
)
client = Mock()
client.list_invoices.return_value = {
"data": [
{"id": 79712},
],
"has_after": False,
}
with (
patch(
"enuxia_einvoice.inbound_sync."
"get_authenticated_client",
return_value=(
client,
"token",
),
),
patch(
"enuxia_einvoice.inbound_sync."
"archive_incoming_invoice",
side_effect=RuntimeError(
"Téléchargement impossible"
),
),
patch(
"enuxia_einvoice.inbound_sync."
"now_datetime",
return_value=(
"2026-06-19 14:30:00"
),
),
patch(
"enuxia_einvoice.inbound_sync."
"frappe"
) as frappe_mock,
):
frappe_mock.get_traceback.return_value = (
"trace"
)
result = _sync_account(
account,
page_size=100,
max_pages=5,
)
self.assertFalse(result["ok"])
self.assertEqual(
result["processed"],
0,
)
self.assertEqual(
result["last_remote_id"],
"79711",
)
frappe_mock.db.rollback.assert_called_once_with(
save_point=(
"enuxia_inbound_79712"
)
)
state = (
frappe_mock.db.set_value
.call_args.args[2]
)
self.assertEqual(
state[
"inbound_last_remote_id"
],
"79711",
)
self.assertIn(
"Téléchargement impossible",
state[
"inbound_last_error"
],
)
if __name__ == "__main__":
unittest.main()