diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.py b/erpnext/accounts/doctype/journal_entry/journal_entry.py index dd3e9d0bcb..4537dede92 100644 --- a/erpnext/accounts/doctype/journal_entry/journal_entry.py +++ b/erpnext/accounts/doctype/journal_entry/journal_entry.py @@ -150,6 +150,20 @@ class JournalEntry(AccountsController): if not self.title: self.title = self.get_title() + def submit(self): + if len(self.accounts) > 100: + msgprint(_("The task has been enqueued as a background job."), alert=True) + self.queue_action("submit", timeout=4600) + else: + return self._submit() + + def cancel(self): + if len(self.accounts) > 100: + msgprint(_("The task has been enqueued as a background job."), alert=True) + self.queue_action("cancel", timeout=4600) + else: + return self._cancel() + def on_submit(self): self.validate_cheque_info() self.check_credit_limit() diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 2954d2fc3f..62e2181230 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -640,7 +640,7 @@ frappe.ui.form.on('Payment Entry', { get_outstanding_invoices_or_orders: function(frm, get_outstanding_invoices, get_orders_to_be_billed) { const today = frappe.datetime.get_today(); - const fields = [ + let fields = [ {fieldtype:"Section Break", label: __("Posting Date")}, {fieldtype:"Date", label: __("From Date"), fieldname:"from_posting_date", default:frappe.datetime.add_days(today, -30)}, @@ -655,18 +655,29 @@ frappe.ui.form.on('Payment Entry', { fieldname:"outstanding_amt_greater_than", default: 0}, {fieldtype:"Column Break"}, {fieldtype:"Float", label: __("Less Than Amount"), fieldname:"outstanding_amt_less_than"}, - {fieldtype:"Section Break"}, - {fieldtype:"Link", label:__("Cost Center"), fieldname:"cost_center", options:"Cost Center", - "get_query": function() { - return { - "filters": {"company": frm.doc.company} - } + ]; + + if (frm.dimension_filters) { + let column_break_insertion_point = Math.ceil((frm.dimension_filters.length)/2); + + fields.push({fieldtype:"Section Break"}); + frm.dimension_filters.map((elem, idx)=>{ + fields.push({ + fieldtype: "Link", + label: elem.document_type == "Cost Center" ? "Cost Center" : elem.label, + options: elem.document_type, + fieldname: elem.fieldname || elem.document_type + }); + if(idx+1 == column_break_insertion_point) { + fields.push({fieldtype:"Column Break"}); } - }, - {fieldtype:"Column Break"}, + }); + } + + fields = fields.concat([ {fieldtype:"Section Break"}, {fieldtype:"Check", label: __("Allocate Payment Amount"), fieldname:"allocate_payment_amount", default:1}, - ]; + ]); let btn_text = ""; diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index aa181564b0..7c2e14ea75 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -87,12 +87,14 @@ "status", "custom_remarks", "remarks", + "base_in_words", "column_break_16", "letter_head", "print_heading", "bank", "bank_account_no", "payment_order", + "in_words", "subscription_section", "auto_repeat", "amended_from", @@ -746,6 +748,20 @@ "hidden": 1, "label": "Book Advance Payments in Separate Party Account", "read_only": 1 + }, + { + "fieldname": "base_in_words", + "fieldtype": "Small Text", + "label": "In Words (Company Currency)", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "in_words", + "fieldtype": "Small Text", + "label": "In Words", + "print_hide": 1, + "read_only": 1 } ], "index_web_pages_for_search": 1, @@ -759,7 +775,7 @@ "table_fieldname": "payment_entries" } ], - "modified": "2023-11-23 12:07:20.887885", + "modified": "2024-01-03 12:46:41.759121", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index e20da1d9d6..cd2ad39476 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -13,6 +13,7 @@ from pypika import Case from pypika.functions import Coalesce, Sum import erpnext +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions from erpnext.accounts.doctype.bank_account.bank_account import ( get_bank_account_details, get_party_bank_account, @@ -95,6 +96,7 @@ class PaymentEntry(AccountsController): self.validate_paid_invoices() self.ensure_supplier_is_not_blocked() self.set_status() + self.set_total_in_words() def on_submit(self): if self.difference_amount: @@ -107,7 +109,7 @@ class PaymentEntry(AccountsController): def set_liability_account(self): # Auto setting liability account should only be done during 'draft' status - if self.docstatus > 0: + if self.docstatus > 0 or self.payment_type == "Internal Transfer": return if not frappe.db.get_value( @@ -702,6 +704,21 @@ class PaymentEntry(AccountsController): self.db_set("status", self.status, update_modified=True) + def set_total_in_words(self): + from frappe.utils import money_in_words + + if self.payment_type in ("Pay", "Internal Transfer"): + base_amount = abs(self.base_paid_amount) + amount = abs(self.paid_amount) + currency = self.paid_from_account_currency + elif self.payment_type == "Receive": + base_amount = abs(self.base_received_amount) + amount = abs(self.received_amount) + currency = self.paid_to_account_currency + + self.base_in_words = money_in_words(base_amount, self.company_currency) + self.in_words = money_in_words(amount, currency) + def set_tax_withholding(self): if not self.party_type == "Supplier": return @@ -1588,6 +1605,13 @@ def get_outstanding_reference_documents(args, validate=False): condition += " and cost_center='%s'" % args.get("cost_center") accounting_dimensions_filter.append(ple.cost_center == args.get("cost_center")) + # dynamic dimension filters + active_dimensions = get_dimensions()[0] + for dim in active_dimensions: + if args.get(dim.fieldname): + condition += " and {0}='{1}'".format(dim.fieldname, args.get(dim.fieldname)) + accounting_dimensions_filter.append(ple[dim.fieldname] == args.get(dim.fieldname)) + date_fields_dict = { "posting_date": ["from_posting_date", "to_posting_date"], "due_date": ["from_due_date", "to_due_date"], @@ -1821,6 +1845,12 @@ def get_orders_to_be_billed( if doc and hasattr(doc, "cost_center") and doc.cost_center: condition = " and cost_center='%s'" % cost_center + # dynamic dimension filters + active_dimensions = get_dimensions()[0] + for dim in active_dimensions: + if filters.get(dim.fieldname): + condition += " and {0}='{1}'".format(dim.fieldname, filters.get(dim.fieldname)) + if party_account_currency == company_currency: grand_total_field = "base_grand_total" rounded_total_field = "base_rounded_total" diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index d9f00befa9..8f36c23099 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -95,6 +95,8 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo this.frm.change_custom_button_type(__('Allocate'), null, 'default'); } + this.frm.trigger("set_query_for_dimension_filters"); + // check for any running reconciliation jobs if (this.frm.doc.receivable_payable_account) { this.frm.call({ @@ -125,6 +127,25 @@ erpnext.accounts.PaymentReconciliationController = class PaymentReconciliationCo } } + set_query_for_dimension_filters() { + frappe.call({ + method: "erpnext.accounts.doctype.payment_reconciliation.payment_reconciliation.get_queries_for_dimension_filters", + args: { + company: this.frm.doc.company, + }, + callback: (r) => { + if (!r.exc && r.message) { + r.message.forEach(x => { + this.frm.set_query(x.fieldname, () => { + return { + 'filters': x.filters + }; + }); + }); + } + } + }); + } company() { this.frm.set_value('party', ''); diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json index ccb9e648cb..666926f00e 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json @@ -25,7 +25,9 @@ "invoice_limit", "payment_limit", "bank_cash_account", + "accounting_dimensions_section", "cost_center", + "dimension_col_break", "sec_break1", "invoice_name", "invoices", @@ -208,6 +210,18 @@ "fieldname": "payment_name", "fieldtype": "Data", "label": "Filter on Payment" + }, + { + "collapsible": 1, + "collapsible_depends_on": "eval: doc.invoices.length == 0", + "depends_on": "eval:doc.receivable_payable_account", + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions Filter" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" } ], "hide_toolbar": 1, @@ -215,7 +229,7 @@ "is_virtual": 1, "issingle": 1, "links": [], - "modified": "2023-11-17 17:33:55.701726", + "modified": "2023-12-14 13:38:16.264013", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation", diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 3ea25d16e6..ca64015188 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -10,6 +10,7 @@ from frappe.query_builder.custom import ConstantColumn from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today import erpnext +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import ( is_any_doc_running, ) @@ -70,6 +71,7 @@ class PaymentReconciliation(Document): self.common_filter_conditions = [] self.accounting_dimension_filter_conditions = [] self.ple_posting_date_filter = [] + self.dimensions = get_dimensions()[0] def load_from_db(self): # 'modified' attribute is required for `run_doc_method` to work properly. @@ -172,6 +174,14 @@ class PaymentReconciliation(Document): if self.payment_name: condition.update({"name": self.payment_name}) + # pass dynamic dimension filter values to query builder + dimensions = {} + for x in self.dimensions: + dimension = x.fieldname + if self.get(dimension): + dimensions.update({dimension: self.get(dimension)}) + condition.update({"accounting_dimensions": dimensions}) + payment_entries = get_advance_payment_entries_for_regional( self.party_type, self.party, @@ -185,66 +195,67 @@ class PaymentReconciliation(Document): return payment_entries def get_jv_entries(self): - condition = self.get_conditions() + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + conditions = self.get_journal_filter_conditions() + + # Dimension filters + for x in self.dimensions: + dimension = x.fieldname + if self.get(dimension): + conditions.append(jea[dimension] == self.get(dimension)) if self.payment_name: - condition += f" and t1.name like '%%{self.payment_name}%%'" + conditions.append(je.name.like(f"%%{self.payment_name}%%")) if self.get("cost_center"): - condition += f" and t2.cost_center = '{self.cost_center}' " + conditions.append(jea.cost_center == self.cost_center) dr_or_cr = ( "credit_in_account_currency" if erpnext.get_party_account_type(self.party_type) == "Receivable" else "debit_in_account_currency" ) + conditions.append(jea[dr_or_cr].gt(0)) - bank_account_condition = ( - "t2.against_account like %(bank_cash_account)s" if self.bank_cash_account else "1=1" + if self.bank_cash_account: + conditions.append(jea.against_account.like(f"%%{self.bank_cash_account}%%")) + + journal_query = ( + qb.from_(je) + .inner_join(jea) + .on(jea.parent == je.name) + .select( + ConstantColumn("Journal Entry").as_("reference_type"), + je.name.as_("reference_name"), + je.posting_date, + je.remark.as_("remarks"), + jea.name.as_("reference_row"), + jea[dr_or_cr].as_("amount"), + jea.is_advance, + jea.exchange_rate, + jea.account_currency.as_("currency"), + jea.cost_center.as_("cost_center"), + ) + .where( + (je.docstatus == 1) + & (jea.party_type == self.party_type) + & (jea.party == self.party) + & (jea.account == self.receivable_payable_account) + & ( + (jea.reference_type == "") + | (jea.reference_type.isnull()) + | (jea.reference_type.isin(("Sales Order", "Purchase Order"))) + ) + ) + .where(Criterion.all(conditions)) + .orderby(je.posting_date) ) - limit = f"limit {self.payment_limit}" if self.payment_limit else " " + if self.payment_limit: + journal_query = journal_query.limit(self.payment_limit) - # nosemgrep - journal_entries = frappe.db.sql( - """ - select - "Journal Entry" as reference_type, t1.name as reference_name, - t1.posting_date, t1.remark as remarks, t2.name as reference_row, - {dr_or_cr} as amount, t2.is_advance, t2.exchange_rate, - t2.account_currency as currency, t2.cost_center as cost_center - from - `tabJournal Entry` t1, `tabJournal Entry Account` t2 - where - t1.name = t2.parent and t1.docstatus = 1 and t2.docstatus = 1 - and t2.party_type = %(party_type)s and t2.party = %(party)s - and t2.account = %(account)s and {dr_or_cr} > 0 {condition} - and (t2.reference_type is null or t2.reference_type = '' or - (t2.reference_type in ('Sales Order', 'Purchase Order') - and t2.reference_name is not null and t2.reference_name != '')) - and (CASE - WHEN t1.voucher_type in ('Debit Note', 'Credit Note') - THEN 1=1 - ELSE {bank_account_condition} - END) - order by t1.posting_date - {limit} - """.format( - **{ - "dr_or_cr": dr_or_cr, - "bank_account_condition": bank_account_condition, - "condition": condition, - "limit": limit, - } - ), - { - "party_type": self.party_type, - "party": self.party, - "account": self.receivable_payable_account, - "bank_cash_account": "%%%s%%" % self.bank_cash_account, - }, - as_dict=1, - ) + journal_entries = journal_query.run(as_dict=True) return list(journal_entries) @@ -298,6 +309,7 @@ class PaymentReconciliation(Document): min_outstanding=-(self.minimum_payment_amount) if self.minimum_payment_amount else None, max_outstanding=-(self.maximum_payment_amount) if self.maximum_payment_amount else None, get_payments=True, + accounting_dimensions=self.accounting_dimension_filter_conditions, ) for inv in return_outstanding: @@ -446,8 +458,15 @@ class PaymentReconciliation(Document): row = self.append("allocation", {}) row.update(entry) + def update_dimension_values_in_allocated_entries(self, res): + for x in self.dimensions: + dimension = x.fieldname + if self.get(dimension): + res[dimension] = self.get(dimension) + return res + def get_allocated_entry(self, pay, inv, allocated_amount): - return frappe._dict( + res = frappe._dict( { "reference_type": pay.get("reference_type"), "reference_name": pay.get("reference_name"), @@ -463,6 +482,9 @@ class PaymentReconciliation(Document): } ) + res = self.update_dimension_values_in_allocated_entries(res) + return res + def reconcile_allocations(self, skip_ref_details_update_for_pe=False): adjust_allocations_for_taxes(self) dr_or_cr = ( @@ -485,10 +507,10 @@ class PaymentReconciliation(Document): reconciled_entry.append(payment_details) if entry_list: - reconcile_against_document(entry_list, skip_ref_details_update_for_pe) + reconcile_against_document(entry_list, skip_ref_details_update_for_pe, self.dimensions) if dr_or_cr_notes: - reconcile_dr_cr_note(dr_or_cr_notes, self.company) + reconcile_dr_cr_note(dr_or_cr_notes, self.company, self.dimensions) @frappe.whitelist() def reconcile(self): @@ -517,7 +539,7 @@ class PaymentReconciliation(Document): self.get_unreconciled_entries() def get_payment_details(self, row, dr_or_cr): - return frappe._dict( + payment_details = frappe._dict( { "voucher_type": row.get("reference_type"), "voucher_no": row.get("reference_name"), @@ -539,6 +561,12 @@ class PaymentReconciliation(Document): } ) + for x in self.dimensions: + if row.get(x.fieldname): + payment_details[x.fieldname] = row.get(x.fieldname) + + return payment_details + def check_mandatory_to_fetch(self): for fieldname in ["company", "party_type", "party", "receivable_payable_account"]: if not self.get(fieldname): @@ -646,6 +674,13 @@ class PaymentReconciliation(Document): if not invoices_to_reconcile: frappe.throw(_("No records found in Allocation table")) + def build_dimensions_filter_conditions(self): + ple = qb.DocType("Payment Ledger Entry") + for x in self.dimensions: + dimension = x.fieldname + if self.get(dimension): + self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension)) + def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False): self.common_filter_conditions.clear() self.accounting_dimension_filter_conditions.clear() @@ -669,40 +704,30 @@ class PaymentReconciliation(Document): if self.to_payment_date: self.ple_posting_date_filter.append(ple.posting_date.lte(self.to_payment_date)) - def get_conditions(self, get_payments=False): - condition = " and company = '{0}' ".format(self.company) + self.build_dimensions_filter_conditions() - if self.get("cost_center") and get_payments: - condition = " and cost_center = '{0}' ".format(self.cost_center) + def get_journal_filter_conditions(self): + conditions = [] + je = qb.DocType("Journal Entry") + jea = qb.DocType("Journal Entry Account") + conditions.append(je.company == self.company) - condition += ( - " and posting_date >= {0}".format(frappe.db.escape(self.from_payment_date)) - if self.from_payment_date - else "" - ) - condition += ( - " and posting_date <= {0}".format(frappe.db.escape(self.to_payment_date)) - if self.to_payment_date - else "" - ) + if self.from_payment_date: + conditions.append(je.posting_date.gte(self.from_payment_date)) + + if self.to_payment_date: + conditions.append(je.posting_date.lte(self.to_payment_date)) if self.minimum_payment_amount: - condition += ( - " and unallocated_amount >= {0}".format(flt(self.minimum_payment_amount)) - if get_payments - else " and total_debit >= {0}".format(flt(self.minimum_payment_amount)) - ) + conditions.append(je.total_debit.gte(self.minimum_payment_amount)) + if self.maximum_payment_amount: - condition += ( - " and unallocated_amount <= {0}".format(flt(self.maximum_payment_amount)) - if get_payments - else " and total_debit <= {0}".format(flt(self.maximum_payment_amount)) - ) + conditions.append(je.total_debit.lte(self.maximum_payment_amount)) - return condition + return conditions -def reconcile_dr_cr_note(dr_cr_notes, company): +def reconcile_dr_cr_note(dr_cr_notes, company, active_dimensions=None): for inv in dr_cr_notes: voucher_type = "Credit Note" if inv.voucher_type == "Sales Invoice" else "Debit Note" @@ -752,6 +777,15 @@ def reconcile_dr_cr_note(dr_cr_notes, company): } ) + # Credit Note(JE) will inherit the same dimension values as payment + dimensions_dict = frappe._dict() + if active_dimensions: + for dim in active_dimensions: + dimensions_dict[dim.fieldname] = inv.get(dim.fieldname) + + jv.accounts[0].update(dimensions_dict) + jv.accounts[1].update(dimensions_dict) + jv.flags.ignore_mandatory = True jv.flags.ignore_exchange_rate = True jv.remark = None @@ -785,9 +819,27 @@ def reconcile_dr_cr_note(dr_cr_notes, company): inv.against_voucher, None, inv.cost_center, + dimensions_dict, ) @erpnext.allow_regional def adjust_allocations_for_taxes(doc): pass + + +@frappe.whitelist() +def get_queries_for_dimension_filters(company: str = None): + dimensions_with_filters = [] + for d in get_dimensions()[0]: + filters = {} + meta = frappe.get_meta(d.document_type) + if meta.has_field("company") and company: + filters.update({"company": company}) + + if meta.is_tree: + filters.update({"is_group": 0}) + + dimensions_with_filters.append({"fieldname": d.fieldname, "filters": filters}) + + return dimensions_with_filters diff --git a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json index 1fc2f5967b..a553b982d1 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json +++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json @@ -23,7 +23,9 @@ "difference_account", "exchange_rate", "currency", - "cost_center" + "accounting_dimensions_section", + "cost_center", + "dimension_col_break" ], "fields": [ { @@ -151,12 +153,26 @@ "fieldtype": "Link", "label": "Cost Center", "options": "Cost Center" + }, + { + "fieldname": "gain_loss_posting_date", + "fieldtype": "Date", + "label": "Difference Posting Date" + }, + { + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" } ], "is_virtual": 1, "istable": 1, "links": [], - "modified": "2023-11-17 17:33:38.612615", + "modified": "2023-12-14 13:38:26.104150", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Allocation", diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index e542d3cc63..9b0b3ecfab 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -11,7 +11,6 @@ from erpnext.accounts.doctype.loyalty_program.loyalty_program import validate_lo from erpnext.accounts.doctype.payment_request.payment_request import make_payment_request from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( SalesInvoice, - get_bank_cash_account, get_mode_of_payment_info, update_multi_mode_option, ) @@ -208,7 +207,6 @@ class POSInvoice(SalesInvoice): self.validate_stock_availablility() self.validate_return_items_qty() self.set_status() - self.set_account_for_mode_of_payment() self.validate_pos() self.validate_payment_amount() self.validate_loyalty_transaction() @@ -643,11 +641,6 @@ class POSInvoice(SalesInvoice): update_multi_mode_option(self, pos_profile) self.paid_amount = 0 - def set_account_for_mode_of_payment(self): - for pay in self.payments: - if not pay.account: - pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account") - @frappe.whitelist() def create_payment_request(self): for pay in self.payments: diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index 981add74a6..e4e2cd79fb 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -1985,6 +1985,21 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): self.assertEqual(pi.items[0].cost_center, "_Test Cost Center Buying - _TC") + def test_debit_note_with_account_mismatch(self): + new_creditors = create_account( + parent_account="Accounts Payable - _TC", + account_name="Creditors 2", + company="_Test Company", + account_type="Payable", + ) + pi = make_purchase_invoice(qty=1, rate=1000) + dr_note = make_purchase_invoice( + qty=-1, rate=1000, is_return=1, return_against=pi.name, do_not_save=True + ) + dr_note.credit_to = new_creditors + + self.assertRaises(frappe.ValidationError, dr_note.save) + def test_debit_note_without_item(self): pi = make_purchase_invoice(item_name="_Test Item", qty=10, do_not_submit=True) pi.items[0].item_code = "" diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 5924586e73..343f3033bf 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -420,7 +420,8 @@ class SalesInvoice(SellingController): self.calculate_taxes_and_totals() def before_save(self): - set_account_for_mode_of_payment(self) + self.set_account_for_mode_of_payment() + self.set_paid_amount() def on_submit(self): self.validate_pos_paid_amount() @@ -706,9 +707,6 @@ class SalesInvoice(SellingController): ): data.sales_invoice = sales_invoice - def on_update(self): - self.set_paid_amount() - def on_update_after_submit(self): if hasattr(self, "repost_required"): fields_to_check = [ @@ -739,6 +737,11 @@ class SalesInvoice(SellingController): self.paid_amount = paid_amount self.base_paid_amount = base_paid_amount + def set_account_for_mode_of_payment(self): + for payment in self.payments: + if not payment.account: + payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") + def validate_time_sheets_are_submitted(self): for data in self.timesheets: if data.time_sheet: @@ -2107,12 +2110,6 @@ def make_sales_return(source_name, target_doc=None): return make_return_doc("Sales Invoice", source_name, target_doc) -def set_account_for_mode_of_payment(self): - for data in self.payments: - if not data.account: - data.account = get_bank_cash_account(data.mode_of_payment, self.company).get("account") - - def get_inter_company_details(doc, doctype): if doctype in ["Sales Invoice", "Sales Order", "Delivery Note"]: parties = frappe.db.get_all( diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 98d4ed46cc..07b51546c6 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1533,6 +1533,19 @@ class TestSalesInvoice(FrappeTestCase): self.assertEqual(frappe.db.get_value("Sales Invoice", si1.name, "outstanding_amount"), -1000) self.assertEqual(frappe.db.get_value("Sales Invoice", si.name, "outstanding_amount"), 2500) + def test_return_invoice_with_account_mismatch(self): + debtors2 = create_account( + parent_account="Accounts Receivable - _TC", + account_name="Debtors 2", + company="_Test Company", + account_type="Receivable", + ) + si = create_sales_invoice(qty=1, rate=1000) + cr_note = create_sales_invoice( + qty=-1, rate=1000, is_return=1, return_against=si.name, debit_to=debtors2, do_not_save=True + ) + self.assertRaises(frappe.ValidationError, cr_note.save) + def test_gle_made_when_asset_is_returned(self): create_asset_data() asset = create_asset(item_code="Macbook Pro") diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 53097afb17..eafce6d326 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -5,7 +5,7 @@ from collections import OrderedDict import frappe -from frappe import _, qb, scrub +from frappe import _, qb, query_builder, scrub from frappe.query_builder import Criterion from frappe.query_builder.functions import Date, Substring, Sum from frappe.utils import cint, cstr, flt, getdate, nowdate @@ -576,6 +576,8 @@ class ReceivablePayableReport(object): def get_future_payments_from_payment_entry(self): pe = frappe.qb.DocType("Payment Entry") pe_ref = frappe.qb.DocType("Payment Entry Reference") + ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"]) + return ( frappe.qb.from_(pe) .inner_join(pe_ref) @@ -587,6 +589,11 @@ class ReceivablePayableReport(object): (pe.posting_date).as_("future_date"), (pe_ref.allocated_amount).as_("future_amount"), (pe.reference_no).as_("future_ref"), + ifelse( + pe.payment_type == "Receive", + pe.source_exchange_rate * pe_ref.allocated_amount, + pe.target_exchange_rate * pe_ref.allocated_amount, + ).as_("future_amount_in_base_currency"), ) .where( (pe.docstatus < 2) @@ -623,13 +630,24 @@ class ReceivablePayableReport(object): query = query.select( Sum(jea.debit_in_account_currency - jea.credit_in_account_currency).as_("future_amount") ) + query = query.select(Sum(jea.debit - jea.credit).as_("future_amount_in_base_currency")) else: query = query.select( Sum(jea.credit_in_account_currency - jea.debit_in_account_currency).as_("future_amount") ) + query = query.select(Sum(jea.credit - jea.debit).as_("future_amount_in_base_currency")) else: query = query.select( - Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_("future_amount") + Sum(jea.debit if self.account_type == "Payable" else jea.credit).as_( + "future_amount_in_base_currency" + ) + ) + query = query.select( + Sum( + jea.debit_in_account_currency + if self.account_type == "Payable" + else jea.credit_in_account_currency + ).as_("future_amount") ) query = query.having(qb.Field("future_amount") > 0) @@ -645,14 +663,19 @@ class ReceivablePayableReport(object): row.remaining_balance = row.outstanding row.future_amount = 0.0 for future in self.future_payments.get((row.voucher_no, row.party), []): - if row.remaining_balance > 0 and future.future_amount: - if future.future_amount > row.outstanding: + if self.filters.in_party_currency: + future_amount_field = "future_amount" + else: + future_amount_field = "future_amount_in_base_currency" + + if row.remaining_balance > 0 and future.get(future_amount_field): + if future.get(future_amount_field) > row.outstanding: row.future_amount = row.outstanding - future.future_amount = future.future_amount - row.outstanding + future[future_amount_field] = future.get(future_amount_field) - row.outstanding row.remaining_balance = 0 else: - row.future_amount += future.future_amount - future.future_amount = 0 + row.future_amount += future.get(future_amount_field) + future[future_amount_field] = 0 row.remaining_balance = row.outstanding - row.future_amount row.setdefault("future_ref", []).append( diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 976935b99f..6ff81be0ab 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -772,3 +772,92 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): # post sorting output should be [[Additional Debtors, ...], [Debtors, ...]] report_output = sorted(report_output, key=lambda x: x[0]) self.assertEqual(expected_data, report_output) + + def test_future_payments_on_foreign_currency(self): + self.customer2 = ( + frappe.get_doc( + { + "doctype": "Customer", + "customer_name": "Jane Doe", + "type": "Individual", + "default_currency": "USD", + } + ) + .insert() + .submit() + ) + + si = self.create_sales_invoice(do_not_submit=True) + si.posting_date = add_days(today(), -1) + si.customer = self.customer2 + si.currency = "USD" + si.conversion_rate = 80 + si.debit_to = self.debtors_usd + si.save().submit() + + # full payment in USD + pe = get_payment_entry(si.doctype, si.name) + pe.posting_date = add_days(today(), 1) + pe.base_received_amount = 7500 + pe.received_amount = 7500 + pe.source_exchange_rate = 75 + pe.save().submit() + + filters = frappe._dict( + { + "company": self.company, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + "show_future_payments": True, + "in_party_currency": False, + } + ) + report = execute(filters)[1] + self.assertEqual(len(report), 1) + + expected_data = [8000.0, 8000.0, 500.0, 7500.0] + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount] + ) + + filters.in_party_currency = True + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [100.0, 100.0, 0.0, 100.0] + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount] + ) + + pe.cancel() + # partial payment in USD on a future date + pe = get_payment_entry(si.doctype, si.name) + pe.posting_date = add_days(today(), 1) + pe.base_received_amount = 6750 + pe.received_amount = 6750 + pe.source_exchange_rate = 75 + pe.paid_amount = 90 # in USD + pe.references[0].allocated_amount = 90 + pe.save().submit() + + filters.in_party_currency = False + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [8000.0, 8000.0, 1250.0, 6750.0] + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount] + ) + + filters.in_party_currency = True + report = execute(filters)[1] + self.assertEqual(len(report), 1) + expected_data = [100.0, 100.0, 10.0, 90.0] + row = report[0] + self.assertEqual( + expected_data, [row.invoiced, row.outstanding, row.remaining_balance, row.future_amount] + ) diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.js b/erpnext/accounts/report/balance_sheet/balance_sheet.js index b05e744ae0..5525024b89 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.js +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.js @@ -8,6 +8,20 @@ frappe.query_reports["Balance Sheet"] = $.extend( erpnext.utils.add_dimensions("Balance Sheet", 10); +frappe.query_reports["Balance Sheet"]["filters"].push( + { + "fieldname": "selected_view", + "label": __("Select View"), + "fieldtype": "Select", + "options": [ + { "value": "Report", "label": __("Report View") }, + { "value": "Growth", "label": __("Growth View") } + ], + "default": "Report", + "reqd": 1 + }, +); + frappe.query_reports["Balance Sheet"]["filters"].push({ fieldname: "accumulated_values", label: __("Accumulated Values"), diff --git a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js index e5898bf69d..a2f0fde16f 100644 --- a/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js +++ b/erpnext/accounts/report/profit_and_loss_statement/profit_and_loss_statement.js @@ -8,6 +8,21 @@ frappe.query_reports["Profit and Loss Statement"] = $.extend( erpnext.utils.add_dimensions("Profit and Loss Statement", 10); +frappe.query_reports["Profit and Loss Statement"]["filters"].push( + { + "fieldname": "selected_view", + "label": __("Select View"), + "fieldtype": "Select", + "options": [ + { "value": "Report", "label": __("Report View") }, + { "value": "Growth", "label": __("Growth View") }, + { "value": "Margin", "label": __("Margin View") }, + ], + "default": "Report", + "reqd": 1 + }, +); + frappe.query_reports["Profit and Loss Statement"]["filters"].push({ fieldname: "accumulated_values", label: __("Accumulated Values"), diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 8f899c3e76..a61b09e1e8 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -240,7 +240,6 @@ def get_balance_on( cond.append("""gle.cost_center = %s """ % (frappe.db.escape(cost_center, percent=False),)) if account: - if not (frappe.flags.ignore_account_permission or ignore_account_permission): acc.check_permission("read") @@ -286,18 +285,22 @@ def get_balance_on( cond.append("""gle.company = %s """ % (frappe.db.escape(company, percent=False))) if account or (party_type and party) or account_type: - + precision = get_currency_precision() if in_account_currency: - select_field = "sum(debit_in_account_currency) - sum(credit_in_account_currency)" + select_field = ( + "sum(round(debit_in_account_currency, %s)) - sum(round(credit_in_account_currency, %s))" + ) else: - select_field = "sum(debit) - sum(credit)" + select_field = "sum(round(debit, %s)) - sum(round(credit, %s))" + bal = frappe.db.sql( """ SELECT {0} FROM `tabGL Entry` gle WHERE {1}""".format( select_field, " and ".join(cond) - ) + ), + (precision, precision), )[0][0] # if bal is None, return 0 return flt(bal) @@ -453,7 +456,19 @@ def add_cc(args=None): return cc.name -def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # nosemgrep +def _build_dimensions_dict_for_exc_gain_loss( + entry: dict | object = None, active_dimensions: list = None +): + dimensions_dict = frappe._dict() + if entry and active_dimensions: + for dim in active_dimensions: + dimensions_dict[dim.fieldname] = entry.get(dim.fieldname) + return dimensions_dict + + +def reconcile_against_document( + args, skip_ref_details_update_for_pe=False, active_dimensions=None +): # nosemgrep """ Cancel PE or JV, Update against document, split if required and resubmit """ @@ -482,6 +497,8 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n check_if_advance_entry_modified(entry) validate_allocated_amount(entry) + dimensions_dict = _build_dimensions_dict_for_exc_gain_loss(entry, active_dimensions) + # update ref in advance entry if voucher_type == "Journal Entry": referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False) @@ -489,10 +506,14 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n # amount and account in args # referenced_row is used to deduplicate gain/loss journal entry.update({"referenced_row": referenced_row}) - doc.make_exchange_gain_loss_journal([entry]) + doc.make_exchange_gain_loss_journal([entry], dimensions_dict) else: referenced_row = update_reference_in_payment_entry( - entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe + entry, + doc, + do_not_save=True, + skip_ref_details_update_for_pe=skip_ref_details_update_for_pe, + dimensions_dict=dimensions_dict, ) doc.save(ignore_permissions=True) @@ -649,7 +670,7 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): def update_reference_in_payment_entry( - d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False + d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False, dimensions_dict=None ): reference_details = { "reference_doctype": d.against_voucher_type, @@ -662,6 +683,7 @@ def update_reference_in_payment_entry( else payment_entry.get_exchange_rate(), "exchange_gain_loss": d.difference_amount, "account": d.account, + "dimensions": d.dimensions, } if d.voucher_detail_no: @@ -694,7 +716,9 @@ def update_reference_in_payment_entry( if not skip_ref_details_update_for_pe: payment_entry.set_missing_ref_details() payment_entry.set_amounts() - payment_entry.make_exchange_gain_loss_journal() + payment_entry.make_exchange_gain_loss_journal( + frappe._dict({"difference_posting_date": d.difference_posting_date}), dimensions_dict + ) if not do_not_save: payment_entry.save(ignore_permissions=True) @@ -2031,6 +2055,7 @@ def create_gain_loss_journal( ref2_dn, ref2_detail_no, cost_center, + dimensions, ) -> str: journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Gain Or Loss" @@ -2064,7 +2089,8 @@ def create_gain_loss_journal( dr_or_cr + "_in_account_currency": 0, } ) - + if dimensions: + journal_account.update(dimensions) journal_entry.append("accounts", journal_account) journal_account = frappe._dict( @@ -2080,7 +2106,8 @@ def create_gain_loss_journal( reverse_dr_or_cr: abs(exc_gain_loss), } ) - + if dimensions: + journal_account.update(dimensions) journal_entry.append("accounts", journal_account) journal_entry.save() diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index 02e7a9bb29..673fe549ee 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -571,16 +571,16 @@ frappe.ui.form.on('Asset', { indicator: 'red' }); } - var is_grouped_asset = frappe.db.get_value('Item', item.item_code, 'is_grouped_asset'); - var asset_quantity = is_grouped_asset ? item.qty : 1; - var purchase_amount = flt(item.valuation_rate * asset_quantity, precision('gross_purchase_amount')); - - frm.set_value('gross_purchase_amount', purchase_amount); - frm.set_value('purchase_receipt_amount', purchase_amount); - frm.set_value('asset_quantity', asset_quantity); - frm.set_value('cost_center', item.cost_center || purchase_doc.cost_center); - if(item.asset_location) { frm.set_value('location', item.asset_location); } + frappe.db.get_value('Item', item.item_code, 'is_grouped_asset', (r) => { + var asset_quantity = r.is_grouped_asset ? item.qty : 1; + var purchase_amount = flt(item.valuation_rate * asset_quantity, precision('gross_purchase_amount')); + frm.set_value('gross_purchase_amount', purchase_amount); + frm.set_value('purchase_receipt_amount', purchase_amount); + frm.set_value('asset_quantity', asset_quantity); + frm.set_value('cost_center', item.cost_center || purchase_doc.cost_center); + if(item.asset_location) { frm.set_value('location', item.asset_location); } + }); }, set_depreciation_rate: function(frm, row) { diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 83a75fafb2..03c3cd2f9d 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -7,6 +7,7 @@ import json import frappe from frappe import _, bold, qb, throw from frappe.model.workflow import get_workflow_name, is_transition_condition_satisfied +from frappe.query_builder import Criterion from frappe.query_builder.custom import ConstantColumn from frappe.query_builder.functions import Abs, Sum from frappe.utils import ( @@ -28,6 +29,7 @@ from frappe.utils import ( import erpnext from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, + get_dimensions, ) from erpnext.accounts.doctype.pricing_rule.utils import ( apply_pricing_rule_for_free_items, @@ -200,6 +202,7 @@ class AccountsController(TransactionBase): self.validate_party() self.validate_currency() self.validate_party_account_currency() + self.validate_return_against_account() if self.doctype in ["Purchase Invoice", "Sales Invoice"]: if invalid_advances := [ @@ -348,6 +351,20 @@ class AccountsController(TransactionBase): for bundle in bundles: frappe.delete_doc("Serial and Batch Bundle", bundle.name) + def validate_return_against_account(self): + if ( + self.doctype in ["Sales Invoice", "Purchase Invoice"] and self.is_return and self.return_against + ): + cr_dr_account_field = "debit_to" if self.doctype == "Sales Invoice" else "credit_to" + cr_dr_account_label = "Debit To" if self.doctype == "Sales Invoice" else "Credit To" + cr_dr_account = self.get(cr_dr_account_field) + if frappe.get_value(self.doctype, self.return_against, cr_dr_account_field) != cr_dr_account: + frappe.throw( + _("'{0}' account: '{1}' should match the Return Against Invoice").format( + frappe.bold(cr_dr_account_label), frappe.bold(cr_dr_account) + ) + ) + def validate_deferred_income_expense_account(self): field_map = { "Sales Invoice": "deferred_revenue_account", @@ -1246,7 +1263,9 @@ class AccountsController(TransactionBase): return True return False - def make_exchange_gain_loss_journal(self, args: dict = None) -> None: + def make_exchange_gain_loss_journal( + self, args: dict = None, dimensions_dict: dict = None + ) -> None: """ Make Exchange Gain/Loss journal for Invoices and Payments """ @@ -1299,6 +1318,7 @@ class AccountsController(TransactionBase): self.name, arg.get("referenced_row"), arg.get("cost_center"), + dimensions_dict, ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( @@ -1379,6 +1399,7 @@ class AccountsController(TransactionBase): self.name, d.idx, self.cost_center, + dimensions_dict, ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( @@ -1443,7 +1464,13 @@ class AccountsController(TransactionBase): if lst: from erpnext.accounts.utils import reconcile_against_document - reconcile_against_document(lst) + # pass dimension values to utility method + active_dimensions = get_dimensions()[0] + for x in lst: + for dim in active_dimensions: + if self.get(dim.fieldname): + x.update({dim.fieldname: self.get(dim.fieldname)}) + reconcile_against_document(lst, active_dimensions=active_dimensions) def on_cancel(self): from erpnext.accounts.doctype.bank_transaction.bank_transaction import ( @@ -2712,47 +2739,37 @@ def get_common_query( q = q.select((payment_entry.target_exchange_rate).as_("exchange_rate")) if condition: - if condition.get("name", None): - q = q.where(payment_entry.name.like(f"%{condition.get('name')}%")) + # conditions should be built as an array and passed as Criterion + common_filter_conditions = [] + + common_filter_conditions.append(payment_entry.company == condition["company"]) + if condition.get("name", None): + common_filter_conditions.append(payment_entry.name.like(f"%{condition.get('name')}%")) + + if condition.get("from_payment_date"): + common_filter_conditions.append(payment_entry.posting_date.gte(condition["from_payment_date"])) + + if condition.get("to_payment_date"): + common_filter_conditions.append(payment_entry.posting_date.lte(condition["to_payment_date"])) - q = q.where(payment_entry.company == condition["company"]) - q = ( - q.where(payment_entry.posting_date >= condition["from_payment_date"]) - if condition.get("from_payment_date") - else q - ) - q = ( - q.where(payment_entry.posting_date <= condition["to_payment_date"]) - if condition.get("to_payment_date") - else q - ) if condition.get("get_payments") == True: - q = ( - q.where(payment_entry.cost_center == condition["cost_center"]) - if condition.get("cost_center") - else q - ) - q = ( - q.where(payment_entry.unallocated_amount >= condition["minimum_payment_amount"]) - if condition.get("minimum_payment_amount") - else q - ) - q = ( - q.where(payment_entry.unallocated_amount <= condition["maximum_payment_amount"]) - if condition.get("maximum_payment_amount") - else q - ) - else: - q = ( - q.where(payment_entry.total_debit >= condition["minimum_payment_amount"]) - if condition.get("minimum_payment_amount") - else q - ) - q = ( - q.where(payment_entry.total_debit <= condition["maximum_payment_amount"]) - if condition.get("maximum_payment_amount") - else q - ) + if condition.get("cost_center"): + common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"]) + + if condition.get("accounting_dimensions"): + for field, val in condition.get("accounting_dimensions").items(): + common_filter_conditions.append(payment_entry[field] == val) + + if condition.get("minimum_payment_amount"): + common_filter_conditions.append( + payment_entry.unallocated_amount.gte(condition["minimum_payment_amount"]) + ) + + if condition.get("maximum_payment_amount"): + common_filter_conditions.append( + payment_entry.unallocated_amount.lte(condition["maximum_payment_amount"]) + ) + q = q.where(Criterion.all(common_filter_conditions)) q = q.orderby(payment_entry.posting_date) q = q.limit(limit) if limit else q diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py index 297f8c26be..da36c7b3d0 100644 --- a/erpnext/controllers/status_updater.py +++ b/erpnext/controllers/status_updater.py @@ -91,7 +91,8 @@ status_map = { ], "Purchase Receipt": [ ["Draft", None], - ["To Bill", "eval:self.per_billed < 100 and self.docstatus == 1"], + ["To Bill", "eval:self.per_billed == 0 and self.docstatus == 1"], + ["Partly Billed", "eval:self.per_billed > 0 and self.per_billed < 100 and self.docstatus == 1"], ["Return Issued", "eval:self.per_returned == 100 and self.docstatus == 1"], ["Completed", "eval:self.per_billed == 100 and self.docstatus == 1"], ["Cancelled", "eval:self.docstatus==2"], diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index a86d7388df..c8516820ef 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -6,7 +6,7 @@ from collections import defaultdict from typing import List, Tuple import frappe -from frappe import _ +from frappe import _, bold from frappe.utils import cint, flt, get_link_to_form, getdate import erpnext @@ -697,6 +697,9 @@ class StockController(AccountsController): self.validate_in_transit_warehouses() self.validate_multi_currency() self.validate_packed_items() + + if self.get("is_internal_supplier"): + self.validate_internal_transfer_qty() else: self.validate_internal_transfer_warehouse() @@ -735,6 +738,116 @@ class StockController(AccountsController): if self.doctype in ("Sales Invoice", "Delivery Note Item") and self.get("packed_items"): frappe.throw(_("Packed Items cannot be transferred internally")) + def validate_internal_transfer_qty(self): + if self.doctype not in ["Purchase Invoice", "Purchase Receipt"]: + return + + item_wise_transfer_qty = self.get_item_wise_inter_transfer_qty() + if not item_wise_transfer_qty: + return + + item_wise_received_qty = self.get_item_wise_inter_received_qty() + precision = frappe.get_precision(self.doctype + " Item", "qty") + + over_receipt_allowance = frappe.db.get_single_value( + "Stock Settings", "over_delivery_receipt_allowance" + ) + + parent_doctype = { + "Purchase Receipt": "Delivery Note", + "Purchase Invoice": "Sales Invoice", + }.get(self.doctype) + + for key, transferred_qty in item_wise_transfer_qty.items(): + recevied_qty = flt(item_wise_received_qty.get(key), precision) + if over_receipt_allowance: + transferred_qty = transferred_qty + flt( + transferred_qty * over_receipt_allowance / 100, precision + ) + + if recevied_qty > flt(transferred_qty, precision): + frappe.throw( + _("For Item {0} cannot be received more than {1} qty against the {2} {3}").format( + bold(key[1]), + bold(flt(transferred_qty, precision)), + bold(parent_doctype), + get_link_to_form(parent_doctype, self.get("inter_company_reference")), + ) + ) + + def get_item_wise_inter_transfer_qty(self): + reference_field = "inter_company_reference" + if self.doctype == "Purchase Invoice": + reference_field = "inter_company_invoice_reference" + + parent_doctype = { + "Purchase Receipt": "Delivery Note", + "Purchase Invoice": "Sales Invoice", + }.get(self.doctype) + + child_doctype = parent_doctype + " Item" + + parent_tab = frappe.qb.DocType(parent_doctype) + child_tab = frappe.qb.DocType(child_doctype) + + query = ( + frappe.qb.from_(parent_doctype) + .inner_join(child_tab) + .on(child_tab.parent == parent_tab.name) + .select( + child_tab.name, + child_tab.item_code, + child_tab.qty, + ) + .where((parent_tab.name == self.get(reference_field)) & (parent_tab.docstatus == 1)) + ) + + data = query.run(as_dict=True) + item_wise_transfer_qty = defaultdict(float) + for row in data: + item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty) + + return item_wise_transfer_qty + + def get_item_wise_inter_received_qty(self): + child_doctype = self.doctype + " Item" + + parent_tab = frappe.qb.DocType(self.doctype) + child_tab = frappe.qb.DocType(child_doctype) + + query = ( + frappe.qb.from_(self.doctype) + .inner_join(child_tab) + .on(child_tab.parent == parent_tab.name) + .select( + child_tab.item_code, + child_tab.qty, + ) + .where(parent_tab.docstatus < 2) + ) + + if self.doctype == "Purchase Invoice": + query = query.select( + child_tab.sales_invoice_item.as_("name"), + ) + + query = query.where( + parent_tab.inter_company_invoice_reference == self.inter_company_invoice_reference + ) + else: + query = query.select( + child_tab.delivery_note_item.as_("name"), + ) + + query = query.where(parent_tab.inter_company_reference == self.inter_company_reference) + + data = query.run(as_dict=True) + item_wise_transfer_qty = defaultdict(float) + for row in data: + item_wise_transfer_qty[(row.name, row.item_code)] += flt(row.qty) + + return item_wise_transfer_qty + def validate_putaway_capacity(self): # if over receipt is attempted while 'apply putaway rule' is disabled # and if rule was applied on the transaction, validate it. diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 65d087261f..17a2b07daa 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -260,18 +260,22 @@ class SubcontractingController(StockController): return frappe.get_all(f"{doctype}", fields=fields, filters=filters) def __get_consumed_items(self, doctype, receipt_items): + fields = [ + "serial_no", + "rm_item_code", + "reference_name", + "batch_no", + "consumed_qty", + "main_item_code", + "parent as voucher_no", + ] + + if self.subcontract_data.receipt_supplied_items_field != "Purchase Receipt Item Supplied": + fields.append("serial_and_batch_bundle") + return frappe.get_all( self.subcontract_data.receipt_supplied_items_field, - fields=[ - "serial_no", - "rm_item_code", - "reference_name", - "serial_and_batch_bundle", - "batch_no", - "consumed_qty", - "main_item_code", - "parent as voucher_no", - ], + fields=fields, filters={"docstatus": 1, "reference_name": ("in", list(receipt_items)), "parenttype": doctype}, ) diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 391258fde7..3d6ebc02e6 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -56,6 +56,7 @@ class TestAccountsController(FrappeTestCase): 20 series - Sales Invoice against Journals 30 series - Sales Invoice against Credit Notes 40 series - Company default Cost center is unset + 50 series - Dimension inheritence """ def setUp(self): @@ -1188,3 +1189,214 @@ class TestAccountsController(FrappeTestCase): ) frappe.db.set_value("Company", self.company, "cost_center", cc) + + def setup_dimensions(self): + # create dimension + from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( + create_dimension, + ) + + create_dimension() + # make it non-mandatory + loc = frappe.get_doc("Accounting Dimension", "Location") + for x in loc.dimension_defaults: + x.mandatory_for_bs = False + x.mandatory_for_pl = False + loc.save() + + def test_50_dimensions_filter(self): + """ + Test workings of dimension filters + """ + self.setup_dimensions() + rate_in_account_currency = 1 + + # Invoices + si1 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si1.department = "Management" + si1.save().submit() + + si2 = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si2.department = "Operations" + si2.save().submit() + + # Payments + cr_note1 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) + cr_note1.department = "Management" + cr_note1.is_return = 1 + cr_note1.save().submit() + + cr_note2 = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) + cr_note2.department = "Legal" + cr_note2.is_return = 1 + cr_note2.save().submit() + + pe1 = get_payment_entry(si1.doctype, si1.name) + pe1.references = [] + pe1.department = "Research & Development" + pe1.save().submit() + + pe2 = get_payment_entry(si1.doctype, si1.name) + pe2.references = [] + pe2.department = "Management" + pe2.save().submit() + + je1 = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=75, + acc2=self.cash, + acc1_amount=-1, + acc2_amount=-75, + acc2_exc_rate=1, + ) + je1.accounts[0].party_type = "Customer" + je1.accounts[0].party = self.customer + je1.accounts[0].department = "Management" + je1.save().submit() + + # assert dimension filter's result + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 2) + self.assertEqual(len(pr.payments), 5) + + pr.department = "Legal" + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 1) + + pr.department = "Management" + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 3) + + pr.department = "Research & Development" + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 1) + + def test_51_cr_note_should_inherit_dimension(self): + self.setup_dimensions() + rate_in_account_currency = 1 + + # Invoice + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si.department = "Management" + si.save().submit() + + # Payment + cr_note = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) + cr_note.department = "Management" + cr_note.is_return = 1 + cr_note.save().submit() + + pr = self.create_payment_reconciliation() + pr.department = "Management" + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # There should be 2 journals, JE(Cr Note) and JE(Exchange Gain/Loss) + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_cr_note = self.get_journals_for(cr_note.doctype, cr_note.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 2) + self.assertEqual(len(exc_je_for_cr_note), 2) + self.assertEqual(exc_je_for_si, exc_je_for_cr_note) + + for x in exc_je_for_si + exc_je_for_cr_note: + with self.subTest(x=x): + self.assertEqual( + [cr_note.department, cr_note.department], + frappe.db.get_all("Journal Entry Account", filters={"parent": x.parent}, pluck="department"), + ) + + def test_52_dimension_inhertiance_exc_gain_loss(self): + # Sales Invoice in Foreign Currency + self.setup_dimensions() + rate = 80 + rate_in_account_currency = 1 + dpt = "Research & Development" + + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_save=True) + si.department = dpt + si.save().submit() + + pe = self.create_payment_entry(amount=1, source_exc_rate=82).save() + pe.department = dpt + pe = pe.save().submit() + + pr = self.create_payment_reconciliation() + pr.department = dpt + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 1) + self.assertEqual(len(pr.payments), 1) + invoices = [x.as_dict() for x in pr.invoices] + payments = [x.as_dict() for x in pr.payments] + pr.allocate_entries(frappe._dict({"invoices": invoices, "payments": payments})) + pr.reconcile() + self.assertEqual(len(pr.invoices), 0) + self.assertEqual(len(pr.payments), 0) + + # Exc Gain/Loss journals should inherit dimension from parent + journals = self.get_journals_for(si.doctype, si.name) + self.assertEqual( + [dpt, dpt], + frappe.db.get_all( + "Journal Entry Account", + filters={"parent": ("in", [x.parent for x in journals])}, + pluck="department", + ), + ) + + def test_53_dimension_inheritance_on_advance(self): + self.setup_dimensions() + dpt = "Research & Development" + + adv = self.create_payment_entry(amount=1, source_exc_rate=85) + adv.department = dpt + adv.save().submit() + adv.reload() + + # Sales Invoices in different exchange rates + si = self.create_sales_invoice(qty=1, conversion_rate=82, rate=1, do_not_submit=True) + si.department = dpt + advances = si.get_advance_entries() + self.assertEqual(len(advances), 1) + self.assertEqual(advances[0].reference_name, adv.name) + si.append( + "advances", + { + "doctype": "Sales Invoice Advance", + "reference_type": advances[0].reference_type, + "reference_name": advances[0].reference_name, + "reference_row": advances[0].reference_row, + "advance_amount": 1, + "allocated_amount": 1, + "ref_exchange_rate": advances[0].exchange_rate, + "remarks": advances[0].remarks, + }, + ) + si = si.save().submit() + + # Outstanding in both currencies should be '0' + adv.reload() + self.assertEqual(si.outstanding_amount, 0) + self.assert_ledger_outstanding(si.doctype, si.name, 0.0, 0.0) + + # Exc Gain/Loss journals should inherit dimension from parent + journals = self.get_journals_for(si.doctype, si.name) + self.assertEqual( + [dpt, dpt], + frappe.db.get_all( + "Journal Entry Account", + filters={"parent": ("in", [x.parent for x in journals])}, + pluck="department", + ), + ) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index d5cb6f5b98..911907c422 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -543,6 +543,8 @@ accounting_dimension_doctypes = [ "Account Closing Balance", "Supplier Quotation", "Supplier Quotation Item", + "Payment Reconciliation", + "Payment Reconciliation Allocation", ] get_matching_queries = ( diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 682c4fb82a..22ab04576c 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -176,8 +176,10 @@ class BOM(WebsiteGenerator): def autoname(self): # ignore amended documents while calculating current index + + search_key = f"{self.doctype}-{self.item}%" existing_boms = frappe.get_all( - "BOM", filters={"item": self.item, "amended_from": ["is", "not set"]}, pluck="name" + "BOM", filters={"name": ("like", search_key), "amended_from": ["is", "not set"]}, pluck="name" ) if existing_boms: diff --git a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js index 243e52df5b..3bd2021e0c 100644 --- a/erpnext/manufacturing/doctype/bom_creator/bom_creator.js +++ b/erpnext/manufacturing/doctype/bom_creator/bom_creator.js @@ -101,6 +101,7 @@ frappe.ui.form.on("BOM Creator", { } }) + dialog.fields_dict.item_code.get_query = "erpnext.controllers.queries.item_query"; dialog.show(); }, @@ -113,6 +114,16 @@ frappe.ui.form.on("BOM Creator", { } } }); + frm.set_query("item_code", "items", function() { + return { + query: "erpnext.controllers.queries.item_query", + } + }); + frm.set_query("fg_item", "items", function() { + return { + query: "erpnext.controllers.queries.item_query", + } + }); }, refresh(frm) { @@ -211,4 +222,4 @@ erpnext.bom.BomConfigurator = class BomConfigurator extends erpnext.TransactionC } }; -extend_cscript(cur_frm.cscript, new erpnext.bom.BomConfigurator({frm: cur_frm})); \ No newline at end of file +extend_cscript(cur_frm.cscript, new erpnext.bom.BomConfigurator({frm: cur_frm})); diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 36cc66313d..a3d4d880b2 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -790,24 +790,25 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe if (me.frm.doc.price_list_currency == company_currency) { me.frm.set_value('plc_conversion_rate', 1.0); } - if (company_doc && company_doc.default_letter_head) { - if(me.frm.fields_dict.letter_head) { - me.frm.set_value("letter_head", company_doc.default_letter_head); + if (company_doc){ + if (company_doc.default_letter_head) { + if(me.frm.fields_dict.letter_head) { + me.frm.set_value("letter_head", company_doc.default_letter_head); + } + } + let selling_doctypes_for_tc = ["Sales Invoice", "Quotation", "Sales Order", "Delivery Note"]; + if (company_doc.default_selling_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") && + selling_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) { + me.frm.set_value("tc_name", company_doc.default_selling_terms); + } + let buying_doctypes_for_tc = ["Request for Quotation", "Supplier Quotation", "Purchase Order", + "Material Request", "Purchase Receipt"]; + // Purchase Invoice is excluded as per issue #3345 + if (company_doc.default_buying_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") && + buying_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) { + me.frm.set_value("tc_name", company_doc.default_buying_terms); } } - let selling_doctypes_for_tc = ["Sales Invoice", "Quotation", "Sales Order", "Delivery Note"]; - if (company_doc.default_selling_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") && - selling_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) { - me.frm.set_value("tc_name", company_doc.default_selling_terms); - } - let buying_doctypes_for_tc = ["Request for Quotation", "Supplier Quotation", "Purchase Order", - "Material Request", "Purchase Receipt"]; - // Purchase Invoice is excluded as per issue #3345 - if (company_doc.default_buying_terms && frappe.meta.has_field(me.frm.doc.doctype, "tc_name") && - buying_doctypes_for_tc.indexOf(me.frm.doc.doctype) != -1) { - me.frm.set_value("tc_name", company_doc.default_buying_terms); - } - frappe.run_serially([ () => me.frm.script_manager.trigger("currency"), () => me.update_item_tax_map(), diff --git a/erpnext/public/js/financial_statements.js b/erpnext/public/js/financial_statements.js index 17341d19d9..43e58c2efd 100644 --- a/erpnext/public/js/financial_statements.js +++ b/erpnext/public/js/financial_statements.js @@ -2,7 +2,58 @@ frappe.provide("erpnext.financial_statements"); erpnext.financial_statements = { "filters": get_filters(), + "baseData": null, "formatter": function(value, row, column, data, default_formatter, filter) { + if(frappe.query_report.get_filter_value("selected_view") == "Growth" && data && column.colIndex >= 3){ + //Assuming that the first three columns are s.no, account name and the very first year of the accounting values, to calculate the relative percentage values of the successive columns. + const lastAnnualValue = row[column.colIndex - 1].content; + const currentAnnualvalue = data[column.fieldname]; + if(currentAnnualvalue == undefined) return 'NA'; //making this not applicable for undefined/null values + let annualGrowth = 0; + if(lastAnnualValue == 0 && currentAnnualvalue > 0){ + //If the previous year value is 0 and the current value is greater than 0 + annualGrowth = 1; + } + else if(lastAnnualValue > 0){ + annualGrowth = (currentAnnualvalue - lastAnnualValue) / lastAnnualValue; + } + + const growthPercent = (Math.round(annualGrowth*10000)/100); //calculating the rounded off percentage + + value = $(`${((growthPercent >=0)? '+':'' )+growthPercent+'%'}`); + if(growthPercent < 0){ + value = $(value).addClass("text-danger"); + } + else{ + value = $(value).addClass("text-success"); + } + value = $(value).wrap("

").parent().html(); + + return value; + } + else if(frappe.query_report.get_filter_value("selected_view") == "Margin" && data){ + if(column.fieldname =="account" && data.account_name == __("Income")){ + //Taking the total income from each column (for all the financial years) as the base (100%) + this.baseData = row; + } + if(column.colIndex >= 2){ + //Assuming that the first two columns are s.no and account name, to calculate the relative percentage values of the successive columns. + const currentAnnualvalue = data[column.fieldname]; + const baseValue = this.baseData[column.colIndex].content; + if(currentAnnualvalue == undefined || baseValue <= 0) return 'NA'; + const marginPercent = Math.round((currentAnnualvalue/baseValue)*10000)/100; + + value = $(`${marginPercent+'%'}`); + if(marginPercent < 0) + value = $(value).addClass("text-danger"); + else + value = $(value).addClass("text-success"); + value = $(value).wrap("

").parent().html(); + return value; + } + + } + if (data && column.fieldname=="account") { value = data.account_name || value; @@ -74,22 +125,24 @@ erpnext.financial_statements = { }); }); - const views_menu = report.page.add_custom_button_group(__('Financial Statements')); + if (report.page){ + const views_menu = report.page.add_custom_button_group(__('Financial Statements')); - report.page.add_custom_menu_item(views_menu, __("Balance Sheet"), function() { - var filters = report.get_values(); - frappe.set_route('query-report', 'Balance Sheet', {company: filters.company}); - }); + report.page.add_custom_menu_item(views_menu, __("Balance Sheet"), function() { + var filters = report.get_values(); + frappe.set_route('query-report', 'Balance Sheet', {company: filters.company}); + }); - report.page.add_custom_menu_item(views_menu, __("Profit and Loss"), function() { - var filters = report.get_values(); - frappe.set_route('query-report', 'Profit and Loss Statement', {company: filters.company}); - }); + report.page.add_custom_menu_item(views_menu, __("Profit and Loss"), function() { + var filters = report.get_values(); + frappe.set_route('query-report', 'Profit and Loss Statement', {company: filters.company}); + }); - report.page.add_custom_menu_item(views_menu, __("Cash Flow Statement"), function() { - var filters = report.get_values(); - frappe.set_route('query-report', 'Cash Flow', {company: filters.company}); - }); + report.page.add_custom_menu_item(views_menu, __("Cash Flow Statement"), function() { + var filters = report.get_values(); + frappe.set_route('query-report', 'Cash Flow', {company: filters.company}); + }); + } } }; diff --git a/erpnext/public/js/utils/dimension_tree_filter.js b/erpnext/public/js/utils/dimension_tree_filter.js index 3f70c09f66..27d00bacb8 100644 --- a/erpnext/public/js/utils/dimension_tree_filter.js +++ b/erpnext/public/js/utils/dimension_tree_filter.js @@ -25,6 +25,10 @@ erpnext.accounts.dimensions = { }, setup_filters(frm, doctype) { + if (doctype == 'Payment Entry' && this.accounting_dimensions) { + frm.dimension_filters = this.accounting_dimensions + } + if (this.accounting_dimensions) { this.accounting_dimensions.forEach((dimension) => { frappe.model.with_doctype(dimension['document_type'], () => { diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index 44a4957b41..80ade7086c 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -71,6 +71,10 @@ erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate { let warehouse = this.item?.type_of_transaction === "Outward" ? (this.item.warehouse || this.item.s_warehouse) : ""; + if (!warehouse && this.frm.doc.doctype === 'Stock Reconciliation') { + warehouse = this.get_warehouse(); + } + return { 'item_code': this.item.item_code, 'warehouse': ["=", warehouse] diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 654f2978fe..5541cc711e 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -391,7 +391,6 @@ def _make_sales_order(source_name, target_doc=None, customer_group=None, ignore_ balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0) target.qty = balance_qty if balance_qty > 0 else 0 target.stock_qty = flt(target.qty) * flt(obj.conversion_factor) - target.delivery_date = nowdate() if obj.against_blanket_order: target.against_blanket_order = obj.against_blanket_order diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index b0a2b7cc44..d06acb81f1 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -87,7 +87,6 @@ class TestQuotation(FrappeTestCase): self.assertEqual(sales_order.get("items")[0].prevdoc_docname, quotation.name) self.assertEqual(sales_order.customer, "_Test Customer") - sales_order.delivery_date = "2014-01-01" sales_order.naming_series = "_T-Quotation-" sales_order.transaction_date = nowdate() sales_order.insert() @@ -120,7 +119,6 @@ class TestQuotation(FrappeTestCase): self.assertEqual(sales_order.get("items")[0].prevdoc_docname, quotation.name) self.assertEqual(sales_order.customer, "_Test Customer") - sales_order.delivery_date = "2014-01-01" sales_order.naming_series = "_T-Quotation-" sales_order.transaction_date = nowdate() sales_order.insert() diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 87aeeac368..9599980418 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -118,6 +118,7 @@ "oldfieldtype": "Link", "options": "Item", "print_width": "150px", + "reqd": 1, "width": "150px" }, { @@ -908,7 +909,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-11-24 13:24:55.756320", + "modified": "2024-01-25 14:24:00.330219", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index 25f5b4b0e7..fa7b9b968f 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -43,7 +43,8 @@ class SalesOrderItem(Document): gross_profit: DF.Currency image: DF.Attach | None is_free_item: DF.Check - item_code: DF.Link | None + is_stock_item: DF.Check + item_code: DF.Link item_group: DF.Link | None item_name: DF.Data item_tax_rate: DF.Code | None diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py index 3682c5fd62..c6e4647538 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -210,7 +210,6 @@ def get_so_with_invoices(filters): .where( (so.docstatus == 1) & (so.status.isin(["To Deliver and Bill", "To Bill"])) - & (so.payment_terms_template != "NULL") & (so.company == conditions.company) & (so.transaction_date[conditions.start_date : conditions.end_date]) ) diff --git a/erpnext/stock/doctype/batch/batch.js b/erpnext/stock/doctype/batch/batch.js index 7bf7a1f65d..f4a935aa90 100644 --- a/erpnext/stock/doctype/batch/batch.js +++ b/erpnext/stock/doctype/batch/batch.js @@ -52,7 +52,7 @@ frappe.ui.form.on('Batch', { // sort by qty r.message.sort(function(a, b) { a.qty > b.qty ? 1 : -1 }); - var rows = $('
').appendTo(section); + const rows = $('
').appendTo(section); // show (r.message || []).forEach(function(d) { @@ -76,7 +76,7 @@ frappe.ui.form.on('Batch', { // move - ask for target warehouse and make stock entry rows.find('.btn-move').on('click', function() { - var $btn = $(this); + const $btn = $(this); const fields = [ { fieldname: 'to_warehouse', @@ -115,7 +115,7 @@ frappe.ui.form.on('Batch', { // split - ask for new qty and batch ID (optional) // and make stock entry via batch.batch_split rows.find('.btn-split').on('click', function() { - var $btn = $(this); + const $btn = $(this); frappe.prompt([{ fieldname: 'qty', label: __('New Batch Qty'), @@ -128,19 +128,16 @@ frappe.ui.form.on('Batch', { fieldtype: 'Data', }], (data) => { - frappe.call({ - method: 'erpnext.stock.doctype.batch.batch.split_batch', - args: { + frappe.xcall( + 'erpnext.stock.doctype.batch.batch.split_batch', + { item_code: frm.doc.item, batch_no: frm.doc.name, qty: data.qty, warehouse: $btn.attr('data-warehouse'), new_batch_id: data.new_batch_id - }, - callback: (r) => { - frm.refresh(); - }, - }); + } + ).then(() => frm.reload_doc()); }, __('Split Batch'), __('Split') diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 7b23f9ec01..e8e94fda31 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -9,7 +9,7 @@ from frappe import _ from frappe.model.document import Document from frappe.model.naming import make_autoname, revert_series_if_last from frappe.query_builder.functions import CurDate, Sum -from frappe.utils import cint, flt, get_link_to_form, nowtime, today +from frappe.utils import cint, flt, get_link_to_form from frappe.utils.data import add_days from frappe.utils.jinja import render_template @@ -248,8 +248,9 @@ def get_batches_by_oldest(item_code, warehouse): @frappe.whitelist() -def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): - +def split_batch( + batch_no: str, item_code: str, warehouse: str, qty: float, new_batch_id: str | None = None +): """Split the batch into a new batch""" batch = frappe.get_doc(dict(doctype="Batch", item=item_code, batch_id=new_batch_id)).insert() qty = flt(qty) @@ -257,29 +258,21 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): company = frappe.db.get_value("Warehouse", warehouse, "company") from_bundle_id = make_batch_bundle( - frappe._dict( - { - "item_code": item_code, - "warehouse": warehouse, - "batches": frappe._dict({batch_no: qty}), - "company": company, - "type_of_transaction": "Outward", - "qty": qty, - } - ) + item_code=item_code, + warehouse=warehouse, + batches=frappe._dict({batch_no: qty}), + company=company, + type_of_transaction="Outward", + qty=qty, ) to_bundle_id = make_batch_bundle( - frappe._dict( - { - "item_code": item_code, - "warehouse": warehouse, - "batches": frappe._dict({batch.name: qty}), - "company": company, - "type_of_transaction": "Inward", - "qty": qty, - } - ) + item_code=item_code, + warehouse=warehouse, + batches=frappe._dict({batch.name: qty}), + company=company, + type_of_transaction="Inward", + qty=qty, ) stock_entry = frappe.get_doc( @@ -304,21 +297,30 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): return batch.name -def make_batch_bundle(kwargs): +def make_batch_bundle( + item_code: str, + warehouse: str, + batches: dict[str, float], + company: str, + type_of_transaction: str, + qty: float, +): + from frappe.utils import nowtime, today + from erpnext.stock.serial_batch_bundle import SerialBatchCreation return ( SerialBatchCreation( { - "item_code": kwargs.item_code, - "warehouse": kwargs.warehouse, + "item_code": item_code, + "warehouse": warehouse, "posting_date": today(), "posting_time": nowtime(), "voucher_type": "Stock Entry", - "qty": flt(kwargs.qty), - "type_of_transaction": kwargs.type_of_transaction, - "company": kwargs.company, - "batches": kwargs.batches, + "qty": qty, + "type_of_transaction": type_of_transaction, + "company": company, + "batches": batches, "do_not_submit": True, } ) diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py index d4a574da73..2440701af9 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note_dashboard.py @@ -8,6 +8,7 @@ def get_data(): "Stock Entry": "delivery_note_no", "Quality Inspection": "reference_name", "Auto Repeat": "reference_document", + "Purchase Receipt": "inter_company_reference", }, "internal_links": { "Sales Order": ["items", "against_sales_order"], @@ -22,6 +23,9 @@ def get_data(): {"label": _("Reference"), "items": ["Sales Order", "Shipment", "Quality Inspection"]}, {"label": _("Returns"), "items": ["Stock Entry"]}, {"label": _("Subscription"), "items": ["Auto Repeat"]}, - {"label": _("Internal Transfer"), "items": ["Material Request", "Purchase Order"]}, + { + "label": _("Internal Transfer"), + "items": ["Material Request", "Purchase Order", "Purchase Receipt"], + }, ], } diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 03fe20b93a..e80218a017 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -429,6 +429,9 @@ frappe.ui.form.on("Material Request Item", { rate: function(frm, doctype, name) { const item = locals[doctype][name]; + item.amount = flt(item.qty) * flt(item.rate); + frappe.model.set_value(doctype, name, "amount", item.amount); + refresh_field("amount", item.name, item.parentfield); frm.events.get_item_data(frm, item, false); }, diff --git a/erpnext/stock/doctype/material_request/test_material_request.py b/erpnext/stock/doctype/material_request/test_material_request.py index e5aff38c52..a5ce06d8d5 100644 --- a/erpnext/stock/doctype/material_request/test_material_request.py +++ b/erpnext/stock/doctype/material_request/test_material_request.py @@ -762,6 +762,62 @@ class TestMaterialRequest(FrappeTestCase): self.assertEqual(mr.per_ordered, 100) self.assertEqual(existing_requested_qty, current_requested_qty) + def test_auto_email_users_with_company_user_permissions(self): + from erpnext.stock.reorder_item import get_email_list + + comapnywise_users = { + "_Test Company": "test_auto_email_@example.com", + "_Test Company 1": "test_auto_email_1@example.com", + } + + permissions = [] + + for company, user in comapnywise_users.items(): + if not frappe.db.exists("User", user): + frappe.get_doc( + { + "doctype": "User", + "email": user, + "first_name": user, + "send_notifications": 0, + "enabled": 1, + "user_type": "System User", + "roles": [{"role": "Purchase Manager"}], + } + ).insert(ignore_permissions=True) + + if not frappe.db.exists( + "User Permission", {"user": user, "allow": "Company", "for_value": company} + ): + perm_doc = frappe.get_doc( + { + "doctype": "User Permission", + "user": user, + "allow": "Company", + "for_value": company, + "apply_to_all_doctypes": 1, + } + ).insert(ignore_permissions=True) + + permissions.append(perm_doc) + + comapnywise_mr_list = frappe._dict({}) + mr1 = make_material_request() + comapnywise_mr_list.setdefault(mr1.company, []).append(mr1.name) + + mr2 = make_material_request( + company="_Test Company 1", warehouse="Stores - _TC1", cost_center="Main - _TC1" + ) + comapnywise_mr_list.setdefault(mr2.company, []).append(mr2.name) + + for company, mr_list in comapnywise_mr_list.items(): + emails = get_email_list(company) + + self.assertTrue(comapnywise_users[company] in emails) + + for perm in permissions: + perm.delete() + def get_in_transit_warehouse(company): if not frappe.db.exists("Warehouse Type", "Transit"): diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js index 4029f0c127..f19f1ff61e 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js @@ -8,8 +8,10 @@ frappe.listview_settings['Purchase Receipt'] = { return [__("Closed"), "green", "status,=,Closed"]; } else if (flt(doc.per_returned, 2) === 100) { return [__("Return Issued"), "grey", "per_returned,=,100"]; - } else if (flt(doc.grand_total) !== 0 && flt(doc.per_billed, 2) < 100) { + } else if (flt(doc.grand_total) !== 0 && flt(doc.per_billed, 2) == 0) { return [__("To Bill"), "orange", "per_billed,<,100"]; + } else if (flt(doc.per_billed, 2) > 0 && flt(doc.per_billed, 2) < 100) { + return [__("Partly Billed"), "yellow", "per_billed,<,100"]; } else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) { return [__("Completed"), "green", "per_billed,=,100"]; } diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 146cbff1aa..dd49eabeaf 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -704,7 +704,7 @@ class TestPurchaseReceipt(FrappeTestCase): pr2.load_from_db() self.assertEqual(pr2.get("items")[0].billed_amt, 2000) self.assertEqual(pr2.per_billed, 80) - self.assertEqual(pr2.status, "To Bill") + self.assertEqual(pr2.status, "Partly Billed") pr2.cancel() pi2.reload() @@ -1115,7 +1115,7 @@ class TestPurchaseReceipt(FrappeTestCase): pi.load_from_db() pr.load_from_db() - self.assertEqual(pr.status, "To Bill") + self.assertEqual(pr.status, "Partly Billed") self.assertAlmostEqual(pr.per_billed, 50.0, places=2) def test_purchase_receipt_with_exchange_rate_difference(self): @@ -1638,9 +1638,10 @@ class TestPurchaseReceipt(FrappeTestCase): make_stock_entry( purpose="Material Receipt", item_code=item.name, - qty=15, + qty=20, company=company, to_warehouse=from_warehouse, + posting_date=add_days(today(), -3), ) # Step 3: Create Delivery Note with Internal Customer @@ -1663,13 +1664,15 @@ class TestPurchaseReceipt(FrappeTestCase): from erpnext.stock.doctype.delivery_note.delivery_note import make_inter_company_purchase_receipt pr = make_inter_company_purchase_receipt(dn.name) + pr.set_posting_time = 1 + pr.posting_date = today() pr.items[0].qty = 15 pr.items[0].from_warehouse = target_warehouse pr.items[0].warehouse = to_warehouse pr.items[0].rejected_warehouse = from_warehouse pr.save() - self.assertRaises(OverAllowanceError, pr.submit) + self.assertRaises(frappe.ValidationError, pr.submit) # Step 5: Test Over Receipt Allowance frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50) @@ -1681,8 +1684,10 @@ class TestPurchaseReceipt(FrappeTestCase): company=company, from_warehouse=from_warehouse, to_warehouse=target_warehouse, + posting_date=add_days(pr.posting_date, -1), ) + pr.reload() pr.submit() frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0) diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py index 63cc938c09..9cad8f62b8 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py @@ -250,6 +250,7 @@ class SerialandBatchBundle(Document): for d in self.entries: available_qty = 0 + if self.has_serial_no: d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0)) else: @@ -892,6 +893,13 @@ class SerialandBatchBundle(Document): elif batch_nos: self.set("entries", batch_nos) + def delete_serial_batch_entries(self): + SBBE = frappe.qb.DocType("Serial and Batch Entry") + + frappe.qb.from_(SBBE).delete().where(SBBE.parent == self.name).run() + + self.set("entries", []) + @frappe.whitelist() def download_blank_csv_template(content): @@ -1374,10 +1382,12 @@ def get_available_serial_nos(kwargs): elif kwargs.based_on == "Expiry": order_by = "amc_expiry_date asc" - filters = {"item_code": kwargs.item_code, "warehouse": ("is", "set")} + filters = {"item_code": kwargs.item_code} - if kwargs.warehouse: - filters["warehouse"] = kwargs.warehouse + if not kwargs.get("ignore_warehouse"): + filters["warehouse"] = ("is", "set") + if kwargs.warehouse: + filters["warehouse"] = kwargs.warehouse # Since SLEs are not present against Reserved Stock [POS invoices, SRE], need to ignore reserved serial nos. ignore_serial_nos = get_reserved_serial_nos(kwargs) diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 9e6ef0f06a..00cc8be4bb 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -228,7 +228,6 @@ class StockEntry(StockController): self.fg_completed_qty = 0.0 self.validate_serialized_batch() - self.set_actual_qty() self.calculate_rate_and_amount() self.validate_putaway_capacity() diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index 6819968394..788ae0d3ab 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -156,6 +156,7 @@ class StockReconciliation(StockController): "warehouse": item.warehouse, "posting_date": self.posting_date, "posting_time": self.posting_time, + "ignore_warehouse": 1, } ) ) @@ -780,7 +781,20 @@ class StockReconciliation(StockController): current_qty = 0.0 if row.current_serial_and_batch_bundle: - current_qty = self.get_qty_for_serial_and_batch_bundle(row) + current_qty = self.get_current_qty_for_serial_or_batch(row) + elif row.serial_no: + item_dict = get_stock_balance_for( + row.item_code, + row.warehouse, + self.posting_date, + self.posting_time, + voucher_no=self.name, + ) + + current_qty = item_dict.get("qty") + row.current_serial_no = item_dict.get("serial_nos") + row.current_valuation_rate = item_dict.get("rate") + val_rate = item_dict.get("rate") elif row.batch_no: current_qty = get_batch_qty_for_stock_reco( row.item_code, row.warehouse, row.batch_no, self.posting_date, self.posting_time, self.name @@ -788,15 +802,16 @@ class StockReconciliation(StockController): precesion = row.precision("current_qty") if flt(current_qty, precesion) != flt(row.current_qty, precesion): - val_rate = get_valuation_rate( - row.item_code, - row.warehouse, - self.doctype, - self.name, - company=self.company, - batch_no=row.batch_no, - serial_and_batch_bundle=row.current_serial_and_batch_bundle, - ) + if not row.serial_no: + val_rate = get_valuation_rate( + row.item_code, + row.warehouse, + self.doctype, + self.name, + company=self.company, + batch_no=row.batch_no, + serial_and_batch_bundle=row.current_serial_and_batch_bundle, + ) row.current_valuation_rate = val_rate row.current_qty = current_qty @@ -842,11 +857,56 @@ class StockReconciliation(StockController): return allow_negative_stock - def get_qty_for_serial_and_batch_bundle(self, row): + def get_current_qty_for_serial_or_batch(self, row): doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle) - precision = doc.entries[0].precision("qty") + current_qty = 0.0 + if doc.has_serial_no: + current_qty = self.get_current_qty_for_serial_nos(doc) + elif doc.has_batch_no: + current_qty = self.get_current_qty_for_batch_nos(doc) - current_qty = 0 + return abs(current_qty) + + def get_current_qty_for_serial_nos(self, doc): + serial_nos_details = get_available_serial_nos( + frappe._dict( + { + "item_code": doc.item_code, + "warehouse": doc.warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_no": self.name, + "ignore_warehouse": 1, + } + ) + ) + + if not serial_nos_details: + return 0.0 + + doc.delete_serial_batch_entries() + current_qty = 0.0 + for serial_no_row in serial_nos_details: + current_qty += 1 + doc.append( + "entries", + { + "serial_no": serial_no_row.serial_no, + "qty": -1, + "warehouse": doc.warehouse, + "batch_no": serial_no_row.batch_no, + }, + ) + + doc.set_incoming_rate(save=True) + doc.calculate_qty_and_amount(save=True) + doc.db_update_all() + + return current_qty + + def get_current_qty_for_batch_nos(self, doc): + current_qty = 0.0 + precision = doc.entries[0].precision("qty") for d in doc.entries: qty = ( get_batch_qty( @@ -864,7 +924,7 @@ class StockReconciliation(StockController): current_qty += qty - return abs(current_qty) + return current_qty def get_batch_qty_for_stock_reco( diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 70e9fb2205..0bbfed40d8 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -925,6 +925,74 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin): self.assertEqual(len(serial_batch_bundle), 0) + def test_backdated_purchase_receipt_with_stock_reco(self): + item_code = self.make_item( + properties={ + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "TEST-SERIAL-.###", + } + ).name + + warehouse = "_Test Warehouse - _TC" + + # Step - 1: Create a Backdated Purchase Receipt + + pr1 = make_purchase_receipt( + item_code=item_code, warehouse=warehouse, qty=10, rate=100, posting_date=add_days(nowdate(), -3) + ) + pr1.reload() + + serial_nos = sorted(get_serial_nos_from_bundle(pr1.items[0].serial_and_batch_bundle))[:5] + + # Step - 2: Create a Stock Reconciliation + sr1 = create_stock_reconciliation( + item_code=item_code, + warehouse=warehouse, + qty=5, + serial_no=serial_nos, + ) + + data = frappe.get_all( + "Stock Ledger Entry", + fields=["serial_no", "actual_qty", "stock_value_difference"], + filters={"voucher_no": sr1.name, "is_cancelled": 0}, + order_by="creation", + ) + + for d in data: + if d.actual_qty < 0: + self.assertEqual(d.actual_qty, -10.0) + self.assertAlmostEqual(d.stock_value_difference, -1000.0) + else: + self.assertEqual(d.actual_qty, 5.0) + self.assertAlmostEqual(d.stock_value_difference, 500.0) + + # Step - 3: Create a Purchase Receipt before the first Purchase Receipt + make_purchase_receipt( + item_code=item_code, warehouse=warehouse, qty=10, rate=200, posting_date=add_days(nowdate(), -5) + ) + + data = frappe.get_all( + "Stock Ledger Entry", + fields=["serial_no", "actual_qty", "stock_value_difference"], + filters={"voucher_no": sr1.name, "is_cancelled": 0}, + order_by="creation", + ) + + for d in data: + if d.actual_qty < 0: + self.assertEqual(d.actual_qty, -20.0) + self.assertAlmostEqual(d.stock_value_difference, -3000.0) + else: + self.assertEqual(d.actual_qty, 5.0) + self.assertAlmostEqual(d.stock_value_difference, 500.0) + + active_serial_no = frappe.get_all( + "Serial No", filters={"status": "Active", "item_code": item_code} + ) + self.assertEqual(len(active_serial_no), 5) + def create_batch_item_with_batch(item_name, batch_id): batch_item_doc = create_item(item_name, is_stock_item=1) diff --git a/erpnext/stock/reorder_item.py b/erpnext/stock/reorder_item.py index a6f52f3731..caa3d66ac3 100644 --- a/erpnext/stock/reorder_item.py +++ b/erpnext/stock/reorder_item.py @@ -145,6 +145,7 @@ def create_material_request(material_requests): mr.log_error("Unable to create material request") + company_wise_mr = frappe._dict({}) for request_type in material_requests: for company in material_requests[request_type]: try: @@ -206,17 +207,19 @@ def create_material_request(material_requests): mr.submit() mr_list.append(mr) + company_wise_mr.setdefault(company, []).append(mr) + except Exception: _log_exception(mr) - if mr_list: + if company_wise_mr: if getattr(frappe.local, "reorder_email_notify", None) is None: frappe.local.reorder_email_notify = cint( frappe.db.get_value("Stock Settings", None, "reorder_email_notify") ) if frappe.local.reorder_email_notify: - send_email_notification(mr_list) + send_email_notification(company_wise_mr) if exceptions_list: notify_errors(exceptions_list) @@ -224,20 +227,56 @@ def create_material_request(material_requests): return mr_list -def send_email_notification(mr_list): +def send_email_notification(company_wise_mr): """Notify user about auto creation of indent""" - email_list = frappe.db.sql_list( - """select distinct r.parent - from `tabHas Role` r, tabUser p - where p.name = r.parent and p.enabled = 1 and p.docstatus < 2 - and r.role in ('Purchase Manager','Stock Manager') - and p.name not in ('Administrator', 'All', 'Guest')""" + for company, mr_list in company_wise_mr.items(): + email_list = get_email_list(company) + + if not email_list: + continue + + msg = frappe.render_template("templates/emails/reorder_item.html", {"mr_list": mr_list}) + + frappe.sendmail( + recipients=email_list, subject=_("Auto Material Requests Generated"), message=msg + ) + + +def get_email_list(company): + users = get_comapny_wise_users(company) + user_table = frappe.qb.DocType("User") + role_table = frappe.qb.DocType("Has Role") + + query = ( + frappe.qb.from_(user_table) + .inner_join(role_table) + .on(user_table.name == role_table.parent) + .select(user_table.email) + .where( + (role_table.role.isin(["Purchase Manager", "Stock Manager"])) + & (user_table.name.notin(["Administrator", "All", "Guest"])) + & (user_table.enabled == 1) + & (user_table.docstatus < 2) + ) ) - msg = frappe.render_template("templates/emails/reorder_item.html", {"mr_list": mr_list}) + if users: + query = query.where(user_table.name.isin(users)) - frappe.sendmail(recipients=email_list, subject=_("Auto Material Requests Generated"), message=msg) + emails = query.run(as_dict=True) + + return list(set([email.email for email in emails])) + + +def get_comapny_wise_users(company): + users = frappe.get_all( + "User Permission", + filters={"allow": "Company", "for_value": company, "apply_to_all_doctypes": 1}, + fields=["user"], + ) + + return [user.user for user in users] def notify_errors(exceptions_list): diff --git a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py index 810dc4666f..3f5216bae8 100644 --- a/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py +++ b/erpnext/stock/report/serial_no_ledger/serial_no_ledger.py @@ -22,9 +22,8 @@ def get_columns(filters): {"label": _("Posting Time"), "fieldtype": "Time", "fieldname": "posting_time", "width": 90}, { "label": _("Voucher Type"), - "fieldtype": "Link", + "fieldtype": "Data", "fieldname": "voucher_type", - "options": "DocType", "width": 160, }, { diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index 6de5f00ece..fe6e83edda 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -99,7 +99,7 @@ frappe.query_reports["Stock Balance"] = { "fieldname": 'ignore_closing_balance', "label": __('Ignore Closing Balance'), "fieldtype": 'Check', - "default": 1 + "default": 0 }, ], diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index a221e006af..0a6a686d8e 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -9,9 +9,18 @@ from typing import Optional, Set, Tuple import frappe from frappe import _, scrub from frappe.model.meta import get_field_precision -from frappe.query_builder import Case from frappe.query_builder.functions import CombineDatetime, Sum -from frappe.utils import cint, flt, get_link_to_form, getdate, now, nowdate, nowtime, parse_json +from frappe.utils import ( + cint, + cstr, + flt, + get_link_to_form, + getdate, + now, + nowdate, + nowtime, + parse_json, +) import erpnext from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty @@ -712,11 +721,10 @@ class update_entries_after(object): if ( sle.voucher_type == "Stock Reconciliation" - and ( - sle.batch_no or (sle.has_batch_no and sle.serial_and_batch_bundle and not sle.has_serial_no) - ) + and (sle.batch_no or sle.serial_no or sle.serial_and_batch_bundle) and sle.voucher_detail_no and not self.args.get("sle_id") + and sle.is_cancelled == 0 ): self.reset_actual_qty_for_stock_reco(sle) @@ -737,6 +745,23 @@ class update_entries_after(object): if sle.serial_and_batch_bundle: self.calculate_valuation_for_serial_batch_bundle(sle) + elif sle.serial_no and not self.args.get("sle_id"): + # Only run in reposting + self.get_serialized_values(sle) + self.wh_data.qty_after_transaction += flt(sle.actual_qty) + if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: + self.wh_data.qty_after_transaction = sle.qty_after_transaction + + self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt( + self.wh_data.valuation_rate + ) + elif ( + sle.batch_no + and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True) + and not self.args.get("sle_id") + ): + # Only run in reposting + self.update_batched_values(sle) else: if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no and not has_dimensions: # assert @@ -782,6 +807,45 @@ class update_entries_after(object): ): self.update_outgoing_rate_on_transaction(sle) + def get_serialized_values(self, sle): + incoming_rate = flt(sle.incoming_rate) + actual_qty = flt(sle.actual_qty) + serial_nos = cstr(sle.serial_no).split("\n") + + if incoming_rate < 0: + # wrong incoming rate + incoming_rate = self.wh_data.valuation_rate + + stock_value_change = 0 + if actual_qty > 0: + stock_value_change = actual_qty * incoming_rate + else: + # In case of delivery/stock issue, get average purchase rate + # of serial nos of current entry + if not sle.is_cancelled: + outgoing_value = self.get_incoming_value_for_serial_nos(sle, serial_nos) + stock_value_change = -1 * outgoing_value + else: + stock_value_change = actual_qty * sle.outgoing_rate + + new_stock_qty = self.wh_data.qty_after_transaction + actual_qty + + if new_stock_qty > 0: + new_stock_value = ( + self.wh_data.qty_after_transaction * self.wh_data.valuation_rate + ) + stock_value_change + if new_stock_value >= 0: + # calculate new valuation rate only if stock value is positive + # else it remains the same as that of previous entry + self.wh_data.valuation_rate = new_stock_value / new_stock_qty + + if not self.wh_data.valuation_rate and sle.voucher_detail_no: + allow_zero_rate = self.check_if_allow_zero_valuation_rate( + sle.voucher_type, sle.voucher_detail_no + ) + if not allow_zero_rate: + self.wh_data.valuation_rate = self.get_fallback_rate(sle) + def reset_actual_qty_for_stock_reco(self, sle): doc = frappe.get_cached_doc("Stock Reconciliation", sle.voucher_no) doc.recalculate_current_qty(sle.voucher_detail_no, sle.creation, sle.actual_qty > 0) @@ -795,6 +859,36 @@ class update_entries_after(object): if abs(sle.actual_qty) == 0.0: sle.is_cancelled = 1 + if sle.serial_and_batch_bundle and frappe.get_cached_value( + "Item", sle.item_code, "has_serial_no" + ): + self.update_serial_no_status(sle) + + def update_serial_no_status(self, sle): + from erpnext.stock.serial_batch_bundle import get_serial_nos + + serial_nos = get_serial_nos(sle.serial_and_batch_bundle) + if not serial_nos: + return + + warehouse = None + status = "Inactive" + + if sle.actual_qty > 0: + warehouse = sle.warehouse + status = "Active" + + sn_table = frappe.qb.DocType("Serial No") + + query = ( + frappe.qb.update(sn_table) + .set(sn_table.warehouse, warehouse) + .set(sn_table.status, status) + .where(sn_table.name.isin(serial_nos)) + ) + + query.run() + def calculate_valuation_for_serial_batch_bundle(self, sle): doc = frappe.get_cached_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle) @@ -1171,11 +1265,12 @@ class update_entries_after(object): outgoing_rate = get_batch_incoming_rate( item_code=sle.item_code, warehouse=sle.warehouse, - serial_and_batch_bundle=sle.serial_and_batch_bundle, + batch_no=sle.batch_no, posting_date=sle.posting_date, posting_time=sle.posting_time, creation=sle.creation, ) + if outgoing_rate is None: # This can *only* happen if qty available for the batch is zero. # in such case fall back various other rates. @@ -1449,11 +1544,10 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): def get_batch_incoming_rate( - item_code, warehouse, serial_and_batch_bundle, posting_date, posting_time, creation=None + item_code, warehouse, batch_no, posting_date, posting_time, creation=None ): sle = frappe.qb.DocType("Stock Ledger Entry") - batch_ledger = frappe.qb.DocType("Serial and Batch Entry") timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime( posting_date, posting_time @@ -1464,28 +1558,13 @@ def get_batch_incoming_rate( == CombineDatetime(posting_date, posting_time) ) & (sle.creation < creation) - batches = frappe.get_all( - "Serial and Batch Entry", fields=["batch_no"], filters={"parent": serial_and_batch_bundle} - ) - batch_details = ( frappe.qb.from_(sle) - .inner_join(batch_ledger) - .on(sle.serial_and_batch_bundle == batch_ledger.parent) - .select( - Sum( - Case() - .when(sle.actual_qty > 0, batch_ledger.qty * batch_ledger.incoming_rate) - .else_(batch_ledger.qty * batch_ledger.outgoing_rate * -1) - ).as_("batch_value"), - Sum(Case().when(sle.actual_qty > 0, batch_ledger.qty).else_(batch_ledger.qty * -1)).as_( - "batch_qty" - ), - ) + .select(Sum(sle.stock_value_difference).as_("batch_value"), Sum(sle.actual_qty).as_("batch_qty")) .where( (sle.item_code == item_code) & (sle.warehouse == warehouse) - & (batch_ledger.batch_no.isin([row.batch_no for row in batches])) + & (sle.batch_no == batch_no) & (sle.is_cancelled == 0) ) .where(timestamp_condition) diff --git a/erpnext/utilities/web_form/addresses/addresses.json b/erpnext/utilities/web_form/addresses/addresses.json index 2f5e180731..4e2d8e36c2 100644 --- a/erpnext/utilities/web_form/addresses/addresses.json +++ b/erpnext/utilities/web_form/addresses/addresses.json @@ -8,26 +8,29 @@ "allow_print": 0, "amount": 0.0, "amount_based_on_field": 0, + "anonymous": 0, + "apply_document_permissions": 1, + "condition_json": "[]", "creation": "2016-06-24 15:50:33.196990", "doc_type": "Address", "docstatus": 0, "doctype": "Web Form", "idx": 0, "is_standard": 1, + "list_columns": [], + "list_title": "", "login_required": 1, "max_attachment_size": 0, - "modified": "2019-10-15 06:55:30.405119", - "modified_by": "Administrator", + "modified": "2024-01-24 10:28:35.026064", + "modified_by": "rohitw1991@gmail.com", "module": "Utilities", "name": "addresses", "owner": "Administrator", "published": 1, "route": "address", - "route_to_success_link": 0, "show_attachments": 0, - "show_in_grid": 0, + "show_list": 1, "show_sidebar": 0, - "sidebar_items": [], "success_url": "/addresses", "title": "Address", "web_form_fields": [