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