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 bank_account_loc = loc
for row in data[1:]: for row in data[1:]:
if bank_account_loc: if bank_account_loc is not None:
row[bank_account_loc] = bank_account row[bank_account_loc] = bank_account
else: else:
row.append(bank_account) row.append(bank_account)

View File

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

View File

@@ -623,15 +623,27 @@ class ExchangeRateRevaluation(Document):
if journals: if journals:
from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry from erpnext.accounts.doctype.journal_entry.mapper import make_reverse_journal_entry
for x in journals: if drafts := frappe.db.get_all(
reversal = make_reverse_journal_entry(x) "Journal Entry",
reversal.posting_date = nowdate() filters={"docstatus": 0, "reversal_of": ["in", journals]},
reversal.submit() pluck="name",
frappe.msgprint( as_list=1,
_("Revaluation journal for {0} has been created: {1}").format( ):
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name) 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): 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.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin): class TestExchangeRateRevaluation(ERPNextTestSuite):
def setUp(self): def setUp(self):
self.company = "_Test Company" self.company = "_Test Company"
self.item = "_Test Item" self.item = "_Test Item"
@@ -23,14 +22,6 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
self.set_system_and_company_settings() self.set_system_and_company_settings()
def set_system_and_company_settings(self): 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. # Using Exchange Gain/Loss account for unrealized as well.
company_doc = frappe.get_doc("Company", self.company) company_doc = frappe.get_doc("Company", self.company)
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account 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( si = create_sales_invoice(
item=self.item, item=self.item,
company=self.company, company=self.company,
customer=self.customer, customer="_Test Customer 1",
debit_to=self.debtors_usd, debit_to=self.debtors_usd,
posting_date=today(), posting_date=today(),
parent_cost_center=self.cost_center, parent_cost_center=self.cost_center,
@@ -377,6 +368,15 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
self.assertFalse(ret.get("reversals_posted")) self.assertFalse(ret.get("reversals_posted"))
err.make_reverse_journal() 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() ret = err.check_journal_and_reversal()
self.assertTrue(ret.get("journals_posted")) self.assertTrue(ret.get("journals_posted"))
self.assertTrue(ret.get("reversals_posted")) self.assertTrue(ret.get("reversals_posted"))

View File

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

View File

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

View File

@@ -219,7 +219,8 @@ class POSClosingEntry(StatusUpdater):
self.update_sales_invoices_closing_entry() self.update_sales_invoices_closing_entry()
def before_cancel(self): def before_cancel(self):
self.check_pce_is_cancellable() if self.status != "Failed":
self.check_pce_is_cancellable()
def on_cancel(self): def on_cancel(self):
unconsolidate_pos_invoices(closing_entry=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()): if frappe.safe_eval(pricing_rule.condition, None, doc.as_dict()):
filtered_pricing_rules.append(pricing_rule) filtered_pricing_rules.append(pricing_rule)
except Exception: 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: else:
filtered_pricing_rules.append(pricing_rule) filtered_pricing_rules.append(pricing_rule)
else: else:

View File

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

View File

@@ -22,27 +22,50 @@ frappe.ui.form.on("Repost Accounting Ledger", {
}, },
refresh: function (frm) { refresh: function (frm) {
frm.add_custom_button(__("Show Preview"), () => { // the server refuses only while the job is alive, so a dead one can be restarted here
frm.call({ if (frm.doc.docstatus == 1 && !["Completed", "Cancelled"].includes(frm.doc.status)) {
method: "generate_preview", frm.add_custom_button(__("Start Reposting"), () => {
doc: frm.doc, frm.events.start_repost(frm);
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
}); });
}
if (frm.doc.docstatus != 2) {
frm.add_custom_button(__("Show Preview"), () => {
frm.events.generate_preview(frm);
});
}
},
generate_preview: function (frm) {
frm.call({
method: "generate_preview",
doc: frm.doc,
freeze: true,
freeze_message: __("Generating Preview"),
callback: function (r) {
if (r && r.message) {
let content = r.message;
let opts = {
title: "Preview",
subtitle: "preview",
content: content,
print_settings: { orientation: "landscape" },
columns: [],
data: [],
};
frappe.render_grid(opts);
}
},
});
},
start_repost: function (frm) {
frm.call({
method: "start_repost",
doc: frm.doc,
callback: function (r) {
frm.reload_doc();
},
}); });
}, },
}); });

View File

@@ -1,5 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_bulk_edit": 1,
"creation": "2023-07-04 13:07:32.923675", "creation": "2023-07-04 13:07:32.923675",
"default_view": "List", "default_view": "List",
"doctype": "DocType", "doctype": "DocType",
@@ -7,16 +8,24 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"company", "company",
"column_break_vpup",
"delete_cancelled_entries", "delete_cancelled_entries",
"column_break_vpup",
"status",
"section_break_metl", "section_break_metl",
"vouchers", "vouchers",
"amended_from" "error_section",
"error_log",
"miscellaneous_section",
"amended_from",
"column_break_hrah",
"scheduled_job"
], ],
"fields": [ "fields": [
{ {
"fieldname": "company", "fieldname": "company",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company", "label": "Company",
"options": "Company" "options": "Company"
}, },
@@ -48,12 +57,54 @@
"fieldname": "delete_cancelled_entries", "fieldname": "delete_cancelled_entries",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Delete Cancelled Ledger Entries" "label": "Delete Cancelled Ledger Entries"
},
{
"fieldname": "error_section",
"fieldtype": "Section Break",
"label": "Error"
},
{
"fieldname": "error_log",
"fieldtype": "Code",
"label": "Error Log",
"no_copy": 1,
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "miscellaneous_section",
"fieldtype": "Section Break",
"label": "Miscellaneous"
},
{
"fieldname": "column_break_hrah",
"fieldtype": "Column Break"
},
{
"depends_on": "eval:doc.docstatus >= 1;",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "\nQueued\nIn Progress\nPartially Reposted\nCompleted\nFailed\nCancelled",
"read_only": 1
},
{
"fieldname": "scheduled_job",
"fieldtype": "Link",
"hidden": 1,
"label": "Scheduled Job",
"no_copy": 1,
"options": "RQ Job",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2024-06-03 17:30:37.012593", "modified": "2026-07-28 00:56:50.290314",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Repost Accounting Ledger", "name": "Repost Accounting Ledger",
@@ -76,8 +127,9 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@@ -7,7 +7,14 @@ import frappe
from frappe import _, qb from frappe import _, qb
from frappe.desk.form.linked_with import get_child_tables_of_doctypes from frappe.desk.form.linked_with import get_child_tables_of_doctypes
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils.background_jobs import create_job_id, is_job_enqueued
from frappe.utils.data import comma_and from frappe.utils.data import comma_and
from frappe.utils.scheduler import is_scheduler_inactive
# a batch has to finish well within the timeout of the job reposting it
MAX_VOUCHERS_PER_REPOST = 50
HANDLED_VOUCHER_STATUSES = ("Reposted", "Skipped")
class RepostAccountingLedger(Document): class RepostAccountingLedger(Document):
@@ -26,6 +33,11 @@ class RepostAccountingLedger(Document):
amended_from: DF.Link | None amended_from: DF.Link | None
company: DF.Link | None company: DF.Link | None
delete_cancelled_entries: DF.Check delete_cancelled_entries: DF.Check
error_log: DF.Code | None
scheduled_job: DF.Link | None
status: DF.Literal[
"", "Queued", "In Progress", "Partially Reposted", "Completed", "Failed", "Cancelled"
]
vouchers: DF.Table[RepostAccountingLedgerItems] vouchers: DF.Table[RepostAccountingLedgerItems]
# end: auto-generated types # end: auto-generated types
@@ -35,6 +47,11 @@ class RepostAccountingLedger(Document):
def validate(self): def validate(self):
self.validate_vouchers() self.validate_vouchers()
self.validate_repost_preconditions()
def validate_repost_preconditions(self):
"""The checks a repost queued days ago could have outlived, re-run before it touches
the ledger. Vouchers cancelled since are skipped one by one while reposting."""
self.validate_for_closed_fiscal_year() self.validate_for_closed_fiscal_year()
self.validate_for_deferred_accounting() self.validate_for_deferred_accounting()
@@ -71,8 +88,52 @@ class RepostAccountingLedger(Document):
frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year.")) frappe.throw(_("Cannot Resubmit Ledger entries for vouchers in Closed fiscal year."))
def validate_vouchers(self): def validate_vouchers(self):
if self.vouchers: if not self.vouchers:
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers]) frappe.throw(_("Add atleast one voucher to repost."))
if len(self.vouchers) > MAX_VOUCHERS_PER_REPOST:
frappe.throw(
_("Cannot repost more than {0} vouchers at once. Split them into multiple documents.").format(
MAX_VOUCHERS_PER_REPOST
)
)
validate_docs_for_voucher_types([x.voucher_type for x in self.vouchers])
self.validate_no_duplicate_vouchers()
self.validate_vouchers_are_submitted()
def validate_no_duplicate_vouchers(self):
vouchers = [(x.voucher_type, x.voucher_no) for x in self.vouchers]
if len(vouchers) != len(set(vouchers)):
frappe.throw(_("Duplicate vouchers found. Remove the duplicate vouchers to continue to repost."))
def validate_vouchers_are_submitted(self):
voucher_type_wise_map = {}
for d in self.vouchers:
voucher_type_wise_map.setdefault(d.voucher_type, [])
voucher_type_wise_map[d.voucher_type].append(d.voucher_no)
non_submitted_vouchers = []
for key in voucher_type_wise_map.keys():
non_submitted_vouchers.extend(
frappe.get_all(
key,
filters={"name": ["in", voucher_type_wise_map[key]], "docstatus": ["!=", 1]},
pluck="name",
)
)
if non_submitted_vouchers:
frappe.throw(
_("The following vouchers are not submitted: {0}").format(
comma_and(non_submitted_vouchers, add_quotes=True)
)
)
def on_discard(self):
self.db_set("status", "Cancelled")
def get_existing_ledger_entries(self): def get_existing_ledger_entries(self):
vouchers = [x.voucher_no for x in self.vouchers] vouchers = [x.voucher_no for x in self.vouchers]
@@ -137,80 +198,245 @@ class RepostAccountingLedger(Document):
return rendered_page return rendered_page
def on_submit(self): def on_submit(self):
if len(self.vouchers) > 5: self.start_repost()
job_name = "repost_accounting_ledger_" + self.name
frappe.enqueue( def before_cancel(self):
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.start_repost", self._raise_error_if_reposting_in_progress()
account_repost_doc=self.name,
is_async=True, def on_cancel(self):
job_name=job_name, self.db_set("status", "Cancelled")
enqueue_after_commit=True,
def _raise_error_if_reposting_in_progress(self):
if self.scheduled_job and is_job_enqueued(_repost_job_id(self.name)):
frappe.throw(_("Reposting is still in progress in background."))
@frappe.whitelist()
def start_repost(self):
if self.docstatus != 1:
frappe.throw(_("Reposting can be started only for submitted document."))
# under a row lock, so two concurrent starts cannot both get past here
status = frappe.db.get_value(self.doctype, self.name, "status", for_update=True)
if status in ("Completed", "Cancelled"):
frappe.throw(_("Reposting cannot be started when status is {0}.").format(status))
# `Queued` and `In Progress` are held back by the job, not by the status: a worker that
# died leaves the status behind and the document has to stay restartable
self._raise_error_if_reposting_in_progress()
self.check_permission("write")
# workers pick up enqueued jobs whether or not the scheduler runs, so this is a warning
if is_scheduler_inactive():
frappe.msgprint(
_("Scheduler is inactive. Reposting will only run once background jobs are processed."),
alert=True,
indicator="orange",
) )
frappe.msgprint(_("Repost has started in the background"))
else: self.db_set({"status": "Queued", "scheduled_job": create_job_id(_repost_job_id(self.name))})
start_repost(self.name) _enqueue_repost(self.name)
frappe.msgprint(_("Repost has started in the background"), alert=True, indicator="blue")
@frappe.whitelist() def _repost_job_id(repost_doc_name: str) -> str:
def start_repost(account_repost_doc: str | None = None) -> None: """Derived from the document, so a repost can only ever have one job."""
from erpnext.accounts.general_ledger import make_reverse_gl_entries return f"repost_accounting_ledger::{repost_doc_name}"
def _enqueue_repost(repost_doc_name: str) -> None:
"""Hand the repost to a background worker.
Tests run it in the foreground, inside their own transaction: documents edited after submit
repost themselves through `repost_accounting_entries`, and tests across apps assert on the
ledger right after doing so.
"""
frappe.enqueue(
method="erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger.repost",
repost_doc_name=repost_doc_name,
commit=not frappe.in_test,
queue="long",
timeout=1500,
job_id=_repost_job_id(repost_doc_name),
deduplicate=True,
enqueue_after_commit=True,
now=frappe.in_test,
)
def _lock_vouchers(vouchers) -> dict:
"""Lock every voucher up front so a concurrent repost cannot touch the same GL entries.
Returns them keyed by voucher, so reposting does not load them again. These are file locks
under the site directory: they serialise nothing across hosts that do not share it, and a
worker killed outright leaves them behind until they expire.
"""
locked_docs = {}
try:
for x in vouchers:
doc = frappe.get_doc(x.voucher_type, x.voucher_no)
doc.lock()
locked_docs[(x.voucher_type, x.voucher_no)] = doc
except Exception:
for doc in locked_docs.values():
doc.unlock()
raise
return locked_docs
def repost(repost_doc_name: str, commit: bool = True):
"""Repost every voucher of the document, one transaction at a time.
`commit` says whether this call owns the transaction. The background job does, and commits
after every voucher so progress survives a crash; a caller inside its own passes `False`.
"""
from erpnext.accounts.utils import _delete_accounting_ledger_entries, _delete_adv_pl_entries
frappe.flags.through_repost_accounting_ledger = True frappe.flags.through_repost_accounting_ledger = True
if account_repost_doc:
repost_doc = frappe.get_doc("Repost Accounting Ledger", account_repost_doc)
repost_doc.check_permission("write")
if repost_doc.docstatus == 1: repost_doc = frappe.get_doc("Repost Accounting Ledger", repost_doc_name)
# Prevent repost on invoices with deferred accounting locked_docs = {}
repost_doc.validate_for_deferred_accounting()
for x in repost_doc.vouchers: try:
doc = frappe.get_doc(x.voucher_type, x.voucher_no) repost_doc.validate_repost_preconditions()
# a retry leaves the vouchers it is done with alone: they are not locked, not loaded
# and not reposted again
pending = [x for x in repost_doc.vouchers if x.status not in HANDLED_VOUCHER_STATUSES]
locked_docs = _lock_vouchers(pending)
repost_doc.db_set("status", "In Progress", commit=commit)
for position, x in enumerate(pending, start=1):
frappe.publish_progress(
position * 100 / len(pending),
doctype=repost_doc.doctype,
docname=repost_doc.name,
description=_("Reposting {0} {1}").format(x.voucher_type, x.voucher_no),
)
save_point = "reposting"
frappe.db.savepoint(save_point=save_point)
try:
doc = locked_docs[(x.voucher_type, x.voucher_no)]
if doc.docstatus == 2:
x.db_set({"status": "Skipped", "traceback": ""})
continue
if repost_doc.delete_cancelled_entries: if repost_doc.delete_cancelled_entries:
frappe.db.delete( _delete_accounting_ledger_entries(doc.doctype, doc.name)
"GL Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name} _delete_adv_pl_entries(doc.doctype, doc.name)
)
frappe.db.delete(
"Payment Ledger Entry", filters={"voucher_type": doc.doctype, "voucher_no": doc.name}
)
frappe.db.delete(
"Advance Payment Ledger Entry",
filters={"voucher_type": doc.doctype, "voucher_no": doc.name},
)
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]: _repost_vouchers(doc, repost_doc.delete_cancelled_entries)
if not repost_doc.delete_cancelled_entries: except Exception:
doc.docstatus = 2 frappe.db.rollback(save_point=save_point)
doc.make_gl_entries_on_cancel(from_repost=True)
doc.docstatus = 1 x.db_set({"status": "Failed", "traceback": frappe.get_traceback()})
if doc.doctype == "Sales Invoice": else:
doc.force_set_against_income_account() x.db_set({"status": "Reposted", "traceback": ""})
else: finally:
doc.force_set_against_expense_account() if commit:
doc.make_gl_entries() frappe.db.commit() # nosemgrep
elif doc.doctype == "Purchase Receipt": except Exception:
if not repost_doc.delete_cancelled_entries: if commit:
doc.docstatus = 2 frappe.db.rollback()
doc.make_gl_entries_on_cancel(from_repost=True)
doc.docstatus = 1 _record_repost_failure(repost_doc, commit=commit)
doc.make_gl_entries(from_repost=True) raise
else:
repost_doc.db_set({"status": _derive_status(repost_doc), "error_log": ""}, notify=True)
finally:
for doc in locked_docs.values():
doc.unlock()
if commit:
frappe.db.commit() # nosemgrep
elif doc.doctype in ["Payment Entry", "Journal Entry", "Expense Claim"]:
if not repost_doc.delete_cancelled_entries: def _derive_status(repost_doc) -> str:
doc.make_gl_entries(1) """Vouchers are committed one by one, so the status follows what was actually handled."""
doc.make_gl_entries() handled = sum(1 for voucher in repost_doc.vouchers if voucher.status in HANDLED_VOUCHER_STATUSES)
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
if hasattr(doc, "make_gl_entries") and callable(doc.make_gl_entries): if handled == len(repost_doc.vouchers):
if not repost_doc.delete_cancelled_entries: return "Completed"
if "cancel" in inspect.getfullargspec(doc.make_gl_entries): elif handled == 0:
doc.make_gl_entries(cancel=1) return "Failed"
else:
make_reverse_gl_entries(voucher_type=doc.doctype, voucher_no=doc.name) return "Partially Reposted"
doc.make_gl_entries()
def _record_repost_failure(repost_doc, commit=False) -> None:
"""Persist the traceback of a run that could not finish, without discarding its progress."""
# the traceback with frame locals goes to the Error Log, which is permissioned separately
traceback = frappe.get_traceback()
frappe.log_error(
title=_("Unable to Repost Accounting Ledger"),
reference_doctype=repost_doc.doctype,
reference_name=repost_doc.name,
)
frappe.db.set_value(
repost_doc.doctype, repost_doc.name, {"error_log": traceback, "status": _derive_status(repost_doc)}
)
if commit:
frappe.db.commit()
def _repost_vouchers(doc, delete_cancelled_entries: bool | int | None):
if doc.doctype in ["Sales Invoice", "Purchase Invoice"]:
_repost_invoices(doc, delete_cancelled_entries)
elif doc.doctype == "Purchase Receipt":
_repost_purchase_receipt(doc, delete_cancelled_entries)
elif doc.doctype in ["Payment Entry", "Journal Entry"]:
_repost_pe_je(doc, delete_cancelled_entries)
elif doc.doctype in frappe.get_hooks("repost_allowed_doctypes"):
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries)
def _repost_invoices(invoice_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
invoice_doc.docstatus = 2
invoice_doc.make_gl_entries_on_cancel(from_repost=True)
invoice_doc.docstatus = 1
if invoice_doc.doctype == "Sales Invoice":
invoice_doc.force_set_against_income_account()
else:
invoice_doc.force_set_against_expense_account()
invoice_doc.make_gl_entries()
def _repost_purchase_receipt(receipt_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
receipt_doc.docstatus = 2
receipt_doc.make_gl_entries_on_cancel(from_repost=True)
receipt_doc.docstatus = 1
receipt_doc.make_gl_entries(from_repost=True)
def _repost_pe_je(entry_doc, delete_cancelled_entries):
if not delete_cancelled_entries:
entry_doc.make_gl_entries(cancel=1)
entry_doc.make_gl_entries()
def _repost_allowed_hook_doctypes(repost_doc, delete_cancelled_entries: bool | int | None):
from erpnext.accounts.general_ledger import make_reverse_gl_entries
if hasattr(repost_doc, "make_gl_entries") and callable(repost_doc.make_gl_entries):
if not delete_cancelled_entries:
if "cancel" in inspect.getfullargspec(repost_doc.make_gl_entries).args:
repost_doc.make_gl_entries(cancel=1)
else:
make_reverse_gl_entries(voucher_type=repost_doc.doctype, voucher_no=repost_doc.name)
repost_doc.make_gl_entries()
def get_allowed_types_from_settings(child_doc: bool = False): def get_allowed_types_from_settings(child_doc: bool = False):

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 # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
from contextlib import contextmanager
from unittest.mock import patch
import frappe import frappe
from frappe import qb from frappe import qb
from frappe.query_builder.functions import Sum from frappe.query_builder.functions import Sum
from frappe.utils import add_days, nowdate, today from frappe.utils import add_days, nowdate, today
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.repost_accounting_ledger.repost_accounting_ledger import (
_lock_vouchers,
_record_repost_failure,
_repost_allowed_hook_doctypes,
_repost_job_id,
_repost_vouchers,
repost,
)
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries, make_purchase_receipt
from erpnext.tests.utils import ERPNextTestSuite 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): class TestRepostAccountingLedger(ERPNextTestSuite):
def setUp(self): def setUp(self):
frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0) frappe.db.set_single_value("Selling Settings", "validate_selling_price", 0)
update_repost_settings() update_repost_settings()
def test_01_basic_functions(self): def make_invoice(self, **kwargs):
si = create_sales_invoice( return create_sales_invoice(
item="_Test Item", item="_Test Item",
company="_Test Company", company="_Test Company",
customer="_Test Customer", customer="_Test Customer",
@@ -29,8 +44,71 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
parent_cost_center="Main - _TC", parent_cost_center="Main - _TC",
cost_center="Main - _TC", cost_center="Main - _TC",
rate=100, rate=100,
**kwargs,
) )
def make_invoice_and_payment(self):
si = self.make_invoice()
pe = get_payment_entry(si.doctype, si.name)
pe.save().submit()
return si, pe
def create_repost_doc(self, vouchers, delete_cancelled_entries=False, submit=False):
ral = frappe.new_doc("Repost Accounting Ledger")
ral.company = "_Test Company"
ral.delete_cancelled_entries = delete_cancelled_entries
for voucher in vouchers:
ral.append("vouchers", {"voucher_type": voucher.doctype, "voucher_no": voucher.name})
ral.save()
if submit:
ral.submit()
ral.reload()
return ral
@contextmanager
def patched_repost(self, fail_for=()):
"""Yield the vouchers handed over to `_repost_vouchers`, failing the given types."""
reposted = []
def repost_voucher(doc, delete_cancelled_entries):
reposted.append(doc.name)
if doc.doctype in fail_for:
frappe.throw(SIMULATED_FAILURE)
_repost_vouchers(doc, delete_cancelled_entries)
with patch(f"{REPOST_MODULE}._repost_vouchers", new=repost_voucher):
yield reposted
def make_period_closing_voucher(self):
fy = get_fiscal_year(today(), company="_Test Company")
pcv = frappe.get_doc(
{
"doctype": "Period Closing Voucher",
"transaction_date": today(),
"period_start_date": fy[1],
"period_end_date": today(),
"company": "_Test Company",
"fiscal_year": fy[0],
"cost_center": "Main - _TC",
"closing_account_head": "Retained Earnings - _TC",
"remarks": "test",
}
)
return pcv.save().submit()
def get_gl_totals(self, voucher_no, is_cancelled=0):
gl = qb.DocType("GL Entry")
return (
qb.from_(gl)
.select(Sum(gl.debit).as_("debit"), Sum(gl.credit).as_("credit"))
.where((gl.voucher_no == voucher_no) & (gl.is_cancelled == is_cancelled))
.run()
)[0]
def test_01_basic_functions(self):
si = self.make_invoice()
preq = frappe.get_doc( preq = frappe.get_doc(
make_payment_request( make_payment_request(
dt=si.doctype, dt=si.doctype,
@@ -64,53 +142,24 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gle = frappe.db.get_all("GL Entry", filters={"voucher_no": si.name, "account": "Debtors - _TC"}) 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) 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 # Assert incorrect ledger balance
self.assertNotEqual(res[0], (si.name, 100, 100)) self.assertNotEqual(self.get_gl_totals(si.name), (100, 100))
# Submit repost document # Submit repost document
ral.save().submit() ral.save().submit()
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 # Ledger should reflect correct amount post repost
self.assertEqual(res[0], (si.name, 100, 100)) self.assertEqual(self.get_gl_totals(si.name), (100, 100))
def test_02_deferred_accounting_valiations(self): def test_02_deferred_accounting_valiations(self):
si = create_sales_invoice( si = self.make_invoice(do_not_submit=True)
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.items[0].enable_deferred_revenue = True si.items[0].enable_deferred_revenue = True
si.items[0].deferred_revenue_account = "Deferred Revenue - _TC" si.items[0].deferred_revenue_account = "Deferred Revenue - _TC"
si.items[0].service_start_date = nowdate() si.items[0].service_start_date = nowdate()
si.items[0].service_end_date = add_days(nowdate(), 90) si.items[0].service_end_date = add_days(nowdate(), 90)
si.save().submit() si.save().submit()
ral = frappe.new_doc("Repost Accounting Ledger") self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1}) @ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_04_pcv_validation(self): def test_04_pcv_validation(self):
@@ -118,86 +167,29 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
gl = frappe.qb.DocType("GL Entry") gl = frappe.qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == "_Test Company").run() qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = create_sales_invoice( si = self.make_invoice()
item="_Test Item", pcv = self.make_period_closing_voucher()
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()
ral = frappe.new_doc("Repost Accounting Ledger") self.assertRaises(frappe.ValidationError, self.create_repost_doc, [si])
ral.company = "_Test Company"
ral.append("vouchers", {"voucher_type": si.doctype, "voucher_no": si.name})
self.assertRaises(frappe.ValidationError, ral.save)
pcv.reload() pcv.reload()
pcv.cancel() pcv.cancel()
pcv.delete() pcv.delete()
def test_03_deletion_flag_and_preview_function(self): def test_03_deletion_flag_and_preview_function(self):
si = create_sales_invoice( si, pe = self.make_invoice_and_payment()
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()
# with deletion flag set # with deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger") self.create_repost_doc([si, pe], delete_cancelled_entries=True, submit=True)
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.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1})) self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1})) self.assertIsNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
def test_05_without_deletion_flag(self): def test_05_without_deletion_flag(self):
si = create_sales_invoice( si, pe = self.make_invoice_and_payment()
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()
# without deletion flag set # without deletion flag set
ral = frappe.new_doc("Repost Accounting Ledger") self.create_repost_doc([si, pe], submit=True)
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.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1})) self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": si.name, "is_cancelled": 1}))
self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1})) self.assertIsNotNone(frappe.db.exists("GL Entry", {"voucher_no": pe.name, "is_cancelled": 1}))
@@ -248,11 +240,7 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
another_provisional_account, another_provisional_account,
) )
repost_doc = frappe.new_doc("Repost Accounting Ledger") repost_doc = self.create_repost_doc([pr], delete_cancelled_entries=True, submit=True)
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()
pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True) pr_gles_after_repost = get_gl_entries(pr.doctype, pr.name, skip_cancelled=True)
expected_pr_gles_after_repost = [ expected_pr_gles_after_repost = [
@@ -273,6 +261,281 @@ class TestRepostAccountingLedger(ERPNextTestSuite):
company.default_provisional_account = None company.default_provisional_account = None
company.save() company.save()
def test_07_voucher_validations(self):
submitted_si = self.make_invoice()
draft_si = self.make_invoice(do_not_submit=True)
cancelled_si = self.make_invoice()
cancelled_si.cancel()
for vouchers, exception, message in (
([], frappe.ValidationError, "Add atleast one voucher"),
([submitted_si, submitted_si], frappe.ValidationError, "Duplicate vouchers found"),
([draft_si], frappe.ValidationError, f"not submitted.*{draft_si.name}"),
# cancelled vouchers don't make it past link validation
([cancelled_si], frappe.CancelledLinkError, "Cannot link cancelled document"),
):
with self.subTest(vouchers=[x.name for x in vouchers]):
self.assertRaisesRegex(exception, message, self.create_repost_doc, vouchers)
self.create_repost_doc([submitted_si])
def test_08_voucher_count_limit(self):
si, pe = self.make_invoice_and_payment()
another_si = self.make_invoice()
with patch(f"{REPOST_MODULE}.MAX_VOUCHERS_PER_REPOST", 2):
self.create_repost_doc([si, pe])
self.assertRaisesRegex(
frappe.ValidationError,
"Cannot repost more than 2 vouchers",
self.create_repost_doc,
[si, pe, another_si],
)
def test_09_status_lifecycle(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
self.assertEqual(ral.status, "")
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Completed")
self.assertFalse(ral.error_log)
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
ral.cancel()
ral.reload()
self.assertEqual(ral.status, "Cancelled")
discarded = self.create_repost_doc([si])
discarded.discard()
discarded.reload()
self.assertEqual(discarded.status, "Cancelled")
def test_10_start_repost_guards(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
self.assertRaisesRegex(frappe.ValidationError, "only for submitted document", ral.start_repost)
ral.submit()
ral.reload()
self.assertRaisesRegex(
frappe.ValidationError, "cannot be started when status is Completed", ral.start_repost
)
# a document left behind by a worker that died mid-repost
ral.db_set("status", "In Progress")
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=True):
self.assertRaisesRegex(
frappe.ValidationError, "still in progress in background", ral.start_repost
)
self.assertRaisesRegex(frappe.ValidationError, "still in progress in background", ral.cancel)
# `cancel` flips docstatus in memory before running `before_cancel`
ral.reload()
with patch(f"{REPOST_MODULE}.is_job_enqueued", return_value=False):
# the job is gone, so `In Progress` must not keep the document stuck
ral.start_repost()
ral.reload()
self.assertEqual(ral.status, "Completed")
def test_11_repost_job_is_tied_to_the_document(self):
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
with patch(f"{REPOST_MODULE}.frappe.enqueue") as enqueue:
ral.start_repost()
kwargs = enqueue.call_args.kwargs
self.assertEqual(kwargs["repost_doc_name"], ral.name)
self.assertEqual(kwargs["job_id"], _repost_job_id(ral.name))
# a second start cannot queue a second job for the same document
self.assertTrue(kwargs["deduplicate"])
def test_12_voucher_failures_are_isolated_and_retried(self):
si, pe = self.make_invoice_and_payment()
pe_gl_entries = frappe.db.count("GL Entry", {"voucher_no": pe.name})
# the deletion flag drops the existing entries before reposting them
ral = self.create_repost_doc([si, pe], delete_cancelled_entries=True)
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Partially Reposted")
si_row, pe_row = ral.vouchers
self.assertEqual((si_row.status, pe_row.status), ("Reposted", "Failed"))
self.assertFalse(si_row.traceback)
self.assertIn(SIMULATED_FAILURE, pe_row.traceback)
# the failed voucher is rolled back to its savepoint, so its entries are back
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pe.name}), pe_gl_entries)
# a retry only picks up the vouchers that are not reposted yet, and leaves the rest
# alone entirely: they are not locked or loaded either
with (
patch(f"{REPOST_MODULE}._lock_vouchers", side_effect=_lock_vouchers) as lock_vouchers,
self.patched_repost() as retried,
):
ral.start_repost()
self.assertEqual(retried, [pe.name])
self.assertEqual([x.voucher_no for x in lock_vouchers.call_args.args[0]], [pe.name])
ral.reload()
self.assertEqual(ral.status, "Completed")
for voucher in ral.vouchers:
self.assertEqual(voucher.status, "Reposted")
self.assertFalse(voucher.traceback)
def test_13_status_of_a_run_that_could_not_finish(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
with self.patched_repost(fail_for=["Payment Entry"]):
ral.submit()
ral.reload()
# the job dies after the loop committed the invoice, e.g. killed or timed out
try:
frappe.throw(SIMULATED_FAILURE)
except frappe.ValidationError:
_record_repost_failure(ral)
ral.reload()
# progress already committed must not be reported as a total failure
self.assertEqual(ral.status, "Partially Reposted")
self.assertIn(SIMULATED_FAILURE, ral.error_log)
self.assertTrue(
frappe.db.exists("Error Log", {"reference_doctype": ral.doctype, "reference_name": ral.name})
)
@ERPNextTestSuite.change_settings("Accounts Settings", {"delete_linked_ledger_entries": 1})
def test_14_period_closed_after_the_repost_was_started(self):
gl = qb.DocType("GL Entry")
qb.from_(gl).delete().where(gl.company == "_Test Company").run()
si = self.make_invoice()
ral = self.create_repost_doc([si], submit=True)
ral.db_set("status", "Failed")
ral.vouchers[0].db_set("status", "Pending")
# the period is closed between the repost being started and the job running
self.make_period_closing_voucher()
gl_entries = frappe.db.count("GL Entry", {"voucher_no": si.name})
self.assertRaisesRegex(frappe.ValidationError, "Closed fiscal year", repost, ral.name, commit=False)
ral.reload()
self.assertEqual(ral.status, "Failed")
self.assertIn("Closed fiscal year", ral.error_log)
# the ledger is left exactly as it was
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": si.name}), gl_entries)
self.assertEqual(ral.vouchers[0].status, "Pending")
def test_15_failed_repost_skips_cancelled_voucher(self):
si = self.make_invoice()
ral = self.create_repost_doc([si])
with self.patched_repost(fail_for=["Sales Invoice"]):
ral.submit()
ral.reload()
self.assertEqual(ral.status, "Failed")
si.reload()
si.cancel()
ral.start_repost()
ral.reload()
# nothing was reposted, but there is nothing left to repost either
self.assertEqual(ral.status, "Completed")
self.assertEqual(ral.vouchers[0].status, "Skipped")
self.assertFalse(ral.vouchers[0].traceback)
def test_16_concurrent_repost_is_blocked_by_voucher_lock(self):
si, pe = self.make_invoice_and_payment()
ral = self.create_repost_doc([si, pe])
# a concurrent repost holding the lock on the second voucher
locked_pe = frappe.get_doc(pe.doctype, pe.name)
locked_pe.lock()
try:
self.assertRaises(frappe.DocumentLockedError, ral.submit)
# vouchers locked before the failure are released again
self.assertFalse(frappe.get_doc(si.doctype, si.name).is_locked)
finally:
locked_pe.unlock()
def test_17_journal_entry_repost(self):
je = make_journal_entry("_Test Bank - _TC", "_Test Cash - _TC", 500, submit=True)
je = frappe.get_doc("Journal Entry", je.name)
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
# without the deletion flag the 2 original entries are marked as cancelled,
# along with the 2 reverse entries booked against them
for delete_cancelled_entries, cancelled_entries in ((False, 4), (True, 0)):
with self.subTest(delete_cancelled_entries=delete_cancelled_entries):
ral = self.create_repost_doc(
[je], delete_cancelled_entries=delete_cancelled_entries, submit=True
)
self.assertEqual(ral.status, "Completed")
self.assertEqual(self.get_gl_totals(je.name), (500.0, 500.0))
self.assertEqual(
frappe.db.count("GL Entry", {"voucher_no": je.name, "is_cancelled": 1}),
cancelled_entries,
)
def test_18_hook_allowed_doctype_repost(self):
class VoucherWithCancelArg:
doctype = "Test Repost Voucher"
name = "TRV-00001"
def __init__(self):
self.calls = []
def make_gl_entries(self, cancel=0):
self.calls.append(cancel)
class VoucherWithoutCancelArg(VoucherWithCancelArg):
def make_gl_entries(self):
self.calls.append("repost")
# vouchers that can reverse their own entries are asked to do so first
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
self.assertEqual(doc.calls, [1, 0])
# nothing to reverse when the old entries are deleted
doc = VoucherWithCancelArg()
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=True)
self.assertEqual(doc.calls, [0])
# the rest fall back to the generic reversal
doc = VoucherWithoutCancelArg()
with patch("erpnext.accounts.general_ledger.make_reverse_gl_entries") as make_reverse_gl_entries:
_repost_allowed_hook_doctypes(doc, delete_cancelled_entries=False)
make_reverse_gl_entries.assert_called_once_with(voucher_type=doc.doctype, voucher_no=doc.name)
self.assertEqual(doc.calls, ["repost"])
def update_repost_settings(): def update_repost_settings():
allowed_types = [ allowed_types = [

View File

@@ -1,5 +1,6 @@
{ {
"actions": [], "actions": [],
"allow_bulk_edit": 1,
"allow_rename": 1, "allow_rename": 1,
"creation": "2023-07-04 14:14:01.243848", "creation": "2023-07-04 14:14:01.243848",
"doctype": "DocType", "doctype": "DocType",
@@ -7,34 +8,70 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"voucher_type", "voucher_type",
"voucher_no" "column_break_ndex",
"voucher_no",
"reposting_status_section",
"status",
"traceback"
], ],
"fields": [ "fields": [
{ {
"columns": 5,
"fieldname": "voucher_type", "fieldname": "voucher_type",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Voucher Type", "label": "Voucher Type",
"options": "DocType" "options": "DocType",
"reqd": 1
}, },
{ {
"fieldname": "column_break_ndex",
"fieldtype": "Column Break"
},
{
"columns": 5,
"fieldname": "voucher_no", "fieldname": "voucher_no",
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"in_list_view": 1, "in_list_view": 1,
"label": "Voucher No", "label": "Voucher No",
"options": "voucher_type" "options": "voucher_type",
"reqd": 1
},
{
"fieldname": "reposting_status_section",
"fieldtype": "Section Break",
"label": "Reposting Status"
},
{
"columns": 2,
"default": "Pending",
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"no_copy": 1,
"options": "Pending\nReposted\nSkipped\nFailed",
"read_only": 1
},
{
"fieldname": "traceback",
"fieldtype": "Code",
"label": "Traceback",
"no_copy": 1,
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2024-03-27 13:10:32.170897", "modified": "2026-07-29 02:41:00.000000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Repost Accounting Ledger Items", "name": "Repost Accounting Ledger Items",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"row_format": "Dynamic",
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"states": [] "states": []
} }

View File

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

View File

@@ -3215,6 +3215,10 @@ class TestSalesInvoice(ERPNextTestSuite):
"Stock Received But Not Billed - _TC1", "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 # begin test
si = create_sales_invoice( si = create_sales_invoice(
company="Wind Power LLC", 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 // Status -> colour and label for the calendar heatmap. Keys are Title-case to
// match the value frappe-charts shows in its hover tooltip. // match the value frappe-charts shows in its hover tooltip.
const HEATMAP_COLORS = { const HEATMAP_COLORS = {

View File

@@ -26,6 +26,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
) )
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate 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): class InvoiceCancelled(frappe.ValidationError):
@@ -981,6 +982,39 @@ def get_prorata_factor(
return diff / plan_days 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: 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 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.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.accounts.utils import update_subscription_on_invoice_update
from erpnext.tests.utils import ERPNextTestSuite from erpnext.tests.utils import ERPNextTestSuite
@@ -951,6 +956,47 @@ class TestSubscription(ERPNextTestSuite):
cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()} cells = {cell["date"]: cell for cell in subscription.get_billing_heatmap()}
self.assertEqual(cells[str(getdate(invoice.from_date))]["status"], "refunded") 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): def make_full_credit_note(invoice_name):
from erpnext.accounts.doctype.sales_invoice.mapper import make_sales_return 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" 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"] 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", fieldname: "report_date",
label: __("Posting Date"), label: __("Report Date"),
fieldtype: "Date", fieldtype: "Date",
default: frappe.datetime.get_today(), default: frappe.datetime.get_today(),
}, },
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
default: "Due Date", default: "Due Date",
}, },
{ {
fieldname: "calculate_ageing_with", fieldname: "age_as_on",
label: __("Calculate Ageing With"), label: __("Age as on"),
fieldtype: "Select", fieldtype: "Select",
options: "Report Date\nToday Date", options: "Report Date\nToday",
default: "Report Date", default: "Report Date",
}, },
{ {
@@ -180,17 +180,25 @@ frappe.query_reports["Accounts Payable"] = {
return Object.assign(options, { return Object.assign(options, {
checkboxColumn: true, checkboxColumn: true,
events: { events: {
onCheckRow: () => erpnext.accounts.toggle_create_pe_primary_action(frappe.query_report), onCheckRow: () => toggle_create_pe_button(frappe.query_report),
}, },
}); });
}, },
after_refresh: function (report) { after_refresh: function (report) {
report.datatable?.rowmanager?.checkAll(false); report.datatable?.rowmanager?.checkAll(false);
report.page.clear_primary_action(); toggle_create_pe_button(report);
}, },
onload: function (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 () { report.page.add_inner_button(__("Accounts Payable Summary"), function () {
var filters = report.get_values(); var filters = report.get_values();
frappe.set_route("query-report", "Accounts Payable Summary", { company: filters.company }); frappe.set_route("query-report", "Accounts Payable Summary", { company: filters.company });
@@ -202,25 +210,17 @@ frappe.query_reports["Accounts Payable"] = {
}, },
}; };
frappe.provide("erpnext.accounts"); function toggle_create_pe_button(report) {
if (!report || !report.create_pe_btn || !report.datatable) return;
erpnext.accounts.toggle_create_pe_primary_action = function (report) {
if (!report || !report.datatable || !frappe.model.can_create("Payment Entry")) return;
const has_purchase_invoice = report.datatable.rowmanager const has_purchase_invoice = report.datatable.rowmanager
.getCheckedRows() .getCheckedRows()
.some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice"); .some((i) => report.datatable.datamanager.data[i]?.voucher_type === "Purchase Invoice");
if (has_purchase_invoice) { report.create_pe_btn.toggle(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();
}
};
erpnext.accounts.create_payment_entries_from_payable_report = function (report) { function create_payment_entries_from_payable_report(report) {
const datatable = report.datatable; const datatable = report.datatable;
if (!datatable) return; if (!datatable) return;
@@ -343,7 +343,7 @@ erpnext.accounts.create_payment_entries_from_payable_report = function (report)
}, },
}); });
dialog.show(); dialog.show();
}; }
erpnext.utils.add_dimensions("Accounts Payable", 10); erpnext.utils.add_dimensions("Accounts Payable", 10);

View File

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

View File

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

View File

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

View File

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

View File

@@ -314,7 +314,7 @@ class ChildItemUpdater:
@frappe.whitelist() @frappe.whitelist()
def update_child_qty_rate( 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: ) -> None:
ChildItemUpdater(parent_doctype, parent_doctype_name, child_docname).update(trans_items) 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: for d in deleted_children:
validate_child_on_delete(d, parent, ordered_item) validate_child_on_delete(d, parent, ordered_item)
d.flags.ignore_permissions = True
d.cancel() d.cancel()
d.delete() d.delete()

View File

@@ -55,3 +55,16 @@ class DeferredAccountingService:
def _is_deferred(self, item) -> bool: def _is_deferred(self, item) -> bool:
return bool(item.get("enable_deferred_revenue") or item.get("enable_deferred_expense")) 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 { return {
"company": purchase_doc.company, "company": purchase_doc.company,
"purchase_date": purchase_doc.get("posting_date"), "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, "asset_quantity": first_item.qty,
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"), "cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
"asset_location": first_item.get("asset_location"), "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) frappe.throw(_("Not Permitted"), frappe.PermissionError)
try: sq_doc = frappe.get_doc(
sq_doc = frappe.get_doc( {
{ "doctype": "Supplier Quotation",
"doctype": "Supplier Quotation", "supplier": doc.get("supplier"),
"supplier": doc.get("supplier"), "terms": doc.get("terms"),
"terms": doc.get("terms"), "company": doc.get("company"),
"company": doc.get("company"), "currency": doc.get("currency")
"currency": doc.get("currency") or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")),
or get_party_account_currency("Supplier", doc.get("supplier"), doc.get("company")), "buying_price_list": doc.get("buying_price_list")
"buying_price_list": doc.get("buying_price_list") or frappe.db.get_single_value("Buying Settings", "buying_price_list"),
or frappe.db.get_single_value("Buying Settings", "buying_price_list"), }
} )
) add_items(sq_doc, doc.get("supplier"), doc.get("items"))
add_items(sq_doc, doc.get("supplier"), doc.get("items")) sq_doc.flags.ignore_permissions = True
sq_doc.flags.ignore_permissions = True sq_doc.run_method("set_missing_values")
sq_doc.run_method("set_missing_values") sq_doc.save()
sq_doc.save() frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name))
frappe.msgprint(_("Supplier Quotation {0} Created").format(sq_doc.name)) return sq_doc.name
return sq_doc.name
except Exception:
return None
def add_items(sq_doc, supplier, items): 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: else:
from erpnext.accounts.services.deferred_accounting import DeferredAccountingService 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 from erpnext.accounts.services.internal_transfer import InternalTransferService

View File

@@ -338,9 +338,6 @@ class BuyingController(SubcontractingController):
if not details.get(field): if not details.get(field):
details[field] = frappe.get_cached_value("Company", self.company, 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: for field in fields:
if not details.get(field): if not details.get(field):
frappe.throw( frappe.throw(
@@ -358,12 +355,26 @@ class BuyingController(SubcontractingController):
if self.doctype == "Purchase Invoice" and not self.update_stock: if self.doctype == "Purchase Invoice" and not self.update_stock:
return return
stock_items = self.get_stock_items()
for row in self.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) details = self.get_validated_purchase_expense_details(row.item_code)
if not details: if not details:
continue continue
amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount")) 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( self.add_gl_entry(
gl_entries=gl_entries, gl_entries=gl_entries,
account=details.purchase_expense_account, account=details.purchase_expense_account,

View File

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

View File

@@ -307,33 +307,32 @@ class calculate_taxes_and_totals:
for item in self.doc.items: for item in self.doc.items:
item._unrounded_net_amount = None item._unrounded_net_amount = None
item_tax_map = self._load_item_tax_rate(item.item_tax_rate) item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
cumulated_tax_fraction = 0 total_tax_slope = 0
total_inclusive_tax_amount_per_qty = 0 total_tax_intercept = 0
for i, tax in enumerate(self.doc.get("taxes")): for i, tax in enumerate(self.doc.get("taxes")):
( (
tax.tax_fraction_for_current_item, tax.tax_fraction_for_current_item,
inclusive_tax_amount_per_qty, tax_intercept_per_qty,
) = self.get_current_tax_fraction(tax, item_tax_map) ) = self.get_current_tax_fraction(tax, item_tax_map, item)
tax.inclusive_amount_per_qty = tax_intercept_per_qty
if i == 0: if i == 0:
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item 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: else:
prev = self.doc.get("taxes")[i - 1]
tax.grand_total_fraction_for_current_item = ( tax.grand_total_fraction_for_current_item = (
self.doc.get("taxes")[i - 1].grand_total_fraction_for_current_item prev.grand_total_fraction_for_current_item + tax.tax_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_tax_slope += tax.tax_fraction_for_current_item
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty) total_tax_intercept += tax_intercept_per_qty * flt(item.qty)
if ( if not self.discount_amount_applied and item.qty and (total_tax_slope or total_tax_intercept):
not self.discount_amount_applied amount = flt(item.amount) - total_tax_intercept
and item.qty
and (cumulated_tax_fraction or total_inclusive_tax_amount_per_qty)
):
amount = flt(item.amount) - total_inclusive_tax_amount_per_qty
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_amount = flt(item._unrounded_net_amount, item.precision("net_amount"))
item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate")) item.net_rate = flt(item.net_amount / item.qty, item.precision("net_rate"))
item.discount_percentage = flt( item.discount_percentage = flt(
@@ -345,41 +344,48 @@ class calculate_taxes_and_totals:
def _load_item_tax_rate(self, item_tax_rate): def _load_item_tax_rate(self, item_tax_rate):
return frappe.parse_json(item_tax_rate) if item_tax_rate else {} 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 tax = slope * net + intercept.
from tax inclusive amount Returns (slope, intercept_per_qty)
""" """
current_tax_fraction = 0 tax_slope = 0
inclusive_tax_amount_per_qty = 0 tax_intercept = 0
if cint(tax.included_in_print_rate): if cint(tax.included_in_print_rate):
tax_rate = self._get_tax_rate(tax, item_tax_map) tax_rate = self._get_tax_rate(tax, item_tax_map)
if tax_rate == NOT_APPLICABLE_TAX: 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": 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": elif tax.charge_type == "On Previous Row Amount":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[ row = self.doc.get("taxes")[cint(tax.row_id) - 1]
cint(tax.row_id) - 1 tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item
].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": elif tax.charge_type == "On Previous Row Total":
current_tax_fraction = (tax_rate / 100.0) * self.doc.get("taxes")[ row = self.doc.get("taxes")[cint(tax.row_id) - 1]
cint(tax.row_id) - 1 tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item
].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": 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": if getattr(tax, "add_deduct_tax", None) and tax.add_deduct_tax == "Deduct":
current_tax_fraction *= -1.0 tax_slope *= -1.0
inclusive_tax_amount_per_qty *= -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): def _get_tax_rate(self, tax, item_tax_map):
if tax.account_head in 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": elif tax.charge_type == "On Net Total":
if tax.account_head in item_tax_map: if tax.account_head in item_tax_map:
current_net_amount = item.net_amount current_net_amount = item.net_amount
# Use unrounded net for inclusive taxes to avoid double rounding # Use unrounded net for inclusive taxes to avoid double rounding
if ( if (
cint(tax.included_in_print_rate) cint(tax.included_in_print_rate)
@@ -624,12 +629,46 @@ class calculate_taxes_and_totals:
elif tax.charge_type == "On Item Quantity": elif tax.charge_type == "On Item Quantity":
# don't sum current net amount due to the field being a currency field # don't sum current net amount due to the field being a currency field
current_tax_amount = tax_rate * item.qty 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"): if not tax.get("dont_recompute_tax"):
self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount, current_net_amount) self.set_item_wise_tax(item, tax, tax_rate, current_tax_amount, current_net_amount)
return current_net_amount, current_tax_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): def set_item_wise_tax(self, item, tax, tax_rate, current_tax_amount, current_net_amount):
# store tax breakup for each item # store tax breakup for each item
multiplier = -1 if tax.get("add_deduct_tax") == "Deduct" else 1 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 from unittest.mock import patch
import frappe import frappe
from frappe.utils import flt
from erpnext.controllers.taxes_and_totals import calculate_taxes_and_totals 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.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite 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): class TestTaxesAndTotals(ERPNextTestSuite):
def test_regional_round_off_accounts(self): def test_regional_round_off_accounts(self):
""" """
@@ -30,6 +42,93 @@ class TestTaxesAndTotals(ERPNextTestSuite):
self.assertIn(test_account, frappe.flags.round_off_applicable_accounts) 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): def test_disabling_rounded_total_resets_base_fields(self):
"""Disabling rounded total should also clear base rounded values.""" """Disabling rounded total should also clear base rounded values."""
so = make_sales_order(do_not_save=True) so = make_sales_order(do_not_save=True)

View File

@@ -280,13 +280,17 @@ class Opportunity(TransactionBase, CRMNote):
self.save() self.save()
else: 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): def has_active_quotation(self):
if not self.get("items", []): if not self.get("items", []):
return frappe.get_all( return frappe.get_all(
"Quotation", "Quotation",
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1}, {
"opportunity": self.name,
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
"docstatus": 1,
},
"name", "name",
) )
else: else:
@@ -300,7 +304,7 @@ class Opportunity(TransactionBase, CRMNote):
.where( .where(
(q.docstatus == 1) (q.docstatus == 1)
& (qi.prevdoc_docname == self.name) & (qi.prevdoc_docname == self.name)
& q.status.notin(["Lost", "Closed"]) & q.status.notin(["Lost", "Cancelled", "Expired"])
) )
.run() .run()
) )
@@ -308,7 +312,13 @@ class Opportunity(TransactionBase, CRMNote):
def has_ordered_quotation(self): def has_ordered_quotation(self):
if not self.get("items", []): if not self.get("items", []):
return frappe.get_all( 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: else:
q = frappe.qb.DocType("Quotation") q = frappe.qb.DocType("Quotation")
@@ -318,7 +328,11 @@ class Opportunity(TransactionBase, CRMNote):
.inner_join(qi) .inner_join(qi)
.on(q.name == qi.parent) .on(q.name == qi.parent)
.select(q.name) .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() .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"): for d in self.doc.get("items"):
old_rate = d.rate 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) d.rate = self.get_rm_rate(self._rm_rate_args(d), notify=False)
self._set_item_amounts(d) self._set_item_amounts(d)

View File

@@ -538,15 +538,8 @@ class BOMCreator(Document):
row.delete() row.delete()
updated = True updated = True
items = get_children(parent=kwargs.fg_item, parent_id=self.name) if self.delete_child_nodes(kwargs.docname or self.name):
if items: updated = True
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 updated: if updated:
self.set_rate_for_items() self.set_rate_for_items()
@@ -556,6 +549,19 @@ class BOMCreator(Document):
return frappe._dict() 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() @frappe.whitelist()
def get_children(doctype: str | None = None, parent: str | None = None, **kwargs): 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) kwargs = frappe._dict(kwargs)
fields = [ fields = [
"item_code as value", "name as value",
"item_name as title", "item_name as title",
"is_expandable as expandable", "is_expandable as expandable",
"parent as parent_id", "parent as parent_id",
@@ -576,6 +582,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
"idx", "idx",
ValueWrapper("BOM Creator Item").as_("doctype"), ValueWrapper("BOM Creator Item").as_("doctype"),
"name", "name",
"item_code",
"uom", "uom",
"rate", "rate",
"amount", "amount",
@@ -584,7 +591,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
] ]
query_filters = { query_filters = {
"fg_item": parent, "fg_reference_id": parent,
"parent": kwargs.parent_id, "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}) data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1})
self.assertEqual(len(data), 2) 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): def test_edit_and_delete_reject_unknown_item(self):
final_product = "Bicycle" final_product = "Bicycle"
make_item( 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): def make_bom_creator(**kwargs):
if isinstance(kwargs, str) or isinstance(kwargs, dict): if isinstance(kwargs, str) or isinstance(kwargs, dict):
kwargs = frappe.parse_json(kwargs) 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 return for_quantity, time_in_mins, process_loss_qty, pending_qty
def update_semi_finished_good_details(self): def update_semi_finished_good_details(self):
if self.operation_id: if not self.operation_id:
qty = max(flt(self.manufactured_qty), flt(self.total_completed_qty)) return
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", qty) job_cards = frappe.get_all(
if ( "Job Card",
self.finished_good filters={
and frappe.get_cached_value("Work Order", self.work_order, "production_item") "work_order": self.work_order,
== self.finished_good "operation_id": self.operation_id,
): "docstatus": 1,
_wo_doc = frappe.get_doc("Work Order", self.work_order) "is_corrective_job_card": 0,
_wo_doc.db_set("produced_qty", self.manufactured_qty) },
_wo_doc.db_set("status", _wo_doc.get_status()) 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): def update_corrective_in_work_order(self, wo):
wo.corrective_operation_cost = 0.0 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(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278) 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): def test_semi_fg_batch_auto_pull_on_manufacture(self):
"""Batch produced by an operation should auto-pull into the next operation's """Batch produced by an operation should auto-pull into the next operation's
semi-finished consumption row (skip-transfer Manufacture entry).""" 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 pypika.terms import ExistsCriterion
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details 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): 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" "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() @frappe.whitelist()

View File

@@ -1903,6 +1903,118 @@ class TestProductionPlan(ERPNextTestSuite):
self.assertEqual(row.warehouse, mrp_warhouse) self.assertEqual(row.warehouse, mrp_warhouse)
self.assertEqual(row.quantity, 12.0) 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): def test_mr_qty_for_complex_bom(self):
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse 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]; const d = locals[cdt][cdn];
frm.events.calculate_operating_cost(frm, d); 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"] = [ frappe.tour["Routing"] = [

View File

@@ -80,7 +80,7 @@ class TestWorkstation(ERPNextTestSuite):
test_routing_operations = [ test_routing_operations = [
{"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 60}, {"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) 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") 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 # 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 # 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'. # update_cost above) and is what silently skipped on Postgres when parenttype was 'routing'.
routing_op_rate = frappe.db.get_value( # It must also refresh operating_cost (hour_rate * time_in_mins / 60); the 30-min op
"BOM Operation", # exercises the arithmetic rather than a plain rate copy.
{"parent": routing_doc.name, "parenttype": "Routing", "workstation": "_Test Workstation A"}, for operation, expected_operating_cost in (("Test Operation A", 250), ("Test Operation B", 125)):
"hour_rate", hour_rate, operating_cost = frappe.db.get_value(
) "BOM Operation",
self.assertEqual(routing_op_rate, 250) {"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): def make_workstation(*args, **kwargs):

View File

@@ -206,6 +206,7 @@ class Workstation(Document):
( (
frappe.qb.update(bom_op) frappe.qb.update(bom_op)
.set(bom_op.hour_rate, self.hour_rate) .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)) .where(bom_op.parent.isin(bom_list) & (bom_op.workstation == self.name))
.run() .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.access_control_for_project_users
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
execute:frappe.db.set_single_value("Stock Settings", "use_inline_serial_batch_editor", 0) 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}) bin_name = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse})
if not bin_name: if not bin_name:
continue 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) rate = get_timesheet_detail_rate(detail.name, timesheet.currency)
self.assertEqual(rate, detail.billing_amount) 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 @staticmethod
def _delete_if_exists(doctype, name): def _delete_if_exists(doctype, name):
if frappe.db.exists(doctype, name): if frappe.db.exists(doctype, name):

View File

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

View File

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

View File

@@ -3,6 +3,11 @@
const NOT_APPLICABLE_TAX = "N/A"; 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 { erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
setup() { setup() {
this.fetch_round_off_accounts(); 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) { $.each(this.frm.doc.items || [], function (n, item) {
item._unrounded_net_amount = null; item._unrounded_net_amount = null;
var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
var cumulated_tax_fraction = 0.0; var total_tax_slope = 0.0;
var total_inclusive_tax_amount_per_qty = 0; var total_tax_intercept = 0;
$.each(me.frm.doc["taxes"] || [], function (i, tax) { $.each(me.frm.doc["taxes"] || [], function (i, tax) {
var current_tax_fraction = me.get_current_tax_fraction(tax, item_tax_map); var tax_contribution = me.get_current_tax_fraction(tax, item_tax_map, item);
tax.tax_fraction_for_current_item = current_tax_fraction[0]; tax.tax_fraction_for_current_item = tax_contribution[0];
var inclusive_tax_amount_per_qty = current_tax_fraction[1]; var tax_intercept_per_qty = tax_contribution[1];
tax.inclusive_amount_per_qty = tax_intercept_per_qty;
if (i == 0) { if (i == 0) {
tax.grand_total_fraction_for_current_item = 1 + tax.tax_fraction_for_current_item; 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 { } else {
var prev = me.frm.doc["taxes"][i - 1];
tax.grand_total_fraction_for_current_item = tax.grand_total_fraction_for_current_item =
me.frm.doc["taxes"][i - 1].grand_total_fraction_for_current_item + prev.grand_total_fraction_for_current_item + tax.tax_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_tax_slope += tax.tax_fraction_for_current_item;
total_inclusive_tax_amount_per_qty += inclusive_tax_amount_per_qty * flt(item.qty); total_tax_intercept += tax_intercept_per_qty * flt(item.qty);
}); });
if ( if (!me.discount_amount_applied && item.qty && (total_tax_intercept || total_tax_slope)) {
!me.discount_amount_applied && var amount = flt(item.amount) - total_tax_intercept;
item.qty && item._unrounded_net_amount = amount / (1 + total_tax_slope);
(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);
item.net_amount = flt(item._unrounded_net_amount, precision("net_amount", item)); 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; 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_current_tax_fraction(tax, item_tax_map, item) {
// Get tax fraction for calculating tax exclusive amount // tax = slope * net + intercept.
// from tax inclusive amount // Returns [slope, intercept_per_qty]
var current_tax_fraction = 0.0; var tax_slope = 0.0;
var inclusive_tax_amount_per_qty = 0; var tax_intercept = 0;
if (cint(tax.included_in_print_rate)) { if (cint(tax.included_in_print_rate)) {
var tax_rate = this._get_tax_rate(tax, item_tax_map); var tax_rate = this._get_tax_rate(tax, item_tax_map);
if (tax_rate === NOT_APPLICABLE_TAX) { 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") { 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") { } else if (tax.charge_type == "On Previous Row Amount") {
current_tax_fraction = const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
(tax_rate / 100.0) * tax_slope = (tax_rate / 100.0) * row.tax_fraction_for_current_item;
this.frm.doc["taxes"][cint(tax.row_id) - 1].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") { } else if (tax.charge_type == "On Previous Row Total") {
current_tax_fraction = const row = this.frm.doc["taxes"][cint(tax.row_id) - 1];
(tax_rate / 100.0) * tax_slope = (tax_rate / 100.0) * row.grand_total_fraction_for_current_item;
this.frm.doc["taxes"][cint(tax.row_id) - 1].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") { } 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") { if (tax.add_deduct_tax && tax.add_deduct_tax == "Deduct") {
current_tax_fraction *= -1; tax_slope *= -1;
inclusive_tax_amount_per_qty *= -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) { _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") { } else if (tax.charge_type == "On Item Quantity") {
// don't sum current net amount due to the field being a currency field // don't sum current net amount due to the field being a currency field
current_tax_amount = tax_rate * item.qty; 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]; 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. # Preflight for successful e-invoice export.
def sales_invoice_validate(doc): def sales_invoice_validate(doc):
# Validate company # Validate company
if doc.doctype != "Sales Invoice": if doc.doctype != "Sales Invoice" or doc.is_opening == "Yes":
return return
if not doc.company_address: if not doc.company_address:
@@ -303,7 +303,7 @@ def sales_invoice_validate(doc):
# Ensure payment details are valid for e-invoice. # Ensure payment details are valid for e-invoice.
def sales_invoice_on_submit(doc, method): def sales_invoice_on_submit(doc, method):
# Validate payment details # 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", "Italy",
"Italia", "Italia",
"Italian Republic", "Italian Republic",
@@ -369,7 +369,7 @@ def generate_single_invoice(docname: str):
# Delete e-invoice attachment on cancel. # Delete e-invoice attachment on cancel.
def sales_invoice_on_cancel(doc, method): 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", "Italy",
"Italia", "Italia",
"Italian Republic", "Italian Republic",

View File

@@ -445,33 +445,44 @@ class TestCustomer(ERPNextTestSuite):
overdue = get_customer_overdue_amount("_Test Customer", "_Test Company") overdue = get_customer_overdue_amount("_Test Customer", "_Test Company")
settings = frappe.get_single("Accounts Settings") settings = frappe.get_single("Accounts Settings")
settings.enable_overdue_billing_threshold = 1 original_enable = settings.enable_overdue_billing_threshold
settings.role_allowed_to_bypass_overdue_billing = None original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
settings.save() try:
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100) 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 # overdue is over the threshold and the user has no bypass role -> blocked
si = create_sales_invoice(do_not_submit=True) si = create_sales_invoice(do_not_submit=True)
self.assertRaises(frappe.ValidationError, si.submit) self.assertRaises(frappe.ValidationError, si.submit)
# a user holding the bypass role can still submit # a user holding the bypass role can still submit
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager" settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
settings.save() settings.save()
si = create_sales_invoice(do_not_submit=True) si = create_sales_invoice(do_not_submit=True)
si.submit() si.submit()
self.assertEqual(si.docstatus, 1) self.assertEqual(si.docstatus, 1)
# threshold still crossed, but the feature is off -> never blocked # threshold still crossed, but the feature is off -> never blocked
settings.enable_overdue_billing_threshold = 0 settings.enable_overdue_billing_threshold = 0
settings.role_allowed_to_bypass_overdue_billing = None settings.role_allowed_to_bypass_overdue_billing = None
settings.save() settings.save()
si = create_sales_invoice(do_not_submit=True) si = create_sales_invoice(do_not_submit=True)
si.submit() si.submit()
self.assertEqual(si.docstatus, 1) 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): def test_overdue_billing_threshold_falls_back_to_customer_group(self):
customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group") customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group")
group = frappe.get_doc("Customer Group", 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.credit_limits = []
group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000}) group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000})
group.save() group.save()
@@ -483,6 +494,22 @@ class TestCustomer(ERPNextTestSuite):
set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000) set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000)
self.assertEqual(get_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): def test_overdue_threshold_row_without_credit_limit(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice

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