feat: require explicit purchase posting dates
This commit is contained in:
+60
@@ -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 d’achat 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
|
||||
);
|
||||
}
|
||||
|
||||
+10
@@ -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 d’achat",
|
||||
"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 d’achat ERPNext."
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_due_date",
|
||||
"label": "Date d’échéance",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 d’achat "
|
||||
"est obligatoire."
|
||||
),
|
||||
}
|
||||
|
||||
try:
|
||||
posting_date = getdate(raw_value)
|
||||
except (TypeError, ValueError):
|
||||
return {
|
||||
"ok": False,
|
||||
"posting_date": raw_value,
|
||||
"fiscal_year": "",
|
||||
"message": (
|
||||
"La date comptable d’achat "
|
||||
"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 d’un échange entrant."""
|
||||
|
||||
del method
|
||||
|
||||
if doc.direction != "Inbound":
|
||||
doc.purchase_posting_date = None
|
||||
return
|
||||
|
||||
# Compatibilité avec les échanges déjà importés
|
||||
# avant l’ajout 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 d’achat "
|
||||
"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 d’achat."
|
||||
)
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user