mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-20 11:57:15 +00:00
Compare commits
1 Commits
develop
...
mergify/bp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6341bb6d0f |
@@ -523,3 +523,8 @@ erpnext.patches.v16_0.set_supplier_quotation_order_status
|
||||
erpnext.patches.v16_0.recalculate_holiday_list_totals
|
||||
erpnext.patches.v16_0.recalculate_returned_delivery_note_billing_status
|
||||
erpnext.patches.v16_0.rename_component_cost_valuation_type
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
erpnext.patches.v16_0.enable_serial_no_wise_valuation
|
||||
erpnext.patches.v16_0.add_voucher_index_to_repost_item_valuation
|
||||
>>>>>>> c055faa (feat(stock): GL-only reposting from Stock and Account Value Comparison report (#59127))
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
# on_doctype_update only runs when the DocType itself is re-synced, so existing sites need this.
|
||||
frappe.db.add_index("Repost Item Valuation", ["voucher_no", "voucher_type", "status"], "voucher_status")
|
||||
@@ -92,6 +92,7 @@ class RepostItemValuation(Document):
|
||||
def validate(self):
|
||||
self.set_default_posting_time()
|
||||
self.reset_repost_only_accounting_ledgers()
|
||||
self.validate_repost_only_accounting_ledgers()
|
||||
self.set_company()
|
||||
self.validate_update_stock()
|
||||
self.validate_period_closing_voucher()
|
||||
@@ -112,6 +113,19 @@ class RepostItemValuation(Document):
|
||||
if self.repost_only_accounting_ledgers and self.based_on != "Transaction":
|
||||
self.repost_only_accounting_ledgers = 0
|
||||
|
||||
def validate_repost_only_accounting_ledgers(self):
|
||||
if not self.repost_only_accounting_ledgers:
|
||||
return
|
||||
|
||||
# A GL Entry is not a stock transaction, so there are no stock ledger entries to rebuild its
|
||||
# accounting ledgers from; reposting it would only delete the entries it already has.
|
||||
if self.voucher_type == "GL Entry":
|
||||
frappe.throw(
|
||||
_("GL reposting is not allowed against the voucher type {0}.").format(
|
||||
frappe.bold(_("GL Entry"))
|
||||
)
|
||||
)
|
||||
|
||||
def validate_update_stock(self):
|
||||
if (
|
||||
self.voucher_type in ["Sales Invoice", "Purchase Invoice"]
|
||||
@@ -534,6 +548,7 @@ def mark_covered_transaction_reposts(source, coverage, affected):
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index("Repost Item Valuation", ["warehouse", "item_code"], "item_warehouse")
|
||||
frappe.db.add_index("Repost Item Valuation", ["voucher_no", "voucher_type", "status"], "voucher_status")
|
||||
|
||||
|
||||
def repost(doc):
|
||||
|
||||
@@ -51,12 +51,7 @@ frappe.query_reports["Stock and Account Value Comparison"] = {
|
||||
<p>${__("Are you sure you want to create Reposting Entries?")}</p>
|
||||
</div>
|
||||
`;
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
|
||||
|
||||
if (!selected_rows.length) {
|
||||
frappe.throw(__("Please select rows to create Reposting Entries"));
|
||||
}
|
||||
let selected_rows = get_selected_rows(__("Reposting Entries"));
|
||||
|
||||
frappe.confirm(message, () => {
|
||||
frappe.call({
|
||||
@@ -68,5 +63,52 @@ frappe.query_reports["Stock and Account Value Comparison"] = {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
report.page.add_inner_button(__("Create GL Reposting Entries"), function () {
|
||||
let selected_rows = get_selected_rows(__("GL Reposting Entries"));
|
||||
|
||||
frappe.prompt(
|
||||
[
|
||||
{
|
||||
label: __("From Date"),
|
||||
fieldname: "from_date",
|
||||
fieldtype: "Date",
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "note",
|
||||
fieldtype: "HTML",
|
||||
options: `<p class="text-muted small">
|
||||
${__(
|
||||
"Only the accounting ledgers (General Ledger and Payment Ledger) will be reposted, and only for the selected rows posted on or after the From Date. Selected rows posted before it are ignored. Stock Ledger Entries and item valuation rates are left untouched."
|
||||
)}
|
||||
</p>`,
|
||||
},
|
||||
],
|
||||
(values) => {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison.create_gl_reposting_entries",
|
||||
args: {
|
||||
rows: selected_rows,
|
||||
company: frappe.query_report.get_filter_values().company,
|
||||
from_date: values.from_date,
|
||||
},
|
||||
});
|
||||
},
|
||||
__("Create GL Reposting Entries"),
|
||||
__("Create")
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
function get_selected_rows(label) {
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
|
||||
|
||||
if (!selected_rows.length) {
|
||||
frappe.throw(__("Please select rows to create {0}", [label]));
|
||||
}
|
||||
|
||||
return selected_rows;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from datetime import date
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import get_datetime, get_link_to_form, parse_json
|
||||
from frappe.utils import create_batch, get_datetime, get_link_to_form, getdate, parse_json
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import get_currency_precision, get_stock_accounts
|
||||
@@ -273,3 +275,108 @@ def repost_based_on_transaction(rows, company=None, entries=None):
|
||||
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
|
||||
except frappe.DuplicateEntryError:
|
||||
frappe.db.rollback(save_point="repost_based_on_transaction")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_gl_reposting_entries(rows: str | list, company: str, from_date: str | date | None = None):
|
||||
"""Repost only the accounting ledgers for the selected vouchers posted on or after `from_date`.
|
||||
|
||||
Unlike `create_reposting_entries`, the stock ledgers and the valuation rates are left untouched.
|
||||
This is meant for the case where the stock valuation itself is correct but the General Ledger has
|
||||
drifted away from it, so there is no need to pay for a full (and much slower) revaluation.
|
||||
|
||||
`from_date` bounds how far back the accounting ledgers are rewritten: selected rows posted before
|
||||
it are ignored, so a stale selection cannot reach into an already reconciled period.
|
||||
"""
|
||||
|
||||
frappe.has_permission("Repost Item Valuation", "create", throw=True)
|
||||
|
||||
if isinstance(rows, str):
|
||||
rows = parse_json(rows)
|
||||
|
||||
if not rows:
|
||||
frappe.throw(_("Please select rows to create GL Reposting Entries"))
|
||||
|
||||
if not from_date:
|
||||
frappe.throw(_("Please select the date to repost the accounting ledgers from"))
|
||||
|
||||
from_date = getdate(from_date)
|
||||
|
||||
entries = []
|
||||
processed_vouchers = set()
|
||||
|
||||
# One batched lookup for the whole selection. Checking each row on its own meant a query per
|
||||
# row, which does not hold up when the report is used on the large selections it is meant for.
|
||||
pending_vouchers = get_pending_gl_reposting_vouchers(
|
||||
[(row.get("voucher_type"), row.get("voucher_no")) for row in rows]
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
# Rows posted before the From Date are skipped, so a stale selection cannot rewrite the
|
||||
# accounting ledgers of an already reconciled period.
|
||||
if getdate(row.get("posting_date")) < from_date:
|
||||
continue
|
||||
|
||||
voucher_type, voucher_no = row.get("voucher_type"), row.get("voucher_no")
|
||||
|
||||
# journal entry has not stock stock value, so no need to create a reposting entry for it
|
||||
if voucher_type == "Journal Entry":
|
||||
continue
|
||||
|
||||
# Skip duplicate vouchers in the selection: a single reposting entry is enough to rewrite the accounting ledgers for a given voucher.
|
||||
if (voucher_type, voucher_no) in processed_vouchers:
|
||||
continue
|
||||
|
||||
processed_vouchers.add((voucher_type, voucher_no))
|
||||
|
||||
# A repost queued by an earlier run still has to rewrite this voucher, so queuing another one
|
||||
# now would just rebuild the same ledgers twice.
|
||||
if (voucher_type, voucher_no) in pending_vouchers:
|
||||
continue
|
||||
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Transaction",
|
||||
"status": "Queued",
|
||||
"voucher_type": voucher_type,
|
||||
"voucher_no": voucher_no,
|
||||
"posting_date": row.get("posting_date"),
|
||||
"posting_time": row.get("posting_time"),
|
||||
"company": company,
|
||||
"repost_only_accounting_ledgers": 1,
|
||||
}
|
||||
).submit()
|
||||
|
||||
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
|
||||
|
||||
if entries:
|
||||
if len(entries) > 20:
|
||||
entries = entries[:20] + ["..."]
|
||||
|
||||
frappe.msgprint(_("GL reposting entries created: {0}").format(", ".join(entries)))
|
||||
else:
|
||||
frappe.msgprint(_("No new GL reposting entries were created for the selected rows."))
|
||||
|
||||
|
||||
def get_pending_gl_reposting_vouchers(transactions) -> set[tuple[str, str]]:
|
||||
"""Vouchers that already have a GL-only repost queued or running."""
|
||||
|
||||
pending_vouchers = set()
|
||||
|
||||
for chunk in create_batch(transactions, 1000):
|
||||
entries = frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={
|
||||
"based_on": "Transaction",
|
||||
"repost_only_accounting_ledgers": 1,
|
||||
"docstatus": 1,
|
||||
"status": ("in", ["Queued", "In Progress"]),
|
||||
"voucher_no": ("in", [voucher_no for _, voucher_no in chunk]),
|
||||
},
|
||||
fields=["voucher_type", "voucher_no"],
|
||||
)
|
||||
|
||||
pending_vouchers.update((d.voucher_type, d.voucher_no) for d in entries)
|
||||
|
||||
return pending_vouchers
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import today
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
@@ -10,6 +10,7 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.doctype.warehouse.warehouse import get_warehouses_based_on_account
|
||||
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
|
||||
create_gl_reposting_entries,
|
||||
create_reposting_entries,
|
||||
execute,
|
||||
)
|
||||
@@ -131,7 +132,170 @@ class TestStockAndAccountValueComparison(ERPNextTestSuite):
|
||||
self.assertIn(inheriting, warehouses)
|
||||
self.assertNotIn(overriding, warehouses)
|
||||
|
||||
<<<<<<< HEAD
|
||||
def run_report(self, **extra):
|
||||
filters = {"company": COMPANY, "as_on_date": "2026-12-31"}
|
||||
filters.update(extra)
|
||||
return execute(frappe._dict(filters))[1]
|
||||
=======
|
||||
def test_gl_reposting_only_repost_accounting_ledgers(self):
|
||||
# When the stock ledger is correct but the accounting ledger has drifted, the report can queue a
|
||||
# repost that touches only the accounting ledgers, leaving stock valuation alone.
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
|
||||
pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100)
|
||||
|
||||
frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
|
||||
|
||||
filters = frappe._dict(company=PI_COMPANY, as_on_date=today())
|
||||
_columns, data = execute(filters)
|
||||
|
||||
row = next((d for d in data if d.get("voucher_no") == pr.name), None)
|
||||
self.assertIsNotNone(row, "Out-of-sync Purchase Receipt should appear in the report")
|
||||
|
||||
create_gl_reposting_entries([row], PI_COMPANY, from_date=pr.posting_date)
|
||||
|
||||
rivs = frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"voucher_no": pr.name, "voucher_type": "Purchase Receipt"},
|
||||
fields=["name", "based_on", "repost_only_accounting_ledgers"],
|
||||
)
|
||||
|
||||
self.assertEqual(len(rivs), 1)
|
||||
self.assertEqual(rivs[0].based_on, "Transaction")
|
||||
self.assertTrue(rivs[0].repost_only_accounting_ledgers)
|
||||
|
||||
# Reposts run inline during tests, so the missing accounting entries must be back.
|
||||
self.assertTrue(
|
||||
frappe.db.exists("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
|
||||
)
|
||||
|
||||
def test_gl_reposting_skips_already_queued_voucher(self):
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
|
||||
pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100)
|
||||
|
||||
row = {
|
||||
"ledger_type": "Stock Ledger Entry",
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"posting_date": pr.posting_date,
|
||||
"posting_time": pr.posting_time,
|
||||
}
|
||||
|
||||
# The same voucher selected twice, and then selected again on a second run, must not pile up
|
||||
# duplicate reposting entries.
|
||||
frappe.flags.dont_execute_stock_reposts = True
|
||||
try:
|
||||
create_gl_reposting_entries([row, dict(row)], PI_COMPANY, from_date=pr.posting_date)
|
||||
create_gl_reposting_entries([row], PI_COMPANY, from_date=pr.posting_date)
|
||||
finally:
|
||||
frappe.flags.dont_execute_stock_reposts = False
|
||||
|
||||
rivs = frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={
|
||||
"voucher_no": pr.name,
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"repost_only_accounting_ledgers": 1,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(len(rivs), 1)
|
||||
|
||||
def test_gl_reposting_skips_journal_entry_rows(self):
|
||||
# A Journal Entry posted straight to a stock account shows up in the report but has no stock
|
||||
# ledger entries, so it is skipped rather than blocking the whole selection.
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
|
||||
pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100)
|
||||
frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
|
||||
|
||||
filters = frappe._dict(company=PI_COMPANY, as_on_date=today())
|
||||
_columns, data = execute(filters)
|
||||
pr_row = next((d for d in data if d.get("voucher_no") == pr.name), None)
|
||||
|
||||
journal_row = {
|
||||
"ledger_type": "GL Entry",
|
||||
"voucher_type": "Journal Entry",
|
||||
"voucher_no": "_Test JE for GL Reposting",
|
||||
"posting_date": today(),
|
||||
}
|
||||
|
||||
create_gl_reposting_entries([journal_row, pr_row], PI_COMPANY, pr.posting_date)
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.exists("Repost Item Valuation", {"voucher_type": "Journal Entry"}),
|
||||
"Journal Entry rows must be skipped",
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Repost Item Valuation", {"voucher_no": pr.name}))
|
||||
|
||||
def test_gl_reposting_ignores_rows_posted_before_from_date(self):
|
||||
# Rows posted before the From Date must be dropped, so a stale selection cannot rewrite the
|
||||
# accounting ledgers of an already reconciled period.
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
|
||||
old_pr = make_purchase_receipt(
|
||||
item_code=item,
|
||||
company=PI_COMPANY,
|
||||
warehouse=PI_STORES,
|
||||
qty=5,
|
||||
rate=100,
|
||||
posting_date=add_days(today(), -10),
|
||||
)
|
||||
new_pr = make_purchase_receipt(
|
||||
item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100
|
||||
)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"ledger_type": "Stock Ledger Entry",
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"posting_date": pr.posting_date,
|
||||
"posting_time": pr.posting_time,
|
||||
}
|
||||
for pr in (old_pr, new_pr)
|
||||
]
|
||||
|
||||
frappe.flags.dont_execute_stock_reposts = True
|
||||
try:
|
||||
create_gl_reposting_entries(rows, PI_COMPANY, from_date=add_days(today(), -1))
|
||||
finally:
|
||||
frappe.flags.dont_execute_stock_reposts = False
|
||||
|
||||
self.assertFalse(
|
||||
frappe.db.exists("Repost Item Valuation", {"voucher_no": old_pr.name}),
|
||||
"Row posted before the From Date must be ignored",
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Repost Item Valuation", {"voucher_no": new_pr.name}))
|
||||
|
||||
def test_gl_reposting_requires_from_date(self):
|
||||
row = {
|
||||
"ledger_type": "Stock Ledger Entry",
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": "some-receipt",
|
||||
"posting_date": today(),
|
||||
}
|
||||
|
||||
self.assertRaises(frappe.ValidationError, create_gl_reposting_entries, [row], PI_COMPANY, None)
|
||||
|
||||
def test_gl_reposting_not_allowed_against_gl_entry_voucher_type(self):
|
||||
# Guard on the Repost Item Valuation itself, for anything creating one outside the report.
|
||||
riv = frappe.new_doc("Repost Item Valuation")
|
||||
riv.update(
|
||||
{
|
||||
"based_on": "Transaction",
|
||||
"voucher_type": "GL Entry",
|
||||
"voucher_no": "some-gl-entry",
|
||||
"posting_date": today(),
|
||||
"company": PI_COMPANY,
|
||||
"repost_only_accounting_ledgers": 1,
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, riv.validate_repost_only_accounting_ledgers)
|
||||
|
||||
riv.repost_only_accounting_ledgers = 0
|
||||
riv.validate_repost_only_accounting_ledgers()
|
||||
>>>>>>> c055faa (feat(stock): GL-only reposting from Stock and Account Value Comparison report (#59127))
|
||||
|
||||
Reference in New Issue
Block a user