feat: require explicit purchase posting dates

This commit is contained in:
Julien Denizot
2026-06-19 14:22:34 +00:00
parent 4d5e827278
commit 0c2bfa6bf9
7 changed files with 524 additions and 1 deletions
@@ -832,3 +832,63 @@ function create_purchase_invoice_draft(frm) {
}
);
}
frappe.ui.form.on(
"Electronic Invoice Exchange",
{
refresh(frm) {
updatePurchasePostingDateDescription(
frm
);
},
purchase_posting_date(frm) {
updatePurchasePostingDateDescription(
frm
);
},
}
);
function updatePurchasePostingDateDescription(
frm
) {
if (
!frm.fields_dict
.purchase_posting_date
) {
return;
}
let description = __(
"Date utilisée comme date comptable "
+ "de la facture dachat ERPNext."
);
if (
frm.doc.direction === "Inbound"
&& frm.doc.issue_date
&& frm.doc.purchase_posting_date
&& frm.doc.issue_date
!== frm.doc.purchase_posting_date
) {
description += `
<br>
<span class="text-warning">
${__(
"Attention : la date comptable "
+ "est différente de la date "
+ "de facture fournisseur."
)}
</span>
`;
}
frm.set_df_property(
"purchase_posting_date",
"description",
description
);
}
@@ -35,6 +35,7 @@
"remote_invoice_number",
"received_at",
"issue_date",
"purchase_posting_date",
"payment_due_date",
"currency",
"inbound_amounts_column",
@@ -295,6 +296,15 @@
"fieldtype": "Date",
"read_only": 1
},
{
"fieldname": "purchase_posting_date",
"fieldtype": "Date",
"label": "Date comptable dachat",
"depends_on": "eval:doc.direction == 'Inbound'",
"mandatory_depends_on": "eval:doc.direction == 'Inbound' && doc.review_status == 'Ready for Import'",
"read_only_depends_on": "eval:!!doc.source_document",
"description": "Date utilisée comme date comptable de la facture dachat ERPNext."
},
{
"fieldname": "payment_due_date",
"label": "Date d’échéance",
+6
View File
@@ -272,6 +272,12 @@ doc_events = {
"Customer": {
"validate": "enuxia_einvoice.customer.validate_customer",
},
"Electronic Invoice Exchange": {
"validate": (
"enuxia_einvoice.purchase_dates."
"validate_exchange_purchase_posting_date"
),
},
"Purchase Invoice": {
"on_trash": (
"enuxia_einvoice.purchase_lifecycle."
+64 -1
View File
@@ -9,6 +9,10 @@ from typing import Any
import frappe
from frappe import _
from enuxia_einvoice.purchase_dates import (
require_purchase_posting_date,
)
from enuxia_einvoice.purchase_preview import (
preview_purchase_invoice,
)
@@ -403,6 +407,13 @@ def build_purchase_invoice_document(
):
"""Construire une Purchase Invoice non enregistrée."""
posting_context = (
require_purchase_posting_date(
exchange.company,
exchange.purchase_posting_date,
)
)
_assert_company_currency(
exchange.company,
preview["currency"],
@@ -480,8 +491,19 @@ def build_purchase_invoice_document(
preview["issue_date"]
),
"posting_date": (
preview["issue_date"]
posting_context[
"posting_date"
]
),
"fiscal_year": (
posting_context[
"fiscal_year"
]
),
"posting_time": (
frappe.utils.nowtime()
),
"set_posting_time": 1,
"due_date": (
preview["payment_due_date"]
),
@@ -638,6 +660,10 @@ def serialize_calculated_invoice(
"posting_date": str(
invoice.posting_date or ""
),
"fiscal_year": str(
invoice.get("fiscal_year")
or ""
),
"due_date": str(
invoice.due_date or ""
),
@@ -853,6 +879,34 @@ def calculate_purchase_invoice_in_memory(
),
]
date_checks = [
{
"code": "POSTING_DATE",
"label": (
"Date comptable ERPNext égale "
"à la date choisie"
),
"expected": str(
exchange.purchase_posting_date
or ""
),
"actual": str(
invoice.posting_date
or ""
),
"ok": (
str(
exchange.purchase_posting_date
or ""
)
== str(
invoice.posting_date
or ""
)
),
}
]
calculation_valid = (
all(
check["ok"]
@@ -862,6 +916,10 @@ def calculate_purchase_invoice_in_memory(
check["ok"]
for check in accounting_checks
)
and all(
check["ok"]
for check in date_checks
)
)
return {
@@ -886,6 +944,11 @@ def calculate_purchase_invoice_in_memory(
"accounting_checks": (
accounting_checks
),
"date_checks": date_checks,
"posting_fiscal_year": str(
invoice.get("fiscal_year")
or ""
),
"message": (
_(
"Le calcul ERPNext correspond "
+231
View File
@@ -0,0 +1,231 @@
# Copyright (c) 2026, Enuxia and contributors
# For license information, please see license.txt
from __future__ import annotations
from datetime import date
from typing import Any
import frappe
from frappe import _
from frappe.utils import getdate
class PurchaseFiscalYearError(ValueError):
"""La date comptable n'appartient à aucun exercice valide."""
def _lookup_fiscal_year(
posting_date: date,
company: str,
) -> Any:
"""
Charger ERPNext uniquement lorsqu'un site Frappe est initialisé.
Cela permet aux tests unitaires purs d'importer ce module sans
initialiser tous les composants comptables et les loggers ERPNext.
"""
from erpnext.accounts.utils import (
FiscalYearError,
get_fiscal_year,
)
try:
return get_fiscal_year(
posting_date,
company=company,
label=_("Date comptable"),
verbose=0,
as_dict=True,
)
except FiscalYearError as error:
raise PurchaseFiscalYearError(
str(error)
) from error
def validate_purchase_posting_date(
company: str,
value: Any,
) -> dict[str, Any]:
"""Valider une date comptable dans un exercice actif."""
raw_value = str(value or "").strip()
if not raw_value:
return {
"ok": False,
"posting_date": "",
"fiscal_year": "",
"message": (
"La date comptable dachat "
"est obligatoire."
),
}
try:
posting_date = getdate(raw_value)
except (TypeError, ValueError):
return {
"ok": False,
"posting_date": raw_value,
"fiscal_year": "",
"message": (
"La date comptable dachat "
"est invalide."
),
}
try:
fiscal_year = _lookup_fiscal_year(
posting_date,
company,
)
except PurchaseFiscalYearError as error:
return {
"ok": False,
"posting_date": (
posting_date.isoformat()
),
"fiscal_year": "",
"message": str(error),
}
fiscal_year_name = str(
fiscal_year.get("name")
if hasattr(fiscal_year, "get")
else ""
)
return {
"ok": True,
"posting_date": (
posting_date.isoformat()
),
"fiscal_year": fiscal_year_name,
"year_start_date": str(
fiscal_year.get(
"year_start_date"
)
or ""
),
"year_end_date": str(
fiscal_year.get(
"year_end_date"
)
or ""
),
"message": "",
}
def require_purchase_posting_date(
company: str,
value: Any,
) -> dict[str, Any]:
result = validate_purchase_posting_date(
company,
value,
)
if not result["ok"]:
frappe.throw(
result["message"],
title=_(
"Date comptable invalide"
),
)
return result
def validate_exchange_purchase_posting_date(
doc: Any,
method: str | None = None,
) -> None:
"""Valider la date comptable dun échange entrant."""
del method
if doc.direction != "Inbound":
doc.purchase_posting_date = None
return
# Compatibilité avec les échanges déjà importés
# avant lajout de ce champ.
if (
not doc.purchase_posting_date
and doc.source_doctype
== "Purchase Invoice"
and doc.source_document
):
doc.purchase_posting_date = (
frappe.db.get_value(
"Purchase Invoice",
doc.source_document,
"posting_date",
)
)
if (
doc.purchase_posting_date
or doc.review_status
in {
"Ready for Import",
"Imported",
}
):
require_purchase_posting_date(
doc.company,
doc.purchase_posting_date,
)
@frappe.whitelist()
def set_purchase_posting_date(
exchange_name: str,
posting_date: str,
) -> dict[str, Any]:
"""Définir explicitement la date comptable."""
exchange = frappe.get_doc(
"Electronic Invoice Exchange",
exchange_name,
)
exchange.check_permission("write")
if exchange.direction != "Inbound":
frappe.throw(
_(
"La date comptable dachat "
"est réservée aux échanges entrants."
)
)
if exchange.source_document:
frappe.throw(
_(
"La date comptable ne peut plus "
"être modifiée après la création "
"de la facture dachat."
)
)
result = require_purchase_posting_date(
exchange.company,
posting_date,
)
exchange.purchase_posting_date = (
result["posting_date"]
)
exchange.save()
return {
"ok": True,
"exchange": exchange.name,
**result,
}
+34
View File
@@ -297,6 +297,40 @@ def _validate_inserted_invoice(
)
)
expected_posting_date = str(
frappe.db.get_value(
"Electronic Invoice Exchange",
invoice.einvoice_exchange,
"purchase_posting_date",
)
or ""
)
accounting_checks.insert(
0,
{
"code": "POSTING_DATE",
"label": (
"Date comptable conservée "
"après insertion"
),
"expected": (
expected_posting_date
),
"actual": str(
invoice.posting_date
or ""
),
"ok": (
expected_posting_date
== str(
invoice.posting_date
or ""
)
),
},
)
return (
amount_checks,
accounting_checks,
@@ -0,0 +1,119 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from enuxia_einvoice.purchase_dates import (
PurchaseFiscalYearError,
validate_purchase_posting_date,
)
class AttrDict(dict):
def __getattr__(self, name):
return self.get(name)
class TestPurchasePostingDate(
unittest.TestCase
):
def test_missing_date(
self,
) -> None:
result = (
validate_purchase_posting_date(
"Test",
None,
)
)
self.assertFalse(result["ok"])
self.assertEqual(
result["posting_date"],
"",
)
@patch(
"enuxia_einvoice.purchase_dates."
"_lookup_fiscal_year"
)
def test_valid_date(
self,
lookup_fiscal_year,
) -> None:
lookup_fiscal_year.return_value = (
AttrDict(
{
"name": "2026",
"year_start_date": (
"2026-01-01"
),
"year_end_date": (
"2026-12-31"
),
}
)
)
result = (
validate_purchase_posting_date(
"Test",
"2026-06-19",
)
)
self.assertTrue(result["ok"])
self.assertEqual(
result["posting_date"],
"2026-06-19",
)
self.assertEqual(
result["fiscal_year"],
"2026",
)
lookup_fiscal_year.assert_called_once()
@patch(
"enuxia_einvoice.purchase_dates."
"_lookup_fiscal_year"
)
def test_date_outside_fiscal_year(
self,
lookup_fiscal_year,
) -> None:
lookup_fiscal_year.side_effect = (
PurchaseFiscalYearError(
"Date hors exercice"
)
)
result = (
validate_purchase_posting_date(
"Test",
"2099-01-01",
)
)
self.assertFalse(result["ok"])
self.assertEqual(
result["posting_date"],
"2099-01-01",
)
self.assertEqual(
result["fiscal_year"],
"",
)
self.assertEqual(
result["message"],
"Date hors exercice",
)
if __name__ == "__main__":
unittest.main()