diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py b/erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js new file mode 100644 index 00000000000..0e36b3fe3d2 --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js @@ -0,0 +1,84 @@ +// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +function get_filters() { + let filters = [ + { + "fieldname":"company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + }, + { + "fieldname":"period_start_date", + "label": __("Start Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1) + }, + { + "fieldname":"period_end_date", + "label": __("End Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.get_today() + }, + { + "fieldname":"sales_order", + "label": __("Sales Order"), + "fieldtype": "MultiSelectList", + "width": 100, + "options": "Sales Order", + "get_data": function(txt) { + return frappe.db.get_link_options("Sales Order", txt, this.filters()); + }, + "filters": () => { + return { + docstatus: 1, + payment_terms_template: ['not in', ['']], + company: frappe.query_report.get_filter_value("company"), + transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]] + } + }, + on_change: function(){ + frappe.query_report.refresh(); + } + } + ] + + return filters; +} + +frappe.query_reports["Payment Terms Status for Sales Order"] = { + "filters": get_filters(), + "formatter": function(value, row, column, data, default_formatter){ + if(column.fieldname == 'invoices' && value) { + invoices = value.split(','); + const invoice_formatter = (prev_value, curr_value) => { + if(prev_value != "") { + return prev_value + ", " + default_formatter(curr_value, row, column, data); + } + else { + return default_formatter(curr_value, row, column, data); + } + } + return invoices.reduce(invoice_formatter, "") + } + else if (column.fieldname == 'paid_amount' && value){ + formatted_value = default_formatter(value, row, column, data); + if(value > 0) { + formatted_value = "" + formatted_value + "" + } + return formatted_value; + } + else if (column.fieldname == 'status' && value == 'Completed'){ + return "" + default_formatter(value, row, column, data) + ""; + } + + return default_formatter(value, row, column, data); + }, + +}; diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json new file mode 100644 index 00000000000..850fa4dc47a --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json @@ -0,0 +1,38 @@ +{ + "add_total_row": 1, + "columns": [], + "creation": "2021-12-28 10:39:34.533964", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-12-30 10:42:06.058457", + "modified_by": "Administrator", + "module": "Selling", + "name": "Payment Terms Status for Sales Order", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Sales Order", + "report_name": "Payment Terms Status for Sales Order", + "report_type": "Script Report", + "roles": [ + { + "role": "Sales User" + }, + { + "role": "Sales Manager" + }, + { + "role": "Maintenance User" + }, + { + "role": "Accounts User" + }, + { + "role": "Stock User" + } + ] +} \ No newline at end of file diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py new file mode 100644 index 00000000000..e6a56eea310 --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -0,0 +1,205 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE + +import frappe +from frappe import _, qb, query_builder +from frappe.query_builder import functions + + +def get_columns(): + columns = [ + { + "label": _("Sales Order"), + "fieldname": "name", + "fieldtype": "Link", + "options": "Sales Order", + }, + { + "label": _("Posting Date"), + "fieldname": "submitted", + "fieldtype": "Date", + }, + { + "label": _("Payment Term"), + "fieldname": "payment_term", + "fieldtype": "Data", + }, + { + "label": _("Description"), + "fieldname": "description", + "fieldtype": "Data", + }, + { + "label": _("Due Date"), + "fieldname": "due_date", + "fieldtype": "Date", + }, + { + "label": _("Invoice Portion"), + "fieldname": "invoice_portion", + "fieldtype": "Percent", + }, + { + "label": _("Payment Amount"), + "fieldname": "base_payment_amount", + "fieldtype": "Currency", + "options": "currency", + }, + { + "label": _("Paid Amount"), + "fieldname": "paid_amount", + "fieldtype": "Currency", + "options": "currency", + }, + { + "label": _("Invoices"), + "fieldname": "invoices", + "fieldtype": "Link", + "options": "Sales Invoice", + }, + { + "label": _("Status"), + "fieldname": "status", + "fieldtype": "Data", + }, + { + "label": _("Currency"), + "fieldname": "currency", + "fieldtype": "Currency", + "hidden": 1 + } + ] + return columns + + +def get_conditions(filters): + """ + Convert filter options to conditions used in query + """ + filters = frappe._dict(filters) if filters else frappe._dict({}) + conditions = frappe._dict({}) + + conditions.company = filters.company or frappe.defaults.get_user_default("company") + conditions.end_date = filters.period_end_date or frappe.utils.today() + conditions.start_date = filters.period_start_date or frappe.utils.add_months( + conditions.end_date, -1 + ) + conditions.sales_order = filters.sales_order or [] + + return conditions + + +def get_so_with_invoices(filters): + """ + Get Sales Order with payment terms template with their associated Invoices + """ + sorders = [] + + so = qb.DocType("Sales Order") + ps = qb.DocType("Payment Schedule") + datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"]) + ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"]) + + conditions = get_conditions(filters) + query_so = ( + qb.from_(so) + .join(ps) + .on(ps.parent == so.name) + .select( + so.name, + so.transaction_date.as_("submitted"), + ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"), + ps.payment_term, + ps.description, + ps.due_date, + ps.invoice_portion, + ps.base_payment_amount, + ps.paid_amount, + ) + .where( + (so.docstatus == 1) + & (so.payment_terms_template != "NULL") + & (so.company == conditions.company) + & (so.transaction_date[conditions.start_date : conditions.end_date]) + ) + .orderby(so.name, so.transaction_date, ps.due_date) + ) + + if conditions.sales_order != []: + query_so = query_so.where(so.name.isin(conditions.sales_order)) + + sorders = query_so.run(as_dict=True) + + invoices = [] + if sorders != []: + soi = qb.DocType("Sales Order Item") + si = qb.DocType("Sales Invoice") + sii = qb.DocType("Sales Invoice Item") + query_inv = ( + qb.from_(sii) + .right_join(si) + .on(si.name == sii.parent) + .inner_join(soi) + .on(soi.name == sii.so_detail) + .select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount")) + .where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1)) + .groupby(sii.parent) + ) + invoices = query_inv.run(as_dict=True) + + return sorders, invoices + + +def set_payment_terms_statuses(sales_orders, invoices, filters): + """ + compute status for payment terms with associated sales invoice using FIFO + """ + + for so in sales_orders: + so.currency = frappe.get_cached_value('Company', filters.get('company'), 'default_currency') + so.invoices = "" + for inv in [x for x in invoices if x.sales_order == so.name and x.invoice_amount > 0]: + if so.base_payment_amount - so.paid_amount > 0: + amount = so.base_payment_amount - so.paid_amount + if inv.invoice_amount >= amount: + inv.invoice_amount -= amount + so.paid_amount += amount + so.invoices += "," + inv.invoice + so.status = "Completed" + break + else: + so.paid_amount += inv.invoice_amount + inv.invoice_amount = 0 + so.invoices += "," + inv.invoice + so.status = "Partly Paid" + + return sales_orders, invoices + + +def prepare_chart(s_orders): + if len(set([x.name for x in s_orders])) == 1: + chart = { + "data": { + "labels": [term.payment_term for term in s_orders], + "datasets": [ + {"name": "Payment Amount", "values": [x.base_payment_amount for x in s_orders],}, + {"name": "Paid Amount", "values": [x.paid_amount for x in s_orders],}, + ], + }, + "type": "bar", + } + return chart + + +def execute(filters=None): + columns = get_columns() + sales_orders, so_invoices = get_so_with_invoices(filters) + sales_orders, so_invoices = set_payment_terms_statuses(sales_orders, so_invoices, filters) + + prepare_chart(sales_orders) + + data = sales_orders + message = [] + chart = prepare_chart(sales_orders) + + return columns, data, message, chart diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py new file mode 100644 index 00000000000..cad41e1dc03 --- /dev/null +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py @@ -0,0 +1,198 @@ +import datetime + +import frappe +from frappe.utils import add_days + +from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import ( + execute, +) +from erpnext.stock.doctype.item.test_item import create_item +from erpnext.tests.utils import ERPNextTestCase + +test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"] + + +class TestPaymentTermsStatusForSalesOrder(ERPNextTestCase): + def create_payment_terms_template(self): + # create template for 50-50 payments + template = None + if frappe.db.exists("Payment Terms Template", "_Test 50-50"): + template = frappe.get_doc("Payment Terms Template", "_Test 50-50") + else: + template = frappe.get_doc( + { + "doctype": "Payment Terms Template", + "template_name": "_Test 50-50", + "terms": [ + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term_name": "_Test 50% on 15 Days", + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 15, + }, + { + "doctype": "Payment Terms Template Detail", + "due_date_based_on": "Day(s) after invoice date", + "payment_term_name": "_Test 50% on 30 Days", + "description": "_Test 50-50", + "invoice_portion": 50, + "credit_days": 30, + }, + ], + } + ) + template.insert() + self.template = template + + def test_payment_terms_status(self): + self.create_payment_terms_template() + item = create_item(item_code="_Test Excavator", is_stock_item=0) + so = make_sales_order( + transaction_date="2021-06-15", + delivery_date=add_days("2021-06-15", -30), + item=item.item_code, + qty=10, + rate=100000, + do_not_save=True, + ) + so.po_no = "" + so.taxes_and_charges = "" + so.taxes = "" + so.payment_terms_template = self.template.name + so.save() + so.submit() + + # make invoice with 60% of the total sales order value + sinv = make_sales_invoice(so.name) + sinv.taxes_and_charges = "" + sinv.taxes = "" + sinv.items[0].qty = 6 + sinv.insert() + sinv.submit() + columns, data, message, chart = execute( + { + "company": "_Test Company", + "period_start_date": "2021-06-01", + "period_end_date": "2021-06-30", + "sales_order": [so.name], + } + ) + + expected_value = [ + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Completed", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 6, 30), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 500000.0, + "paid_amount": 500000.0, + "invoices": ","+sinv.name, + }, + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Partly Paid", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 7, 15), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 500000.0, + "paid_amount": 100000.0, + "invoices": ","+sinv.name, + }, + ] + self.assertEqual(data, expected_value) + + def create_exchange_rate(self, date): + # make an entry in Currency Exchange list. serves as a static exchange rate + if frappe.db.exists({'doctype': "Currency Exchange",'date': date,'from_currency': 'USD', 'to_currency':'INR'}): + return + else: + doc = frappe.get_doc({ + 'doctype': "Currency Exchange", + 'date': date, + 'from_currency': 'USD', + 'to_currency': frappe.get_cached_value("Company", '_Test Company','default_currency'), + 'exchange_rate': 70, + 'for_buying': True, + 'for_selling': True + }) + doc.insert() + + def test_alternate_currency(self): + transaction_date = "2021-06-15" + self.create_payment_terms_template() + self.create_exchange_rate(transaction_date) + item = create_item(item_code="_Test Excavator", is_stock_item=0) + so = make_sales_order( + transaction_date=transaction_date, + currency="USD", + delivery_date=add_days(transaction_date, -30), + item=item.item_code, + qty=10, + rate=10000, + do_not_save=True, + ) + so.po_no = "" + so.taxes_and_charges = "" + so.taxes = "" + so.payment_terms_template = self.template.name + so.save() + so.submit() + + # make invoice with 60% of the total sales order value + sinv = make_sales_invoice(so.name) + sinv.currency = "USD" + sinv.taxes_and_charges = "" + sinv.taxes = "" + sinv.items[0].qty = 6 + sinv.insert() + sinv.submit() + columns, data, message, chart = execute( + { + "company": "_Test Company", + "period_start_date": "2021-06-01", + "period_end_date": "2021-06-30", + "sales_order": [so.name], + } + ) + + # report defaults to company currency. + expected_value = [ + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Completed", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 6, 30), + "invoice_portion": 50.0, + "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'), + "base_payment_amount": 3500000.0, + "paid_amount": 3500000.0, + "invoices": ","+sinv.name, + }, + { + "name": so.name, + "submitted": datetime.date(2021, 6, 15), + "status": "Partly Paid", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 7, 15), + "invoice_portion": 50.0, + "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'), + "base_payment_amount": 3500000.0, + "paid_amount": 700000.0, + "invoices": ","+sinv.name, + }, + ] + self.assertEqual(data, expected_value)