diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index 9ffdf186f02..a48ce9b4c63 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -360,45 +360,45 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): ) if not amount: - return - - gl_posting_date = end_date - prev_posting_date = None - # check if books nor frozen till endate: - if accounts_frozen_upto and getdate(end_date) <= getdate(accounts_frozen_upto): - gl_posting_date = get_last_day(add_days(accounts_frozen_upto, 1)) prev_posting_date = end_date - - if via_journal_entry: - book_revenue_via_journal_entry( - doc, - credit_account, - debit_account, - amount, - base_amount, - gl_posting_date, - project, - account_currency, - item.cost_center, - item, - deferred_process, - submit_journal_entry, - ) else: - make_gl_entries( - doc, - credit_account, - debit_account, - against, - amount, - base_amount, - gl_posting_date, - project, - account_currency, - item.cost_center, - item, - deferred_process, - ) + gl_posting_date = end_date + prev_posting_date = None + # check if books nor frozen till endate: + if accounts_frozen_upto and getdate(end_date) <= getdate(accounts_frozen_upto): + gl_posting_date = get_last_day(add_days(accounts_frozen_upto, 1)) + prev_posting_date = end_date + + if via_journal_entry: + book_revenue_via_journal_entry( + doc, + credit_account, + debit_account, + amount, + base_amount, + gl_posting_date, + project, + account_currency, + item.cost_center, + item, + deferred_process, + submit_journal_entry, + ) + else: + make_gl_entries( + doc, + credit_account, + debit_account, + against, + amount, + base_amount, + gl_posting_date, + project, + account_currency, + item.cost_center, + item, + deferred_process, + ) # Returned in case of any errors because it tries to submit the same record again and again in case of errors if frappe.flags.deferred_accounting_error: diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 8ec055e28a4..3d5d030a9ee 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -196,7 +196,7 @@ frappe.ui.form.on('Payment Entry', { }, hide_unhide_fields: function(frm) { - var company_currency = frm.doc.company? frappe.get_doc(":Company", frm.doc.company).default_currency: ""; + var company_currency = frm.doc.company? frappe.get_doc(":Company", frm.doc.company)?.default_currency: ""; frm.toggle_display("source_exchange_rate", (frm.doc.paid_amount && frm.doc.paid_from_account_currency != company_currency)); diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 0fa2e7835eb..106000a78f3 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -284,6 +284,17 @@ class PaymentRequest(Document): 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 ( + self.payment_request_type == "Outward" + and payment_entry.paid_from_account_currency == ref_doc.company_currency + and payment_entry.paid_from_account_currency != payment_entry.paid_to_account_currency + ): + payment_entry.paid_amount = payment_entry.base_paid_amount = ( + payment_entry.target_exchange_rate * payment_entry.received_amount + ) + for dimension in get_accounting_dimensions(): payment_entry.update({dimension: self.get(dimension)}) diff --git a/erpnext/accounts/doctype/payment_request/test_payment_request.py b/erpnext/accounts/doctype/payment_request/test_payment_request.py index 932060895b0..6d15f84d7cf 100644 --- a/erpnext/accounts/doctype/payment_request/test_payment_request.py +++ b/erpnext/accounts/doctype/payment_request/test_payment_request.py @@ -4,10 +4,12 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase 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 +from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.setup.utils import get_exchange_rate @@ -32,7 +34,7 @@ payment_method = [ ] -class TestPaymentRequest(unittest.TestCase): +class TestPaymentRequest(FrappeTestCase): def setUp(self): if not frappe.db.get_value("Payment Gateway", payment_gateway["gateway"], "name"): frappe.get_doc(payment_gateway).insert(ignore_permissions=True) @@ -260,3 +262,19 @@ class TestPaymentRequest(unittest.TestCase): # Try to make Payment Request more than SO amount, should give validation pr2.grand_total = 900 self.assertRaises(frappe.ValidationError, pr2.save) + + def test_conversion_on_foreign_currency_accounts(self): + po_doc = create_purchase_order(supplier="_Test Supplier USD", currency="USD", do_not_submit=1) + po_doc.conversion_rate = 80 + po_doc.items[0].qty = 1 + po_doc.items[0].rate = 10 + po_doc.save().submit() + + pr = make_payment_request(dt=po_doc.doctype, dn=po_doc.name, recipient_id="nabin@erpnext.com") + pr = frappe.get_doc(pr).save().submit() + + pe = pr.create_payment_entry() + self.assertEqual(pe.base_paid_amount, 800) + self.assertEqual(pe.paid_amount, 800) + self.assertEqual(pe.base_received_amount, 800) + self.assertEqual(pe.received_amount, 10) diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json index bdbb1419784..8584cbb31cb 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.json @@ -16,6 +16,7 @@ "cost_center", "territory", "ignore_exchange_rate_revaluation_journals", + "ignore_cr_dr_notes", "column_break_14", "to_date", "finance_book", @@ -381,10 +382,16 @@ "fieldname": "ignore_exchange_rate_revaluation_journals", "fieldtype": "Check", "label": "Ignore Exchange Rate Revaluation Journals" + }, + { + "default": "0", + "fieldname": "ignore_cr_dr_notes", + "fieldtype": "Check", + "label": "Ignore System Generated Credit / Debit Notes" } ], "links": [], - "modified": "2023-12-18 12:20:08.965120", + "modified": "2024-08-13 10:41:18.381165", "modified_by": "Administrator", "module": "Accounts", "name": "Process Statement Of Accounts", diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index a602e0d7df3..078ac034eeb 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -79,6 +79,9 @@ def get_statement_dict(doc, get_statement_dict=False): if doc.ignore_exchange_rate_revaluation_journals: filters.update({"ignore_err": True}) + if doc.ignore_cr_dr_notes: + filters.update({"ignore_cr_dr_notes": True}) + if doc.report == "General Ledger": filters.update(get_gl_filters(doc, entry, tax_id, presentation_currency)) col, res = get_soa(filters) diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index a254c917189..9dd62d76fef 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1294,6 +1294,10 @@ class SalesInvoice(SellingController): if skip_change_gl_entries and payment_mode.account == self.account_for_change_amount: payment_mode.base_amount -= flt(self.change_amount) + against_voucher = self.name + if self.is_return and self.return_against and not self.update_outstanding_for_self: + against_voucher = self.return_against + if payment_mode.base_amount: # POS, make payment entries gl_entries.append( @@ -1307,7 +1311,7 @@ class SalesInvoice(SellingController): "credit_in_account_currency": payment_mode.base_amount if self.party_account_currency == self.company_currency else payment_mode.amount, - "against_voucher": self.name, + "against_voucher": against_voucher, "against_voucher_type": self.doctype, "cost_center": self.cost_center, }, diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 9e81fcf502f..bb51223db16 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -5,6 +5,7 @@ import copy import json import frappe +from frappe import qb from frappe.model.dynamic_links import get_dynamic_link_map from frappe.model.naming import make_autoname from frappe.tests.utils import FrappeTestCase, change_settings @@ -3025,6 +3026,84 @@ class TestSalesInvoice(FrappeTestCase): party_link.delete() frappe.db.set_value("Accounts Settings", None, "enable_common_party_accounting", 0) + def test_sales_invoice_against_supplier_usd_with_dimensions(self): + from erpnext.accounts.doctype.opening_invoice_creation_tool.test_opening_invoice_creation_tool import ( + make_customer, + ) + from erpnext.accounts.doctype.party_link.party_link import create_party_link + from erpnext.buying.doctype.supplier.test_supplier import create_supplier + + # create a customer + customer = make_customer(customer="_Test Common Supplier USD") + cust_doc = frappe.get_doc("Customer", customer) + cust_doc.default_currency = "USD" + cust_doc.save() + # create a supplier + supplier = create_supplier(supplier_name="_Test Common Supplier USD").name + supp_doc = frappe.get_doc("Supplier", supplier) + supp_doc.default_currency = "USD" + supp_doc.save() + + # create a party link between customer & supplier + party_link = create_party_link("Supplier", supplier, customer) + + # enable common party accounting + frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 1) + + # create a dimension and make it mandatory + if not frappe.get_all("Accounting Dimension", filters={"document_type": "Department"}): + dim = frappe.get_doc( + { + "doctype": "Accounting Dimension", + "document_type": "Department", + "dimension_defaults": [{"company": "_Test Company", "mandatory_for_bs": True}], + } + ) + dim.save() + else: + dim = frappe.get_doc( + "Accounting Dimension", + frappe.get_all("Accounting Dimension", filters={"document_type": "Department"})[0], + ) + dim.disabled = False + dim.dimension_defaults = [] + dim.append("dimension_defaults", {"company": "_Test Company", "mandatory_for_bs": True}) + dim.save() + + # create a sales invoice + si = create_sales_invoice( + customer=customer, parent_cost_center="_Test Cost Center - _TC", do_not_submit=True + ) + si.department = "All Departments" + si.save().submit() + + # check outstanding of sales invoice + si.reload() + self.assertEqual(si.status, "Paid") + self.assertEqual(flt(si.outstanding_amount), 0.0) + + # check creation of journal entry + jv = frappe.get_all( + "Journal Entry Account", + { + "account": si.debit_to, + "party_type": "Customer", + "party": si.customer, + "reference_type": si.doctype, + "reference_name": si.name, + "department": "All Departments", + }, + pluck="credit_in_account_currency", + ) + + self.assertTrue(jv) + self.assertEqual(jv[0], si.grand_total) + + dim.disabled = True + dim.save() + party_link.delete() + frappe.db.set_single_value("Accounts Settings", "enable_common_party_accounting", 0) + def test_payment_statuses(self): from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry @@ -3512,6 +3591,40 @@ class TestSalesInvoice(FrappeTestCase): ] self.assertEqual(expected, actual) + def test_pos_returns_without_update_outstanding_for_self(self): + from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return + + pos_profile = make_pos_profile() + pos_profile.payments = [] + pos_profile.append("payments", {"default": 1, "mode_of_payment": "Cash"}) + pos_profile.save() + + pos = create_sales_invoice(qty=10, do_not_save=True) + pos.is_pos = 1 + pos.pos_profile = pos_profile.name + pos.append( + "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 500} + ) + pos.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 500}) + pos.save().submit() + + pos_return = make_sales_return(pos.name) + pos_return.update_outstanding_for_self = False + pos_return.save().submit() + + gle = qb.DocType("GL Entry") + res = ( + qb.from_(gle) + .select(gle.against_voucher) + .distinct() + .where( + gle.is_cancelled.eq(0) & gle.voucher_no.eq(pos_return.name) & gle.against_voucher.notnull() + ) + .run(as_list=1) + ) + self.assertEqual(len(res), 1) + self.assertEqual(res[0][0], pos_return.return_against) + def check_gl_entries(doc, voucher_no, expected_gle, posting_date): gl_entries = frappe.db.sql( diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js index 60981f4b1d1..caaa60b5757 100644 --- a/erpnext/accounts/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -37,6 +37,12 @@ frappe.ui.form.on("Subscription", { frm.add_custom_button(__("Fetch Subscription Updates"), () => frm.events.get_subscription_updates(frm) ); + + frm.add_custom_button( + __("Force-Fetch Subscription Updates"), + () => frm.trigger("force_fetch_subscription_updates"), + __("Actions") + ); } else if (frm.doc.status === "Cancelled") { frm.add_custom_button(__("Restart Subscription"), () => frm.events.renew_this_subscription(frm) @@ -96,4 +102,11 @@ frappe.ui.form.on("Subscription", { }, }); }, + force_fetch_subscription_updates: function (frm) { + frm.call("force_fetch_subscription_updates").then((r) => { + if (!r.exec) { + frm.reload_doc(); + } + }); + }, }); diff --git a/erpnext/accounts/doctype/subscription/subscription.py b/erpnext/accounts/doctype/subscription/subscription.py index fea2ae9bf8e..96c97659ad8 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -674,6 +674,31 @@ class Subscription(Document): if invoice: return invoice.precision("grand_total") + @frappe.whitelist() + def force_fetch_subscription_updates(self): + """ + Process Subscription and create Invoices even if current date doesn't lie between current_invoice_start and currenct_invoice_end + It makes use of 'Proces Subscription' to force processing in a specific 'posting_date' + """ + + # Don't process future subscriptions + if nowdate() < self.current_invoice_start: + frappe.msgprint(_("Subscription for Future dates cannot be processed.")) + return + + processing_date = None + if self.generate_invoice_at == "Beginning of the current subscription period": + processing_date = self.current_invoice_start + elif self.generate_invoice_at == "End of the current subscription period": + processing_date = self.current_invoice_end + elif self.generate_invoice_at == "Days before the current subscription period": + processing_date = add_days(self.current_invoice_start, -self.number_of_days) + + process_subscription = frappe.new_doc("Process Subscription") + process_subscription.posting_date = processing_date + process_subscription.subscription = self.name + process_subscription.save().submit() + def get_calendar_months(billing_interval): calendar_months = [] diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index 2f0b87e9ea2..26dcb2413f4 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -712,3 +712,18 @@ class TestSubscription(FrappeTestCase): self.assertEqual(pi.total, 55333.33) subscription.delete() + + def test_future_subscription(self): + """Force-Fetch should not process future subscriptions""" + subscription = frappe.new_doc("Subscription") + subscription.party_type = "Customer" + subscription.party = "_Test Customer" + subscription.generate_invoice_at_period_start = 1 + subscription.generate_new_invoices_past_due_date = 1 + subscription.start_date = add_months(nowdate(), 1) + subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1}) + subscription.save() + + subscription.force_fetch_subscription_updates() + subscription.reload() + self.assertEqual(len(subscription.invoices), 0) diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py index f8a965b699c..4ca65dc04e5 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py @@ -279,3 +279,79 @@ class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin): {"key": "aug_2021", "total": 0, "actual": 0}, ] self.assertEqual(report.period_total, expected) + + @change_settings( + "Accounts Settings", + {"book_deferred_entries_based_on": "Months", "book_deferred_entries_via_journal_entry": 0}, + ) + def test_zero_amount(self): + self.create_item("_Test Office Desk", 0, self.warehouse, self.company) + item = frappe.get_doc("Item", self.item) + item.enable_deferred_expense = 1 + item.item_defaults[0].deferred_expense_account = self.deferred_expense_account + item.no_of_months_exp = 12 + item.save() + + pi = make_purchase_invoice( + item=self.item, + company=self.company, + supplier=self.supplier, + is_return=False, + update_stock=False, + posting_date=frappe.utils.datetime.date(2021, 12, 30), + parent_cost_center=self.cost_center, + cost_center=self.cost_center, + do_not_save=True, + rate=3910, + price_list_rate=3910, + warehouse=self.warehouse, + qty=1, + ) + pi.set_posting_time = True + pi.items[0].enable_deferred_expense = 1 + pi.items[0].service_start_date = "2021-12-30" + pi.items[0].service_end_date = "2022-12-30" + pi.items[0].deferred_expense_account = self.deferred_expense_account + pi.items[0].expense_account = self.expense_account + pi.save() + pi.submit() + + pda = frappe.get_doc( + doctype="Process Deferred Accounting", + posting_date=nowdate(), + start_date="2022-01-01", + end_date="2022-01-31", + type="Expense", + company=self.company, + ) + pda.insert() + pda.submit() + + # execute report + fiscal_year = frappe.get_doc("Fiscal Year", get_fiscal_year(date="2022-01-31")) + self.filters = frappe._dict( + { + "company": self.company, + "filter_based_on": "Date Range", + "period_start_date": "2022-01-01", + "period_end_date": "2022-01-31", + "from_fiscal_year": fiscal_year.year, + "to_fiscal_year": fiscal_year.year, + "periodicity": "Monthly", + "type": "Expense", + "with_upcoming_postings": False, + } + ) + + report = Deferred_Revenue_and_Expense_Report(filters=self.filters) + report.run() + + # fetch the invoice from deferred invoices list + inv = [d for d in report.deferred_invoices if d.name == pi.name] + # make sure the list isn't empty + self.assertTrue(inv) + # calculate the total deferred expense for the period + inv = inv[0].calculate_invoice_revenue_expense_for_period() + deferred_exp = sum([inv[idx].actual for idx in range(len(report.period_list))]) + # make sure the total deferred expense is greater than 0 + self.assertLess(deferred_exp, 0) diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 05bbc4a79c9..b7f035b1e80 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -246,7 +246,10 @@ def get_conditions(filters): as_list=True, ) if system_generated_cr_dr_journals: - filters.update({"voucher_no_not_in": [x[0] for x in system_generated_cr_dr_journals]}) + vouchers_to_ignore = (filters.get("voucher_no_not_in") or []) + [ + x[0] for x in system_generated_cr_dr_journals + ] + filters.update({"voucher_no_not_in": vouchers_to_ignore}) if filters.get("voucher_no_not_in"): conditions.append("voucher_no not in %(voucher_no_not_in)s") diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index a9494cf6e80..ed4a4f52b8f 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2312,6 +2312,15 @@ class AccountsController(TransactionBase): advance_entry.cost_center = self.cost_center or erpnext.get_default_cost_center(self.company) advance_entry.is_advance = "Yes" + # update dimesions + dimensions_dict = frappe._dict() + active_dimensions = get_dimensions()[0] + for dim in active_dimensions: + dimensions_dict[dim.fieldname] = self.get(dim.fieldname) + + reconcilation_entry.update(dimensions_dict) + advance_entry.update(dimensions_dict) + if self.doctype == "Sales Invoice": reconcilation_entry.credit_in_account_currency = self.outstanding_amount advance_entry.debit_in_account_currency = self.outstanding_amount diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index a0301f9ea0a..c7b0304203c 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -27,7 +27,26 @@ from erpnext.utilities.transaction_base import TransactionBase class Opportunity(TransactionBase, CRMNote): def onload(self): ref_doc = frappe.get_doc(self.opportunity_from, self.party_name) + load_address_and_contact(ref_doc) + load_address_and_contact(self) + + ref_doc_contact_list = ref_doc.get("__onload").get("contact_list") + opportunity_doc_contact_list = [ + contact + for contact in self.get("__onload").get("contact_list") + if contact not in ref_doc_contact_list + ] + ref_doc_contact_list.extend(opportunity_doc_contact_list) + ref_doc.set_onload("contact_list", ref_doc_contact_list) + + ref_doc_addr_list = ref_doc.get("__onload").get("addr_list") + opportunity_doc_addr_list = [ + addr for addr in self.get("__onload").get("addr_list") if addr not in ref_doc_addr_list + ] + ref_doc_addr_list.extend(opportunity_doc_addr_list) + ref_doc.set_onload("addr_list", ref_doc_addr_list) + self.set("__onload", ref_doc.get("__onload")) def after_insert(self): diff --git a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py index 5a36c999747..2e8ae7e340e 100644 --- a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py +++ b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py @@ -108,7 +108,9 @@ class OpportunitySummaryBySalesStage: self.grouped_data = [] grouping_key = lambda o: (o["sales_stage"], o[based_on]) # noqa - for (sales_stage, _based_on), rows in groupby(self.query_result, grouping_key): + for (sales_stage, _based_on), rows in groupby( + sorted(self.query_result, key=grouping_key), key=grouping_key + ): self.grouped_data.append( { "sales_stage": sales_stage, diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py index e5d34231a87..9c19e9f0148 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py @@ -122,7 +122,9 @@ class SalesPipelineAnalytics: self.grouped_data = [] grouping_key = lambda o: (o.get(self.pipeline_by) or "Not Assigned", o[self.period_by]) # noqa - for (pipeline_by, period_by), rows in groupby(self.query_result, grouping_key): + for (pipeline_by, period_by), rows in groupby( + sorted(self.query_result, key=grouping_key), grouping_key + ): self.grouped_data.append( { self.pipeline_by: pipeline_by, diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 1f6363d56cb..3e17a07c5c1 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -357,6 +357,7 @@ erpnext.patches.v14_0.clear_reconciliation_values_from_singles erpnext.patches.v14_0.update_total_asset_cost_field erpnext.patches.v14_0.create_accounting_dimensions_in_reconciliation_tool erpnext.patches.v14_0.update_flag_for_return_invoices #2024-03-22 +erpnext.patches.v14_0.update_pos_return_ledger_entries # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger erpnext.stock.doctype.delivery_note.patches.drop_unused_return_against_index # 2023-12-20 diff --git a/erpnext/patches/v14_0/update_pos_return_ledger_entries.py b/erpnext/patches/v14_0/update_pos_return_ledger_entries.py new file mode 100644 index 00000000000..60e867d1bcb --- /dev/null +++ b/erpnext/patches/v14_0/update_pos_return_ledger_entries.py @@ -0,0 +1,61 @@ +import frappe +from frappe import qb + + +def execute(): + sinv = qb.DocType("Sales Invoice") + pos_returns_without_self = ( + qb.from_(sinv) + .select(sinv.name) + .where( + sinv.docstatus.eq(1) + & sinv.is_pos.eq(1) + & sinv.is_return.eq(1) + & sinv.return_against.notnull() + & sinv.update_outstanding_for_self.eq(0) + ) + .run() + ) + if pos_returns_without_self: + pos_returns_without_self = [x[0] for x in pos_returns_without_self] + + gle = qb.DocType("GL Entry") + gl_against_references = ( + qb.from_(gle) + .select(gle.voucher_no, gle.against_voucher) + .where( + gle.voucher_no.isin(pos_returns_without_self) + & gle.against_voucher.notnull() + & gle.against_voucher.eq(gle.voucher_no) + & gle.is_cancelled.eq(0) + ) + .run() + ) + + _vouchers = list(set([x[0] for x in gl_against_references])) + invoice_return_against = ( + qb.from_(sinv) + .select(sinv.name, sinv.return_against) + .where(sinv.name.isin(_vouchers) & sinv.return_against.notnull()) + .orderby(sinv.name) + .run() + ) + + valid_references = set(invoice_return_against) + actual_references = set(gl_against_references) + + invalid_references = actual_references.difference(valid_references) + + if invalid_references: + # Repost Accounting Ledger + pos_for_reposting = ( + qb.from_(sinv) + .select(sinv.company, sinv.name) + .where(sinv.name.isin([x[0] for x in invalid_references])) + .run(as_dict=True) + ) + for x in pos_for_reposting: + ral = frappe.new_doc("Repost Accounting Ledger") + ral.company = x.company + ral.append("vouchers", {"voucher_type": "Sales Invoice", "voucher_no": x.name}) + ral.save().submit() diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index bf7e5b9e48f..98c1434e4d0 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -1139,6 +1139,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe "Sales Invoice Item": ["dn_detail", "so_detail", "sales_invoice_item"], "Purchase Receipt Item": ["purchase_order_item", "purchase_invoice_item", "purchase_receipt_item"], "Purchase Invoice Item": ["purchase_order_item", "pr_detail", "po_detail"], + "Sales Order Item": ["prevdoc_docname", "quotation_item"], }; const mappped_fields = mapped_item_field_map[item.doctype] || []; diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.js b/erpnext/selling/page/sales_funnel/sales_funnel.js index 954705b5c32..d9a0be9eedd 100644 --- a/erpnext/selling/page/sales_funnel/sales_funnel.js +++ b/erpnext/selling/page/sales_funnel/sales_funnel.js @@ -248,7 +248,7 @@ erpnext.SalesFunnel = class SalesFunnel { context.fill(); // draw text - context.fillStyle = ""; + context.fillStyle = getComputedStyle(document.body).getPropertyValue("--text-color"); context.textBaseline = "middle"; context.font = "1.1em sans-serif"; context.fillText(__(title), width + 20, y_mid); diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.py b/erpnext/selling/page/sales_funnel/sales_funnel.py index 24bb0d2fe71..73deb8fe66b 100644 --- a/erpnext/selling/page/sales_funnel/sales_funnel.py +++ b/erpnext/selling/page/sales_funnel/sales_funnel.py @@ -93,7 +93,7 @@ def get_opp_by_lead_source(from_date, to_date, company): summary = {} sales_stages = set() group_key = lambda o: (o["source"], o["sales_stage"]) # noqa - for (source, sales_stage), rows in groupby(cp_opportunities, group_key): + for (source, sales_stage), rows in groupby(sorted(cp_opportunities, key=group_key), group_key): summary.setdefault(source, {})[sales_stage] = sum(r["compound_amount"] for r in rows) sales_stages.add(sales_stage) diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 2a5e527f591..aa25d625ffe 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -837,7 +837,8 @@ def create_delivery_note(source_name, target_doc=None): ) ) - for customer, rows in groupby(sales_orders, key=lambda so: so["customer"]): + group_key = lambda so: so["customer"] # noqa + for customer, rows in groupby(sorted(sales_orders, key=group_key), key=group_key): sales_dict[customer] = {row.sales_order for row in rows} if sales_dict: diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 2864c00864b..8df785a0c1f 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -4,10 +4,10 @@ import json import frappe -from frappe import _ +from frappe import _, bold from frappe.model.meta import get_field_precision from frappe.query_builder.functions import Sum -from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate +from frappe.utils import cint, cstr, flt, format_date, get_link_to_form, getdate, now, nowdate import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty @@ -542,11 +542,31 @@ class update_entries_after: return self.distinct_item_warehouses[key].dependent_voucher_detail_nos + def validate_previous_sle_qty(self, sle): + previous_sle = self.data[sle.warehouse].previous_sle + if previous_sle and previous_sle.get("qty_after_transaction") < 0 and sle.get("actual_qty") > 0: + frappe.msgprint( + _( + "The stock for the item {0} in the {1} warehouse was negative on the {2}. You should create a positive entry {3} before the date {4} and time {5} to post the correct valuation rate. For more details, please read the documentation." + ).format( + bold(sle.item_code), + bold(sle.warehouse), + bold(format_date(previous_sle.posting_date)), + sle.voucher_no, + bold(format_date(previous_sle.posting_date)), + bold(previous_sle.posting_time), + ), + title=_("Warning on Negative Stock"), + indicator="blue", + ) + def process_sle(self, sle): from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos # previous sle data for this warehouse self.wh_data = self.data[sle.warehouse] + + self.validate_previous_sle_qty(sle) self.affected_transactions.add((sle.voucher_type, sle.voucher_no)) if (sle.serial_no and not self.via_landed_cost_voucher) or not cint(self.allow_negative_stock): diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index 0905c06896c..e93fd851fa0 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -1351,7 +1351,7 @@ Lead to Quotation,Vom Lead zum Angebot, Learn,Lernen, Leave Management,Urlaube verwalten, Leave and Attendance,Urlaub und Anwesenheit, -Leave application {0} already exists against the student {1},Verlassen der Anwendung {0} ist bereits für den Schüler {1} vorhanden, +Leave application {0} already exists against the student {1},Abwesenheitsantrag {0} existiert bereits für den Schüler {1}, Leaves has been granted sucessfully,Urlaub wurde genehmigt, Leaves must be allocated in multiples of 0.5,"Abwesenheiten müssen ein Vielfaches von 0,5 sein", Ledger,Hauptbuch, @@ -5655,10 +5655,10 @@ Guardian Details,Erziehungsberechtigten-Details, Guardians,Erziehungsberechtigte, Sibling Details,Geschwister-Details, Siblings,Geschwister, -Exit,Verlassen, +Exit,Austritt, Date of Leaving,Austrittsdatum, Leaving Certificate Number,Leaving Certificate Nummer, -Reason For Leaving,Grund für das Verlassen, +Reason For Leaving,Grund für den Austritt, Student Admission,Studenten Eintritt, Admission Start Date,Stichtag zum Zulassungsbeginn, Admission End Date,Stichtag für Zulassungsende,