diff --git a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py index e24c7c9409f..e7dab34d04a 100644 --- a/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py +++ b/erpnext/accounts/doctype/chart_of_accounts_importer/chart_of_accounts_importer.py @@ -47,9 +47,11 @@ def validate_columns(data): no_of_columns = max([len(d) for d in data]) - if no_of_columns > 8: + if no_of_columns != 8: frappe.throw( - _("More columns found than expected. Please compare the uploaded file with standard template"), + _( + "Columns are not according to template. Please compare the uploaded file with standard template" + ), title=(_("Wrong Template")), ) diff --git a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js index 741039acae6..eeda531c4d6 100644 --- a/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js +++ b/erpnext/accounts/doctype/exchange_rate_revaluation/exchange_rate_revaluation.js @@ -85,18 +85,16 @@ frappe.ui.form.on("Exchange Rate Revaluation", { }, make_jv: function (frm) { - let revaluation_journal = null; - let zero_balance_journal = null; frappe.call({ method: "make_jv_entries", doc: frm.doc, freeze: true, - freeze_message: "Making Journal Entries...", + freeze_message: __("Creating Journal Entries..."), callback: function (r) { if (r.message) { let response = r.message; if (response["revaluation_jv"] || response["zero_balance_jv"]) { - frappe.msgprint(__("Journals have been created")); + frappe.msgprint(__("Journal entries have been created")); } } }, diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index d9a84431bbd..c28dcf525df 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -230,7 +230,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 + ? frappe.get_doc(":Company", frm.doc.company)?.default_currency : ""; frm.toggle_display( diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py index 48bb6f2ae86..a2c6a9d856a 100644 --- a/erpnext/accounts/doctype/payment_request/payment_request.py +++ b/erpnext/accounts/doctype/payment_request/payment_request.py @@ -338,6 +338,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 ae6059c0057..22be5299280 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", @@ -383,10 +384,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 e0ec144e314..509199ccae6 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 @@ -54,6 +54,7 @@ class ProcessStatementOfAccounts(Document): frequency: DF.Literal["Weekly", "Monthly", "Quarterly"] from_date: DF.Date | None group_by: DF.Literal["", "Group by Voucher", "Group by Voucher (Consolidated)"] + ignore_cr_dr_notes: DF.Check ignore_exchange_rate_revaluation_journals: DF.Check include_ageing: DF.Check include_break: DF.Check @@ -133,6 +134,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/purchase_invoice_item/purchase_invoice_item.json b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json index 1d7b0c2f461..8a2ba36cf62 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.json @@ -57,6 +57,7 @@ "base_net_rate", "base_net_amount", "valuation_rate", + "sales_incoming_rate", "item_tax_amount", "landed_cost_voucher_amount", "rm_supp_cost", @@ -958,12 +959,22 @@ "print_hide": 1, "read_only": 1, "search_index": 1 + }, + { + "description": "Valuation rate for the item as per Sales Invoice (Only for Internal Transfers)", + "fieldname": "sales_incoming_rate", + "fieldtype": "Currency", + "hidden": 1, + "label": "Sales Incoming Rate", + "no_copy": 1, + "options": "Company:company:default_currency", + "print_hide": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-06-14 11:57:07.171700", + "modified": "2024-07-19 12:12:42.449298", "modified_by": "Administrator", "module": "Accounts", "name": "Purchase Invoice Item", diff --git a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py index baeece4815c..a8f844c6c1c 100644 --- a/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py +++ b/erpnext/accounts/doctype/purchase_invoice_item/purchase_invoice_item.py @@ -79,6 +79,7 @@ class PurchaseInvoiceItem(Document): rejected_serial_no: DF.Text | None rejected_warehouse: DF.Link | None rm_supp_cost: DF.Currency + sales_incoming_rate: DF.Currency sales_invoice_item: DF.Data | None serial_and_batch_bundle: DF.Link | None serial_no: DF.Text | None diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 24e61b0d221..5d6c8a353fb 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1316,6 +1316,10 @@ class SalesInvoice(SellingController): for item in self.get("items"): if flt(item.base_net_amount, item.precision("base_net_amount")): + # Do not book income for transfer within same company + if self.is_internal_transfer(): + continue + if item.is_fixed_asset: asset = self.get_asset(item) @@ -1374,37 +1378,33 @@ class SalesInvoice(SellingController): self.set_asset_status(asset) else: - # Do not book income for transfer within same company - if not self.is_internal_transfer(): - income_account = ( - item.income_account - if (not item.enable_deferred_revenue or self.is_return) - else item.deferred_revenue_account - ) + income_account = ( + item.income_account + if (not item.enable_deferred_revenue or self.is_return) + else item.deferred_revenue_account + ) - amount, base_amount = self.get_amount_and_base_amount( - item, enable_discount_accounting - ) + amount, base_amount = self.get_amount_and_base_amount(item, enable_discount_accounting) - account_currency = get_account_currency(income_account) - gl_entries.append( - self.get_gl_dict( - { - "account": income_account, - "against": self.customer, - "credit": flt(base_amount, item.precision("base_net_amount")), - "credit_in_account_currency": ( - flt(base_amount, item.precision("base_net_amount")) - if account_currency == self.company_currency - else flt(amount, item.precision("net_amount")) - ), - "cost_center": item.cost_center, - "project": item.project or self.project, - }, - account_currency, - item=item, - ) + account_currency = get_account_currency(income_account) + gl_entries.append( + self.get_gl_dict( + { + "account": income_account, + "against": self.customer, + "credit": flt(base_amount, item.precision("base_net_amount")), + "credit_in_account_currency": ( + flt(base_amount, item.precision("base_net_amount")) + if account_currency == self.company_currency + else flt(amount, item.precision("net_amount")) + ), + "cost_center": item.cost_center, + "project": item.project or self.project, + }, + account_currency, + item=item, ) + ) # expense account gl entries if cint(self.update_stock) and erpnext.is_perpetual_inventory_enabled(self.company): @@ -1479,6 +1479,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( @@ -1492,7 +1496,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 9197f2e091a..fef30bdfecd 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.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, flt, getdate, nowdate, today @@ -3083,6 +3084,84 @@ class TestSalesInvoice(FrappeTestCase): party_link.delete() frappe.db.set_single_value("Accounts Settings", "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 @@ -3758,6 +3837,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 set_advance_flag(company, flag, default_account): frappe.db.set_value( diff --git a/erpnext/accounts/doctype/subscription/subscription.js b/erpnext/accounts/doctype/subscription/subscription.js index b3e21abcaae..629d118080a 100644 --- a/erpnext/accounts/doctype/subscription/subscription.js +++ b/erpnext/accounts/doctype/subscription/subscription.js @@ -38,6 +38,12 @@ frappe.ui.form.on("Subscription", { __("Actions") ); + frm.add_custom_button( + __("Force-Fetch Subscription Updates"), + () => frm.trigger("force_fetch_subscription_updates"), + __("Actions") + ); + frm.add_custom_button( __("Cancel Subscription"), () => frm.trigger("cancel_this_subscription"), @@ -82,4 +88,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 a9dbf333335..916757a8d6d 100644 --- a/erpnext/accounts/doctype/subscription/subscription.py +++ b/erpnext/accounts/doctype/subscription/subscription.py @@ -717,6 +717,31 @@ class Subscription(Document): self.update_subscription_period(posting_date or nowdate()) self.save() + @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 is_prorate() -> int: return cint(frappe.db.get_single_value("Subscription Settings", "prorate")) diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index af3916ae469..8d4ec3dd084 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -521,6 +521,18 @@ class TestSubscription(FrappeTestCase): subscription.process(posting_date="2023-01-22") self.assertEqual(len(subscription.invoices), 2) + def test_future_subscription(self): + """Force-Fetch should not process future subscriptions""" + subscription = create_subscription( + start_date=add_months(nowdate(), 1), + submit_invoice=0, + generate_new_invoices_past_due_date=1, + party="_Test Subscription Customer John Doe", + ) + subscription.force_fetch_subscription_updates() + subscription.reload() + self.assertEqual(len(subscription.invoices), 0) + def make_plans(): create_plan(plan_name="_Test Plan Name", cost=900, currency="INR") diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 63e57438fca..2564eb0800f 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -248,7 +248,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/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index 586385adb0d..06f170200a8 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -234,7 +234,7 @@ class TestAsset(AssetSetup): pro_rata_amount, _, _ = _get_pro_rata_amt( asset.finance_books[0], 9000, - get_last_day(add_months(purchase_date, 1)), + add_days(get_last_day(add_months(purchase_date, 1)), 1), date, original_schedule_date=get_last_day(nowdate()), ) @@ -320,7 +320,7 @@ class TestAsset(AssetSetup): pro_rata_amount, _, _ = _get_pro_rata_amt( asset.finance_books[0], 9000, - get_last_day(add_months(purchase_date, 1)), + add_days(get_last_day(add_months(purchase_date, 1)), 1), date, original_schedule_date=get_last_day(nowdate()), ) diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py index e7b5a25cc73..bad89e93259 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py @@ -291,7 +291,9 @@ class AssetDepreciationSchedule(Document): if skip_row: continue - schedule_date = add_months(row.depreciation_start_date, n * cint(row.frequency_of_depreciation)) + schedule_date = get_last_day( + add_months(row.depreciation_start_date, n * cint(row.frequency_of_depreciation)) + ) if not current_fiscal_year_end_date: current_fiscal_year_end_date = get_fiscal_year(row.depreciation_start_date)[2] elif getdate(schedule_date) > getdate(current_fiscal_year_end_date): @@ -330,8 +332,10 @@ class AssetDepreciationSchedule(Document): getdate(asset_doc.available_for_use_date), (asset_doc.opening_number_of_booked_depreciations * row.frequency_of_depreciation), ) + if is_last_day_of_the_month(getdate(asset_doc.available_for_use_date)): + from_date = get_last_day(from_date) if self.depreciation_schedule: - from_date = self.depreciation_schedule[-1].schedule_date + from_date = add_days(self.depreciation_schedule[-1].schedule_date, 1) depreciation_amount, days, months = _get_pro_rata_amt( row, @@ -353,9 +357,8 @@ class AssetDepreciationSchedule(Document): and not self.opening_accumulated_depreciation and not self.flags.wdv_it_act_applied ): - from_date = add_days( - asset_doc.available_for_use_date, -1 - ) # needed to calc depr amount for available_for_use_date too + from_date = asset_doc.available_for_use_date + # needed to calc depr amount for available_for_use_date too depreciation_amount, days, months = _get_pro_rata_amt( row, depreciation_amount, @@ -406,6 +409,8 @@ class AssetDepreciationSchedule(Document): (n + self.opening_number_of_booked_depreciations) * cint(row.frequency_of_depreciation), ) + if is_last_day_of_the_month(getdate(asset_doc.available_for_use_date)): + asset_doc.to_date = get_last_day(asset_doc.to_date) depreciation_amount_without_pro_rata = depreciation_amount @@ -421,7 +426,7 @@ class AssetDepreciationSchedule(Document): depreciation_amount_without_pro_rata, depreciation_amount ) - schedule_date = add_days(schedule_date, days) + schedule_date = add_days(schedule_date, days - 1) if not depreciation_amount: continue @@ -504,7 +509,10 @@ class AssetDepreciationSchedule(Document): continue if not accumulated_depreciation: - if i > 0 and asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment: + if i > 0 and ( + asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment + or asset_doc.flags.increase_in_asset_value_due_to_repair + ): accumulated_depreciation = self.get("depreciation_schedule")[ i - 1 ].accumulated_depreciation_amount @@ -553,9 +561,11 @@ def _check_is_pro_rata(asset_doc, row, wdv_or_dd_non_yearly=False): # otherwise, if opening_number_of_booked_depreciations = 2, available_for_use_date = 01/01/2020 and frequency_of_depreciation = 12 # from_date = 01/01/2022 if row.depreciation_method in ("Straight Line", "Manual"): - prev_depreciation_start_date = add_months( - row.depreciation_start_date, - (row.frequency_of_depreciation * -1) * asset_doc.opening_number_of_booked_depreciations, + prev_depreciation_start_date = get_last_day( + add_months( + row.depreciation_start_date, + (row.frequency_of_depreciation * -1) * asset_doc.opening_number_of_booked_depreciations, + ) ) from_date = asset_doc.available_for_use_date days = date_diff(prev_depreciation_start_date, from_date) + 1 @@ -610,7 +620,7 @@ def _get_pro_rata_amt( has_wdv_or_dd_non_yearly_pro_rata=False, original_schedule_date=None, ): - days = date_diff(to_date, from_date) + days = date_diff(to_date, from_date) + 1 months = month_diff(to_date, from_date) if has_wdv_or_dd_non_yearly_pro_rata: total_days = get_total_days(original_schedule_date or to_date, 12) @@ -670,7 +680,7 @@ def get_straight_line_or_manual_depr_amount( # if the Depreciation Schedule is being modified after Asset Repair due to increase in asset value elif asset.flags.increase_in_asset_value_due_to_repair: return (flt(row.value_after_depreciation) - flt(row.expected_value_after_useful_life)) / flt( - row.total_number_of_depreciations + number_of_pending_depreciations ) # if the Depreciation Schedule is being modified after Asset Value Adjustment due to decrease in asset value elif asset.flags.decrease_in_asset_value_due_to_value_adjustment: @@ -1034,6 +1044,7 @@ def make_new_active_asset_depr_schedules_and_cancel_current_ones( date_of_return=None, value_after_depreciation=None, ignore_booked_entry=False, + difference_amount=None, ): for row in asset_doc.get("finance_books"): current_asset_depr_schedule_doc = get_asset_depr_schedule_doc( @@ -1048,6 +1059,8 @@ def make_new_active_asset_depr_schedules_and_cancel_current_ones( ) new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) + if asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment and not value_after_depreciation: + value_after_depreciation = row.value_after_depreciation + difference_amount if asset_doc.flags.increase_in_asset_value_due_to_repair and row.depreciation_method in ( "Written Down Value", diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 9284c86bd92..67ce6e6f7ef 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -29,6 +29,15 @@ frappe.ui.form.on("Asset Repair", { }; }); + frm.set_query("purchase_invoice", function () { + return { + filters: { + company: frm.doc.company, + docstatus: 1, + }, + }; + }); + frm.set_query("warehouse", "stock_items", function () { return { filters: { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index 903e68e32e0..4e73148828d 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -117,7 +117,9 @@ class AssetRepair(AccountsController): get_link_to_form(self.doctype, self.name), ) self.asset_doc.flags.ignore_validate_update_after_submit = True - make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes) + make_new_active_asset_depr_schedules_and_cancel_current_ones( + self.asset_doc, notes, ignore_booked_entry=True + ) self.asset_doc.save() add_asset_activity( @@ -154,7 +156,9 @@ class AssetRepair(AccountsController): get_link_to_form(self.doctype, self.name), ) self.asset_doc.flags.ignore_validate_update_after_submit = True - make_new_active_asset_depr_schedules_and_cancel_current_ones(self.asset_doc, notes) + make_new_active_asset_depr_schedules_and_cancel_current_ones( + self.asset_doc, notes, ignore_booked_entry=True + ) self.asset_doc.save() add_asset_activity( diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 0d02347f20a..9b0212b037f 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -56,7 +56,8 @@ class AssetValueAdjustment(Document): ) def on_cancel(self): - self.update_asset(self.current_asset_value) + frappe.get_doc("Journal Entry", self.journal_entry).cancel() + self.update_asset() add_asset_activity( self.asset, _("Asset's value adjusted after cancellation of Asset Value Adjustment {0}").format( @@ -144,7 +145,7 @@ class AssetValueAdjustment(Document): self.db_set("journal_entry", je.name) - def update_asset(self, asset_value): + def update_asset(self, asset_value=None): asset = frappe.get_doc("Asset", self.asset) if not asset.calculate_depreciation: @@ -170,7 +171,11 @@ class AssetValueAdjustment(Document): ) make_new_active_asset_depr_schedules_and_cancel_current_ones( - asset, notes, value_after_depreciation=asset_value, ignore_booked_entry=True + asset, + notes, + value_after_depreciation=asset_value, + ignore_booked_entry=True, + difference_amount=self.difference_amount, ) asset.flags.ignore_validate_update_after_submit = True asset.save() diff --git a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py index 634ed413773..963be704524 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/test_asset_value_adjustment.py @@ -12,6 +12,7 @@ from erpnext.assets.doctype.asset.test_asset import create_asset_data from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( get_asset_depr_schedule_doc, ) +from erpnext.assets.doctype.asset_repair.test_asset_repair import create_asset_repair from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt @@ -128,6 +129,136 @@ class TestAssetValueAdjustment(unittest.TestCase): self.assertEqual(schedules, expected_schedules) + def test_depreciation_after_cancelling_asset_repair(self): + pr = make_purchase_receipt(item_code="Macbook Pro", qty=1, rate=120000.0, location="Test Location") + + asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, "name") + asset_doc = frappe.get_doc("Asset", asset_name) + asset_doc.calculate_depreciation = 1 + asset_doc.available_for_use_date = "2023-01-15" + asset_doc.purchase_date = "2023-01-15" + + asset_doc.append( + "finance_books", + { + "expected_value_after_useful_life": 200, + "depreciation_method": "Straight Line", + "total_number_of_depreciations": 12, + "frequency_of_depreciation": 1, + "depreciation_start_date": "2023-01-31", + }, + ) + asset_doc.submit() + + post_depreciation_entries(getdate("2023-08-21")) + + # create asset repair + asset_repair = create_asset_repair(asset=asset_doc, capitalize_repair_cost=1, submit=1) + + first_asset_depr_schedule = get_asset_depr_schedule_doc(asset_doc.name, "Active") + self.assertEqual(first_asset_depr_schedule.status, "Active") + + # create asset value adjustment + current_value = get_asset_value_after_depreciation(asset_doc.name) + + adj_doc = make_asset_value_adjustment( + asset=asset_doc.name, + current_asset_value=current_value, + new_asset_value=50000.0, + date="2023-08-21", + ) + adj_doc.submit() + + first_asset_depr_schedule.load_from_db() + + second_asset_depr_schedule = get_asset_depr_schedule_doc(asset_doc.name, "Active") + self.assertEqual(second_asset_depr_schedule.status, "Active") + self.assertEqual(first_asset_depr_schedule.status, "Cancelled") + + # Test gl entry creted from asset value adjustemnet + expected_gle = ( + ("_Test Accumulated Depreciations - _TC", 0.0, 5625.29), + ("_Test Depreciations - _TC", 5625.29, 0.0), + ) + + gle = frappe.db.sql( + """select account, debit, credit from `tabGL Entry` + where voucher_type='Journal Entry' and voucher_no = %s + order by account""", + adj_doc.journal_entry, + ) + + self.assertSequenceEqual(gle, expected_gle) + + # test depreciation schedule after asset repair and asset value adjustemnet + expected_schedules = [ + ["2023-01-31", 5474.73, 5474.73], + ["2023-02-28", 9983.33, 15458.06], + ["2023-03-31", 9983.33, 25441.39], + ["2023-04-30", 9983.33, 35424.72], + ["2023-05-31", 9983.33, 45408.05], + ["2023-06-30", 9983.33, 55391.38], + ["2023-07-31", 9983.33, 65374.71], + ["2023-08-31", 2766.67, 68141.38], + ["2023-09-30", 2766.67, 70908.05], + ["2023-10-31", 2766.67, 73674.72], + ["2023-11-30", 2766.67, 76441.39], + ["2023-12-31", 2766.67, 79208.06], + ["2024-01-31", 2766.67, 81974.73], + ["2024-02-29", 2766.67, 84741.4], + ["2024-03-31", 2766.67, 87508.07], + ["2024-04-30", 2766.67, 90274.74], + ["2024-05-31", 2766.67, 93041.41], + ["2024-06-30", 2766.67, 95808.08], + ["2024-07-31", 2766.67, 98574.75], + ["2024-08-31", 2766.67, 101341.42], + ["2024-09-30", 2766.67, 104108.09], + ["2024-10-31", 2766.67, 106874.76], + ["2024-11-30", 2766.67, 109641.43], + ["2024-12-31", 2766.67, 112408.1], + ["2025-01-15", 2766.61, 115174.71], + ] + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount] + for d in second_asset_depr_schedule.get("depreciation_schedule") + ] + + self.assertEqual(schedules, expected_schedules) + + # Cancel asset repair + asset_repair.cancel() + asset_repair.load_from_db() + second_asset_depr_schedule.load_from_db() + + third_asset_depr_schedule = get_asset_depr_schedule_doc(asset_doc.name, "Active") + self.assertEqual(third_asset_depr_schedule.status, "Active") + self.assertEqual(second_asset_depr_schedule.status, "Cancelled") + + # After cancelling asset repair asset life will be decreased and new depreciation schedule should be calculated + expected_schedules = [ + ["2023-01-31", 5474.73, 5474.73], + ["2023-02-28", 9983.33, 15458.06], + ["2023-03-31", 9983.33, 25441.39], + ["2023-04-30", 9983.33, 35424.72], + ["2023-05-31", 9983.33, 45408.05], + ["2023-06-30", 9983.33, 55391.38], + ["2023-07-31", 9983.33, 65374.71], + ["2023-08-31", 8133.33, 73508.04], + ["2023-09-30", 8133.33, 81641.37], + ["2023-10-31", 8133.33, 89774.7], + ["2023-11-30", 8133.33, 97908.03], + ["2023-12-31", 8133.33, 106041.36], + ["2024-01-15", 8133.35, 114174.71], + ] + + schedules = [ + [cstr(d.schedule_date), d.depreciation_amount, d.accumulated_depreciation_amount] + for d in third_asset_depr_schedule.get("depreciation_schedule") + ] + + self.assertEqual(schedules, expected_schedules) + def make_asset_value_adjustment(**args): args = frappe._dict(args) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index dc65d76a357..63d0404642a 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -2433,6 +2433,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/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 958ac266e61..a55eded2a4c 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -314,18 +314,22 @@ class BuyingController(SubcontractingController): get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 ) + net_rate = item.base_net_amount + if item.sales_incoming_rate: # for internal transfer + net_rate = item.qty * item.sales_incoming_rate + qty_in_stock_uom = flt(item.qty * item.conversion_factor) if self.get("is_old_subcontracting_flow"): item.rm_supp_cost = self.get_supplied_items_cost(item.name, reset_outgoing_rate) item.valuation_rate = ( - item.base_net_amount + net_rate + item.item_tax_amount + item.rm_supp_cost + flt(item.landed_cost_voucher_amount) ) / qty_in_stock_uom else: item.valuation_rate = ( - item.base_net_amount + net_rate + item.item_tax_amount + flt(item.landed_cost_voucher_amount) + flt(item.get("rate_difference_with_purchase_invoice")) @@ -336,72 +340,88 @@ class BuyingController(SubcontractingController): update_regional_item_valuation_rate(self) def set_incoming_rate(self): - if self.doctype not in ("Purchase Receipt", "Purchase Invoice", "Purchase Order"): + """ + Override item rate with incoming rate for internal stock transfer + """ + if self.doctype not in ("Purchase Receipt", "Purchase Invoice"): + return + + if not (self.doctype == "Purchase Receipt" or self.get("update_stock")): + return + + if cint(self.get("is_return")): + # Get outgoing rate based on original item cost based on valuation method return if not self.is_internal_transfer(): return + allow_at_arms_length_price = frappe.get_cached_value( + "Stock Settings", None, "allow_internal_transfer_at_arms_length_price" + ) + if allow_at_arms_length_price: + return + + self.set_sales_incoming_rate_for_internal_transfer() + + for d in self.get("items"): + d.discount_percentage = 0.0 + d.discount_amount = 0.0 + d.margin_rate_or_amount = 0.0 + + if d.rate == d.sales_incoming_rate: + continue + + d.rate = d.sales_incoming_rate + frappe.msgprint( + _( + "Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer" + ).format(d.idx), + alert=1, + ) + + def set_sales_incoming_rate_for_internal_transfer(self): + """ + Set incoming rate from the sales transaction against which the + purchase is made (internal transfer) + """ ref_doctype_map = { - "Purchase Order": "Sales Order Item", "Purchase Receipt": "Delivery Note Item", "Purchase Invoice": "Sales Invoice Item", } ref_doctype = ref_doctype_map.get(self.doctype) - items = self.get("items") - for d in items: - if not cint(self.get("is_return")): - # Get outgoing rate based on original item cost based on valuation method + for d in self.get("items"): + if not d.get(frappe.scrub(ref_doctype)): + posting_time = self.get("posting_time") + if not posting_time: + posting_time = nowtime() - if not d.get(frappe.scrub(ref_doctype)): - posting_time = self.get("posting_time") - if not posting_time and self.doctype == "Purchase Order": - posting_time = nowtime() + outgoing_rate = get_incoming_rate( + { + "item_code": d.item_code, + "warehouse": d.get("from_warehouse"), + "posting_date": self.get("posting_date") or self.get("transaction_date"), + "posting_time": posting_time, + "qty": -1 * flt(d.get("stock_qty")), + "serial_and_batch_bundle": d.get("serial_and_batch_bundle"), + "company": self.company, + "voucher_type": self.doctype, + "voucher_no": self.name, + "allow_zero_valuation": d.get("allow_zero_valuation"), + "voucher_detail_no": d.name, + }, + raise_error_if_no_rate=False, + ) - outgoing_rate = get_incoming_rate( - { - "item_code": d.item_code, - "warehouse": d.get("from_warehouse"), - "posting_date": self.get("posting_date") or self.get("transaction_date"), - "posting_time": posting_time, - "qty": -1 * flt(d.get("stock_qty")), - "serial_and_batch_bundle": d.get("serial_and_batch_bundle"), - "company": self.company, - "voucher_type": self.doctype, - "voucher_no": self.name, - "allow_zero_valuation": d.get("allow_zero_valuation"), - "voucher_detail_no": d.name, - }, - raise_error_if_no_rate=False, - ) - - rate = flt(outgoing_rate * (d.conversion_factor or 1), d.precision("rate")) - else: - field = ( - "incoming_rate" - if self.get("is_internal_supplier") and not self.doctype == "Purchase Order" - else "rate" - ) - rate = flt( - frappe.db.get_value(ref_doctype, d.get(frappe.scrub(ref_doctype)), field) - * (d.conversion_factor or 1), - d.precision("rate"), - ) - - if self.is_internal_transfer(): - if self.doctype == "Purchase Receipt" or self.get("update_stock"): - if rate != d.rate: - d.rate = rate - frappe.msgprint( - _( - "Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer" - ).format(d.idx), - alert=1, - ) - d.discount_percentage = 0.0 - d.discount_amount = 0.0 - d.margin_rate_or_amount = 0.0 + d.sales_incoming_rate = flt(outgoing_rate * (d.conversion_factor or 1), d.precision("rate")) + else: + field = "incoming_rate" if self.get("is_internal_supplier") else "rate" + d.sales_incoming_rate = flt( + frappe.db.get_value(ref_doctype, d.get(frappe.scrub(ref_doctype)), field) + * (d.conversion_factor or 1), + d.precision("rate"), + ) def validate_for_subcontracting(self): if self.is_subcontracted and self.get("is_old_subcontracting_flow"): @@ -566,11 +586,9 @@ class BuyingController(SubcontractingController): if d.from_warehouse: sle.dependant_sle_voucher_detail_no = d.name else: - val_rate_db_precision = 6 if cint(self.precision("valuation_rate", d)) <= 6 else 9 - incoming_rate = flt(d.valuation_rate, val_rate_db_precision) sle.update( { - "incoming_rate": incoming_rate, + "incoming_rate": d.valuation_rate, "recalculate_rate": 1 if (self.is_subcontracted and (d.bom or d.get("fg_item"))) or d.from_warehouse else 0, diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index fd856194559..c1f565e7323 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -435,6 +435,9 @@ class SellingController(StockController): if self.doctype not in ("Delivery Note", "Sales Invoice"): return + allow_at_arms_length_price = frappe.get_cached_value( + "Stock Settings", None, "allow_internal_transfer_at_arms_length_price" + ) items = self.get("items") + (self.get("packed_items") or []) for d in items: if not frappe.get_cached_value("Item", d.item_code, "is_stock_item"): @@ -481,6 +484,9 @@ class SellingController(StockController): if d.incoming_rate != incoming_rate: d.incoming_rate = incoming_rate else: + if allow_at_arms_length_price: + continue + rate = flt( flt(d.incoming_rate, d.precision("incoming_rate")) * d.conversion_factor, d.precision("rate"), diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 3f6830c2021..b2f8fce3d31 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -5,7 +5,7 @@ import frappe from frappe import qb from frappe.query_builder.functions import Sum -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, getdate, nowdate from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry @@ -13,6 +13,7 @@ from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_pay 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.accounts.party import get_party_account +from erpnext.buying.doctype.purchase_order.test_purchase_order import prepare_data_for_internal_transfer from erpnext.stock.doctype.item.test_item import create_item @@ -804,6 +805,41 @@ class TestAccountsController(FrappeTestCase): self.assertEqual(exc_je_for_si, []) self.assertEqual(exc_je_for_pe, []) + @change_settings("Stock Settings", {"allow_internal_transfer_at_arms_length_price": 1}) + def test_16_internal_transfer_at_arms_length_price(self): + from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse + + prepare_data_for_internal_transfer() + company = "_Test Company with perpetual inventory" + target_warehouse = create_warehouse("_Test Internal Warehouse New 1", company=company) + warehouse = create_warehouse("_Test Internal Warehouse New 2", company=company) + arms_length_price = 40 + + si = create_sales_invoice( + company=company, + customer="_Test Internal Customer 2", + debit_to="Debtors - TCP1", + target_warehouse=target_warehouse, + warehouse=warehouse, + income_account="Sales - TCP1", + expense_account="Cost of Goods Sold - TCP1", + cost_center="Main - TCP1", + update_stock=True, + do_not_save=True, + do_not_submit=True, + ) + + si.items[0].rate = arms_length_price + si.save() + # rate should not reset to incoming rate + self.assertEqual(si.items[0].rate, arms_length_price) + + frappe.db.set_single_value("Stock Settings", "allow_internal_transfer_at_arms_length_price", 0) + si.items[0].rate = arms_length_price + si.save() + # rate should reset to incoming rate + self.assertEqual(si.items[0].rate, 100) + def test_20_journal_against_sales_invoice(self): # Invoice in Foreign Currency si = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index db9d31b53bb..9229ab22227 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -93,7 +93,26 @@ 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 e1c2878e9bd..797e8cebc80 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -357,6 +357,7 @@ erpnext.patches.v14_0.create_accounting_dimensions_in_reconciliation_tool erpnext.patches.v15_0.allow_on_submit_dimensions_for_repostable_doctypes erpnext.patches.v14_0.update_flag_for_return_invoices #2024-03-22 erpnext.patches.v15_0.create_accounting_dimensions_in_payment_request +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/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index 5b9b5c9a9f6..fb15b507efb 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -379,6 +379,9 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None target.project = timesheet.parent_project if customer: target.customer = customer + default_price_list = frappe.get_value("Customer", customer, "default_price_list") + if default_price_list: + target.selling_price_list = default_price_list if currency: target.currency = currency diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 8699f253e3d..4696c7f3b4d 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -840,6 +840,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } var get_party_currency = function() { + if (me.is_a_mapped_document()) { + return; + } + var party_type = frappe.meta.has_field(me.frm.doc.doctype, "customer") ? "Customer" : "Supplier"; var party_name = me.frm.doc[party_type.toLowerCase()]; if (party_name) { @@ -1249,16 +1253,24 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe is_a_mapped_document(item) { const mapped_item_field_map = { - "Delivery Note Item": ["si_detail", "so_detail", "dn_detail"], - "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"], + "Delivery Note": ["si_detail", "so_detail", "dn_detail"], + "Sales Invoice": ["dn_detail", "so_detail", "sales_invoice_item"], + "Purchase Receipt": ["purchase_order_item", "purchase_invoice_item", "purchase_receipt_item"], + "Purchase Invoice": ["purchase_order_item", "pr_detail", "po_detail"], + "Sales Order": ["prevdoc_docname", "quotation_item"], }; - const mappped_fields = mapped_item_field_map[item.doctype] || []; + const mappped_fields = mapped_item_field_map[this.frm.doc.doctype] || []; - return mappped_fields - .map((field) => item[field]) - .filter(Boolean).length > 0; + if (item) { + return mappped_fields + .map((field) => item[field]) + .filter(Boolean).length > 0; + } else if (this.frm.doc?.items) { + let first_row = this.frm.doc.items[0]; + let mapped_rows = mappped_fields.filter(d => first_row[d]) + + return mapped_rows?.length > 0; + } } batch_no(doc, cdt, cdn) { diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.js b/erpnext/selling/page/sales_funnel/sales_funnel.js index 1e2aa8ea56b..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 = "black"; + 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/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index c971c8da4f0..8a096aca80c 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -328,6 +328,9 @@ class DeliveryNote(SellingController): return for item in self.items: + if item.use_serial_batch_fields: + continue + if item.pick_list_item and not item.serial_and_batch_bundle: filters = { "item_code": item.item_code, diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 066dba95b1e..8d7022c3581 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -1097,7 +1097,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: @@ -1178,6 +1179,7 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) dn_item.batch_no = location.batch_no dn_item.serial_no = location.serial_no + dn_item.use_serial_batch_fields = location.use_serial_batch_fields update_delivery_note_item(source_doc, dn_item, delivery_note) diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json index fd26b611f45..610bceddf0f 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json @@ -67,6 +67,7 @@ "base_net_rate", "base_net_amount", "valuation_rate", + "sales_incoming_rate", "item_tax_amount", "rm_supp_cost", "landed_cost_voucher_amount", @@ -1124,12 +1125,22 @@ "fieldtype": "Check", "label": "Return Qty from Rejected Warehouse", "read_only": 1 + }, + { + "description": "Valuation rate for the item as per Sales Invoice (Only for Internal Transfers)", + "fieldname": "sales_incoming_rate", + "fieldtype": "Currency", + "hidden": 1, + "label": "Sales Incoming Rate", + "no_copy": 1, + "options": "Company:company:default_currency", + "print_hide": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2024-05-28 09:48:24.448815", + "modified": "2024-07-19 12:14:21.521466", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt Item", diff --git a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py index 393b6a25691..2154007771d 100644 --- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py +++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.py @@ -88,6 +88,7 @@ class PurchaseReceiptItem(Document): return_qty_from_rejected_warehouse: DF.Check returned_qty: DF.Float rm_supp_cost: DF.Currency + sales_incoming_rate: DF.Currency sales_order: DF.Link | None sales_order_item: DF.Data | None sample_quantity: DF.Int diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index 495af7f173a..3f467d3627a 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -303,7 +303,7 @@ "depends_on": "from_warehouse", "fieldname": "source_warehouse_address", "fieldtype": "Link", - "label": "Source Warehouse Address", + "label": "Source Warehouse Address Link", "options": "Address" }, { @@ -333,7 +333,7 @@ "depends_on": "to_warehouse", "fieldname": "target_warehouse_address", "fieldtype": "Link", - "label": "Target Warehouse Address", + "label": "Target Warehouse Address Link", "options": "Address" }, { @@ -686,10 +686,10 @@ "read_only": 1 }, { - "fieldname": "tab_connections", - "fieldtype": "Tab Break", - "label": "Connections", - "show_dashboard": 1 + "fieldname": "tab_connections", + "fieldtype": "Tab Break", + "label": "Connections", + "show_dashboard": 1 } ], "icon": "fa fa-file-text", @@ -697,7 +697,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-06-26 19:12:17.937088", + "modified": "2024-08-13 19:02:42.386955", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 11c4b1c14f7..069e7da41cb 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -32,6 +32,7 @@ "allow_negative_stock", "show_barcode_field", "clean_description_html", + "allow_internal_transfer_at_arms_length_price", "quality_inspection_settings_section", "action_if_quality_inspection_is_not_submitted", "column_break_23", @@ -439,6 +440,13 @@ "fieldtype": "Check", "label": "Do Not Update Serial / Batch on Creation of Auto Bundle" }, + { + "default": "0", + "description": "If enabled, the item rate won't adjust to the valuation rate during internal transfers, but accounting will still use the valuation rate.", + "fieldname": "allow_internal_transfer_at_arms_length_price", + "fieldtype": "Check", + "label": "Allow Internal Transfers at Arm's Length Price" + }, { "default": "0", "depends_on": "eval:doc.valuation_method === \"Moving Average\"", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index fae75f49777..c029b7bd1fb 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -27,6 +27,7 @@ class StockSettings(Document): action_if_quality_inspection_is_rejected: DF.Literal["Stop", "Warn"] allow_from_dn: DF.Check allow_from_pr: DF.Check + allow_internal_transfer_at_arms_length_price: DF.Check allow_negative_stock: DF.Check allow_partial_reservation: DF.Check allow_to_edit_stock_uom_qty_for_purchase: DF.Check diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ac78f1a7f68..693481dfba8 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -6,13 +6,14 @@ import gzip import json import frappe -from frappe import _, scrub +from frappe import _, bold, scrub from frappe.model.meta import get_field_precision from frappe.query_builder.functions import Sum from frappe.utils import ( cint, cstr, flt, + format_date, get_link_to_form, getdate, now, @@ -743,9 +744,29 @@ 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): # 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 e80569069e7..12b2854862c 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -1334,7 +1334,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, @@ -5529,10 +5529,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, @@ -8795,13 +8795,13 @@ Warehouse wise Stock Value,Warenwert nach Lager, Ex Works,Ab Werk, Free Carrier,Frei Frachtführer, Free Alongside Ship,Frei Längsseite Schiff, -Free on Board,Frei an Bord, +Free On Board,Frei an Bord, Carriage Paid To,Frachtfrei, Carriage and Insurance Paid to,Frachtfrei versichert, Cost and Freight,Kosten und Fracht, "Cost, Insurance and Freight","Kosten, Versicherung und Fracht", -Delivered at Place,Geliefert benannter Ort, -Delivered at Place Unloaded,Geliefert benannter Ort entladen, +Delivered At Place,Geliefert benannter Ort, +Delivered At Place Unloaded,Geliefert benannter Ort entladen, Delivered Duty Paid,Geliefert verzollt, Discount Validity,Frist für den Rabatt, Discount Validity Based On,Frist für den Rabatt berechnet sich nach,