feat: synchronize incoming invoices incrementally
This commit is contained in:
+29
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 "
|
||||
"d’identifiant 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 "
|
||||
"n’a 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,
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user