mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-01 15:47:06 +00:00
refactor(accounts): repost accounting ledger (#56442)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 372dff2ffa)
# Conflicts:
# erpnext/accounts/doctype/repost_accounting_ledger/repost_accounting_ledger.json
# erpnext/accounts/doctype/repost_accounting_ledger/test_repost_accounting_ledger.py
# erpnext/accounts/doctype/repost_accounting_ledger_items/repost_accounting_ledger_items.json
# erpnext/patches.txt
This commit is contained in:
@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
refresh: function (frm) {
|
refresh: function (frm) {
|
||||||
frm.add_custom_button(__("Show Preview"), () => {
|
// the server refuses only while the job is alive, so a dead one can be restarted here
|
||||||
frm.call({
|
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
|
||||||
method: "generate_preview",
|
frm.add_custom_button(__("Start Reposting"), () => {
|
||||||
doc: frm.doc,
|
frm.events.start_repost(frm);
|
||||||
freeze: true,
|
|
||||||
freeze_message: __("Generating Preview"),
|
|
||||||
callback: function (r) {
|
|
||||||
if (r && r.message) {
|
|
||||||
let content = r.message;
|
|
||||||
let opts = {
|
|
||||||
title: "Preview",
|
|
||||||
subtitle: "preview",
|
|
||||||
content: content,
|
|
||||||
print_settings: { orientation: "landscape" },
|
|
||||||
columns: [],
|
|
||||||
data: [],
|
|
||||||
};
|
|
||||||
frappe.render_grid(opts);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frm.doc.docstatus != 2) {
|
||||||
|
frm.add_custom_button(__("Show Preview"), () => {
|
||||||
|
frm.events.generate_preview(frm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generate_preview: function (frm) {
|
||||||
|
frm.call({
|
||||||
|
method: "generate_preview",
|
||||||
|
doc: frm.doc,
|
||||||
|
freeze: true,
|
||||||
|
freeze_message: __("Generating Preview"),
|
||||||
|
callback: function (r) {
|
||||||
|
if (r && r.message) {
|
||||||
|
let content = r.message;
|
||||||
|
let opts = {
|
||||||
|
title: "Preview",
|
||||||
|
subtitle: "preview",
|
||||||
|
content: content,
|
||||||
|
print_settings: { orientation: "landscape" },
|
||||||
|
columns: [],
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
frappe.render_grid(opts);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
start_repost: function (frm) {
|
||||||
|
frm.call({
|
||||||
|
method: "start_repost",
|
||||||
|
doc: frm.doc,
|
||||||
|
callback: function (r) {
|
||||||
|
frm.reload_doc();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"creation": "2023-07-04 13:07:32.923675",
|
"creation": "2023-07-04 13:07:32.923675",
|
||||||
"default_view": "List",
|
"default_view": "List",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -7,16 +8,24 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"company",
|
"company",
|
||||||
"column_break_vpup",
|
|
||||||
"delete_cancelled_entries",
|
"delete_cancelled_entries",
|
||||||
|
"column_break_vpup",
|
||||||
|
"status",
|
||||||
"section_break_metl",
|
"section_break_metl",
|
||||||
"vouchers",
|
"vouchers",
|
||||||
"amended_from"
|
"error_section",
|
||||||
|
"error_log",
|
||||||
|
"miscellaneous_section",
|
||||||
|
"amended_from",
|
||||||
|
"column_break_hrah",
|
||||||
|
"scheduled_job"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
"fieldname": "company",
|
"fieldname": "company",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
"label": "Company",
|
"label": "Company",
|
||||||
"options": "Company"
|
"options": "Company"
|
||||||
},
|
},
|
||||||
@@ -48,12 +57,54 @@
|
|||||||
"fieldname": "delete_cancelled_entries",
|
"fieldname": "delete_cancelled_entries",
|
||||||
"fieldtype": "Check",
|
"fieldtype": "Check",
|
||||||
"label": "Delete Cancelled Ledger Entries"
|
"label": "Delete Cancelled Ledger Entries"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "error_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "error_log",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Error Log",
|
||||||
|
"no_copy": 1,
|
||||||
|
"print_hide": 1,
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "miscellaneous_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Miscellaneous"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "column_break_hrah",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"depends_on": "eval:doc.docstatus >= 1;",
|
||||||
|
"fieldname": "status",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"in_standard_filter": 1,
|
||||||
|
"label": "Status",
|
||||||
|
"no_copy": 1,
|
||||||
|
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "scheduled_job",
|
||||||
|
"fieldtype": "Link",
|
||||||
|
"hidden": 1,
|
||||||
|
"label": "Scheduled Job",
|
||||||
|
"no_copy": 1,
|
||||||
|
"options": "RQ Job",
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-06-03 17:30:37.012593",
|
"modified": "2026-07-28 00:56:50.290314",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Repost Accounting Ledger",
|
"name": "Repost Accounting Ledger",
|
||||||
@@ -76,8 +127,13 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
<<<<<<< HEAD
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
|
=======
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
"track_changes": 1
|
"track_changes": 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ import frappe
|
|||||||
from frappe import _, qb
|
from frappe import _, qb
|
||||||
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
|
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
|
||||||
from frappe.utils.data import comma_and
|
from frappe.utils.data import comma_and
|
||||||
|
from frappe.utils.scheduler import is_scheduler_inactive
|
||||||
|
|
||||||
|
# a batch has to finish well within the timeout of the job reposting it
|
||||||
|
MAX_VOUCHERS_PER_REPOST = 50
|
||||||
|
|
||||||
|
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
|
||||||
|
|
||||||
from erpnext.stock import get_warehouse_account_map
|
from erpnext.stock import get_warehouse_account_map
|
||||||
|
|
||||||
@@ -28,6 +35,11 @@ class RepostAccountingLedger(Document):
|
|||||||
amended_from: DF.Link | None
|
amended_from: DF.Link | None
|
||||||
company: DF.Link | None
|
company: DF.Link | None
|
||||||
delete_cancelled_entries: DF.Check
|
delete_cancelled_entries: DF.Check
|
||||||
|
error_log: DF.Code | None
|
||||||
|
scheduled_job: DF.Link | None
|
||||||
|
status: DF.Literal[
|
||||||
|
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
|
||||||
|
]
|
||||||
vouchers: DF.Table[RepostAccountingLedgerItems]
|
vouchers: DF.Table[RepostAccountingLedgerItems]
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
@@ -37,6 +49,11 @@ class RepostAccountingLedger(Document):
|
|||||||
|
|
||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_vouchers()
|
self.validate_vouchers()
|
||||||
|
self.validate_repost_preconditions()
|
||||||
|
|
||||||
|
def validate_repost_preconditions(self):
|
||||||
|
"""The checks a repost queued days ago could have outlived, re-run before it touches
|
||||||
|
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
|
||||||
self.validate_for_closed_fiscal_year()
|
self.validate_for_closed_fiscal_year()
|
||||||
self.validate_for_deferred_accounting()
|
self.validate_for_deferred_accounting()
|
||||||
|
|
||||||
@@ -73,8 +90,52 @@ class RepostAccountingLedger(Document):
|
|||||||
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
|
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
|
||||||
|
|
||||||
def validate_vouchers(self):
|
def validate_vouchers(self):
|
||||||
if self.vouchers:
|
if not self.vouchers:
|
||||||
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
frappe.throw(_("Add atleast one voucher to repost."))
|
||||||
|
|
||||||
|
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
|
||||||
|
frappe.throw(
|
||||||
|
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
|
||||||
|
MAX_VOUCHERS_PER_REPOST
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
|
||||||
|
|
||||||
|
self.validate_no_duplicate_vouchers()
|
||||||
|
self.validate_vouchers_are_submitted()
|
||||||
|
|
||||||
|
def validate_no_duplicate_vouchers(self):
|
||||||
|
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
|
||||||
|
|
||||||
|
if len(vouchers) != len(set(vouchers)):
|
||||||
|
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
|
||||||
|
|
||||||
|
def validate_vouchers_are_submitted(self):
|
||||||
|
voucher_type_wise_map = {}
|
||||||
|
for d in self.vouchers:
|
||||||
|
voucher_type_wise_map.setdefault(d.voucher_type, [])
|
||||||
|
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
|
||||||
|
|
||||||
|
non_submitted_vouchers = []
|
||||||
|
for key in voucher_type_wise_map.keys():
|
||||||
|
non_submitted_vouchers.extend(
|
||||||
|
frappe.get_all(
|
||||||
|
key,
|
||||||
|
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if non_submitted_vouchers:
|
||||||
|
frappe.throw(
|
||||||
|
_("The following vouchers are not submitted: {0}").format(
|
||||||
|
comma_and(non_submitted_vouchers, add_quotes=True)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_discard(self):
|
||||||
|
self.db_set("status", "Cancelled")
|
||||||
|
|
||||||
def get_existing_ledger_entries(self):
|
def get_existing_ledger_entries(self):
|
||||||
vouchers = [x.voucher_no for x in self.vouchers]
|
vouchers = [x.voucher_no for x in self.vouchers]
|
||||||
@@ -139,80 +200,245 @@ class RepostAccountingLedger(Document):
|
|||||||
return rendered_page
|
return rendered_page
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
if len(self.vouchers) > 5:
|
self.start_repost()
|
||||||
job_name = "repost_accounting_ledger_" + self.name
|
|
||||||
frappe.enqueue(
|
def before_cancel(self):
|
||||||
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
|
self._raise_error_if_reposting_in_progress()
|
||||||
account_repost_doc=self.name,
|
|
||||||
is_async=True,
|
def on_cancel(self):
|
||||||
job_name=job_name,
|
self.db_set("status", "Cancelled")
|
||||||
enqueue_after_commit=True,
|
|
||||||
|
def _raise_error_if_reposting_in_progress(self):
|
||||||
|
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
|
||||||
|
frappe.throw(_("Reposting is still in progress in background."))
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def start_repost(self):
|
||||||
|
if self.docstatus != 1:
|
||||||
|
frappe.throw(_("Reposting can be started only for submitted document."))
|
||||||
|
|
||||||
|
# under a row lock, so two concurrent starts cannot both get past here
|
||||||
|
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
|
||||||
|
if status in ("Completed", "Cancelled"):
|
||||||
|
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
|
||||||
|
|
||||||
|
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
|
||||||
|
# died leaves the status behind and the document has to stay restartable
|
||||||
|
self._raise_error_if_reposting_in_progress()
|
||||||
|
|
||||||
|
self.check_permission("write")
|
||||||
|
|
||||||
|
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
|
||||||
|
if is_scheduler_inactive():
|
||||||
|
frappe.msgprint(
|
||||||
|
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
|
||||||
|
alert=True,
|
||||||
|
indicator="orange",
|
||||||
)
|
)
|
||||||
frappe.msgprint(_("Repost has started in the background"))
|
|
||||||
else:
|
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
|
||||||
start_repost(self.name)
|
_enqueue_repost(self.name)
|
||||||
|
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
|
||||||
|
|
||||||
|
|
||||||
@frappe.whitelist()
|
def _repost_job_id(repost_doc_name: str) -> str:
|
||||||
def start_repost(account_repost_doc: str | None = None) -> None:
|
"""Derived from the document, so a repost can only ever have one job."""
|
||||||
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
return f"repost_accounting_ledger::{repost_doc_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _enqueue_repost(repost_doc_name: str) -> None:
|
||||||
|
"""Hand the repost to a background worker.
|
||||||
|
|
||||||
|
Tests run it in the foreground, inside their own transaction: documents edited after submit
|
||||||
|
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
|
||||||
|
ledger right after doing so.
|
||||||
|
"""
|
||||||
|
frappe.enqueue(
|
||||||
|
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
|
||||||
|
repost_doc_name=repost_doc_name,
|
||||||
|
commit=not frappe.in_test,
|
||||||
|
queue="long",
|
||||||
|
timeout=1500,
|
||||||
|
job_id=_repost_job_id(repost_doc_name),
|
||||||
|
deduplicate=True,
|
||||||
|
enqueue_after_commit=True,
|
||||||
|
now=frappe.in_test,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _lock_vouchers(vouchers) -> dict:
|
||||||
|
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
|
||||||
|
|
||||||
|
Returns them keyed by voucher, so reposting does not load them again. These are file locks
|
||||||
|
under the site directory: they serialise nothing across hosts that do not share it, and a
|
||||||
|
worker killed outright leaves them behind until they expire.
|
||||||
|
"""
|
||||||
|
locked_docs = {}
|
||||||
|
try:
|
||||||
|
for x in vouchers:
|
||||||
|
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
||||||
|
doc.lock()
|
||||||
|
locked_docs[(x.voucher_type, x.voucher_no)] = doc
|
||||||
|
except Exception:
|
||||||
|
for doc in locked_docs.values():
|
||||||
|
doc.unlock()
|
||||||
|
raise
|
||||||
|
return locked_docs
|
||||||
|
|
||||||
|
|
||||||
|
def repost(repost_doc_name: str, commit: bool = True):
|
||||||
|
"""Repost every voucher of the document, one transaction at a time.
|
||||||
|
|
||||||
|
`commit` says whether this call owns the transaction. The background job does, and commits
|
||||||
|
after every voucher so progress survives a crash; a caller inside its own passes `False`.
|
||||||
|
"""
|
||||||
|
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
|
||||||
|
|
||||||
frappe.flags.through_repost_accounting_ledger = True
|
frappe.flags.through_repost_accounting_ledger = True
|
||||||
if account_repost_doc:
|
|
||||||
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
|
|
||||||
repost_doc.check_permission("write")
|
|
||||||
|
|
||||||
if repost_doc.docstatus == 1:
|
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
|
||||||
# Prevent repost on invoices with deferred accounting
|
locked_docs = {}
|
||||||
repost_doc.validate_for_deferred_accounting()
|
|
||||||
|
|
||||||
for x in repost_doc.vouchers:
|
try:
|
||||||
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
|
repost_doc.validate_repost_preconditions()
|
||||||
|
|
||||||
|
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
|
||||||
|
# and not reposted again
|
||||||
|
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
|
||||||
|
locked_docs = _lock_vouchers(pending)
|
||||||
|
|
||||||
|
repost_doc.db_set("status", "In Progress", commit=commit)
|
||||||
|
|
||||||
|
for position, x in enumerate(pending, start=1):
|
||||||
|
frappe.publish_progress(
|
||||||
|
position * 100 / len(pending),
|
||||||
|
doctype=repost_doc.doctype,
|
||||||
|
docname=repost_doc.name,
|
||||||
|
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
|
||||||
|
)
|
||||||
|
|
||||||
|
save_point = "reposting"
|
||||||
|
frappe.db.savepoint(save_point=save_point)
|
||||||
|
try:
|
||||||
|
doc = locked_docs[(x.voucher_type, x.voucher_no)]
|
||||||
|
|
||||||
|
if doc.docstatus == 2:
|
||||||
|
x.db_set({"status": "Skipped", "traceback": ""})
|
||||||
|
continue
|
||||||
|
|
||||||
if repost_doc.delete_cancelled_entries:
|
if repost_doc.delete_cancelled_entries:
|
||||||
frappe.db.delete(
|
_delete_accounting_ledger_entries(doc.doctype, doc.name)
|
||||||
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
_delete_adv_pl_entries(doc.doctype, doc.name)
|
||||||
)
|
|
||||||
frappe.db.delete(
|
|
||||||
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
|
|
||||||
)
|
|
||||||
frappe.db.delete(
|
|
||||||
"Advance Payment Ledger Entry",
|
|
||||||
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
|
|
||||||
)
|
|
||||||
|
|
||||||
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
|
||||||
if not repost_doc.delete_cancelled_entries:
|
except Exception:
|
||||||
doc.docstatus = 2
|
frappe.db.rollback(save_point=save_point)
|
||||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
|
||||||
|
|
||||||
doc.docstatus = 1
|
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
|
||||||
if doc.doctype == "Sales Invoice":
|
else:
|
||||||
doc.force_set_against_income_account()
|
x.db_set({"status": "Reposted", "traceback": ""})
|
||||||
else:
|
finally:
|
||||||
doc.force_set_against_expense_account()
|
if commit:
|
||||||
doc.make_gl_entries()
|
frappe.db.commit() # nosemgrep
|
||||||
|
|
||||||
elif doc.doctype == "Purchase Receipt":
|
except Exception:
|
||||||
if not repost_doc.delete_cancelled_entries:
|
if commit:
|
||||||
doc.docstatus = 2
|
frappe.db.rollback()
|
||||||
doc.make_gl_entries_on_cancel(from_repost=True)
|
|
||||||
|
|
||||||
doc.docstatus = 1
|
_record_repost_failure(repost_doc, commit=commit)
|
||||||
doc.make_gl_entries(from_repost=True)
|
raise
|
||||||
|
else:
|
||||||
|
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
|
||||||
|
finally:
|
||||||
|
for doc in locked_docs.values():
|
||||||
|
doc.unlock()
|
||||||
|
if commit:
|
||||||
|
frappe.db.commit() # nosemgrep
|
||||||
|
|
||||||
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
|
|
||||||
if not repost_doc.delete_cancelled_entries:
|
def _derive_status(repost_doc) -> str:
|
||||||
doc.make_gl_entries(1)
|
"""Vouchers are committed one by one, so the status follows what was actually handled."""
|
||||||
doc.make_gl_entries()
|
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
|
||||||
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
|
||||||
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
|
if handled == len(repost_doc.vouchers):
|
||||||
if not repost_doc.delete_cancelled_entries:
|
return "Completed"
|
||||||
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
|
elif handled == 0:
|
||||||
doc.make_gl_entries(cancel=1)
|
return "Failed"
|
||||||
else:
|
|
||||||
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
|
return "Partially Reposted"
|
||||||
doc.make_gl_entries()
|
|
||||||
|
|
||||||
|
def _record_repost_failure(repost_doc, commit=False) -> None:
|
||||||
|
"""Persist the traceback of a run that could not finish, without discarding its progress."""
|
||||||
|
# the traceback with frame locals goes to the Error Log, which is permissioned separately
|
||||||
|
traceback = frappe.get_traceback()
|
||||||
|
|
||||||
|
frappe.log_error(
|
||||||
|
title=_("Unable to Repost Accounting Ledger"),
|
||||||
|
reference_doctype=repost_doc.doctype,
|
||||||
|
reference_name=repost_doc.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
frappe.db.set_value(
|
||||||
|
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
|
||||||
|
)
|
||||||
|
|
||||||
|
if commit:
|
||||||
|
frappe.db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
|
||||||
|
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||||
|
_repost_invoices(doc, delete_cancelled_entries)
|
||||||
|
|
||||||
|
elif doc.doctype == "Purchase Receipt":
|
||||||
|
_repost_purchase_receipt(doc, delete_cancelled_entries)
|
||||||
|
|
||||||
|
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
|
||||||
|
_repost_pe_je(doc, delete_cancelled_entries)
|
||||||
|
|
||||||
|
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
|
||||||
|
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
|
||||||
|
|
||||||
|
|
||||||
|
def _repost_invoices(invoice_doc, delete_cancelled_entries):
|
||||||
|
if not delete_cancelled_entries:
|
||||||
|
invoice_doc.docstatus = 2
|
||||||
|
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||||
|
|
||||||
|
invoice_doc.docstatus = 1
|
||||||
|
if invoice_doc.doctype == "Sales Invoice":
|
||||||
|
invoice_doc.force_set_against_income_account()
|
||||||
|
else:
|
||||||
|
invoice_doc.force_set_against_expense_account()
|
||||||
|
invoice_doc.make_gl_entries()
|
||||||
|
|
||||||
|
|
||||||
|
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
|
||||||
|
if not delete_cancelled_entries:
|
||||||
|
receipt_doc.docstatus = 2
|
||||||
|
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
|
||||||
|
|
||||||
|
receipt_doc.docstatus = 1
|
||||||
|
receipt_doc.make_gl_entries(from_repost=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _repost_pe_je(entry_doc, delete_cancelled_entries):
|
||||||
|
if not delete_cancelled_entries:
|
||||||
|
entry_doc.make_gl_entries(cancel=1)
|
||||||
|
entry_doc.make_gl_entries()
|
||||||
|
|
||||||
|
|
||||||
|
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
|
||||||
|
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||||
|
|
||||||
|
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
|
||||||
|
if not delete_cancelled_entries:
|
||||||
|
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
|
||||||
|
repost_doc.make_gl_entries(cancel=1)
|
||||||
|
else:
|
||||||
|
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
|
||||||
|
repost_doc.make_gl_entries()
|
||||||
|
|
||||||
|
|
||||||
def get_allowed_types_from_settings(child_doc: bool = False):
|
def get_allowed_types_from_settings(child_doc: bool = False):
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
frappe.listview_settings["Repost Accounting Ledger"] = {
|
||||||
|
add_fields: ["status"],
|
||||||
|
// drafts and cancelled documents are coloured by the framework before it gets here
|
||||||
|
get_indicator: function (doc) {
|
||||||
|
if (!doc.status) return;
|
||||||
|
|
||||||
|
const status_color = {
|
||||||
|
Queued: "yellow",
|
||||||
|
"In Progress": "blue",
|
||||||
|
"Partially Reposted": "orange",
|
||||||
|
Completed: "green",
|
||||||
|
Failed: "red",
|
||||||
|
};
|
||||||
|
return [__(doc.status), status_color[doc.status] || "gray", "status,=," + doc.status];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,20 +1,35 @@
|
|||||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import qb
|
from frappe import qb
|
||||||
from frappe.query_builder.functions import Sum
|
from frappe.query_builder.functions import Sum
|
||||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||||
from frappe.utils import add_days, nowdate, today
|
from frappe.utils import add_days, nowdate, today
|
||||||
|
|
||||||
|
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||||
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
|
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
|
||||||
|
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
|
||||||
|
_lock_vouchers,
|
||||||
|
_record_repost_failure,
|
||||||
|
_repost_allowed_hook_doctypes,
|
||||||
|
_repost_job_id,
|
||||||
|
_repost_vouchers,
|
||||||
|
repost,
|
||||||
|
)
|
||||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||||
from erpnext.accounts.utils import get_fiscal_year
|
from erpnext.accounts.utils import get_fiscal_year
|
||||||
from erpnext.stock.doctype.item.test_item import make_item
|
from erpnext.stock.doctype.item.test_item import make_item
|
||||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
|
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
|
||||||
|
|
||||||
|
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
|
||||||
|
SIMULATED_FAILURE = "Simulated repost failure"
|
||||||
|
|
||||||
|
|
||||||
class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -23,6 +38,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
self.create_item()
|
self.create_item()
|
||||||
update_repost_settings()
|
update_repost_settings()
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
frappe.db.rollback()
|
frappe.db.rollback()
|
||||||
|
|
||||||
@@ -34,9 +50,82 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
debit_to=self.debit_to,
|
debit_to=self.debit_to,
|
||||||
parent_cost_center=self.cost_center,
|
parent_cost_center=self.cost_center,
|
||||||
cost_center=self.cost_center,
|
cost_center=self.cost_center,
|
||||||
|
=======
|
||||||
|
def make_invoice(self, **kwargs):
|
||||||
|
return create_sales_invoice(
|
||||||
|
item="_Test Item",
|
||||||
|
company="_Test Company",
|
||||||
|
customer="_Test Customer",
|
||||||
|
debit_to="Debtors - _TC",
|
||||||
|
parent_cost_center="Main - _TC",
|
||||||
|
cost_center="Main - _TC",
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
rate=100,
|
rate=100,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def make_invoice_and_payment(self):
|
||||||
|
si = self.make_invoice()
|
||||||
|
pe = get_payment_entry(si.doctype, si.name)
|
||||||
|
pe.save().submit()
|
||||||
|
return si, pe
|
||||||
|
|
||||||
|
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
|
||||||
|
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||||
|
ral.company = "_Test Company"
|
||||||
|
ral.delete_cancelled_entries = delete_cancelled_entries
|
||||||
|
for voucher in vouchers:
|
||||||
|
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
|
||||||
|
|
||||||
|
ral.save()
|
||||||
|
if submit:
|
||||||
|
ral.submit()
|
||||||
|
ral.reload()
|
||||||
|
return ral
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def patched_repost(self, fail_for=()):
|
||||||
|
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
|
||||||
|
reposted = []
|
||||||
|
|
||||||
|
def repost_voucher(doc, delete_cancelled_entries):
|
||||||
|
reposted.append(doc.name)
|
||||||
|
if doc.doctype in fail_for:
|
||||||
|
frappe.throw(SIMULATED_FAILURE)
|
||||||
|
_repost_vouchers(doc, delete_cancelled_entries)
|
||||||
|
|
||||||
|
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
|
||||||
|
yield reposted
|
||||||
|
|
||||||
|
def make_period_closing_voucher(self):
|
||||||
|
fy = get_fiscal_year(today(), company="_Test Company")
|
||||||
|
pcv = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Period Closing Voucher",
|
||||||
|
"transaction_date": today(),
|
||||||
|
"period_start_date": fy[1],
|
||||||
|
"period_end_date": today(),
|
||||||
|
"company": "_Test Company",
|
||||||
|
"fiscal_year": fy[0],
|
||||||
|
"cost_center": "Main - _TC",
|
||||||
|
"closing_account_head": "Retained Earnings - _TC",
|
||||||
|
"remarks": "test",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return pcv.save().submit()
|
||||||
|
|
||||||
|
def get_gl_totals(self, voucher_no, is_cancelled=0):
|
||||||
|
gl = qb.DocType("GL Entry")
|
||||||
|
return (
|
||||||
|
qb.from_(gl)
|
||||||
|
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||||
|
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
|
||||||
|
.run()
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
def test_01_basic_functions(self):
|
||||||
|
si = self.make_invoice()
|
||||||
|
|
||||||
preq = frappe.get_doc(
|
preq = frappe.get_doc(
|
||||||
make_payment_request(
|
make_payment_request(
|
||||||
dt=si.doctype,
|
dt=si.doctype,
|
||||||
@@ -70,6 +159,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to})
|
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": self.debit_to})
|
||||||
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
|
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
gl = qb.DocType("GL Entry")
|
gl = qb.DocType("GL Entry")
|
||||||
res = (
|
res = (
|
||||||
qb.from_(gl)
|
qb.from_(gl)
|
||||||
@@ -78,12 +168,15 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
.run()
|
.run()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
# Assert incorrect ledger balance
|
# Assert incorrect ledger balance
|
||||||
self.assertNotEqual(res[0], (si.name, 100, 100))
|
self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
|
||||||
|
|
||||||
# Submit repost document
|
# Submit repost document
|
||||||
ral.save().submit()
|
ral.save().submit()
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
res = (
|
res = (
|
||||||
qb.from_(gl)
|
qb.from_(gl)
|
||||||
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
|
||||||
@@ -91,10 +184,13 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
.run()
|
.run()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
=======
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
# Ledger should reflect correct amount post repost
|
# Ledger should reflect correct amount post repost
|
||||||
self.assertEqual(res[0], (si.name, 100, 100))
|
self.assertEqual(self.get_gl_totals(si.name), (100, 100))
|
||||||
|
|
||||||
def test_02_deferred_accounting_valiations(self):
|
def test_02_deferred_accounting_valiations(self):
|
||||||
|
<<<<<<< HEAD
|
||||||
si = create_sales_invoice(
|
si = create_sales_invoice(
|
||||||
item=self.item,
|
item=self.item,
|
||||||
company=self.company,
|
company=self.company,
|
||||||
@@ -105,16 +201,23 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
rate=100,
|
rate=100,
|
||||||
do_not_submit=True,
|
do_not_submit=True,
|
||||||
)
|
)
|
||||||
|
=======
|
||||||
|
si = self.make_invoice(do_not_submit=True)
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
si.items[0].enable_deferred_revenue = True
|
si.items[0].enable_deferred_revenue = True
|
||||||
si.items[0].deferred_revenue_account = self.deferred_revenue
|
si.items[0].deferred_revenue_account = self.deferred_revenue
|
||||||
si.items[0].service_start_date = nowdate()
|
si.items[0].service_start_date = nowdate()
|
||||||
si.items[0].service_end_date = add_days(nowdate(), 90)
|
si.items[0].service_end_date = add_days(nowdate(), 90)
|
||||||
si.save().submit()
|
si.save().submit()
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
ral = frappe.new_doc("Repost Accounting Ledger")
|
ral = frappe.new_doc("Repost Accounting Ledger")
|
||||||
ral.company = self.company
|
ral.company = self.company
|
||||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||||
self.assertRaises(frappe.ValidationError, ral.save)
|
self.assertRaises(frappe.ValidationError, ral.save)
|
||||||
|
=======
|
||||||
|
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
|
|
||||||
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
@change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||||
def test_04_pcv_validation(self):
|
def test_04_pcv_validation(self):
|
||||||
@@ -122,6 +225,7 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
gl = frappe.qb.DocType("GL Entry")
|
gl = frappe.qb.DocType("GL Entry")
|
||||||
qb.from_(gl).delete().where(gl.company == self.company).run()
|
qb.from_(gl).delete().where(gl.company == self.company).run()
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
si = create_sales_invoice(
|
si = create_sales_invoice(
|
||||||
item=self.item,
|
item=self.item,
|
||||||
company=self.company,
|
company=self.company,
|
||||||
@@ -151,12 +255,19 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
ral.company = self.company
|
ral.company = self.company
|
||||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||||
self.assertRaises(frappe.ValidationError, ral.save)
|
self.assertRaises(frappe.ValidationError, ral.save)
|
||||||
|
=======
|
||||||
|
si = self.make_invoice()
|
||||||
|
pcv = self.make_period_closing_voucher()
|
||||||
|
|
||||||
|
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
|
|
||||||
pcv.reload()
|
pcv.reload()
|
||||||
pcv.cancel()
|
pcv.cancel()
|
||||||
pcv.delete()
|
pcv.delete()
|
||||||
|
|
||||||
def test_03_deletion_flag_and_preview_function(self):
|
def test_03_deletion_flag_and_preview_function(self):
|
||||||
|
<<<<<<< HEAD
|
||||||
si = create_sales_invoice(
|
si = create_sales_invoice(
|
||||||
item=self.item,
|
item=self.item,
|
||||||
company=self.company,
|
company=self.company,
|
||||||
@@ -177,11 +288,18 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||||
ral.save().submit()
|
ral.save().submit()
|
||||||
|
=======
|
||||||
|
si, pe = self.make_invoice_and_payment()
|
||||||
|
|
||||||
|
# with deletion flag set
|
||||||
|
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
|
|
||||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||||
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||||
|
|
||||||
def test_05_without_deletion_flag(self):
|
def test_05_without_deletion_flag(self):
|
||||||
|
<<<<<<< HEAD
|
||||||
si = create_sales_invoice(
|
si = create_sales_invoice(
|
||||||
item=self.item,
|
item=self.item,
|
||||||
company=self.company,
|
company=self.company,
|
||||||
@@ -202,6 +320,12 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
|
||||||
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
|
||||||
ral.save().submit()
|
ral.save().submit()
|
||||||
|
=======
|
||||||
|
si, pe = self.make_invoice_and_payment()
|
||||||
|
|
||||||
|
# without deletion flag set
|
||||||
|
self.create_repost_doc([si, pe], submit=True)
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
|
|
||||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
|
||||||
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
|
||||||
@@ -247,11 +371,15 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
another_provisional_account,
|
another_provisional_account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
repost_doc = frappe.new_doc("Repost Accounting Ledger")
|
repost_doc = frappe.new_doc("Repost Accounting Ledger")
|
||||||
repost_doc.company = self.company
|
repost_doc.company = self.company
|
||||||
repost_doc.delete_cancelled_entries = True
|
repost_doc.delete_cancelled_entries = True
|
||||||
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
|
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
|
||||||
repost_doc.save().submit()
|
repost_doc.save().submit()
|
||||||
|
=======
|
||||||
|
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
|
|
||||||
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
|
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
|
||||||
expected_pr_gles_after_repost = [
|
expected_pr_gles_after_repost = [
|
||||||
@@ -272,6 +400,281 @@ class TestRepostAccountingLedger(AccountsTestMixin, FrappeTestCase):
|
|||||||
company.default_provisional_account = None
|
company.default_provisional_account = None
|
||||||
company.save()
|
company.save()
|
||||||
|
|
||||||
|
def test_07_voucher_validations(self):
|
||||||
|
submitted_si = self.make_invoice()
|
||||||
|
draft_si = self.make_invoice(do_not_submit=True)
|
||||||
|
cancelled_si = self.make_invoice()
|
||||||
|
cancelled_si.cancel()
|
||||||
|
|
||||||
|
for vouchers, exception, message in (
|
||||||
|
([], frappe.ValidationError, "Add atleast one voucher"),
|
||||||
|
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
|
||||||
|
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
|
||||||
|
# cancelled vouchers don't make it past link validation
|
||||||
|
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
|
||||||
|
):
|
||||||
|
with self.subTest(vouchers=[x.name for x in vouchers]):
|
||||||
|
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
|
||||||
|
|
||||||
|
self.create_repost_doc([submitted_si])
|
||||||
|
|
||||||
|
def test_08_voucher_count_limit(self):
|
||||||
|
si, pe = self.make_invoice_and_payment()
|
||||||
|
another_si = self.make_invoice()
|
||||||
|
|
||||||
|
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
|
||||||
|
self.create_repost_doc([si, pe])
|
||||||
|
self.assertRaisesRegex(
|
||||||
|
frappe.ValidationError,
|
||||||
|
"Cannot repost more than 2 vouchers",
|
||||||
|
self.create_repost_doc,
|
||||||
|
[si, pe, another_si],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_09_status_lifecycle(self):
|
||||||
|
si, pe = self.make_invoice_and_payment()
|
||||||
|
|
||||||
|
ral = self.create_repost_doc([si, pe])
|
||||||
|
self.assertEqual(ral.status, "")
|
||||||
|
|
||||||
|
ral.submit()
|
||||||
|
ral.reload()
|
||||||
|
|
||||||
|
self.assertEqual(ral.status, "Completed")
|
||||||
|
self.assertFalse(ral.error_log)
|
||||||
|
for voucher in ral.vouchers:
|
||||||
|
self.assertEqual(voucher.status, "Reposted")
|
||||||
|
self.assertFalse(voucher.traceback)
|
||||||
|
|
||||||
|
ral.cancel()
|
||||||
|
ral.reload()
|
||||||
|
self.assertEqual(ral.status, "Cancelled")
|
||||||
|
|
||||||
|
discarded = self.create_repost_doc([si])
|
||||||
|
discarded.discard()
|
||||||
|
discarded.reload()
|
||||||
|
self.assertEqual(discarded.status, "Cancelled")
|
||||||
|
|
||||||
|
def test_10_start_repost_guards(self):
|
||||||
|
si = self.make_invoice()
|
||||||
|
ral = self.create_repost_doc([si])
|
||||||
|
|
||||||
|
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
|
||||||
|
|
||||||
|
ral.submit()
|
||||||
|
ral.reload()
|
||||||
|
self.assertRaisesRegex(
|
||||||
|
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
|
||||||
|
)
|
||||||
|
|
||||||
|
# a document left behind by a worker that died mid-repost
|
||||||
|
ral.db_set("status", "In Progress")
|
||||||
|
|
||||||
|
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
|
||||||
|
self.assertRaisesRegex(
|
||||||
|
frappe.ValidationError, "still in progress in background", ral.start_repost
|
||||||
|
)
|
||||||
|
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
|
||||||
|
|
||||||
|
# `cancel` flips docstatus in memory before running `before_cancel`
|
||||||
|
ral.reload()
|
||||||
|
|
||||||
|
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
|
||||||
|
# the job is gone, so `In Progress` must not keep the document stuck
|
||||||
|
ral.start_repost()
|
||||||
|
|
||||||
|
ral.reload()
|
||||||
|
self.assertEqual(ral.status, "Completed")
|
||||||
|
|
||||||
|
def test_11_repost_job_is_tied_to_the_document(self):
|
||||||
|
si = self.make_invoice()
|
||||||
|
ral = self.create_repost_doc([si], submit=True)
|
||||||
|
ral.db_set("status", "Failed")
|
||||||
|
|
||||||
|
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
|
||||||
|
ral.start_repost()
|
||||||
|
|
||||||
|
kwargs = enqueue.call_args.kwargs
|
||||||
|
self.assertEqual(kwargs["repost_doc_name"], ral.name)
|
||||||
|
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
|
||||||
|
# a second start cannot queue a second job for the same document
|
||||||
|
self.assertTrue(kwargs["deduplicate"])
|
||||||
|
|
||||||
|
def test_12_voucher_failures_are_isolated_and_retried(self):
|
||||||
|
si, pe = self.make_invoice_and_payment()
|
||||||
|
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
|
||||||
|
|
||||||
|
# the deletion flag drops the existing entries before reposting them
|
||||||
|
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
|
||||||
|
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||||
|
ral.submit()
|
||||||
|
|
||||||
|
ral.reload()
|
||||||
|
self.assertEqual(ral.status, "Partially Reposted")
|
||||||
|
|
||||||
|
si_row, pe_row = ral.vouchers
|
||||||
|
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
|
||||||
|
self.assertFalse(si_row.traceback)
|
||||||
|
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
|
||||||
|
|
||||||
|
# the failed voucher is rolled back to its savepoint, so its entries are back
|
||||||
|
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
|
||||||
|
|
||||||
|
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
|
||||||
|
# alone entirely: they are not locked or loaded either
|
||||||
|
with (
|
||||||
|
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
|
||||||
|
self.patched_repost() as retried,
|
||||||
|
):
|
||||||
|
ral.start_repost()
|
||||||
|
|
||||||
|
self.assertEqual(retried, [pe.name])
|
||||||
|
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
|
||||||
|
|
||||||
|
ral.reload()
|
||||||
|
self.assertEqual(ral.status, "Completed")
|
||||||
|
for voucher in ral.vouchers:
|
||||||
|
self.assertEqual(voucher.status, "Reposted")
|
||||||
|
self.assertFalse(voucher.traceback)
|
||||||
|
|
||||||
|
def test_13_status_of_a_run_that_could_not_finish(self):
|
||||||
|
si, pe = self.make_invoice_and_payment()
|
||||||
|
|
||||||
|
ral = self.create_repost_doc([si, pe])
|
||||||
|
with self.patched_repost(fail_for=["Payment Entry"]):
|
||||||
|
ral.submit()
|
||||||
|
|
||||||
|
ral.reload()
|
||||||
|
|
||||||
|
# the job dies after the loop committed the invoice, e.g. killed or timed out
|
||||||
|
try:
|
||||||
|
frappe.throw(SIMULATED_FAILURE)
|
||||||
|
except frappe.ValidationError:
|
||||||
|
_record_repost_failure(ral)
|
||||||
|
|
||||||
|
ral.reload()
|
||||||
|
|
||||||
|
# progress already committed must not be reported as a total failure
|
||||||
|
self.assertEqual(ral.status, "Partially Reposted")
|
||||||
|
self.assertIn(SIMULATED_FAILURE, ral.error_log)
|
||||||
|
self.assertTrue(
|
||||||
|
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
|
||||||
|
)
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
|
||||||
|
def test_14_period_closed_after_the_repost_was_started(self):
|
||||||
|
gl = qb.DocType("GL Entry")
|
||||||
|
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
|
||||||
|
|
||||||
|
si = self.make_invoice()
|
||||||
|
ral = self.create_repost_doc([si], submit=True)
|
||||||
|
ral.db_set("status", "Failed")
|
||||||
|
ral.vouchers[0].db_set("status", "Pending")
|
||||||
|
|
||||||
|
# the period is closed between the repost being started and the job running
|
||||||
|
self.make_period_closing_voucher()
|
||||||
|
|
||||||
|
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
|
||||||
|
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
|
||||||
|
|
||||||
|
ral.reload()
|
||||||
|
self.assertEqual(ral.status, "Failed")
|
||||||
|
self.assertIn("Closed fiscal year", ral.error_log)
|
||||||
|
|
||||||
|
# the ledger is left exactly as it was
|
||||||
|
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
|
||||||
|
self.assertEqual(ral.vouchers[0].status, "Pending")
|
||||||
|
|
||||||
|
def test_15_failed_repost_skips_cancelled_voucher(self):
|
||||||
|
si = self.make_invoice()
|
||||||
|
|
||||||
|
ral = self.create_repost_doc([si])
|
||||||
|
with self.patched_repost(fail_for=["Sales Invoice"]):
|
||||||
|
ral.submit()
|
||||||
|
|
||||||
|
ral.reload()
|
||||||
|
self.assertEqual(ral.status, "Failed")
|
||||||
|
|
||||||
|
si.reload()
|
||||||
|
si.cancel()
|
||||||
|
|
||||||
|
ral.start_repost()
|
||||||
|
ral.reload()
|
||||||
|
|
||||||
|
# nothing was reposted, but there is nothing left to repost either
|
||||||
|
self.assertEqual(ral.status, "Completed")
|
||||||
|
self.assertEqual(ral.vouchers[0].status, "Skipped")
|
||||||
|
self.assertFalse(ral.vouchers[0].traceback)
|
||||||
|
|
||||||
|
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
|
||||||
|
si, pe = self.make_invoice_and_payment()
|
||||||
|
ral = self.create_repost_doc([si, pe])
|
||||||
|
|
||||||
|
# a concurrent repost holding the lock on the second voucher
|
||||||
|
locked_pe = frappe.get_doc(pe.doctype, pe.name)
|
||||||
|
locked_pe.lock()
|
||||||
|
try:
|
||||||
|
self.assertRaises(frappe.DocumentLockedError, ral.submit)
|
||||||
|
|
||||||
|
# vouchers locked before the failure are released again
|
||||||
|
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
|
||||||
|
finally:
|
||||||
|
locked_pe.unlock()
|
||||||
|
|
||||||
|
def test_17_journal_entry_repost(self):
|
||||||
|
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
|
||||||
|
je = frappe.get_doc("Journal Entry", je.name)
|
||||||
|
|
||||||
|
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||||
|
|
||||||
|
# without the deletion flag the 2 original entries are marked as cancelled,
|
||||||
|
# along with the 2 reverse entries booked against them
|
||||||
|
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
|
||||||
|
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
|
||||||
|
ral = self.create_repost_doc(
|
||||||
|
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(ral.status, "Completed")
|
||||||
|
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
|
||||||
|
self.assertEqual(
|
||||||
|
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
|
||||||
|
cancelled_entries,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_18_hook_allowed_doctype_repost(self):
|
||||||
|
class VoucherWithCancelArg:
|
||||||
|
doctype = "Test Repost Voucher"
|
||||||
|
name = "TRV-00001"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def make_gl_entries(self, cancel=0):
|
||||||
|
self.calls.append(cancel)
|
||||||
|
|
||||||
|
class VoucherWithoutCancelArg(VoucherWithCancelArg):
|
||||||
|
def make_gl_entries(self):
|
||||||
|
self.calls.append("repost")
|
||||||
|
|
||||||
|
# vouchers that can reverse their own entries are asked to do so first
|
||||||
|
doc = VoucherWithCancelArg()
|
||||||
|
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||||
|
self.assertEqual(doc.calls, [1, 0])
|
||||||
|
|
||||||
|
# nothing to reverse when the old entries are deleted
|
||||||
|
doc = VoucherWithCancelArg()
|
||||||
|
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
|
||||||
|
self.assertEqual(doc.calls, [0])
|
||||||
|
|
||||||
|
# the rest fall back to the generic reversal
|
||||||
|
doc = VoucherWithoutCancelArg()
|
||||||
|
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
|
||||||
|
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
|
||||||
|
|
||||||
|
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
|
||||||
|
self.assertEqual(doc.calls, ["repost"])
|
||||||
|
|
||||||
|
|
||||||
def update_repost_settings():
|
def update_repost_settings():
|
||||||
allowed_types = [
|
allowed_types = [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"creation": "2023-07-04 14:14:01.243848",
|
"creation": "2023-07-04 14:14:01.243848",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -7,34 +8,78 @@
|
|||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
"field_order": [
|
"field_order": [
|
||||||
"voucher_type",
|
"voucher_type",
|
||||||
"voucher_no"
|
"column_break_ndex",
|
||||||
|
"voucher_no",
|
||||||
|
"reposting_status_section",
|
||||||
|
"status",
|
||||||
|
"traceback"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
|
"columns": 5,
|
||||||
"fieldname": "voucher_type",
|
"fieldname": "voucher_type",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Voucher Type",
|
"label": "Voucher Type",
|
||||||
"options": "DocType"
|
"options": "DocType",
|
||||||
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
"fieldname": "column_break_ndex",
|
||||||
|
"fieldtype": "Column Break"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": 5,
|
||||||
"fieldname": "voucher_no",
|
"fieldname": "voucher_no",
|
||||||
"fieldtype": "Dynamic Link",
|
"fieldtype": "Dynamic Link",
|
||||||
"in_list_view": 1,
|
"in_list_view": 1,
|
||||||
"label": "Voucher No",
|
"label": "Voucher No",
|
||||||
"options": "voucher_type"
|
"options": "voucher_type",
|
||||||
|
"reqd": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "reposting_status_section",
|
||||||
|
"fieldtype": "Section Break",
|
||||||
|
"label": "Reposting Status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"columns": 2,
|
||||||
|
"default": "Pending",
|
||||||
|
"fieldname": "status",
|
||||||
|
"fieldtype": "Select",
|
||||||
|
"in_list_view": 1,
|
||||||
|
"label": "Status",
|
||||||
|
"no_copy": 1,
|
||||||
|
"options": "Pending\nReposted\nSkipped\nFailed",
|
||||||
|
"read_only": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "traceback",
|
||||||
|
"fieldtype": "Code",
|
||||||
|
"label": "Traceback",
|
||||||
|
"no_copy": 1,
|
||||||
|
"read_only": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
|
<<<<<<< HEAD
|
||||||
"modified": "2023-07-04 14:15:51.165584",
|
"modified": "2023-07-04 14:15:51.165584",
|
||||||
|
=======
|
||||||
|
"modified": "2026-07-29 02:41:00.000000",
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Repost Accounting Ledger Items",
|
"name": "Repost Accounting Ledger Items",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
|
<<<<<<< HEAD
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
|
=======
|
||||||
|
"row_format": "Dynamic",
|
||||||
|
"sort_field": "creation",
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ class RepostAccountingLedgerItems(Document):
|
|||||||
parent: DF.Data
|
parent: DF.Data
|
||||||
parentfield: DF.Data
|
parentfield: DF.Data
|
||||||
parenttype: DF.Data
|
parenttype: DF.Data
|
||||||
voucher_no: DF.DynamicLink | None
|
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
|
||||||
voucher_type: DF.Link | None
|
traceback: DF.Code | None
|
||||||
|
voucher_no: DF.DynamicLink
|
||||||
|
voucher_type: DF.Link
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -444,4 +444,10 @@ erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
|
|||||||
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
|
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
|
||||||
erpnext.patches.v16_0.access_control_for_project_users
|
erpnext.patches.v16_0.access_control_for_project_users
|
||||||
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||||
|
<<<<<<< HEAD
|
||||||
erpnext.patches.v15_0.fix_titles
|
erpnext.patches.v15_0.fix_titles
|
||||||
|
=======
|
||||||
|
erpnext.patches.v16_0.fix_subcontracting_titles
|
||||||
|
erpnext.patches.v16_0.move_warehouse_defaults_to_company
|
||||||
|
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||||
|
>>>>>>> 372dff2ffa (refactor(accounts): repost accounting ledger (#56442))
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import frappe
|
||||||
|
from frappe.query_builder.functions import Coalesce
|
||||||
|
|
||||||
|
|
||||||
|
def execute():
|
||||||
|
"""Backfill the statuses of documents reposted before those fields existed.
|
||||||
|
|
||||||
|
Without it they show up as drafts and are offered a `Start Reposting` button that would
|
||||||
|
repost vouchers which are already reposted.
|
||||||
|
"""
|
||||||
|
ral = frappe.qb.DocType("Repost Accounting Ledger")
|
||||||
|
items = frappe.qb.DocType("Repost Accounting Ledger Items")
|
||||||
|
|
||||||
|
reposted = (
|
||||||
|
frappe.qb.from_(ral).select(ral.name).where((ral.docstatus == 1) & (Coalesce(ral.status, "") == ""))
|
||||||
|
)
|
||||||
|
frappe.qb.update(items).set(items.status, "Reposted").where(items.parent.isin(reposted)).run()
|
||||||
|
|
||||||
|
for docstatus, status in ((1, "Completed"), (2, "Cancelled")):
|
||||||
|
(
|
||||||
|
frappe.qb.update(ral)
|
||||||
|
.set(ral.status, status)
|
||||||
|
.where((ral.docstatus == docstatus) & (Coalesce(ral.status, "") == ""))
|
||||||
|
.run()
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user