diff --git a/README.md b/README.md
index 44bd7296881..3b703d97831 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ New passwords will be created for the ERPNext "Administrator" user, the MariaDB
## Learning and community
-1. [Frappe School](https://frappe.school) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
+1. [Frappe School](https://school.frappe.io) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
2. [Official documentation](https://docs.erpnext.com/) - Extensive documentation for ERPNext.
3. [Discussion Forum](https://discuss.erpnext.com/) - Engage with community of ERPNext users and service providers.
4. [Telegram Group](https://erpnext_public.t.me) - Get instant help from huge community of users.
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
index 6c57ea42367..7f97c3677bd 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -8,6 +8,7 @@ import frappe
from frappe import _
from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
+from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt
from erpnext import get_default_cost_center
@@ -373,8 +374,6 @@ def auto_reconcile_vouchers(
from_reference_date=None,
to_reference_date=None,
):
- frappe.flags.auto_reconcile_vouchers = True
-
bank_transactions = get_bank_transactions(bank_account)
if len(bank_transactions) > 10:
@@ -403,6 +402,8 @@ def auto_reconcile_vouchers(
def start_auto_reconcile(
bank_transactions, from_date, to_date, filter_by_reference_date, from_reference_date, to_reference_date
):
+ frappe.flags.auto_reconcile_vouchers = True
+
reconciled, partially_reconciled = set(), set()
for transaction in bank_transactions:
linked_payments = get_linked_payments(
@@ -517,16 +518,23 @@ def subtract_allocations(gl_account, vouchers):
voucher_allocated_amounts = get_total_allocated_amount(voucher_docs)
for voucher in vouchers:
- rows = voucher_allocated_amounts.get((voucher.get("doctype"), voucher.get("name"))) or []
- filtered_row = list(filter(lambda row: row.get("gl_account") == gl_account, rows))
-
- if amount := None if not filtered_row else filtered_row[0]["total"]:
+ if amount := get_allocated_amount(voucher_allocated_amounts, voucher, gl_account):
voucher["paid_amount"] -= amount
copied.append(voucher)
return copied
+def get_allocated_amount(voucher_allocated_amounts, voucher, gl_account):
+ if not (voucher_details := voucher_allocated_amounts.get((voucher.get("doctype"), voucher.get("name")))):
+ return
+
+ if not (row := voucher_details.get(gl_account)):
+ return
+
+ return row.get("total")
+
+
def check_matching(
bank_account,
company,
@@ -796,26 +804,20 @@ def get_je_matching_query(
je = frappe.qb.DocType("Journal Entry")
jea = frappe.qb.DocType("Journal Entry Account")
- ref_condition = je.cheque_no == transaction.reference_number
- ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
-
amount_field = f"{cr_or_dr}_in_account_currency"
- amount_equality = getattr(jea, amount_field) == transaction.unallocated_amount
- amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
filter_by_date = je.posting_date.between(from_date, to_date)
if cint(filter_by_reference_date):
filter_by_date = je.cheque_date.between(from_reference_date, to_reference_date)
- query = (
+ subquery = (
frappe.qb.from_(jea)
.join(je)
.on(jea.parent == je.name)
.select(
- (ref_rank + amount_rank + 1).as_("rank"),
+ Sum(getattr(jea, amount_field)).as_("paid_amount"),
ConstantColumn("Journal Entry").as_("doctype"),
je.name,
- getattr(jea, amount_field).as_("paid_amount"),
je.cheque_no.as_("reference_no"),
je.cheque_date.as_("reference_date"),
je.pay_to_recd_from.as_("party"),
@@ -827,13 +829,26 @@ def get_je_matching_query(
.where(je.voucher_type != "Opening Entry")
.where(je.clearance_date.isnull())
.where(jea.account == common_filters.bank_account)
- .where(amount_equality if exact_match else getattr(jea, amount_field) > 0.0)
.where(filter_by_date)
+ .groupby(je.name)
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
)
if frappe.flags.auto_reconcile_vouchers is True:
- query = query.where(ref_condition)
+ subquery = subquery.where(je.cheque_no == transaction.reference_number)
+
+ ref_rank = frappe.qb.terms.Case().when(subquery.reference_no == transaction.reference_number, 1).else_(0)
+ amount_equality = subquery.paid_amount == transaction.unallocated_amount
+ amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
+
+ query = (
+ frappe.qb.from_(subquery)
+ .select(
+ "*",
+ (ref_rank + amount_rank + 1).as_("rank"),
+ )
+ .where(amount_equality if exact_match else subquery.paid_amount > 0.0)
+ )
return query
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
index d899d429178..1ca76a68fde 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js
@@ -2,27 +2,6 @@
// For license information, please see license.txt
frappe.ui.form.on("Bank Transaction", {
- onload(frm) {
- frm.set_query("payment_document", "payment_entries", function () {
- const payment_doctypes = frm.events.get_payment_doctypes(frm);
- return {
- filters: {
- name: ["in", payment_doctypes],
- },
- };
- });
- },
- refresh(frm) {
- if (!frm.is_dirty() && frm.doc.payment_entries.length > 0) {
- frm.add_custom_button(__("Unreconcile Transaction"), () => {
- frm.call("remove_payment_entries").then(() => frm.refresh());
- });
- }
- },
- bank_account: function (frm) {
- set_bank_statement_filter(frm);
- },
-
setup: function (frm) {
frm.set_query("party_type", function () {
return {
@@ -31,6 +10,41 @@ frappe.ui.form.on("Bank Transaction", {
},
};
});
+
+ frm.set_query("bank_account", function () {
+ return {
+ filters: { is_company_account: 1 },
+ };
+ });
+
+ frm.set_query("payment_document", "payment_entries", function () {
+ const payment_doctypes = frm.events.get_payment_doctypes(frm);
+ return {
+ filters: {
+ name: ["in", payment_doctypes],
+ },
+ };
+ });
+
+ frm.set_query("payment_entry", "payment_entries", function () {
+ return {
+ filters: {
+ docstatus: ["!=", 2],
+ },
+ };
+ });
+ },
+
+ refresh(frm) {
+ if (!frm.is_dirty() && frm.doc.payment_entries.length > 0) {
+ frm.add_custom_button(__("Unreconcile Transaction"), () => {
+ frm.call("remove_payment_entries").then(() => frm.refresh());
+ });
+ }
+ },
+
+ bank_account: function (frm) {
+ set_bank_statement_filter(frm);
},
get_payment_doctypes: function () {
@@ -39,31 +53,6 @@ frappe.ui.form.on("Bank Transaction", {
},
});
-frappe.ui.form.on("Bank Transaction Payments", {
- payment_entries_remove: function (frm, cdt, cdn) {
- update_clearance_date(frm, cdt, cdn);
- },
-});
-
-const update_clearance_date = (frm, cdt, cdn) => {
- if (frm.doc.docstatus === 1) {
- frappe
- .xcall("erpnext.accounts.doctype.bank_transaction.bank_transaction.unclear_reference_payment", {
- doctype: cdt,
- docname: cdn,
- bt_name: frm.doc.name,
- })
- .then((e) => {
- if (e == "success") {
- frappe.show_alert({
- message: __("Document {0} successfully uncleared", [e]),
- indicator: "green",
- });
- }
- });
- }
-};
-
function set_bank_statement_filter(frm) {
frm.set_query("bank_statement", function () {
return {
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
index 7e509896fec..39ea5fde777 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
@@ -5,7 +5,7 @@ import frappe
from frappe import _
from frappe.model.docstatus import DocStatus
from frappe.model.document import Document
-from frappe.utils import flt
+from frappe.utils import flt, getdate
class BankTransaction(Document):
@@ -84,16 +84,16 @@ class BankTransaction(Document):
if not self.payment_entries:
return
- pe = []
+ references = set()
for row in self.payment_entries:
reference = (row.payment_document, row.payment_entry)
- if reference in pe:
+ if reference in references:
frappe.throw(
_("{0} {1} is allocated twice in this Bank Transaction").format(
row.payment_document, row.payment_entry
)
)
- pe.append(reference)
+ references.add(reference)
def update_allocated_amount(self):
allocated_amount = (
@@ -104,6 +104,19 @@ class BankTransaction(Document):
self.allocated_amount = flt(allocated_amount, self.precision("allocated_amount"))
self.unallocated_amount = flt(unallocated_amount, self.precision("unallocated_amount"))
+ def delink_old_payment_entries(self):
+ if self.flags.updating_linked_bank_transaction:
+ return
+
+ old_doc = self.get_doc_before_save()
+ payment_entry_names = set(pe.name for pe in self.payment_entries)
+
+ for old_pe in old_doc.payment_entries:
+ if old_pe.name in payment_entry_names:
+ continue
+
+ self.delink_payment_entry(old_pe)
+
def before_submit(self):
self.allocate_payment_entries()
self.set_status()
@@ -113,13 +126,14 @@ class BankTransaction(Document):
def before_update_after_submit(self):
self.validate_duplicate_references()
- self.allocate_payment_entries()
self.update_allocated_amount()
+ self.delink_old_payment_entries()
+ self.allocate_payment_entries()
self.set_status()
def on_cancel(self):
for payment_entry in self.payment_entries:
- self.clear_linked_payment_entry(payment_entry, for_cancel=True)
+ self.delink_payment_entry(payment_entry)
self.set_status()
@@ -152,43 +166,55 @@ class BankTransaction(Document):
- 0 > a: Error: already over-allocated
- clear means: set the latest transaction date as clearance date
"""
+ if self.flags.updating_linked_bank_transaction or not self.payment_entries:
+ return
+
remaining_amount = self.unallocated_amount
- to_remove = []
payment_entry_docs = [(pe.payment_document, pe.payment_entry) for pe in self.payment_entries]
pe_bt_allocations = get_total_allocated_amount(payment_entry_docs)
+ gl_entries = get_related_bank_gl_entries(payment_entry_docs)
+ gl_bank_account = frappe.db.get_value("Bank Account", self.bank_account, "account")
- for payment_entry in self.payment_entries:
- if payment_entry.allocated_amount == 0.0:
- unallocated_amount, should_clear, latest_transaction = get_clearance_details(
- self,
- payment_entry,
- pe_bt_allocations.get((payment_entry.payment_document, payment_entry.payment_entry))
- or [],
+ for payment_entry in list(self.payment_entries):
+ if payment_entry.allocated_amount != 0:
+ continue
+
+ allocable_amount, should_clear, clearance_date = get_clearance_details(
+ self,
+ payment_entry,
+ pe_bt_allocations.get((payment_entry.payment_document, payment_entry.payment_entry)) or {},
+ gl_entries.get((payment_entry.payment_document, payment_entry.payment_entry)) or {},
+ gl_bank_account,
+ )
+
+ if allocable_amount < 0:
+ frappe.throw(_("Voucher {0} is over-allocated by {1}").format(allocable_amount))
+
+ if remaining_amount <= 0:
+ self.remove(payment_entry)
+ continue
+
+ if allocable_amount == 0:
+ if should_clear:
+ self.clear_linked_payment_entry(payment_entry, clearance_date=clearance_date)
+ self.remove(payment_entry)
+ continue
+
+ should_clear = should_clear and allocable_amount <= remaining_amount
+ payment_entry.allocated_amount = min(allocable_amount, remaining_amount)
+ remaining_amount = flt(
+ remaining_amount - payment_entry.allocated_amount,
+ self.precision("unallocated_amount"),
+ )
+
+ if payment_entry.payment_document == "Bank Transaction":
+ self.update_linked_bank_transaction(
+ payment_entry.payment_entry, payment_entry.allocated_amount
)
+ elif should_clear:
+ self.clear_linked_payment_entry(payment_entry, clearance_date=clearance_date)
- if 0.0 == unallocated_amount:
- if should_clear:
- latest_transaction.clear_linked_payment_entry(payment_entry)
- to_remove.append(payment_entry)
-
- elif remaining_amount <= 0.0:
- to_remove.append(payment_entry)
-
- elif 0.0 < unallocated_amount <= remaining_amount:
- payment_entry.allocated_amount = unallocated_amount
- remaining_amount -= unallocated_amount
- if should_clear:
- latest_transaction.clear_linked_payment_entry(payment_entry)
-
- elif 0.0 < unallocated_amount:
- payment_entry.allocated_amount = remaining_amount
- remaining_amount = 0.0
-
- elif 0.0 > unallocated_amount:
- frappe.throw(_("Voucher {0} is over-allocated by {1}").format(unallocated_amount))
-
- for payment_entry in to_remove:
- self.remove(payment_entry)
+ self.update_allocated_amount()
@frappe.whitelist()
def remove_payment_entries(self):
@@ -199,14 +225,64 @@ class BankTransaction(Document):
def remove_payment_entry(self, payment_entry):
"Clear payment entry and clearance"
- self.clear_linked_payment_entry(payment_entry, for_cancel=True)
+ self.delink_payment_entry(payment_entry)
self.remove(payment_entry)
- def clear_linked_payment_entry(self, payment_entry, for_cancel=False):
- clearance_date = None if for_cancel else self.date
- set_voucher_clearance(
- payment_entry.payment_document, payment_entry.payment_entry, clearance_date, self
- )
+ def delink_payment_entry(self, payment_entry):
+ if payment_entry.payment_document == "Bank Transaction":
+ self.update_linked_bank_transaction(payment_entry.payment_entry, allocated_amount=None)
+ else:
+ self.clear_linked_payment_entry(payment_entry, clearance_date=None)
+
+ def clear_linked_payment_entry(self, payment_entry, clearance_date=None):
+ doctype = payment_entry.payment_document
+ docname = payment_entry.payment_entry
+
+ # might be a bank transaction
+ if doctype not in get_doctypes_for_bank_reconciliation():
+ return
+
+ if doctype == "Sales Invoice":
+ frappe.db.set_value(
+ "Sales Invoice Payment",
+ dict(parenttype=doctype, parent=docname),
+ "clearance_date",
+ clearance_date,
+ )
+ return
+
+ frappe.db.set_value(doctype, docname, "clearance_date", clearance_date)
+
+ def update_linked_bank_transaction(self, bank_transaction_name, allocated_amount=None):
+ """For when a second bank transaction has fixed another, e.g. refund"""
+
+ bt = frappe.get_doc(self.doctype, bank_transaction_name)
+ if allocated_amount:
+ bt.append(
+ "payment_entries",
+ {
+ "payment_document": self.doctype,
+ "payment_entry": self.name,
+ "allocated_amount": allocated_amount,
+ },
+ )
+
+ else:
+ pe = next(
+ (
+ pe
+ for pe in bt.payment_entries
+ if pe.payment_document == self.doctype and pe.payment_entry == self.name
+ ),
+ None,
+ )
+ if not pe:
+ return
+
+ bt.flags.updating_linked_bank_transaction = True
+ bt.remove(pe)
+
+ bt.save()
def auto_set_party(self):
from erpnext.accounts.doctype.bank_transaction.auto_match_party import AutoMatchParty
@@ -238,71 +314,107 @@ def get_doctypes_for_bank_reconciliation():
return frappe.get_hooks("bank_reconciliation_doctypes")
-def get_clearance_details(transaction, payment_entry, bt_allocations):
+def get_clearance_details(transaction, payment_entry, bt_allocations, gl_entries, gl_bank_account):
"""
- There should only be one bank gle for a voucher.
- Could be none for a Bank Transaction.
- But if a JE, could affect two banks.
- Should only clear the voucher if all bank gles are allocated.
+ There should only be one bank gl entry for a voucher, except for JE.
+ For JE, there can be multiple bank gl entries for the same account.
+ In this case, the allocable_amount will be the sum of amounts of all gl entries of the account.
+ There will be no gl entry for a Bank Transaction so return the unallocated amount.
+ Should only clear the voucher if all bank gl entries are allocated.
"""
- gl_bank_account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
- gles = get_related_bank_gl_entries(payment_entry.payment_document, payment_entry.payment_entry)
- unallocated_amount = min(
- transaction.unallocated_amount,
- get_paid_amount(payment_entry, transaction.currency, gl_bank_account),
- )
- unmatched_gles = len(gles)
- latest_transaction = transaction
- for gle in gles:
- if gle["gl_account"] == gl_bank_account:
- if gle["amount"] <= 0.0:
- frappe.throw(
- _("Voucher {0} value is broken: {1}").format(payment_entry.payment_entry, gle["amount"])
+ transaction_date = getdate(transaction.date)
+
+ if payment_entry.payment_document == "Bank Transaction":
+ bt = frappe.db.get_value(
+ "Bank Transaction",
+ payment_entry.payment_entry,
+ ("unallocated_amount", "bank_account"),
+ as_dict=True,
+ )
+
+ if bt.bank_account != gl_bank_account:
+ frappe.throw(
+ _("Bank Account {} in Bank Transaction {} is not matching with Bank Account {}").format(
+ bt.bank_account, payment_entry.payment_entry, gl_bank_account
)
+ )
- unmatched_gles -= 1
- unallocated_amount = gle["amount"]
- for a in bt_allocations:
- if a["gl_account"] == gle["gl_account"]:
- unallocated_amount = gle["amount"] - a["total"]
- if frappe.utils.getdate(transaction.date) < a["latest_date"]:
- latest_transaction = frappe.get_doc("Bank Transaction", a["latest_name"])
- else:
- # Must be a Journal Entry affecting more than one bank
- for a in bt_allocations:
- if a["gl_account"] == gle["gl_account"] and a["total"] == gle["amount"]:
- unmatched_gles -= 1
+ return abs(bt.unallocated_amount), True, transaction_date
- return unallocated_amount, unmatched_gles == 0, latest_transaction
+ if gl_bank_account not in gl_entries:
+ frappe.throw(
+ _("{} {} is not affecting bank account {}").format(
+ payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account
+ )
+ )
+
+ allocable_amount = gl_entries.pop(gl_bank_account) or 0
+ if allocable_amount <= 0.0:
+ frappe.throw(
+ _("Invalid amount in accounting entries of {} {} for Account {}: {}").format(
+ payment_entry.payment_document, payment_entry.payment_entry, gl_bank_account, allocable_amount
+ )
+ )
+
+ matching_bt_allocaion = bt_allocations.pop(gl_bank_account, {})
+
+ allocable_amount = flt(
+ allocable_amount - matching_bt_allocaion.get("total", 0), transaction.precision("unallocated_amount")
+ )
+
+ should_clear = all(
+ gl_entries[gle_account] == bt_allocations.get(gle_account, {}).get("total", 0)
+ for gle_account in gl_entries
+ )
+
+ bt_allocation_date = matching_bt_allocaion.get("latest_date", None)
+ clearance_date = transaction_date if not bt_allocation_date else max(transaction_date, bt_allocation_date)
+
+ return allocable_amount, should_clear, clearance_date
-def get_related_bank_gl_entries(doctype, docname):
+def get_related_bank_gl_entries(docs):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
- return frappe.db.sql(
+ if not docs:
+ return {}
+
+ result = frappe.db.sql(
"""
- SELECT
- ABS(gle.credit_in_account_currency - gle.debit_in_account_currency) AS amount,
- gle.account AS gl_account
- FROM
- `tabGL Entry` gle
- LEFT JOIN
- `tabAccount` ac ON ac.name=gle.account
- WHERE
- ac.account_type = 'Bank'
- AND gle.voucher_type = %(doctype)s
- AND gle.voucher_no = %(docname)s
- AND is_cancelled = 0
- """,
- dict(doctype=doctype, docname=docname),
+ SELECT
+ gle.voucher_type AS doctype,
+ gle.voucher_no AS docname,
+ gle.account AS gl_account,
+ SUM(ABS(gle.credit_in_account_currency - gle.debit_in_account_currency)) AS amount
+ FROM
+ `tabGL Entry` gle
+ LEFT JOIN
+ `tabAccount` ac ON ac.name = gle.account
+ WHERE
+ ac.account_type = 'Bank'
+ AND (gle.voucher_type, gle.voucher_no) IN %(docs)s
+ AND gle.is_cancelled = 0
+ GROUP BY
+ gle.voucher_type, gle.voucher_no, gle.account
+ """,
+ {"docs": docs},
as_dict=True,
)
+ entries = {}
+ for row in result:
+ key = (row["doctype"], row["docname"])
+ if key not in entries:
+ entries[key] = {}
+ entries[key][row["gl_account"]] = row["amount"]
+
+ return entries
+
def get_total_allocated_amount(docs):
"""
Gets the sum of allocations for a voucher on each bank GL account
- along with the latest bank transaction name & date
+ along with the latest bank transaction date
NOTE: query may also include just saved vouchers/payments but with zero allocated_amount
"""
if not docs:
@@ -311,11 +423,10 @@ def get_total_allocated_amount(docs):
# nosemgrep: frappe-semgrep-rules.rules.frappe-using-db-sql
result = frappe.db.sql(
"""
- SELECT total, latest_name, latest_date, gl_account, payment_document, payment_entry FROM (
+ SELECT total, latest_date, gl_account, payment_document, payment_entry FROM (
SELECT
ROW_NUMBER() OVER w AS rownum,
SUM(btp.allocated_amount) OVER(PARTITION BY ba.account, btp.payment_document, btp.payment_entry) AS total,
- FIRST_VALUE(bt.name) OVER w AS latest_name,
FIRST_VALUE(bt.date) OVER w AS latest_date,
ba.account AS gl_account,
btp.payment_document,
@@ -338,104 +449,14 @@ def get_total_allocated_amount(docs):
payment_allocation_details = {}
for row in result:
- # Why is this *sometimes* a byte string?
- if isinstance(row["latest_name"], bytes):
- row["latest_name"] = row["latest_name"].decode()
- row["latest_date"] = frappe.utils.getdate(row["latest_date"])
- payment_allocation_details.setdefault((row["payment_document"], row["payment_entry"]), []).append(row)
+ row["latest_date"] = getdate(row["latest_date"])
+ payment_allocation_details.setdefault((row["payment_document"], row["payment_entry"]), {})[
+ row["gl_account"]
+ ] = row
return payment_allocation_details
-def get_paid_amount(payment_entry, currency, gl_bank_account):
- if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]:
- paid_amount_field = "paid_amount"
- if payment_entry.payment_document == "Payment Entry":
- doc = frappe.get_doc("Payment Entry", payment_entry.payment_entry)
-
- if doc.payment_type == "Receive":
- paid_amount_field = (
- "received_amount" if doc.paid_to_account_currency == currency else "base_received_amount"
- )
- elif doc.payment_type == "Pay":
- paid_amount_field = (
- "paid_amount" if doc.paid_from_account_currency == currency else "base_paid_amount"
- )
-
- return frappe.db.get_value(
- payment_entry.payment_document, payment_entry.payment_entry, paid_amount_field
- )
-
- elif payment_entry.payment_document == "Journal Entry":
- return abs(
- frappe.db.get_value(
- "Journal Entry Account",
- {"parent": payment_entry.payment_entry, "account": gl_bank_account},
- "sum(debit_in_account_currency-credit_in_account_currency)",
- )
- or 0
- )
-
- elif payment_entry.payment_document == "Expense Claim":
- return frappe.db.get_value(
- payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed"
- )
-
- elif payment_entry.payment_document == "Loan Disbursement":
- return frappe.db.get_value(
- payment_entry.payment_document, payment_entry.payment_entry, "disbursed_amount"
- )
-
- elif payment_entry.payment_document == "Loan Repayment":
- return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "amount_paid")
-
- elif payment_entry.payment_document == "Bank Transaction":
- dep, wth = frappe.db.get_value(
- "Bank Transaction", payment_entry.payment_entry, ("deposit", "withdrawal")
- )
- return abs(flt(wth) - flt(dep))
-
- else:
- frappe.throw(
- f"Please reconcile {payment_entry.payment_document}: {payment_entry.payment_entry} manually"
- )
-
-
-def set_voucher_clearance(doctype, docname, clearance_date, self):
- if doctype in get_doctypes_for_bank_reconciliation():
- if (
- doctype == "Payment Entry"
- and frappe.db.get_value("Payment Entry", docname, "payment_type") == "Internal Transfer"
- and len(get_reconciled_bank_transactions(doctype, docname)) < 2
- ):
- return
-
- if doctype == "Sales Invoice":
- frappe.db.set_value(
- "Sales Invoice Payment",
- dict(parenttype=doctype, parent=docname),
- "clearance_date",
- clearance_date,
- )
- return
-
- frappe.db.set_value(doctype, docname, "clearance_date", clearance_date)
-
- elif doctype == "Bank Transaction":
- # For when a second bank transaction has fixed another, e.g. refund
- bt = frappe.get_doc(doctype, docname)
- if clearance_date:
- vouchers = [{"payment_doctype": "Bank Transaction", "payment_name": self.name}]
- bt.add_payment_entries(vouchers)
- bt.save()
- else:
- for pe in bt.payment_entries:
- if pe.payment_document == self.doctype and pe.payment_entry == self.name:
- bt.remove(pe)
- bt.save()
- break
-
-
def get_reconciled_bank_transactions(doctype, docname):
return frappe.get_all(
"Bank Transaction Payments",
@@ -444,13 +465,6 @@ def get_reconciled_bank_transactions(doctype, docname):
)
-@frappe.whitelist()
-def unclear_reference_payment(doctype, docname, bt_name):
- bt = frappe.get_doc("Bank Transaction", bt_name)
- set_voucher_clearance(doctype, docname, None, bt)
- return docname
-
-
def remove_from_bank_transaction(doctype, docname):
"""Remove a (cancelled) voucher from all Bank Transactions."""
for bt_name in get_reconciled_bank_transactions(doctype, docname):
diff --git a/erpnext/accounts/doctype/dunning/dunning.py b/erpnext/accounts/doctype/dunning/dunning.py
index d63db3a09a1..00ed85a4e0b 100644
--- a/erpnext/accounts/doctype/dunning/dunning.py
+++ b/erpnext/accounts/doctype/dunning/dunning.py
@@ -220,6 +220,7 @@ def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str |
if not language:
language = doc.get("language")
+ letter_text = None
if language:
letter_text = frappe.db.get_value(
DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 6c7b1ad5f5a..4e9e3c4c042 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -249,16 +249,18 @@ class PaymentEntry(AccountsController):
reference_names.add(key)
def set_bank_account_data(self):
- if self.bank_account:
- bank_data = get_bank_account_details(self.bank_account)
+ if not self.bank_account:
+ return
- field = "paid_from" if self.payment_type == "Pay" else "paid_to"
+ bank_data = get_bank_account_details(self.bank_account)
- self.bank = bank_data.bank
- self.bank_account_no = bank_data.bank_account_no
+ field = "paid_from" if self.payment_type == "Pay" else "paid_to"
- if not self.get(field):
- self.set(field, bank_data.account)
+ self.bank = bank_data.bank
+ self.bank_account_no = bank_data.bank_account_no
+
+ if not self.get(field):
+ self.set(field, bank_data.account)
def validate_payment_type_with_outstanding(self):
total_outstanding = sum(d.allocated_amount for d in self.get("references"))
@@ -276,15 +278,16 @@ class PaymentEntry(AccountsController):
if self.party_type in ("Customer", "Supplier"):
self.validate_allocated_amount_with_latest_data()
- else:
- fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
- for d in self.get("references"):
- if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(d.outstanding_amount):
- frappe.throw(fail_message.format(d.idx))
+ return
- # Check for negative outstanding invoices as well
- if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(d.outstanding_amount):
- frappe.throw(fail_message.format(d.idx))
+ fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
+ for d in self.get("references"):
+ if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(d.outstanding_amount):
+ frappe.throw(fail_message.format(d.idx))
+
+ # Check for negative outstanding invoices as well
+ if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(d.outstanding_amount):
+ frappe.throw(fail_message.format(d.idx))
def validate_allocated_amount_as_per_payment_request(self):
"""
@@ -322,91 +325,89 @@ class PaymentEntry(AccountsController):
return False
def validate_allocated_amount_with_latest_data(self):
- if self.references:
- uniq_vouchers = set([(x.reference_doctype, x.reference_name) for x in self.references])
- vouchers = [frappe._dict({"voucher_type": x[0], "voucher_no": x[1]}) for x in uniq_vouchers]
- latest_references = get_outstanding_reference_documents(
- {
- "posting_date": self.posting_date,
- "company": self.company,
- "party_type": self.party_type,
- "payment_type": self.payment_type,
- "party": self.party,
- "party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to,
- "get_outstanding_invoices": True,
- "get_orders_to_be_billed": True,
- "vouchers": vouchers,
- "book_advance_payments_in_separate_party_account": self.book_advance_payments_in_separate_party_account,
- },
- validate=True,
- )
+ if not self.references:
+ return
- # Group latest_references by (voucher_type, voucher_no)
- latest_lookup = {}
- for d in latest_references:
- d = frappe._dict(d)
- latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d
+ uniq_vouchers = {(x.reference_doctype, x.reference_name) for x in self.references}
+ vouchers = [frappe._dict({"voucher_type": x[0], "voucher_no": x[1]}) for x in uniq_vouchers]
+ latest_references = get_outstanding_reference_documents(
+ {
+ "posting_date": self.posting_date,
+ "company": self.company,
+ "party_type": self.party_type,
+ "payment_type": self.payment_type,
+ "party": self.party,
+ "party_account": self.paid_from if self.payment_type == "Receive" else self.paid_to,
+ "get_outstanding_invoices": True,
+ "get_orders_to_be_billed": True,
+ "vouchers": vouchers,
+ "book_advance_payments_in_separate_party_account": self.book_advance_payments_in_separate_party_account,
+ },
+ validate=True,
+ )
- for idx, d in enumerate(self.get("references"), start=1):
- latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict()
+ # Group latest_references by (voucher_type, voucher_no)
+ latest_lookup = {}
+ for d in latest_references:
+ d = frappe._dict(d)
+ latest_lookup.setdefault((d.voucher_type, d.voucher_no), frappe._dict())[d.payment_term] = d
- # If term based allocation is enabled, throw
- if (
- d.payment_term is None or d.payment_term == ""
- ) and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name):
- frappe.throw(
- _(
- "{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section"
- ).format(frappe.bold(d.reference_name), frappe.bold(idx))
- )
+ for idx, d in enumerate(self.get("references"), start=1):
+ latest = latest_lookup.get((d.reference_doctype, d.reference_name)) or frappe._dict()
- # if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key
- latest = latest.get(d.payment_term) or latest.get(None)
- # The reference has already been fully paid
- if not latest:
- frappe.throw(
- _("{0} {1} has already been fully paid.").format(
- _(d.reference_doctype), d.reference_name
- )
- )
- # The reference has already been partly paid
- elif (
- latest.outstanding_amount < latest.invoice_amount
- and flt(d.outstanding_amount, d.precision("outstanding_amount"))
- != flt(latest.outstanding_amount, d.precision("outstanding_amount"))
- and d.payment_term == ""
- ):
- frappe.throw(
- _(
- "{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts."
- ).format(_(d.reference_doctype), d.reference_name)
- )
+ # If term based allocation is enabled, throw
+ if (
+ d.payment_term is None or d.payment_term == ""
+ ) and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name):
+ frappe.throw(
+ _(
+ "{0} has Payment Term based allocation enabled. Select a Payment Term for Row #{1} in Payment References section"
+ ).format(frappe.bold(d.reference_name), frappe.bold(idx))
+ )
- fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
+ # if no payment template is used by invoice and has a custom term(no `payment_term`), then invoice outstanding will be in 'None' key
+ latest = latest.get(d.payment_term) or latest.get(None)
+ # The reference has already been fully paid
+ if not latest:
+ frappe.throw(
+ _("{0} {1} has already been fully paid.").format(_(d.reference_doctype), d.reference_name)
+ )
+ # The reference has already been partly paid
+ elif (
+ latest.outstanding_amount < latest.invoice_amount
+ and flt(d.outstanding_amount, d.precision("outstanding_amount"))
+ != flt(latest.outstanding_amount, d.precision("outstanding_amount"))
+ and d.payment_term == ""
+ ):
+ frappe.throw(
+ _(
+ "{0} {1} has already been partly paid. Please use the 'Get Outstanding Invoice' or the 'Get Outstanding Orders' button to get the latest outstanding amounts."
+ ).format(_(d.reference_doctype), d.reference_name)
+ )
- if (
- d.payment_term
- and (
- (flt(d.allocated_amount)) > 0
- and latest.payment_term_outstanding
- and (flt(d.allocated_amount) > flt(latest.payment_term_outstanding))
- )
- and self.term_based_allocation_enabled_for_reference(
- d.reference_doctype, d.reference_name
- )
- ):
- frappe.throw(
- _(
- "Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}"
- ).format(d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term)
- )
+ fail_message = _("Row #{0}: Allocated Amount cannot be greater than outstanding amount.")
- if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount):
- frappe.throw(fail_message.format(d.idx))
+ if (
+ d.payment_term
+ and (
+ (flt(d.allocated_amount)) > 0
+ and latest.payment_term_outstanding
+ and (flt(d.allocated_amount) > flt(latest.payment_term_outstanding))
+ )
+ and self.term_based_allocation_enabled_for_reference(d.reference_doctype, d.reference_name)
+ ):
+ frappe.throw(
+ _(
+ "Row #{0}: Allocated amount:{1} is greater than outstanding amount:{2} for Payment Term {3}"
+ ).format(d.idx, d.allocated_amount, latest.payment_term_outstanding, d.payment_term)
+ )
- # Check for negative outstanding invoices as well
- if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(latest.outstanding_amount):
- frappe.throw(fail_message.format(d.idx))
+ if (flt(d.allocated_amount)) > 0 and flt(d.allocated_amount) > flt(latest.outstanding_amount):
+ frappe.throw(fail_message.format(d.idx))
+
+ # Check for negative outstanding invoices as well
+ if flt(d.allocated_amount) < 0 and flt(d.allocated_amount) < flt(latest.outstanding_amount):
+ frappe.throw(fail_message.format(d.idx))
def delink_advance_entry_references(self):
for reference in self.references:
@@ -479,47 +480,48 @@ class PaymentEntry(AccountsController):
reference_exchange_details: dict | None = None,
) -> None:
for d in self.get("references"):
- if d.allocated_amount:
- if update_ref_details_only_for and (
- (d.reference_doctype, d.reference_name) not in update_ref_details_only_for
- ):
+ if not d.allocated_amount:
+ continue
+
+ if update_ref_details_only_for and (
+ (d.reference_doctype, d.reference_name) not in update_ref_details_only_for
+ ):
+ continue
+
+ ref_details = get_reference_details(
+ d.reference_doctype,
+ d.reference_name,
+ self.party_account_currency,
+ self.party_type,
+ self.party,
+ )
+
+ # Only update exchange rate when the reference is Journal Entry
+ if (
+ reference_exchange_details
+ and d.reference_doctype == reference_exchange_details.reference_doctype
+ and d.reference_name == reference_exchange_details.reference_name
+ ):
+ ref_details.update({"exchange_rate": reference_exchange_details.exchange_rate})
+
+ for field, value in ref_details.items():
+ if d.exchange_gain_loss:
+ # for cases where gain/loss is booked into invoice
+ # exchange_gain_loss is calculated from invoice & populated
+ # and row.exchange_rate is already set to payment entry's exchange rate
+ # refer -> `update_reference_in_payment_entry()` in utils.py
continue
- ref_details = get_reference_details(
- d.reference_doctype,
- d.reference_name,
- self.party_account_currency,
- self.party_type,
- self.party,
- )
-
- # Only update exchange rate when the reference is Journal Entry
- if (
- reference_exchange_details
- and d.reference_doctype == reference_exchange_details.reference_doctype
- and d.reference_name == reference_exchange_details.reference_name
- ):
- ref_details.update({"exchange_rate": reference_exchange_details.exchange_rate})
-
- for field, value in ref_details.items():
- if d.exchange_gain_loss:
- # for cases where gain/loss is booked into invoice
- # exchange_gain_loss is calculated from invoice & populated
- # and row.exchange_rate is already set to payment entry's exchange rate
- # refer -> `update_reference_in_payment_entry()` in utils.py
- continue
-
- if field == "exchange_rate" or not d.get(field) or force:
- d.db_set(field, value)
+ if field == "exchange_rate" or not d.get(field) or force:
+ d.db_set(field, value)
def validate_payment_type(self):
if self.payment_type not in ("Receive", "Pay", "Internal Transfer"):
frappe.throw(_("Payment Type must be one of Receive, Pay and Internal Transfer"))
def validate_party_details(self):
- if self.party:
- if not frappe.db.exists(self.party_type, self.party):
- frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
+ if self.party and not frappe.db.exists(self.party_type, self.party):
+ frappe.throw(_("{0} {1} does not exist").format(_(self.party_type), self.party))
def set_exchange_rate(self, ref_doc=None):
self.set_source_exchange_rate(ref_doc)
@@ -529,12 +531,8 @@ class PaymentEntry(AccountsController):
if self.paid_from:
if self.paid_from_account_currency == self.company_currency:
self.source_exchange_rate = 1
- else:
- if ref_doc:
- if self.paid_from_account_currency == ref_doc.currency:
- self.source_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get(
- "conversion_rate"
- )
+ elif ref_doc and self.paid_from_account_currency == ref_doc.currency:
+ self.source_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
if not self.source_exchange_rate:
self.source_exchange_rate = get_exchange_rate(
@@ -545,9 +543,8 @@ class PaymentEntry(AccountsController):
if self.paid_from_account_currency == self.paid_to_account_currency:
self.target_exchange_rate = self.source_exchange_rate
elif self.paid_to and not self.target_exchange_rate:
- if ref_doc:
- if self.paid_to_account_currency == ref_doc.currency:
- self.target_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
+ if ref_doc and self.paid_to_account_currency == ref_doc.currency:
+ self.target_exchange_rate = ref_doc.get("exchange_rate") or ref_doc.get("conversion_rate")
if not self.target_exchange_rate:
self.target_exchange_rate = get_exchange_rate(
@@ -578,63 +575,61 @@ class PaymentEntry(AccountsController):
elif d.reference_name:
if not frappe.db.exists(d.reference_doctype, d.reference_name):
frappe.throw(_("{0} {1} does not exist").format(d.reference_doctype, d.reference_name))
- else:
- ref_doc = frappe.get_doc(d.reference_doctype, d.reference_name)
- if d.reference_doctype != "Journal Entry":
- if self.party != ref_doc.get(scrub(self.party_type)):
- frappe.throw(
- _("{0} {1} is not associated with {2} {3}").format(
- _(d.reference_doctype), d.reference_name, _(self.party_type), self.party
- )
- )
- else:
- self.validate_journal_entry()
+ ref_doc = frappe.get_doc(d.reference_doctype, d.reference_name)
- if d.reference_doctype in frappe.get_hooks("invoice_doctypes"):
- if self.party_type == "Customer":
- ref_party_account = (
- get_party_account_based_on_invoice_discounting(d.reference_name)
- or ref_doc.debit_to
- )
- elif self.party_type == "Supplier":
- ref_party_account = ref_doc.credit_to
- elif self.party_type == "Employee":
- ref_party_account = ref_doc.payable_account
-
- if (
- ref_party_account != self.party_account
- and not self.book_advance_payments_in_separate_party_account
- ):
- frappe.throw(
- _("{0} {1} is associated with {2}, but Party Account is {3}").format(
- _(d.reference_doctype),
- d.reference_name,
- ref_party_account,
- self.party_account,
- )
- )
-
- if ref_doc.doctype == "Purchase Invoice" and ref_doc.get("on_hold"):
- frappe.throw(
- _("{0} {1} is on hold").format(_(d.reference_doctype), d.reference_name),
- title=_("Invalid Purchase Invoice"),
- )
-
- if ref_doc.docstatus != 1:
+ if d.reference_doctype != "Journal Entry":
+ if self.party != ref_doc.get(scrub(self.party_type)):
frappe.throw(
- _("{0} {1} must be submitted").format(_(d.reference_doctype), d.reference_name)
+ _("{0} {1} is not associated with {2} {3}").format(
+ _(d.reference_doctype), d.reference_name, _(self.party_type), self.party
+ )
)
+ else:
+ self.validate_journal_entry()
+
+ if d.reference_doctype in frappe.get_hooks("invoice_doctypes"):
+ if self.party_type == "Customer":
+ ref_party_account = (
+ get_party_account_based_on_invoice_discounting(d.reference_name)
+ or ref_doc.debit_to
+ )
+ elif self.party_type == "Supplier":
+ ref_party_account = ref_doc.credit_to
+ elif self.party_type == "Employee":
+ ref_party_account = ref_doc.payable_account
+
+ if (
+ ref_party_account != self.party_account
+ and not self.book_advance_payments_in_separate_party_account
+ ):
+ frappe.throw(
+ _("{0} {1} is associated with {2}, but Party Account is {3}").format(
+ _(d.reference_doctype),
+ d.reference_name,
+ ref_party_account,
+ self.party_account,
+ )
+ )
+
+ if ref_doc.doctype == "Purchase Invoice" and ref_doc.get("on_hold"):
+ frappe.throw(
+ _("{0} {1} is on hold").format(_(d.reference_doctype), d.reference_name),
+ title=_("Invalid Purchase Invoice"),
+ )
+
+ if ref_doc.docstatus != 1:
+ frappe.throw(
+ _("{0} {1} must be submitted").format(_(d.reference_doctype), d.reference_name)
+ )
def get_valid_reference_doctypes(self):
if self.party_type == "Customer":
return ("Sales Order", "Sales Invoice", "Journal Entry", "Dunning", "Payment Entry")
+ elif self.party_type in ["Shareholder", "Employee"]:
+ return ("Journal Entry",)
elif self.party_type == "Supplier":
return ("Purchase Order", "Purchase Invoice", "Journal Entry", "Payment Entry")
- elif self.party_type == "Shareholder":
- return ("Journal Entry",)
- elif self.party_type == "Employee":
- return ("Journal Entry",)
def validate_paid_invoices(self):
no_oustanding_refs = {}
@@ -700,37 +695,39 @@ class PaymentEntry(AccountsController):
invoice_paid_amount_map = {}
for ref in self.get("references"):
- if ref.payment_term and ref.reference_name:
- key = (ref.payment_term, ref.reference_name, ref.reference_doctype)
- invoice_payment_amount_map.setdefault(key, 0.0)
- invoice_payment_amount_map[key] += ref.allocated_amount
+ if not ref.payment_term or not ref.reference_name:
+ continue
- if not invoice_paid_amount_map.get(key):
- payment_schedule = frappe.get_all(
- "Payment Schedule",
- filters={"parent": ref.reference_name},
- fields=[
- "paid_amount",
- "payment_amount",
- "payment_term",
- "discount",
- "outstanding",
- "discount_type",
- ],
- )
- for term in payment_schedule:
- invoice_key = (term.payment_term, ref.reference_name, ref.reference_doctype)
- invoice_paid_amount_map.setdefault(invoice_key, {})
- invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding
- if not (term.discount_type and term.discount):
- continue
+ key = (ref.payment_term, ref.reference_name, ref.reference_doctype)
+ invoice_payment_amount_map.setdefault(key, 0.0)
+ invoice_payment_amount_map[key] += ref.allocated_amount
- if term.discount_type == "Percentage":
- invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * (
- term.discount / 100
- )
- else:
- invoice_paid_amount_map[invoice_key]["discounted_amt"] = term.discount
+ if not invoice_paid_amount_map.get(key):
+ payment_schedule = frappe.get_all(
+ "Payment Schedule",
+ filters={"parent": ref.reference_name},
+ fields=[
+ "paid_amount",
+ "payment_amount",
+ "payment_term",
+ "discount",
+ "outstanding",
+ "discount_type",
+ ],
+ )
+ for term in payment_schedule:
+ invoice_key = (term.payment_term, ref.reference_name, ref.reference_doctype)
+ invoice_paid_amount_map.setdefault(invoice_key, {})
+ invoice_paid_amount_map[invoice_key]["outstanding"] = term.outstanding
+ if not (term.discount_type and term.discount):
+ continue
+
+ if term.discount_type == "Percentage":
+ invoice_paid_amount_map[invoice_key]["discounted_amt"] = ref.total_amount * (
+ term.discount / 100
+ )
+ else:
+ invoice_paid_amount_map[invoice_key]["discounted_amt"] = term.discount
for idx, (key, allocated_amount) in enumerate(invoice_payment_amount_map.items(), 1):
if not invoice_paid_amount_map.get(key):
@@ -977,14 +974,14 @@ class PaymentEntry(AccountsController):
applicable_tax = 0
base_applicable_tax = 0
for tax in self.get("taxes"):
- if not tax.included_in_paid_amount:
- amount = -1 * tax.tax_amount if tax.add_deduct_tax == "Deduct" else tax.tax_amount
- base_amount = (
- -1 * tax.base_tax_amount if tax.add_deduct_tax == "Deduct" else tax.base_tax_amount
- )
+ if tax.included_in_paid_amount:
+ continue
- applicable_tax += amount
- base_applicable_tax += base_amount
+ amount = -1 * tax.tax_amount if tax.add_deduct_tax == "Deduct" else tax.tax_amount
+ base_amount = -1 * tax.base_tax_amount if tax.add_deduct_tax == "Deduct" else tax.base_tax_amount
+
+ applicable_tax += amount
+ base_applicable_tax += base_amount
self.paid_amount_after_tax = flt(
flt(self.paid_amount) + flt(applicable_tax), self.precision("paid_amount_after_tax")
@@ -1648,25 +1645,27 @@ class PaymentEntry(AccountsController):
def add_deductions_gl_entries(self, gl_entries):
for d in self.get("deductions"):
- if d.amount:
- account_currency = get_account_currency(d.account)
- if account_currency != self.company_currency:
- frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))
+ if not d.amount:
+ continue
- gl_entries.append(
- self.get_gl_dict(
- {
- "account": d.account,
- "account_currency": account_currency,
- "against": self.party or self.paid_from,
- "debit_in_account_currency": d.amount,
- "debit_in_transaction_currency": d.amount / self.transaction_exchange_rate,
- "debit": d.amount,
- "cost_center": d.cost_center,
- },
- item=d,
- )
+ account_currency = get_account_currency(d.account)
+ if account_currency != self.company_currency:
+ frappe.throw(_("Currency for {0} must be {1}").format(d.account, self.company_currency))
+
+ gl_entries.append(
+ self.get_gl_dict(
+ {
+ "account": d.account,
+ "account_currency": account_currency,
+ "against": self.party or self.paid_from,
+ "debit_in_account_currency": d.amount,
+ "debit_in_transaction_currency": d.amount / self.transaction_exchange_rate,
+ "debit": d.amount,
+ "cost_center": d.cost_center,
+ },
+ item=d,
)
+ )
def get_party_account_for_taxes(self):
if self.payment_type == "Receive":
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 4f6205a2445..0027f0aa8be 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -1,9 +1,9 @@
import json
import frappe
-from frappe import _, qb
+from frappe import _
from frappe.model.document import Document
-from frappe.query_builder.functions import Abs, Sum
+from frappe.query_builder.functions import Sum
from frappe.utils import flt, nowdate
from frappe.utils.background_jobs import enqueue
@@ -12,7 +12,6 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
from erpnext.accounts.doctype.payment_entry.payment_entry import (
- get_company_defaults,
get_payment_entry,
)
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
@@ -120,13 +119,13 @@ class PaymentRequest(Document):
title=_("Invalid Amount"),
)
- existing_payment_request_amount = flt(
- get_existing_payment_request_amount(self.reference_doctype, self.reference_name)
- )
-
ref_doc = frappe.get_doc(self.reference_doctype, self.reference_name)
if not hasattr(ref_doc, "order_type") or ref_doc.order_type != "Shopping Cart":
ref_amount = get_amount(ref_doc, self.payment_account)
+ if not ref_amount:
+ frappe.throw(_("Payment Entry is already created"))
+
+ existing_payment_request_amount = flt(get_existing_payment_request_amount(ref_doc))
if existing_payment_request_amount + flt(self.grand_total) > ref_amount:
frappe.throw(
@@ -544,6 +543,8 @@ def make_payment_request(**args):
gateway_account = get_gateway_details(args) or frappe._dict()
grand_total = get_amount(ref_doc, gateway_account.get("payment_account"))
+ if not grand_total:
+ frappe.throw(_("Payment Entry is already created"))
if args.loyalty_points and args.dt == "Sales Order":
from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_loyalty_points
@@ -554,19 +555,8 @@ def make_payment_request(**args):
frappe.db.set_value("Sales Order", args.dn, "loyalty_amount", loyalty_amount, update_modified=False)
grand_total = grand_total - loyalty_amount
- bank_account = (
- get_party_bank_account(args.get("party_type"), args.get("party")) if args.get("party_type") else ""
- )
-
- draft_payment_request = frappe.db.get_value(
- "Payment Request",
- {"reference_doctype": args.dt, "reference_name": args.dn, "docstatus": 0},
- )
-
# fetches existing payment request `grand_total` amount
- existing_payment_request_amount = get_existing_payment_request_amount(ref_doc.doctype, ref_doc.name)
-
- existing_paid_amount = get_existing_paid_amount(ref_doc.doctype, ref_doc.name)
+ existing_payment_request_amount = get_existing_payment_request_amount(ref_doc)
def validate_and_calculate_grand_total(grand_total, existing_payment_request_amount):
grand_total -= existing_payment_request_amount
@@ -578,7 +568,7 @@ def make_payment_request(**args):
if args.order_type == "Shopping Cart":
# If Payment Request is in an advanced stage, then create for remaining amount.
if get_existing_payment_request_amount(
- ref_doc.doctype, ref_doc.name, ["Initiated", "Partially Paid", "Payment Ordered", "Paid"]
+ ref_doc, ["Initiated", "Partially Paid", "Payment Ordered", "Paid"]
):
grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
else:
@@ -587,14 +577,10 @@ def make_payment_request(**args):
else:
grand_total = validate_and_calculate_grand_total(grand_total, existing_payment_request_amount)
- if existing_paid_amount:
- if ref_doc.party_account_currency == ref_doc.currency:
- if ref_doc.conversion_rate:
- grand_total -= flt(existing_paid_amount / ref_doc.conversion_rate)
- else:
- grand_total -= flt(existing_paid_amount)
- else:
- grand_total -= flt(existing_paid_amount / ref_doc.conversion_rate)
+ draft_payment_request = frappe.db.get_value(
+ "Payment Request",
+ {"reference_doctype": ref_doc.doctype, "reference_name": ref_doc.name, "docstatus": 0},
+ )
if draft_payment_request:
frappe.db.set_value(
@@ -602,6 +588,11 @@ def make_payment_request(**args):
)
pr = frappe.get_doc("Payment Request", draft_payment_request)
else:
+ bank_account = (
+ get_party_bank_account(args.get("party_type"), args.get("party"))
+ if args.get("party_type")
+ else ""
+ )
pr = frappe.new_doc("Payment Request")
if not args.get("payment_request_type"):
@@ -675,22 +666,35 @@ def make_payment_request(**args):
def get_amount(ref_doc, payment_account=None):
"""get amount based on doctype"""
+ grand_total = 0
+
dt = ref_doc.doctype
if dt in ["Sales Order", "Purchase Order"]:
- grand_total = flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)
+ grand_total = (flt(ref_doc.rounded_total) or flt(ref_doc.grand_total)) - ref_doc.advance_paid
elif dt in ["Sales Invoice", "Purchase Invoice"]:
- if not ref_doc.get("is_pos"):
+ if (
+ dt == "Sales Invoice"
+ and ref_doc.is_pos
+ and ref_doc.payments
+ and any(
+ [
+ payment.type == "Phone" and payment.account == payment_account
+ for payment in ref_doc.payments
+ ]
+ )
+ ):
+ grand_total = sum(
+ [
+ payment.amount
+ for payment in ref_doc.payments
+ if payment.type == "Phone" and payment.account == payment_account
+ ]
+ )
+ else:
if ref_doc.party_account_currency == ref_doc.currency:
- grand_total = flt(ref_doc.rounded_total or ref_doc.grand_total)
+ grand_total = flt(ref_doc.outstanding_amount)
else:
- grand_total = flt(
- flt(ref_doc.base_rounded_total or ref_doc.base_grand_total) / ref_doc.conversion_rate
- )
- elif dt == "Sales Invoice":
- for pay in ref_doc.payments:
- if pay.type == "Phone" and pay.account == payment_account:
- grand_total = pay.amount
- break
+ grand_total = flt(flt(ref_doc.outstanding_amount) / ref_doc.conversion_rate)
elif dt == "POS Invoice":
for pay in ref_doc.payments:
if pay.type == "Phone" and pay.account == payment_account:
@@ -699,10 +703,7 @@ def get_amount(ref_doc, payment_account=None):
elif dt == "Fees":
grand_total = ref_doc.outstanding_amount
- if grand_total > 0:
- return flt(grand_total, get_currency_precision())
- else:
- frappe.throw(_("Payment Entry is already created"))
+ return flt(grand_total, get_currency_precision()) if grand_total > 0 else 0
def get_irequest_status(payment_requests: None | list = None) -> list:
@@ -745,7 +746,7 @@ def cancel_old_payment_requests(ref_dt, ref_dn):
frappe.db.set_value("Integration Request", ireq.name, "status", "Cancelled")
-def get_existing_payment_request_amount(ref_dt, ref_dn, statuses: list | None = None) -> list:
+def get_existing_payment_request_amount(ref_doc, statuses: list | None = None) -> list:
"""
Return the total amount of Payment Requests against a reference document.
"""
@@ -753,9 +754,9 @@ def get_existing_payment_request_amount(ref_dt, ref_dn, statuses: list | None =
query = (
frappe.qb.from_(PR)
- .select(Sum(PR.grand_total))
- .where(PR.reference_doctype == ref_dt)
- .where(PR.reference_name == ref_dn)
+ .select(Sum(PR.outstanding_amount))
+ .where(PR.reference_doctype == ref_doc.doctype)
+ .where(PR.reference_name == ref_doc.name)
.where(PR.docstatus == 1)
)
@@ -764,43 +765,12 @@ def get_existing_payment_request_amount(ref_dt, ref_dn, statuses: list | None =
response = query.run()
- return response[0][0] if response[0] else 0
+ os_amount_in_transaction_currency = flt(response[0][0] if response[0] else 0)
+ if ref_doc.currency != ref_doc.party_account_currency:
+ os_amount_in_transaction_currency = flt(os_amount_in_transaction_currency / ref_doc.conversion_rate)
-def get_existing_paid_amount(doctype, name):
- PLE = frappe.qb.DocType("Payment Ledger Entry")
- PER = frappe.qb.DocType("Payment Entry Reference")
-
- query = (
- frappe.qb.from_(PLE)
- .left_join(PER)
- .on(
- (PLE.against_voucher_type == PER.reference_doctype)
- & (PLE.against_voucher_no == PER.reference_name)
- & (PLE.voucher_type == PER.parenttype)
- & (PLE.voucher_no == PER.parent)
- )
- .select(
- Abs(Sum(PLE.amount)).as_("total_amount"),
- Abs(Sum(frappe.qb.terms.Case().when(PER.payment_request.isnotnull(), PLE.amount).else_(0))).as_(
- "request_paid_amount"
- ),
- )
- .where(
- (PLE.voucher_type.isin([doctype, "Journal Entry", "Payment Entry"]))
- & (PLE.against_voucher_type == doctype)
- & (PLE.against_voucher_no == name)
- & (PLE.delinked == 0)
- & (PLE.docstatus == 1)
- & (PLE.amount < 0)
- )
- )
-
- result = query.run()
- ledger_amount = flt(result[0][0]) if result else 0
- request_paid_amount = flt(result[0][1]) if result else 0
-
- return ledger_amount - request_paid_amount
+ return os_amount_in_transaction_currency
def get_gateway_details(args): # nosemgrep
diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py
index 02ecb85ac4d..c9628bda433 100644
--- a/erpnext/accounts/doctype/payment_request/test_payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py
@@ -313,6 +313,16 @@ class TestPaymentRequest(FrappeTestCase):
self.assertEqual(pr.outstanding_amount, 800)
self.assertEqual(pr.grand_total, 1000)
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"Payment Request is already created"),
+ make_payment_request,
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
# complete payment
pe = pr.create_payment_entry()
@@ -331,7 +341,7 @@ class TestPaymentRequest(FrappeTestCase):
# creating a more payment Request must not allowed
self.assertRaisesRegex(
frappe.exceptions.ValidationError,
- re.compile(r"Payment Request is already created"),
+ re.compile(r"Payment Entry is already created"),
make_payment_request,
dt="Sales Order",
dn=so.name,
@@ -361,6 +371,17 @@ class TestPaymentRequest(FrappeTestCase):
self.assertEqual(pr.party_account_currency, "INR")
self.assertEqual(pr.status, "Initiated")
+ self.assertRaisesRegex(
+ frappe.exceptions.ValidationError,
+ re.compile(r"Payment Request is already created"),
+ make_payment_request,
+ dt="Purchase Invoice",
+ dn=pi.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
# to make partial payment
pe = pr.create_payment_entry(submit=False)
pe.paid_amount = 2000
@@ -389,7 +410,7 @@ class TestPaymentRequest(FrappeTestCase):
# creating a more payment Request must not allowed
self.assertRaisesRegex(
frappe.exceptions.ValidationError,
- re.compile(r"Payment Request is already created"),
+ re.compile(r"Payment Entry is already created"),
make_payment_request,
dt="Purchase Invoice",
dn=pi.name,
diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
index fda868cfe51..4fa8317ff76 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.py
@@ -124,6 +124,11 @@ class POSClosingEntry(StatusUpdater):
def on_submit(self):
consolidate_pos_invoices(closing_entry=self)
+ frappe.publish_realtime(
+ f"poe_{self.pos_opening_entry}_closed",
+ self,
+ docname=f"POS Opening Entry/{self.pos_opening_entry}",
+ )
def on_cancel(self):
unconsolidate_pos_invoices(closing_entry=self)
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index a8a733ac42c..6933b04d2e1 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -196,6 +196,7 @@ class POSInvoice(SalesInvoice):
# run on validate method of selling controller
super(SalesInvoice, self).validate()
+ self.validate_pos_opening_entry()
self.validate_auto_set_posting_time()
self.validate_mode_of_payment()
self.validate_uom_is_integer("stock_uom", "stock_qty")
@@ -320,6 +321,18 @@ class POSInvoice(SalesInvoice):
_("Payment related to {0} is not completed").format(pay.mode_of_payment)
)
+ def validate_pos_opening_entry(self):
+ opening_entries = frappe.get_list(
+ "POS Opening Entry", filters={"pos_profile": self.pos_profile, "status": "Open", "docstatus": 1}
+ )
+ if len(opening_entries) == 0:
+ frappe.throw(
+ title=_("POS Opening Entry Missing"),
+ msg=_("No open POS Opening Entry found for POS Profile {0}.").format(
+ frappe.bold(self.pos_profile)
+ ),
+ )
+
def validate_stock_availablility(self):
if self.is_return:
return
diff --git a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
index 09c9443bdd9..cfe805be4ce 100644
--- a/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/test_pos_invoice.py
@@ -26,6 +26,12 @@ class TestPOSInvoice(unittest.TestCase):
make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=800, basic_rate=100)
frappe.db.sql("delete from `tabTax Rule`")
+ from erpnext.accounts.doctype.pos_closing_entry.test_pos_closing_entry import init_user_and_profile
+ from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
+
+ cls.test_user, cls.pos_profile = init_user_and_profile()
+ create_opening_entry(cls.pos_profile, cls.test_user)
+
def tearDown(self):
if frappe.session.user != "Administrator":
frappe.set_user("Administrator")
diff --git a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py
index 10b07c2c800..7f1890ceabf 100644
--- a/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py
+++ b/erpnext/accounts/doctype/pos_opening_entry/pos_opening_entry.py
@@ -70,3 +70,6 @@ class POSOpeningEntry(StatusUpdater):
def on_submit(self):
self.set_status(update=True)
+
+ def on_cancel(self):
+ self.set_status(update=True)
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index cf5dbccd54f..084a262a890 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -2094,7 +2094,7 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
1,
)
pi = make_pi_from_pr(pr.name)
- self.assertEqual(pi.payment_schedule[0].payment_amount, 2500)
+ self.assertEqual(pi.payment_schedule[0].payment_amount, 1000)
automatically_fetch_payment_terms(enable=0)
frappe.db.set_value(
@@ -2683,6 +2683,78 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
self.assertRaises(StockOverReturnError, return_doc.save)
+ def test_apply_discount_on_grand_total(self):
+ """
+ To test if after applying discount on grand total,
+ the grand total is calculated correctly without any rounding errors
+ """
+ invoice = make_purchase_invoice(qty=2, rate=100, do_not_save=True, do_not_submit=True)
+ invoice.append(
+ "items",
+ {
+ "item_code": "_Test Item",
+ "qty": 1,
+ "rate": 21.39,
+ },
+ )
+ invoice.append(
+ "taxes",
+ {
+ "charge_type": "On Net Total",
+ "account_head": "_Test Account VAT - _TC",
+ "description": "VAT",
+ "rate": 15.5,
+ },
+ )
+
+ # the grand total here will be 255.71
+ invoice.disable_rounded_total = 1
+ # apply discount on grand total to adjust the grand total to 255
+ invoice.discount_amount = 0.71
+ invoice.save()
+
+ # check if grand total is 496 and not something like 254.99 due to rounding errors
+ self.assertEqual(invoice.grand_total, 255)
+
+ def test_apply_discount_on_grand_total_with_previous_row_total_tax(self):
+ """
+ To test if after applying discount on grand total,
+ where the tax is calculated on previous row total, the grand total is calculated correctly
+ """
+
+ invoice = make_purchase_invoice(qty=2, rate=100, do_not_save=True, do_not_submit=True)
+ invoice.extend(
+ "taxes",
+ [
+ {
+ "charge_type": "Actual",
+ "account_head": "_Test Account VAT - _TC",
+ "description": "VAT",
+ "tax_amount": 100,
+ },
+ {
+ "charge_type": "On Previous Row Amount",
+ "account_head": "_Test Account VAT - _TC",
+ "description": "VAT",
+ "row_id": 1,
+ "rate": 10,
+ },
+ {
+ "charge_type": "On Previous Row Total",
+ "account_head": "_Test Account VAT - _TC",
+ "description": "VAT",
+ "row_id": 1,
+ "rate": 10,
+ },
+ ],
+ )
+
+ # the total here will be 340, so applying 40 discount
+ invoice.discount_amount = 40
+ invoice.save()
+
+ self.assertEqual(invoice.grand_total, 300)
+
def set_advance_flag(company, flag, default_account):
frappe.db.set_value(
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 673a65dae55..a57e313e2e9 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -267,8 +267,8 @@ class SalesInvoice(SellingController):
self.indicator_title = _("Paid")
def validate(self):
- super().validate()
self.validate_auto_set_posting_time()
+ super().validate()
if not (self.is_pos or self.is_debit_note):
self.so_dn_required()
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index fe8342d78bd..d2398d21f4e 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -4284,6 +4284,35 @@ class TestSalesInvoice(FrappeTestCase):
pos_return = make_sales_return(pos.name)
self.assertEqual(abs(pos_return.payments[0].amount), pos.payments[0].amount)
+ def test_create_return_invoice_for_self_update(self):
+ 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.controllers.sales_and_purchase_return import make_return_doc
+
+ invoice = create_sales_invoice()
+
+ payment_entry = get_payment_entry(dt=invoice.doctype, dn=invoice.name)
+ payment_entry.reference_no = "test001"
+ payment_entry.reference_date = getdate()
+
+ payment_entry.save()
+ payment_entry.submit()
+
+ r_invoice = make_return_doc(invoice.doctype, invoice.name)
+
+ r_invoice.update_outstanding_for_self = 0
+ r_invoice.save()
+
+ self.assertEqual(r_invoice.update_outstanding_for_self, 1)
+
+ r_invoice.submit()
+
+ self.assertNotEqual(r_invoice.outstanding_amount, 0)
+
+ invoice.reload()
+
+ self.assertEqual(invoice.outstanding_amount, 0)
+
def test_prevents_fully_returned_invoice_with_zero_quantity(self):
from erpnext.controllers.sales_and_purchase_return import StockOverReturnError, make_return_doc
diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js
index c13197613d2..56550ac1fd4 100644
--- a/erpnext/accounts/report/accounts_payable/accounts_payable.js
+++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js
@@ -164,7 +164,7 @@ frappe.query_reports["Accounts Payable"] = {
},
};
-erpnext.utils.add_dimensions("Accounts Payable", 9);
+erpnext.utils.add_dimensions("Accounts Payable", 10);
function get_party_type_options() {
let options = [];
diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
index 39ca78153c3..44fee120d8b 100644
--- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
+++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py
@@ -21,7 +21,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
def tearDown(self):
frappe.db.rollback()
- def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False):
+ def create_sales_invoice(self, no_payment_schedule=False, do_not_submit=False, **args):
frappe.set_user("Administrator")
si = create_sales_invoice(
item=self.item,
@@ -34,6 +34,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
rate=100,
price_list_rate=100,
do_not_save=1,
+ **args,
)
if not no_payment_schedule:
si.append(
@@ -108,7 +109,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
self.assertEqual(expected_data[0], [row.invoiced, row.paid, row.credit_note])
pos_inv.cancel()
- def test_accounts_receivable(self):
+ def test_accounts_receivable_with_payment(self):
filters = {
"company": self.company,
"based_on_payment_terms": 1,
@@ -145,11 +146,15 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
cr_note = self.create_credit_note(si.name, do_not_submit=True)
cr_note.update_outstanding_for_self = False
cr_note.save().submit()
+
+ # as the invoice partially paid and returning the full amount so the outstanding amount should be True
+ self.assertEqual(cr_note.update_outstanding_for_self, True)
+
report = execute(filters)
- expected_data_after_credit_note = [100, 0, 0, 40, -40, self.debit_to]
+ expected_data_after_credit_note = [0, 0, 100, 0, -100, self.debit_to]
- row = report[1][0]
+ row = report[1][-1]
self.assertEqual(
expected_data_after_credit_note,
[
@@ -162,6 +167,99 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
],
)
+ def test_accounts_receivable_without_payment(self):
+ filters = {
+ "company": self.company,
+ "based_on_payment_terms": 1,
+ "report_date": today(),
+ "range": "30, 60, 90, 120",
+ "show_remarks": True,
+ }
+
+ # check invoice grand total and invoiced column's value for 3 payment terms
+ si = self.create_sales_invoice()
+
+ report = execute(filters)
+
+ expected_data = [[100, 30, "No Remarks"], [100, 50, "No Remarks"], [100, 20, "No Remarks"]]
+
+ for i in range(3):
+ row = report[1][i - 1]
+ self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks])
+
+ # check invoice grand total, invoiced, paid and outstanding column's value after credit note
+ cr_note = self.create_credit_note(si.name, do_not_submit=True)
+ cr_note.update_outstanding_for_self = False
+ cr_note.save().submit()
+
+ self.assertEqual(cr_note.update_outstanding_for_self, False)
+
+ report = execute(filters)
+
+ row = report[1]
+ self.assertTrue(len(row) == 0)
+
+ def test_accounts_receivable_with_partial_payment(self):
+ filters = {
+ "company": self.company,
+ "based_on_payment_terms": 1,
+ "report_date": today(),
+ "range": "30, 60, 90, 120",
+ "show_remarks": True,
+ }
+
+ # check invoice grand total and invoiced column's value for 3 payment terms
+ si = self.create_sales_invoice(qty=2)
+
+ report = execute(filters)
+
+ expected_data = [[200, 60, "No Remarks"], [200, 100, "No Remarks"], [200, 40, "No Remarks"]]
+
+ for i in range(3):
+ row = report[1][i - 1]
+ self.assertEqual(expected_data[i - 1], [row.invoice_grand_total, row.invoiced, row.remarks])
+
+ # check invoice grand total, invoiced, paid and outstanding column's value after payment
+ self.create_payment_entry(si.name)
+ report = execute(filters)
+
+ expected_data_after_payment = [[200, 60, 40, 20], [200, 100, 0, 100], [200, 40, 0, 40]]
+
+ for i in range(3):
+ row = report[1][i - 1]
+ self.assertEqual(
+ expected_data_after_payment[i - 1],
+ [row.invoice_grand_total, row.invoiced, row.paid, row.outstanding],
+ )
+
+ # check invoice grand total, invoiced, paid and outstanding column's value after credit note
+ cr_note = self.create_credit_note(si.name, do_not_submit=True)
+ cr_note.update_outstanding_for_self = False
+ cr_note.save().submit()
+
+ self.assertFalse(cr_note.update_outstanding_for_self)
+
+ report = execute(filters)
+
+ expected_data_after_credit_note = [
+ [200, 100, 0, 80, 20, self.debit_to],
+ [200, 40, 0, 0, 40, self.debit_to],
+ ]
+
+ for i in range(2):
+ row = report[1][i - 1]
+ self.assertEqual(
+ expected_data_after_credit_note[i - 1],
+ [
+ row.invoice_grand_total,
+ row.invoiced,
+ row.paid,
+ row.credit_note,
+ row.outstanding,
+ row.party_account,
+ ],
+ )
+
def test_cr_note_flag_to_update_self(self):
filters = {
"company": self.company,
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.js b/erpnext/accounts/report/general_ledger/general_ledger.js
index 54d6fb2e2f6..bcd850c0896 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.js
+++ b/erpnext/accounts/report/general_ledger/general_ledger.js
@@ -52,11 +52,6 @@ frappe.query_reports["General Ledger"] = {
frappe.query_report.set_filter_value("group_by", "Group by Voucher (Consolidated)");
},
},
- {
- fieldname: "against_voucher_no",
- label: __("Against Voucher No"),
- fieldtype: "Data",
- },
{
fieldtype: "Break",
},
@@ -66,7 +61,7 @@ frappe.query_reports["General Ledger"] = {
fieldtype: "Autocomplete",
options: Object.keys(frappe.boot.party_account_types),
on_change: function () {
- frappe.query_report.set_filter_value("party", "");
+ frappe.query_report.set_filter_value("party", []);
},
},
{
diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py
index a62ba2e3732..73a16e730ac 100644
--- a/erpnext/accounts/report/general_ledger/general_ledger.py
+++ b/erpnext/accounts/report/general_ledger/general_ledger.py
@@ -224,9 +224,6 @@ def get_conditions(filters):
if filters.get("voucher_no"):
conditions.append("voucher_no=%(voucher_no)s")
- if filters.get("against_voucher_no"):
- conditions.append("against_voucher=%(against_voucher_no)s")
-
if filters.get("ignore_err"):
err_journals = frappe.db.get_all(
"Journal Entry",
@@ -490,9 +487,6 @@ def get_accountwise_gle(filters, accounting_dimensions, gl_entries, gle_map, tot
data[key][rev_dr_or_cr] = 0
data[key][rev_dr_or_cr + "_in_account_currency"] = 0
- if data[key].against_voucher and gle.against_voucher:
- data[key].against_voucher += ", " + gle.against_voucher
-
from_date, to_date = getdate(filters.from_date), getdate(filters.to_date)
show_opening_entries = filters.get("show_opening_entries")
@@ -695,14 +689,6 @@ def get_columns(filters):
columns.extend(
[
- {"label": _("Against Voucher Type"), "fieldname": "against_voucher_type", "width": 100},
- {
- "label": _("Against Voucher"),
- "fieldname": "against_voucher",
- "fieldtype": "Dynamic Link",
- "options": "against_voucher_type",
- "width": 100,
- },
{"label": _("Supplier Invoice No"), "fieldname": "bill_no", "fieldtype": "Data", "width": 100},
]
)
diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
index 83f664c984a..b313ed8b173 100644
--- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
+++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py
@@ -11,7 +11,7 @@ import erpnext
from erpnext.accounts.report.item_wise_sales_register.item_wise_sales_register import (
add_sub_total_row,
add_total_row,
- apply_group_by_conditions,
+ apply_order_by_conditions,
get_grand_total,
get_group_by_and_display_fields,
get_tax_accounts,
@@ -305,12 +305,6 @@ def apply_conditions(query, pi, pii, filters):
if filters.get("item_group"):
query = query.where(pii.item_group == filters.get("item_group"))
- if not filters.get("group_by"):
- query = query.orderby(pi.posting_date, order=Order.desc)
- query = query.orderby(pii.item_group, order=Order.desc)
- else:
- query = apply_group_by_conditions(query, pi, pii, filters)
-
return query
@@ -372,7 +366,17 @@ def get_items(filters, additional_table_columns):
query = apply_conditions(query, pi, pii, filters)
- return query.run(as_dict=True)
+ from frappe.desk.reportview import build_match_conditions
+
+ query, params = query.walk()
+ match_conditions = build_match_conditions("Sales Invoice")
+
+ if match_conditions:
+ query += " and " + match_conditions
+
+ query = apply_order_by_conditions(query, pi, pii, filters)
+
+ return frappe.db.sql(query, params, as_dict=True)
def get_aii_accounts():
diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
index 604c0a6569d..af2c4e7e38b 100644
--- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
+++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
@@ -384,27 +384,24 @@ def apply_conditions(query, si, sii, filters, additional_conditions=None):
| (si.unrealized_profit_loss_account == filters.get("income_account"))
)
- if not filters.get("group_by"):
- query = query.orderby(si.posting_date, order=Order.desc)
- query = query.orderby(sii.item_group, order=Order.desc)
- else:
- query = apply_group_by_conditions(query, si, sii, filters)
-
for key, value in (additional_conditions or {}).items():
query = query.where(si[key] == value)
return query
-def apply_group_by_conditions(query, si, ii, filters):
- if filters.get("group_by") == "Invoice":
- query = query.orderby(ii.parent, order=Order.desc)
+def apply_order_by_conditions(query, si, ii, filters):
+ if not filters.get("group_by"):
+ query += f" order by {si.posting_date} desc, {ii.item_group} desc"
+ elif filters.get("group_by") == "Invoice":
+ query += f" order by {ii.parent} desc"
elif filters.get("group_by") == "Item":
- query = query.orderby(ii.item_code)
+ query += f" order by {ii.item_code}"
elif filters.get("group_by") == "Item Group":
- query = query.orderby(ii.item_group)
+ query += f" order by {ii.item_group}"
elif filters.get("group_by") in ("Customer", "Customer Group", "Territory", "Supplier"):
- query = query.orderby(si[frappe.scrub(filters.get("group_by"))])
+ filter_field = frappe.scrub(filters.get("group_by"))
+ query += f" order by {filter_field} desc"
return query
@@ -479,7 +476,17 @@ def get_items(filters, additional_query_columns, additional_conditions=None):
query = apply_conditions(query, si, sii, filters, additional_conditions)
- return query.run(as_dict=True)
+ from frappe.desk.reportview import build_match_conditions
+
+ query, params = query.walk()
+ match_conditions = build_match_conditions("Sales Invoice")
+
+ if match_conditions:
+ query += " and " + match_conditions
+
+ query = apply_order_by_conditions(query, si, sii, filters)
+
+ return frappe.db.sql(query, params, as_dict=True)
def get_delivery_notes_against_sales_order(item_list):
diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py
index 48364cc2c91..026aecce036 100644
--- a/erpnext/accounts/report/purchase_register/purchase_register.py
+++ b/erpnext/accounts/report/purchase_register/purchase_register.py
@@ -397,7 +397,6 @@ def get_invoices(filters, additional_query_columns):
pi.mode_of_payment,
)
.where(pi.docstatus == 1)
- .orderby(pi.posting_date, pi.name, order=Order.desc)
)
if additional_query_columns:
@@ -421,8 +420,17 @@ def get_invoices(filters, additional_query_columns):
)
query = query.where(pi.credit_to.isin(party_account))
- invoices = query.run(as_dict=True)
- return invoices
+ from frappe.desk.reportview import build_match_conditions
+
+ query, params = query.walk()
+ match_conditions = build_match_conditions("Purchase Invoice")
+
+ if match_conditions:
+ query += " and " + match_conditions
+
+ query += " order by posting_date desc, name desc"
+
+ return frappe.db.sql(query, params, as_dict=True)
def get_conditions(filters, query, doctype):
diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py
index 34d53238f50..e55f217682d 100644
--- a/erpnext/accounts/report/sales_register/sales_register.py
+++ b/erpnext/accounts/report/sales_register/sales_register.py
@@ -439,7 +439,6 @@ def get_invoices(filters, additional_query_columns):
si.company,
)
.where(si.docstatus == 1)
- .orderby(si.posting_date, si.name, order=Order.desc)
)
if additional_query_columns:
@@ -457,8 +456,17 @@ def get_invoices(filters, additional_query_columns):
filters, query, doctype="Sales Invoice", child_doctype="Sales Invoice Item"
)
- invoices = query.run(as_dict=True)
- return invoices
+ from frappe.desk.reportview import build_match_conditions
+
+ query, params = query.walk()
+ match_conditions = build_match_conditions("Sales Invoice")
+
+ if match_conditions:
+ query += " and " + match_conditions
+
+ query += " order by posting_date desc, name desc"
+
+ return frappe.db.sql(query, params, as_dict=True)
def get_conditions(filters, query, doctype):
diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py
index e526e07c734..fbd0c76a229 100644
--- a/erpnext/accounts/test/accounts_mixin.py
+++ b/erpnext/accounts/test/accounts_mixin.py
@@ -85,6 +85,7 @@ class AccountsTestMixin:
"attribute_name": "bank",
"account_name": "HDFC",
"parent_account": "Bank Accounts - " + abbr,
+ "account_type": "Bank",
}
),
frappe._dict(
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index ac37775f45f..b46e427382f 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -1854,14 +1854,17 @@ def update_voucher_outstanding(voucher_type, voucher_no, account, party_type, pa
):
outstanding = voucher_outstanding[0]
ref_doc = frappe.get_doc(voucher_type, voucher_no)
+ outstanding_amount = flt(
+ outstanding["outstanding_in_account_currency"], ref_doc.precision("outstanding_amount")
+ )
# Didn't use db_set for optimisation purpose
- ref_doc.outstanding_amount = outstanding["outstanding_in_account_currency"] or 0.0
+ ref_doc.outstanding_amount = outstanding_amount
frappe.db.set_value(
voucher_type,
voucher_no,
"outstanding_amount",
- outstanding["outstanding_in_account_currency"] or 0.0,
+ outstanding_amount,
)
ref_doc.set_status(update=True)
diff --git a/erpnext/accounts/workspace/accounting/accounting.json b/erpnext/accounts/workspace/accounting/accounting.json
index 45ab92e2c56..c6a7fc6d84c 100644
--- a/erpnext/accounts/workspace/accounting/accounting.json
+++ b/erpnext/accounts/workspace/accounting/accounting.json
@@ -621,7 +621,7 @@
"doc_view": "List",
"label": "Learn Accounting",
"type": "URL",
- "url": "https://frappe.school/courses/erpnext-accounting?utm_source=in_app"
+ "url": "https://school.frappe.io/lms/courses/erpnext-accounting?utm_source=in_app"
},
{
"label": "Chart of Accounts",
@@ -670,4 +670,4 @@
}
],
"title": "Accounting"
-}
\ No newline at end of file
+}
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order_list.js b/erpnext/buying/doctype/purchase_order/purchase_order_list.js
index 3c357c0a933..8dc84e23816 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order_list.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order_list.js
@@ -22,15 +22,27 @@ frappe.listview_settings["Purchase Order"] = {
return [
__("To Receive and Bill"),
"orange",
- "per_received,<,100|per_billed,<,100|status,!=,Closed",
+ "per_received,<,100|per_billed,<,100|status,!=,Closed|docstatus,=,1",
];
} else {
- return [__("To Receive"), "orange", "per_received,<,100|per_billed,=,100|status,!=,Closed"];
+ return [
+ __("To Receive"),
+ "orange",
+ "per_received,<,100|per_billed,=,100|status,!=,Closed|docstatus,=,1",
+ ];
}
} else if (flt(doc.per_received) >= 100 && flt(doc.per_billed) < 100 && doc.status !== "Closed") {
- return [__("To Bill"), "orange", "per_received,=,100|per_billed,<,100|status,!=,Closed"];
+ return [
+ __("To Bill"),
+ "orange",
+ "per_received,=,100|per_billed,<,100|status,!=,Closed|docstatus,=,1",
+ ];
} else if (flt(doc.per_received) >= 100 && flt(doc.per_billed) == 100 && doc.status !== "Closed") {
- return [__("Completed"), "green", "per_received,=,100|per_billed,=,100|status,!=,Closed"];
+ return [
+ __("Completed"),
+ "green",
+ "per_received,=,100|per_billed,=,100|status,!=,Closed|docstatus,=,1",
+ ];
}
},
onload: function (listview) {
diff --git a/erpnext/buying/workspace/buying/buying.json b/erpnext/buying/workspace/buying/buying.json
index 1394fc48d5b..db5c7d046db 100644
--- a/erpnext/buying/workspace/buying/buying.json
+++ b/erpnext/buying/workspace/buying/buying.json
@@ -537,7 +537,7 @@
"doc_view": "List",
"label": "Learn Procurement",
"type": "URL",
- "url": "https://frappe.school/courses/procurement?utm_source=in_app"
+ "url": "https://school.frappe.io/lms/courses/procurement?utm_source=in_app"
},
{
"color": "Yellow",
@@ -572,4 +572,4 @@
}
],
"title": "Buying"
-}
\ No newline at end of file
+}
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 2e90c446ade..46418ca8a63 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -165,6 +165,48 @@ class AccountsController(TransactionBase):
raise_exception=1,
)
+ def validate_against_voucher_outstanding(self):
+ from frappe.model.meta import get_meta
+
+ if not get_meta(self.doctype).has_field("outstanding_amount"):
+ return
+
+ if self.get("is_return") and self.return_against and not self.get("is_pos"):
+ against_voucher_outstanding = frappe.get_value(
+ self.doctype, self.return_against, "outstanding_amount"
+ )
+ document_type = "Credit Note" if self.doctype == "Sales Invoice" else "Debit Note"
+
+ msg = ""
+ if self.get("update_outstanding_for_self"):
+ msg = (
+ "We can see {0} is made against {1}. If you want {1}'s outstanding to be updated, "
+ "uncheck '{2}' checkbox.
Or"
+ ).format(
+ frappe.bold(document_type),
+ get_link_to_form(self.doctype, self.get("return_against")),
+ frappe.bold(_("Update Outstanding for Self")),
+ )
+
+ elif not self.update_outstanding_for_self and (
+ abs(flt(self.rounded_total) or flt(self.grand_total)) > flt(against_voucher_outstanding)
+ ):
+ self.update_outstanding_for_self = 1
+ msg = (
+ "The outstanding amount {} in {} is lesser than {}. Updating the outstanding to this invoice.
And"
+ ).format(
+ against_voucher_outstanding,
+ get_link_to_form(self.doctype, self.get("return_against")),
+ flt(abs(self.outstanding_amount)),
+ )
+
+ if msg:
+ msg += " you can use {} tool to reconcile against {} later.".format(
+ get_link_to_form("Payment Reconciliation", "Payment Reconciliation"),
+ get_link_to_form(self.doctype, self.get("return_against")),
+ )
+ frappe.msgprint(_(msg))
+
def validate(self):
if not self.get("is_return") and not self.get("is_debit_note"):
self.validate_qty_is_not_zero()
@@ -193,6 +235,7 @@ class AccountsController(TransactionBase):
self.disable_tax_included_prices_for_internal_transfer()
self.set_incoming_rate()
self.init_internal_values()
+ self.validate_against_voucher_outstanding()
# Need to set taxes based on taxes_and_charges template
# before calculating taxes and totals
@@ -228,20 +271,6 @@ class AccountsController(TransactionBase):
)
)
- if self.get("is_return") and self.get("return_against") and not self.get("is_pos"):
- if self.get("update_outstanding_for_self"):
- document_type = "Credit Note" if self.doctype == "Sales Invoice" else "Debit Note"
- frappe.msgprint(
- _(
- "We can see {0} is made against {1}. If you want {1}'s outstanding to be updated, uncheck '{2}' checkbox.
Or you can use {3} tool to reconcile against {1} later."
- ).format(
- frappe.bold(document_type),
- get_link_to_form(self.doctype, self.get("return_against")),
- frappe.bold(_("Update Outstanding for Self")),
- get_link_to_form("Payment Reconciliation", "Payment Reconciliation"),
- )
- )
-
pos_check_field = "is_pos" if self.doctype == "Sales Invoice" else "is_paid"
if cint(self.allocate_advances_automatically) and not cint(self.get(pos_check_field)):
self.set_advances()
@@ -2328,7 +2357,9 @@ class AccountsController(TransactionBase):
and automatically_fetch_payment_terms
and self.linked_order_has_payment_terms(po_or_so, fieldname, doctype)
):
- self.fetch_payment_terms_from_order(po_or_so, doctype)
+ self.fetch_payment_terms_from_order(
+ po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
+ )
if self.get("payment_terms_template"):
self.ignore_default_payment_terms_template = 1
elif self.get("payment_terms_template"):
@@ -2372,7 +2403,9 @@ class AccountsController(TransactionBase):
d.payment_amount * self.get("conversion_rate"), d.precision("base_payment_amount")
)
else:
- self.fetch_payment_terms_from_order(po_or_so, doctype)
+ self.fetch_payment_terms_from_order(
+ po_or_so, doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
+ )
self.ignore_default_payment_terms_template = 1
def get_order_details(self):
@@ -2410,7 +2443,9 @@ class AccountsController(TransactionBase):
def linked_order_has_payment_schedule(self, po_or_so):
return frappe.get_all("Payment Schedule", filters={"parent": po_or_so})
- def fetch_payment_terms_from_order(self, po_or_so, po_or_so_doctype):
+ def fetch_payment_terms_from_order(
+ self, po_or_so, po_or_so_doctype, grand_total, base_grand_total, automatically_fetch_payment_terms
+ ):
"""
Fetch Payment Terms from Purchase/Sales Order on creating a new Purchase/Sales Invoice.
"""
@@ -2426,12 +2461,25 @@ class AccountsController(TransactionBase):
"invoice_portion": schedule.invoice_portion,
"mode_of_payment": schedule.mode_of_payment,
"description": schedule.description,
- "payment_amount": schedule.payment_amount,
- "base_payment_amount": schedule.base_payment_amount,
- "outstanding": schedule.outstanding,
"paid_amount": schedule.paid_amount,
}
+ if automatically_fetch_payment_terms:
+ payment_schedule["payment_amount"] = flt(
+ grand_total * flt(payment_schedule["invoice_portion"]) / 100,
+ schedule.precision("payment_amount"),
+ )
+ payment_schedule["base_payment_amount"] = flt(
+ base_grand_total * flt(payment_schedule["invoice_portion"]) / 100,
+ schedule.precision("base_payment_amount"),
+ )
+ payment_schedule["outstanding"] = payment_schedule["payment_amount"]
+ else:
+ payment_schedule["base_payment_amount"] = flt(
+ schedule.base_payment_amount * self.get("conversion_rate"),
+ schedule.precision("base_payment_amount"),
+ )
+
if schedule.discount_type == "Percentage":
payment_schedule["discount_type"] = schedule.discount_type
payment_schedule["discount"] = schedule.discount
diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py
index e9177f84fcc..670d46453f9 100644
--- a/erpnext/controllers/queries.py
+++ b/erpnext/controllers/queries.py
@@ -898,3 +898,32 @@ def get_filtered_child_rows(doctype, txt, searchfield, start, page_len, filters)
)
return query.run(as_dict=False)
+
+
+@frappe.whitelist()
+@frappe.validate_and_sanitize_search_inputs
+def get_item_uom_query(doctype, txt, searchfield, start, page_len, filters):
+ if frappe.db.get_single_value("Stock Settings", "allow_uom_with_conversion_rate_defined_in_item"):
+ query_filters = {"parent": filters.get("item_code")}
+
+ if txt:
+ query_filters["uom"] = ["like", f"%{txt}%"]
+
+ return frappe.get_all(
+ "UOM Conversion Detail",
+ filters=query_filters,
+ fields=["uom", "conversion_factor"],
+ limit_start=start,
+ limit_page_length=page_len,
+ order_by="idx",
+ as_list=1,
+ )
+
+ return frappe.get_all(
+ "UOM",
+ filters={"name": ["like", f"%{txt}%"]},
+ fields=["name"],
+ limit_start=start,
+ limit_page_length=page_len,
+ as_list=1,
+ )
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index e892c5d27e2..10799744d41 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -774,7 +774,7 @@ class StockController(AccountsController):
if row.get("batch_no"):
update_values["batch_no"] = None
- if row.serial_and_batch_bundle:
+ if row.get("serial_and_batch_bundle"):
update_values["serial_and_batch_bundle"] = None
frappe.db.set_value(
"Serial and Batch Bundle", row.serial_and_batch_bundle, {"is_cancelled": 1}
@@ -1631,6 +1631,8 @@ def is_reposting_pending():
def future_sle_exists(args, sl_entries=None, allow_force_reposting=True):
+ from erpnext.stock.utils import get_combine_datetime
+
if allow_force_reposting and frappe.db.get_single_value(
"Stock Reposting Settings", "do_reposting_for_each_stock_transaction"
):
@@ -1652,14 +1654,15 @@ def future_sle_exists(args, sl_entries=None, allow_force_reposting=True):
or_conditions = get_conditions_to_validate_future_sle(sl_entries)
+ args["posting_datetime"] = get_combine_datetime(args["posting_date"], args["posting_time"])
+
data = frappe.db.sql(
"""
select item_code, warehouse, count(name) as total_row
- from `tabStock Ledger Entry` force index (item_warehouse)
+ from `tabStock Ledger Entry`
where
({})
- and timestamp(posting_date, posting_time)
- >= timestamp(%(posting_date)s, %(posting_time)s)
+ and posting_datetime >= %(posting_datetime)s
and voucher_no != %(voucher_no)s
and is_cancelled = 0
GROUP BY
diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py
index 739e64fa2eb..ba6f604960c 100644
--- a/erpnext/controllers/subcontracting_controller.py
+++ b/erpnext/controllers/subcontracting_controller.py
@@ -743,7 +743,9 @@ class SubcontractingController(StockController):
):
continue
- if self.doctype == self.subcontract_data.order_doctype or self.backflush_based_on == "BOM":
+ if self.doctype == self.subcontract_data.order_doctype or (
+ self.backflush_based_on == "BOM" or self.is_return
+ ):
for bom_item in self.__get_materials_from_bom(
row.item_code, row.bom, row.get("include_exploded_items")
):
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index b6315df3246..955c9261031 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -377,9 +377,7 @@ class calculate_taxes_and_totals:
self._calculate()
def calculate_taxes(self):
- rounding_adjustment_computed = self.doc.get("is_consolidated") and self.doc.get("rounding_adjustment")
- if not rounding_adjustment_computed:
- self.doc.rounding_adjustment = 0
+ self.grand_total_diff = 0
# maintain actual tax rate based on idx
actual_tax_dict = dict(
@@ -446,9 +444,8 @@ class calculate_taxes_and_totals:
and self.discount_amount_applied
and self.doc.discount_amount
and self.doc.apply_discount_on == "Grand Total"
- and not rounding_adjustment_computed
):
- self.doc.rounding_adjustment = flt(
+ self.grand_total_diff = flt(
self.doc.grand_total - flt(self.doc.discount_amount) - tax.total,
self.doc.precision("rounding_adjustment"),
)
@@ -552,11 +549,11 @@ class calculate_taxes_and_totals:
return self.adjust_grand_total_for_inclusive_tax()
def adjust_grand_total_for_inclusive_tax(self):
- # if fully inclusive taxes and diff
+ # if any inclusive taxes and diff
if self.doc.get("taxes") and any(cint(t.included_in_print_rate) for t in self.doc.get("taxes")):
last_tax = self.doc.get("taxes")[-1]
non_inclusive_tax_amount = sum(
- flt(d.tax_amount_after_discount_amount)
+ self.get_tax_amount_if_for_valuation_or_deduction(d.tax_amount_after_discount_amount, d)
for d in self.doc.get("taxes")
if not d.included_in_print_rate
)
@@ -573,27 +570,23 @@ class calculate_taxes_and_totals:
diff = flt(diff, self.doc.precision("rounding_adjustment"))
if diff and abs(diff) <= (5.0 / 10 ** last_tax.precision("tax_amount")):
- self.doc.grand_total_diff = diff
- else:
- self.doc.grand_total_diff = 0
+ self.grand_total_diff = diff
def calculate_totals(self):
if self.doc.get("taxes"):
- self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + flt(
- self.doc.get("grand_total_diff")
- )
+ self.doc.grand_total = flt(self.doc.get("taxes")[-1].total) + self.grand_total_diff
else:
self.doc.grand_total = flt(self.doc.net_total)
if self.doc.get("taxes"):
self.doc.total_taxes_and_charges = flt(
- self.doc.grand_total - self.doc.net_total - flt(self.doc.get("grand_total_diff")),
+ self.doc.grand_total - self.doc.net_total - self.grand_total_diff,
self.doc.precision("total_taxes_and_charges"),
)
else:
self.doc.total_taxes_and_charges = 0.0
- self._set_in_company_currency(self.doc, ["total_taxes_and_charges", "rounding_adjustment"])
+ self._set_in_company_currency(self.doc, ["total_taxes_and_charges"])
if self.doc.doctype in [
"Quotation",
@@ -643,7 +636,9 @@ class calculate_taxes_and_totals:
if self.doc.meta.get_field("rounded_total"):
if self.doc.is_rounded_total_disabled():
- self.doc.rounded_total = self.doc.base_rounded_total = 0
+ self.doc.rounded_total = 0
+ self.doc.base_rounded_total = 0
+ self.doc.rounding_adjustment = 0
return
self.doc.rounded_total = round_based_on_smallest_currency_fraction(
@@ -687,33 +682,29 @@ class calculate_taxes_and_totals:
return
total_for_discount_amount = self.get_total_for_discount_amount()
- taxes = self.doc.get("taxes")
net_total = 0
+ expected_net_total = 0
if total_for_discount_amount:
# calculate item amount after Discount Amount
- for i, item in enumerate(self._items):
+ for item in self._items:
distributed_amount = (
flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount
)
- item.net_amount = flt(item.net_amount - distributed_amount, item.precision("net_amount"))
+ adjusted_net_amount = item.net_amount - distributed_amount
+ expected_net_total += adjusted_net_amount
+ item.net_amount = flt(adjusted_net_amount, item.precision("net_amount"))
net_total += item.net_amount
- # discount amount rounding loss adjustment if no taxes
- if (
- self.doc.apply_discount_on == "Net Total"
- or not taxes
- or total_for_discount_amount == self.doc.net_total
- ) and i == len(self._items) - 1:
- discount_amount_loss = flt(
- self.doc.net_total - net_total - self.doc.discount_amount,
- self.doc.precision("net_total"),
- )
-
+ # discount amount rounding adjustment
+ if rounding_difference := flt(
+ expected_net_total - net_total, self.doc.precision("net_total")
+ ):
item.net_amount = flt(
- item.net_amount + discount_amount_loss, item.precision("net_amount")
+ item.net_amount + rounding_difference, item.precision("net_amount")
)
+ net_total += rounding_difference
item.net_rate = (
flt(item.net_amount / item.qty, item.precision("net_rate")) if item.qty else 0
@@ -729,20 +720,44 @@ class calculate_taxes_and_totals:
def get_total_for_discount_amount(self):
if self.doc.apply_discount_on == "Net Total":
return self.doc.net_total
- else:
- actual_taxes_dict = {}
- for tax in self.doc.get("taxes"):
- if tax.charge_type in ["Actual", "On Item Quantity"]:
- tax_amount = self.get_tax_amount_if_for_valuation_or_deduction(tax.tax_amount, tax)
- actual_taxes_dict.setdefault(tax.idx, tax_amount)
- elif tax.row_id in actual_taxes_dict:
- actual_tax_amount = flt(actual_taxes_dict.get(tax.row_id, 0)) * flt(tax.rate) / 100
- actual_taxes_dict.setdefault(tax.idx, actual_tax_amount)
+ total_actual_tax = 0
+ actual_taxes_dict = {}
- return flt(
- self.doc.grand_total - sum(actual_taxes_dict.values()), self.doc.precision("grand_total")
+ def update_actual_tax_dict(tax, tax_amount):
+ nonlocal total_actual_tax
+
+ if tax.get("add_deduct_tax") == "Deduct":
+ tax_amount *= -1
+
+ if tax.get("category") != "Valuation":
+ total_actual_tax += tax_amount
+
+ actual_taxes_dict[int(tax.idx)] = {
+ "tax_amount": tax_amount,
+ "cumulative_tax_amount": total_actual_tax,
+ }
+
+ for tax in self.doc.get("taxes"):
+ if tax.charge_type in ["Actual", "On Item Quantity"]:
+ update_actual_tax_dict(tax, tax.tax_amount)
+ continue
+
+ if not tax.row_id:
+ continue
+
+ base_row = actual_taxes_dict.get(int(tax.row_id))
+ if not base_row:
+ continue
+
+ base_tax_amount = (
+ base_row["tax_amount"]
+ if tax.charge_type == "On Previous Row Amount"
+ else base_row["cumulative_tax_amount"]
)
+ update_actual_tax_dict(tax, base_tax_amount * tax.rate / 100)
+
+ return self.doc.grand_total - total_actual_tax
def calculate_total_advance(self):
if not self.doc.docstatus.is_cancelled():
@@ -804,9 +819,12 @@ class calculate_taxes_and_totals:
if (
self.doc.is_return
and self.doc.return_against
+ and not self.doc.update_outstanding_for_self
and not self.doc.get("is_pos")
or self.is_internal_invoice()
):
+ # Do not calculate the outstanding amount for a return invoice if 'update_outstanding_for_self' is not enabled.
+ self.doc.outstanding_amount = 0
return
self.doc.round_floats_in(self.doc, ["grand_total", "total_advance", "write_off_amount"])
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 7f2d2acc00d..46bf75780d4 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -1496,11 +1496,8 @@ def item_query(doctype, txt, searchfield, start, page_len, filters):
fields = ["name", "item_name", "item_group", "description"]
fields.extend([field for field in searchfields if field not in ["name", "item_group", "description"]])
- searchfields = searchfields + [
- field
- for field in [searchfield or "name", "item_code", "item_group", "item_name"]
- if field not in searchfields
- ]
+ if not searchfields:
+ searchfields = ["name"]
query_filters = {"disabled": 0, "ifnull(end_of_life, '3099-12-31')": (">", today())}
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 885978deece..0bf383de285 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -1616,7 +1616,7 @@ def create_job_card(work_order, row, enable_capacity_planning=False, auto_create
"posting_date": nowdate(),
"for_quantity": row.job_card_qty or work_order.get("qty", 0),
"operation_id": row.get("name"),
- "bom_no": row.get("bom"),
+ "bom_no": row.get("bom") or work_order.bom_no,
"project": work_order.project,
"company": work_order.company,
"sequence_id": row.get("sequence_id"),
diff --git a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
index 06b9f1b0759..320db68e037 100644
--- a/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
+++ b/erpnext/manufacturing/workspace/manufacturing/manufacturing.json
@@ -336,7 +336,7 @@
"doc_view": "List",
"label": "Learn Manufacturing",
"type": "URL",
- "url": "https://frappe.school/courses/manufacturing?utm_source=in_app"
+ "url": "https://school.frappe.io/lms/courses/manufacturing?utm_source=in_app"
},
{
"color": "Grey",
@@ -402,4 +402,4 @@
],
"title": "Manufacturing",
"type": "Workspace"
-}
\ No newline at end of file
+}
diff --git a/erpnext/projects/workspace/projects/projects.json b/erpnext/projects/workspace/projects/projects.json
index 94ae9c04a40..19c94bc8f90 100644
--- a/erpnext/projects/workspace/projects/projects.json
+++ b/erpnext/projects/workspace/projects/projects.json
@@ -210,7 +210,7 @@
"doc_view": "List",
"label": "Learn Project Management",
"type": "URL",
- "url": "https://frappe.school/courses/project-management?utm_source=in_app"
+ "url": "https://school.frappe.io/lms/courses/project-management?utm_source=in_app"
},
{
"color": "Blue",
@@ -245,4 +245,4 @@
}
],
"title": "Projects"
-}
\ No newline at end of file
+}
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 30ebb3d97ce..4bc787f85ff 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -343,7 +343,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
calculate_taxes() {
var me = this;
- this.frm.doc.rounding_adjustment = 0;
+ this.grand_total_diff = 0;
var actual_tax_dict = {};
// maintain actual tax rate based on idx
@@ -417,7 +417,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
// adjust Discount Amount loss in last tax iteration
if ((i == me.frm.doc["taxes"].length - 1) && me.discount_amount_applied
&& me.frm.doc.apply_discount_on == "Grand Total" && me.frm.doc.discount_amount) {
- me.frm.doc.rounding_adjustment = flt(me.frm.doc.grand_total -
+ me.grand_total_diff = flt(me.frm.doc.grand_total -
flt(me.frm.doc.discount_amount) - tax.total, precision("rounding_adjustment"));
}
}
@@ -535,7 +535,8 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
adjust_grand_total_for_inclusive_tax() {
var me = this;
- // if fully inclusive taxes and diff
+
+ // if any inclusive taxes and diff
if (this.frm.doc["taxes"] && this.frm.doc["taxes"].length) {
var any_inclusive_tax = false;
$.each(this.frm.doc.taxes || [], function(i, d) {
@@ -546,7 +547,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
var non_inclusive_tax_amount = frappe.utils.sum($.map(this.frm.doc.taxes || [],
function(d) {
if(!d.included_in_print_rate) {
- return flt(d.tax_amount_after_discount_amount);
+ let tax_amount = d.category === "Valuation" ? 0 : d.tax_amount_after_discount_amount;
+ if (d.add_deduct_tax === "Deduct") tax_amount *= -1;
+ return tax_amount;
}
}
));
@@ -560,9 +563,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
diff = flt(diff, precision("rounding_adjustment"));
if ( diff && Math.abs(diff) <= (5.0 / Math.pow(10, precision("tax_amount", last_tax))) ) {
- me.frm.doc.grand_total_diff = diff;
- } else {
- me.frm.doc.grand_total_diff = 0;
+ me.grand_total_diff = diff;
}
}
}
@@ -573,7 +574,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
var me = this;
var tax_count = this.frm.doc["taxes"] ? this.frm.doc["taxes"].length : 0;
this.frm.doc.grand_total = flt(tax_count
- ? this.frm.doc["taxes"][tax_count - 1].total + flt(this.frm.doc.grand_total_diff)
+ ? this.frm.doc["taxes"][tax_count - 1].total + this.grand_total_diff
: this.frm.doc.net_total);
if(["Quotation", "Sales Order", "Delivery Note", "Sales Invoice", "POS Invoice"].includes(this.frm.doc.doctype)) {
@@ -605,9 +606,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
this.frm.doc.total_taxes_and_charges = flt(this.frm.doc.grand_total - this.frm.doc.net_total
- - flt(this.frm.doc.rounding_adjustment), precision("total_taxes_and_charges"));
+ - this.grand_total_diff, precision("total_taxes_and_charges"));
- this.set_in_company_currency(this.frm.doc, ["total_taxes_and_charges", "rounding_adjustment"]);
+ this.set_in_company_currency(this.frm.doc, ["total_taxes_and_charges"]);
// Round grand total as per precision
frappe.model.round_floats_in(this.frm.doc, ["grand_total", "base_grand_total"]);
@@ -627,6 +628,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
if (cint(disable_rounded_total)) {
this.frm.doc.rounded_total = 0;
this.frm.doc.base_rounded_total = 0;
+ this.frm.doc.rounding_adjustment = 0;
return;
}
@@ -695,22 +697,26 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
return;
}
- var total_for_discount_amount = this.get_total_for_discount_amount();
- var net_total = 0;
+ const total_for_discount_amount = this.get_total_for_discount_amount();
+ let net_total = 0;
+ let expected_net_total = 0;
+
// calculate item amount after Discount Amount
if (total_for_discount_amount) {
$.each(this.frm._items || [], function(i, item) {
distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount;
- item.net_amount = flt(item.net_amount - distributed_amount, precision("net_amount", item));
+
+ const adjusted_net_amount = item.net_amount - distributed_amount;
+ expected_net_total += adjusted_net_amount
+ item.net_amount = flt(adjusted_net_amount, precision("net_amount", item));
net_total += item.net_amount;
- // discount amount rounding loss adjustment if no taxes
- if ((!(me.frm.doc.taxes || []).length || total_for_discount_amount==me.frm.doc.net_total || (me.frm.doc.apply_discount_on == "Net Total"))
- && i == (me.frm._items || []).length - 1) {
- var discount_amount_loss = flt(me.frm.doc.net_total - net_total
- - me.frm.doc.discount_amount, precision("net_total"));
- item.net_amount = flt(item.net_amount + discount_amount_loss,
- precision("net_amount", item));
+ // discount amount rounding adjustment
+ // assignment to rounding_difference is intentional
+ const rounding_difference = flt(expected_net_total - net_total, precision("net_total"));
+ if (rounding_difference) {
+ item.net_amount = flt(item.net_amount + rounding_difference, precision("net_amount", item));
+ net_total += rounding_difference;
}
item.net_rate = item.qty ? flt(item.net_amount / item.qty, precision("net_rate", item)) : 0;
me.set_in_company_currency(item, ["net_rate", "net_amount"]);
@@ -723,29 +729,38 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
}
get_total_for_discount_amount() {
- if(this.frm.doc.apply_discount_on == "Net Total") {
+ if(this.frm.doc.apply_discount_on == "Net Total")
return this.frm.doc.net_total;
- } else {
- var total_actual_tax = 0.0;
- var actual_taxes_dict = {};
- $.each(this.frm.doc["taxes"] || [], function(i, tax) {
- if (["Actual", "On Item Quantity"].includes(tax.charge_type)) {
- var tax_amount = (tax.category == "Valuation") ? 0.0 : tax.tax_amount;
- tax_amount *= (tax.add_deduct_tax == "Deduct") ? -1.0 : 1.0;
- actual_taxes_dict[tax.idx] = tax_amount;
- } else if (actual_taxes_dict[tax.row_id] !== null) {
- var actual_tax_amount = flt(actual_taxes_dict[tax.row_id]) * flt(tax.rate) / 100;
- actual_taxes_dict[tax.idx] = actual_tax_amount;
- }
- });
+ let total_actual_tax = 0.0;
+ let actual_taxes_dict = {};
- $.each(actual_taxes_dict, function(key, value) {
- if (value) total_actual_tax += value;
- });
+ function update_actual_taxes_dict(tax, tax_amount) {
+ if (tax.add_deduct_tax == "Deduct") tax_amount *= -1;
+ if (tax.category != "Valuation") total_actual_tax += tax_amount;
- return flt(this.frm.doc.grand_total - total_actual_tax, precision("grand_total"));
+ actual_taxes_dict[tax.idx] = {
+ tax_amount: tax_amount,
+ cumulative_total: total_actual_tax
+ };
}
+
+ $.each(this.frm.doc["taxes"] || [], function(i, tax) {
+ if (["Actual", "On Item Quantity"].includes(tax.charge_type)) {
+ update_actual_taxes_dict(tax, tax.tax_amount);
+ return;
+ }
+
+ const base_row = actual_taxes_dict[tax.row_id];
+ if (!base_row) return;
+
+ // if charge type is 'On Previous Row Amount', calculate tax on previous row amount
+ // else (On Previous Row Total) calculate tax on cumulative total
+ const base_tax_amount = tax.charge_type == "On Previous Row Amount" ? base_row["tax_amount"]: base_row["cumulative_total"];
+ update_actual_taxes_dict(tax, base_tax_amount * tax.rate / 100);
+ });
+
+ return this.frm.doc.grand_total - total_actual_tax;
}
calculate_total_advance(update_paid_amount) {
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 6423fd78fe9..da3c1ec6d33 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -150,6 +150,19 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
});
}
+ if (this.frm.fields_dict["items"].grid.get_field("uom")) {
+ this.frm.set_query("uom", "items", function(doc, cdt, cdn) {
+ let row = locals[cdt][cdn];
+
+ return {
+ query: "erpnext.controllers.queries.get_item_uom_query",
+ filters: {
+ "item_code": row.item_code
+ }
+ };
+ });
+ }
+
if(
this.frm.docstatus < 2
&& this.frm.fields_dict["payment_terms_template"]
@@ -238,6 +251,15 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
}
+ use_serial_batch_fields(frm, cdt, cdn) {
+ const item = locals[cdt][cdn];
+ if (!item.use_serial_batch_fields) {
+ frappe.model.set_value(cdt, cdn, "serial_no", "");
+ frappe.model.set_value(cdt, cdn, "batch_no", "");
+ frappe.model.set_value(cdt, cdn, "rejected_serial_no", "");
+ }
+ }
+
set_fields_onload_for_line_item() {
if (this.frm.is_new() && this.frm.doc?.items) {
this.frm.doc.items.forEach(item => {
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index c0126f2c6ee..39a341814db 100755
--- a/erpnext/public/js/utils.js
+++ b/erpnext/public/js/utils.js
@@ -263,6 +263,10 @@ $.extend(erpnext.utils, {
fieldname: dimension["fieldname"],
label: __(dimension["doctype"]),
fieldtype: "MultiSelectList",
+ depends_on:
+ report_name === "Stock Balance"
+ ? "eval:doc.show_dimension_wise_stock === 1"
+ : "",
get_data: function (txt) {
return frappe.db.get_link_options(dimension["doctype"], txt);
},
diff --git a/erpnext/public/js/utils/unreconcile.js b/erpnext/public/js/utils/unreconcile.js
index 7dba4705e40..072b541753d 100644
--- a/erpnext/public/js/utils/unreconcile.js
+++ b/erpnext/public/js/utils/unreconcile.js
@@ -91,7 +91,13 @@ erpnext.accounts.unreconcile_payment = {
read_only: 1,
options: "account_currency",
},
- { label: __("Currency"), fieldname: "account_currency", fieldtype: "Currency", read_only: 1 },
+ {
+ label: __("Currency"),
+ fieldname: "account_currency",
+ fieldtype: "Link",
+ options: "Currency",
+ read_only: 1,
+ },
];
let unreconcile_dialog_fields = [
{
@@ -121,10 +127,10 @@ erpnext.accounts.unreconcile_payment = {
};
let d = new frappe.ui.Dialog({
- title: "UnReconcile Allocations",
+ title: __("UnReconcile Allocations"),
fields: unreconcile_dialog_fields,
size: "large",
- primary_action_label: "UnReconcile",
+ primary_action_label: __("UnReconcile"),
primary_action(values) {
let selected_allocations = values.allocations.filter((x) => x.__checked);
if (selected_allocations.length > 0) {
@@ -138,7 +144,7 @@ erpnext.accounts.unreconcile_payment = {
);
d.hide();
} else {
- frappe.msgprint("No Selection");
+ frappe.msgprint(__("No Selection"));
}
},
});
diff --git a/erpnext/selling/doctype/customer/customer.js b/erpnext/selling/doctype/customer/customer.js
index 98301fb3ddd..11a3eecb967 100644
--- a/erpnext/selling/doctype/customer/customer.js
+++ b/erpnext/selling/doctype/customer/customer.js
@@ -18,7 +18,6 @@ frappe.ui.form.on("Customer", {
frm.add_fetch("lead_name", "company_name", "customer_name");
frm.add_fetch("default_sales_partner", "commission_rate", "default_commission_rate");
- frm.set_query("customer_group", { is_group: 0 });
frm.set_query("default_price_list", { selling: 1 });
frm.set_query("account", "accounts", function (doc, cdt, cdn) {
let d = locals[cdt][cdn];
diff --git a/erpnext/selling/doctype/customer/customer_dashboard.py b/erpnext/selling/doctype/customer/customer_dashboard.py
index 161a3ba0c50..fc3c5cf1ab2 100644
--- a/erpnext/selling/doctype/customer/customer_dashboard.py
+++ b/erpnext/selling/doctype/customer/customer_dashboard.py
@@ -15,7 +15,7 @@ def get_data():
"transactions": [
{"label": _("Pre Sales"), "items": ["Opportunity", "Quotation"]},
{"label": _("Orders"), "items": ["Sales Order", "Delivery Note", "Sales Invoice"]},
- {"label": _("Payments"), "items": ["Payment Entry", "Bank Account"]},
+ {"label": _("Payments"), "items": ["Payment Entry", "Bank Account", "Dunning"]},
{
"label": _("Support"),
"items": ["Issue", "Maintenance Visit", "Installation Note", "Warranty Claim"],
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index 03665b48a85..b9d46e0b84b 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -69,6 +69,8 @@ frappe.ui.form.on("Quotation", {
erpnext.selling.QuotationController = class QuotationController extends erpnext.selling.SellingController {
onload(doc, dt, dn) {
super.onload(doc, dt, dn);
+
+ this.frm.trigger("disable_customer_if_creating_from_opportunity");
}
party_name() {
var me = this;
@@ -373,6 +375,12 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
);
}
}
+
+ disable_customer_if_creating_from_opportunity(doc) {
+ if (doc.opportunity) {
+ this.frm.set_df_property("party_name", "read_only", 1);
+ }
+ }
};
cur_frm.script_manager.make(erpnext.selling.QuotationController);
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 4a026395425..220dafb2e45 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -875,7 +875,7 @@ def make_material_request(source_name, target_doc=None):
"field_map": {
"name": "sales_order_item",
"parent": "sales_order",
- "delivery_date": "required_by",
+ "delivery_date": "schedule_date",
"bom_no": "bom_no",
},
"condition": lambda item: not frappe.db.exists(
diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js
index c9bd4fc0f9d..6bad47c2693 100644
--- a/erpnext/selling/doctype/sales_order/sales_order_list.js
+++ b/erpnext/selling/doctype/sales_order/sales_order_list.js
@@ -23,10 +23,18 @@ frappe.listview_settings["Sales Order"] = {
} else if (!doc.skip_delivery_note && flt(doc.per_delivered) < 100) {
if (frappe.datetime.get_diff(doc.delivery_date) < 0) {
// not delivered & overdue
- return [__("Overdue"), "red", "per_delivered,<,100|delivery_date,<,Today|status,!=,Closed"];
+ return [
+ __("Overdue"),
+ "red",
+ "per_delivered,<,100|delivery_date,<,Today|status,!=,Closed|docstatus,=,1",
+ ];
} else if (flt(doc.grand_total) === 0) {
// not delivered (zeroount order)
- return [__("To Deliver"), "orange", "per_delivered,<,100|grand_total,=,0|status,!=,Closed"];
+ return [
+ __("To Deliver"),
+ "orange",
+ "per_delivered,<,100|grand_total,=,0|status,!=,Closed|docstatus,=,1",
+ ];
} else if (flt(doc.per_billed) < 100) {
// not delivered & not billed
return [
diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js
index b7062abecba..d86bf92177f 100644
--- a/erpnext/selling/page/point_of_sale/pos_controller.js
+++ b/erpnext/selling/page/point_of_sale/pos_controller.js
@@ -149,6 +149,26 @@ erpnext.PointOfSale.Controller = class {
this.make_app();
},
});
+
+ frappe.realtime.on(`poe_${this.pos_opening}_closed`, (data) => {
+ const route = frappe.get_route_str();
+ if (data && route == "point-of-sale") {
+ frappe.dom.freeze();
+ frappe.msgprint({
+ title: __("POS Closed"),
+ indicator: "orange",
+ message: __("POS has been closed at {0}. Please refresh the page.", [
+ frappe.datetime.str_to_user(data.creation).bold(),
+ ]),
+ primary_action_label: __("Refresh"),
+ primary_action: {
+ action() {
+ window.location.reload();
+ },
+ },
+ });
+ }
+ });
}
set_opening_entry_status() {
diff --git a/erpnext/selling/workspace/selling/selling.json b/erpnext/selling/workspace/selling/selling.json
index e13bdec11fb..d17b8438eae 100644
--- a/erpnext/selling/workspace/selling/selling.json
+++ b/erpnext/selling/workspace/selling/selling.json
@@ -639,7 +639,7 @@
"doc_view": "List",
"label": "Learn Sales Management",
"type": "URL",
- "url": "https://frappe.school/courses/sales-management-course?utm_source=in_app"
+ "url": "https://school.frappe.io/lms/courses/sales-management-course?utm_source=in_app"
},
{
"label": "Point of Sale",
@@ -676,5 +676,6 @@
"type": "Dashboard"
}
],
- "title": "Selling"
-}
\ No newline at end of file
+ "title": "Selling",
+ "type": "Workspace"
+}
diff --git a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
index edb55f5dc46..227df38e116 100644
--- a/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
+++ b/erpnext/setup/doctype/transaction_deletion_record/transaction_deletion_record.py
@@ -10,6 +10,14 @@ from frappe.model.document import Document
from frappe.utils import cint, comma_and, create_batch, get_link_to_form
from frappe.utils.background_jobs import get_job, is_job_enqueued
+LEDGER_ENTRY_DOCTYPES = frozenset(
+ (
+ "GL Entry",
+ "Payment Ledger Entry",
+ "Stock Ledger Entry",
+ )
+)
+
class TransactionDeletionRecord(Document):
# begin: auto-generated types
@@ -475,31 +483,31 @@ def get_doctypes_to_be_ignored():
@frappe.whitelist()
def is_deletion_doc_running(company: str | None = None, err_msg: str | None = None):
- if company:
- if running_deletion_jobs := frappe.db.get_all(
- "Transaction Deletion Record",
- filters={"docstatus": 1, "company": company, "status": "Running"},
- ):
- if not err_msg:
- err_msg = ""
- frappe.throw(
- title=_("Deletion in Progress!"),
- msg=_("Transaction Deletion Document: {0} is running for this Company. {1}").format(
- get_link_to_form("Transaction Deletion Record", running_deletion_jobs[0].name), err_msg
- ),
- )
+ if not company:
+ return
+
+ running_deletion_job = frappe.db.get_value(
+ "Transaction Deletion Record",
+ {"docstatus": 1, "company": company, "status": "Running"},
+ "name",
+ )
+
+ if not running_deletion_job:
+ return
+
+ frappe.throw(
+ title=_("Deletion in Progress!"),
+ msg=_("Transaction Deletion Document: {0} is running for this Company. {1}").format(
+ get_link_to_form("Transaction Deletion Record", running_deletion_job), err_msg or ""
+ ),
+ )
def check_for_running_deletion_job(doc, method=None):
# Check if DocType has 'company' field
- if doc.doctype not in ("GL Entry", "Payment Ledger Entry", "Stock Ledger Entry"):
- df = qb.DocType("DocField")
- if (
- qb.from_(df)
- .select(df.parent)
- .where((df.fieldname == "company") & (df.parent == doc.doctype))
- .run()
- ):
- is_deletion_doc_running(
- doc.company, _("Cannot make any transactions until the deletion job is completed")
- )
+ if doc.doctype in LEDGER_ENTRY_DOCTYPES or not doc.meta.has_field("company"):
+ return
+
+ is_deletion_doc_running(
+ doc.company, _("Cannot make any transactions until the deletion job is completed")
+ )
diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py
index 23ffd49de5d..81e1ac6a458 100644
--- a/erpnext/setup/install.py
+++ b/erpnext/setup/install.py
@@ -182,7 +182,7 @@ def add_standard_navbar_items():
{
"item_label": "Frappe School",
"item_type": "Route",
- "route": "https://frappe.school?utm_source=in_app",
+ "route": "https://frappe.io/school?utm_source=in_app",
"is_standard": 1,
},
{
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
index dd09f6cfcf5..af40fd6a8a2 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
@@ -19,9 +19,9 @@ frappe.listview_settings["Delivery Note"] = {
} else if (doc.status === "Return Issued") {
return [__("Return Issued"), "grey", "status,=,Return Issued"];
} else if (flt(doc.per_billed, 2) < 100) {
- return [__("To Bill"), "orange", "per_billed,<,100"];
+ return [__("To Bill"), "orange", "per_billed,<,100|docstatus,=,1"];
} else if (flt(doc.per_billed, 2) === 100) {
- return [__("Completed"), "green", "per_billed,=,100"];
+ return [__("Completed"), "green", "per_billed,=,100|docstatus,=,1"];
}
},
onload: function (doclist) {
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index e40b3822af6..003e0d4d3a0 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -1196,7 +1196,7 @@ def get_last_purchase_details(item_code, doc_name=None, conversion_rate=1.0):
return out
-def get_purchase_voucher_details(doctype, item_code, document_name):
+def get_purchase_voucher_details(doctype, item_code, document_name=None):
parent_doc = frappe.qb.DocType(doctype)
child_doc = frappe.qb.DocType(doctype + " Item")
@@ -1215,9 +1215,11 @@ def get_purchase_voucher_details(doctype, item_code, document_name):
)
.where(parent_doc.docstatus == 1)
.where(child_doc.item_code == item_code)
- .where(parent_doc.name != document_name)
)
+ if document_name:
+ query = query.where(parent_doc.name != document_name)
+
if doctype in ("Purchase Receipt", "Purchase Invoice"):
query = query.select(parent_doc.posting_date, parent_doc.posting_time)
query = query.orderby(
diff --git a/erpnext/stock/doctype/material_request/material_request_list.js b/erpnext/stock/doctype/material_request/material_request_list.js
index 57332aa7730..b20ac15f136 100644
--- a/erpnext/stock/doctype/material_request/material_request_list.js
+++ b/erpnext/stock/doctype/material_request/material_request_list.js
@@ -13,7 +13,7 @@ frappe.listview_settings["Material Request"] = {
return [__("Completed"), "green"];
}
} else if (doc.docstatus == 1 && flt(doc.per_ordered, precision) == 0) {
- return [__("Pending"), "orange", "per_ordered,=,0"];
+ return [__("Pending"), "orange", "per_ordered,=,0|docstatus,=,1"];
} else if (
doc.docstatus == 1 &&
flt(doc.per_ordered, precision) < 100 &&
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
index fc4aabdaa18..e295127e6fc 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
@@ -16,13 +16,13 @@ frappe.listview_settings["Purchase Receipt"] = {
} else if (doc.status === "Closed") {
return [__("Closed"), "green", "status,=,Closed"];
} else if (flt(doc.per_returned, 2) === 100) {
- return [__("Return Issued"), "grey", "per_returned,=,100"];
+ return [__("Return Issued"), "grey", "per_returned,=,100|docstatus,=,1"];
} else if (flt(doc.grand_total) !== 0 && flt(doc.per_billed, 2) == 0) {
- return [__("To Bill"), "orange", "per_billed,<,100"];
+ return [__("To Bill"), "orange", "per_billed,<,100|docstatus,=,1"];
} else if (flt(doc.per_billed, 2) > 0 && flt(doc.per_billed, 2) < 100) {
- return [__("Partly Billed"), "yellow", "per_billed,<,100"];
+ return [__("Partly Billed"), "yellow", "per_billed,<,100|docstatus,=,1"];
} else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) {
- return [__("Completed"), "green", "per_billed,=,100"];
+ return [__("Completed"), "green", "per_billed,=,100|docstatus,=,1"];
}
},
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 4f22d83244e..b7aa97ddb54 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -4077,6 +4077,54 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.reload()
self.assertEqual(pr.status, "To Bill")
+ def test_recreate_stock_ledgers(self):
+ item_code = "Test Item for Recreate Stock Ledgers"
+ create_item(item_code)
+
+ pr = make_purchase_receipt(item_code=item_code, qty=10, rate=100)
+ pr.submit()
+
+ sles = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_type": pr.doctype, "voucher_no": pr.name},
+ pluck="name",
+ )
+
+ self.assertTrue(sles)
+
+ for row in sles:
+ doc = frappe.get_doc("Stock Ledger Entry", row)
+ doc.delete()
+
+ sles = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_type": pr.doctype, "voucher_no": pr.name},
+ pluck="name",
+ )
+
+ self.assertFalse(sles)
+
+ frappe.get_doc(
+ {
+ "doctype": "Repost Item Valuation",
+ "based_on": "Transaction",
+ "voucher_type": pr.doctype,
+ "voucher_no": pr.name,
+ "posting_date": pr.posting_date,
+ "posting_time": pr.posting_time,
+ "company": pr.company,
+ "recreate_stock_ledgers": 1,
+ }
+ ).submit()
+
+ sles = frappe.get_all(
+ "Stock Ledger Entry",
+ filters={"voucher_type": pr.doctype, "voucher_no": pr.name},
+ pluck="name",
+ )
+
+ self.assertTrue(sles)
+
def prepare_data_for_internal_transfer():
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
index 67e97964e18..bd70072e4bd 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json
@@ -19,6 +19,7 @@
"allow_negative_stock",
"via_landed_cost_voucher",
"allow_zero_rate",
+ "recreate_stock_ledgers",
"amended_from",
"error_section",
"error_log",
@@ -220,12 +221,20 @@
"label": "Reposting Data File",
"no_copy": 1,
"read_only": 1
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.based_on == \"Transaction\"",
+ "fieldname": "recreate_stock_ledgers",
+ "fieldtype": "Check",
+ "label": "Recreate Stock Ledgers"
}
],
+ "grid_page_length": 50,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-06-27 16:55:23.150146",
+ "modified": "2025-03-31 12:38:20.566196",
"modified_by": "Administrator",
"module": "Stock",
"name": "Repost Item Valuation",
@@ -274,7 +283,7 @@
"write": 1
}
],
- "sort_field": "modified",
+ "sort_field": "creation",
"sort_order": "DESC",
"states": []
}
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index 0c81a296d5e..880acb65dc3 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -47,6 +47,7 @@ class RepostItemValuation(Document):
items_to_be_repost: DF.Code | None
posting_date: DF.Date
posting_time: DF.Time | None
+ recreate_stock_ledgers: DF.Check
reposting_data_file: DF.Attach | None
status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"]
total_reposting_count: DF.Int
@@ -74,6 +75,7 @@ class RepostItemValuation(Document):
self.reset_field_values()
self.set_company()
self.validate_accounts_freeze()
+ self.reset_recreate_stock_ledgers()
def validate_period_closing_voucher(self):
# Period Closing Voucher
@@ -105,6 +107,10 @@ class RepostItemValuation(Document):
msg = f"Due to closing stock balance {name}, you cannot repost item valuation before {to_date}"
frappe.throw(_(msg))
+ def reset_recreate_stock_ledgers(self):
+ if self.recreate_stock_ledgers and self.based_on != "Transaction":
+ self.recreate_stock_ledgers = 0
+
def get_closing_stock_balance(self):
filters = {
"company": self.company,
@@ -250,6 +256,16 @@ class RepostItemValuation(Document):
filters,
)
+ def recreate_stock_ledger_entries(self):
+ """Recreate Stock Ledger Entries for the transaction."""
+ if self.based_on == "Transaction" and self.recreate_stock_ledgers:
+ doc = frappe.get_doc(self.voucher_type, self.voucher_no)
+ doc.docstatus = 2
+ doc.update_stock_ledger(allow_negative_stock=True, via_landed_cost_voucher=True)
+
+ doc.docstatus = 1
+ doc.update_stock_ledger(allow_negative_stock=True)
+
def on_doctype_update():
frappe.db.add_index("Repost Item Valuation", ["warehouse", "item_code"], "item_warehouse")
@@ -268,6 +284,9 @@ def repost(doc):
if not frappe.flags.in_test:
frappe.db.commit()
+ if doc.recreate_stock_ledgers:
+ doc.recreate_stock_ledger_entries()
+
repost_sl_entries(doc)
repost_gl_entries(doc)
@@ -291,7 +310,7 @@ def repost(doc):
status = "Failed"
# If failed because of timeout, set status to In Progress
- if traceback and "timeout" in traceback.lower():
+ if traceback and ("timeout" in traceback.lower() or "Deadlock found" in traceback):
status = "In Progress"
if traceback:
@@ -306,13 +325,14 @@ def repost(doc):
},
)
- outgoing_email_account = frappe.get_cached_value(
- "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
- )
+ if status == "Failed":
+ outgoing_email_account = frappe.get_cached_value(
+ "Email Account", {"default_outgoing": 1, "enable_outgoing": 1}, "name"
+ )
- if outgoing_email_account and not isinstance(e, RecoverableErrors):
- notify_error_to_stock_managers(doc, message)
- doc.set_status("Failed")
+ if outgoing_email_account and not isinstance(e, RecoverableErrors):
+ notify_error_to_stock_managers(doc, message)
+ doc.set_status("Failed")
finally:
if not frappe.flags.in_test:
frappe.db.commit()
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index fea807264f8..0791877f522 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -1907,6 +1907,59 @@ class TestStockEntry(FrappeTestCase):
self.assertEqual(sle.stock_value_difference, 100)
self.assertEqual(sle.stock_value, 100 * i)
+ def test_stock_entry_amount(self):
+ warehouse = "_Test Warehouse - _TC"
+ rm_item_code = "Test Stock Entry Amount 1"
+ make_item(rm_item_code, {"is_stock_item": 1})
+
+ fg_item_code = "Test Repack Stock Entry Amount 1"
+ make_item(fg_item_code, {"is_stock_item": 1})
+
+ make_stock_entry(
+ item_code=rm_item_code,
+ qty=1,
+ to_warehouse=warehouse,
+ basic_rate=200,
+ posting_date=nowdate(),
+ )
+
+ se = make_stock_entry(
+ item_code=rm_item_code,
+ qty=1,
+ purpose="Repack",
+ basic_rate=100,
+ do_not_save=True,
+ )
+
+ se.items[0].s_warehouse = warehouse
+ se.append(
+ "items",
+ {
+ "item_code": fg_item_code,
+ "qty": 1,
+ "t_warehouse": warehouse,
+ "uom": "Nos",
+ "conversion_factor": 1.0,
+ },
+ )
+ se.set_stock_entry_type()
+ se.submit()
+
+ self.assertEqual(se.items[0].amount, 200)
+ self.assertEqual(se.items[0].basic_amount, 200)
+
+ make_stock_entry(
+ item_code=rm_item_code,
+ qty=1,
+ to_warehouse=warehouse,
+ basic_rate=300,
+ posting_date=add_days(nowdate(), -1),
+ )
+
+ se.reload()
+ self.assertEqual(se.items[0].amount, 300)
+ self.assertEqual(se.items[0].basic_amount, 300)
+
def make_serialized_item(**args):
args = frappe._dict(args)
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index 1987bc8642d..551104688a2 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -22,6 +22,8 @@
"allow_to_edit_stock_uom_qty_for_sales",
"column_break_lznj",
"allow_to_edit_stock_uom_qty_for_purchase",
+ "section_break_ylhd",
+ "allow_uom_with_conversion_rate_defined_in_item",
"stock_validations_tab",
"section_break_9",
"over_delivery_receipt_allowance",
@@ -490,6 +492,17 @@
{
"fieldname": "column_break_wslv",
"fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "section_break_ylhd",
+ "fieldtype": "Section Break"
+ },
+ {
+ "default": "0",
+ "description": "If enabled, the system will allow selecting UOMs in sales and purchase transactions only if the conversion rate is set in the item master.",
+ "fieldname": "allow_uom_with_conversion_rate_defined_in_item",
+ "fieldtype": "Check",
+ "label": "Allow UOM with Conversion Rate Defined in Item"
}
],
"icon": "icon-cog",
@@ -497,7 +510,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2025-02-28 16:08:35.938840",
+ "modified": "2025-03-31 15:34:20.752065",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
@@ -518,7 +531,7 @@
}
],
"quick_entry": 1,
- "sort_field": "modified",
+ "sort_field": "creation",
"sort_order": "ASC",
"states": [],
"track_changes": 1
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py
index 8589004c8bf..4de8057a006 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.py
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.py
@@ -33,6 +33,7 @@ class StockSettings(Document):
allow_partial_reservation: DF.Check
allow_to_edit_stock_uom_qty_for_purchase: DF.Check
allow_to_edit_stock_uom_qty_for_sales: DF.Check
+ allow_uom_with_conversion_rate_defined_in_item: DF.Check
auto_create_serial_and_batch_bundle_for_outward: DF.Check
auto_indent: DF.Check
auto_insert_price_list_rate_if_missing: DF.Check
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index 2153b1d73b3..b6587cd7b37 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -406,16 +406,17 @@ class StockBalanceReport:
},
]
- for dimension in get_inventory_dimensions():
- columns.append(
- {
- "label": _(dimension.doctype),
- "fieldname": dimension.fieldname,
- "fieldtype": "Link",
- "options": dimension.doctype,
- "width": 110,
- }
- )
+ if self.filters.get("show_dimension_wise_stock"):
+ for dimension in get_inventory_dimensions():
+ columns.append(
+ {
+ "label": _(dimension.doctype),
+ "fieldname": dimension.fieldname,
+ "fieldtype": "Link",
+ "options": dimension.doctype,
+ "width": 110,
+ }
+ )
columns.extend(
[
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 4627c8aecfa..52f481f1374 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -184,11 +184,16 @@ def validate_serial_no(sle):
frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction)
-def validate_cancellation(args):
- if args[0].get("is_cancelled"):
+def validate_cancellation(kargs):
+ if kargs[0].get("is_cancelled"):
repost_entry = frappe.db.get_value(
"Repost Item Valuation",
- {"voucher_type": args[0].voucher_type, "voucher_no": args[0].voucher_no, "docstatus": 1},
+ {
+ "voucher_type": kargs[0].voucher_type,
+ "voucher_no": kargs[0].voucher_no,
+ "docstatus": 1,
+ "recreate_stock_ledgers": 0,
+ },
["name", "status"],
as_dict=1,
)
@@ -1233,7 +1238,11 @@ class update_entries_after:
stock_entry.db_update()
for d in stock_entry.items:
# Update only the row that matches the voucher_detail_no or the row containing the FG/Scrap Item.
- if d.name == voucher_detail_no or (not d.s_warehouse and d.t_warehouse):
+ if (
+ d.name == voucher_detail_no
+ or (not d.s_warehouse and d.t_warehouse)
+ or stock_entry.purpose in ["Manufacture", "Repack"]
+ ):
d.db_update()
def update_rate_on_delivery_and_sales_return(self, sle, outgoing_rate):
diff --git a/erpnext/stock/workspace/stock/stock.json b/erpnext/stock/workspace/stock/stock.json
index 1a683e2b3d8..2f8435ac702 100644
--- a/erpnext/stock/workspace/stock/stock.json
+++ b/erpnext/stock/workspace/stock/stock.json
@@ -802,7 +802,7 @@
"doc_view": "List",
"label": "Learn Inventory Management",
"type": "URL",
- "url": "https://frappe.school/courses/inventory-management?utm_source=in_app"
+ "url": "https://school.frappe.io/lms/courses/inventory-management?utm_source=in_app"
},
{
"color": "Yellow",
@@ -850,4 +850,4 @@
}
],
"title": "Stock"
-}
\ No newline at end of file
+}
diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
index 3433a842ea8..26c017bddaa 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
@@ -475,7 +475,7 @@ def get_repeated(values):
def get_documents_with_active_service_level_agreement():
- sla_doctypes = frappe.cache().hget("service_level_agreement", "active")
+ sla_doctypes = frappe.cache.get_value("doctypes_with_active_sla")
if sla_doctypes is None:
return set_documents_with_active_service_level_agreement()
@@ -484,20 +484,22 @@ def get_documents_with_active_service_level_agreement():
def set_documents_with_active_service_level_agreement():
- active = [
+ active = frozenset(
sla.document_type for sla in frappe.get_all("Service Level Agreement", fields=["document_type"])
- ]
- frappe.cache().hset("service_level_agreement", "active", active)
+ )
+ frappe.cache.set_value("doctypes_with_active_sla", active)
return active
def apply(doc, method=None):
# Applies SLA to document on validate
+ flags = frappe.local.flags
+
if (
- frappe.flags.in_patch
- or frappe.flags.in_migrate
- or frappe.flags.in_install
- or frappe.flags.in_setup_wizard
+ flags.in_patch
+ or flags.in_migrate
+ or flags.in_install
+ or flags.in_setup_wizard
or doc.doctype not in get_documents_with_active_service_level_agreement()
):
return
diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html
index 315478fc649..0805a32ae33 100644
--- a/erpnext/templates/pages/order.html
+++ b/erpnext/templates/pages/order.html
@@ -40,7 +40,7 @@