Merge branch 'develop' into fix/maintain-same-rate-refetch-57436

This commit is contained in:
Jatin3128
2026-07-30 17:50:43 +05:30
committed by GitHub
172 changed files with 5326 additions and 1499 deletions

View File

@@ -331,7 +331,7 @@ def add_bank_account(data, bank_account):
bank_account_loc = loc
for row in data[1:]:
if bank_account_loc:
if bank_account_loc is not None:
row[bank_account_loc] = bank_account
else:
row.append(bank_account)

View File

@@ -157,12 +157,9 @@ class BankTransactionRule(Document):
"""
Delete the matched rule from the bank transaction
"""
try:
frappe.db.set_value(
"Bank Transaction", {"matched_transaction_rule": self.name}, "matched_transaction_rule", None
)
except Exception:
pass
frappe.db.set_value(
"Bank Transaction", {"matched_transaction_rule": self.name}, "matched_transaction_rule", None
)
def after_delete(self):
"""

View File

@@ -623,15 +623,27 @@ class ExchangeRateRevaluation(Document):
if journals:
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.submit()
frappe.msgprint(
_("Revaluation journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
if drafts := frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": ["in", journals]},
pluck="name",
as_list=1,
):
part = "journals are" if len(drafts) > 1 else "journal is"
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
frappe.throw(
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
)
else:
for x in journals:
reversal = make_reverse_journal_entry(x)
reversal.posting_date = nowdate()
reversal.save()
frappe.msgprint(
_("A draft reverse journal for {0} has been created: {1}").format(
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
)
)
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):

View File

@@ -9,11 +9,10 @@ from frappe.utils import add_days, flt, today
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
class TestExchangeRateRevaluation(ERPNextTestSuite):
def setUp(self):
self.company = "_Test Company"
self.item = "_Test Item"
@@ -23,14 +22,6 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
self.set_system_and_company_settings()
def set_system_and_company_settings(self):
# set number and currency precision
system_settings = frappe.get_doc("System Settings")
system_settings.float_precision = 2
system_settings.currency_precision = 2
system_settings.language = "en"
system_settings.time_zone = "Asia/Kolkata"
system_settings.save()
# Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", self.company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
@@ -312,7 +303,7 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
si = create_sales_invoice(
item=self.item,
company=self.company,
customer=self.customer,
customer="_Test Customer 1",
debit_to=self.debtors_usd,
posting_date=today(),
parent_cost_center=self.cost_center,
@@ -377,6 +368,15 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
self.assertFalse(ret.get("reversals_posted"))
err.make_reverse_journal()
# submit
draft = frappe.db.get_all(
"Journal Entry",
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
pluck="name",
as_list=1,
)
self.assertIsNotNone(draft)
frappe.get_doc("Journal Entry", draft[0]).submit()
ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted"))
self.assertTrue(ret.get("reversals_posted"))

View File

@@ -235,6 +235,7 @@ Object.assign(erpnext.journal_entry, {
lock_reversal_entry(frm) {
frm.fields
.filter((field) => field.has_input)
.filter((field) => field.df.fieldname != "posting_date")
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
frm.set_df_property("accounts", "read_only", 1);
},

View File

@@ -459,6 +459,11 @@ class PaymentRequest(Document):
else:
return True
except Exception:
frappe.log_error(
title=f"Payment Gateway validation failed: {self.payment_gateway}",
reference_doctype=self.doctype,
reference_name=self.name,
)
return False
def set_payment_request_url(self):

View File

@@ -219,7 +219,8 @@ class POSClosingEntry(StatusUpdater):
self.update_sales_invoices_closing_entry()
def before_cancel(self):
self.check_pce_is_cancellable()
if self.status != "Failed":
self.check_pce_is_cancellable()
def on_cancel(self):
unconsolidate_pos_invoices(closing_entry=self)

View File

@@ -89,7 +89,11 @@ def filter_pricing_rule_based_on_condition(pricing_rules, doc=None):
if frappe.safe_eval(pricing_rule.condition, None, doc.as_dict()):
filtered_pricing_rules.append(pricing_rule)
except Exception:
pass
frappe.log_error(
title=f"Pricing Rule condition failed to evaluate: {pricing_rule.name}",
reference_doctype="Pricing Rule",
reference_name=pricing_rule.name,
)
else:
filtered_pricing_rules.append(pricing_rule)
else:

View File

@@ -403,12 +403,7 @@ def get_recipients_and_cc(customer, doc):
if doc.primary_mandatory and clist.primary_email:
for email in clist.primary_email.split(","):
recipients.append(email.strip())
cc = []
if doc.cc_to != "":
try:
cc = [frappe.get_value("User", user.cc, "email") for user in doc.cc_to]
except Exception:
pass
cc = [email for user in doc.cc_to if (email := frappe.get_value("User", user.cc, "email"))]
return recipients, cc

View File

@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
},
refresh: function (frm) {
frm.add_custom_button(__("Show Preview"), () => {
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);
}
},
// the server refuses only while the job is alive, so a dead one can be restarted here
if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
frm.add_custom_button(__("Start Reposting"), () => {
frm.events.start_repost(frm);
});
}
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();
},
});
},
});

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"creation": "2023-07-04 13:07:32.923675",
"default_view": "List",
"doctype": "DocType",
@@ -7,16 +8,24 @@
"engine": "InnoDB",
"field_order": [
"company",
"column_break_vpup",
"delete_cancelled_entries",
"column_break_vpup",
"status",
"section_break_metl",
"vouchers",
"amended_from"
"error_section",
"error_log",
"miscellaneous_section",
"amended_from",
"column_break_hrah",
"scheduled_job"
],
"fields": [
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company",
"options": "Company"
},
@@ -48,12 +57,54 @@
"fieldname": "delete_cancelled_entries",
"fieldtype": "Check",
"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,
"is_submittable": 1,
"links": [],
"modified": "2024-06-03 17:30:37.012593",
"modified": "2026-07-28 00:56:50.290314",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger",
@@ -76,8 +127,9 @@
"write": 1
}
],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}
}

View File

@@ -7,7 +7,14 @@ import frappe
from frappe import _, qb
from frappe.desk.form.linked_with import get_child_tables_of_doctypes
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.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")
class RepostAccountingLedger(Document):
@@ -26,6 +33,11 @@ class RepostAccountingLedger(Document):
amended_from: DF.Link | None
company: DF.Link | None
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]
# end: auto-generated types
@@ -35,6 +47,11 @@ class RepostAccountingLedger(Document):
def validate(self):
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_deferred_accounting()
@@ -71,8 +88,52 @@ class RepostAccountingLedger(Document):
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
def validate_vouchers(self):
if self.vouchers:
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
if not 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):
vouchers = [x.voucher_no for x in self.vouchers]
@@ -137,80 +198,245 @@ class RepostAccountingLedger(Document):
return rendered_page
def on_submit(self):
if len(self.vouchers) > 5:
job_name = "repost_accounting_ledger_" + self.name
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost",
account_repost_doc=self.name,
is_async=True,
job_name=job_name,
enqueue_after_commit=True,
self.start_repost()
def before_cancel(self):
self._raise_error_if_reposting_in_progress()
def on_cancel(self):
self.db_set("status", "Cancelled")
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:
start_repost(self.name)
self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
_enqueue_repost(self.name)
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
@frappe.whitelist()
def start_repost(account_repost_doc: str | None = None) -> None:
from erpnext.accounts.general_ledger import make_reverse_gl_entries
def _repost_job_id(repost_doc_name: str) -> str:
"""Derived from the document, so a repost can only ever have one job."""
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
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:
# Prevent repost on invoices with deferred accounting
repost_doc.validate_for_deferred_accounting()
repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
locked_docs = {}
for x in repost_doc.vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
try:
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:
frappe.db.delete(
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": 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},
)
_delete_accounting_ledger_entries(doc.doctype, doc.name)
_delete_adv_pl_entries(doc.doctype, doc.name)
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
_repost_vouchers(doc, repost_doc.delete_cancelled_entries)
except Exception:
frappe.db.rollback(save_point=save_point)
doc.docstatus = 1
if doc.doctype == "Sales Invoice":
doc.force_set_against_income_account()
else:
doc.force_set_against_expense_account()
doc.make_gl_entries()
x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
else:
x.db_set({"status": "Reposted", "traceback": ""})
finally:
if commit:
frappe.db.commit() # nosemgrep
elif doc.doctype == "Purchase Receipt":
if not repost_doc.delete_cancelled_entries:
doc.docstatus = 2
doc.make_gl_entries_on_cancel(from_repost=True)
except Exception:
if commit:
frappe.db.rollback()
doc.docstatus = 1
doc.make_gl_entries(from_repost=True)
_record_repost_failure(repost_doc, commit=commit)
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:
doc.make_gl_entries(1)
doc.make_gl_entries()
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries):
if not repost_doc.delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(doc.make_gl_entries):
doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name)
doc.make_gl_entries()
def _derive_status(repost_doc) -> str:
"""Vouchers are committed one by one, so the status follows what was actually handled."""
handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
if handled == len(repost_doc.vouchers):
return "Completed"
elif handled == 0:
return "Failed"
return "Partially Reposted"
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):

View File

@@ -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];
},
};

View File

@@ -1,27 +1,42 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe
from frappe import qb
from frappe.query_builder.functions import Sum
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_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.utils import get_fiscal_year
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.tests.utils import ERPNextTestSuite
REPOST_MODULE = "erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger"
SIMULATED_FAILURE = "Simulated repost failure"
class TestRepostAccountingLedger(ERPNextTestSuite):
def setUp(self):
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
update_repost_settings()
def test_01_basic_functions(self):
si = create_sales_invoice(
def make_invoice(self, **kwargs):
return create_sales_invoice(
item="_Test Item",
company="_Test Company",
customer="_Test Customer",
@@ -29,8 +44,71 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
parent_cost_center="Main - _TC",
cost_center="Main - _TC",
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(
make_payment_request(
dt=si.doctype,
@@ -64,53 +142,24 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"})
frappe.db.set_value("GL Entry", gle[0], "debit", 90)
gl = qb.DocType("GL Entry")
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.groupby(gl.voucher_no)
.run()
)
# 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
ral.save().submit()
res = (
qb.from_(gl)
.select(gl.voucher_no, Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == si.name) & (gl.is_cancelled == 0))
.groupby(gl.voucher_no)
.run()
)
# 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):
si = 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",
rate=100,
do_not_submit=True,
)
si = self.make_invoice(do_not_submit=True)
si.items[0].enable_deferred_revenue = True
si.items[0].deferred_revenue_account = "Deferred Revenue - _TC"
si.items[0].service_start_date = nowdate()
si.items[0].service_end_date = add_days(nowdate(), 90)
si.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_04_pcv_validation(self):
@@ -118,86 +167,29 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = 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",
rate=100,
)
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",
}
)
pcv.save().submit()
si = self.make_invoice()
pcv = self.make_period_closing_voucher()
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
pcv.reload()
pcv.cancel()
pcv.delete()
def test_03_deletion_flag_and_preview_function(self):
si = 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",
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
si, pe = self.make_invoice_and_payment()
# with deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = True
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
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}))
def test_05_without_deletion_flag(self):
si = 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",
rate=100,
)
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
si, pe = self.make_invoice_and_payment()
# without deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = False
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
ral.append("vouchers", {"voucher_type": pe.doctype, "voucher_no": pe.name})
ral.save().submit()
self.create_repost_doc([si, pe], submit=True)
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}))
@@ -248,11 +240,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
another_provisional_account,
)
repost_doc = frappe.new_doc("Repost Accounting Ledger")
repost_doc.company = "_Test Company"
repost_doc.delete_cancelled_entries = True
repost_doc.append("vouchers", {"voucher_type": pr.doctype, "voucher_no": pr.name})
repost_doc.save().submit()
repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles_after_repost = [
@@ -273,6 +261,281 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
company.default_provisional_account = None
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():
allowed_types = [

View File

@@ -1,5 +1,6 @@
{
"actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1,
"creation": "2023-07-04 14:14:01.243848",
"doctype": "DocType",
@@ -7,34 +8,70 @@
"engine": "InnoDB",
"field_order": [
"voucher_type",
"voucher_no"
"column_break_ndex",
"voucher_no",
"reposting_status_section",
"status",
"traceback"
],
"fields": [
{
"columns": 5,
"fieldname": "voucher_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Voucher Type",
"options": "DocType"
"options": "DocType",
"reqd": 1
},
{
"fieldname": "column_break_ndex",
"fieldtype": "Column Break"
},
{
"columns": 5,
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"in_list_view": 1,
"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,
"istable": 1,
"links": [],
"modified": "2024-03-27 13:10:32.170897",
"modified": "2026-07-29 02:41:00.000000",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Repost Accounting Ledger Items",
"owner": "Administrator",
"permissions": [],
"row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": []
}
}

View File

@@ -17,8 +17,10 @@ class RepostAccountingLedgerItems(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
voucher_no: DF.DynamicLink | None
voucher_type: DF.Link | None
status: DF.Literal["Pending", "Reposted", "Skipped", "Failed"]
traceback: DF.Code | None
voucher_no: DF.DynamicLink
voucher_type: DF.Link
# end: auto-generated types
pass

View File

@@ -3215,6 +3215,10 @@ class TestSalesInvoice(ERPNextTestSuite):
"Stock Received But Not Billed - _TC1",
)
# companies are created with their Stores warehouse as Default Warehouse; clear it so the
# item genuinely maps without one
frappe.db.set_value("Company", "_Test Company 1", "default_warehouse", None)
# begin test
si = create_sales_invoice(
company="Wind Power LLC",

View File

@@ -110,6 +110,32 @@ frappe.ui.form.on("Subscription", {
},
});
frappe.ui.form.on("Subscription Plan Detail", {
plan: function (frm, cdt, cdn) {
const row = locals[cdt][cdn];
if (!row.plan) return;
const requested_plan = row.plan;
frappe.call({
method: "erpnext.accounts.doctype.subscription.subscription.get_plan_dimensions",
args: {
plan: requested_plan,
company: frm.doc.company,
party_type: frm.doc.party_type,
},
callback: function (r) {
if (!r.message || locals[cdt]?.[cdn]?.plan !== requested_plan) return;
// Only fill dimensions left empty, so a manual entry or an earlier plan is never overwritten.
for (const [dimension, value] of Object.entries(r.message)) {
if (frm.fields_dict[dimension] && !frm.doc[dimension]) {
frm.set_value(dimension, value);
}
}
},
});
},
});
// Status -> colour and label for the calendar heatmap. Keys are Title-case to
// match the value frappe-charts shows in its hover tooltip.
const HEATMAP_COLORS = {

View File

@@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.stock.doctype.item.item import get_item_defaults
class InvoiceCancelled(frappe.ValidationError):
@@ -981,6 +982,39 @@ def get_prorata_factor(
return diff / plan_days
@frappe.whitelist()
def get_plan_dimensions(
plan: str, company: str | None = None, party_type: str | None = None
) -> dict[str, str]:
"""Resolve a plan's accounting dimensions, falling back to the plan item's company defaults."""
plan_doc = frappe.get_cached_doc("Subscription Plan", plan)
dimensions = {}
for dimension in ["cost_center", *get_accounting_dimensions()]:
value = plan_doc.get(dimension) or get_item_dimension(plan_doc.item, dimension, company, party_type)
if value:
dimensions[dimension] = value
return dimensions
def get_item_dimension(
item_code: str, dimension: str, company: str | None, party_type: str | None
) -> str | None:
if not company:
return None
item_defaults = get_item_defaults(item_code, company)
if dimension != "cost_center":
return item_defaults.get(dimension)
selling = item_defaults.get("selling_cost_center")
buying = item_defaults.get("buying_cost_center")
if party_type == PARTY_SUPPLIER:
return buying or selling
return selling or buying
def process_all(subscription: list, posting_date: DateTimeLikeObject | None = None) -> None:
"""
Task to updates the status of all `Subscription` apart from those that are cancelled

View File

@@ -18,7 +18,12 @@ from frappe.utils.data import (
)
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.subscription.subscription import Subscription, get_prorata_factor, process_all
from erpnext.accounts.doctype.subscription.subscription import (
Subscription,
get_plan_dimensions,
get_prorata_factor,
process_all,
)
from erpnext.accounts.utils import update_subscription_on_invoice_update
from erpnext.tests.utils import ERPNextTestSuite
@@ -951,6 +956,47 @@ class TestSubscription(ERPNextTestSuite):
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded")
def test_plan_dimensions_resolve_from_plan_then_item(self):
from erpnext.stock.doctype.item.test_item import make_item
# Plan-level cost center takes precedence.
create_plan(plan_name="_Test Sub Plan CC", cost=100, currency="INR")
frappe.db.set_value(
"Subscription Plan", "_Test Sub Plan CC", "cost_center", "_Test Cost Center - _TC"
)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan CC", "_Test Company", "Customer").get("cost_center"),
"_Test Cost Center - _TC",
)
# No plan cost center: fall back to the item's company default (selling vs buying by party type).
item = make_item(
"_Test Sub Dimension Item",
{
"is_stock_item": 0,
"item_defaults": [
{
"company": "_Test Company",
"selling_cost_center": "_Test Cost Center - _TC",
"buying_cost_center": "_Test Cost Center 2 - _TC",
}
],
},
)
create_plan(plan_name="_Test Sub Plan No CC", cost=100, currency="INR", item=item.name)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Customer").get("cost_center"),
"_Test Cost Center - _TC",
)
self.assertEqual(
get_plan_dimensions("_Test Sub Plan No CC", "_Test Company", "Supplier").get("cost_center"),
"_Test Cost Center 2 - _TC",
)
# Without a company the item fallback is skipped.
self.assertNotIn("cost_center", get_plan_dimensions("_Test Sub Plan No CC"))
def make_full_credit_note(invoice_name):
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return

View File

@@ -869,7 +869,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
companies = frappe.get_all(
companies = frappe.get_list(
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{
@@ -180,17 +180,25 @@ frappe.query_reports["Accounts Payable"] = {
return Object.assign(options, {
checkboxColumn: true,
events: {
onCheckRow: () => erpnext.accounts.toggle_create_pe_primary_action(frappe.query_report),
onCheckRow: () => toggle_create_pe_button(frappe.query_report),
},
});
},
after_refresh: function (report) {
report.datatable?.rowmanager?.checkAll(false);
report.page.clear_primary_action();
toggle_create_pe_button(report);
},
onload: function (report) {
if (frappe.model.can_create("Payment Entry")) {
report.create_pe_btn = report.page
.add_inner_button(__("Create Payment Entries"), function () {
create_payment_entries_from_payable_report(report);
})
.toggle(false);
}
report.page.add_inner_button(__("Accounts Payable Summary"), function () {
var filters = report.get_values();
frappe.set_route("query-report", "Accounts Payable Summary", { company: filters.company });
@@ -202,25 +210,17 @@ frappe.query_reports["Accounts Payable"] = {
},
};
frappe.provide("erpnext.accounts");
erpnext.accounts.toggle_create_pe_primary_action = function (report) {
if (!report || !report.datatable || !frappe.model.can_create("Payment Entry")) return;
function toggle_create_pe_button(report) {
if (!report || !report.create_pe_btn || !report.datatable) return;
const has_purchase_invoice = report.datatable.rowmanager
.getCheckedRows()
.some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice");
if (has_purchase_invoice) {
report.page.set_primary_action(__("Create Payment Entries"), () =>
erpnext.accounts.create_payment_entries_from_payable_report(report)
);
} else {
report.page.clear_primary_action();
}
};
report.create_pe_btn.toggle(has_purchase_invoice);
}
erpnext.accounts.create_payment_entries_from_payable_report = function (report) {
function create_payment_entries_from_payable_report(report) {
const datatable = report.datatable;
if (!datatable) return;
@@ -343,7 +343,7 @@ erpnext.accounts.create_payment_entries_from_payable_report = function (report)
},
});
dialog.show();
};
}
erpnext.utils.add_dimensions("Accounts Payable", 10);

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -54,8 +54,7 @@ class ReceivablePayableReport:
self.filters.report_date = getdate(self.filters.report_date or nowdate())
self.age_as_on = (
getdate(nowdate())
if "calculate_ageing_with" not in self.filters
or self.filters.calculate_ageing_with == "Today Date"
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
else self.filters.report_date
)

View File

@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
},
{
fieldname: "report_date",
label: __("Posting Date"),
label: __("Report Date"),
fieldtype: "Date",
default: frappe.datetime.get_today(),
},
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
default: "Due Date",
},
{
fieldname: "calculate_ageing_with",
label: __("Calculate Ageing With"),
fieldname: "age_as_on",
label: __("Age as on"),
fieldtype: "Select",
options: "Report Date\nToday Date",
options: "Report Date\nToday",
default: "Report Date",
},
{

View File

@@ -314,7 +314,7 @@ class ChildItemUpdater:
@frappe.whitelist()
def update_child_qty_rate(
parent_doctype: str, trans_items: str, parent_doctype_name: str, child_docname: str = "items"
parent_doctype: str, trans_items: str | list, parent_doctype_name: str, child_docname: str = "items"
) -> None:
ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items)
@@ -432,6 +432,7 @@ def validate_and_delete_children(parent, data, ordered_item=None) -> bool:
for d in deleted_children:
validate_child_on_delete(d, parent, ordered_item)
d.flags.ignore_permissions = True
d.cancel()
d.delete()

View File

@@ -55,3 +55,16 @@ class DeferredAccountingService:
def _is_deferred(self, item) -> bool:
return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense"))
def clear_stale_deferred_fields(self) -> None:
account_field = DEFERRED_ACCOUNT_FIELD.get(self.doc.doctype)
for item in self.doc.get("items"):
if self._is_deferred(item):
continue
item.service_start_date = None
item.service_end_date = None
item.service_stop_date = None
if account_field:
item.set(account_field, None)

View File

@@ -1203,7 +1203,7 @@ def get_values_from_purchase_doc(
return {
"company": purchase_doc.company,
"purchase_date": purchase_doc.get("posting_date"),
"net_purchase_amount": flt(first_item.base_net_amount),
"net_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
"asset_quantity": first_item.qty,
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
"asset_location": first_item.get("asset_location"),

View File

@@ -64,27 +64,24 @@ def create_supplier_quotation(doc: str | Document | dict):
):
frappe.throw(_("Not Permitted"), frappe.PermissionError)
try:
sq_doc = frappe.get_doc(
{
"doctype": "Supplier Quotation",
"supplier": doc.get("supplier"),
"terms": doc.get("terms"),
"company": doc.get("company"),
"currency": doc.get("currency")
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
"buying_price_list": doc.get("buying_price_list")
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
}
)
add_items(sq_doc, doc.get("supplier"), doc.get("items"))
sq_doc.flags.ignore_permissions = True
sq_doc.run_method("set_missing_values")
sq_doc.save()
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
return sq_doc.name
except Exception:
return None
sq_doc = frappe.get_doc(
{
"doctype": "Supplier Quotation",
"supplier": doc.get("supplier"),
"terms": doc.get("terms"),
"company": doc.get("company"),
"currency": doc.get("currency")
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
"buying_price_list": doc.get("buying_price_list")
or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
}
)
add_items(sq_doc, doc.get("supplier"), doc.get("items"))
sq_doc.flags.ignore_permissions = True
sq_doc.run_method("set_missing_values")
sq_doc.save()
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
return sq_doc.name
def add_items(sq_doc, supplier, items):

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
{
"absolute_value": 0,
"align_labels_right": 0,
"creation": "2026-07-24 17:15:36.456156",
"custom_format": 0,
"disabled": 0,
"doc_type": "Request for Quotation",
"docstatus": 0,
"doctype": "Print Format",
"font": "Inter",
"font_size": 12,
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"show_label\":\"inline\",\"label_gap\":6},{\"label\":\"Request for Quotation\",\"fieldname\":\"name\",\"fieldtype\":\"Data\",\"show_label\":\"inline\",\"label_gap\":6},{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_vytjghmu\",\"fieldtype\":\"HTML\",\"html\":\"<div style=\\\"margin-left: 10px; padding-top: 10px;\\\">\\n <div style=\\\"color:#6b7280;\\\">Company:</div>\\n <div style=\\\"font-weight:600;color:#1f2328;margin-top:2px;\\\">{{ doc.company }}</div>\\n</div>\",\"custom\":1},{\"label\":\"Address\",\"fieldname\":\"billing_address_display\",\"fieldtype\":\"Text Editor\",\"show_label\":\"hide\"}]},{\"label\":\"\",\"fields\":[{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"show_label\":\"inline\",\"label_gap\":6},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"show_label\":\"inline\",\"label_gap\":6}]}],\"has_fields\":true,\"cell_padding\":10,\"custom_style\":\"\",\"margin\":{\"top\":15,\"right\":0,\"bottom\":0,\"left\":0},\"field_borders\":true,\"gap\":0},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":22,\"column_condition\":\"\"},{\"label\":\"Item\",\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"options\":\"Item\",\"width\":13},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":10,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":10,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_style\":\"lined\",\"table_bordered\":true,\"table_header\":\"styled\",\"table_cell_padding\":8,\"table_radius\":10,\"show_label\":\"hide\"}]}],\"has_fields\":true,\"margin\":{\"top\":10,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_dGLQjxHJ\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"has_fields\":true,\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
"idx": 0,
"label_color": "#6b7280",
"line_breaks": 0,
"margin_bottom": 8.0,
"margin_left": 8.0,
"margin_right": 8.0,
"margin_top": 15.0,
"modified": "2026-07-24 17:25:22.878108",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Bordered",
"owner": "Administrator",
"page_number": "Hide",
"pdf_generator": "chrome",
"print_format_builder": 0,
"print_format_builder_beta": 1,
"print_format_for": "DocType",
"print_format_type": "Jinja",
"raw_printing": 0,
"show_label_colon": 0,
"show_section_headings": 0,
"standard": "Yes",
"value_color": "#1f2328"
}

View File

@@ -0,0 +1,36 @@
{
"absolute_value": 0,
"align_labels_right": 0,
"creation": "2026-07-24 17:15:36.313234",
"custom_format": 0,
"disabled": 0,
"doc_type": "Request for Quotation",
"docstatus": 0,
"doctype": "Print Format",
"font": "Inter",
"font_size": 13,
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_jtLStRVi\",\"fieldtype\":\"HTML\",\"html\":\"<div>\\n <div style=\\\"color:#6b7280;\\\">\\n Supplier\\n </div>\\n</div>\",\"custom\":1},{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"show_label\":\"hide\",\"custom_style\":\"font-weight: bold;\"}],\"width\":53},{\"label\":\"\",\"fields\":[{\"label\":\"Request for Quotation\",\"fieldname\":\"name\",\"fieldtype\":\"Data\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"font-weight: bold;\\nborder-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\",\"label_color\":\"#292929\"},{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\",\"custom_style\":\"border-bottom: 1px solid #e5e7eb;\\npadding-bottom: 10px;\"}],\"width\":44}],\"show_label\":\"hide\",\"field_orientation\":\"left-right\",\"margin\":{\"top\":15,\"right\":12,\"bottom\":0,\"left\":12}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":21},{\"label\":\"Code\",\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"options\":\"Item\",\"width\":12},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":13,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":14,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_style\":\"lined\",\"table_bordered\":true,\"table_header\":\"styled\",\"table_cell_padding\":10,\"table_radius\":10,\"table_header_bg\":\"#f3f3f3\",\"show_label\":\"hide\"}]}],\"has_fields\":true,\"margin\":{\"top\":15,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_LeiIYjph\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions Details\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
"idx": 0,
"label_color": "#6b7280",
"line_breaks": 0,
"margin_bottom": 8.0,
"margin_left": 8.0,
"margin_right": 8.0,
"margin_top": 10.0,
"modified": "2026-07-24 17:19:05.063875",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Classic",
"owner": "Administrator",
"page_number": "Hide",
"pdf_generator": "chrome",
"print_format_builder": 0,
"print_format_builder_beta": 1,
"print_format_for": "DocType",
"print_format_type": "Jinja",
"raw_printing": 0,
"show_label_colon": 0,
"show_section_headings": 0,
"standard": "Yes",
"value_color": "#1f2328"
}

View File

@@ -0,0 +1,36 @@
{
"absolute_value": 0,
"align_labels_right": 0,
"creation": "2026-07-24 17:15:36.444078",
"custom_format": 0,
"disabled": 0,
"doc_type": "Request for Quotation",
"docstatus": 0,
"doctype": "Print Format",
"font": "Inter",
"font_size": 14,
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_jPuFiKyJ\",\"fieldtype\":\"HTML\",\"html\":\"<div style=\\\"text-align:center;\\\">\\n <div style=\\\"font-size:1.5em;font-weight:700;\\\">\\n Request for Quotation\\n </div>\\n <div style=\\\"color:#6b7280;margin-top:4px;\\\">\\n {{ doc.name }}\\n </div>\\n</div>\",\"custom\":1}]}],\"margin\":{\"top\":10,\"right\":0,\"bottom\":10,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_QbdbRgGE\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"custom_style\":\"flex-direction:column;align-items:flex-start;gap:3px;\"}],\"width\":56},{\"label\":\"\",\"fields\":[{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\"},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"align\":\"left\",\"label_justify\":\"space-between\"},{\"label\":\"Status\",\"fieldname\":\"status\",\"fieldtype\":\"Select\",\"options\":\"\\nDraft\\nSubmitted\\nCancelled\",\"label_justify\":\"space-between\",\"visible_if\":\"\"}],\"width\":38}],\"has_fields\":true,\"field_orientation\":\"left-right\",\"gap\":44,\"margin\":{\"top\":15,\"right\":12,\"bottom\":0,\"left\":12}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":25,\"merged_fields\":[{\"fieldname\":\"description\",\"fieldtype\":\"Text Editor\",\"style\":\"muted-sm\"}]},{\"label\":\"Code\",\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"options\":\"Item\",\"width\":12},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":12,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":12,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_bordered\":false,\"table_header\":\"styled\",\"table_cell_padding\":10,\"table_radius\":8,\"table_header_bg\":\"#f3f3f3\",\"table_border_color\":\"#f3f3f3\",\"show_label\":\"hide\",\"custom_style\":\"\"}]}],\"has_fields\":true,\"margin\":{\"top\":0,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_IMskVBrj\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions Details\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
"idx": 0,
"label_color": "#6b7280",
"line_breaks": 0,
"margin_bottom": 8.0,
"margin_left": 8.0,
"margin_right": 8.0,
"margin_top": 15.0,
"modified": "2026-07-24 17:19:05.091173",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Modern",
"owner": "Administrator",
"page_number": "Hide",
"pdf_generator": "chrome",
"print_format_builder": 0,
"print_format_builder_beta": 1,
"print_format_for": "DocType",
"print_format_type": "Jinja",
"raw_printing": 0,
"show_label_colon": 0,
"show_section_headings": 0,
"standard": "Yes",
"value_color": "#1f2328"
}

View File

@@ -0,0 +1,36 @@
{
"absolute_value": 0,
"align_labels_right": 0,
"creation": "2026-07-24 17:15:36.431075",
"custom_format": 0,
"disabled": 0,
"doc_type": "Request for Quotation",
"docstatus": 0,
"doctype": "Print Format",
"font": "Inter",
"font_size": 13,
"format_data": "{\"header\":{\"columns\":[{\"label\":\"\",\"fields\":[]}]},\"sections\":[{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_WJGFzvLg\",\"fieldtype\":\"HTML\",\"html\":\"<div style=\\\"text-align:center;\\\">\\n <div style=\\\"font-size:1.5em;font-weight:700;\\\">\\n Request for Quotation\\n </div>\\n <div style=\\\"color:#6b7280;margin-top:4px;\\\">\\n {{ doc.name }}\\n </div>\\n</div>\",\"custom\":1}]}],\"margin\":{\"top\":10,\"right\":0,\"bottom\":10,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_hXQEMfIw\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Custom HTML\",\"fieldname\":\"custom_html_WbBDnaVv_lSJFDiWO\",\"fieldtype\":\"HTML\",\"html\":\"<div>\\n <div style=\\\"color:#6b7280;\\\">\\n Supplier\\n </div>\\n</div>\",\"custom_style\":\"\",\"custom\":1},{\"label\":\"Supplier\",\"fieldname\":\"vendor\",\"fieldtype\":\"Link\",\"show_label\":\"hide\",\"align\":\"left\",\"label_justify\":\"\",\"custom_style\":\"font-weight: bold;\\n\"}],\"width\":67},{\"label\":\"\",\"fields\":[{\"label\":\"Order Date\",\"fieldname\":\"transaction_date\",\"fieldtype\":\"Date\",\"align\":\"right\",\"label_justify\":\"space-between\",\"label_gap\":null,\"custom_style\":\"\"},{\"label\":\"Required By\",\"fieldname\":\"schedule_date\",\"fieldtype\":\"Date\",\"show_label\":\"show\",\"align\":\"right\",\"label_justify\":\"space-between\",\"label_gap\":20,\"custom_style\":\"\"},{\"label\":\"Status\",\"fieldname\":\"status\",\"fieldtype\":\"Select\",\"options\":\"\\nDraft\\nSubmitted\\nCancelled\",\"align\":\"right\"}],\"width\":33}],\"has_fields\":true,\"label_case\":\"normal\",\"background\":\"\",\"field_orientation\":\"\",\"gap\":0,\"padding\":{\"top\":0,\"right\":0,\"bottom\":0,\"left\":0},\"inner_rows\":true,\"inner_cols\":true,\"cell_padding\":0,\"border\":{\"width\":1,\"color\":\"#e5e7eb\",\"radius\":6},\"show_label\":\"show\",\"margin\":{\"top\":15,\"right\":12,\"bottom\":0,\"left\":12},\"custom_style\":\"\"},{\"label\":\"Item\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"\",\"fieldname\":\"items\",\"fieldtype\":\"Table\",\"options\":\"Request for Quotation Item\",\"table_columns\":[{\"label\":\"No.\",\"fieldname\":\"idx\",\"fieldtype\":\"Data\",\"width\":5},{\"label\":\"Item\",\"fieldname\":\"item_name\",\"fieldtype\":\"Data\",\"width\":30,\"merged_fields\":[{\"fieldname\":\"image\",\"fieldtype\":\"Attach\",\"style\":\"secondary\"},{\"fieldname\":\"item_code\",\"fieldtype\":\"Link\",\"style\":\"secondary\"},{\"fieldname\":\"description\",\"fieldtype\":\"Text Editor\",\"style\":\"muted-sm\"}],\"image_size\":44},{\"label\":\"Quantity\",\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"options\":\"UOM\",\"width\":10,\"merged_fields\":[{\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity != 1\"},{\"label\":\"Quantity\",\"fieldname\":\"qty\",\"fieldtype\":\"Float\",\"width\":10,\"merged_fields\":[{\"fieldname\":\"uom\",\"fieldtype\":\"Link\",\"style\":\"secondary\"}],\"merge_direction\":\"horizontal\",\"column_condition\":\"print_settings.print_uom_after_quantity\"}],\"table_style\":\"lined\",\"table_bordered\":false,\"table_header\":\"styled\",\"table_cell_padding\":10,\"table_radius\":8}]}],\"has_fields\":true,\"gap\":12,\"show_label\":\"hide\",\"margin\":{\"top\":15,\"right\":0,\"bottom\":0,\"left\":0}},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Divider\",\"fieldname\":\"divider_cdcIgUdZ\",\"fieldtype\":\"Divider\",\"custom\":1}]}]},{\"label\":\"\",\"columns\":[{\"label\":\"\",\"fields\":[{\"label\":\"Terms and Conditions Details\",\"fieldname\":\"terms\",\"fieldtype\":\"Text Editor\"}]}],\"margin\":{\"top\":5,\"right\":12,\"bottom\":0,\"left\":12}}],\"footer\":{\"columns\":[{\"label\":\"\",\"fields\":[]}],\"show_label\":\"hide\"}}",
"idx": 0,
"label_color": "#6b7280",
"line_breaks": 0,
"margin_bottom": 8.0,
"margin_left": 8.0,
"margin_right": 8.0,
"margin_top": 15.0,
"modified": "2026-07-24 17:19:05.124407",
"modified_by": "Administrator",
"module": "Buying",
"name": "Request for Quotation Modern with Images",
"owner": "Administrator",
"page_number": "Hide",
"pdf_generator": "chrome",
"print_format_builder": 0,
"print_format_builder_beta": 1,
"print_format_for": "DocType",
"print_format_type": "Jinja",
"raw_printing": 0,
"show_label_colon": 0,
"show_section_headings": 0,
"standard": "Yes",
"value_color": "#1f2328"
}

View File

@@ -236,7 +236,9 @@ class AccountsController(TransactionBase):
else:
from erpnext.accounts.services.deferred_accounting import DeferredAccountingService
DeferredAccountingService(self).validate_start_and_end_date()
deferred_service = DeferredAccountingService(self)
deferred_service.clear_stale_deferred_fields()
deferred_service.validate_start_and_end_date()
from erpnext.accounts.services.internal_transfer import InternalTransferService

View File

@@ -338,9 +338,6 @@ class BuyingController(SubcontractingController):
if not details.get(field):
details[field] = frappe.get_cached_value("Company", self.company, field)
if not any(details.get(field) for field in fields):
return None
for field in fields:
if not details.get(field):
frappe.throw(
@@ -358,12 +355,26 @@ class BuyingController(SubcontractingController):
if self.doctype == "Purchase Invoice" and not self.update_stock:
return
stock_items = self.get_stock_items()
for row in self.items:
# A service item holds no stock value, so there is nothing to book against it - and it
# must not make the expense accounts mandatory either.
if row.item_code not in stock_items:
continue
details = self.get_validated_purchase_expense_details(row.item_code)
if not details:
continue
amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount"))
if row.landed_cost_voucher_amount:
amount -= flt(row.landed_cost_voucher_amount, row.precision("base_amount"))
if not amount:
# GL Entry rejects a row with neither a debit nor a credit.
continue
self.add_gl_entry(
gl_entries=gl_entries,
account=details.purchase_expense_account,

View File

@@ -911,8 +911,8 @@ class SellingController(StockController):
if self.get("is_return"):
return
sample_retention_warehouse = frappe.db.get_single_value(
"Stock Settings", "sample_retention_warehouse"
sample_retention_warehouse = frappe.get_cached_value(
"Company", self.company, "sample_retention_warehouse"
)
if not sample_retention_warehouse:
return

View File

@@ -307,33 +307,32 @@ class calculate_taxes_and_totals:
for item in self.doc.items:
item._unrounded_net_amount = None
item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0
total_inclusive_tax_amount_per_qty = 0
total_tax_slope = 0
total_tax_intercept = 0
for i, tax in enumerate(self.doc.get("taxes")):
(
tax.tax_fraction_for_current_item,
inclusive_tax_amount_per_qty,
) = self.get_current_tax_fraction(tax, item_tax_map)
tax_intercept_per_qty,
) = self.get_current_tax_fraction(tax, item_tax_map, item)
tax.inclusive_amount_per_qty = tax_intercept_per_qty
if i == 0:
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item
tax.grand_total_amount_per_qty = tax_intercept_per_qty
else:
prev = self.doc.get("taxes")[i - 1]
tax.grand_total_fraction_for_current_item = (
self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item
+ tax.tax_fraction_for_current_item
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item
)
tax.grand_total_amount_per_qty = prev.grand_total_amount_per_qty + tax_intercept_per_qty
cumulated_tax_fraction += tax.tax_fraction_for_current_item
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty)
total_tax_slope += tax.tax_fraction_for_current_item
total_tax_intercept += tax_intercept_per_qty * flt(item.qty)
if (
not self.discount_amount_applied
and item.qty
and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty)
):
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept):
amount = flt(item.amount) - total_tax_intercept
item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction)
item._unrounded_net_amount = amount / (1 + total_tax_slope)
item.net_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.discount_percentage = flt(
@@ -345,41 +344,48 @@ class calculate_taxes_and_totals:
def _load_item_tax_rate(self, item_tax_rate):
return frappe.parse_json(item_tax_rate) if item_tax_rate else {}
def get_current_tax_fraction(self, tax, item_tax_map):
def get_current_tax_fraction(self, tax, item_tax_map, item):
"""
Get tax fraction for calculating tax exclusive amount
from tax inclusive amount
tax = slope * net + intercept.
Returns (slope, intercept_per_qty)
"""
current_tax_fraction = 0
inclusive_tax_amount_per_qty = 0
tax_slope = 0
tax_intercept = 0
if cint(tax.included_in_print_rate):
tax_rate = self._get_tax_rate(tax, item_tax_map)
if tax_rate == NOT_APPLICABLE_TAX:
return current_tax_fraction, inclusive_tax_amount_per_qty
return tax_slope, tax_intercept
if tax.charge_type == "On Net Total":
current_tax_fraction = tax_rate / 100.0
tax_slope = tax_rate / 100.0
elif tax.charge_type == "On Previous Row Amount":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].tax_fraction_for_current_item
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "inclusive_amount_per_qty", 0))
elif tax.charge_type == "On Previous Row Total":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[
cint(tax.row_id) - 1
].grand_total_fraction_for_current_item
row = self.doc.get("taxes")[cint(tax.row_id) - 1]
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item
tax_intercept = (tax_rate / 100.0) * flt(getattr(row, "grand_total_amount_per_qty", 0))
elif tax.charge_type == "On Item Quantity":
inclusive_tax_amount_per_qty = flt(tax_rate)
tax_intercept = flt(tax_rate)
else:
# Custom charge_type: the rate applies to a resolved (fixed) base,
# e.g. a tax on MRP included in the printed price.
qty = flt(item.qty) or 1
base = self.get_item_taxable_base(item, tax)
tax_intercept = (tax_rate / 100.0) * base / qty
if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
current_tax_fraction *= -1.0
inclusive_tax_amount_per_qty *= -1.0
tax_slope *= -1.0
tax_intercept *= -1.0
return current_tax_fraction, inclusive_tax_amount_per_qty
return tax_slope, tax_intercept
def _get_tax_rate(self, tax, item_tax_map):
if tax.account_head in item_tax_map:
@@ -605,7 +611,6 @@ class calculate_taxes_and_totals:
elif tax.charge_type == "On Net Total":
if tax.account_head in item_tax_map:
current_net_amount = item.net_amount
# Use unrounded net for inclusive taxes to avoid double rounding
if (
cint(tax.included_in_print_rate)
@@ -624,12 +629,46 @@ class calculate_taxes_and_totals:
elif tax.charge_type == "On Item Quantity":
# don't sum current net amount due to the field being a currency field
current_tax_amount = tax_rate * item.qty
else:
# Custom charge_type: rate applies to the resolver-provided base.
base = self.get_item_taxable_base(item, tax)
current_net_amount = base
current_tax_amount = (tax_rate / 100.0) * base
if not tax.get("dont_recompute_tax"):
self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount, current_net_amount)
return current_net_amount, current_tax_amount
def get_item_taxable_base(self, item, tax):
"""Per-item base a custom charge_type's rate is applied to.
Override the base (gross, MRP, net of other taxes, …) via the
`erpnext_taxable_base_resolvers` hook
Register a resolver in `hooks.py`, keyed by charge_type:
erpnext_taxable_base_resolvers = {"On Gross Amount": "my_app.taxes.gross_base"}
It receives (calc, item, tax) — calc is this instance, calc.doc the parent —
and returns the base (flt-coerced by the caller):
def gross_base(calc, item, tax):
return item.custom_field_mrp * item.qty
A resolver may stamp transient attributes on `item`; it can be called more than once
per item, so such stamping must be idempotent.
"""
resolvers = frappe.get_hooks("erpnext_taxable_base_resolvers") or {}
path = resolvers.get(tax.charge_type)
if path:
method = path[-1] if isinstance(path, list | tuple) else path
return flt(frappe.get_attr(method)(self, item, tax))
# fallback
return flt(item.net_amount)
def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount):
# store tax breakup for each item
multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1

View File

@@ -1,12 +1,24 @@
from unittest import mock
from unittest.mock import patch
import frappe
from frappe.utils import flt
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
def resolve_on_gross(calc, item, tax):
# base = gross printed line amount
return flt(item.amount)
def resolve_on_mrp(calc, item, tax):
# base = MRP, not net
return flt(item.price_list_rate) * flt(item.qty)
class TestTaxesAndTotals(ERPNextTestSuite):
def test_regional_round_off_accounts(self):
"""
@@ -30,6 +42,93 @@ class TestTaxesAndTotals(ERPNextTestSuite):
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts)
def test_exclusive_custom_charge_on_resolved_base(self):
"""Added (exclusive) custom charge_type whose base is resolved by the
`erpnext_taxable_base_resolvers` hook. IPI 10% on the gross product value 1000
-> tax 100, net 1000, grand 1100."""
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 1000,
"price_list_rate": 1000,
"warehouse": "_Test Warehouse - _TC",
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On Gross Value",
"account_head": "_Test Account Excise Duty - _TC",
"description": "IPI 10% on gross product value",
"rate": 10,
"cost_center": "_Test Cost Center - _TC",
},
)
real_get_hooks = frappe.get_hooks
def fake_get_hooks(hook=None, *args, **kwargs):
if hook == "erpnext_taxable_base_resolvers":
return {
"On Gross Value": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_gross"]
}
return real_get_hooks(hook, *args, **kwargs)
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
calculate_taxes_and_totals(so)
self.assertEqual(so.net_total, 1000.0)
self.assertEqual(so.taxes[0].tax_amount, 100.0)
self.assertEqual(so.grand_total, 1100.0)
def test_inclusive_custom_charge_on_resolved_base(self):
"""Inclusive custom charge on a resolved base backs out non-compounding
(tax = rate x resolved base) — a resolved base is fixed, so it never
compounds. MRP 1200, printed 1000, rate 10%: tax 120, net 880."""
so = make_sales_order(do_not_save=True)
so.items = []
so.append(
"items",
{
"item_code": "_Test Item",
"qty": 1,
"rate": 1000,
"price_list_rate": 1200,
"warehouse": "_Test Warehouse - _TC",
},
)
so.set("taxes", [])
so.append(
"taxes",
{
"charge_type": "On MRP",
"account_head": "_Test Account VAT - _TC",
"description": "Tax 10% on MRP, inclusive",
"rate": 10,
"included_in_print_rate": 1,
"cost_center": "_Test Cost Center - _TC",
},
)
real_get_hooks = frappe.get_hooks
def fake_get_hooks(hook=None, *args, **kwargs):
if hook == "erpnext_taxable_base_resolvers":
return {"On MRP": ["erpnext.controllers.tests.test_taxes_and_totals.resolve_on_mrp"]}
return real_get_hooks(hook, *args, **kwargs)
with mock.patch("frappe.get_hooks", side_effect=fake_get_hooks):
calculate_taxes_and_totals(so)
self.assertEqual(so.taxes[0].tax_amount, 120.0)
self.assertEqual(so.net_total, 880.0)
self.assertEqual(so.grand_total, 1000.0)
def test_disabling_rounded_total_resets_base_fields(self):
"""Disabling rounded total should also clear base rounded values."""
so = make_sales_order(do_not_save=True)

View File

@@ -280,13 +280,17 @@ class Opportunity(TransactionBase, CRMNote):
self.save()
else:
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
frappe.throw(_("Cannot declare as Lost because an active Quotation exists."))
def has_active_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation",
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
{
"opportunity": self.name,
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
"docstatus": 1,
},
"name",
)
else:
@@ -300,7 +304,7 @@ class Opportunity(TransactionBase, CRMNote):
.where(
(q.docstatus == 1)
& (qi.prevdoc_docname == self.name)
& q.status.notin(["Lost", "Closed"])
& q.status.notin(["Lost", "Cancelled", "Expired"])
)
.run()
)
@@ -308,7 +312,13 @@ class Opportunity(TransactionBase, CRMNote):
def has_ordered_quotation(self):
if not self.get("items", []):
return frappe.get_all(
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
"Quotation",
{
"opportunity": self.name,
"status": ("in", ["Ordered", "Partially Ordered"]),
"docstatus": 1,
},
"name",
)
else:
q = frappe.qb.DocType("Quotation")
@@ -318,7 +328,11 @@ class Opportunity(TransactionBase, CRMNote):
.inner_join(qi)
.on(q.name == qi.parent)
.select(q.name)
.where((q.docstatus == 1) & (qi.prevdoc_docname == self.name) & (q.status == "Ordered"))
.where(
(q.docstatus == 1)
& (qi.prevdoc_docname == self.name)
& (q.status.isin(["Ordered", "Partially Ordered"]))
)
.run()
)

File diff suppressed because it is too large Load Diff

View File

@@ -225,7 +225,7 @@ class BOMCostingService:
for d in self.doc.get("items"):
old_rate = d.rate
if not self.doc.bom_creator and (d.is_stock_item or d.is_phantom_item):
if d.is_stock_item or d.is_phantom_item:
d.rate = self.get_rm_rate(self._rm_rate_args(d), notify=False)
self._set_item_amounts(d)

View File

@@ -538,15 +538,8 @@ class BOMCreator(Document):
row.delete()
updated = True
items = get_children(parent=kwargs.fg_item, parent_id=self.name)
if items:
for item in items:
updated = True
child_row = next((row for row in self.items if row.name == item.name), None)
if child_row:
child_row.delete()
if item.expandable:
self.delete_node(fg_item=item.value)
if self.delete_child_nodes(kwargs.docname or self.name):
updated = True
if updated:
self.set_rate_for_items()
@@ -556,6 +549,19 @@ class BOMCreator(Document):
return frappe._dict()
def delete_child_nodes(self, fg_reference_id: str):
deleted = False
for item in get_children(parent=fg_reference_id, parent_id=self.name):
child_row = next((row for row in self.items if row.name == item.name), None)
if child_row:
child_row.delete()
deleted = True
if item.expandable:
self.delete_child_nodes(item.name)
return deleted
@frappe.whitelist()
def get_children(doctype: str | None = None, parent: str | None = None, **kwargs):
@@ -568,7 +574,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
kwargs = frappe._dict(kwargs)
fields = [
"item_code as value",
"name as value",
"item_name as title",
"is_expandable as expandable",
"parent as parent_id",
@@ -576,6 +582,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
"idx",
ValueWrapper("BOM Creator Item").as_("doctype"),
"name",
"item_code",
"uom",
"rate",
"amount",
@@ -584,7 +591,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
]
query_filters = {
"fg_item": parent,
"fg_reference_id": parent,
"parent": kwargs.parent_id,
}

View File

@@ -241,6 +241,26 @@ class TestBOMCreator(ERPNextTestSuite):
data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1})
self.assertEqual(len(data), 2)
def test_repeated_sub_assembly_keeps_own_raw_materials(self):
doc, first_wheel, second_wheel = make_repeated_sub_assembly_bom(
"Bicycle BOM with Repeated Sub Assembly"
)
self.assertEqual(child_items(doc, doc.name), ["Frame Assembly", "Seat Assembly"])
self.assertEqual(child_items(doc, first_wheel), ["Rim", "Spokes"])
self.assertEqual(child_items(doc, second_wheel), ["Hub"])
def test_delete_repeated_sub_assembly_keeps_sibling_raw_materials(self):
doc, first_wheel, second_wheel = make_repeated_sub_assembly_bom(
"Bicycle BOM with Deleted Sub Assembly"
)
doc.delete_node(doctype="BOM Creator Item", docname=second_wheel)
doc.reload()
self.assertEqual(child_items(doc, first_wheel), ["Rim", "Spokes"])
self.assertFalse([row for row in doc.items if row.item_code == "Hub"])
def test_edit_and_delete_reject_unknown_item(self):
final_product = "Bicycle"
make_item(
@@ -327,6 +347,55 @@ def create_items():
)
def make_repeated_sub_assembly_bom(name):
"""Bicycle > (Frame Assembly > Wheel Assembly > Rim, Spokes), (Seat Assembly > Wheel Assembly > Hub)"""
final_product = "Bicycle"
make_item(final_product, {"item_group": "Raw Material", "stock_uom": "Nos"})
doc = make_bom_creator(
name=name,
company="_Test Company",
item_code=final_product,
qty=1,
rm_cosy_as_per="Valuation Rate",
currency="INR",
plc_conversion_rate=1,
conversion_rate=1,
)
def add_sub_assembly(fg_item, fg_reference_id, item_code, raw_materials):
doc.add_sub_assembly(
fg_item=fg_item,
fg_reference_id=fg_reference_id,
bom_item={
"item_code": item_code,
"qty": 1,
"items": [{"item_code": item, "qty": 1} for item in raw_materials],
},
)
doc.reload()
return next(
row.name
for row in doc.items
if row.item_code == item_code and row.fg_reference_id == fg_reference_id
)
frame = add_sub_assembly(final_product, doc.name, "Frame Assembly", ["Frame"])
first_wheel = add_sub_assembly("Frame Assembly", frame, "Wheel Assembly", ["Rim", "Spokes"])
seat = add_sub_assembly(final_product, doc.name, "Seat Assembly", ["Seat"])
second_wheel = add_sub_assembly("Seat Assembly", seat, "Wheel Assembly", ["Hub"])
return doc, first_wheel, second_wheel
def child_items(doc, parent):
from erpnext.manufacturing.doctype.bom_creator.bom_creator import get_children
return sorted(row.item_code for row in get_children(parent=parent, parent_id=doc.name))
def make_bom_creator(**kwargs):
if isinstance(kwargs, str) or isinstance(kwargs, dict):
kwargs = frappe.parse_json(kwargs)

View File

@@ -1037,18 +1037,31 @@ class JobCard(Document):
return for_quantity, time_in_mins, process_loss_qty, pending_qty
def update_semi_finished_good_details(self):
if self.operation_id:
qty = max(flt(self.manufactured_qty), flt(self.total_completed_qty))
if not self.operation_id:
return
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", qty)
if (
self.finished_good
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
== self.finished_good
):
_wo_doc = frappe.get_doc("Work Order", self.work_order)
_wo_doc.db_set("produced_qty", self.manufactured_qty)
_wo_doc.db_set("status", _wo_doc.get_status())
job_cards = frappe.get_all(
"Job Card",
filters={
"work_order": self.work_order,
"operation_id": self.operation_id,
"docstatus": 1,
"is_corrective_job_card": 0,
},
fields=["manufactured_qty", "total_completed_qty"],
)
completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards)
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
if (
self.finished_good
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
== self.finished_good
):
_wo_doc = frappe.get_doc("Work Order", self.work_order)
_wo_doc.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
_wo_doc.db_set("status", _wo_doc.get_status())
def update_corrective_in_work_order(self, wo):
wo.corrective_operation_cost = 0.0

View File

@@ -1186,6 +1186,109 @@ class TestJobCard(ERPNextTestSuite):
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
def test_semi_fg_produced_qty_across_split_job_cards(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.manufacturing.doctype.work_order.mapper import make_job_card
from erpnext.stock.doctype.item.test_item import make_item
warehouse = "Stores - _TC"
rm = make_item("Split JC RM 1", {"is_stock_item": 1}).name
fg = make_item("Split JC FG 1", {"is_stock_item": 1}).name
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
operation = {
"operation": "Split JC Op A",
"workstation": "_Test Workstation A",
"finished_good": fg,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation)
make_operation(operation)
fg_bom.append("operations", operation)
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg,
qty=8,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
)
job_card.for_quantity = 5
job_card.append(
"time_logs",
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5},
)
job_card.save()
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 5)
make_job_card(
work_order.name,
[
{
"name": work_order.operations[0].name,
"operation": "Split JC Op A",
"qty": 3,
"pending_qty": 3,
"skip_material_transfer": 1,
}
],
)
job_card = frappe.get_doc(
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0})
)
job_card.append(
"time_logs",
{
"from_time": "2024-02-02 08:00:00",
"to_time": "2024-02-02 09:00:00",
"completed_qty": job_card.for_quantity,
},
)
job_card.save()
job_card.submit()
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
work_order.reload()
self.assertEqual(flt(work_order.produced_qty), 8)
self.assertEqual(work_order.status, "Completed")
self.assertEqual(
flt(frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "completed_qty")),
8,
)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
"""Batch produced by an operation should auto-pull into the next operation's
semi-finished consumption row (skip-transfer Manufacture entry)."""

View File

@@ -10,12 +10,20 @@ from frappe.query_builder.functions import IfNull, Sum
from pypika.terms import ExistsCriterion
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
from erpnext.stock.doctype.item.item import get_uom_conv_factor
def get_uom_conversion_factor(item_code, uom):
return frappe.db.get_value(
item = frappe.get_cached_value("Item", item_code, ["variant_of", "stock_uom"], as_dict=True)
conversion_factor = frappe.db.get_value(
"UOM Conversion Detail", {"parent": item_code, "uom": uom}, "conversion_factor"
)
if not conversion_factor and item.variant_of:
conversion_factor = frappe.db.get_value(
"UOM Conversion Detail", {"parent": item.variant_of, "uom": uom}, "conversion_factor"
)
return conversion_factor or get_uom_conv_factor(uom, item.stock_uom)
@frappe.whitelist()

View File

@@ -1903,6 +1903,118 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(row.warehouse, mrp_warhouse)
self.assertEqual(row.quantity, 12.0)
def test_purchase_uom_falls_back_to_uom_conversion_factor(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
if not frappe.db.exists("UOM Conversion Factor", {"from_uom": "Kg", "to_uom": "Gram"}):
frappe.get_doc(
doctype="UOM Conversion Factor",
category="Mass",
from_uom="Kg",
to_uom="Gram",
value=1000,
).insert()
rm = make_item("Test RM Item Global CF", {"is_stock_item": 1, "stock_uom": "Gram"})
rm.purchase_uom = "Kg"
rm.save()
self.assertFalse([row for row in rm.uoms if row.uom == "Kg"])
bom_tree = {"Test FG Item Global CF": {rm.name: {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=2000,
ignore_existing_ordered_qty=1,
skip_getting_mr_items=1,
do_not_submit=1,
warehouse="_Test Warehouse - _TC",
)
plan.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
)
row = frappe._dict(next(item for item in items if item["item_code"] == rm.name))
self.assertEqual(row.uom, "Kg")
self.assertEqual(row.conversion_factor, 1000)
self.assertEqual(row.quantity, 2)
def test_variant_inherits_purchase_uom_conversion_factor_of_template(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
template = make_item(
"TRMVCF",
{
"is_stock_item": 1,
"stock_uom": "Nos",
"has_variants": 1,
"attributes": [{"attribute": "Colour"}],
},
)
if not [row for row in template.uoms if row.uom == "Box"]:
template.purchase_uom = "Box"
template.append("uoms", {"uom": "Box", "conversion_factor": 12})
template.save()
if not frappe.db.exists("Item", "TRMVCF-RED"):
create_variant("TRMVCF", {"Colour": "Red"}).insert()
variant = frappe.get_doc("Item", "TRMVCF-RED")
variant.uoms = [row for row in variant.uoms if row.uom != "Box"]
variant.purchase_uom = "Box"
variant.save()
bom_tree = {"Test FG Item Variant CF": {variant.name: {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=24,
ignore_existing_ordered_qty=1,
skip_getting_mr_items=1,
do_not_submit=1,
warehouse="_Test Warehouse - _TC",
)
plan.for_warehouse = "_Test Warehouse - _TC"
items = get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
)
row = frappe._dict(next(item for item in items if item["item_code"] == variant.name))
self.assertEqual(row.conversion_factor, 12)
self.assertEqual(row.quantity, 2)
def test_missing_purchase_uom_conversion_factor_throws(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
rm = make_item("Test RM Item Missing CF", {"is_stock_item": 1, "stock_uom": "Nos"})
rm.purchase_uom = "Box"
rm.save()
bom_tree = {"Test FG Item Missing CF": {rm.name: {}}}
parent_bom = create_nested_bom(bom_tree, prefix="")
plan = create_production_plan(
item_code=parent_bom.item,
planned_qty=10,
ignore_existing_ordered_qty=1,
skip_getting_mr_items=1,
do_not_submit=1,
warehouse="_Test Warehouse - _TC",
)
plan.for_warehouse = "_Test Warehouse - _TC"
with self.assertRaises(frappe.ValidationError) as error:
get_items_for_material_requests(
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
)
self.assertIn("UOM Conversion factor", str(error.exception))
def test_mr_qty_for_complex_bom(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse

View File

@@ -79,6 +79,11 @@ frappe.ui.form.on("BOM Operation", {
const d = locals[cdt][cdn];
frm.events.calculate_operating_cost(frm, d);
},
hour_rate: function (frm, cdt, cdn) {
const d = locals[cdt][cdn];
frm.events.calculate_operating_cost(frm, d);
},
});
frappe.tour["Routing"] = [

View File

@@ -80,7 +80,7 @@ class TestWorkstation(ERPNextTestSuite):
test_routing_operations = [
{"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 60},
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 60},
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 30},
]
routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations)
bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR")
@@ -113,12 +113,16 @@ class TestWorkstation(ERPNextTestSuite):
# update_bom_operation() (run on w1.save()) must write the new rate directly onto the
# Routing's BOM Operation rows. This is the converted query's own effect (not the BOM
# update_cost above) and is what silently skipped on Postgres when parenttype was 'routing'.
routing_op_rate = frappe.db.get_value(
"BOM Operation",
{"parent": routing_doc.name, "parenttype": "Routing", "workstation": "_Test Workstation A"},
"hour_rate",
)
self.assertEqual(routing_op_rate, 250)
# It must also refresh operating_cost (hour_rate * time_in_mins / 60); the 30-min op
# exercises the arithmetic rather than a plain rate copy.
for operation, expected_operating_cost in (("Test Operation A", 250), ("Test Operation B", 125)):
hour_rate, operating_cost = frappe.db.get_value(
"BOM Operation",
{"parent": routing_doc.name, "parenttype": "Routing", "operation": operation},
["hour_rate", "operating_cost"],
)
self.assertEqual(hour_rate, 250)
self.assertEqual(operating_cost, expected_operating_cost)
def make_workstation(*args, **kwargs):

View File

@@ -206,6 +206,7 @@ class Workstation(Document):
(
frappe.qb.update(bom_op)
.set(bom_op.hour_rate, self.hour_rate)
.set(bom_op.operating_cost, self.hour_rate * bom_op.time_in_mins / 60)
.where(bom_op.parent.isin(bom_list) & (bom_op.workstation == self.name))
.run()
)

View File

@@ -501,4 +501,9 @@ erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
erpnext.patches.v16_0.access_control_for_project_users
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0)
erpnext.patches.v16_0.recompute_production_plan_reserved_qty
erpnext.patches.v16_0.recalculate_bins_for_production_plan_items
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
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
erpnext.patches.v16_0.merge_seeded_item_group_root

View File

@@ -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()
)

View File

@@ -0,0 +1,28 @@
import frappe
def execute():
"""
This patch corrects the titles of the subcontracting order doctypes set to
the text strings "{customer_name}" or "{supplier_name}" instead of the
actual customer or supplier name.
Their `title_field` never pointed at `title`, so the template default was
stored verbatim instead of being substituted.
"""
party_fields = {
"Subcontracting Order": "supplier_name",
"Subcontracting Inward Order": "customer_name",
}
for doctype, party_field in party_fields.items():
if not frappe.db.has_column(doctype, "title"):
continue
table = frappe.qb.DocType(doctype)
(
frappe.qb.update(table)
.set(table.title, table[party_field])
.where(table.title == f"{{{party_field}}}")
).run()

View File

@@ -0,0 +1,23 @@
import frappe
from frappe.utils.nestedset import get_root_of
SEEDED_ROOT = "All Item Groups"
def execute():
"""Collapse the "All Item Groups" node seeded under a pre-existing root.
Setup seeding always inserted "All Item Groups" as a parentless group. On a
site where another app had already created the root (under a translated
name), it was re-parented instead, leaving a second group-root holding the
standard Item Groups.
"""
root = get_root_of("Item Group")
if not root or root == SEEDED_ROOT:
return
seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
if not seeded or not seeded.is_group or seeded.parent_item_group != root:
return
frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)

View File

@@ -0,0 +1,20 @@
import frappe
import frappe.defaults
FIELDS = ("default_warehouse", "sample_retention_warehouse")
def execute():
"""Move the global warehouse defaults from Stock Settings onto the Company that owns them."""
settings = frappe.db.get_singles_dict("Stock Settings")
warehouses = {field: settings.get(field) for field in FIELDS if settings.get(field)}
if not warehouses:
return
for field, warehouse in warehouses.items():
company = frappe.db.get_value("Warehouse", warehouse, "company")
if company:
frappe.db.set_value("Company", company, field, warehouse)
frappe.db.delete("Singles", {"doctype": "Stock Settings", "field": ("in", FIELDS)})
frappe.defaults.clear_default("default_warehouse")

View File

@@ -20,4 +20,4 @@ def execute():
bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
if not bin_name:
continue
frappe.get_doc("Bin", bin_name, for_update=True).update_reserved_qty_for_production_plan()
frappe.get_doc("Bin", bin_name, for_update=True).recalculate_values()

View File

@@ -0,0 +1,45 @@
import frappe
REPORTS = (
"Accounts Receivable",
"Accounts Payable",
"Accounts Receivable Summary",
"Accounts Payable Summary",
)
def execute():
# filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today"
_migrate("Auto Email Report", "filters", "report")
_migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type")
_migrate("Number Card", "filters_json", "report_name", type_field="type")
def _migrate(doctype, filter_field, report_field, type_field=None):
conditions = {report_field: ("in", REPORTS)}
if type_field:
conditions[type_field] = "Report"
for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]):
updated = _rewrite(row.get(filter_field))
if updated is not None:
frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False)
def _rewrite(raw):
if not raw:
return None
try:
filters = frappe.parse_json(raw)
except ValueError:
return None
if not isinstance(filters, dict) or "calculate_ageing_with" not in filters:
return None
filters["age_as_on"] = filters.pop("calculate_ageing_with")
if filters["age_as_on"] == "Today Date":
filters["age_as_on"] = "Today"
return frappe.as_json(filters, indent=None)

View File

@@ -453,6 +453,17 @@ class TestTimesheet(ERPNextTestSuite):
rate = get_timesheet_detail_rate(detail.name, timesheet.currency)
self.assertEqual(rate, detail.billing_amount)
def test_title_follows_employee(self):
first = make_employee("_test_timesheet_title_one@example.com", company="_Test Company")
second = make_employee("_test_timesheet_title_two@example.com", company="_Test Company")
timesheet = make_timesheet(first, simulate=True, do_not_submit=True)
self.assertEqual(timesheet.get_title(), frappe.db.get_value("Employee", first, "employee_name"))
timesheet.employee = second
timesheet.save()
self.assertEqual(timesheet.get_title(), frappe.db.get_value("Employee", second, "employee_name"))
@staticmethod
def _delete_if_exists(doctype, name):
if frappe.db.exists(doctype, name):

View File

@@ -49,7 +49,6 @@
"fields": [
{
"allow_on_submit": 1,
"default": "{employee_name}",
"fieldname": "title",
"fieldtype": "Data",
"hidden": 1,
@@ -315,7 +314,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
"modified": "2026-04-08 12:43:30.658074",
"modified": "2026-07-30 11:04:12.882140",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet",
@@ -409,5 +408,5 @@
"sort_field": "creation",
"sort_order": "ASC",
"states": [],
"title_field": "title"
"title_field": "employee_name"
}

View File

@@ -57,6 +57,7 @@ class BOMConfigurator {
breadcrumb: "Manufacturing",
get_tree_nodes: "erpnext.manufacturing.doctype.bom_creator.bom_creator.get_children",
root_label: this.frm.doc.item_code,
get_label: (node) => this.get_node_label(node),
disable_add_node: true,
get_tree_root: false,
show_expand_all: false,
@@ -66,6 +67,23 @@ class BOMConfigurator {
};
}
get_node_label(node) {
const item_code = this.get_item_code(node);
const item_name = node.data?.title || item_code;
if (item_name === item_code) {
return frappe.utils.escape_html(item_code);
}
return `${frappe.utils.escape_html(item_name)} <span class='text-muted'>(${frappe.utils.escape_html(
item_code
)})</span>`;
}
get_item_code(node) {
return node.data?.item_code || this.frm.doc.item_code;
}
tree_methods() {
let frm_obj = this;
let view = frappe.views.trees["BOM Configurator"];
@@ -73,7 +91,8 @@ class BOMConfigurator {
return {
onload: function (me) {
me.args["parent_id"] = frm_obj.frm.doc.name;
me.args["parent"] = frm_obj.frm.doc.item_code;
me.args["parent"] = frm_obj.frm.doc.name;
me.root_value = frm_obj.frm.doc.name;
me.parent = frm_obj.$wrapper.get(0);
me.body = frm_obj.$wrapper.get(0);
me.make_tree();
@@ -83,7 +102,7 @@ class BOMConfigurator {
const uom = node.data.uom || frm_obj.frm.doc.uom;
const docname = node.data.name || frm_obj.frm.doc.name;
let amount = node.data.amount;
if (node.data.value === frm_obj.frm.doc.item_code) {
if (node.is_root) {
amount = frm_obj.frm.doc.raw_material_cost;
}
@@ -243,7 +262,7 @@ class BOMConfigurator {
method: "add_item",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
fg_item: this.get_item_code(node),
item_code: data.item_code,
fg_reference_id: node.data.name || this.frm.doc.name,
qty: data.qty,
@@ -298,7 +317,7 @@ class BOMConfigurator {
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
fg_item: this.get_item_code(node),
fg_reference_id: node.data.name || this.frm.doc.name,
bom_item: bom_item,
operation: node.data.operation,
@@ -417,7 +436,7 @@ class BOMConfigurator {
});
dialog.set_values({
item_code: node.data.value,
item_code: this.get_item_code(node),
qty: node.data.qty,
});
@@ -445,7 +464,7 @@ class BOMConfigurator {
method: "add_sub_assembly",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
fg_item: this.get_item_code(node),
bom_item: bom_item,
fg_reference_id: node.data.name || this.frm.doc.name,
convert_to_sub_assembly: true,
@@ -482,7 +501,6 @@ class BOMConfigurator {
method: "delete_node",
doc: this.frm.doc,
args: {
fg_item: node.data.value,
doctype: node.data.doctype,
docname: node.data.name,
},

View File

@@ -3,6 +3,11 @@
const NOT_APPLICABLE_TAX = "N/A";
// Per-charge_type base resolvers, mirror of the `erpnext_taxable_base_resolvers`
// server hook. A localization registers `fn(calc, item, tax)` returning the per-item
// base, so the client preview matches the server for custom charge types.
erpnext.taxable_base_resolvers = erpnext.taxable_base_resolvers || {};
erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
setup() {
this.fetch_round_off_accounts();
@@ -263,32 +268,32 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
$.each(this.frm.doc.items || [], function (n, item) {
item._unrounded_net_amount = null;
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
var cumulated_tax_fraction = 0.0;
var total_inclusive_tax_amount_per_qty = 0;
var total_tax_slope = 0.0;
var total_tax_intercept = 0;
$.each(me.frm.doc["taxes"] || [], function (i, tax) {
var current_tax_fraction = me.get_current_tax_fraction(tax, item_tax_map);
tax.tax_fraction_for_current_item = current_tax_fraction[0];
var inclusive_tax_amount_per_qty = current_tax_fraction[1];
var tax_contribution = me.get_current_tax_fraction(tax, item_tax_map, item);
tax.tax_fraction_for_current_item = tax_contribution[0];
var tax_intercept_per_qty = tax_contribution[1];
tax.inclusive_amount_per_qty = tax_intercept_per_qty;
if (i == 0) {
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item;
tax.grand_total_amount_per_qty = tax_intercept_per_qty;
} else {
var prev = me.frm.doc["taxes"][i - 1];
tax.grand_total_fraction_for_current_item =
me.frm.doc["taxes"][i - 1].grand_total_fraction_for_current_item +
tax.tax_fraction_for_current_item;
prev.grand_total_fraction_for_current_item + tax.tax_fraction_for_current_item;
tax.grand_total_amount_per_qty =
flt(prev.grand_total_amount_per_qty) + tax_intercept_per_qty;
}
cumulated_tax_fraction += tax.tax_fraction_for_current_item;
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty);
total_tax_slope += tax.tax_fraction_for_current_item;
total_tax_intercept += tax_intercept_per_qty * flt(item.qty);
});
if (
!me.discount_amount_applied &&
item.qty &&
(total_inclusive_tax_amount_per_qty || cumulated_tax_fraction)
) {
var amount = flt(item.amount) - total_inclusive_tax_amount_per_qty;
item._unrounded_net_amount = amount / (1 + cumulated_tax_fraction);
if (!me.discount_amount_applied && item.qty && (total_tax_intercept || total_tax_slope)) {
var amount = flt(item.amount) - total_tax_intercept;
item._unrounded_net_amount = amount / (1 + total_tax_slope);
item.net_amount = flt(item._unrounded_net_amount, precision("net_amount", item));
item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0;
@@ -297,39 +302,53 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
});
}
get_current_tax_fraction(tax, item_tax_map) {
// Get tax fraction for calculating tax exclusive amount
// from tax inclusive amount
var current_tax_fraction = 0.0;
var inclusive_tax_amount_per_qty = 0;
get_current_tax_fraction(tax, item_tax_map, item) {
// tax = slope * net + intercept.
// Returns [slope, intercept_per_qty]
var tax_slope = 0.0;
var tax_intercept = 0;
if (cint(tax.included_in_print_rate)) {
var tax_rate = this._get_tax_rate(tax, item_tax_map);
if (tax_rate === NOT_APPLICABLE_TAX) {
return [current_tax_fraction, inclusive_tax_amount_per_qty];
return [tax_slope, tax_intercept];
}
if (tax.charge_type == "On Net Total") {
current_tax_fraction = tax_rate / 100.0;
tax_slope = tax_rate / 100.0;
} else if (tax.charge_type == "On Previous Row Amount") {
current_tax_fraction =
(tax_rate / 100.0) *
this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_fraction_for_current_item;
const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item;
tax_intercept = (tax_rate / 100.0) * flt(row.inclusive_amount_per_qty);
} else if (tax.charge_type == "On Previous Row Total") {
current_tax_fraction =
(tax_rate / 100.0) *
this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_fraction_for_current_item;
const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item;
tax_intercept = (tax_rate / 100.0) * flt(row.grand_total_amount_per_qty);
} else if (tax.charge_type == "On Item Quantity") {
inclusive_tax_amount_per_qty = flt(tax_rate);
tax_intercept = flt(tax_rate);
} else {
// Custom charge_type: the rate applies to a resolved (fixed) base,
// e.g. a tax on MRP included in the printed price.
const qty = flt(item.qty) || 1;
const base = this.get_item_taxable_base(item, tax);
tax_intercept = ((tax_rate / 100.0) * base) / qty;
}
}
if (tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") {
current_tax_fraction *= -1;
inclusive_tax_amount_per_qty *= -1;
tax_slope *= -1;
tax_intercept *= -1;
}
return [current_tax_fraction, inclusive_tax_amount_per_qty];
return [tax_slope, tax_intercept];
}
get_item_taxable_base(item, tax) {
// Mirror of the server get_item_taxable_base: a custom charge_type's resolver
// overrides the base value; otherwise the net amount.
const resolver = erpnext.taxable_base_resolvers[tax.charge_type];
if (resolver) return flt(resolver(this, item, tax));
return flt(item.net_amount);
}
_get_tax_rate(tax, item_tax_map) {
@@ -591,6 +610,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
} else if (tax.charge_type == "On Item Quantity") {
// don't sum current net amount due to the field being a currency field
current_tax_amount = tax_rate * item.qty;
} else {
// Custom charge_type: rate applies to the resolver-provided base.
var resolved_base = this.get_item_taxable_base(item, tax);
current_net_amount = resolved_base;
current_tax_amount = (tax_rate / 100.0) * resolved_base;
}
return [current_net_amount, current_tax_amount];

View File

@@ -219,7 +219,7 @@ def append_row_as_charges(items, tax, reference_row, summary_data):
# Preflight for successful e-invoice export.
def sales_invoice_validate(doc):
# Validate company
if doc.doctype != "Sales Invoice":
if doc.doctype != "Sales Invoice" or doc.is_opening == "Yes":
return
if not doc.company_address:
@@ -303,7 +303,7 @@ def sales_invoice_validate(doc):
# Ensure payment details are valid for e-invoice.
def sales_invoice_on_submit(doc, method):
# Validate payment details
if get_company_country(doc.company) not in [
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
"Italy",
"Italia",
"Italian Republic",
@@ -369,7 +369,7 @@ def generate_single_invoice(docname: str):
# Delete e-invoice attachment on cancel.
def sales_invoice_on_cancel(doc, method):
if get_company_country(doc.company) not in [
if doc.is_opening == "Yes" or get_company_country(doc.company) not in [
"Italy",
"Italia",
"Italian Republic",

View File

@@ -445,33 +445,44 @@ class TestCustomer(ERPNextTestSuite):
overdue = get_customer_overdue_amount("_Test Customer", "_Test Company")
settings = frappe.get_single("Accounts Settings")
settings.enable_overdue_billing_threshold = 1
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100)
original_enable = settings.enable_overdue_billing_threshold
original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
try:
settings.enable_overdue_billing_threshold = 1
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100)
# overdue is over the threshold and the user has no bypass role -> blocked
si = create_sales_invoice(do_not_submit=True)
self.assertRaises(frappe.ValidationError, si.submit)
# overdue is over the threshold and the user has no bypass role -> blocked
si = create_sales_invoice(do_not_submit=True)
self.assertRaises(frappe.ValidationError, si.submit)
# a user holding the bypass role can still submit
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
# a user holding the bypass role can still submit
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
# threshold still crossed, but the feature is off -> never blocked
settings.enable_overdue_billing_threshold = 0
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
# threshold still crossed, but the feature is off -> never blocked
settings.enable_overdue_billing_threshold = 0
settings.role_allowed_to_bypass_overdue_billing = None
settings.save()
si = create_sales_invoice(do_not_submit=True)
si.submit()
self.assertEqual(si.docstatus, 1)
finally:
settings.enable_overdue_billing_threshold = original_enable
settings.role_allowed_to_bypass_overdue_billing = original_bypass_role
settings.save()
def test_overdue_billing_threshold_falls_back_to_customer_group(self):
customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group")
group = frappe.get_doc("Customer Group", customer_group)
customer = frappe.get_doc("Customer", "_Test Customer")
self._restore_credit_limits_after(group)
self._restore_credit_limits_after(customer)
group.credit_limits = []
group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000})
group.save()
@@ -483,6 +494,22 @@ class TestCustomer(ERPNextTestSuite):
set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000)
# a 0 on the customer inherits the group's limit
set_overdue_billing_threshold("_Test Customer", "_Test Company", 0)
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
def _restore_credit_limits_after(self, doc):
original = [row.as_dict(no_default_fields=True) for row in doc.credit_limits]
def restore():
fresh = frappe.get_doc(doc.doctype, doc.name)
fresh.credit_limits = []
for row in original:
fresh.append("credit_limits", row)
fresh.save()
self.addCleanup(restore)
def test_overdue_threshold_row_without_credit_limit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice

Some files were not shown because too many files have changed in this diff Show More