diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js
index f46c782112c..7ababfec81a 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.js
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js
@@ -174,6 +174,17 @@ frappe.ui.form.on("Payment Entry", {
};
});
+ frm.set_query("payment_request", "references", function (doc, cdt, cdn) {
+ const row = frappe.get_doc(cdt, cdn);
+ return {
+ query: "erpnext.accounts.doctype.payment_request.payment_request.get_open_payment_requests_query",
+ filters: {
+ reference_doctype: row.reference_doctype,
+ reference_name: row.reference_name,
+ },
+ };
+ });
+
frm.set_query("sales_taxes_and_charges_template", function () {
return {
filters: {
@@ -191,7 +202,15 @@ frappe.ui.form.on("Payment Entry", {
},
};
});
+
+ frm.add_fetch(
+ "payment_request",
+ "outstanding_amount",
+ "payment_request_outstanding",
+ "Payment Entry Reference"
+ );
},
+
refresh: function (frm) {
erpnext.hide_company(frm);
frm.events.hide_unhide_fields(frm);
@@ -216,6 +235,7 @@ frappe.ui.form.on("Payment Entry", {
);
}
erpnext.accounts.unreconcile_payment.add_unreconcile_btn(frm);
+ frappe.flags.allocate_payment_amount = true;
},
validate_company: (frm) => {
@@ -797,7 +817,7 @@ frappe.ui.form.on("Payment Entry", {
);
if (frm.doc.payment_type == "Pay")
- frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, 1);
+ frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.received_amount, true);
else frm.events.set_unallocated_amount(frm);
frm.set_paid_amount_based_on_received_amount = false;
@@ -818,7 +838,7 @@ frappe.ui.form.on("Payment Entry", {
}
if (frm.doc.payment_type == "Receive")
- frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, 1);
+ frm.events.allocate_party_amount_against_ref_docs(frm, frm.doc.paid_amount, true);
else frm.events.set_unallocated_amount(frm);
},
@@ -989,6 +1009,7 @@ frappe.ui.form.on("Payment Entry", {
c.outstanding_amount = d.outstanding_amount;
c.bill_no = d.bill_no;
c.payment_term = d.payment_term;
+ c.payment_term_outstanding = d.payment_term_outstanding;
c.allocated_amount = d.allocated_amount;
c.account = d.account;
@@ -1038,7 +1059,8 @@ frappe.ui.form.on("Payment Entry", {
frm.events.allocate_party_amount_against_ref_docs(
frm,
- frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount
+ frm.doc.payment_type == "Receive" ? frm.doc.paid_amount : frm.doc.received_amount,
+ false
);
},
});
@@ -1052,93 +1074,13 @@ frappe.ui.form.on("Payment Entry", {
return ["Sales Invoice", "Purchase Invoice"];
},
- allocate_party_amount_against_ref_docs: function (frm, paid_amount, paid_amount_change) {
- var total_positive_outstanding_including_order = 0;
- var total_negative_outstanding = 0;
- var total_deductions = frappe.utils.sum(
- $.map(frm.doc.deductions || [], function (d) {
- return flt(d.amount);
- })
- );
-
- paid_amount -= total_deductions;
-
- $.each(frm.doc.references || [], function (i, row) {
- if (flt(row.outstanding_amount) > 0)
- total_positive_outstanding_including_order += flt(row.outstanding_amount);
- else total_negative_outstanding += Math.abs(flt(row.outstanding_amount));
+ allocate_party_amount_against_ref_docs: async function (frm, paid_amount, paid_amount_change) {
+ await frm.call("allocate_amount_to_references", {
+ paid_amount: paid_amount,
+ paid_amount_change: paid_amount_change,
+ allocate_payment_amount: frappe.flags.allocate_payment_amount ?? false,
});
- var allocated_negative_outstanding = 0;
- if (
- (frm.doc.payment_type == "Receive" && frm.doc.party_type == "Customer") ||
- (frm.doc.payment_type == "Pay" && frm.doc.party_type == "Supplier") ||
- (frm.doc.payment_type == "Pay" && frm.doc.party_type == "Employee")
- ) {
- if (total_positive_outstanding_including_order > paid_amount) {
- var remaining_outstanding = total_positive_outstanding_including_order - paid_amount;
- allocated_negative_outstanding =
- total_negative_outstanding < remaining_outstanding
- ? total_negative_outstanding
- : remaining_outstanding;
- }
-
- var allocated_positive_outstanding = paid_amount + allocated_negative_outstanding;
- } else if (["Customer", "Supplier"].includes(frm.doc.party_type)) {
- total_negative_outstanding = flt(total_negative_outstanding, precision("outstanding_amount"));
- if (paid_amount > total_negative_outstanding) {
- if (total_negative_outstanding == 0) {
- frappe.msgprint(
- __("Cannot {0} {1} {2} without any negative outstanding invoice", [
- frm.doc.payment_type,
- frm.doc.party_type == "Customer" ? "to" : "from",
- frm.doc.party_type,
- ])
- );
- return false;
- } else {
- frappe.msgprint(
- __("Paid Amount cannot be greater than total negative outstanding amount {0}", [
- total_negative_outstanding,
- ])
- );
- return false;
- }
- } else {
- allocated_positive_outstanding = total_negative_outstanding - paid_amount;
- allocated_negative_outstanding =
- paid_amount +
- (total_positive_outstanding_including_order < allocated_positive_outstanding
- ? total_positive_outstanding_including_order
- : allocated_positive_outstanding);
- }
- }
-
- $.each(frm.doc.references || [], function (i, row) {
- if (frappe.flags.allocate_payment_amount == 0) {
- //If allocate payment amount checkbox is unchecked, set zero to allocate amount
- row.allocated_amount = 0;
- } else if (
- frappe.flags.allocate_payment_amount != 0 &&
- (!row.allocated_amount || paid_amount_change)
- ) {
- if (row.outstanding_amount > 0 && allocated_positive_outstanding >= 0) {
- row.allocated_amount =
- row.outstanding_amount >= allocated_positive_outstanding
- ? allocated_positive_outstanding
- : row.outstanding_amount;
- allocated_positive_outstanding -= flt(row.allocated_amount);
- } else if (row.outstanding_amount < 0 && allocated_negative_outstanding) {
- row.allocated_amount =
- Math.abs(row.outstanding_amount) >= allocated_negative_outstanding
- ? -1 * allocated_negative_outstanding
- : row.outstanding_amount;
- allocated_negative_outstanding -= Math.abs(flt(row.allocated_amount));
- }
- }
- });
-
- frm.refresh_fields();
frm.events.set_total_allocated_amount(frm);
},
@@ -1686,6 +1628,62 @@ frappe.ui.form.on("Payment Entry", {
return current_tax_amount;
},
+
+ cost_center: function (frm) {
+ if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
+ return frappe.call({
+ method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
+ args: {
+ company: frm.doc.company,
+ date: frm.doc.posting_date,
+ paid_from: frm.doc.paid_from,
+ paid_to: frm.doc.paid_to,
+ ptype: frm.doc.party_type,
+ pty: frm.doc.party,
+ cost_center: frm.doc.cost_center,
+ },
+ callback: function (r, rt) {
+ if (r.message) {
+ frappe.run_serially([
+ () => {
+ frm.set_value(
+ "paid_from_account_balance",
+ r.message.paid_from_account_balance
+ );
+ frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
+ frm.set_value("party_balance", r.message.party_balance);
+ },
+ ]);
+ }
+ },
+ });
+ }
+ },
+
+ after_save: function (frm) {
+ const { matched_payment_requests } = frappe.last_response;
+ if (!matched_payment_requests) return;
+
+ const COLUMN_LABEL = [
+ [__("Reference DocType"), __("Reference Name"), __("Allocated Amount"), __("Payment Request")],
+ ];
+
+ frappe.msgprint({
+ title: __("Unset Matched Payment Request"),
+ message: COLUMN_LABEL.concat(matched_payment_requests),
+ as_table: true,
+ wide: true,
+ primary_action: {
+ label: __("Allocate Payment Request"),
+ action() {
+ frappe.hide_msgprint();
+ frm.call("set_matched_payment_requests", { matched_payment_requests }, () => {
+ frm.dirty();
+ });
+ },
+ },
+ });
+ },
});
frappe.ui.form.on("Payment Entry Reference", {
@@ -1778,35 +1776,3 @@ frappe.ui.form.on("Payment Entry Deduction", {
frm.events.set_unallocated_amount(frm);
},
});
-frappe.ui.form.on("Payment Entry", {
- cost_center: function (frm) {
- if (frm.doc.posting_date && (frm.doc.paid_from || frm.doc.paid_to)) {
- return frappe.call({
- method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_party_and_account_balance",
- args: {
- company: frm.doc.company,
- date: frm.doc.posting_date,
- paid_from: frm.doc.paid_from,
- paid_to: frm.doc.paid_to,
- ptype: frm.doc.party_type,
- pty: frm.doc.party,
- cost_center: frm.doc.cost_center,
- },
- callback: function (r, rt) {
- if (r.message) {
- frappe.run_serially([
- () => {
- frm.set_value(
- "paid_from_account_balance",
- r.message.paid_from_account_balance
- );
- frm.set_value("paid_to_account_balance", r.message.paid_to_account_balance);
- frm.set_value("party_balance", r.message.party_balance);
- },
- ]);
- }
- },
- });
- }
- },
-});
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 625608b5374..9424d722cf5 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -7,8 +7,10 @@ from functools import reduce
import frappe
from frappe import ValidationError, _, qb, scrub, throw
+from frappe.query_builder import Tuple
+from frappe.query_builder.functions import Count
from frappe.utils import cint, comma_or, flt, getdate, nowdate
-from frappe.utils.data import comma_and, fmt_money
+from frappe.utils.data import comma_and, fmt_money, get_link_to_form
from pypika import Case
from pypika.functions import Coalesce, Sum
@@ -98,13 +100,17 @@ class PaymentEntry(AccountsController):
self.set_status()
self.set_total_in_words()
+ def before_save(self):
+ self.set_matched_unset_payment_requests_to_response()
+
def on_submit(self):
if self.difference_amount:
frappe.throw(_("Difference Amount must be zero"))
self.make_gl_entries()
self.update_outstanding_amounts()
- self.update_advance_paid()
self.update_payment_schedule()
+ self.update_payment_requests()
+ self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
def set_liability_account(self):
@@ -188,30 +194,34 @@ class PaymentEntry(AccountsController):
super().on_cancel()
self.make_gl_entries(cancel=1)
self.update_outstanding_amounts()
- self.update_advance_paid()
self.delink_advance_entry_references()
self.update_payment_schedule(cancel=1)
- self.set_payment_req_status()
+ self.update_payment_requests(cancel=True)
+ self.update_advance_paid() # advance_paid_status depends on the payment request amount
self.set_status()
- def set_payment_req_status(self):
- from erpnext.accounts.doctype.payment_request.payment_request import update_payment_req_status
+ def update_payment_requests(self, cancel=False):
+ from erpnext.accounts.doctype.payment_request.payment_request import (
+ update_payment_requests_as_per_pe_references,
+ )
- update_payment_req_status(self, None)
+ update_payment_requests_as_per_pe_references(self.references, cancel=cancel)
def update_outstanding_amounts(self):
self.set_missing_ref_details(force=True)
def validate_duplicate_entry(self):
- reference_names = []
+ reference_names = set()
for d in self.get("references"):
- if (d.reference_doctype, d.reference_name, d.payment_term) in reference_names:
+ key = (d.reference_doctype, d.reference_name, d.payment_term, d.payment_request)
+ if key in reference_names:
frappe.throw(
_("Row #{0}: Duplicate entry in References {1} {2}").format(
d.idx, d.reference_doctype, d.reference_name
)
)
- reference_names.append((d.reference_doctype, d.reference_name, d.payment_term))
+
+ reference_names.add(key)
def set_bank_account_data(self):
if self.bank_account:
@@ -237,6 +247,8 @@ class PaymentEntry(AccountsController):
if self.payment_type == "Internal Transfer":
return
+ self.validate_allocated_amount_as_per_payment_request()
+
if self.party_type in ("Customer", "Supplier"):
self.validate_allocated_amount_with_latest_data()
else:
@@ -249,6 +261,27 @@ class PaymentEntry(AccountsController):
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):
+ """
+ Allocated amount should not be greater than the outstanding amount of the Payment Request.
+ """
+ if not self.references:
+ return
+
+ pr_outstanding_amounts = get_payment_request_outstanding_set_in_references(self.references)
+
+ if not pr_outstanding_amounts:
+ return
+
+ for ref in self.references:
+ if ref.payment_request and ref.allocated_amount > pr_outstanding_amounts[ref.payment_request]:
+ frappe.throw(
+ msg=_(
+ "Row #{0}: Allocated Amount cannot be greater than Outstanding Amount of Payment Request {1}"
+ ).format(ref.idx, get_link_to_form("Payment Request", ref.payment_request)),
+ title=_("Invalid Allocated Amount"),
+ )
+
def term_based_allocation_enabled_for_reference(
self, reference_doctype: str, reference_name: str
) -> bool:
@@ -1606,6 +1639,380 @@ class PaymentEntry(AccountsController):
return current_tax_fraction
+ def set_matched_unset_payment_requests_to_response(self):
+ """
+ Find matched Payment Requests for those references which have no Payment Request set.\n
+ And set to `frappe.response` to show in the frontend for allocation.
+ """
+ if not self.references:
+ return
+
+ matched_payment_requests = get_matched_payment_request_of_references(
+ [row for row in self.references if not row.payment_request]
+ )
+
+ if not matched_payment_requests:
+ return
+
+ frappe.response["matched_payment_requests"] = matched_payment_requests
+
+ @frappe.whitelist()
+ def allocate_amount_to_references(self, paid_amount, paid_amount_change, allocate_payment_amount):
+ """
+ Allocate `Allocated Amount` and `Payment Request` against `Reference` based on `Paid Amount` and `Outstanding Amount`.\n
+ :param paid_amount: Paid Amount / Received Amount.
+ :param paid_amount_change: Flag to check if `Paid Amount` is changed or not.
+ :param allocate_payment_amount: Flag to allocate amount or not. (Payment Request is also dependent on this flag)
+ """
+ if not self.references:
+ return
+
+ if not allocate_payment_amount:
+ for ref in self.references:
+ ref.allocated_amount = 0
+ return
+
+ # calculating outstanding amounts
+ precision = self.precision("paid_amount")
+ total_positive_outstanding_including_order = 0
+ total_negative_outstanding = 0
+ paid_amount -= sum(flt(d.amount, precision) for d in self.deductions)
+
+ for ref in self.references:
+ reference_outstanding_amount = ref.outstanding_amount
+ abs_outstanding_amount = abs(reference_outstanding_amount)
+
+ if reference_outstanding_amount > 0:
+ total_positive_outstanding_including_order += abs_outstanding_amount
+ else:
+ total_negative_outstanding += abs_outstanding_amount
+
+ # calculating allocated outstanding amounts
+ allocated_negative_outstanding = 0
+ allocated_positive_outstanding = 0
+
+ # checking party type and payment type
+ if (self.payment_type == "Receive" and self.party_type == "Customer") or (
+ self.payment_type == "Pay" and self.party_type in ("Supplier", "Employee")
+ ):
+ if total_positive_outstanding_including_order > paid_amount:
+ remaining_outstanding = flt(
+ total_positive_outstanding_including_order - paid_amount, precision
+ )
+ allocated_negative_outstanding = min(remaining_outstanding, total_negative_outstanding)
+
+ allocated_positive_outstanding = paid_amount + allocated_negative_outstanding
+
+ elif self.party_type in ("Supplier", "Employee"):
+ if paid_amount > total_negative_outstanding:
+ if total_negative_outstanding == 0:
+ frappe.msgprint(
+ _("Cannot {0} from {2} without any negative outstanding invoice").format(
+ self.payment_type,
+ self.party_type,
+ )
+ )
+ else:
+ frappe.msgprint(
+ _("Paid Amount cannot be greater than total negative outstanding amount {0}").format(
+ total_negative_outstanding
+ )
+ )
+
+ return
+
+ else:
+ allocated_positive_outstanding = flt(total_negative_outstanding - paid_amount, precision)
+ allocated_negative_outstanding = paid_amount + min(
+ total_positive_outstanding_including_order, allocated_positive_outstanding
+ )
+
+ # inner function to set `allocated_amount` to those row which have no PR
+ def _allocation_to_unset_pr_row(
+ row, outstanding_amount, allocated_positive_outstanding, allocated_negative_outstanding
+ ):
+ if outstanding_amount > 0 and allocated_positive_outstanding >= 0:
+ row.allocated_amount = min(allocated_positive_outstanding, outstanding_amount)
+ allocated_positive_outstanding = flt(
+ allocated_positive_outstanding - row.allocated_amount, precision
+ )
+ elif outstanding_amount < 0 and allocated_negative_outstanding:
+ row.allocated_amount = min(allocated_negative_outstanding, abs(outstanding_amount)) * -1
+ allocated_negative_outstanding = flt(
+ allocated_negative_outstanding - abs(row.allocated_amount), precision
+ )
+ return allocated_positive_outstanding, allocated_negative_outstanding
+
+ # allocate amount based on `paid_amount` is changed or not
+ if not paid_amount_change:
+ for ref in self.references:
+ allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
+ ref,
+ ref.outstanding_amount,
+ allocated_positive_outstanding,
+ allocated_negative_outstanding,
+ )
+
+ allocate_open_payment_requests_to_references(self.references, self.precision("paid_amount"))
+
+ else:
+ payment_request_outstanding_amounts = (
+ get_payment_request_outstanding_set_in_references(self.references) or {}
+ )
+ references_outstanding_amounts = get_references_outstanding_amount(self.references) or {}
+ remaining_references_allocated_amounts = references_outstanding_amounts.copy()
+
+ # Re allocate amount to those references which have PR set (Higher priority)
+ for ref in self.references:
+ if not ref.payment_request:
+ continue
+
+ # fetch outstanding_amount of `Reference` (Payment Term) and `Payment Request` to allocate new amount
+ key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
+ reference_outstanding_amount = references_outstanding_amounts[key]
+ pr_outstanding_amount = payment_request_outstanding_amounts[ref.payment_request]
+
+ if reference_outstanding_amount > 0 and allocated_positive_outstanding >= 0:
+ # allocate amount according to outstanding amounts
+ outstanding_amounts = (
+ allocated_positive_outstanding,
+ reference_outstanding_amount,
+ pr_outstanding_amount,
+ )
+
+ ref.allocated_amount = min(outstanding_amounts)
+
+ # update amounts to track allocation
+ allocated_amount = ref.allocated_amount
+ allocated_positive_outstanding = flt(
+ allocated_positive_outstanding - allocated_amount, precision
+ )
+ remaining_references_allocated_amounts[key] = flt(
+ remaining_references_allocated_amounts[key] - allocated_amount, precision
+ )
+ payment_request_outstanding_amounts[ref.payment_request] = flt(
+ payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
+ )
+
+ elif reference_outstanding_amount < 0 and allocated_negative_outstanding:
+ # allocate amount according to outstanding amounts
+ outstanding_amounts = (
+ allocated_negative_outstanding,
+ abs(reference_outstanding_amount),
+ pr_outstanding_amount,
+ )
+
+ ref.allocated_amount = min(outstanding_amounts) * -1
+
+ # update amounts to track allocation
+ allocated_amount = abs(ref.allocated_amount)
+ allocated_negative_outstanding = flt(
+ allocated_negative_outstanding - allocated_amount, precision
+ )
+ remaining_references_allocated_amounts[key] += allocated_amount # negative amount
+ payment_request_outstanding_amounts[ref.payment_request] = flt(
+ payment_request_outstanding_amounts[ref.payment_request] - allocated_amount, precision
+ )
+ # Re allocate amount to those references which have no PR (Lower priority)
+ for ref in self.references:
+ if ref.payment_request:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, ref.get("payment_term"))
+ reference_outstanding_amount = remaining_references_allocated_amounts[key]
+
+ allocated_positive_outstanding, allocated_negative_outstanding = _allocation_to_unset_pr_row(
+ ref,
+ reference_outstanding_amount,
+ allocated_positive_outstanding,
+ allocated_negative_outstanding,
+ )
+
+ @frappe.whitelist()
+ def set_matched_payment_requests(self, matched_payment_requests):
+ """
+ Set `Payment Request` against `Reference` based on `matched_payment_requests`.\n
+ :param matched_payment_requests: List of tuple of matched Payment Requests.
+
+ ---
+ Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
+ """
+ if not self.references or not matched_payment_requests:
+ return
+
+ if isinstance(matched_payment_requests, str):
+ matched_payment_requests = json.loads(matched_payment_requests)
+
+ # modify matched_payment_requests
+ # like (reference_doctype, reference_name, allocated_amount): payment_request
+ payment_requests = {}
+
+ for row in matched_payment_requests:
+ key = tuple(row[:3])
+ payment_requests[key] = row[3]
+
+ for ref in self.references:
+ if ref.payment_request:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, ref.allocated_amount)
+
+ if key in payment_requests:
+ ref.payment_request = payment_requests[key]
+ del payment_requests[key] # to avoid duplicate allocation
+
+
+def get_matched_payment_request_of_references(references=None):
+ """
+ Get those `Payment Requests` which are matched with `References`.\n
+ - Amount must be same.
+ - Only single `Payment Request` available for this amount.
+
+ Example: [(reference_doctype, reference_name, allocated_amount, payment_request), ...]
+ """
+ if not references:
+ return
+
+ # to fetch matched rows
+ refs = {
+ (row.reference_doctype, row.reference_name, row.allocated_amount)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.allocated_amount
+ }
+
+ if not refs:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ # query to group by reference_doctype, reference_name, outstanding_amount
+ subquery = (
+ frappe.qb.from_(PR)
+ .select(
+ PR.reference_doctype,
+ PR.reference_name,
+ PR.outstanding_amount.as_("allocated_amount"),
+ PR.name.as_("payment_request"),
+ Count("*").as_("count"),
+ )
+ .where(Tuple(PR.reference_doctype, PR.reference_name, PR.outstanding_amount).isin(refs))
+ .where(PR.status != "Paid")
+ .where(PR.docstatus == 1)
+ .groupby(PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
+ )
+
+ # query to fetch matched rows which are single
+ matched_prs = (
+ frappe.qb.from_(subquery)
+ .select(
+ subquery.reference_doctype,
+ subquery.reference_name,
+ subquery.allocated_amount,
+ subquery.payment_request,
+ )
+ .where(subquery.count == 1)
+ .run()
+ )
+
+ return matched_prs if matched_prs else None
+
+
+def get_references_outstanding_amount(references=None):
+ """
+ Fetch accurate outstanding amount of `References`.\n
+ - If `Payment Term` is set, then fetch outstanding amount from `Payment Schedule`.
+ - If `Payment Term` is not set, then fetch outstanding amount from `References` it self.
+
+ Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ refs_with_payment_term = get_outstanding_of_references_with_payment_term(references) or {}
+ refs_without_payment_term = get_outstanding_of_references_with_no_payment_term(references) or {}
+
+ return {**refs_with_payment_term, **refs_without_payment_term}
+
+
+def get_outstanding_of_references_with_payment_term(references=None):
+ """
+ Fetch outstanding amount of `References` which have `Payment Term` set.\n
+ Example: {(reference_doctype, reference_name, payment_term): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ refs = {
+ (row.reference_doctype, row.reference_name, row.payment_term)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.payment_term
+ }
+
+ if not refs:
+ return
+
+ PS = frappe.qb.DocType("Payment Schedule")
+
+ response = (
+ frappe.qb.from_(PS)
+ .select(PS.parenttype, PS.parent, PS.payment_term, PS.outstanding)
+ .where(Tuple(PS.parenttype, PS.parent, PS.payment_term).isin(refs))
+ ).run(as_dict=True)
+
+ if not response:
+ return
+
+ return {(row.parenttype, row.parent, row.payment_term): row.outstanding for row in response}
+
+
+def get_outstanding_of_references_with_no_payment_term(references):
+ """
+ Fetch outstanding amount of `References` which have no `Payment Term` set.\n
+ - Fetch outstanding amount from `References` it self.
+
+ Note: `None` is used for allocation of `Payment Request`
+ Example: {(reference_doctype, reference_name, None): outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ outstanding_amounts = {}
+
+ for ref in references:
+ if ref.payment_term:
+ continue
+
+ key = (ref.reference_doctype, ref.reference_name, None)
+
+ if key not in outstanding_amounts:
+ outstanding_amounts[key] = ref.outstanding_amount
+
+ return outstanding_amounts
+
+
+def get_payment_request_outstanding_set_in_references(references=None):
+ """
+ Fetch outstanding amount of `Payment Request` which are set in `References`.\n
+ Example: {payment_request: outstanding_amount, ...}
+ """
+ if not references:
+ return
+
+ referenced_payment_requests = {row.payment_request for row in references if row.payment_request}
+
+ if not referenced_payment_requests:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(PR.name, PR.outstanding_amount)
+ .where(PR.name.isin(referenced_payment_requests))
+ ).run()
+
+ return dict(response) if response else None
+
def validate_inclusive_tax(tax, doc):
def _on_previous_row_error(row_range):
@@ -2236,6 +2643,8 @@ def get_payment_entry(
party_type=None,
payment_type=None,
reference_date=None,
+ ignore_permissions=False,
+ created_from_payment_request=False,
):
doc = frappe.get_doc(dt, dn)
over_billing_allowance = frappe.db.get_single_value("Accounts Settings", "over_billing_allowance")
@@ -2385,9 +2794,179 @@ def get_payment_entry(
pe.set_difference_amount()
+ # If PE is created from PR directly, then no need to find open PRs for the references
+ if not created_from_payment_request:
+ allocate_open_payment_requests_to_references(pe.references, pe.precision("paid_amount"))
+
return pe
+def get_open_payment_requests_for_references(references=None):
+ """
+ Fetch all unpaid Payment Requests for the references. \n
+ - Each reference can have multiple Payment Requests. \n
+
+ Example: {("Sales Invoice", "SINV-00001"): {"PREQ-00001": 1000, "PREQ-00002": 2000}}
+ """
+ if not references:
+ return
+
+ refs = {
+ (row.reference_doctype, row.reference_name)
+ for row in references
+ if row.reference_doctype and row.reference_name and row.allocated_amount
+ }
+
+ if not refs:
+ return
+
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(PR.name, PR.reference_doctype, PR.reference_name, PR.outstanding_amount)
+ .where(Tuple(PR.reference_doctype, PR.reference_name).isin(list(refs)))
+ .where(PR.status != "Paid")
+ .where(PR.docstatus == 1)
+ .orderby(Coalesce(PR.transaction_date, PR.creation), order=frappe.qb.asc)
+ ).run(as_dict=True)
+
+ if not response:
+ return
+
+ reference_payment_requests = {}
+
+ for row in response:
+ key = (row.reference_doctype, row.reference_name)
+
+ if key not in reference_payment_requests:
+ reference_payment_requests[key] = {row.name: row.outstanding_amount}
+ else:
+ reference_payment_requests[key][row.name] = row.outstanding_amount
+
+ return reference_payment_requests
+
+
+def allocate_open_payment_requests_to_references(references=None, precision=None):
+ """
+ Allocate unpaid Payment Requests to the references. \n
+ ---
+ - Allocation based on below factors
+ - Reference Allocated Amount
+ - Reference Outstanding Amount (With Payment Terms or without Payment Terms)
+ - Reference Payment Request's outstanding amount
+ ---
+ - Allocation based on below scenarios
+ - Reference's Allocated Amount == Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - This PR will not be allocated further
+ - Reference's Allocated Amount < Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - Reduce the PR's outstanding amount by the allocated amount
+ - This PR can be allocated further
+ - Reference's Allocated Amount > Payment Request's Outstanding Amount
+ - Allocate the Payment Request to the reference
+ - Reduce Allocated Amount of the reference by the PR's outstanding amount
+ - Create a new row for the remaining amount until the Allocated Amount is 0
+ - Allocate PR if available
+ ---
+ - Note:
+ - Priority is given to the first Payment Request of respective references.
+ - Single Reference can have multiple rows.
+ - With Payment Terms or without Payment Terms
+ - With Payment Request or without Payment Request
+ """
+ if not references:
+ return
+
+ # get all unpaid payment requests for the references
+ references_open_payment_requests = get_open_payment_requests_for_references(references)
+
+ if not references_open_payment_requests:
+ return
+
+ if not precision:
+ precision = references[0].precision("allocated_amount")
+
+ # to manage new rows
+ row_number = 1
+ MOVE_TO_NEXT_ROW = 1
+ TO_SKIP_NEW_ROW = 2
+
+ while row_number <= len(references):
+ row = references[row_number - 1]
+ reference_key = (row.reference_doctype, row.reference_name)
+
+ # update the idx to maintain the order
+ row.idx = row_number
+
+ # unpaid payment requests for the reference
+ reference_payment_requests = references_open_payment_requests.get(reference_key)
+
+ if not reference_payment_requests:
+ row_number += MOVE_TO_NEXT_ROW # to move to next reference row
+ continue
+
+ # get the first payment request and its outstanding amount
+ payment_request, pr_outstanding_amount = next(iter(reference_payment_requests.items()))
+ allocated_amount = row.allocated_amount
+
+ # allocate the payment request to the reference and PR's outstanding amount
+ row.payment_request = payment_request
+
+ if pr_outstanding_amount == allocated_amount:
+ del reference_payment_requests[payment_request]
+ row_number += MOVE_TO_NEXT_ROW
+
+ elif pr_outstanding_amount > allocated_amount:
+ # reduce the outstanding amount of the payment request
+ reference_payment_requests[payment_request] -= allocated_amount
+ row_number += MOVE_TO_NEXT_ROW
+
+ else:
+ # split the reference row to allocate the remaining amount
+ del reference_payment_requests[payment_request]
+ row.allocated_amount = pr_outstanding_amount
+ allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
+
+ # set the remaining amount to the next row
+ while allocated_amount:
+ # create a new row for the remaining amount
+ new_row = frappe.copy_doc(row)
+ references.insert(row_number, new_row)
+
+ # get the first payment request and its outstanding amount
+ payment_request, pr_outstanding_amount = next(
+ iter(reference_payment_requests.items()), (None, None)
+ )
+
+ # update new row
+ new_row.idx = row_number + 1
+ new_row.payment_request = payment_request
+ new_row.allocated_amount = min(
+ pr_outstanding_amount if pr_outstanding_amount else allocated_amount, allocated_amount
+ )
+
+ if not payment_request or not pr_outstanding_amount:
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ elif pr_outstanding_amount == allocated_amount:
+ del reference_payment_requests[payment_request]
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ elif pr_outstanding_amount > allocated_amount:
+ reference_payment_requests[payment_request] -= allocated_amount
+ row_number += TO_SKIP_NEW_ROW
+ break
+
+ else:
+ allocated_amount = flt(allocated_amount - pr_outstanding_amount, precision)
+ del reference_payment_requests[payment_request]
+ row_number += MOVE_TO_NEXT_ROW
+
+
def update_accounting_dimensions(pe, doc):
"""
Updates accounting dimensions in Payment Entry based on the accounting dimensions in the reference document
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
index 23ed8252333..361f516b830 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.json
@@ -10,6 +10,7 @@
"due_date",
"bill_no",
"payment_term",
+ "payment_term_outstanding",
"account_type",
"payment_type",
"column_break_4",
@@ -18,7 +19,9 @@
"allocated_amount",
"exchange_rate",
"exchange_gain_loss",
- "account"
+ "account",
+ "payment_request",
+ "payment_request_outstanding"
],
"fields": [
{
@@ -120,12 +123,33 @@
"fieldname": "payment_type",
"fieldtype": "Data",
"label": "Payment Type"
+ },
+ {
+ "fieldname": "payment_request",
+ "fieldtype": "Link",
+ "label": "Payment Request",
+ "options": "Payment Request"
+ },
+ {
+ "depends_on": "eval: doc.payment_term",
+ "fieldname": "payment_term_outstanding",
+ "fieldtype": "Float",
+ "label": "Payment Term Outstanding",
+ "read_only": 1
+ },
+ {
+ "depends_on": "eval: doc.payment_request && doc.payment_request_outstanding",
+ "fieldname": "payment_request_outstanding",
+ "fieldtype": "Float",
+ "is_virtual": 1,
+ "label": "Payment Request Outstanding",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2024-04-05 09:44:08.310593",
+ "modified": "2024-09-16 18:11:50.019343",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Entry Reference",
diff --git a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
index 4a027b4ee32..2ac92ba4a84 100644
--- a/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
+++ b/erpnext/accounts/doctype/payment_entry_reference/payment_entry_reference.py
@@ -1,7 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
-
+import frappe
from frappe.model.document import Document
@@ -25,11 +25,19 @@ class PaymentEntryReference(Document):
parent: DF.Data
parentfield: DF.Data
parenttype: DF.Data
+ payment_request: DF.Link | None
+ payment_request_outstanding: DF.Float
payment_term: DF.Link | None
+ payment_term_outstanding: DF.Float
payment_type: DF.Data | None
reference_doctype: DF.Link
reference_name: DF.DynamicLink
total_amount: DF.Float
# end: auto-generated types
- pass
+ @property
+ def payment_request_outstanding(self):
+ if not self.payment_request:
+ return
+
+ return frappe.db.get_value("Payment Request", self.payment_request, "outstanding_amount")
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.js b/erpnext/accounts/doctype/payment_request/payment_request.js
index e45aa512fe8..50f96a4e2b6 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.js
+++ b/erpnext/accounts/doctype/payment_request/payment_request.js
@@ -48,8 +48,8 @@ frappe.ui.form.on("Payment Request", "refresh", function (frm) {
}
if (
- (!frm.doc.payment_gateway_account || frm.doc.payment_request_type == "Outward") &&
- frm.doc.status == "Initiated"
+ frm.doc.payment_request_type == "Outward" &&
+ ["Initiated", "Partially Paid"].includes(frm.doc.status)
) {
frm.add_custom_button(__("Create Payment Entry"), function () {
frappe.call({
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.json b/erpnext/accounts/doctype/payment_request/payment_request.json
index d0651f74bdf..b7af8412810 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.json
+++ b/erpnext/accounts/doctype/payment_request/payment_request.json
@@ -19,9 +19,11 @@
"reference_name",
"transaction_details",
"grand_total",
+ "currency",
"is_a_subscription",
"column_break_18",
- "currency",
+ "outstanding_amount",
+ "party_account_currency",
"subscription_section",
"subscription_plans",
"bank_account_details",
@@ -69,6 +71,7 @@
{
"fieldname": "transaction_date",
"fieldtype": "Date",
+ "in_preview": 1,
"label": "Transaction Date"
},
{
@@ -133,7 +136,8 @@
"no_copy": 1,
"options": "reference_doctype",
"print_hide": 1,
- "read_only": 1
+ "read_only": 1,
+ "search_index": 1
},
{
"fieldname": "transaction_details",
@@ -141,12 +145,14 @@
"label": "Transaction Details"
},
{
- "description": "Amount in customer's currency",
+ "description": "Amount in transaction currency",
"fieldname": "grand_total",
"fieldtype": "Currency",
+ "in_preview": 1,
"label": "Amount",
"non_negative": 1,
- "options": "currency"
+ "options": "currency",
+ "reqd": 1
},
{
"default": "0",
@@ -392,19 +398,37 @@
"print_hide": 1,
"read_only": 1
},
+ {
+ "depends_on": "eval: doc.docstatus === 1",
+ "description": "Amount in party's bank account currency",
+ "fieldname": "outstanding_amount",
+ "fieldtype": "Currency",
+ "in_preview": 1,
+ "label": "Outstanding Amount",
+ "non_negative": 1,
+ "options": "party_account_currency",
+ "read_only": 1
+ },
{
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"read_only": 1
+ },
+ {
+ "fieldname": "party_account_currency",
+ "fieldtype": "Link",
+ "label": "Party Account Currency",
+ "options": "Currency",
+ "read_only": 1
}
],
"in_create": 1,
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2024-08-07 16:39:54.288002",
+ "modified": "2024-09-16 17:50:54.440090",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Payment Request",
@@ -439,6 +463,7 @@
"write": 1
}
],
+ "show_preview_popup": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": []
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 83b43a15987..8c370ba9d8c 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -3,9 +3,11 @@ import json
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.query_builder.functions import Sum
from frappe.utils import flt, nowdate
from frappe.utils.background_jobs import enqueue
+from erpnext import get_company_currency
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions,
)
@@ -45,6 +47,7 @@ class PaymentRequest(Document):
bank_account: DF.Link | None
bank_account_no: DF.ReadOnly | None
branch_code: DF.ReadOnly | None
+ company: DF.Link | None
cost_center: DF.Link | None
currency: DF.Link | None
email_to: DF.Data | None
@@ -56,16 +59,18 @@ class PaymentRequest(Document):
mode_of_payment: DF.Link | None
mute_email: DF.Check
naming_series: DF.Literal["ACC-PRQ-.YYYY.-"]
+ outstanding_amount: DF.Currency
party: DF.DynamicLink | None
+ party_account_currency: DF.Link | None
party_type: DF.Link | None
payment_account: DF.ReadOnly | None
- payment_channel: DF.Literal["", "Email", "Phone"]
+ payment_channel: DF.Literal["", "Email", "Phone", "Other"]
payment_gateway: DF.ReadOnly | None
payment_gateway_account: DF.Link | None
payment_order: DF.Link | None
payment_request_type: DF.Literal["Outward", "Inward"]
payment_url: DF.Data | None
- print_format: DF.Literal
+ print_format: DF.Literal[None]
project: DF.Link | None
reference_doctype: DF.Link | None
reference_name: DF.DynamicLink | None
@@ -84,7 +89,6 @@ class PaymentRequest(Document):
subscription_plans: DF.Table[SubscriptionPlanDetail]
swift_number: DF.ReadOnly | None
transaction_date: DF.Date | None
- company: DF.Link | None
# end: auto-generated types
def validate(self):
@@ -100,6 +104,12 @@ class PaymentRequest(Document):
frappe.throw(_("To create a Payment Request reference document is required"))
def validate_payment_request_amount(self):
+ if self.grand_total == 0:
+ frappe.throw(
+ _("{0} cannot be zero").format(self.get_label_from_fieldname("grand_total")),
+ title=_("Invalid Amount"),
+ )
+
existing_payment_request_amount = flt(
get_existing_payment_request_amount(self.reference_doctype, self.reference_name)
)
@@ -147,6 +157,28 @@ class PaymentRequest(Document):
).format(self.grand_total, amount)
)
+ def before_submit(self):
+ if (
+ self.currency != self.party_account_currency
+ and self.party_account_currency == get_company_currency(self.company)
+ ):
+ # set outstanding amount in party account currency
+ invoice = frappe.get_value(
+ self.reference_doctype,
+ self.reference_name,
+ ["rounded_total", "grand_total", "base_rounded_total", "base_grand_total"],
+ as_dict=1,
+ )
+ grand_total = invoice.get("rounded_total") or invoice.get("grand_total")
+ base_grand_total = invoice.get("base_rounded_total") or invoice.get("base_grand_total")
+ self.outstanding_amount = flt(
+ self.grand_total / grand_total * base_grand_total,
+ self.precision("outstanding_amount"),
+ )
+
+ else:
+ self.outstanding_amount = self.grand_total
+
def on_submit(self):
if self.payment_request_type == "Outward":
self.db_set("status", "Initiated")
@@ -275,7 +307,7 @@ class PaymentRequest(Document):
def set_as_paid(self):
if self.payment_channel == "Phone":
- self.db_set("status", "Paid")
+ self.db_set({"status": "Paid", "outstanding_amount": 0})
else:
payment_entry = self.create_payment_entry()
@@ -296,26 +328,32 @@ class PaymentRequest(Document):
else:
party_account = get_party_account("Customer", ref_doc.get("customer"), ref_doc.company)
- party_account_currency = ref_doc.get("party_account_currency") or get_account_currency(party_account)
+ party_account_currency = (
+ self.get("party_account_currency")
+ or ref_doc.get("party_account_currency")
+ or get_account_currency(party_account)
+ )
+
+ party_amount = bank_amount = self.outstanding_amount
- bank_amount = self.grand_total
if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
- party_amount = ref_doc.get("base_rounded_total") or ref_doc.get("base_grand_total")
- else:
- party_amount = self.grand_total
+ exchange_rate = ref_doc.get("conversion_rate")
+ bank_amount = flt(self.outstanding_amount / exchange_rate, self.precision("grand_total"))
+ # outstanding amount is already in Part's account currency
payment_entry = get_payment_entry(
self.reference_doctype,
self.reference_name,
party_amount=party_amount,
bank_account=self.payment_account,
bank_amount=bank_amount,
+ created_from_payment_request=True,
)
payment_entry.update(
{
"mode_of_payment": self.mode_of_payment,
- "reference_no": self.name,
+ "reference_no": self.name, # to prevent validation error
"reference_date": nowdate(),
"remarks": "Payment Entry against {} {} via Payment Request {}".format(
self.reference_doctype, self.reference_name, self.name
@@ -323,6 +361,9 @@ class PaymentRequest(Document):
}
)
+ # Allocate payment_request for each reference in payment_entry (Payment Term can splits the row)
+ self._allocate_payment_request_to_pe_references(references=payment_entry.references)
+
# Update dimensions
payment_entry.update(
{
@@ -331,14 +372,6 @@ class PaymentRequest(Document):
}
)
- if party_account_currency == ref_doc.company_currency and party_account_currency != self.currency:
- amount = payment_entry.base_paid_amount
- else:
- amount = self.grand_total
-
- payment_entry.received_amount = amount
- payment_entry.get("references")[0].allocated_amount = amount
-
# Update 'Paid Amount' on Forex transactions
if self.currency != ref_doc.company_currency:
if (
@@ -429,6 +462,62 @@ class PaymentRequest(Document):
return create_stripe_subscription(gateway_controller, data)
+ def _allocate_payment_request_to_pe_references(self, references):
+ """
+ Allocate the Payment Request to the Payment Entry references based on\n
+ - Allocated Amount.
+ - Outstanding Amount of Payment Request.\n
+ Payment Request is doc itself and references are the rows of Payment Entry.
+ """
+ if len(references) == 1:
+ references[0].payment_request = self.name
+ return
+
+ precision = references[0].precision("allocated_amount")
+ outstanding_amount = self.outstanding_amount
+
+ # to manage rows
+ row_number = 1
+ MOVE_TO_NEXT_ROW = 1
+ TO_SKIP_NEW_ROW = 2
+ NEW_ROW_ADDED = False
+
+ while row_number <= len(references):
+ row = references[row_number - 1]
+
+ # update the idx to maintain the order
+ row.idx = row_number
+
+ if outstanding_amount == 0:
+ if not NEW_ROW_ADDED:
+ break
+
+ row_number += MOVE_TO_NEXT_ROW
+ continue
+
+ # allocate the payment request to the row
+ row.payment_request = self.name
+
+ if row.allocated_amount <= outstanding_amount:
+ outstanding_amount = flt(outstanding_amount - row.allocated_amount, precision)
+ row_number += MOVE_TO_NEXT_ROW
+ else:
+ remaining_allocated_amount = flt(row.allocated_amount - outstanding_amount, precision)
+ row.allocated_amount = outstanding_amount
+ outstanding_amount = 0
+
+ # create a new row without PR for remaining unallocated amount
+ new_row = frappe.copy_doc(row)
+ references.insert(row_number, new_row)
+
+ # update new row
+ new_row.idx = row_number + 1
+ new_row.payment_request = None
+ new_row.allocated_amount = remaining_allocated_amount
+
+ NEW_ROW_ADDED = True
+ row_number += TO_SKIP_NEW_ROW
+
@frappe.whitelist(allow_guest=True)
def make_payment_request(**args):
@@ -459,11 +548,15 @@ def make_payment_request(**args):
{"reference_doctype": args.dt, "reference_name": args.dn, "docstatus": 0},
)
- existing_payment_request_amount = get_existing_payment_request_amount(args.dt, args.dn)
+ # fetches existing payment request `grand_total` amount
+ existing_payment_request_amount = get_existing_payment_request_amount(ref_doc.doctype, ref_doc.name)
if existing_payment_request_amount:
grand_total -= existing_payment_request_amount
+ if not grand_total:
+ frappe.throw(_("Payment Request is already created"))
+
if draft_payment_request:
frappe.db.set_value(
"Payment Request", draft_payment_request, "grand_total", grand_total, update_modified=False
@@ -477,6 +570,13 @@ def make_payment_request(**args):
"Outward" if args.get("dt") in ["Purchase Order", "Purchase Invoice"] else "Inward"
)
+ party_type = args.get("party_type") or "Customer"
+ party_account_currency = ref_doc.party_account_currency
+
+ if not party_account_currency:
+ party_account = get_party_account(party_type, ref_doc.get(party_type.lower()), ref_doc.company)
+ party_account_currency = get_account_currency(party_account)
+
pr.update(
{
"payment_gateway_account": gateway_account.get("name"),
@@ -485,6 +585,7 @@ def make_payment_request(**args):
"payment_channel": gateway_account.get("payment_channel"),
"payment_request_type": args.get("payment_request_type"),
"currency": ref_doc.currency,
+ "party_account_currency": party_account_currency,
"grand_total": grand_total,
"mode_of_payment": args.mode_of_payment,
"email_to": args.recipient_id or ref_doc.owner,
@@ -493,7 +594,7 @@ def make_payment_request(**args):
"reference_doctype": args.dt,
"reference_name": args.dn,
"company": ref_doc.get("company"),
- "party_type": args.get("party_type") or "Customer",
+ "party_type": party_type,
"party": args.get("party") or ref_doc.get("customer"),
"bank_account": bank_account,
}
@@ -539,9 +640,11 @@ def get_amount(ref_doc, payment_account=None):
elif dt in ["Sales Invoice", "Purchase Invoice"]:
if not ref_doc.get("is_pos"):
if ref_doc.party_account_currency == ref_doc.currency:
- grand_total = flt(ref_doc.grand_total)
+ grand_total = flt(ref_doc.rounded_total or ref_doc.grand_total)
else:
- grand_total = flt(ref_doc.base_grand_total) / ref_doc.conversion_rate
+ 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:
@@ -563,24 +666,20 @@ def get_amount(ref_doc, payment_account=None):
def get_existing_payment_request_amount(ref_dt, ref_dn):
"""
- Get the existing payment request which are unpaid or partially paid for payment channel other than Phone
- and get the summation of existing paid payment request for Phone payment channel.
+ Return the total amount of Payment Requests against a reference document.
"""
- existing_payment_request_amount = frappe.db.sql(
- """
- select sum(grand_total)
- from `tabPayment Request`
- where
- reference_doctype = %s
- and reference_name = %s
- and docstatus = 1
- and (status != 'Paid'
- or (payment_channel = 'Phone'
- and status = 'Paid'))
- """,
- (ref_dt, ref_dn),
+ PR = frappe.qb.DocType("Payment Request")
+
+ response = (
+ frappe.qb.from_(PR)
+ .select(Sum(PR.grand_total))
+ .where(PR.reference_doctype == ref_dt)
+ .where(PR.reference_name == ref_dn)
+ .where(PR.docstatus == 1)
+ .run()
)
- return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0
+
+ return response[0][0] if response[0] else 0
def get_gateway_details(args): # nosemgrep
@@ -627,41 +726,66 @@ def make_payment_entry(docname):
return doc.create_payment_entry(submit=False).as_dict()
-def update_payment_req_status(doc, method):
- from erpnext.accounts.doctype.payment_entry.payment_entry import get_reference_details
+def update_payment_requests_as_per_pe_references(references=None, cancel=False):
+ """
+ Update Payment Request's `Status` and `Outstanding Amount` based on Payment Entry Reference's `Allocated Amount`.
+ """
+ if not references:
+ return
- for ref in doc.references:
- payment_request_name = frappe.db.get_value(
- "Payment Request",
- {
- "reference_doctype": ref.reference_doctype,
- "reference_name": ref.reference_name,
- "docstatus": 1,
- },
+ precision = references[0].precision("allocated_amount")
+
+ referenced_payment_requests = frappe.get_all(
+ "Payment Request",
+ filters={"name": ["in", {row.payment_request for row in references if row.payment_request}]},
+ fields=[
+ "name",
+ "grand_total",
+ "outstanding_amount",
+ "payment_request_type",
+ ],
+ )
+
+ referenced_payment_requests = {pr.name: pr for pr in referenced_payment_requests}
+
+ for ref in references:
+ if not ref.payment_request:
+ continue
+
+ payment_request = referenced_payment_requests[ref.payment_request]
+ pr_outstanding = payment_request["outstanding_amount"]
+
+ # update outstanding amount
+ new_outstanding_amount = flt(
+ pr_outstanding + ref.allocated_amount if cancel else pr_outstanding - ref.allocated_amount,
+ precision,
)
- if payment_request_name:
- ref_details = get_reference_details(
- ref.reference_doctype,
- ref.reference_name,
- doc.party_account_currency,
- doc.party_type,
- doc.party,
+ # to handle same payment request for the multiple allocations
+ payment_request["outstanding_amount"] = new_outstanding_amount
+
+ if not cancel and new_outstanding_amount < 0:
+ frappe.throw(
+ msg=_(
+ "The allocated amount is greater than the outstanding amount of Payment Request {0}"
+ ).format(ref.payment_request),
+ title=_("Invalid Allocated Amount"),
)
- pay_req_doc = frappe.get_doc("Payment Request", payment_request_name)
- status = pay_req_doc.status
- if status != "Paid" and not ref_details.outstanding_amount:
- status = "Paid"
- elif status != "Partially Paid" and ref_details.outstanding_amount != ref_details.total_amount:
- status = "Partially Paid"
- elif ref_details.outstanding_amount == ref_details.total_amount:
- if pay_req_doc.payment_request_type == "Outward":
- status = "Initiated"
- elif pay_req_doc.payment_request_type == "Inward":
- status = "Requested"
+ # update status
+ if new_outstanding_amount == payment_request["grand_total"]:
+ status = "Initiated" if payment_request["payment_request_type"] == "Outward" else "Requested"
+ elif new_outstanding_amount == 0:
+ status = "Paid"
+ elif new_outstanding_amount > 0:
+ status = "Partially Paid"
- pay_req_doc.db_set("status", status)
+ # update database
+ frappe.db.set_value(
+ "Payment Request",
+ ref.payment_request,
+ {"outstanding_amount": new_outstanding_amount, "status": status},
+ )
def get_dummy_message(doc):
@@ -745,3 +869,35 @@ def validate_payment(doc, method=None):
doc.reference_docname
)
)
+
+
+@frappe.whitelist()
+def get_open_payment_requests_query(doctype, txt, searchfield, start, page_len, filters):
+ # permission checks in `get_list()`
+ reference_doctype = filters.get("reference_doctype")
+ reference_name = filters.get("reference_doctype")
+
+ if not reference_doctype or not reference_name:
+ return []
+
+ open_payment_requests = frappe.get_list(
+ "Payment Request",
+ filters={
+ "reference_doctype": filters["reference_doctype"],
+ "reference_name": filters["reference_name"],
+ "status": ["!=", "Paid"],
+ "outstanding_amount": ["!=", 0], # for compatibility with old data
+ "docstatus": 1,
+ },
+ fields=["name", "grand_total", "outstanding_amount"],
+ order_by="transaction_date ASC,creation ASC",
+ )
+
+ return [
+ (
+ pr.name,
+ _("Grand Total: {0}").format(pr.grand_total),
+ _("Outstanding Amount: {0}").format(pr.outstanding_amount),
+ )
+ for pr in open_payment_requests
+ ]
diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py
index 6d15f84d7cf..b0c3dbf4d5b 100644
--- a/erpnext/accounts/doctype/payment_request/test_payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py
@@ -1,11 +1,13 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
+import re
import unittest
import frappe
-from frappe.tests.utils import FrappeTestCase
+from frappe.tests.utils import FrappeTestCase, change_settings
+from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_terms_template
from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -15,6 +17,7 @@ from erpnext.setup.utils import get_exchange_rate
test_dependencies = ["Currency Exchange", "Journal Entry", "Contact", "Address"]
+
payment_gateway = {"doctype": "Payment Gateway", "gateway": "_Test Gateway"}
payment_method = [
@@ -278,3 +281,246 @@ class TestPaymentRequest(FrappeTestCase):
self.assertEqual(pe.paid_amount, 800)
self.assertEqual(pe.base_received_amount, 800)
self.assertEqual(pe.received_amount, 10)
+
+ def test_multiple_payment_if_partially_paid_for_same_currency(self):
+ so = make_sales_order(currency="INR", qty=1, rate=1000)
+
+ pr = make_payment_request(
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 1000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+ self.assertEqual(pr.party_account_currency, pr.currency) # INR
+
+ so.load_from_db()
+
+ # to make partial payment
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 200
+ pe.references[0].allocated_amount = 200
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 800)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # complete payment
+ pe = pr.create_payment_entry()
+
+ self.assertEqual(pe.paid_amount, 800) # paid amount set from pr's outstanding amount
+ self.assertEqual(pe.references[0].allocated_amount, 800)
+ self.assertEqual(pe.references[0].outstanding_amount, 800) # for Orders it is not zero
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # creating a more payment Request must not allowed
+ 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,
+ )
+
+ @change_settings("Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1})
+ def test_multiple_payment_if_partially_paid_for_multi_currency(self):
+ pi = make_purchase_invoice(currency="USD", conversion_rate=50, qty=1, rate=100, do_not_save=1)
+ pi.credit_to = "Creditors - _TC"
+ pi.submit()
+
+ pr = make_payment_request(
+ dt="Purchase Invoice",
+ dn=pi.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ # 100 USD -> 5000 INR
+ self.assertEqual(pr.grand_total, 100)
+ self.assertEqual(pr.outstanding_amount, 5000)
+ self.assertEqual(pr.currency, "USD")
+ self.assertEqual(pr.party_account_currency, "INR")
+ self.assertEqual(pr.status, "Initiated")
+
+ # to make partial payment
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 2000
+ pe.references[0].allocated_amount = 2000
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 3000)
+ self.assertEqual(pr.grand_total, 100)
+
+ # complete payment
+ pe = pr.create_payment_entry()
+ self.assertEqual(pe.paid_amount, 3000) # paid amount set from pr's outstanding amount
+ self.assertEqual(pe.references[0].allocated_amount, 3000)
+ self.assertEqual(pe.references[0].outstanding_amount, 0) # for Invoices it will zero
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 100)
+
+ # creating a more payment Request must not allowed
+ 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,
+ )
+
+ def test_single_payment_with_payment_term_for_same_currency(self):
+ create_payment_terms_template()
+
+ po = create_purchase_order(do_not_save=1, currency="INR", qty=1, rate=20000)
+ po.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
+ po.save()
+ po.submit()
+
+ pr = make_payment_request(
+ dt="Purchase Order",
+ dn=po.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 20000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+ self.assertEqual(pr.party_account_currency, pr.currency) # INR
+ self.assertEqual(pr.status, "Initiated")
+
+ po.load_from_db()
+
+ pe = pr.create_payment_entry()
+
+ self.assertEqual(len(pe.references), 2)
+ self.assertEqual(pe.paid_amount, 20000)
+
+ # check 1st payment term
+ self.assertEqual(pe.references[0].allocated_amount, 16949.2)
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ # check 2nd payment term
+ self.assertEqual(pe.references[1].allocated_amount, 3050.8)
+ self.assertEqual(pe.references[1].payment_request, pr.name)
+
+ po.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 20000)
+
+ @change_settings("Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1})
+ def test_single_payment_with_payment_term_for_multi_currency(self):
+ create_payment_terms_template()
+
+ si = create_sales_invoice(
+ do_not_save=1, currency="USD", debit_to="Debtors - _TC", qty=1, rate=200, conversion_rate=50
+ )
+ si.payment_terms_template = "Test Receivable Template" # 84.746 and 15.254
+ si.save()
+ si.submit()
+
+ pr = make_payment_request(
+ dt="Sales Invoice",
+ dn=si.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ # 200 USD -> 10000 INR
+ self.assertEqual(pr.grand_total, 200)
+ self.assertEqual(pr.outstanding_amount, 10000)
+ self.assertEqual(pr.currency, "USD")
+ self.assertEqual(pr.party_account_currency, "INR")
+
+ pe = pr.create_payment_entry()
+ self.assertEqual(len(pe.references), 2)
+ self.assertEqual(pe.paid_amount, 10000)
+
+ # check 1st payment term
+ # convert it via dollar and conversion_rate
+ self.assertEqual(pe.references[0].allocated_amount, 8474.5) # multi currency conversion
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ # check 2nd payment term
+ self.assertEqual(pe.references[1].allocated_amount, 1525.5) # multi currency conversion
+ self.assertEqual(pe.references[1].payment_request, pr.name)
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Paid")
+ self.assertEqual(pr.outstanding_amount, 0)
+ self.assertEqual(pr.grand_total, 200)
+
+ def test_payment_cancel_process(self):
+ so = make_sales_order(currency="INR", qty=1, rate=1000)
+
+ pr = make_payment_request(
+ dt="Sales Order",
+ dn=so.name,
+ mute_email=1,
+ submit_doc=1,
+ return_doc=1,
+ )
+
+ self.assertEqual(pr.grand_total, 1000)
+ self.assertEqual(pr.outstanding_amount, pr.grand_total)
+
+ so.load_from_db()
+
+ pe = pr.create_payment_entry(submit=False)
+ pe.paid_amount = 800
+ pe.references[0].allocated_amount = 800
+ pe.submit()
+
+ self.assertEqual(pe.references[0].payment_request, pr.name)
+
+ so.load_from_db()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Partially Paid")
+ self.assertEqual(pr.outstanding_amount, 200)
+ self.assertEqual(pr.grand_total, 1000)
+
+ # cancelling PE
+ pe.cancel()
+
+ pr.load_from_db()
+ self.assertEqual(pr.status, "Requested")
+ self.assertEqual(pr.outstanding_amount, 1000)
+ self.assertEqual(pr.grand_total, 1000)
+
+ so.load_from_db()
diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
index 9bc110d243e..c68cd292523 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py
@@ -392,8 +392,7 @@ def process_closing_entries(gl_entries, closing_entries, voucher_name, company,
)
try:
- if gl_entries + closing_entries:
- make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date)
+ make_closing_entries(gl_entries + closing_entries, voucher_name, company, closing_date)
except Exception as e:
frappe.db.rollback()
frappe.log_error(e)
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 742af7dd0fe..20316e3394b 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -862,6 +862,7 @@ def get_item_group(pos_profile):
if pos_profile.get("item_groups"):
# Get items based on the item groups defined in the POS profile
for row in pos_profile.get("item_groups"):
+ item_groups.append(row.item_group)
item_groups.extend(get_descendants_of("Item Group", row.item_group))
return list(set(item_groups))
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index 2d5cbb9e6c3..c78db5b86d6 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -561,11 +561,12 @@ frappe.ui.form.on("Purchase Invoice", {
frm.custom_make_buttons = {
"Purchase Invoice": "Return / Debit Note",
"Payment Entry": "Payment",
- "Landed Cost Voucher": function () {
- frm.trigger("create_landed_cost_voucher");
- },
};
+ if (frm.doc.update_stock) {
+ frm.custom_make_buttons["Landed Cost Voucher"] = "Landed Cost Voucher";
+ }
+
frm.set_query("additional_discount_account", function () {
return {
filters: {
@@ -607,20 +608,6 @@ frappe.ui.form.on("Purchase Invoice", {
});
},
- create_landed_cost_voucher: function (frm) {
- let lcv = frappe.model.get_new_doc("Landed Cost Voucher");
- lcv.company = frm.doc.company;
-
- let lcv_receipt = frappe.model.get_new_doc("Landed Cost Purchase Invoice");
- lcv_receipt.receipt_document_type = "Purchase Invoice";
- lcv_receipt.receipt_document = frm.doc.name;
- lcv_receipt.supplier = frm.doc.supplier;
- lcv_receipt.grand_total = frm.doc.grand_total;
- lcv.purchase_receipts = [lcv_receipt];
-
- frappe.set_route("Form", lcv.doctype, lcv.name);
- },
-
add_custom_buttons: function (frm) {
if (frm.doc.docstatus == 1 && frm.doc.per_received < 100) {
frm.add_custom_button(
@@ -645,6 +632,32 @@ frappe.ui.form.on("Purchase Invoice", {
__("View")
);
}
+
+ if (frm.doc.docstatus === 1 && frm.doc.update_stock) {
+ frm.add_custom_button(
+ __("Landed Cost Voucher"),
+ () => {
+ frm.events.make_lcv(frm);
+ },
+ __("Create")
+ );
+ }
+ },
+
+ make_lcv(frm) {
+ frappe.call({
+ method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_lcv",
+ args: {
+ doctype: frm.doc.doctype,
+ docname: frm.doc.name,
+ },
+ callback: (r) => {
+ if (r.message) {
+ var doc = frappe.model.sync(r.message);
+ frappe.set_route("Form", doc[0].doctype, doc[0].name);
+ }
+ },
+ });
},
onload: function (frm) {
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 010d3b8bcd5..529086228de 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -2123,7 +2123,7 @@ def make_delivery_note(source_name, target_doc=None):
"postprocess": update_item,
"condition": lambda doc: doc.delivered_by_supplier != 1,
},
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {
"doctype": "Sales Team",
"field_map": {"incentives": "incentives"},
diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
index 9fd78da2d07..67a33faf9bf 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py
@@ -327,7 +327,7 @@ def get_tax_amount(party_type, parties, inv, tax_details, posting_date, pan_no=N
tax_amount = 0
else:
# if no TCS has been charged in FY,
- # then chargeable value is "prev invoices + advances" value which cross the threshold
+ # then chargeable value is "prev invoices + advances - advance_adjusted" value which cross the threshold
tax_amount = get_tcs_amount(parties, inv, tax_details, vouchers, advance_vouchers)
if cint(tax_details.round_off_tax_amount):
@@ -414,6 +414,9 @@ def get_advance_vouchers(parties, company=None, from_date=None, to_date=None, pa
Use Payment Ledger to fetch unallocated Advance Payments
"""
+ if party_type == "Supplier":
+ return []
+
ple = qb.DocType("Payment Ledger Entry")
conditions = []
@@ -631,9 +634,12 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):
)
cumulative_threshold = tax_details.get("cumulative_threshold", 0)
+ advance_adjusted = get_advance_adjusted_in_invoice(inv)
current_invoice_total = get_invoice_total_without_tcs(inv, tax_details)
- total_invoiced_amt = current_invoice_total + invoiced_amt + advance_amt - credit_note_amt
+ total_invoiced_amt = (
+ current_invoice_total + invoiced_amt + advance_amt - credit_note_amt - advance_adjusted
+ )
if cumulative_threshold and total_invoiced_amt >= cumulative_threshold:
chargeable_amt = total_invoiced_amt - cumulative_threshold
@@ -642,6 +648,14 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers):
return tcs_amount
+def get_advance_adjusted_in_invoice(inv):
+ advances_adjusted = 0
+ for row in inv.get("advances", []):
+ advances_adjusted += row.allocated_amount
+
+ return advances_adjusted
+
+
def get_invoice_total_without_tcs(inv, tax_details):
tcs_tax_row = [d for d in inv.taxes if d.account_head == tax_details.account_head]
tcs_tax_row_amount = tcs_tax_row[0].base_tax_amount if tcs_tax_row else 0
diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
index 1e3939d98a4..24c9265eecd 100644
--- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
+++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py
@@ -210,6 +210,46 @@ class TestTaxWithholdingCategory(FrappeTestCase):
d.reload()
d.cancel()
+ def test_tcs_on_allocated_advance_payments(self):
+ frappe.db.set_value(
+ "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS"
+ )
+
+ vouchers = []
+
+ # create advance payment
+ pe = create_payment_entry(
+ payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=30000
+ )
+ pe.paid_from = "Debtors - _TC"
+ pe.paid_to = "Cash - _TC"
+ pe.submit()
+ vouchers.append(pe)
+
+ si = create_sales_invoice(customer="Test TCS Customer", rate=50000)
+ advances = si.get_advance_entries()
+ si.append(
+ "advances",
+ {
+ "reference_type": advances[0].reference_type,
+ "reference_name": advances[0].reference_name,
+ "advance_amount": advances[0].amount,
+ "allocated_amount": 30000,
+ },
+ )
+ si.submit()
+ vouchers.append(si)
+
+ # assert tax collection on total invoice ,advance payment adjusted should be excluded.
+ tcs_charged = sum([d.base_tax_amount for d in si.taxes if d.account_head == "TCS - _TC"])
+ # tcs = (inv amt)50000+(adv amt)30000-(adv adj) 30000 - threshold(30000) * rate 10%
+ self.assertEqual(tcs_charged, 2000)
+
+ # cancel invoice and payments to avoid clashing
+ for d in reversed(vouchers):
+ d.reload()
+ d.cancel()
+
def test_tds_calculation_on_net_total(self):
frappe.db.set_value(
"Supplier", "Test TDS Supplier4", "tax_withholding_category", "Cumulative Threshold TDS"
diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py
index 504c74babcb..48364cc2c91 100644
--- a/erpnext/accounts/report/purchase_register/purchase_register.py
+++ b/erpnext/accounts/report/purchase_register/purchase_register.py
@@ -311,6 +311,7 @@ def get_account_columns(invoice_list, include_payments):
"""select distinct expense_account
from `tabPurchase Invoice Item` where docstatus = 1
and (expense_account is not null and expense_account != '')
+ and parenttype='Purchase Invoice'
and parent in (%s) order by expense_account"""
% ", ".join(["%s"] * len(invoice_list)),
tuple([inv.name for inv in invoice_list]),
@@ -451,7 +452,7 @@ def get_invoice_expense_map(invoice_list):
"""
select parent, expense_account, sum(base_net_amount) as amount
from `tabPurchase Invoice Item`
- where parent in (%s)
+ where parent in (%s) and parenttype='Purchase Invoice'
group by parent, expense_account
"""
% ", ".join(["%s"] * len(invoice_list)),
@@ -522,7 +523,7 @@ def get_invoice_po_pr_map(invoice_list):
"""
select parent, purchase_order, purchase_receipt, po_detail, project
from `tabPurchase Invoice Item`
- where parent in (%s)
+ where parent in (%s) and parenttype='Purchase Invoice'
"""
% ", ".join(["%s"] * len(invoice_list)),
tuple(inv.name for inv in invoice_list),
diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py
index bd1b35559ea..d6c1b95cf7c 100644
--- a/erpnext/accounts/report/utils.py
+++ b/erpnext/accounts/report/utils.py
@@ -326,6 +326,7 @@ def apply_common_conditions(filters, query, doctype, child_doctype=None, payment
if join_required:
query = query.inner_join(child_doc).on(parent_doc.name == child_doc.parent)
+ query = query.where(child_doc.parenttype == doctype)
query = query.distinct()
if parent_doc.get_table_name() != "tabJournal Entry":
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 14424dfdf4a..c03c896c29f 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -738,7 +738,7 @@ def make_purchase_receipt(source_name, target_doc=None):
"condition": lambda doc: abs(doc.received_qty) < abs(doc.qty)
and doc.delivered_by_supplier != 1,
},
- "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "add_if_empty": True},
+ "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
},
target_doc,
set_missing_values,
@@ -819,7 +819,7 @@ def get_mapped_purchase_invoice(source_name, target_doc=None, ignore_permissions
"postprocess": update_item,
"condition": lambda doc: (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)),
},
- "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "add_if_empty": True},
+ "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True},
}
doc = get_mapped_doc(
@@ -889,6 +889,20 @@ def make_subcontracting_order(source_name, target_doc=None, save=False, submit=F
def get_mapped_subcontracting_order(source_name, target_doc=None):
+ def post_process(source_doc, target_doc):
+ target_doc.populate_items_table()
+
+ if target_doc.set_warehouse:
+ for item in target_doc.items:
+ item.warehouse = target_doc.set_warehouse
+ else:
+ if source_doc.set_warehouse:
+ for item in target_doc.items:
+ item.warehouse = source_doc.set_warehouse
+ else:
+ for idx, item in enumerate(target_doc.items):
+ item.warehouse = source_doc.items[idx].warehouse
+
if target_doc and isinstance(target_doc, str):
target_doc = json.loads(target_doc)
for key in ["service_items", "items", "supplied_items"]:
@@ -919,22 +933,9 @@ def get_mapped_subcontracting_order(source_name, target_doc=None):
},
},
target_doc,
+ post_process,
)
- target_doc.populate_items_table()
- source_doc = frappe.get_doc("Purchase Order", source_name)
-
- if target_doc.set_warehouse:
- for item in target_doc.items:
- item.warehouse = target_doc.set_warehouse
- else:
- if source_doc.set_warehouse:
- for item in target_doc.items:
- item.warehouse = source_doc.set_warehouse
- else:
- for idx, item in enumerate(target_doc.items):
- item.warehouse = source_doc.items[idx].warehouse
-
return target_doc
diff --git a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js
index 56684a8659b..9b193a34d83 100644
--- a/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js
+++ b/erpnext/buying/report/purchase_order_trends/purchase_order_trends.js
@@ -2,3 +2,10 @@
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Purchase Order Trends"] = $.extend({}, erpnext.purchase_trends_filters);
+
+frappe.query_reports["Purchase Order Trends"]["filters"].push({
+ fieldname: "include_closed_orders",
+ label: __("Include Closed Orders"),
+ fieldtype: "Check",
+ default: 0,
+});
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 3c1fb8bae44..73923464ed9 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -1974,7 +1974,7 @@ class AccountsController(TransactionBase):
).format(formatted_advance_paid, self.name, formatted_order_total)
)
- frappe.db.set_value(self.doctype, self.name, "advance_paid", advance_paid)
+ self.db_set("advance_paid", advance_paid)
@property
def company_abbr(self):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 9fb0cc88cfb..537b37facf4 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -357,6 +357,9 @@ class StockController(AccountsController):
@frappe.request_cache
def is_serial_batch_item(self, item_code) -> bool:
+ if not frappe.db.exists("Item", item_code):
+ frappe.throw(_("Item {0} does not exist.").format(bold(item_code)))
+
item_details = frappe.db.get_value("Item", item_code, ["has_serial_no", "has_batch_no"], as_dict=1)
if item_details.has_serial_no or item_details.has_batch_no:
diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py
index a6727ef8826..f6f6742cc87 100644
--- a/erpnext/controllers/subcontracting_controller.py
+++ b/erpnext/controllers/subcontracting_controller.py
@@ -576,30 +576,56 @@ class SubcontractingController(StockController):
self.__set_batch_nos(bom_item, item_row, rm_obj, qty)
if self.doctype == "Subcontracting Receipt" and not use_serial_batch_fields:
- args = frappe._dict(
- {
- "item_code": rm_obj.rm_item_code,
- "warehouse": self.supplier_warehouse,
- "posting_date": self.posting_date,
- "posting_time": self.posting_time,
- "qty": -1 * flt(rm_obj.consumed_qty),
- "actual_qty": -1 * flt(rm_obj.consumed_qty),
- "voucher_type": self.doctype,
- "voucher_no": self.name,
- "voucher_detail_no": item_row.name,
- "company": self.company,
- "allow_zero_valuation": 1,
- }
- )
-
rm_obj.serial_and_batch_bundle = self.__set_serial_and_batch_bundle(
item_row, rm_obj, rm_obj.consumed_qty
)
- if rm_obj.serial_and_batch_bundle:
- args["serial_and_batch_bundle"] = rm_obj.serial_and_batch_bundle
+ self.set_rate_for_supplied_items(rm_obj, item_row)
- rm_obj.rate = get_incoming_rate(args)
+ def update_rate_for_supplied_items(self):
+ if self.doctype != "Subcontracting Receipt":
+ return
+
+ for row in self.supplied_items:
+ item_row = None
+ if row.reference_name:
+ item_row = self.get_item_row(row.reference_name)
+
+ if not item_row:
+ continue
+
+ self.set_rate_for_supplied_items(row, item_row)
+
+ def get_item_row(self, reference_name):
+ for item in self.items:
+ if item.name == reference_name:
+ return item
+
+ def set_rate_for_supplied_items(self, rm_obj, item_row):
+ args = frappe._dict(
+ {
+ "item_code": rm_obj.rm_item_code,
+ "warehouse": self.supplier_warehouse,
+ "posting_date": self.posting_date,
+ "posting_time": self.posting_time,
+ "qty": -1 * flt(rm_obj.consumed_qty),
+ "actual_qty": -1 * flt(rm_obj.consumed_qty),
+ "voucher_type": self.doctype,
+ "voucher_no": self.name,
+ "voucher_detail_no": item_row.name,
+ "company": self.company,
+ "allow_zero_valuation": 1,
+ }
+ )
+
+ if rm_obj.serial_and_batch_bundle:
+ args["serial_and_batch_bundle"] = rm_obj.serial_and_batch_bundle
+
+ if rm_obj.use_serial_batch_fields:
+ args["batch_no"] = rm_obj.batch_no
+ args["serial_no"] = rm_obj.serial_no
+
+ rm_obj.rate = get_incoming_rate(args)
def __set_batch_nos(self, bom_item, item_row, rm_obj, qty):
key = (rm_obj.rm_item_code, item_row.item_code, item_row.get(self.subcontract_data.order_field))
diff --git a/erpnext/controllers/trends.py b/erpnext/controllers/trends.py
index 18fe7767c5d..24d11e6050a 100644
--- a/erpnext/controllers/trends.py
+++ b/erpnext/controllers/trends.py
@@ -69,13 +69,15 @@ def get_data(filters, conditions):
"Delivery Note",
]:
posting_date = "t1.posting_date"
- if filters.period_based_on:
+ if filters.period_based_on and conditions.get("trans") in ["Sales Invoice", "Purchase Invoice"]:
posting_date = "t1." + filters.period_based_on
if conditions["based_on_select"] in ["t1.project,", "t2.project,"]:
cond = " and " + conditions["based_on_select"][:-1] + " IS Not NULL"
- if conditions.get("trans") in ["Sales Order", "Purchase Order"]:
- cond += " and t1.status != 'Closed'"
+
+ if not filters.get("include_closed_orders"):
+ if conditions.get("trans") in ["Sales Order", "Purchase Order"]:
+ cond += " and t1.status != 'Closed'"
if conditions.get("trans") == "Quotation" and filters.get("group_by") == "Customer":
cond += " and t1.quotation_to = 'Customer'"
@@ -222,7 +224,7 @@ def period_wise_columns_query(filters, trans):
if trans in ["Purchase Receipt", "Delivery Note", "Purchase Invoice", "Sales Invoice"]:
trans_date = "posting_date"
- if filters.period_based_on:
+ if filters.period_based_on and trans in ["Purchase Invoice", "Sales Invoice"]:
trans_date = filters.period_based_on
else:
trans_date = "transaction_date"
diff --git a/erpnext/crm/doctype/lead/lead.js b/erpnext/crm/doctype/lead/lead.js
index 609eab7f9a2..e50cf9e4dd0 100644
--- a/erpnext/crm/doctype/lead/lead.js
+++ b/erpnext/crm/doctype/lead/lead.js
@@ -28,18 +28,18 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
erpnext.toggle_naming_series();
if (!this.frm.is_new() && doc.__onload && !doc.__onload.is_customer) {
- this.frm.add_custom_button(__("Customer"), this.make_customer, __("Create"));
- this.frm.add_custom_button(
- __("Opportunity"),
- function () {
- me.frm.trigger("make_opportunity");
- },
- __("Create")
- );
- this.frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create"));
+ this.frm.add_custom_button(__("Customer"), this.make_customer.bind(this), __("Create"));
+ this.frm.add_custom_button(__("Opportunity"), this.make_opportunity.bind(this), __("Create"));
+ this.frm.add_custom_button(__("Quotation"), this.make_quotation.bind(this), __("Create"));
if (!doc.__onload.linked_prospects.length) {
- this.frm.add_custom_button(__("Prospect"), this.make_prospect, __("Create"));
- this.frm.add_custom_button(__("Add to Prospect"), this.add_lead_to_prospect, __("Action"));
+ this.frm.add_custom_button(__("Prospect"), this.make_prospect.bind(this), __("Create"));
+ this.frm.add_custom_button(
+ __("Add to Prospect"),
+ () => {
+ this.add_lead_to_prospect(this.frm);
+ },
+ __("Action")
+ );
}
}
@@ -53,8 +53,7 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
this.show_activities();
}
- add_lead_to_prospect() {
- let me = this;
+ add_lead_to_prospect(frm) {
frappe.prompt(
[
{
@@ -69,12 +68,12 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
frappe.call({
method: "erpnext.crm.doctype.lead.lead.add_lead_to_prospect",
args: {
- lead: me.frm.doc.name,
+ lead: frm.doc.name,
prospect: data.prospect,
},
callback: function (r) {
if (!r.exc) {
- me.frm.reload_doc();
+ frm.reload_doc();
}
},
freeze: true,
@@ -89,32 +88,123 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
make_customer() {
frappe.model.open_mapped_doc({
method: "erpnext.crm.doctype.lead.lead.make_customer",
- frm: cur_frm,
+ frm: this.frm,
});
}
make_quotation() {
frappe.model.open_mapped_doc({
method: "erpnext.crm.doctype.lead.lead.make_quotation",
- frm: cur_frm,
+ frm: this.frm,
});
}
+ async make_opportunity() {
+ const frm = this.frm;
+ let existing_prospect = (
+ await frappe.db.get_value(
+ "Prospect Lead",
+ {
+ lead: frm.doc.name,
+ },
+ "name",
+ null,
+ "Prospect"
+ )
+ ).message?.name;
+
+ let fields = [];
+ if (!existing_prospect) {
+ fields.push(
+ {
+ label: "Create Prospect",
+ fieldname: "create_prospect",
+ fieldtype: "Check",
+ default: 1,
+ },
+ {
+ label: "Prospect Name",
+ fieldname: "prospect_name",
+ fieldtype: "Data",
+ default: frm.doc.company_name,
+ reqd: 1,
+ depends_on: "create_prospect",
+ }
+ );
+ }
+
+ await frm.reload_doc();
+
+ let existing_contact = (
+ await frappe.db.get_value(
+ "Contact",
+ {
+ first_name: frm.doc.first_name || frm.doc.lead_name,
+ last_name: frm.doc.last_name,
+ },
+ "name"
+ )
+ ).message?.name;
+
+ if (!existing_contact) {
+ fields.push({
+ label: "Create Contact",
+ fieldname: "create_contact",
+ fieldtype: "Check",
+ default: "1",
+ });
+ }
+
+ if (fields.length) {
+ const d = new frappe.ui.Dialog({
+ title: __("Create Opportunity"),
+ fields: fields,
+ primary_action: function (data) {
+ frappe.call({
+ method: "create_prospect_and_contact",
+ doc: frm.doc,
+ args: {
+ data: data,
+ },
+ freeze: true,
+ callback: function (r) {
+ if (!r.exc) {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.crm.doctype.lead.lead.make_opportunity",
+ frm: frm,
+ });
+ }
+ d.hide();
+ },
+ });
+ },
+ primary_action_label: __("Create"),
+ });
+ d.show();
+ } else {
+ frappe.model.open_mapped_doc({
+ method: "erpnext.crm.doctype.lead.lead.make_opportunity",
+ frm: frm,
+ });
+ }
+ }
+
make_prospect() {
+ const me = this;
frappe.model.with_doctype("Prospect", function () {
let prospect = frappe.model.get_new_doc("Prospect");
- prospect.company_name = cur_frm.doc.company_name;
- prospect.no_of_employees = cur_frm.doc.no_of_employees;
- prospect.industry = cur_frm.doc.industry;
- prospect.market_segment = cur_frm.doc.market_segment;
- prospect.territory = cur_frm.doc.territory;
- prospect.fax = cur_frm.doc.fax;
- prospect.website = cur_frm.doc.website;
- prospect.prospect_owner = cur_frm.doc.lead_owner;
- prospect.notes = cur_frm.doc.notes;
+ prospect.company_name = me.frm.doc.company_name;
+ prospect.no_of_employees = me.frm.doc.no_of_employees;
+ prospect.industry = me.frm.doc.industry;
+ prospect.market_segment = me.frm.doc.market_segment;
+ prospect.territory = me.frm.doc.territory;
+ prospect.fax = me.frm.doc.fax;
+ prospect.website = me.frm.doc.website;
+ prospect.prospect_owner = me.frm.doc.lead_owner;
+ prospect.notes = me.frm.doc.notes;
let leads_row = frappe.model.add_child(prospect, "leads");
- leads_row.lead = cur_frm.doc.name;
+ leads_row.lead = me.frm.doc.name;
frappe.set_route("Form", "Prospect", prospect.name);
});
@@ -150,90 +240,3 @@ erpnext.LeadController = class LeadController extends frappe.ui.form.Controller
};
extend_cscript(cur_frm.cscript, new erpnext.LeadController({ frm: cur_frm }));
-
-frappe.ui.form.on("Lead", {
- make_opportunity: async function (frm) {
- let existing_prospect = (
- await frappe.db.get_value(
- "Prospect Lead",
- {
- lead: frm.doc.name,
- },
- "name",
- null,
- "Prospect"
- )
- ).message.name;
-
- if (!existing_prospect) {
- var fields = [
- {
- label: "Create Prospect",
- fieldname: "create_prospect",
- fieldtype: "Check",
- default: 1,
- },
- {
- label: "Prospect Name",
- fieldname: "prospect_name",
- fieldtype: "Data",
- default: frm.doc.company_name,
- depends_on: "create_prospect",
- },
- ];
- }
- let existing_contact = (
- await frappe.db.get_value(
- "Contact",
- {
- first_name: frm.doc.first_name || frm.doc.lead_name,
- last_name: frm.doc.last_name,
- },
- "name"
- )
- ).message.name;
-
- if (!existing_contact) {
- fields.push({
- label: "Create Contact",
- fieldname: "create_contact",
- fieldtype: "Check",
- default: "1",
- });
- }
-
- if (fields) {
- var d = new frappe.ui.Dialog({
- title: __("Create Opportunity"),
- fields: fields,
- primary_action: function () {
- var data = d.get_values();
- frappe.call({
- method: "create_prospect_and_contact",
- doc: frm.doc,
- args: {
- data: data,
- },
- freeze: true,
- callback: function (r) {
- if (!r.exc) {
- frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.lead.lead.make_opportunity",
- frm: frm,
- });
- }
- d.hide();
- },
- });
- },
- primary_action_label: __("Create"),
- });
- d.show();
- } else {
- frappe.model.open_mapped_doc({
- method: "erpnext.crm.doctype.lead.lead.make_opportunity",
- frm: frm,
- });
- }
- },
-});
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index bd367d6d95e..30121e5f2cb 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -365,7 +365,6 @@ doc_events = {
"Payment Entry": {
"on_submit": [
"erpnext.regional.create_transaction_log",
- "erpnext.accounts.doctype.payment_request.payment_request.update_payment_req_status",
"erpnext.accounts.doctype.dunning.dunning.resolve_dunning",
],
"on_cancel": ["erpnext.accounts.doctype.dunning.dunning.resolve_dunning"],
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index abea4c86279..de8116b296c 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -669,7 +669,7 @@ class JobCard(Document):
self.set_transferred_qty()
def validate_transfer_qty(self):
- if self.items and self.transferred_qty < self.for_quantity:
+ if not self.is_corrective_job_card and self.items and self.transferred_qty < self.for_quantity:
frappe.throw(
_(
"Materials needs to be transferred to the work in progress warehouse for the job card {0}"
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js
index 5b4ef233926..aba213ebca4 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.js
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js
@@ -277,7 +277,7 @@ frappe.ui.form.on("Production Plan", {
frm.clear_table("prod_plan_references");
frappe.call({
- method: "get_items",
+ method: "combine_so_items",
freeze: true,
doc: frm.doc,
callback: function () {
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 7d3aa000c87..3f82a75d302 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -269,6 +269,31 @@ class ProductionPlan(Document):
{"material_request": data.name, "material_request_date": data.transaction_date},
)
+ @frappe.whitelist()
+ def combine_so_items(self):
+ if self.combine_items and self.po_items and len(self.po_items) > 0:
+ items = []
+ for row in self.po_items:
+ items.append(
+ frappe._dict(
+ {
+ "parent": row.sales_order,
+ "item_code": row.item_code,
+ "warehouse": row.warehouse,
+ "qty": row.pending_qty,
+ "pending_qty": row.pending_qty,
+ "conversion_factor": 1.0,
+ "description": row.description,
+ "bom_no": row.bom_no,
+ }
+ )
+ )
+
+ self.set("po_items", [])
+ self.add_items(items)
+ else:
+ self.get_items()
+
@frappe.whitelist()
def get_items(self):
self.set("po_items", [])
@@ -435,24 +460,28 @@ class ProductionPlan(Document):
item_details = get_item_details(data.item_code, throw=False)
if self.combine_items:
- if item_details.bom_no in refs:
- refs[item_details.bom_no]["so_details"].append(
+ bom_no = item_details.bom_no
+ if data.get("bom_no"):
+ bom_no = data.get("bom_no")
+
+ if bom_no in refs:
+ refs[bom_no]["so_details"].append(
{"sales_order": data.parent, "sales_order_item": data.name, "qty": data.pending_qty}
)
- refs[item_details.bom_no]["qty"] += data.pending_qty
+ refs[bom_no]["qty"] += data.pending_qty
continue
else:
- refs[item_details.bom_no] = {
+ refs[bom_no] = {
"qty": data.pending_qty,
"po_item_ref": data.name,
"so_details": [],
}
- refs[item_details.bom_no]["so_details"].append(
+ refs[bom_no]["so_details"].append(
{"sales_order": data.parent, "sales_order_item": data.name, "qty": data.pending_qty}
)
- bom_no = data.bom_no or item_details and item_details.bom_no or ""
+ bom_no = data.bom_no or item_details and item_details.get("bom_no") or ""
if not bom_no:
continue
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 057c49949ec..218ab2f2bf8 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -520,7 +520,6 @@ class WorkOrder(Document):
def delete_auto_created_batch_and_serial_no(self):
for row in frappe.get_all("Serial No", filters={"work_order": self.name}):
frappe.delete_doc("Serial No", row.name)
- self.db_set("serial_no", "")
for row in frappe.get_all("Batch", filters={"reference_name": self.name}):
frappe.delete_doc("Batch", row.name)
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index 1d0680de861..202efe157f0 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -392,7 +392,7 @@ erpnext.buying = {
item[field] = r.message[field];
});
- item.type_of_transaction = item.rejected_qty > 0 ? "Inward" : "Outward";
+ item.type_of_transaction = !doc.is_return > 0 ? "Inward" : "Outward";
item.is_rejected = true;
new erpnext.SerialBatchPackageSelector(
@@ -404,7 +404,7 @@ erpnext.buying = {
}
let update_values = {
- "serial_and_batch_bundle": r.name,
+ "rejected_serial_and_batch_bundle": r.name,
"use_serial_batch_fields": 0,
"rejected_qty": qty / flt(item.conversion_factor || 1, precision("conversion_factor", item))
}
diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js
index a7d88edcafa..7c02fefc0f9 100755
--- a/erpnext/public/js/utils.js
+++ b/erpnext/public/js/utils.js
@@ -920,6 +920,7 @@ erpnext.utils.map_current_doc = function (opts) {
target: opts.target,
date_field: opts.date_field || undefined,
setters: opts.setters,
+ read_only_setters: opts.read_only_setters,
data_fields: data_fields,
get_query: opts.get_query,
add_filters_group: 1,
diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js
index 46ff6366de8..1045965e43d 100644
--- a/erpnext/public/js/utils/serial_no_batch_selector.js
+++ b/erpnext/public/js/utils/serial_no_batch_selector.js
@@ -96,7 +96,12 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
options: "Warehouse",
default: this.get_warehouse(),
onchange: () => {
- this.item.warehouse = this.dialog.get_value("warehouse");
+ if (this.item?.is_rejected) {
+ this.item.rejected_warehouse = this.dialog.get_value("warehouse");
+ } else {
+ this.item.warehouse = this.dialog.get_value("warehouse");
+ }
+
this.get_auto_data();
},
get_query: () => {
@@ -282,10 +287,6 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
return fields;
}
- set_serial_nos_from_series() {}
-
- set_batch_nos_from_series() {}
-
set_serial_nos_from_range() {
const serial_no_range = this.dialog.get_value("serial_no_range");
@@ -508,12 +509,17 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
based_on = "FIFO";
}
+ let warehouse = this.item.warehouse || this.item.s_warehouse;
+ if (this.item?.is_rejected) {
+ warehouse = this.item.rejected_warehouse;
+ }
+
if (qty) {
frappe.call({
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_auto_data",
args: {
item_code: this.item.item_code,
- warehouse: this.item.warehouse || this.item.s_warehouse,
+ warehouse: warehouse,
has_serial_no: this.item.has_serial_no,
has_batch_no: this.item.has_batch_no,
qty: qty,
@@ -627,6 +633,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
frappe.throw(__("Please select a Warehouse"));
}
+ if (this.item?.is_rejected && this.item.rejected_warehouse === this.item.warehouse) {
+ frappe.throw(__("Rejected Warehouse and Accepted Warehouse cannot be same."));
+ }
+
frappe
.call({
method: "erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_ledgers",
@@ -701,5 +711,8 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
});
this.dialog.fields_dict.entries.grid.refresh();
+ if (this.dialog.fields_dict.entries.df.data?.length) {
+ this.dialog.set_value("enter_manually", 0);
+ }
}
};
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index 7ebcb329193..2d7fef2d6e0 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -430,7 +430,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
"postprocess": update_item,
"condition": can_map_row,
},
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
"Payment Schedule": {"doctype": "Payment Schedule", "add_if_empty": True},
},
@@ -495,7 +495,7 @@ def _make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
"postprocess": update_item,
"condition": lambda row: not row.is_alternative,
},
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
},
target_doc,
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index 2f2d840cce8..4804080be38 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -933,7 +933,7 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None):
mapper = {
"Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}},
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
+ "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "reset_value": True},
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
}
@@ -1125,7 +1125,10 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False):
"condition": lambda doc: doc.qty
and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)),
},
- "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
+ "Sales Taxes and Charges": {
+ "doctype": "Sales Taxes and Charges",
+ "reset_value": True,
+ },
"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
},
target_doc,
diff --git a/erpnext/selling/report/sales_order_trends/sales_order_trends.js b/erpnext/selling/report/sales_order_trends/sales_order_trends.js
index 28bd5504930..a44353cf54b 100644
--- a/erpnext/selling/report/sales_order_trends/sales_order_trends.js
+++ b/erpnext/selling/report/sales_order_trends/sales_order_trends.js
@@ -2,3 +2,10 @@
// License: GNU General Public License v3. See license.txt
frappe.query_reports["Sales Order Trends"] = $.extend({}, erpnext.sales_trends_filters);
+
+frappe.query_reports["Sales Order Trends"]["filters"].push({
+ fieldname: "include_closed_orders",
+ label: __("Include Closed Orders"),
+ fieldtype: "Check",
+ default: 0,
+});
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 5d95e7b66d3..d203b979c61 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -1030,7 +1030,7 @@ def make_sales_invoice(source_name, target_doc=None, args=None):
},
"Sales Taxes and Charges": {
"doctype": "Sales Taxes and Charges",
- "add_if_empty": True,
+ "reset_value": not (args and args.get("merge_taxes")),
"ignore": args.get("merge_taxes") if args else 0,
},
"Sales Team": {
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
index bfac4381a06..bcecf8be14d 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.js
@@ -11,25 +11,10 @@ erpnext.buying.setup_buying_controller();
frappe.ui.form.on("Purchase Receipt", {
setup: (frm) => {
- frm.make_methods = {
- "Landed Cost Voucher": () => {
- let lcv = frappe.model.get_new_doc("Landed Cost Voucher");
- lcv.company = frm.doc.company;
-
- let lcv_receipt = frappe.model.get_new_doc("Landed Cost Purchase Receipt");
- lcv_receipt.receipt_document_type = "Purchase Receipt";
- lcv_receipt.receipt_document = frm.doc.name;
- lcv_receipt.supplier = frm.doc.supplier;
- lcv_receipt.grand_total = frm.doc.grand_total;
- lcv.purchase_receipts = [lcv_receipt];
-
- frappe.set_route("Form", lcv.doctype, lcv.name);
- },
- };
-
frm.custom_make_buttons = {
"Stock Entry": "Return",
"Purchase Invoice": "Purchase Invoice",
+ "Landed Cost Voucher": "Landed Cost Voucher",
};
frm.set_query("expense_account", "items", function () {
@@ -114,9 +99,35 @@ frappe.ui.form.on("Purchase Receipt", {
}
}
+ if (frm.doc.docstatus === 1) {
+ frm.add_custom_button(
+ __("Landed Cost Voucher"),
+ () => {
+ frm.events.make_lcv(frm);
+ },
+ __("Create")
+ );
+ }
+
frm.events.add_custom_buttons(frm);
},
+ make_lcv(frm) {
+ frappe.call({
+ method: "erpnext.stock.doctype.purchase_receipt.purchase_receipt.make_lcv",
+ args: {
+ doctype: frm.doc.doctype,
+ docname: frm.doc.name,
+ },
+ callback: (r) => {
+ if (r.message) {
+ var doc = frappe.model.sync(r.message);
+ frappe.set_route("Form", doc[0].doctype, doc[0].name);
+ }
+ },
+ });
+ },
+
add_custom_buttons: function (frm) {
if (frm.doc.docstatus == 0) {
frm.add_custom_button(
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 42b70a08222..228bc35693b 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -1214,7 +1214,7 @@ def make_purchase_invoice(source_name, target_doc=None, args=None):
},
"Purchase Taxes and Charges": {
"doctype": "Purchase Taxes and Charges",
- "add_if_empty": True,
+ "reset_value": not (args and args.get("merge_taxes")),
"ignore": args.get("merge_taxes") if args else 0,
},
},
@@ -1370,3 +1370,26 @@ def get_item_account_wise_additional_cost(purchase_document):
@erpnext.allow_regional
def update_regional_gl_entries(gl_list, doc):
return
+
+
+@frappe.whitelist()
+def make_lcv(doctype, docname):
+ landed_cost_voucher = frappe.new_doc("Landed Cost Voucher")
+
+ details = frappe.db.get_value(doctype, docname, ["supplier", "company", "base_grand_total"], as_dict=1)
+
+ landed_cost_voucher.company = details.company
+
+ landed_cost_voucher.append(
+ "purchase_receipts",
+ {
+ "receipt_document_type": doctype,
+ "receipt_document": docname,
+ "grand_total": details.base_grand_total,
+ "supplier": details.supplier,
+ },
+ )
+
+ landed_cost_voucher.get_items_from_purchase_receipts()
+
+ return landed_cost_voucher.as_dict()
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index 62f95f5b2e8..94ec8675db8 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -1308,8 +1308,12 @@ def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=Fal
if parent_doc and isinstance(parent_doc, str):
parent_doc = parse_json(parent_doc)
- if frappe.db.exists("Serial and Batch Bundle", child_row.serial_and_batch_bundle):
- sb_doc = update_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse)
+ bundle = child_row.serial_and_batch_bundle
+ if child_row.get("is_rejected"):
+ bundle = child_row.rejected_serial_and_batch_bundle
+
+ if frappe.db.exists("Serial and Batch Bundle", bundle):
+ sb_doc = update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, warehouse)
else:
sb_doc = create_serial_batch_no_ledgers(
entries, child_row, parent_doc, warehouse, do_not_save=do_not_save
@@ -1412,8 +1416,8 @@ def get_type_of_transaction(parent_doc, child_row):
return type_of_transaction
-def update_serial_batch_no_ledgers(entries, child_row, parent_doc, warehouse=None) -> object:
- doc = frappe.get_doc("Serial and Batch Bundle", child_row.serial_and_batch_bundle)
+def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, warehouse=None) -> object:
+ doc = frappe.get_doc("Serial and Batch Bundle", bundle)
doc.voucher_detail_no = child_row.name
doc.posting_date = parent_doc.posting_date
doc.posting_time = parent_doc.posting_time
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index c54876713c3..cb442f6666d 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -447,9 +447,11 @@ frappe.ui.form.on("Stock Entry", {
source_doctype: "Stock Entry",
target: frm,
date_field: "posting_date",
+ read_only_setters: ["stock_entry_type", "purpose", "add_to_transit"],
setters: {
stock_entry_type: "Material Transfer",
purpose: "Material Transfer",
+ add_to_transit: 1,
},
get_query_filters: {
docstatus: 1,
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
index 83113a223c2..2aaf8a8adcd 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js
@@ -249,11 +249,15 @@ frappe.ui.form.on("Subcontracting Receipt", {
});
frm.set_query("batch_no", "supplied_items", (doc, cdt, cdn) => {
- var row = locals[cdt][cdn];
+ let row = locals[cdt][cdn];
+ let filters = {
+ item_code: row.rm_item_code,
+ warehouse: doc.supplier_warehouse,
+ };
+
return {
- filters: {
- item: row.rm_item_code,
- },
+ query: "erpnext.controllers.queries.get_batch_no",
+ filters: filters,
};
});
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
index 48203167187..db912514988 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py
@@ -237,9 +237,14 @@ class SubcontractingReceipt(SubcontractingController):
frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on")
== "BOM"
and self.supplied_items
- and not any(item.serial_and_batch_bundle for item in self.supplied_items)
):
- self.supplied_items = []
+ if not any(
+ item.serial_and_batch_bundle or item.batch_no or item.serial_no
+ for item in self.supplied_items
+ ):
+ self.supplied_items = []
+ else:
+ self.update_rate_for_supplied_items()
@frappe.whitelist()
def get_scrap_items(self, recalculate_rate=False):
diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
index 8ff5c8f27b0..27ad7dbebdf 100644
--- a/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
+++ b/erpnext/subcontracting/doctype/subcontracting_receipt/test_subcontracting_receipt.py
@@ -1361,6 +1361,66 @@ class TestSubcontractingReceipt(FrappeTestCase):
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
+ def test_change_batch_for_raw_materials(self):
+ set_backflush_based_on("BOM")
+
+ fg_item = make_item(properties={"is_stock_item": 1, "is_sub_contracted_item": 1}).name
+ rm_item1 = make_item(
+ properties={
+ "is_stock_item": 1,
+ "has_batch_no": 1,
+ "create_new_batch": 1,
+ "batch_number_series": "BNGS-.####",
+ }
+ ).name
+
+ bom = make_bom(item=fg_item, raw_materials=[rm_item1])
+ second_batch_no = None
+ for row in bom.items:
+ se = make_stock_entry(
+ item_code=row.item_code,
+ qty=1,
+ target="_Test Warehouse 1 - _TC",
+ rate=300,
+ )
+
+ se.reload()
+ se1 = make_stock_entry(
+ item_code=row.item_code,
+ qty=1,
+ target="_Test Warehouse 1 - _TC",
+ rate=300,
+ )
+
+ se1.reload()
+
+ second_batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
+
+ service_items = [
+ {
+ "warehouse": "_Test Warehouse - _TC",
+ "item_code": "Subcontracted Service Item 1",
+ "qty": 1,
+ "rate": 100,
+ "fg_item": fg_item,
+ "fg_item_qty": 1,
+ },
+ ]
+ sco = get_subcontracting_order(service_items=service_items)
+ scr = make_subcontracting_receipt(sco.name)
+ scr.save()
+ scr.reload()
+
+ scr.supplied_items[0].batch_no = second_batch_no
+ scr.supplied_items[0].use_serial_batch_fields = 1
+ scr.submit()
+ scr.reload()
+
+ batch_no = get_batch_from_bundle(scr.supplied_items[0].serial_and_batch_bundle)
+ self.assertEqual(batch_no, second_batch_no)
+ self.assertEqual(scr.items[0].rm_cost_per_qty, 300)
+ self.assertEqual(scr.items[0].service_cost_per_qty, 100)
+
def make_return_subcontracting_receipt(**args):
args = frappe._dict(args)
diff --git a/pyproject.toml b/pyproject.toml
index aaac05d7ed0..d891b186d89 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,7 @@ requires = ["flit_core >=3.4,<4"]
build-backend = "flit_core.buildapi"
[tool.bench.frappe-dependencies]
-frappe = ">=15.10.0,<16.0.0"
+frappe = ">=15.40.4,<16.0.0"
[tool.ruff]
line-length = 110