diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js index e548b4c7e9a8..b3cc1cbb1beb 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.js +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.js @@ -13,10 +13,11 @@ frappe.ui.form.on("Bank Transaction", { }); }, refresh(frm) { - frm.add_custom_button(__('Unreconcile Transaction'), () => { - frm.call('remove_payment_entries') - .then( () => frm.refresh() ); - }); + if (!frm.is_dirty() && frm.doc.payment_entries.length > 0) { + frm.add_custom_button(__("Unreconcile Transaction"), () => { + frm.call("remove_payment_entries").then(() => frm.refresh()); + }); + } }, bank_account: function (frm) { set_bank_statement_filter(frm); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index f131be2dfeda..9a0adf5815d8 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -535,15 +535,21 @@ frappe.ui.form.on('Payment Entry', { }, source_exchange_rate: function(frm) { + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (frm.doc.paid_amount) { frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); // target exchange rate should always be same as source if both account currencies is same if(frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate); frm.set_value("base_received_amount", frm.doc.base_paid_amount); + } else if (company_currency == frm.doc.paid_to_account_currency) { + frm.set_value("received_amount", frm.doc.base_paid_amount); + frm.set_value("base_received_amount", frm.doc.base_paid_amount); } - frm.events.set_unallocated_amount(frm); + // set_unallocated_amount is called by below method, + // no need trigger separately + frm.events.set_total_allocated_amount(frm); } // Make read only if Accounts Settings doesn't allow stale rates @@ -552,6 +558,7 @@ frappe.ui.form.on('Payment Entry', { target_exchange_rate: function(frm) { frm.set_paid_amount_based_on_received_amount = true; + let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; if (frm.doc.received_amount) { frm.set_value("base_received_amount", @@ -561,9 +568,14 @@ frappe.ui.form.on('Payment Entry', { (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency)) { frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate); frm.set_value("base_paid_amount", frm.doc.base_received_amount); + } else if (company_currency == frm.doc.paid_from_account_currency) { + frm.set_value("paid_amount", frm.doc.base_received_amount); + frm.set_value("base_paid_amount", frm.doc.base_received_amount); } - frm.events.set_unallocated_amount(frm); + // set_unallocated_amount is called by below method, + // no need trigger separately + frm.events.set_total_allocated_amount(frm); } frm.set_paid_amount_based_on_received_amount = false; @@ -879,12 +891,18 @@ frappe.ui.form.on('Payment Entry', { }, set_total_allocated_amount: function(frm) { + let exchange_rate = 1; + if (frm.doc.payment_type == "Receive") { + exchange_rate = frm.doc.source_exchange_rate; + } else if (frm.doc.payment_type == "Pay") { + exchange_rate = frm.doc.target_exchange_rate; + } var total_allocated_amount = 0.0; var base_total_allocated_amount = 0.0; $.each(frm.doc.references || [], function(i, row) { if (row.allocated_amount) { total_allocated_amount += flt(row.allocated_amount); - base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(row.exchange_rate), + base_total_allocated_amount += flt(flt(row.allocated_amount)*flt(exchange_rate), precision("base_paid_amount")); } }); diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index 9ed3d32c57fc..2c2efc06455d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -856,6 +856,11 @@ def calculate_base_allocated_amount_for_reference(self, d) -> float: flt(d.allocated_amount) * flt(exchange_rate), self.precision("base_paid_amount") ) + # on rare case, when `exchange_rate` is unset, gain/loss amount is incorrectly calculated + # for base currency transactions + if d.exchange_rate is None: + d.exchange_rate = 1 + allocated_amount_in_pe_exchange_rate = flt( flt(d.allocated_amount) * flt(d.exchange_rate), self.precision("base_paid_amount") ) @@ -2300,7 +2305,7 @@ def set_paid_amount_and_received_amount( if bank_amount: received_amount = bank_amount else: - if company_currency != bank.account_currency: + if bank and company_currency != bank.account_currency: received_amount = paid_amount / doc.get("conversion_rate", 1) else: received_amount = paid_amount * doc.get("conversion_rate", 1) @@ -2309,7 +2314,7 @@ def set_paid_amount_and_received_amount( if bank_amount: paid_amount = bank_amount else: - if company_currency != bank.account_currency: + if bank and company_currency != bank.account_currency: paid_amount = received_amount / doc.get("conversion_rate", 1) else: # if party account currency and bank currency is different then populate paid amount as well diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index 7ef5278d2524..96ae0c30f954 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -116,7 +116,7 @@ def get_jv_entries(self): "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.account_currency as currency, t2.cost_center as cost_center from `tabJournal Entry` t1, `tabJournal Entry Account` t2 where @@ -209,6 +209,7 @@ def get_dr_or_cr_notes(self): "amount": -(inv.outstanding_in_account_currency), "posting_date": inv.posting_date, "currency": inv.currency, + "cost_center": inv.cost_center, } ) ) @@ -357,6 +358,7 @@ def get_allocated_entry(self, pay, inv, allocated_amount): "allocated_amount": allocated_amount, "difference_amount": pay.get("difference_amount"), "currency": inv.get("currency"), + "cost_center": pay.get("cost_center"), } ) @@ -431,6 +433,7 @@ def get_payment_details(self, row, dr_or_cr): "allocated_amount": flt(row.get("allocated_amount")), "difference_amount": flt(row.get("difference_amount")), "difference_account": row.get("difference_account"), + "cost_center": row.get("cost_center"), } ) @@ -603,7 +606,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company): inv.dr_or_cr: abs(inv.allocated_amount), "reference_type": inv.against_voucher_type, "reference_name": inv.against_voucher, - "cost_center": erpnext.get_default_cost_center(company), + "cost_center": inv.cost_center or erpnext.get_default_cost_center(company), "exchange_rate": inv.exchange_rate, "user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} against {inv.against_voucher}", }, @@ -618,7 +621,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company): ), "reference_type": inv.voucher_type, "reference_name": inv.voucher_no, - "cost_center": erpnext.get_default_cost_center(company), + "cost_center": inv.cost_center or erpnext.get_default_cost_center(company), "exchange_rate": inv.exchange_rate, "user_remark": f"{fmt_money(flt(inv.allocated_amount), currency=company_currency)} from {inv.voucher_no}", }, @@ -644,6 +647,7 @@ def reconcile_dr_cr_note(dr_cr_notes, company): create_gain_loss_journal( company, + today(), inv.party_type, inv.party, inv.account, @@ -657,4 +661,5 @@ def reconcile_dr_cr_note(dr_cr_notes, company): inv.against_voucher_type, inv.against_voucher, None, + inv.cost_center, ) 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 0f7e47acfee4..ec718aa70d31 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json +++ b/erpnext/accounts/doctype/payment_reconciliation_allocation/payment_reconciliation_allocation.json @@ -22,7 +22,8 @@ "column_break_7", "difference_account", "exchange_rate", - "currency" + "currency", + "cost_center" ], "fields": [ { @@ -144,11 +145,17 @@ "fieldtype": "Float", "label": "Exchange Rate", "read_only": 1 + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" } ], "istable": 1, "links": [], - "modified": "2022-12-24 21:01:14.882747", + "modified": "2023-09-03 07:52:33.684217", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Allocation", diff --git a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json index d300ea97abc4..17f3900880c6 100644 --- a/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json +++ b/erpnext/accounts/doctype/payment_reconciliation_payment/payment_reconciliation_payment.json @@ -16,7 +16,8 @@ "sec_break1", "remark", "currency", - "exchange_rate" + "exchange_rate", + "cost_center" ], "fields": [ { @@ -98,11 +99,17 @@ "fieldtype": "Float", "hidden": 1, "label": "Exchange Rate" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" } ], "istable": 1, "links": [], - "modified": "2022-11-08 18:18:36.268760", + "modified": "2023-09-03 07:43:29.965353", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation Payment", diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index 49472484ef44..af1c06643a1c 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -126,7 +126,7 @@ def check_if_previous_year_closed(self): def make_gl_entries(self, get_opening_entries=False): gl_entries = self.get_gl_entries() closing_entries = self.get_grouped_gl_entries(get_opening_entries=get_opening_entries) - if len(gl_entries) > 5000: + if len(gl_entries + closing_entries) > 3000: frappe.enqueue( process_gl_entries, gl_entries=gl_entries, diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 8bd7b5a3fee5..b99bb83c5b22 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -409,7 +409,7 @@ def get_party_account(party_type, party=None, company=None, include_advance=Fals if (account and account_currency != existing_gle_currency) or not account: account = get_party_gle_account(party_type, party, company) - if include_advance and party_type in ["Customer", "Supplier"]: + if include_advance and party_type in ["Customer", "Supplier", "Student"]: advance_account = get_party_advance_account(party_type, party, company) if advance_account: return [account, advance_account] diff --git a/erpnext/accounts/report/accounts_payable/accounts_payable.js b/erpnext/accounts/report/accounts_payable/accounts_payable.js index e1a30a4b77e0..27a85701edda 100644 --- a/erpnext/accounts/report/accounts_payable/accounts_payable.js +++ b/erpnext/accounts/report/accounts_payable/accounts_payable.js @@ -37,24 +37,6 @@ frappe.query_reports["Accounts Payable"] = { } } }, - { - "fieldname": "supplier", - "label": __("Supplier"), - "fieldtype": "Link", - "options": "Supplier", - on_change: () => { - var supplier = frappe.query_report.get_filter_value('supplier'); - if (supplier) { - frappe.db.get_value('Supplier', supplier, "tax_id", function(value) { - frappe.query_report.set_filter_value('tax_id', value["tax_id"]); - }); - } else { - frappe.query_report.set_filter_value('tax_id', ""); - } - - frappe.query_report.refresh(); - } - }, { "fieldname": "party_account", "label": __("Payable Account"), @@ -112,11 +94,38 @@ frappe.query_reports["Accounts Payable"] = { "fieldtype": "Link", "options": "Payment Terms Template" }, + { + "fieldname": "party_type", + "label": __("Party Type"), + "fieldtype": "Link", + "options": "Party Type", + get_query: () => { + return { + filters: { + 'account_type': 'Payable' + } + }; + }, + on_change: () => { + frappe.query_report.set_filter_value('party', ""); + let party_type = frappe.query_report.get_filter_value('party_type'); + frappe.query_report.toggle_filter_display('supplier_group', frappe.query_report.get_filter_value('party_type') !== "Supplier"); + + } + + }, + { + "fieldname":"party", + "label": __("Party"), + "fieldtype": "Dynamic Link", + "options": "party_type", + }, { "fieldname": "supplier_group", "label": __("Supplier Group"), "fieldtype": "Link", - "options": "Supplier Group" + "options": "Supplier Group", + "hidden": 1 }, { "fieldname": "group_by_party", @@ -133,12 +142,6 @@ frappe.query_reports["Accounts Payable"] = { "label": __("Show Remarks"), "fieldtype": "Check", }, - { - "fieldname": "tax_id", - "label": __("Tax Id"), - "fieldtype": "Data", - "hidden": 1 - }, { "fieldname": "show_future_payments", "label": __("Show Future Payments"), diff --git a/erpnext/accounts/report/accounts_payable/test_accounts_payable.py b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py new file mode 100644 index 000000000000..cb84cf4fc0a6 --- /dev/null +++ b/erpnext/accounts/report/accounts_payable/test_accounts_payable.py @@ -0,0 +1,67 @@ +import unittest + +import frappe +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils import add_days, flt, getdate, today + +from erpnext import get_default_cost_center +from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice +from erpnext.accounts.report.accounts_payable.accounts_payable import execute +from erpnext.accounts.test.accounts_mixin import AccountsTestMixin +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order + + +class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): + def setUp(self): + self.create_company() + self.create_customer() + self.create_item() + self.create_supplier(currency="USD", supplier_name="Test Supplier2") + self.create_usd_payable_account() + + def tearDown(self): + frappe.db.rollback() + + def test_accounts_receivable_with_supplier(self): + pi = self.create_purchase_invoice(do_not_submit=True) + pi.currency = "USD" + pi.conversion_rate = 80 + pi.credit_to = self.creditors_usd + pi = pi.save().submit() + + filters = { + "company": self.company, + "party_type": "Supplier", + "party": self.supplier, + "report_date": today(), + "range1": 30, + "range2": 60, + "range3": 90, + "range4": 120, + } + + data = execute(filters) + self.assertEqual(data[1][0].get("outstanding"), 300) + self.assertEqual(data[1][0].get("currency"), "USD") + + def create_purchase_invoice(self, do_not_submit=False): + frappe.set_user("Administrator") + pi = make_purchase_invoice( + item=self.item, + company=self.company, + supplier=self.supplier, + is_return=False, + update_stock=False, + posting_date=frappe.utils.datetime.date(2021, 5, 1), + do_not_save=1, + rate=300, + price_list_rate=300, + qty=1, + ) + + pi = pi.save() + if not do_not_submit: + pi = pi.submit() + return pi diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js index 0b4e577f6cb3..cb8ec876e9ee 100644 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.js +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.js @@ -46,8 +46,7 @@ frappe.query_reports["Accounts Receivable"] = { var customer = frappe.query_report.get_filter_value('customer'); var company = frappe.query_report.get_filter_value('company'); if (customer) { - frappe.db.get_value('Customer', customer, ["tax_id", "customer_name", "payment_terms"], function(value) { - frappe.query_report.set_filter_value('tax_id', value["tax_id"]); + frappe.db.get_value('Customer', customer, ["customer_name", "payment_terms"], function(value) { frappe.query_report.set_filter_value('customer_name', value["customer_name"]); frappe.query_report.set_filter_value('payment_terms', value["payment_terms"]); }); @@ -59,7 +58,6 @@ frappe.query_reports["Accounts Receivable"] = { } }, "Customer"); } else { - frappe.query_report.set_filter_value('tax_id', ""); frappe.query_report.set_filter_value('customer_name', ""); frappe.query_report.set_filter_value('credit_limit', ""); frappe.query_report.set_filter_value('payment_terms', ""); @@ -172,12 +170,6 @@ frappe.query_reports["Accounts Receivable"] = { "label": __("Show Sales Person"), "fieldtype": "Check", }, - { - "fieldname": "tax_id", - "label": __("Tax Id"), - "fieldtype": "Data", - "hidden": 1 - }, { "fieldname": "show_remarks", "label": __("Show Remarks"), diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 751063ad8e64..3700f00ee226 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -211,11 +211,10 @@ def update_voucher_balance(self, ple): return # amount in "Party Currency", if its supplied. If not, amount in company currency - for party_type in self.party_type: - if self.filters.get(scrub(party_type)): - amount = ple.amount_in_account_currency - else: - amount = ple.amount + if self.filters.get("party_type") and self.filters.get("party"): + amount = ple.amount_in_account_currency + else: + amount = ple.amount amount_in_account_currency = ple.amount_in_account_currency # update voucher @@ -426,10 +425,9 @@ def set_party_details(self, row): # customer / supplier name party_details = self.get_party_details(row.party) or {} row.update(party_details) - for party_type in self.party_type: - if self.filters.get(scrub(party_type)): - row.currency = row.account_currency - break + + if self.filters.get("party_type") and self.filters.get("party"): + row.currency = row.account_currency else: row.currency = self.company_currency @@ -765,6 +763,7 @@ def get_sales_invoices_or_customers_based_on_sales_person(self): def prepare_conditions(self): self.qb_selection_filter = [] self.or_filters = [] + for party_type in self.party_type: party_type_field = scrub(party_type) self.or_filters.append(self.ple.party_type == party_type) @@ -800,6 +799,12 @@ def add_common_filters(self, party_type_field): if self.filters.get(party_type_field): self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field)) + if self.filters.get("party_type"): + self.qb_selection_filter.append(self.filters.party_type == self.ple.party_type) + + if self.filters.get("party"): + self.qb_selection_filter.append(self.filters.party == self.ple.party) + if self.filters.party_account: self.qb_selection_filter.append(self.ple.account == self.filters.party_account) else: diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.js b/erpnext/accounts/report/balance_sheet/balance_sheet.js index c65b9e8ccc74..ecc13d7dc8aa 100644 --- a/erpnext/accounts/report/balance_sheet/balance_sheet.js +++ b/erpnext/accounts/report/balance_sheet/balance_sheet.js @@ -15,7 +15,6 @@ frappe.require("assets/erpnext/js/financial_statements.js", function () { fieldtype: "Check", default: 1, }); - console.log(frappe.query_reports["Balance Sheet"]["filters"]); frappe.query_reports["Balance Sheet"]["filters"].push({ fieldname: "include_default_book_entries", diff --git a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py index 8b929bf472fa..5d3d4d74978c 100644 --- a/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py +++ b/erpnext/accounts/report/item_wise_purchase_register/item_wise_purchase_register.py @@ -287,7 +287,7 @@ def get_conditions(filters): conditions = "" for opts in ( - ("company", " and company=%(company)s"), + ("company", " and `tabPurchase Invoice`.company=%(company)s"), ("supplier", " and `tabPurchase Invoice`.supplier = %(supplier)s"), ("item_code", " and `tabPurchase Invoice Item`.item_code = %(item_code)s"), ("from_date", " and `tabPurchase Invoice`.posting_date>=%(from_date)s"), diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py index 1e7ac33c3256..ce22d7566c16 100644 --- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py +++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py @@ -332,7 +332,7 @@ def get_conditions(filters, additional_conditions=None): conditions = "" for opts in ( - ("company", " and company=%(company)s"), + ("company", " and `tabSales Invoice`.company=%(company)s"), ("customer", " and `tabSales Invoice`.customer = %(customer)s"), ("item_code", " and `tabSales Invoice Item`.item_code = %(item_code)s"), ("from_date", " and `tabSales Invoice`.posting_date>=%(from_date)s"), diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index c7b7e2f7c128..162e5b57c7a3 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -10,8 +10,8 @@ from erpnext.accounts.party import get_party_account from erpnext.accounts.report.utils import ( + apply_common_conditions, get_advance_taxes_and_charges, - get_conditions, get_journal_entries, get_opening_row, get_party_details, @@ -378,11 +378,8 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): pi = frappe.qb.DocType("Purchase Invoice") - invoice_item = frappe.qb.DocType("Purchase Invoice Item") query = ( frappe.qb.from_(pi) - .inner_join(invoice_item) - .on(pi.name == invoice_item.parent) .select( ConstantColumn("Purchase Invoice").as_("doctype"), pi.name, @@ -396,29 +393,46 @@ def get_invoices(filters, additional_query_columns): pi.remarks, pi.base_net_total, pi.base_grand_total, + pi.base_rounded_total, pi.outstanding_amount, pi.mode_of_payment, ) .where((pi.docstatus == 1)) .orderby(pi.posting_date, pi.name, order=Order.desc) ) + if additional_query_columns: for col in additional_query_columns: query = query.select(col) + if filters.get("supplier"): query = query.where(pi.supplier == filters.supplier) - query = get_conditions( + + query = get_conditions(filters, query, "Purchase Invoice") + + query = apply_common_conditions( filters, query, doctype="Purchase Invoice", child_doctype="Purchase Invoice Item" ) + if filters.get("include_payments"): party_account = get_party_account( "Supplier", filters.get("supplier"), filters.get("company"), include_advance=True ) query = query.where(pi.credit_to.isin(party_account)) + invoices = query.run(as_dict=True) return invoices +def get_conditions(filters, query, doctype): + parent_doc = frappe.qb.DocType(doctype) + + if filters.get("mode_of_payment"): + query = query.where(parent_doc.mode_of_payment == filters.mode_of_payment) + + return query + + def get_payments(filters): args = frappe._dict( account="credit_to", diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py index 35d8d164794d..0ba7186fa674 100644 --- a/erpnext/accounts/report/sales_register/sales_register.py +++ b/erpnext/accounts/report/sales_register/sales_register.py @@ -11,8 +11,8 @@ from erpnext.accounts.party import get_party_account from erpnext.accounts.report.utils import ( + apply_common_conditions, get_advance_taxes_and_charges, - get_conditions, get_journal_entries, get_opening_row, get_party_details, @@ -38,7 +38,7 @@ def _execute(filters, additional_table_columns=None): if filters.get("include_payments"): invoice_list += get_payments(filters) - columns, income_accounts, tax_accounts, unrealized_profit_loss_accounts = get_columns( + columns, income_accounts, unrealized_profit_loss_accounts, tax_accounts = get_columns( invoice_list, additional_table_columns, include_payments ) @@ -415,14 +415,8 @@ def get_account_columns(invoice_list, include_payments): def get_invoices(filters, additional_query_columns): si = frappe.qb.DocType("Sales Invoice") - invoice_item = frappe.qb.DocType("Sales Invoice Item") - invoice_payment = frappe.qb.DocType("Sales Invoice Payment") query = ( frappe.qb.from_(si) - .inner_join(invoice_item) - .on(si.name == invoice_item.parent) - .left_join(invoice_payment) - .on(si.name == invoice_payment.parent) .select( ConstantColumn("Sales Invoice").as_("doctype"), si.name, @@ -447,18 +441,36 @@ def get_invoices(filters, additional_query_columns): .where((si.docstatus == 1)) .orderby(si.posting_date, si.name, order=Order.desc) ) + if additional_query_columns: for col in additional_query_columns: query = query.select(col) + if filters.get("customer"): query = query.where(si.customer == filters.customer) - query = get_conditions( + + query = get_conditions(filters, query, "Sales Invoice") + query = apply_common_conditions( filters, query, doctype="Sales Invoice", child_doctype="Sales Invoice Item" ) + invoices = query.run(as_dict=True) return invoices +def get_conditions(filters, query, doctype): + parent_doc = frappe.qb.DocType(doctype) + if filters.get("owner"): + query = query.where(parent_doc.owner == filters.owner) + + if filters.get("mode_of_payment"): + payment_doc = frappe.qb.DocType("Sales Invoice Payment") + query = query.inner_join(payment_doc).on(parent_doc.name == payment_doc.parent) + query = query.where(payment_doc.mode_of_payment == filters.mode_of_payment).distinct() + + return query + + def get_payments(filters): args = frappe._dict( account="debit_to", diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py index 0753fff83444..9f96449ba7c4 100644 --- a/erpnext/accounts/report/utils.py +++ b/erpnext/accounts/report/utils.py @@ -256,7 +256,8 @@ def get_journal_entries(filters, args): ) .orderby(je.posting_date, je.name, order=Order.desc) ) - query = get_conditions(filters, query, doctype="Journal Entry", payments=True) + query = apply_common_conditions(filters, query, doctype="Journal Entry", payments=True) + journal_entries = query.run(as_dict=True) return journal_entries @@ -284,28 +285,17 @@ def get_payment_entries(filters, args): ) .orderby(pe.posting_date, pe.name, order=Order.desc) ) - query = get_conditions(filters, query, doctype="Payment Entry", payments=True) + query = apply_common_conditions(filters, query, doctype="Payment Entry", payments=True) payment_entries = query.run(as_dict=True) return payment_entries -def get_conditions(filters, query, doctype, child_doctype=None, payments=False): +def apply_common_conditions(filters, query, doctype, child_doctype=None, payments=False): parent_doc = frappe.qb.DocType(doctype) if child_doctype: child_doc = frappe.qb.DocType(child_doctype) - if parent_doc.get_table_name() == "tabSales Invoice": - if filters.get("owner"): - query = query.where(parent_doc.owner == filters.owner) - if filters.get("mode_of_payment"): - payment_doc = frappe.qb.DocType("Sales Invoice Payment") - query = query.where(payment_doc.mode_of_payment == filters.mode_of_payment) - if not payments: - if filters.get("brand"): - query = query.where(child_doc.brand == filters.brand) - else: - if filters.get("mode_of_payment"): - query = query.where(parent_doc.mode_of_payment == filters.mode_of_payment) + join_required = False if filters.get("company"): query = query.where(parent_doc.company == filters.company) @@ -320,13 +310,26 @@ def get_conditions(filters, query, doctype, child_doctype=None, payments=False): else: if filters.get("cost_center"): query = query.where(child_doc.cost_center == filters.cost_center) + join_required = True if filters.get("warehouse"): query = query.where(child_doc.warehouse == filters.warehouse) + join_required = True if filters.get("item_group"): query = query.where(child_doc.item_group == filters.item_group) + join_required = True + + if not payments: + if filters.get("brand"): + query = query.where(child_doc.brand == filters.brand) + join_required = True + + if join_required: + query = query.inner_join(child_doc).on(parent_doc.name == child_doc.parent) + query = query.distinct() if parent_doc.get_table_name() != "tabJournal Entry": query = filter_invoices_based_on_dimensions(filters, query, parent_doc) + return query diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py index bf01362c97ff..08688608f4b1 100644 --- a/erpnext/accounts/test/accounts_mixin.py +++ b/erpnext/accounts/test/accounts_mixin.py @@ -126,6 +126,28 @@ def create_usd_receivable_account(self): acc = frappe.get_doc("Account", name) self.debtors_usd = acc.name + def create_usd_payable_account(self): + account_name = "Creditors USD" + if not frappe.db.get_value( + "Account", filters={"account_name": account_name, "company": self.company} + ): + acc = frappe.new_doc("Account") + acc.account_name = account_name + acc.parent_account = "Accounts Payable - " + self.company_abbr + acc.company = self.company + acc.account_currency = "USD" + acc.account_type = "Payable" + acc.insert() + else: + name = frappe.db.get_value( + "Account", + filters={"account_name": account_name, "company": self.company}, + fieldname="name", + pluck=True, + ) + acc = frappe.get_doc("Account", name) + self.creditors_usd = acc.name + def clear_old_entries(self): doctype_list = [ "GL Entry", diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 1aefeaacf787..eed74a5f0177 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -474,10 +474,12 @@ def reconcile_against_document(args, skip_ref_details_update_for_pe=False): # n # update ref in advance entry if voucher_type == "Journal Entry": - update_reference_in_journal_entry(entry, doc, do_not_save=True) + referenced_row = update_reference_in_journal_entry(entry, doc, do_not_save=False) # advance section in sales/purchase invoice and reconciliation tool,both pass on exchange gain/loss # amount and account in args - doc.make_exchange_gain_loss_journal(args) + # referenced_row is used to deduplicate gain/loss journal + entry.update({"referenced_row": referenced_row}) + doc.make_exchange_gain_loss_journal([entry]) else: update_reference_in_payment_entry( entry, doc, do_not_save=True, skip_ref_details_update_for_pe=skip_ref_details_update_for_pe @@ -627,6 +629,8 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False): if not do_not_save: journal_entry.save(ignore_permissions=True) + return new_row.name + def update_reference_in_payment_entry( d, payment_entry, do_not_save=False, skip_ref_details_update_for_pe=False @@ -1164,7 +1168,7 @@ def parse_naming_series_variable(doc, variable): @frappe.whitelist() -def get_coa(doctype, parent, is_root, chart=None): +def get_coa(doctype, parent, is_root=None, chart=None): from erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts import ( build_tree_from_json, ) @@ -1750,6 +1754,7 @@ def query_for_outstanding(self): ple.posting_date, ple.due_date, ple.account_currency.as_("currency"), + ple.cost_center.as_("cost_center"), Sum(ple.amount).as_("amount"), Sum(ple.amount_in_account_currency).as_("amount_in_account_currency"), ) @@ -1812,6 +1817,7 @@ def query_for_outstanding(self): ).as_("paid_amount_in_account_currency"), Table("vouchers").due_date, Table("vouchers").currency, + Table("vouchers").cost_center.as_("cost_center"), ) .where(Criterion.all(filter_on_outstanding_amount)) ) @@ -1882,6 +1888,7 @@ def get_voucher_outstandings( def create_gain_loss_journal( company, + posting_date, party_type, party, party_account, @@ -1895,12 +1902,14 @@ def create_gain_loss_journal( ref2_dt, ref2_dn, ref2_detail_no, + cost_center, ) -> str: journal_entry = frappe.new_doc("Journal Entry") journal_entry.voucher_type = "Exchange Gain Or Loss" journal_entry.company = company - journal_entry.posting_date = nowdate() + journal_entry.posting_date = posting_date or nowdate() journal_entry.multi_currency = 1 + journal_entry.is_system_generated = True party_account_currency = frappe.get_cached_value("Account", party_account, "account_currency") @@ -1919,7 +1928,7 @@ def create_gain_loss_journal( "party": party, "account_currency": party_account_currency, "exchange_rate": 0, - "cost_center": erpnext.get_default_cost_center(company), + "cost_center": cost_center or erpnext.get_default_cost_center(company), "reference_type": ref1_dt, "reference_name": ref1_dn, "reference_detail_no": ref1_detail_no, @@ -1935,7 +1944,7 @@ def create_gain_loss_journal( "account": gain_loss_account, "account_currency": gain_loss_account_currency, "exchange_rate": 1, - "cost_center": erpnext.get_default_cost_center(company), + "cost_center": cost_center or erpnext.get_default_cost_center(company), "reference_type": ref2_dt, "reference_name": ref2_dn, "reference_detail_no": ref2_detail_no, diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index ddb09c1f44b7..b5a4c2df76be 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -840,7 +840,7 @@ def make_journal_entry(asset_name): je.voucher_type = "Depreciation Entry" je.naming_series = depreciation_series je.company = asset.company - je.remark = _("Depreciation Entry against asset {0}").format(asset_name) + je.remark = ("Depreciation Entry against asset {0}").format(asset_name) je.append( "accounts", diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py index 0bf2fbb14b67..662e4b983b1e 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.py @@ -404,14 +404,11 @@ def get_gl_entries_for_consumed_stock_items( def get_gl_entries_for_consumed_asset_items( self, gl_entries, target_account, target_against, precision ): - self.are_all_asset_items_non_depreciable = True - # Consumed Assets for item in self.asset_items: asset = frappe.get_doc("Asset", item.asset) if asset.calculate_depreciation: - self.are_all_asset_items_non_depreciable = False notes = _( "This schedule was created when Asset {0} was consumed through Asset Capitalization {1}." ).format( diff --git a/erpnext/assets/doctype/location/location.json b/erpnext/assets/doctype/location/location.json index f56fd05d98c8..9202fb9d95f6 100644 --- a/erpnext/assets/doctype/location/location.json +++ b/erpnext/assets/doctype/location/location.json @@ -39,6 +39,7 @@ { "fieldname": "parent_location", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Parent Location", "options": "Location", "search_index": 1 @@ -141,11 +142,11 @@ ], "is_tree": 1, "links": [], - "modified": "2020-05-08 16:11:11.375701", + "modified": "2023-08-29 12:49:33.290527", "modified_by": "Administrator", "module": "Assets", "name": "Location", - "name_case": "Title Case", + "naming_rule": "By fieldname", "nsm_parent_field": "parent_location", "owner": "Administrator", "permissions": [ @@ -224,5 +225,6 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index f6a195143922..88faeee982bc 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -118,6 +118,24 @@ frappe.ui.form.on("Purchase Order", { frm.set_value("tax_withholding_category", frm.supplier_tds); } }, + + get_subcontracting_boms_for_finished_goods: function(fg_item) { + return frappe.call({ + method:"erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_finished_goods", + args: { + fg_items: fg_item + }, + }); + }, + + get_subcontracting_boms_for_service_item: function(service_item) { + return frappe.call({ + method:"erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom.get_subcontracting_boms_for_service_item", + args: { + service_item: service_item + }, + }); + }, }); frappe.ui.form.on("Purchase Order Item", { @@ -132,15 +150,83 @@ frappe.ui.form.on("Purchase Order Item", { } }, - qty: function(frm, cdt, cdn) { + item_code: async function(frm, cdt, cdn) { if (frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow) { var row = locals[cdt][cdn]; - if (row.qty) { - row.fg_item_qty = row.qty; + if (row.item_code && !row.fg_item) { + var result = await frm.events.get_subcontracting_boms_for_service_item(row.item_code) + + if (result.message && Object.keys(result.message).length) { + var finished_goods = Object.keys(result.message); + + // Set FG if only one active Subcontracting BOM is found + if (finished_goods.length === 1) { + row.fg_item = result.message[finished_goods[0]].finished_good; + row.uom = result.message[finished_goods[0]].finished_good_uom; + refresh_field("items"); + } else { + const dialog = new frappe.ui.Dialog({ + title: __("Select Finished Good"), + size: "small", + fields: [ + { + fieldname: "finished_good", + fieldtype: "Autocomplete", + label: __("Finished Good"), + options: finished_goods, + } + ], + primary_action_label: __("Select"), + primary_action: () => { + var subcontracting_bom = result.message[dialog.get_value("finished_good")]; + + if (subcontracting_bom) { + row.fg_item = subcontracting_bom.finished_good; + row.uom = subcontracting_bom.finished_good_uom; + refresh_field("items"); + } + + dialog.hide(); + }, + }); + + dialog.show(); + } + } } } - } + }, + + fg_item: async function(frm, cdt, cdn) { + if (frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow) { + var row = locals[cdt][cdn]; + + if (row.fg_item) { + var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item) + + if (result.message && Object.keys(result.message).length) { + frappe.model.set_value(cdt, cdn, "item_code", result.message.service_item); + frappe.model.set_value(cdt, cdn, "qty", flt(row.fg_item_qty) * flt(result.message.conversion_factor)); + frappe.model.set_value(cdt, cdn, "uom", result.message.service_item_uom); + } + } + } + }, + + fg_item_qty: async function(frm, cdt, cdn) { + if (frm.doc.is_subcontracted && !frm.doc.is_old_subcontracting_flow) { + var row = locals[cdt][cdn]; + + if (row.fg_item) { + var result = await frm.events.get_subcontracting_boms_for_finished_goods(row.fg_item) + + if (result.message && row.item_code == result.message.service_item && row.uom == result.message.service_item_uom) { + frappe.model.set_value(cdt, cdn, "qty", flt(row.fg_item_qty) * flt(result.message.conversion_factor)); + } + } + } + }, }); erpnext.buying.PurchaseOrderController = class PurchaseOrderController extends erpnext.buying.BuyingController { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index 3576cd426d6f..465fe96b58b5 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -28,6 +28,9 @@ from erpnext.stock.doctype.item.item import get_item_defaults, get_last_purchase_details from erpnext.stock.stock_balance import get_ordered_qty, update_bin_qty from erpnext.stock.utils import get_bin +from erpnext.subcontracting.doctype.subcontracting_bom.subcontracting_bom import ( + get_subcontracting_boms_for_finished_goods, +) form_grid_templates = {"items": "templates/form_grid/item_grid.html"} @@ -451,6 +454,25 @@ def update_receiving_percentage(self): else: self.db_set("per_received", 0, update_modified=False) + def set_service_items_for_finished_goods(self): + if not self.is_subcontracted or self.is_old_subcontracting_flow: + return + + finished_goods_without_service_item = { + d.fg_item for d in self.items if (not d.item_code and d.fg_item) + } + + if subcontracting_boms := get_subcontracting_boms_for_finished_goods( + finished_goods_without_service_item + ): + for item in self.items: + if not item.item_code and item.fg_item in subcontracting_boms: + subcontracting_bom = subcontracting_boms[item.fg_item] + + item.item_code = subcontracting_bom.service_item + item.qty = flt(item.fg_item_qty) * flt(subcontracting_bom.conversion_factor) + item.uom = subcontracting_bom.service_item_uom + def can_update_items(self) -> bool: result = True diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index c645b04e1294..414f0866ccb2 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -7,13 +7,13 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "fg_item", + "fg_item_qty", "item_code", "supplier_part_no", "item_name", "brand", "product_bundle", - "fg_item", - "fg_item_qty", "column_break_4", "schedule_date", "expected_delivery_date", @@ -862,7 +862,7 @@ "depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow", "fieldname": "fg_item", "fieldtype": "Link", - "label": "Finished Good Item", + "label": "Finished Good", "mandatory_depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow", "options": "Item" }, @@ -871,7 +871,7 @@ "depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow", "fieldname": "fg_item_qty", "fieldtype": "Float", - "label": "Finished Good Item Qty", + "label": "Finished Good Qty", "mandatory_depends_on": "eval:parent.is_subcontracted && !parent.is_old_subcontracting_flow" }, { @@ -902,7 +902,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-11-29 16:47:41.364387", + "modified": "2023-08-17 10:17:40.893393", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 1d5063904387..0ca1e94427ef 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -1023,6 +1023,44 @@ def make_precision_loss_gl_entry(self, gl_entries): ) ) + def gain_loss_journal_already_booked( + self, + gain_loss_account, + exc_gain_loss, + ref2_dt, + ref2_dn, + ref2_detail_no, + ) -> bool: + """ + Check if gain/loss is booked + """ + if res := frappe.db.get_all( + "Journal Entry Account", + filters={ + "docstatus": 1, + "account": gain_loss_account, + "reference_type": ref2_dt, # this will be Journal Entry + "reference_name": ref2_dn, + "reference_detail_no": ref2_detail_no, + }, + pluck="parent", + ): + # deduplicate + res = list({x for x in res}) + if exc_vouchers := frappe.db.get_all( + "Journal Entry", + filters={"name": ["in", res], "voucher_type": "Exchange Gain Or Loss"}, + fields=["voucher_type", "total_debit", "total_credit"], + ): + booked_voucher = exc_vouchers[0] + if ( + booked_voucher.total_debit == exc_gain_loss + and booked_voucher.total_credit == exc_gain_loss + and booked_voucher.voucher_type == "Exchange Gain Or Loss" + ): + return True + return False + def make_exchange_gain_loss_journal(self, args: dict = None) -> None: """ Make Exchange Gain/Loss journal for Invoices and Payments @@ -1051,27 +1089,37 @@ def make_exchange_gain_loss_journal(self, args: dict = None) -> None: reverse_dr_or_cr = "debit" if dr_or_cr == "credit" else "credit" - je = create_gain_loss_journal( - self.company, - arg.get("party_type"), - arg.get("party"), - party_account, + if not self.gain_loss_journal_already_booked( gain_loss_account, difference_amount, - dr_or_cr, - reverse_dr_or_cr, - arg.get("against_voucher_type"), - arg.get("against_voucher"), - arg.get("idx"), self.doctype, self.name, - arg.get("idx"), - ) - frappe.msgprint( - _("Exchange Gain/Loss amount has been booked through {0}").format( - get_link_to_form("Journal Entry", je) + arg.get("referenced_row"), + ): + posting_date = frappe.db.get_value(arg.voucher_type, arg.voucher_no, "posting_date") + je = create_gain_loss_journal( + self.company, + posting_date, + arg.get("party_type"), + arg.get("party"), + party_account, + gain_loss_account, + difference_amount, + dr_or_cr, + reverse_dr_or_cr, + arg.get("against_voucher_type"), + arg.get("against_voucher"), + arg.get("idx"), + self.doctype, + self.name, + arg.get("referenced_row"), + arg.get("cost_center"), + ) + frappe.msgprint( + _("Exchange Gain/Loss amount has been booked through {0}").format( + get_link_to_form("Journal Entry", je) + ) ) - ) if self.get("doctype") == "Payment Entry": # For Payment Entry, exchange_gain_loss field in the `references` table is the trigger for journal creation @@ -1131,6 +1179,7 @@ def make_exchange_gain_loss_journal(self, args: dict = None) -> None: je = create_gain_loss_journal( self.company, + self.posting_date, self.party_type, self.party, party_account, @@ -1144,6 +1193,7 @@ def make_exchange_gain_loss_journal(self, args: dict = None) -> None: self.doctype, self.name, d.idx, + self.cost_center, ) frappe.msgprint( _("Exchange Gain/Loss amount has been booked through {0}").format( @@ -1381,7 +1431,7 @@ def make_discount_gl_entries(self, gl_entries): { "account": self.additional_discount_account, "against": supplier_or_customer, - dr_or_cr: self.discount_amount, + dr_or_cr: self.base_discount_amount, "cost_center": self.cost_center, }, item=self, @@ -1653,6 +1703,7 @@ def validate_currency(self): and party_account_currency != self.company_currency and self.currency != party_account_currency ): + frappe.throw( _("Accounting Entry for {0}: {1} can only be made in currency: {2}").format( party_type, party, party_account_currency @@ -2386,7 +2437,8 @@ def get_common_query( limit, condition, ): - payment_type = "Receive" if party_type == "Customer" else "Pay" + account_type = frappe.db.get_value("Party Type", party_type, "account_type") + payment_type = "Receive" if account_type == "Receivable" else "Pay" payment_entry = frappe.qb.DocType("Payment Entry") q = ( @@ -2403,7 +2455,7 @@ def get_common_query( .where(payment_entry.docstatus == 1) ) - if party_type == "Customer": + if payment_type == "Receive": q = q.select((payment_entry.paid_from_account_currency).as_("currency")) q = q.select(payment_entry.paid_from) q = q.where(payment_entry.paid_from.isin(party_account)) @@ -3095,7 +3147,9 @@ def validate_fg_item_for_subcontracting(new_data, is_new): if has_reserved_stock(parent.doctype, parent.name): cancel_stock_reservation_entries(parent.doctype, parent.name) - parent.create_stock_reservation_entries() + + if parent.per_picked == 0: + parent.create_stock_reservation_entries() @erpnext.allow_regional diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index 0f8e133e0fdc..391258fde778 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -55,6 +55,7 @@ class TestAccountsController(FrappeTestCase): 10 series - Sales Invoice against Payment Entries 20 series - Sales Invoice against Journals 30 series - Sales Invoice against Credit Notes + 40 series - Company default Cost center is unset """ def setUp(self): @@ -941,6 +942,60 @@ def test_23_same_journal_split_against_single_invoice(self): self.assertEqual(exc_je_for_si, []) self.assertEqual(exc_je_for_je, []) + def test_24_journal_against_multiple_invoices(self): + si1 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) + si2 = self.create_sales_invoice(qty=1, conversion_rate=80, rate=1) + + # Payment + je = self.create_journal_entry( + acc1=self.debit_usd, + acc1_exc_rate=75, + acc2=self.cash, + acc1_amount=-2, + acc2_amount=-150, + acc2_exc_rate=1, + ) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je = je.save().submit() + + pr = self.create_payment_reconciliation() + pr.get_unreconciled_entries() + self.assertEqual(len(pr.invoices), 2) + 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) + + si1.reload() + si2.reload() + + self.assertEqual(si1.outstanding_amount, 0) + self.assertEqual(si2.outstanding_amount, 0) + self.assert_ledger_outstanding(si1.doctype, si1.name, 0.0, 0.0) + self.assert_ledger_outstanding(si2.doctype, si2.name, 0.0, 0.0) + + # Exchange Gain/Loss Journal should've been created + # remove payment JE from list + exc_je_for_si1 = [x for x in self.get_journals_for(si1.doctype, si1.name) if x.parent != je.name] + exc_je_for_si2 = [x for x in self.get_journals_for(si2.doctype, si2.name) if x.parent != je.name] + exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name] + self.assertEqual(len(exc_je_for_si1), 1) + self.assertEqual(len(exc_je_for_si2), 1) + self.assertEqual(len(exc_je_for_je), 2) + + si1.cancel() + # Gain/Loss JE of si1 should've been cancelled + exc_je_for_si1 = [x for x in self.get_journals_for(si1.doctype, si1.name) if x.parent != je.name] + exc_je_for_si2 = [x for x in self.get_journals_for(si2.doctype, si2.name) if x.parent != je.name] + exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name] + self.assertEqual(len(exc_je_for_si1), 0) + self.assertEqual(len(exc_je_for_si2), 1) + self.assertEqual(len(exc_je_for_je), 1) + def test_30_cr_note_against_sales_invoice(self): """ Reconciling Cr Note against Sales Invoice, both having different exchange rates @@ -997,3 +1052,139 @@ def test_30_cr_note_against_sales_invoice(self): si.reload() self.assertEqual(si.outstanding_amount, 1) self.assert_ledger_outstanding(si.doctype, si.name, 80.0, 1.0) + + def test_40_cost_center_from_payment_entry(self): + """ + Gain/Loss JE should inherit cost center from payment if company default is unset + """ + # remove default cost center + cc = frappe.db.get_value("Company", self.company, "cost_center") + frappe.db.set_value("Company", self.company, "cost_center", None) + + rate_in_account_currency = 1 + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si.cost_center = None + si.save().submit() + + pe = get_payment_entry(si.doctype, si.name) + pe.source_exchange_rate = 75 + pe.received_amount = 75 + pe.cost_center = self.cost_center + pe = pe.save().submit() + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = self.get_journals_for(si.doctype, si.name) + exc_je_for_pe = self.get_journals_for(pe.doctype, pe.name) + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_pe), 1) + self.assertEqual(exc_je_for_si[0], exc_je_for_pe[0]) + + self.assertEqual( + [self.cost_center, self.cost_center], + frappe.db.get_all( + "Journal Entry Account", filters={"parent": exc_je_for_si[0].parent}, pluck="cost_center" + ), + ) + frappe.db.set_value("Company", self.company, "cost_center", cc) + + def test_41_cost_center_from_journal_entry(self): + """ + Gain/Loss JE should inherit cost center from payment if company default is unset + """ + # remove default cost center + cc = frappe.db.get_value("Company", self.company, "cost_center") + frappe.db.set_value("Company", self.company, "cost_center", None) + + rate_in_account_currency = 1 + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si.cost_center = None + si.save().submit() + + je = 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, + ) + je.accounts[0].party_type = "Customer" + je.accounts[0].party = self.customer + je.accounts[0].cost_center = self.cost_center + je = je.save().submit() + + # Reconcile + pr = self.create_payment_reconciliation() + 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) + + # Exchange Gain/Loss Journal should've been created. + exc_je_for_si = [x for x in self.get_journals_for(si.doctype, si.name) if x.parent != je.name] + exc_je_for_je = [x for x in self.get_journals_for(je.doctype, je.name) if x.parent != je.name] + self.assertNotEqual(exc_je_for_si, []) + self.assertEqual(len(exc_je_for_si), 1) + self.assertEqual(len(exc_je_for_je), 1) + self.assertEqual(exc_je_for_si[0], exc_je_for_je[0]) + + self.assertEqual( + [self.cost_center, self.cost_center], + frappe.db.get_all( + "Journal Entry Account", filters={"parent": exc_je_for_si[0].parent}, pluck="cost_center" + ), + ) + frappe.db.set_value("Company", self.company, "cost_center", cc) + + def test_42_cost_center_from_cr_note(self): + """ + Gain/Loss JE should inherit cost center from payment if company default is unset + """ + # remove default cost center + cc = frappe.db.get_value("Company", self.company, "cost_center") + frappe.db.set_value("Company", self.company, "cost_center", None) + + rate_in_account_currency = 1 + si = self.create_sales_invoice(qty=1, rate=rate_in_account_currency, do_not_submit=True) + si.cost_center = None + si.save().submit() + + cr_note = self.create_sales_invoice(qty=-1, conversion_rate=75, rate=1, do_not_save=True) + cr_note.cost_center = self.cost_center + cr_note.is_return = 1 + cr_note.save().submit() + + # Reconcile + pr = self.create_payment_reconciliation() + 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) + + # Exchange Gain/Loss Journal should've been created. + 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( + [self.cost_center, self.cost_center], + frappe.db.get_all("Journal Entry Account", filters={"parent": x.parent}, pluck="cost_center"), + ) + + frappe.db.set_value("Company", self.company, "cost_center", cc) diff --git a/erpnext/crm/doctype/lead/lead.json b/erpnext/crm/doctype/lead/lead.json index 0cb8824577a0..dafbd9f06d90 100644 --- a/erpnext/crm/doctype/lead/lead.json +++ b/erpnext/crm/doctype/lead/lead.json @@ -516,7 +516,7 @@ "idx": 5, "image_field": "image", "links": [], - "modified": "2023-04-14 18:20:05.044791", + "modified": "2023-08-28 22:28:00.104413", "modified_by": "Administrator", "module": "CRM", "name": "Lead", @@ -527,7 +527,7 @@ "permlevel": 1, "read": 1, "report": 1, - "role": "All" + "role": "Desk User" }, { "create": 1, @@ -583,4 +583,4 @@ "states": [], "subject_field": "title", "title_field": "title" -} +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json index 5c4be6ffaa29..510317f5c2ec 100644 --- a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json +++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json @@ -1,6 +1,6 @@ { "charts": [], - "content": "[{\"id\":\"e88ADOJ7WC\",\"type\":\"header\",\"data\":{\"text\":\"Integrations\",\"col\":12}},{\"id\":\"G0tyx9WOfm\",\"type\":\"card\",\"data\":{\"card_name\":\"Backup\",\"col\":4}},{\"id\":\"nu4oSjH5Rd\",\"type\":\"card\",\"data\":{\"card_name\":\"Authentication\",\"col\":4}},{\"id\":\"nG8cdkpzoc\",\"type\":\"card\",\"data\":{\"card_name\":\"Google Services\",\"col\":4}},{\"id\":\"4hwuQn6E95\",\"type\":\"card\",\"data\":{\"card_name\":\"Communication Channels\",\"col\":4}},{\"id\":\"sEGAzTJRmq\",\"type\":\"card\",\"data\":{\"card_name\":\"Payments\",\"col\":4}},{\"id\":\"ZC6xu-cLBR\",\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}}]", + "content": "[{\"id\":\"e88ADOJ7WC\",\"type\":\"header\",\"data\":{\"text\":\"Integrations\",\"col\":12}},{\"id\":\"pZEYOOCdB0\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Browse Apps\",\"col\":3}},{\"id\":\"St7AHbhVOr\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"nu4oSjH5Rd\",\"type\":\"card\",\"data\":{\"card_name\":\"Authentication\",\"col\":4}},{\"id\":\"G0tyx9WOfm\",\"type\":\"card\",\"data\":{\"card_name\":\"Backup\",\"col\":4}},{\"id\":\"nG8cdkpzoc\",\"type\":\"card\",\"data\":{\"card_name\":\"Google Services\",\"col\":4}},{\"id\":\"4hwuQn6E95\",\"type\":\"card\",\"data\":{\"card_name\":\"Communication Channels\",\"col\":4}},{\"id\":\"sEGAzTJRmq\",\"type\":\"card\",\"data\":{\"card_name\":\"Payments\",\"col\":4}}]", "creation": "2020-08-20 19:30:48.138801", "custom_blocks": [], "docstatus": 0, @@ -221,27 +221,9 @@ "link_type": "DocType", "onboard": 0, "type": "Link" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Settings", - "link_count": 2, - "onboard": 0, - "type": "Card Break" - }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Woocommerce Settings", - "link_count": 0, - "link_to": "Woocommerce Settings", - "link_type": "DocType", - "onboard": 0, - "type": "Link" } ], - "modified": "2023-05-24 14:47:26.984717", + "modified": "2023-08-29 15:48:59.010704", "modified_by": "Administrator", "module": "ERPNext Integrations", "name": "ERPNext Integrations", @@ -253,6 +235,14 @@ "restrict_to_domain": "", "roles": [], "sequence_id": 21.0, - "shortcuts": [], + "shortcuts": [ + { + "color": "Grey", + "doc_view": "List", + "label": "Browse Apps", + "type": "URL", + "url": "https://frappecloud.com/marketplace" + } + ], "title": "ERPNext Integrations" } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 34e94232c45a..b7a248901e7d 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -673,6 +673,7 @@ def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders): po.append("items", po_data) + po.set_service_items_for_finished_goods() po.set_missing_values() po.flags.ignore_mandatory = True po.flags.ignore_validate = True diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 2871a29d7682..dccb90311937 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -412,11 +412,15 @@ def test_production_plan_subassembly_default_supplier(self): def test_production_plan_for_subcontracting_po(self): from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom + from erpnext.subcontracting.doctype.subcontracting_bom.test_subcontracting_bom import ( + create_subcontracting_bom, + ) - bom_tree_1 = {"Test Laptop 1": {"Test Motherboard 1": {"Test Motherboard Wires 1": {}}}} + fg_item = "Test Motherboard 1" + bom_tree_1 = {"Test Laptop 1": {fg_item: {"Test Motherboard Wires 1": {}}}} create_nested_bom(bom_tree_1, prefix="") - item_doc = frappe.get_doc("Item", "Test Motherboard 1") + item_doc = frappe.get_doc("Item", fg_item) company = "_Test Company" item_doc.is_sub_contracted_item = 1 @@ -429,6 +433,12 @@ def test_production_plan_for_subcontracting_po(self): item_doc.save() + service_item = make_item(properties={"is_stock_item": 0}).name + create_subcontracting_bom( + finished_good=fg_item, + service_item=service_item, + ) + plan = create_production_plan( item_code="Test Laptop 1", planned_qty=10, use_multi_level_bom=1, do_not_submit=True ) @@ -445,7 +455,8 @@ def test_production_plan_for_subcontracting_po(self): self.assertEqual(po_doc.items[0].qty, 10.0) self.assertEqual(po_doc.items[0].fg_item_qty, 10.0) self.assertEqual(po_doc.items[0].fg_item_qty, 10.0) - self.assertEqual(po_doc.items[0].fg_item, "Test Motherboard 1") + self.assertEqual(po_doc.items[0].fg_item, fg_item) + self.assertEqual(po_doc.items[0].item_code, service_item) def test_production_plan_combine_subassembly(self): """ diff --git a/erpnext/patches.txt b/erpnext/patches.txt index c8cf7bc6be3f..b3d6d3e80a45 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -263,7 +263,6 @@ erpnext.patches.v15_0.saudi_depreciation_warning erpnext.patches.v15_0.delete_saudi_doctypes erpnext.patches.v14_0.show_loan_management_deprecation_warning execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Details", force=True) -erpnext.patches.v14_0.delete_education_module_portal_menu_items [post_model_sync] execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') @@ -340,5 +339,6 @@ execute:frappe.defaults.clear_default("fiscal_year") erpnext.patches.v15_0.remove_exotel_integration erpnext.patches.v14_0.single_to_multi_dunning execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for_items', 0) +erpnext.patches.v15_0.correct_asset_value_if_je_with_workflow # below migration patch should always run last erpnext.patches.v14_0.migrate_gl_to_payment_ledger diff --git a/erpnext/patches/v14_0/delete_education_module_portal_menu_items.py b/erpnext/patches/v14_0/delete_education_module_portal_menu_items.py deleted file mode 100644 index d964f1494415..000000000000 --- a/erpnext/patches/v14_0/delete_education_module_portal_menu_items.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors -# License: MIT. See LICENSE - -import frappe - - -def execute(): - doctypes = frappe.get_all("DocType", {"module": "education", "custom": 0}, pluck="name") - items = frappe.get_all( - "Portal Menu Item", filters={"reference_doctype": ("in", doctypes)}, pluck="name" - ) - for item in items: - frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True) diff --git a/erpnext/patches/v15_0/correct_asset_value_if_je_with_workflow.py b/erpnext/patches/v15_0/correct_asset_value_if_je_with_workflow.py new file mode 100644 index 000000000000..aededa2287d6 --- /dev/null +++ b/erpnext/patches/v15_0/correct_asset_value_if_je_with_workflow.py @@ -0,0 +1,119 @@ +import frappe +from frappe.model.workflow import get_workflow_name +from frappe.query_builder.functions import IfNull, Sum + + +def execute(): + active_je_workflow = get_workflow_name("Journal Entry") + if not active_je_workflow: + return + + correct_value_for_assets_with_manual_depr_entries() + + finance_books = frappe.db.get_all("Finance Book", pluck="name") + + if finance_books: + for fb_name in finance_books: + correct_value_for_assets_with_auto_depr(fb_name) + + correct_value_for_assets_with_auto_depr() + + +def correct_value_for_assets_with_manual_depr_entries(): + asset = frappe.qb.DocType("Asset") + gle = frappe.qb.DocType("GL Entry") + aca = frappe.qb.DocType("Asset Category Account") + company = frappe.qb.DocType("Company") + + asset_details_and_depr_amount_map = ( + frappe.qb.from_(gle) + .join(asset) + .on(gle.against_voucher == asset.name) + .join(aca) + .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company)) + .join(company) + .on(company.name == asset.company) + .select( + asset.name.as_("asset_name"), + asset.gross_purchase_amount.as_("gross_purchase_amount"), + asset.opening_accumulated_depreciation.as_("opening_accumulated_depreciation"), + Sum(gle.debit).as_("depr_amount"), + ) + .where( + gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account) + ) + .where(gle.debit != 0) + .where(gle.is_cancelled == 0) + .where(asset.docstatus == 1) + .where(asset.calculate_depreciation == 0) + .groupby(asset.name) + ) + + frappe.qb.update(asset).join(asset_details_and_depr_amount_map).on( + asset_details_and_depr_amount_map.asset_name == asset.name + ).set( + asset.value_after_depreciation, + asset_details_and_depr_amount_map.gross_purchase_amount + - asset_details_and_depr_amount_map.opening_accumulated_depreciation + - asset_details_and_depr_amount_map.depr_amount, + ).run() + + +def correct_value_for_assets_with_auto_depr(fb_name=None): + asset = frappe.qb.DocType("Asset") + gle = frappe.qb.DocType("GL Entry") + aca = frappe.qb.DocType("Asset Category Account") + company = frappe.qb.DocType("Company") + afb = frappe.qb.DocType("Asset Finance Book") + + asset_details_and_depr_amount_map = ( + frappe.qb.from_(gle) + .join(asset) + .on(gle.against_voucher == asset.name) + .join(aca) + .on((aca.parent == asset.asset_category) & (aca.company_name == asset.company)) + .join(company) + .on(company.name == asset.company) + .select( + asset.name.as_("asset_name"), + asset.gross_purchase_amount.as_("gross_purchase_amount"), + asset.opening_accumulated_depreciation.as_("opening_accumulated_depreciation"), + Sum(gle.debit).as_("depr_amount"), + ) + .where( + gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account) + ) + .where(gle.debit != 0) + .where(gle.is_cancelled == 0) + .where(asset.docstatus == 1) + .where(asset.calculate_depreciation == 1) + .groupby(asset.name) + ) + + if fb_name: + asset_details_and_depr_amount_map = asset_details_and_depr_amount_map.where( + gle.finance_book == fb_name + ) + else: + asset_details_and_depr_amount_map = asset_details_and_depr_amount_map.where( + (gle.finance_book.isin([""])) | (gle.finance_book.isnull()) + ) + + query = ( + frappe.qb.update(afb) + .join(asset_details_and_depr_amount_map) + .on(asset_details_and_depr_amount_map.asset_name == afb.parent) + .set( + afb.value_after_depreciation, + asset_details_and_depr_amount_map.gross_purchase_amount + - asset_details_and_depr_amount_map.opening_accumulated_depreciation + - asset_details_and_depr_amount_map.depr_amount, + ) + ) + + if fb_name: + query = query.where(afb.finance_book == fb_name) + else: + query = query.where((afb.finance_book.isin([""])) | (afb.finance_book.isnull())) + + query.run() diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json index 502ee574159e..715b09c64bc6 100644 --- a/erpnext/projects/doctype/project/project.json +++ b/erpnext/projects/doctype/project/project.json @@ -453,7 +453,7 @@ "index_web_pages_for_search": 1, "links": [], "max_attachments": 4, - "modified": "2023-06-28 18:57:11.603497", + "modified": "2023-08-28 22:27:28.370849", "modified_by": "Administrator", "module": "Projects", "name": "Project", @@ -475,7 +475,7 @@ "permlevel": 1, "read": 1, "report": 1, - "role": "All" + "role": "Desk User" }, { "create": 1, diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index ac5735b70727..80d7b79c1ecb 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -702,7 +702,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } on_submit() { - refresh_field("items"); + this.refresh_serial_batch_bundle_field(); + } + + refresh_serial_batch_bundle_field() { + frappe.route_hooks.after_submit = (frm_obj) => { + frm_obj.reload_doc(); + } } update_qty(cdt, cdn) { diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index a3c10c68a7a5..89750f8446c2 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -571,6 +571,7 @@ erpnext.utils.update_child_items = function(opts) { const cannot_add_row = (typeof opts.cannot_add_row === 'undefined') ? true : opts.cannot_add_row; const child_docname = (typeof opts.cannot_add_row === 'undefined') ? "items" : opts.child_docname; const child_meta = frappe.get_meta(`${frm.doc.doctype} Item`); + const has_reserved_stock = opts.has_reserved_stock ? true : false; const get_precision = (fieldname) => child_meta.fields.find(f => f.fieldname == fieldname).precision; this.data = frm.doc[opts.child_docname].map((d) => { @@ -734,6 +735,17 @@ erpnext.utils.update_child_items = function(opts) { }, ], primary_action: function() { + if (frm.doctype == "Sales Order" && has_reserved_stock) { + this.hide(); + frappe.confirm( + __('The reserved stock will be released when you update items. Are you certain you wish to proceed?'), + () => this.update_items(), + ) + } else { + this.update_items(); + } + }, + update_items: function() { const trans_items = this.get_values()["trans_items"].filter((item) => !!item.item_code); frappe.call({ method: 'erpnext.controllers.accounts_controller.update_child_qty_rate', @@ -823,6 +835,8 @@ erpnext.utils.map_current_doc = function(opts) { "target_doc": cur_frm.doc, "args": opts.args }, + freeze: true, + freeze_message: __("Mapping {0} ...", [opts.source_doctype]), callback: function(r) { if(!r.exc) { var doc = frappe.model.sync(r.message); diff --git a/erpnext/quality_management/doctype/quality_action/quality_action.json b/erpnext/quality_management/doctype/quality_action/quality_action.json index 0cc2a98cd249..f0b33b9eaf7f 100644 --- a/erpnext/quality_management/doctype/quality_action/quality_action.json +++ b/erpnext/quality_management/doctype/quality_action/quality_action.json @@ -91,7 +91,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2020-10-27 16:21:59.533937", + "modified": "2023-08-28 22:33:14.358143", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Action", @@ -117,12 +117,13 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "Desk User", "share": 1, "write": 1 } ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json index f3bd0ddb2ede..5fe6375fccf3 100644 --- a/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json +++ b/erpnext/quality_management/doctype/quality_feedback/quality_feedback.json @@ -61,7 +61,7 @@ "link_fieldname": "feedback" } ], - "modified": "2020-10-27 16:20:10.918544", + "modified": "2023-08-28 22:21:36.144820", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Feedback", @@ -87,12 +87,13 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "Desk User", "share": 1, "write": 1 } ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_goal/quality_goal.json b/erpnext/quality_management/doctype/quality_goal/quality_goal.json index 26802550dcaa..f2b6ebc589dc 100644 --- a/erpnext/quality_management/doctype/quality_goal/quality_goal.json +++ b/erpnext/quality_management/doctype/quality_goal/quality_goal.json @@ -76,7 +76,7 @@ "link_fieldname": "goal" } ], - "modified": "2020-10-27 15:57:59.368605", + "modified": "2023-08-28 22:33:27.718899", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Goal", @@ -102,12 +102,13 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "Desk User", "share": 1, "write": 1 } ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json index e2125c3933ae..7ab28d8443b5 100644 --- a/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json +++ b/erpnext/quality_management/doctype/quality_meeting/quality_meeting.json @@ -48,7 +48,7 @@ ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-02-27 16:36:45.657883", + "modified": "2023-08-28 22:33:57.447634", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Meeting", @@ -74,7 +74,7 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "Desk User", "share": 1, "write": 1 } @@ -82,5 +82,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json index f588f9aea1a4..fd5a5959b8a8 100644 --- a/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json +++ b/erpnext/quality_management/doctype/quality_procedure/quality_procedure.json @@ -23,6 +23,7 @@ { "fieldname": "parent_quality_procedure", "fieldtype": "Link", + "ignore_user_permissions": 1, "label": "Parent Procedure", "options": "Quality Procedure" }, @@ -115,7 +116,7 @@ "link_fieldname": "procedure" } ], - "modified": "2020-10-26 15:25:39.316088", + "modified": "2023-08-28 22:33:36.483420", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Procedure", @@ -142,12 +143,13 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "Desk User", "share": 1, "write": 1 } ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/quality_management/doctype/quality_review/quality_review.json b/erpnext/quality_management/doctype/quality_review/quality_review.json index 31ad34136279..f38e8a50d646 100644 --- a/erpnext/quality_management/doctype/quality_review/quality_review.json +++ b/erpnext/quality_management/doctype/quality_review/quality_review.json @@ -84,7 +84,7 @@ "link_fieldname": "review" } ], - "modified": "2020-10-21 12:56:47.046172", + "modified": "2023-08-28 22:33:22.472980", "modified_by": "Administrator", "module": "Quality Management", "name": "Quality Review", @@ -110,7 +110,7 @@ "print": 1, "read": 1, "report": 1, - "role": "All", + "role": "Desk User", "share": 1, "write": 1 }, @@ -129,6 +129,7 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "title_field": "goal", "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 27163dbf2839..ba8bc339f38f 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -59,19 +59,27 @@ frappe.ui.form.on("Sales Order", { child_docname: "items", child_doctype: "Sales Order Detail", cannot_add_row: false, + has_reserved_stock: frm.doc.__onload && frm.doc.__onload.has_reserved_stock }) }); - // Stock Reservation > Reserve button will be only visible if the SO has unreserved stock. - if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) { + // Stock Reservation > Reserve button should only be visible if the SO has unreserved stock and no Pick List is created against the SO. + if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock && flt(frm.doc.per_picked) === 0) { frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation')); } } - // Stock Reservation > Unreserve button will be only visible if the SO has reserved stock. + // Stock Reservation > Unreserve button will be only visible if the SO has un-delivered reserved stock. if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) { frm.add_custom_button(__('Unreserve'), () => frm.events.cancel_stock_reservation_entries(frm), __('Stock Reservation')); } + + frm.doc.items.forEach(item => { + if (flt(item.stock_reserved_qty) > 0) { + frm.add_custom_button(__('Reserved Stock'), () => frm.events.show_reserved_stock(frm), __('Stock Reservation')); + return; + } + }); } if (frm.doc.docstatus === 0) { @@ -82,7 +90,7 @@ frappe.ui.form.on("Sales Order", { if (frm.is_new()) { frappe.db.get_single_value("Stock Settings", "enable_stock_reservation").then((value) => { if (value) { - frappe.db.get_single_value("Stock Settings", "reserve_stock_on_sales_order_submission").then((value) => { + frappe.db.get_single_value("Stock Settings", "auto_reserve_stock_for_sales_order").then((value) => { // If `Reserve Stock on Sales Order Submission` is enabled in Stock Settings, set Reserve Stock to 1 else 0. frm.set_value("reserve_stock", value ? 1 : 0); }) @@ -94,6 +102,11 @@ frappe.ui.form.on("Sales Order", { }) } } + + // Hide `Reserve Stock` field description in submitted or cancelled Sales Order. + if (frm.doc.docstatus > 0) { + frm.set_df_property("reserve_stock", "description", null); + } }, get_items_from_internal_purchase_order(frm) { @@ -171,76 +184,115 @@ frappe.ui.form.on("Sales Order", { }, create_stock_reservation_entries(frm) { - let items_data = []; - - const dialog = frappe.prompt({fieldname: 'items', fieldtype: 'Table', label: __('Items to Reserve'), + const dialog = new frappe.ui.Dialog({ + title: __("Stock Reservation"), + size: "large", fields: [ { - fieldtype: 'Data', - fieldname: 'name', - label: __('Name'), - reqd: 1, - read_only: 1, - }, - { - fieldtype: 'Link', - fieldname: 'item_code', - label: __('Item Code'), - options: 'Item', - reqd: 1, - read_only: 1, - in_list_view: 1, - }, - { - fieldtype: 'Link', - fieldname: 'warehouse', - label: __('Warehouse'), - options: 'Warehouse', - reqd: 1, - in_list_view: 1, - get_query: function () { + fieldname: "set_warehouse", + fieldtype: "Link", + label: __("Set Warehouse"), + options: "Warehouse", + default: frm.doc.set_warehouse, + get_query: () => { return { filters: [ ["Warehouse", "is_group", "!=", 1] ] }; }, + onchange: () => { + if (dialog.get_value("set_warehouse")) { + dialog.fields_dict.items.df.data.forEach((row) => { + row.warehouse = dialog.get_value("set_warehouse"); + }); + dialog.fields_dict.items.grid.refresh(); + } + }, }, + {fieldtype: "Column Break"}, + {fieldtype: "Section Break"}, { - fieldtype: 'Float', - fieldname: 'qty_to_reserve', - label: __('Qty'), - reqd: 1, - in_list_view: 1 - } + fieldname: "items", + fieldtype: "Table", + label: __("Items to Reserve"), + allow_bulk_edit: false, + cannot_add_rows: true, + cannot_delete_rows: true, + data: [], + fields: [ + { + fieldname: "name", + fieldtype: "Data", + label: __("Name"), + reqd: 1, + read_only: 1, + }, + { + fieldname: "item_code", + fieldtype: "Link", + label: __("Item Code"), + options: "Item", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "warehouse", + fieldtype: "Link", + label: __("Warehouse"), + options: "Warehouse", + reqd: 1, + in_list_view: 1, + get_query: () => { + return { + filters: [ + ["Warehouse", "is_group", "!=", 1] + ] + }; + }, + }, + { + fieldname: "qty_to_reserve", + fieldtype: "Float", + label: __("Qty"), + reqd: 1, + in_list_view: 1 + } + ], + }, ], - data: items_data, - in_place_edit: true, - get_data: function() { - return items_data; - } - }, function(data) { - if (data.items.length > 0) { - frappe.call({ - doc: frm.doc, - method: 'create_stock_reservation_entries', - args: { - items_details: data.items, - notify: true - }, - freeze: true, - freeze_message: __('Reserving Stock...'), - callback: (r) => { - frm.doc.__onload.has_unreserved_stock = false; - frm.reload_doc(); - } - }); - } - }, __("Stock Reservation"), __("Reserve Stock")); + primary_action_label: __("Reserve Stock"), + primary_action: () => { + var data = {items: dialog.fields_dict.items.grid.get_selected_children()}; + + if (data.items && data.items.length > 0) { + frappe.call({ + doc: frm.doc, + method: "create_stock_reservation_entries", + args: { + items_details: data.items, + notify: true + }, + freeze: true, + freeze_message: __("Reserving Stock..."), + callback: (r) => { + frm.doc.__onload.has_unreserved_stock = false; + frm.reload_doc(); + } + }); + } + else { + frappe.msgprint(__("Please select items to reserve.")); + } + + dialog.hide(); + }, + }); frm.doc.items.forEach(item => { if (item.reserve_stock) { - let unreserved_qty = (flt(item.stock_qty) - (flt(item.delivered_qty) * flt(item.conversion_factor)) - flt(item.stock_reserved_qty)) + let unreserved_qty = (flt(item.stock_qty) - (item.stock_reserved_qty ? flt(item.stock_reserved_qty) : (flt(item.delivered_qty) * flt(item.conversion_factor)))) if (unreserved_qty > 0) { dialog.fields_dict.items.df.data.push({ @@ -254,22 +306,127 @@ frappe.ui.form.on("Sales Order", { }); dialog.fields_dict.items.grid.refresh(); + dialog.show(); }, cancel_stock_reservation_entries(frm) { + const dialog = new frappe.ui.Dialog({ + title: __("Stock Unreservation"), + size: "large", + fields: [ + { + fieldname: "sr_entries", + fieldtype: "Table", + label: __("Reserved Stock"), + allow_bulk_edit: false, + cannot_add_rows: true, + cannot_delete_rows: true, + in_place_edit: true, + data: [], + fields: [ + { + fieldname: "name", + fieldtype: "Link", + label: __("SRE"), + options: "Stock Reservation Entry", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "item_code", + fieldtype: "Link", + label: __("Item Code"), + options: "Item", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "warehouse", + fieldtype: "Link", + label: __("Warehouse"), + options: "Warehouse", + reqd: 1, + read_only: 1, + in_list_view: 1, + }, + { + fieldname: "qty", + fieldtype: "Float", + label: __("Qty"), + reqd: 1, + read_only: 1, + in_list_view: 1 + } + ] + } + ], + primary_action_label: __("Unreserve Stock"), + primary_action: () => { + var data = {sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children()}; + + if (data.sr_entries && data.sr_entries.length > 0) { + frappe.call({ + doc: frm.doc, + method: "cancel_stock_reservation_entries", + args: { + sre_list: data.sr_entries, + }, + freeze: true, + freeze_message: __('Unreserving Stock...'), + callback: (r) => { + frm.doc.__onload.has_reserved_stock = false; + frm.reload_doc(); + } + }); + } + else { + frappe.msgprint(__("Please select items to unreserve.")); + } + + dialog.hide(); + }, + }); + frappe.call({ - method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.cancel_stock_reservation_entries', + method: 'erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry.get_stock_reservation_entries_for_voucher', args: { voucher_type: frm.doctype, - voucher_no: frm.docname + voucher_no: frm.docname, }, - freeze: true, - freeze_message: __('Unreserving Stock...'), callback: (r) => { - frm.doc.__onload.has_reserved_stock = false; - frm.reload_doc(); + if (!r.exc && r.message) { + r.message.forEach(sre => { + if (flt(sre.reserved_qty) > flt(sre.delivered_qty)) { + dialog.fields_dict.sr_entries.df.data.push({ + 'name': sre.name, + 'item_code': sre.item_code, + 'warehouse': sre.warehouse, + 'qty': (flt(sre.reserved_qty) - flt(sre.delivered_qty)) + }); + } + }); + } } - }) + }).then(r => { + dialog.fields_dict.sr_entries.grid.refresh(); + dialog.show(); + }); + }, + + show_reserved_stock(frm) { + // Get the latest modified date from the items table. + var to_date = moment(new Date(Math.max(...frm.doc.items.map(e => new Date(e.modified))))).format('YYYY-MM-DD'); + + frappe.route_options = { + company: frm.doc.company, + from_date: frm.doc.transaction_date, + to_date: to_date, + voucher_type: frm.doc.doctype, + voucher_no: frm.doc.name, + } + frappe.set_route("query-report", "Reserved Stock"); } }); @@ -335,8 +492,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } } - if (flt(doc.per_picked, 2) < 100 && flt(doc.per_delivered, 2) < 100) { - this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create')); + if (!doc.__onload || !doc.__onload.has_reserved_stock) { + // Don't show the `Reserve` button if the Sales Order has Picked Items. + if (flt(doc.per_picked, 2) < 100 && flt(doc.per_delivered, 2) < 100) { + this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create')); + } } const order_is_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1; @@ -346,7 +506,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex // delivery note if(flt(doc.per_delivered, 2) < 100 && (order_is_a_sale || order_is_a_custom_sale) && allow_delivery) { - this.frm.add_custom_button(__('Delivery Note'), () => this.make_delivery_note_based_on_delivery_date(), __('Create')); + this.frm.add_custom_button(__('Delivery Note'), () => this.make_delivery_note_based_on_delivery_date(true), __('Create')); this.frm.add_custom_button(__('Work Order'), () => this.make_work_order(), __('Create')); } @@ -639,7 +799,7 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex d.show(); } - make_delivery_note_based_on_delivery_date() { + make_delivery_note_based_on_delivery_date(for_reserved_stock=false) { var me = this; var delivery_dates = this.frm.doc.items.map(i => i.delivery_date); @@ -681,22 +841,25 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex if(!dates) return; - me.make_delivery_note(dates); + me.make_delivery_note(dates, for_reserved_stock); dialog.hide(); }); dialog.show(); } else { - this.make_delivery_note(); + this.make_delivery_note([], for_reserved_stock); } } - make_delivery_note(delivery_dates) { + make_delivery_note(delivery_dates, for_reserved_stock=false) { frappe.model.open_mapped_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", frm: this.frm, args: { - delivery_dates - } + delivery_dates, + for_reserved_stock: for_reserved_stock + }, + freeze: true, + freeze_message: __("Creating Delivery Note ...") }) } diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index f65969e9938d..a74084d21fdd 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -1027,7 +1027,6 @@ "length": 240, "oldfieldname": "in_words_export", "oldfieldtype": "Data", - "print_hide": 1, "read_only": 1, "width": "200px" }, @@ -1635,6 +1634,7 @@ "description": "If checked, Stock Reservation Entries will be created on Submit", "fieldname": "reserve_stock", "fieldtype": "Check", + "hidden": 1, "label": "Reserve Stock", "no_copy": 1, "print_hide": 1, @@ -1645,7 +1645,7 @@ "idx": 105, "is_submittable": 1, "links": [], - "modified": "2023-06-03 16:16:23.411247", + "modified": "2023-07-24 08:59:11.599875", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index cc141ffa111d..aae0fee4673d 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -31,7 +31,6 @@ from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - cancel_stock_reservation_entries, get_sre_reserved_qty_details_for_voucher, has_reserved_stock, ) @@ -283,7 +282,7 @@ def on_cancel(self): self.db_set("status", "Cancelled") self.update_blanket_order() - cancel_stock_reservation_entries("Sales Order", self.name) + self.cancel_stock_reservation_entries() unlink_inter_company_doc(self.doctype, self.name, self.inter_company_order_reference) if self.coupon_code: @@ -535,138 +534,26 @@ def has_unreserved_stock(self) -> bool: return False @frappe.whitelist() - def create_stock_reservation_entries(self, items_details=None, notify=True): + def create_stock_reservation_entries(self, items_details=None, notify=True) -> None: """Creates Stock Reservation Entries for Sales Order Items.""" from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_available_qty_to_reserve, - validate_stock_reservation_settings, + create_stock_reservation_entries_for_so_items as create_stock_reservation_entries, ) - validate_stock_reservation_settings(self) + create_stock_reservation_entries(so=self, items_details=items_details, notify=notify) - allow_partial_reservation = frappe.db.get_single_value( - "Stock Settings", "allow_partial_reservation" - ) - - items = [] - if items_details: - for item in items_details: - so_item = frappe.get_doc("Sales Order Item", item["name"]) - so_item.reserve_stock = 1 - so_item.warehouse = item["warehouse"] - so_item.qty_to_reserve = flt(item["qty_to_reserve"]) * flt(so_item.conversion_factor) - items.append(so_item) - - sre_count = 0 - reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", self.name) - for item in items or self.get("items"): - # Skip if `Reserved Stock` is not checked for the item. - if not item.get("reserve_stock"): - continue - - # Skip if Non-Stock Item. - if not frappe.get_cached_value("Item", item.item_code, "is_stock_item"): - frappe.msgprint( - _("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format( - item.idx, frappe.bold(item.item_code) - ), - title=_("Stock Reservation"), - indicator="yellow", - ) - item.db_set("reserve_stock", 0) - continue - - # Skip if Group Warehouse. - if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"): - frappe.msgprint( - _("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format( - item.idx, frappe.bold(item.warehouse) - ), - title=_("Stock Reservation"), - indicator="yellow", - ) - continue - - unreserved_qty = get_unreserved_qty(item, reserved_qty_details) - - # Stock is already reserved for the item, notify the user and skip the item. - if unreserved_qty <= 0: - frappe.msgprint( - _("Row #{0}: Stock is already reserved for the Item {1}.").format( - item.idx, frappe.bold(item.item_code) - ), - title=_("Stock Reservation"), - indicator="yellow", - ) - continue - - available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse) - - # No stock available to reserve, notify the user and skip the item. - if available_qty_to_reserve <= 0: - frappe.msgprint( - _("Row #{0}: No available stock to reserve for the Item {1} in Warehouse {2}.").format( - item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse) - ), - title=_("Stock Reservation"), - indicator="orange", - ) - continue - - # The quantity which can be reserved. - qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve) - - if hasattr(item, "qty_to_reserve"): - if item.qty_to_reserve <= 0: - frappe.msgprint( - _("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format( - item.idx, frappe.bold(item.item_code) - ), - title=_("Stock Reservation"), - indicator="orange", - ) - continue - else: - qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve) - - # Partial Reservation - if qty_to_be_reserved < unreserved_qty: - if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")): - frappe.msgprint( - _("Row #{0}: Only {1} available to reserve for the Item {2}").format( - item.idx, - frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom), - frappe.bold(item.item_code), - ), - title=_("Stock Reservation"), - indicator="orange", - ) - - # Skip the item if `Partial Reservation` is disabled in the Stock Settings. - if not allow_partial_reservation: - continue - - # Create and Submit Stock Reservation Entry - sre = frappe.new_doc("Stock Reservation Entry") - sre.item_code = item.item_code - sre.warehouse = item.warehouse - sre.voucher_type = self.doctype - sre.voucher_no = self.name - sre.voucher_detail_no = item.name - sre.available_qty = available_qty_to_reserve - sre.voucher_qty = item.stock_qty - sre.reserved_qty = qty_to_be_reserved - sre.company = self.company - sre.stock_uom = item.stock_uom - sre.project = self.project - sre.save() - sre.submit() + @frappe.whitelist() + def cancel_stock_reservation_entries(self, sre_list=None, notify=True) -> None: + """Cancel Stock Reservation Entries for Sales Order Items.""" - sre_count += 1 + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + ) - if sre_count and notify: - frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") + cancel_stock_reservation_entries( + voucher_type=self.doctype, voucher_no=self.name, sre_list=sre_list, notify=notify + ) def get_unreserved_qty(item: object, reserved_qty_details: dict) -> float: @@ -813,8 +700,31 @@ def postprocess(source, doc): @frappe.whitelist() -def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False): +def make_delivery_note(source_name, target_doc=None, kwargs=None): from erpnext.stock.doctype.packed_item.packed_item import make_packing_list + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_details_for_voucher, + get_sre_reserved_qty_details_for_voucher, + get_ssb_bundle_for_voucher, + ) + + if not kwargs: + kwargs = { + "for_reserved_stock": frappe.flags.args and frappe.flags.args.for_reserved_stock, + "skip_item_mapping": frappe.flags.args and frappe.flags.args.skip_item_mapping, + } + + kwargs = frappe._dict(kwargs) + + sre_details = {} + if kwargs.for_reserved_stock: + sre_details = get_sre_reserved_qty_details_for_voucher("Sales Order", source_name) + + mapper = { + "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, + "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, + "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, + } def set_missing_values(source, target): target.run_method("set_missing_values") @@ -832,6 +742,18 @@ def set_missing_values(source, target): make_packing_list(target) + def condition(doc): + if doc.name in sre_details: + del sre_details[doc.name] + return False + + # make_mapped_doc sets js `args` into `frappe.flags.args` + if frappe.flags.args and frappe.flags.args.delivery_dates: + if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: + return False + + return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1 + def update_item(source, target, source_parent): target.base_amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.base_rate) target.amount = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.rate) @@ -847,21 +769,7 @@ def update_item(source, target, source_parent): or item_group.get("buying_cost_center") ) - mapper = { - "Sales Order": {"doctype": "Delivery Note", "validation": {"docstatus": ["=", 1]}}, - "Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True}, - "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, - } - - if not skip_item_mapping: - - def condition(doc): - # make_mapped_doc sets js `args` into `frappe.flags.args` - if frappe.flags.args and frappe.flags.args.delivery_dates: - if cstr(doc.delivery_date) not in frappe.flags.args.delivery_dates: - return False - return abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier != 1 - + if not kwargs.skip_item_mapping: mapper["Sales Order Item"] = { "doctype": "Delivery Note Item", "field_map": { @@ -869,11 +777,56 @@ def condition(doc): "name": "so_detail", "parent": "against_sales_order", }, - "postprocess": update_item, "condition": condition, + "postprocess": update_item, } - target_doc = get_mapped_doc("Sales Order", source_name, mapper, target_doc, set_missing_values) + so = frappe.get_doc("Sales Order", source_name) + target_doc = get_mapped_doc("Sales Order", so.name, mapper, target_doc) + + if not kwargs.skip_item_mapping and kwargs.for_reserved_stock: + sre_list = get_sre_details_for_voucher("Sales Order", source_name) + + if sre_list: + + def update_dn_item(source, target, source_parent): + update_item(source, target, so) + + so_items = {d.name: d for d in so.items if d.stock_reserved_qty} + + for sre in sre_list: + if not condition(so_items[sre.voucher_detail_no]): + continue + + dn_item = get_mapped_doc( + "Sales Order Item", + sre.voucher_detail_no, + { + "Sales Order Item": { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "postprocess": update_dn_item, + } + }, + ) + + dn_item.qty = flt(sre.reserved_qty) * flt(dn_item.get("conversion_factor", 1)) + + if sre.reservation_based_on == "Serial and Batch" and (sre.has_serial_no or sre.has_batch_no): + dn_item.serial_and_batch_bundle = get_ssb_bundle_for_voucher(sre) + + target_doc.append("items", dn_item) + else: + # Correct rows index. + for idx, item in enumerate(target_doc.items): + item.idx = idx + 1 + + # Should be called after mapping items. + set_missing_values(so, target_doc) target_doc.set_onload("ignore_price_list", True) return target_doc @@ -1436,6 +1389,16 @@ def make_inter_company_purchase_order(source_name, target_doc=None): def create_pick_list(source_name, target_doc=None): from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle + def validate_sales_order(): + so = frappe.get_doc("Sales Order", source_name) + for item in so.items: + if item.stock_reserved_qty > 0: + frappe.throw( + _( + "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list." + ).format(frappe.bold(source_name)) + ) + def update_item_quantity(source, target, source_parent) -> None: picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1) qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty)) @@ -1459,6 +1422,9 @@ def should_pick_order_item(item) -> bool: and not is_product_bundle(item.item_code) ) + # Don't allow a Pick List to be created against a Sales Order that has reserved stock. + validate_sales_order() + doc = get_mapped_doc( "Sales Order", source_name, diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 954393f5730c..ed270bec50bd 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1789,147 +1789,6 @@ def test_sales_order_partial_advance_payment(self): self.assertEqual(pe.references[1].reference_name, so.name) self.assertEqual(pe.references[1].allocated_amount, 300) - @change_settings( - "Stock Settings", - { - "enable_stock_reservation": 1, - "auto_create_serial_and_batch_bundle_for_outward": 1, - "pick_serial_and_batch_based_on": "FIFO", - }, - ) - def test_stock_reservation_against_sales_order(self) -> None: - from random import randint, uniform - - from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - cancel_stock_reservation_entries, - get_sre_reserved_qty_details_for_voucher, - get_stock_reservation_entries_for_voucher, - has_reserved_stock, - ) - from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import ( - create_items, - create_material_receipt, - ) - - items_details, warehouse = create_items(), "_Test Warehouse - _TC" - se = create_material_receipt(items_details, warehouse, qty=10) - - item_list = [] - for item_code, properties in items_details.items(): - stock_uom = properties.stock_uom - item_list.append( - { - "item_code": item_code, - "warehouse": warehouse, - "qty": flt(uniform(11, 100), 0 if stock_uom == "Nos" else 3), - "uom": stock_uom, - "rate": randint(10, 200), - } - ) - - so = make_sales_order( - item_list=item_list, - warehouse="_Test Warehouse - _TC", - ) - - # Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings. - with change_settings("Stock Settings", {"allow_partial_reservation": 0}): - so.create_stock_reservation_entries() - self.assertFalse(has_reserved_stock("Sales Order", so.name)) - - # Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings. - with change_settings("Stock Settings", {"allow_partial_reservation": 1}): - so.create_stock_reservation_entries() - so.load_from_db() - self.assertTrue(has_reserved_stock("Sales Order", so.name)) - - for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( - "Sales Order", so.name, item.name, fields=["reserved_qty", "status"] - ) - self.assertEqual(item.stock_reserved_qty, sre_details[0].reserved_qty) - self.assertEqual(sre_details[0].status, "Partially Reserved") - - se.cancel() - - # Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty. - create_material_receipt(items_details, warehouse, qty=110) - so.create_stock_reservation_entries() - so.load_from_db() - - reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) - for item in so.items: - reserved_qty = reserved_qty_details[item.name] - self.assertEqual(item.stock_reserved_qty, reserved_qty) - self.assertEqual(item.stock_qty, item.stock_reserved_qty) - - # Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries. - cancel_stock_reservation_entries("Sales Order", so.name) - so.load_from_db() - self.assertFalse(has_reserved_stock("Sales Order", so.name)) - - for item in so.items: - self.assertEqual(item.stock_reserved_qty, 0) - - # Test - 5: Re-reserve the stock. - so.create_stock_reservation_entries() - self.assertTrue(has_reserved_stock("Sales Order", so.name)) - - # Test - 6: Stock should get unreserved on cancellation of Sales Order. - so.cancel() - so.load_from_db() - self.assertFalse(has_reserved_stock("Sales Order", so.name)) - - for item in so.items: - self.assertEqual(item.stock_reserved_qty, 0) - - # Create Sales Order and Reserve Stock. - so = make_sales_order( - item_list=item_list, - warehouse="_Test Warehouse - _TC", - ) - so.create_stock_reservation_entries() - - # Test - 7: Partial Delivery against Sales Order. - dn1 = make_delivery_note(so.name) - - for item in dn1.items: - item.qty = flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3) - - dn1.save() - dn1.submit() - - for item in so.items: - sre_details = get_stock_reservation_entries_for_voucher( - "Sales Order", so.name, item.name, fields=["delivered_qty", "status"] - ) - self.assertGreater(sre_details[0].delivered_qty, 0) - self.assertEqual(sre_details[0].status, "Partially Delivered") - - # Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty. - with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}): - dn2 = make_delivery_note(so.name) - - for item in dn2.items: - item.qty += flt(uniform(1, 10), 0 if item.stock_uom == "Nos" else 3) - - dn2.save() - dn2.submit() - - for item in so.items: - sre_details = frappe.db.get_all( - "Stock Reservation Entry", - filters={ - "voucher_type": "Sales Order", - "voucher_no": so.name, - "voucher_detail_no": item.name, - }, - fields=["reserved_qty", "delivered_qty"], - ) - - for sre_detail in sre_details: - self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty) - def test_delivered_item_material_request(self): "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO." from erpnext.manufacturing.doctype.work_order.work_order import ( diff --git a/erpnext/setup/doctype/department/department.json b/erpnext/setup/doctype/department/department.json index 5a16bae0f2e6..99deca5c19d4 100644 --- a/erpnext/setup/doctype/department/department.json +++ b/erpnext/setup/doctype/department/department.json @@ -25,18 +25,15 @@ "label": "Department", "oldfieldname": "department_name", "oldfieldtype": "Data", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "fieldname": "parent_department", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_list_view": 1, "label": "Parent Department", - "options": "Department", - "show_days": 1, - "show_seconds": 1 + "options": "Department" }, { "fieldname": "company", @@ -44,9 +41,7 @@ "in_standard_filter": 1, "label": "Company", "options": "Company", - "reqd": 1, - "show_days": 1, - "show_seconds": 1 + "reqd": 1 }, { "bold": 1, @@ -54,17 +49,13 @@ "fieldname": "is_group", "fieldtype": "Check", "in_list_view": 1, - "label": "Is Group", - "show_days": 1, - "show_seconds": 1 + "label": "Is Group" }, { "default": "0", "fieldname": "disabled", "fieldtype": "Check", - "label": "Disabled", - "show_days": 1, - "show_seconds": 1 + "label": "Disabled" }, { "fieldname": "lft", @@ -72,9 +63,7 @@ "hidden": 1, "label": "lft", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "rgt", @@ -82,9 +71,7 @@ "hidden": 1, "label": "rgt", "print_hide": 1, - "read_only": 1, - "show_days": 1, - "show_seconds": 1 + "read_only": 1 }, { "fieldname": "old_parent", @@ -92,22 +79,18 @@ "hidden": 1, "ignore_user_permissions": 1, "label": "Old Parent", - "print_hide": 1, - "show_days": 1, - "show_seconds": 1 + "print_hide": 1 }, { "fieldname": "column_break_3", - "fieldtype": "Column Break", - "show_days": 1, - "show_seconds": 1 + "fieldtype": "Column Break" } ], "icon": "fa fa-sitemap", "idx": 1, "is_tree": 1, "links": [], - "modified": "2020-06-10 12:28:00.563272", + "modified": "2023-08-28 17:26:46.826501", "modified_by": "Administrator", "module": "Setup", "name": "Department", @@ -147,12 +130,12 @@ "read": 1, "report": 1, "role": "HR Manager", - "set_user_permissions": 1, "share": 1, "write": 1 } ], "show_name_in_global_search": 1, "sort_field": "modified", - "sort_order": "ASC" + "sort_order": "ASC", + "states": [] } \ No newline at end of file diff --git a/erpnext/setup/doctype/item_group/item_group.json b/erpnext/setup/doctype/item_group/item_group.json index 2986087277c8..e0f509047498 100644 --- a/erpnext/setup/doctype/item_group/item_group.json +++ b/erpnext/setup/doctype/item_group/item_group.json @@ -233,7 +233,7 @@ "is_tree": 1, "links": [], "max_attachments": 3, - "modified": "2023-01-05 12:21:30.458628", + "modified": "2023-08-28 22:27:48.382985", "modified_by": "Administrator", "module": "Setup", "name": "Item Group", @@ -266,7 +266,6 @@ "read": 1, "report": 1, "role": "Item Manager", - "set_user_permissions": 1, "share": 1, "write": 1 }, @@ -296,7 +295,7 @@ "export": 1, "print": 1, "report": 1, - "role": "All", + "role": "Desk User", "select": 1, "share": 1 } diff --git a/erpnext/setup/doctype/print_heading/print_heading.json b/erpnext/setup/doctype/print_heading/print_heading.json index dc07f0c8d83d..1083583037b4 100644 --- a/erpnext/setup/doctype/print_heading/print_heading.json +++ b/erpnext/setup/doctype/print_heading/print_heading.json @@ -1,131 +1,68 @@ { - "allow_copy": 0, - "allow_import": 1, - "allow_rename": 1, - "autoname": "field:print_heading", - "beta": 0, - "creation": "2013-01-10 16:34:24", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "field:print_heading", + "creation": "2013-01-10 16:34:24", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "print_heading", + "description" + ], "fields": [ { - "allow_on_submit": 1, - "bold": 0, - "collapsible": 0, - "fieldname": "print_heading", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 1, - "in_list_view": 1, - "label": "Print Heading", - "length": 0, - "no_copy": 0, - "oldfieldname": "print_heading", - "oldfieldtype": "Data", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 1, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "allow_on_submit": 1, + "fieldname": "print_heading", + "fieldtype": "Data", + "in_filter": 1, + "in_list_view": 1, + "label": "Print Heading", + "oldfieldname": "print_heading", + "oldfieldtype": "Data", + "reqd": 1, + "unique": 1 + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "fieldname": "description", - "fieldtype": "Small Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "label": "Description", - "length": 0, - "no_copy": 0, - "oldfieldname": "description", - "oldfieldtype": "Small Text", - "permlevel": 0, - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0, + "fieldname": "description", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Description", + "oldfieldname": "description", + "oldfieldtype": "Small Text", "width": "300px" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-font", - "idx": 1, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2016-07-25 05:24:25.628101", - "modified_by": "Administrator", - "module": "Setup", - "name": "Print Heading", - "owner": "Administrator", + ], + "icon": "fa fa-font", + "idx": 1, + "links": [], + "modified": "2023-08-28 22:17:42.041255", + "modified_by": "Administrator", + "module": "Setup", + "name": "Print Heading", + "naming_rule": "By fieldname", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 0, - "delete": 0, - "email": 0, - "export": 0, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "All", - "set_user_permissions": 0, - "share": 0, - "submit": 0, - "write": 0 + "read": 1, + "role": "Desk User" } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "search_fields": "print_heading", - "track_seen": 0 + ], + "quick_entry": 1, + "search_fields": "print_heading", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/stock/dashboard/item_dashboard.py b/erpnext/stock/dashboard/item_dashboard.py index 8fbb56ced0fa..62bd61f19e3a 100644 --- a/erpnext/stock/dashboard/item_dashboard.py +++ b/erpnext/stock/dashboard/item_dashboard.py @@ -2,6 +2,10 @@ from frappe.model.db_query import DatabaseQuery from frappe.utils import cint, flt +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + get_sre_reserved_qty_for_item_and_warehouse as get_reserved_stock, +) + @frappe.whitelist() def get_data( @@ -57,6 +61,7 @@ def get_data( limit_page_length=21, ) + sre_reserved_stock_details = get_reserved_stock(item_code, warehouse) precision = cint(frappe.db.get_single_value("System Settings", "float_precision")) for item in items: @@ -70,6 +75,7 @@ def get_data( "reserved_qty_for_production": flt(item.reserved_qty_for_production, precision), "reserved_qty_for_sub_contract": flt(item.reserved_qty_for_sub_contract, precision), "actual_qty": flt(item.actual_qty, precision), + "reserved_stock": sre_reserved_stock_details.get((item.item_code, item.warehouse), 0), } ) return items diff --git a/erpnext/stock/dashboard/item_dashboard_list.html b/erpnext/stock/dashboard/item_dashboard_list.html index 0c10be462a11..3b2619133bf4 100644 --- a/erpnext/stock/dashboard/item_dashboard_list.html +++ b/erpnext/stock/dashboard/item_dashboard_list.html @@ -12,7 +12,10 @@ {% endif %} -
+
+ {{ d.reserved_stock }} +
+
{{ d.total_reserved }} diff --git a/erpnext/stock/doctype/batch/batch.js b/erpnext/stock/doctype/batch/batch.js index 3b07e4e80c15..7bf7a1f65df0 100644 --- a/erpnext/stock/doctype/batch/batch.js +++ b/erpnext/stock/doctype/batch/batch.js @@ -41,7 +41,7 @@ frappe.ui.form.on('Batch', { if(!frm.is_new()) { frappe.call({ method: 'erpnext.stock.doctype.batch.batch.get_batch_qty', - args: {batch_no: frm.doc.name}, + args: {batch_no: frm.doc.name, item_code: frm.doc.item}, callback: (r) => { if(!r.message) { return; diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 7ef1c9ba5876..ec68549846fe 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -150,6 +150,9 @@ erpnext.stock.DeliveryNoteController = class DeliveryNoteController extends erpn } erpnext.utils.map_current_doc({ method: "erpnext.selling.doctype.sales_order.sales_order.make_delivery_note", + args: { + for_reserved_stock: 1 + }, source_doctype: "Sales Order", target: me.frm, setters: { diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index ea20a264674b..190575eb94ee 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -279,6 +279,8 @@ def on_cancel(self): self.update_prevdoc_status() self.update_billing_status() + self.update_stock_reservation_entries() + # Updating stock ledger should always be called after updating prevdoc status, # because updating reserved qty in bin depends upon updated delivered qty in SO self.update_stock_ledger() @@ -297,55 +299,141 @@ def on_cancel(self): def update_stock_reservation_entries(self) -> None: """Updates Delivered Qty in Stock Reservation Entries.""" - # Don't update Delivered Qty on Return or Cancellation. - if self.is_return or self._action == "cancel": + # Don't update Delivered Qty on Return. + if self.is_return: return - for item in self.get("items"): - # Skip if `Sales Order` or `Sales Order Item` reference is not set. - if not item.against_sales_order or not item.so_detail: - continue - - sre_list = frappe.db.get_all( - "Stock Reservation Entry", - { - "docstatus": 1, - "voucher_type": "Sales Order", - "voucher_no": item.against_sales_order, - "voucher_detail_no": item.so_detail, - "warehouse": item.warehouse, - "status": ["not in", ["Delivered", "Cancelled"]], - }, - order_by="creation", - ) - - # Skip if no Stock Reservation Entries. - if not sre_list: - continue + if self._action == "submit": + for item in self.get("items"): + # Skip if `Sales Order` or `Sales Order Item` reference is not set. + if not item.against_sales_order or not item.so_detail: + continue - available_qty_to_deliver = item.stock_qty - for sre in sre_list: - if available_qty_to_deliver <= 0: - break - - sre_doc = frappe.get_doc("Stock Reservation Entry", sre) - - # `Delivered Qty` should be less than or equal to `Reserved Qty`. - qty_to_be_deliver = min(sre_doc.reserved_qty - sre_doc.delivered_qty, available_qty_to_deliver) - - sre_doc.delivered_qty += qty_to_be_deliver - sre_doc.db_update() + sre_list = frappe.db.get_all( + "Stock Reservation Entry", + { + "docstatus": 1, + "voucher_type": "Sales Order", + "voucher_no": item.against_sales_order, + "voucher_detail_no": item.so_detail, + "warehouse": item.warehouse, + "status": ["not in", ["Delivered", "Cancelled"]], + }, + order_by="creation", + ) - # Update Stock Reservation Entry `Status` based on `Delivered Qty`. - sre_doc.update_status() + # Skip if no Stock Reservation Entries. + if not sre_list: + continue + + qty_to_deliver = item.stock_qty + for sre in sre_list: + if qty_to_deliver <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + qty_can_be_deliver = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + delivered_serial_nos = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in delivered_serial_nos: + entry.delivered_qty = 1 # Qty will always be 0 or 1 for Serial No. + entry.db_update() + qty_can_be_deliver += 1 + delivered_serial_nos.remove(entry.serial_no) + else: + delivered_batch_qty = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in delivered_batch_qty: + delivered_qty = min( + (entry.qty - entry.delivered_qty), delivered_batch_qty[entry.batch_no] + ) + entry.delivered_qty += delivered_qty + entry.db_update() + qty_can_be_deliver += delivered_qty + delivered_batch_qty[entry.batch_no] -= delivered_qty + else: + # `Delivered Qty` should be less than or equal to `Reserved Qty`. + qty_can_be_deliver = min((sre_doc.reserved_qty - sre_doc.delivered_qty), qty_to_deliver) + + sre_doc.delivered_qty += qty_can_be_deliver + sre_doc.db_update() + + # Update Stock Reservation Entry `Status` based on `Delivered Qty`. + sre_doc.update_status() + + qty_to_deliver -= qty_can_be_deliver + + if self._action == "cancel": + for item in self.get("items"): + # Skip if `Sales Order` or `Sales Order Item` reference is not set. + if not item.against_sales_order or not item.so_detail: + continue + + sre_list = frappe.db.get_all( + "Stock Reservation Entry", + { + "docstatus": 1, + "voucher_type": "Sales Order", + "voucher_no": item.against_sales_order, + "voucher_detail_no": item.so_detail, + "warehouse": item.warehouse, + "status": ["in", ["Partially Delivered", "Delivered"]], + }, + order_by="creation", + ) - available_qty_to_deliver -= qty_to_be_deliver + # Skip if no Stock Reservation Entries. + if not sre_list: + continue + + qty_to_undelivered = item.stock_qty + for sre in sre_list: + if qty_to_undelivered <= 0: + break + + sre_doc = frappe.get_doc("Stock Reservation Entry", sre) + + qty_can_be_undelivered = 0 + if sre_doc.reservation_based_on == "Serial and Batch": + sbb = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle) + if sre_doc.has_serial_no: + serial_nos_to_undelivered = [d.serial_no for d in sbb.entries] + for entry in sre_doc.sb_entries: + if entry.serial_no in serial_nos_to_undelivered: + entry.delivered_qty = 0 # Qty will always be 0 or 1 for Serial No. + entry.db_update() + qty_can_be_undelivered += 1 + serial_nos_to_undelivered.remove(entry.serial_no) + else: + batch_qty_to_undelivered = {d.batch_no: -1 * d.qty for d in sbb.entries} + for entry in sre_doc.sb_entries: + if entry.batch_no in batch_qty_to_undelivered: + undelivered_qty = min(entry.delivered_qty, batch_qty_to_undelivered[entry.batch_no]) + entry.delivered_qty -= undelivered_qty + entry.db_update() + qty_can_be_undelivered += undelivered_qty + batch_qty_to_undelivered[entry.batch_no] -= undelivered_qty + else: + # `Qty to Undelivered` should be less than or equal to `Delivered Qty`. + qty_can_be_undelivered = min(sre_doc.delivered_qty, qty_to_undelivered) + + sre_doc.delivered_qty -= qty_can_be_undelivered + sre_doc.db_update() + + # Update Stock Reservation Entry `Status` based on `Delivered Qty`. + sre_doc.update_status() + + qty_to_undelivered -= qty_can_be_undelivered def validate_against_stock_reservation_entries(self): """Validates if Stock Reservation Entries are available for the Sales Order Item reference.""" from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details_for_voucher_detail_no, + get_sre_reserved_warehouses_for_voucher, ) # Don't validate if Return @@ -357,26 +445,30 @@ def validate_against_stock_reservation_entries(self): if not item.against_sales_order or not item.so_detail: continue - sre_data = get_sre_reserved_qty_details_for_voucher_detail_no( + reserved_warehouses = get_sre_reserved_warehouses_for_voucher( "Sales Order", item.against_sales_order, item.so_detail ) # Skip if stock is not reserved. - if not sre_data: + if not reserved_warehouses: continue # Set `Warehouse` from SRE if not set. if not item.warehouse: - item.warehouse = sre_data[0] + item.warehouse = reserved_warehouses[0] else: - # Throw if `Warehouse` is different from SRE. - if item.warehouse != sre_data[0]: - frappe.throw( - _("Row #{0}: Stock is reserved for Item {1} in Warehouse {2}.").format( - item.idx, frappe.bold(item.item_code), frappe.bold(sre_data[0]) + # Throw if `Warehouse` not in Reserved Warehouses. + if item.warehouse not in reserved_warehouses: + msg = _("Row #{0}: Stock is reserved for item {1} in warehouse {2}.").format( + item.idx, + frappe.bold(item.item_code), + frappe.bold(reserved_warehouses[0]) + if len(reserved_warehouses) == 1 + else _("{0} and {1}").format( + frappe.bold(", ".join(reserved_warehouses[:-1])), frappe.bold(reserved_warehouses[-1]) ), - title=_("Stock Reservation Warehouse Mismatch"), ) + frappe.throw(msg, title=_("Stock Reservation Warehouse Mismatch")) def check_credit_limit(self): from erpnext.selling.doctype.customer.customer import check_credit_limit diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 31a3ecbc47ec..76e8866ece6e 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -3,6 +3,9 @@ frappe.provide("erpnext.item"); +const SALES_DOCTYPES = ['Quotation', 'Sales Order', 'Delivery Note', 'Sales Invoice']; +const PURCHASE_DOCTYPES = ['Purchase Order', 'Purchase Receipt', 'Purchase Invoice']; + frappe.ui.form.on("Item", { setup: function(frm) { frm.add_fetch('attribute', 'numeric_values', 'numeric_values'); @@ -888,7 +891,13 @@ function open_form(frm, doctype, child_doctype, parentfield) { let new_child_doc = frappe.model.add_child(new_doc, child_doctype, parentfield); new_child_doc.item_code = frm.doc.name; new_child_doc.item_name = frm.doc.item_name; - new_child_doc.uom = frm.doc.stock_uom; + if (in_list(SALES_DOCTYPES, doctype) && frm.doc.sales_uom) { + new_child_doc.uom = frm.doc.sales_uom; + } else if (in_list(PURCHASE_DOCTYPES, doctype) && frm.doc.purchase_uom) { + new_child_doc.uom = frm.doc.purchase_uom; + } else { + new_child_doc.uom = frm.doc.stock_uom; + } new_child_doc.description = frm.doc.description; if (!new_child_doc.qty) { new_child_doc.qty = 1.0; diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 87c2a7ea691b..756d0040f1ea 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -912,7 +912,7 @@ "index_web_pages_for_search": 1, "links": [], "make_attachments_public": 1, - "modified": "2023-07-14 17:18:18.658942", + "modified": "2023-08-28 22:16:40.305094", "modified_by": "Administrator", "module": "Stock", "name": "Item", @@ -971,7 +971,7 @@ "export": 1, "print": 1, "report": 1, - "role": "All", + "role": "Desk User", "select": 1, "share": 1 } diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js index 35c35a6f0795..4eed285fdab4 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.js +++ b/erpnext/stock/doctype/pick_list/pick_list.js @@ -115,6 +115,22 @@ frappe.ui.form.on('Pick List', { frm.add_custom_button(__('Stock Entry'), () => frm.trigger('create_stock_entry'), __('Create')); } }); + + if (frm.doc.purpose === 'Delivery' && frm.doc.status === 'Open') { + if (frm.doc.__onload && frm.doc.__onload.has_unreserved_stock) { + frm.add_custom_button(__('Reserve'), () => frm.events.create_stock_reservation_entries(frm), __('Stock Reservation')); + } + + if (frm.doc.__onload && frm.doc.__onload.has_reserved_stock) { + frm.add_custom_button(__('Unreserve'), () => { + frappe.confirm( + __('The reserved stock will be released. Are you certain you wish to proceed?'), + () => frm.events.cancel_stock_reservation_entries(frm) + ) + }, __('Stock Reservation')); + frm.add_custom_button(__('Reserved Stock'), () => frm.events.show_reserved_stock(frm), __('Stock Reservation')); + } + } } }, work_order: (frm) => { @@ -209,6 +225,49 @@ frappe.ui.form.on('Pick List', { }; const barcode_scanner = new erpnext.utils.BarcodeScanner(opts); barcode_scanner.process_scan(); + }, + create_stock_reservation_entries: (frm) => { + frappe.call({ + doc: frm.doc, + method: "create_stock_reservation_entries", + args: { + notify: true + }, + freeze: true, + freeze_message: __("Reserving Stock..."), + callback: (r) => { + frm.doc.__onload.has_unreserved_stock = false; + frm.reload_doc(); + } + }); + }, + cancel_stock_reservation_entries: (frm) => { + frappe.call({ + doc: frm.doc, + method: "cancel_stock_reservation_entries", + args: { + notify: true + }, + freeze: true, + freeze_message: __('Unreserving Stock...'), + callback: (r) => { + frm.doc.__onload.has_reserved_stock = false; + frm.reload_doc(); + } + }); + }, + show_reserved_stock(frm) { + // Get the latest modified date from the locations table. + var to_date = moment(new Date(Math.max(...frm.doc.locations.map(e => new Date(e.modified))))).format('YYYY-MM-DD'); + + frappe.route_options = { + company: frm.doc.company, + from_date: moment(frm.doc.creation).format('YYYY-MM-DD'), + to_date: to_date, + voucher_type: "Sales Order", + against_pick_list: frm.doc.name, + } + frappe.set_route("query-report", "Reserved Stock"); } }); diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index 922f76cff2e0..2fcd1025a0e4 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -29,6 +29,14 @@ class PickList(Document): + def onload(self) -> None: + if frappe.get_cached_value("Stock Settings", None, "enable_stock_reservation"): + if self.has_unreserved_stock(): + self.set_onload("has_unreserved_stock", True) + + if self.has_reserved_stock(): + self.set_onload("has_reserved_stock", True) + def validate(self): self.validate_for_qty() @@ -47,8 +55,28 @@ def before_save(self): ) def before_submit(self): + self.validate_sales_order() self.validate_picked_items() + def validate_sales_order(self): + """Raises an exception if the `Sales Order` has reserved stock.""" + + if self.purpose != "Delivery": + return + + so_list = set(location.sales_order for location in self.locations if location.sales_order) + + if so_list: + for so in so_list: + so_doc = frappe.get_doc("Sales Order", so) + for item in so_doc.items: + if item.stock_reserved_qty > 0: + frappe.throw( + _( + "Cannot create a pick list for Sales Order {0} because it has reserved stock. Please unreserve the stock in order to create a pick list." + ).format(frappe.bold(so)) + ) + def validate_picked_items(self): for item in self.locations: if self.scan_mode and item.picked_qty < item.stock_qty: @@ -70,8 +98,19 @@ def on_submit(self): self.update_reference_qty() self.update_sales_order_picking_status() + def on_update_after_submit(self) -> None: + if self.has_reserved_stock(): + msg = _( + "The Pick List having Stock Reservation Entries cannot be updated. If you need to make changes, we recommend canceling the existing Stock Reservation Entries before updating the Pick List." + ) + frappe.throw(msg) + def on_cancel(self): - self.ignore_linked_doctypes = "Serial and Batch Bundle" + self.ignore_linked_doctypes = [ + "Serial and Batch Bundle", + "Stock Reservation Entry", + "Delivery Note", + ] self.update_status() self.update_bundle_picked_qty() @@ -186,6 +225,36 @@ def update_sales_order_picking_status(self) -> None: for sales_order in sales_orders: frappe.get_doc("Sales Order", sales_order, for_update=True).update_picking_status() + @frappe.whitelist() + def create_stock_reservation_entries(self, notify=True) -> None: + """Creates Stock Reservation Entries for Sales Order Items against Pick List.""" + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + create_stock_reservation_entries_for_so_items, + ) + + so_details = {} + for location in self.locations: + if location.warehouse and location.sales_order and location.sales_order_item: + so_details.setdefault(location.sales_order, []).append(location) + + if so_details: + for so, locations in so_details.items(): + so_doc = frappe.get_doc("Sales Order", so) + create_stock_reservation_entries_for_so_items( + so=so_doc, items_details=locations, against_pick_list=True, notify=notify + ) + + @frappe.whitelist() + def cancel_stock_reservation_entries(self, notify=True) -> None: + """Cancel Stock Reservation Entries for Sales Order Items created against Pick List.""" + + from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + ) + + cancel_stock_reservation_entries(against_pick_list=self.name, notify=notify) + def validate_picked_qty(self, data): over_delivery_receipt_allowance = 100 + flt( frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance") @@ -448,6 +517,26 @@ def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int: possible_bundles.append(0) return int(flt(min(possible_bundles), precision or 6)) + def has_unreserved_stock(self): + if self.purpose == "Delivery": + for location in self.locations: + if ( + location.sales_order + and location.sales_order_item + and (flt(location.picked_qty) - flt(location.stock_reserved_qty)) > 0 + ): + return True + + return False + + def has_reserved_stock(self): + if self.purpose == "Delivery": + for location in self.locations: + if location.sales_order and location.sales_order_item and flt(location.stock_reserved_qty) > 0: + return True + + return False + def update_pick_list_status(pick_list): if pick_list: @@ -781,7 +870,8 @@ def create_dn_with_so(sales_dict, pick_list): for customer in sales_dict: for so in sales_dict[customer]: delivery_note = None - delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True) + kwargs = {"skip_item_mapping": True} + delivery_note = create_delivery_note_from_sales_order(so, delivery_note, kwargs=kwargs) break if delivery_note: # map all items of all sales orders of that customer diff --git a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py index 7fbcbafbac1f..0830fa21430f 100644 --- a/erpnext/stock/doctype/pick_list/pick_list_dashboard.py +++ b/erpnext/stock/doctype/pick_list/pick_list_dashboard.py @@ -1,10 +1,13 @@ def get_data(): return { "fieldname": "pick_list", + "non_standard_fieldnames": { + "Stock Reservation Entry": "against_pick_list", + }, "internal_links": { "Sales Order": ["locations", "sales_order"], }, "transactions": [ - {"items": ["Stock Entry", "Sales Order", "Delivery Note"]}, + {"items": ["Stock Entry", "Sales Order", "Delivery Note", "Stock Reservation Entry"]}, ], } diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index 2e56af3395c5..e8e4afc6e3f3 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -16,6 +16,7 @@ "qty", "stock_qty", "picked_qty", + "stock_reserved_qty", "column_break_11", "uom", "conversion_factor", @@ -46,7 +47,7 @@ "fieldname": "picked_qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Picked Qty" + "label": "Picked Qty (in Stock UOM)" }, { "fieldname": "warehouse", @@ -154,8 +155,7 @@ "fieldtype": "Data", "hidden": 1, "label": "Sales Order Item", - "read_only": 1, - "search_index": 1 + "read_only": 1 }, { "fieldname": "serial_no_and_batch_section", @@ -207,6 +207,17 @@ "fieldname": "pick_serial_and_batch", "fieldtype": "Button", "label": "Pick Serial / Batch No" + }, + { + "default": "0", + "fieldname": "stock_reserved_qty", + "fieldtype": "Float", + "label": "Stock Reserved Qty (in Stock UOM)", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 } ], "istable": 1, 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 1f90c5bf7a53..96e4a556306c 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 @@ -66,7 +66,7 @@ def validate_serial_nos_inventory(self): serial_nos = [d.serial_no for d in self.entries if d.serial_no] kwargs = {"item_code": self.item_code, "warehouse": self.warehouse} if self.voucher_type == "POS Invoice": - kwargs["ignore_voucher_no"] = self.voucher_no + kwargs["ignore_voucher_nos"] = [self.voucher_no] available_serial_nos = get_available_serial_nos(frappe._dict(kwargs)) @@ -1098,8 +1098,8 @@ def get_available_serial_nos(kwargs): if kwargs.warehouse: filters["warehouse"] = kwargs.warehouse - # Since SLEs are not present against POS invoices, need to ignore serial nos present in the POS invoice - ignore_serial_nos = get_reserved_serial_nos_for_pos(kwargs) + # 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) # To ignore serial nos in the same record for the draft state if kwargs.get("ignore_serial_nos"): @@ -1180,6 +1180,20 @@ def get_serial_nos_based_on_posting_date(kwargs, ignore_serial_nos): return serial_nos +def get_reserved_serial_nos(kwargs) -> list: + """Returns a list of `Serial No` reserved in POS Invoice and Stock Reservation Entry.""" + + ignore_serial_nos = [] + + # Extend the list by serial nos reserved in POS Invoice + ignore_serial_nos.extend(get_reserved_serial_nos_for_pos(kwargs)) + + # Extend the list by serial nos reserved via SRE + ignore_serial_nos.extend(get_reserved_serial_nos_for_sre(kwargs)) + + return ignore_serial_nos + + def get_reserved_serial_nos_for_pos(kwargs): from erpnext.controllers.sales_and_purchase_return import get_returned_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos @@ -1199,7 +1213,7 @@ def get_reserved_serial_nos_for_pos(kwargs): ["POS Invoice", "docstatus", "=", 1], ["POS Invoice", "is_return", "=", 0], ["POS Invoice Item", "item_code", "=", kwargs.item_code], - ["POS Invoice", "name", "!=", kwargs.ignore_voucher_no], + ["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos], ], ) @@ -1251,7 +1265,37 @@ def get_reserved_serial_nos_for_pos(kwargs): return list(ignore_serial_nos_counter - returned_serial_nos_counter) -def get_reserved_batches_for_pos(kwargs): +def get_reserved_serial_nos_for_sre(kwargs) -> list: + """Returns a list of `Serial No` reserved in Stock Reservation Entry.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.serial_no) + .where( + (sre.docstatus == 1) + & (sre.item_code == kwargs.item_code) + & (sre.reserved_qty >= sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") + ) + ) + + if kwargs.warehouse: + query = query.where(sre.warehouse == kwargs.warehouse) + + if kwargs.ignore_voucher_nos: + query = query.where(sre.name.notin(kwargs.ignore_voucher_nos)) + + return [row[0] for row in query.run()] + + +def get_reserved_batches_for_pos(kwargs) -> dict: + """Returns a dict of `Batch No` followed by the `Qty` reserved in POS Invoices.""" + pos_batches = frappe._dict() pos_invoices = frappe.get_all( "POS Invoice", @@ -1267,7 +1311,7 @@ def get_reserved_batches_for_pos(kwargs): ["POS Invoice", "consolidated_invoice", "is", "not set"], ["POS Invoice", "docstatus", "=", 1], ["POS Invoice Item", "item_code", "=", kwargs.item_code], - ["POS Invoice", "name", "!=", kwargs.ignore_voucher_no], + ["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos], ], ) @@ -1278,7 +1322,7 @@ def get_reserved_batches_for_pos(kwargs): ] if not ids: - return [] + return {} if ids: for d in get_serial_batch_ledgers(kwargs.item_code, docstatus=1, name=ids): @@ -1314,14 +1358,65 @@ def get_reserved_batches_for_pos(kwargs): return pos_batches +def get_reserved_batches_for_sre(kwargs) -> dict: + """Returns a dict of `Batch No` followed by the `Qty` reserved in Stock Reservation Entry.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + query = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select( + sb_entry.batch_no, sre.warehouse, (-1 * Sum(sb_entry.qty - sb_entry.delivered_qty)).as_("qty") + ) + .where( + (sre.docstatus == 1) + & (sre.item_code == kwargs.item_code) + & (sre.reserved_qty >= sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") + ) + .groupby(sb_entry.batch_no, sre.warehouse) + ) + + if kwargs.batch_no: + if isinstance(kwargs.batch_no, list): + query = query.where(sb_entry.batch_no.isin(kwargs.batch_no)) + else: + query = query.where(sb_entry.batch_no == kwargs.batch_no) + + if kwargs.warehouse: + query = query.where(sre.warehouse == kwargs.warehouse) + + if kwargs.ignore_voucher_nos: + query = query.where(sre.name.notin(kwargs.ignore_voucher_nos)) + + data = query.run(as_dict=True) + + reserved_batches_details = frappe._dict() + if data: + reserved_batches_details = frappe._dict( + { + (d.batch_no, d.warehouse): frappe._dict({"warehouse": d.warehouse, "qty": d.qty}) for d in data + } + ) + + return reserved_batches_details + + def get_auto_batch_nos(kwargs): available_batches = get_available_batches(kwargs) qty = flt(kwargs.qty) - pos_invoice_batches = get_reserved_batches_for_pos(kwargs) stock_ledgers_batches = get_stock_ledgers_batches(kwargs) - if stock_ledgers_batches or pos_invoice_batches: - update_available_batches(available_batches, stock_ledgers_batches, pos_invoice_batches) + pos_invoice_batches = get_reserved_batches_for_pos(kwargs) + sre_reserved_batches = get_reserved_batches_for_sre(kwargs) + + if stock_ledgers_batches or pos_invoice_batches or sre_reserved_batches: + update_available_batches( + available_batches, stock_ledgers_batches, pos_invoice_batches, sre_reserved_batches + ) available_batches = list(filter(lambda x: x.qty > 0, available_batches)) @@ -1364,8 +1459,8 @@ def get_qty_based_available_batches(available_batches, qty): return batches -def update_available_batches(available_batches, reserved_batches=None, pos_invoice_batches=None): - for batches in [reserved_batches, pos_invoice_batches]: +def update_available_batches(available_batches, *reserved_batches) -> None: + for batches in reserved_batches: if batches: for key, data in batches.items(): batch_no, warehouse = key diff --git a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json index 6ec212994421..09565cbc8a8b 100644 --- a/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json +++ b/erpnext/stock/doctype/serial_and_batch_entry/serial_and_batch_entry.json @@ -10,6 +10,7 @@ "column_break_2", "qty", "warehouse", + "delivered_qty", "section_break_6", "incoming_rate", "column_break_8", @@ -104,12 +105,24 @@ "fieldtype": "Small Text", "label": "FIFO Stock Queue (qty, rate)", "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval: parent.doctype == \"Stock Reservation Entry\"", + "fieldname": "delivered_qty", + "fieldtype": "Float", + "label": "Delivered Qty", + "no_copy": 1, + "non_negative": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2023-03-31 11:18:59.809486", + "modified": "2023-07-03 15:29:50.199075", "modified_by": "Administrator", "module": "Stock", "name": "Serial and Batch Entry", diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py index f009bd42e4ef..26ca012d2c46 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py @@ -346,7 +346,7 @@ def validate_reserved_stock(self) -> None: """Raises an exception if there is any reserved stock for the items in the Stock Reconciliation.""" from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details_for_item_and_warehouse as get_sre_reserved_qty_details, + get_sre_reserved_qty_for_item_and_warehouse as get_sre_reserved_qty_details, ) item_code_list, warehouse_list = [], [] diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js index 666fd24329b5..4d9663602dd3 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.js @@ -3,6 +3,124 @@ frappe.ui.form.on("Stock Reservation Entry", { refresh(frm) { - frm.page.btn_primary.hide() + frm.trigger("set_queries"); + frm.trigger("toggle_read_only_fields"); + frm.trigger("hide_rate_related_fields"); + frm.trigger("hide_primary_action_button"); + frm.trigger("make_sb_entries_warehouse_read_only"); + }, + + has_serial_no(frm) { + frm.trigger("toggle_read_only_fields"); + }, + + has_batch_no(frm) { + frm.trigger("toggle_read_only_fields"); + }, + + warehouse(frm) { + if (frm.doc.warehouse) { + frm.doc.sb_entries.forEach((row) => { + frappe.model.set_value(row.doctype, row.name, "warehouse", frm.doc.warehouse); + }); + } + }, + + set_queries(frm) { + frm.set_query("warehouse", () => { + return { + filters: { + "is_group": 0, + "company": frm.doc.company, + } + }; + }); + + frm.set_query("serial_no", "sb_entries", function(doc, cdt, cdn) { + var selected_serial_nos = doc.sb_entries.map(row => { + return row.serial_no; + }); + var row = locals[cdt][cdn]; + return { + filters: { + item_code: doc.item_code, + warehouse: row.warehouse, + status: "Active", + name: ["not in", selected_serial_nos], + } + } + }); + + frm.set_query("batch_no", "sb_entries", function(doc, cdt, cdn) { + let filters = { + item: doc.item_code, + batch_qty: [">", 0], + disabled: 0, + } + + if (!doc.has_serial_no) { + var selected_batch_nos = doc.sb_entries.map(row => { + return row.batch_no; + }); + + filters.name = ["not in", selected_batch_nos]; + } + + return { filters: filters } + }); + }, + + toggle_read_only_fields(frm) { + if (frm.doc.has_serial_no) { + frm.doc.sb_entries.forEach(row => { + if (row.qty !== 1) { + frappe.model.set_value(row.doctype, row.name, "qty", 1); + } + }) + } + + frm.fields_dict.sb_entries.grid.update_docfield_property( + "serial_no", "read_only", !frm.doc.has_serial_no + ); + + frm.fields_dict.sb_entries.grid.update_docfield_property( + "batch_no", "read_only", !frm.doc.has_batch_no + ); + + // Qty will always be 1 for Serial No. + frm.fields_dict.sb_entries.grid.update_docfield_property( + "qty", "read_only", frm.doc.has_serial_no + ); + + frm.set_df_property("sb_entries", "allow_on_submit", frm.doc.against_pick_list ? 0 : 1); + }, + + hide_rate_related_fields(frm) { + ["incoming_rate", "outgoing_rate", "stock_value_difference", "is_outward", "stock_queue"].forEach(field => { + frm.fields_dict.sb_entries.grid.update_docfield_property( + field, "hidden", 1 + ); + }); + }, + + hide_primary_action_button(frm) { + // Hide "Amend" button on cancelled document + if (frm.doc.docstatus == 2) { + frm.page.btn_primary.hide() + } + }, + + make_sb_entries_warehouse_read_only(frm) { + frm.fields_dict.sb_entries.grid.update_docfield_property( + "warehouse", "read_only", 1 + ); }, }); + +frappe.ui.form.on("Serial and Batch Entry", { + sb_entries_add(frm, cdt, cdn) { + if (frm.doc.warehouse) { + frappe.model.set_value(cdt, cdn, "warehouse", frm.doc.warehouse); + } + }, +}); \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json index 7c7abacd9127..5c3018f73423 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.json @@ -2,7 +2,7 @@ "actions": [], "allow_copy": 1, "autoname": "MAT-SRE-.YYYY.-.#####", - "creation": "2023-03-20 10:45:59.258959", + "creation": "2023-06-06 15:20:48.016846", "default_view": "List", "doctype": "DocType", "editable_grid": 1, @@ -10,17 +10,26 @@ "field_order": [ "item_code", "warehouse", + "has_serial_no", + "has_batch_no", "column_break_elik", "voucher_type", "voucher_no", "voucher_detail_no", + "column_break_7dxj", + "against_pick_list", + "against_pick_list_item", "section_break_xt4m", + "stock_uom", + "column_break_grdt", "available_qty", "voucher_qty", - "stock_uom", "column_break_o6ex", "reserved_qty", "delivered_qty", + "serial_and_batch_reservation_section", + "reservation_based_on", + "sb_entries", "section_break_3vb3", "company", "column_break_jbyr", @@ -36,6 +45,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Item Code", + "no_copy": 1, "oldfieldname": "item_code", "oldfieldtype": "Link", "options": "Item", @@ -51,6 +61,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Warehouse", + "no_copy": 1, "oldfieldname": "warehouse", "oldfieldtype": "Link", "options": "Warehouse", @@ -64,6 +75,7 @@ "fieldtype": "Select", "in_filter": 1, "label": "Voucher Type", + "no_copy": 1, "oldfieldname": "voucher_type", "oldfieldtype": "Data", "options": "\nSales Order", @@ -78,17 +90,20 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Voucher No", + "no_copy": 1, "oldfieldname": "voucher_no", "oldfieldtype": "Data", "options": "voucher_type", "print_width": "150px", "read_only": 1, + "search_index": 1, "width": "150px" }, { "fieldname": "voucher_detail_no", "fieldtype": "Data", "label": "Voucher Detail No", + "no_copy": 1, "oldfieldname": "voucher_detail_no", "oldfieldtype": "Data", "print_width": "150px", @@ -100,6 +115,7 @@ "fieldname": "stock_uom", "fieldtype": "Link", "label": "Stock UOM", + "no_copy": 1, "oldfieldname": "stock_uom", "oldfieldtype": "Data", "options": "UOM", @@ -111,14 +127,17 @@ "fieldname": "project", "fieldtype": "Link", "label": "Project", + "no_copy": 1, "options": "Project", - "read_only": 1 + "read_only": 1, + "search_index": 1 }, { "fieldname": "company", "fieldtype": "Link", "in_filter": 1, "label": "Company", + "no_copy": 1, "oldfieldname": "company", "oldfieldtype": "Data", "options": "Company", @@ -128,23 +147,26 @@ "width": "150px" }, { + "allow_on_submit": 1, "fieldname": "reserved_qty", "fieldtype": "Float", "in_filter": 1, "in_list_view": 1, "label": "Reserved Qty", + "no_copy": 1, + "non_negative": 1, "oldfieldname": "actual_qty", "oldfieldtype": "Currency", "print_width": "150px", - "read_only": 1, + "read_only_depends_on": "eval: ((doc.reservation_based_on == \"Serial and Batch\") || (doc.against_pick_list) || (doc.delivered_qty > 0))", "width": "150px" }, { "default": "Draft", "fieldname": "status", "fieldtype": "Select", - "hidden": 1, "label": "Status", + "no_copy": 1, "options": "Draft\nPartially Reserved\nReserved\nPartially Delivered\nDelivered\nCancelled", "read_only": 1 }, @@ -153,6 +175,8 @@ "fieldname": "delivered_qty", "fieldtype": "Float", "label": "Delivered Qty", + "no_copy": 1, + "non_negative": 1, "read_only": 1 }, { @@ -170,6 +194,7 @@ "fieldtype": "Float", "label": "Available Qty to Reserve", "no_copy": 1, + "non_negative": 1, "read_only": 1 }, { @@ -178,6 +203,7 @@ "fieldtype": "Float", "label": "Voucher Qty", "no_copy": 1, + "non_negative": 1, "read_only": 1 }, { @@ -193,12 +219,84 @@ "fieldtype": "Column Break" }, { + "collapsible": 1, "fieldname": "section_break_3vb3", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "More Information" }, { "fieldname": "column_break_jbyr", "fieldtype": "Column Break" + }, + { + "default": "0", + "depends_on": "eval: doc.has_serial_no", + "fieldname": "has_serial_no", + "fieldtype": "Check", + "label": "Has Serial No", + "no_copy": 1, + "read_only": 1 + }, + { + "default": "0", + "depends_on": "eval: doc.has_batch_no", + "fieldname": "has_batch_no", + "fieldtype": "Check", + "label": "Has Batch No", + "no_copy": 1, + "read_only": 1 + }, + { + "allow_on_submit": 1, + "depends_on": "eval: (doc.has_serial_no || doc.has_batch_no) && doc.reservation_based_on == \"Serial and Batch\"", + "fieldname": "sb_entries", + "fieldtype": "Table", + "options": "Serial and Batch Entry", + "read_only_depends_on": "eval: (doc.delivered_qty > 0)" + }, + { + "fieldname": "serial_and_batch_reservation_section", + "fieldtype": "Section Break", + "label": "Serial and Batch Reservation" + }, + { + "allow_on_submit": 1, + "default": "Qty", + "depends_on": "eval: parent.has_serial_no || parent.has_batch_no", + "fieldname": "reservation_based_on", + "fieldtype": "Select", + "label": "Reservation Based On", + "no_copy": 1, + "options": "Qty\nSerial and Batch", + "read_only_depends_on": "eval: (doc.delivered_qty > 0 || doc.against_pick_list)" + }, + { + "fieldname": "against_pick_list", + "fieldtype": "Link", + "label": "Against Pick List", + "no_copy": 1, + "options": "Pick List", + "print_hide": 1, + "read_only": 1, + "report_hide": 1, + "search_index": 1 + }, + { + "fieldname": "against_pick_list_item", + "fieldtype": "Data", + "label": "Against Pick List Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1, + "report_hide": 1 + }, + { + "fieldname": "column_break_7dxj", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_grdt", + "fieldtype": "Column Break" } ], "hide_toolbar": 1, @@ -206,7 +304,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-03-29 18:36:26.752872", + "modified": "2023-08-08 17:15:13.317706", "modified_by": "Administrator", "module": "Stock", "name": "Stock Reservation Entry", @@ -230,5 +328,6 @@ ], "sort_field": "modified", "sort_order": "DESC", - "states": [] + "states": [], + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py index 5819dd734238..bd7bb6683676 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/stock_reservation_entry.py @@ -5,27 +5,57 @@ from frappe import _ from frappe.model.document import Document from frappe.query_builder.functions import Sum +from frappe.utils import cint, flt class StockReservationEntry(Document): def validate(self) -> None: from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company + self.validate_amended_doc() self.validate_mandatory() self.validate_for_group_warehouse() validate_disabled_warehouse(self.warehouse) validate_warehouse_company(self.warehouse, self.company) + self.validate_uom_is_integer() + + def before_submit(self) -> None: + self.set_reservation_based_on() + self.validate_reservation_based_on_qty() + self.auto_reserve_serial_and_batch() + self.validate_reservation_based_on_serial_and_batch() def on_submit(self) -> None: self.update_reserved_qty_in_voucher() + self.update_reserved_qty_in_pick_list() self.update_status() + def on_update_after_submit(self) -> None: + self.can_be_updated() + self.validate_uom_is_integer() + self.set_reservation_based_on() + self.validate_reservation_based_on_qty() + self.validate_reservation_based_on_serial_and_batch() + self.update_reserved_qty_in_voucher() + self.update_status() + self.reload() + def on_cancel(self) -> None: self.update_reserved_qty_in_voucher() + self.update_reserved_qty_in_pick_list() self.update_status() + def validate_amended_doc(self) -> None: + """Raises an exception if document is amended.""" + + if self.amended_from: + msg = _("Cannot amend {0} {1}, please create a new one instead.").format( + self.doctype, frappe.bold(self.amended_from) + ) + frappe.throw(msg) + def validate_mandatory(self) -> None: - """Raises exception if mandatory fields are not set.""" + """Raises an exception if mandatory fields are not set.""" mandatory = [ "item_code", @@ -41,36 +71,217 @@ def validate_mandatory(self) -> None: ] for d in mandatory: if not self.get(d): - frappe.throw(_("{0} is required").format(self.meta.get_label(d))) + msg = _("{0} is required").format(self.meta.get_label(d)) + frappe.throw(msg) def validate_for_group_warehouse(self) -> None: - """Raises exception if `Warehouse` is a Group Warehouse.""" + """Raises an exception if `Warehouse` is a Group Warehouse.""" if frappe.get_cached_value("Warehouse", self.warehouse, "is_group"): - frappe.throw( - _("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse)), - title=_("Invalid Warehouse"), + msg = _("Stock cannot be reserved in group warehouse {0}.").format(frappe.bold(self.warehouse)) + frappe.throw(msg, title=_("Invalid Warehouse")) + + def validate_uom_is_integer(self) -> None: + """Validates `Reserved Qty` with Stock UOM.""" + + if cint(frappe.db.get_value("UOM", self.stock_uom, "must_be_whole_number", cache=True)): + if cint(self.reserved_qty) != flt(self.reserved_qty, self.precision("reserved_qty")): + msg = _( + "Reserved Qty ({0}) cannot be a fraction. To allow this, disable '{1}' in UOM {3}." + ).format( + flt(self.reserved_qty, self.precision("reserved_qty")), + frappe.bold(_("Must be Whole Number")), + frappe.bold(self.stock_uom), + ) + frappe.throw(msg) + + def set_reservation_based_on(self) -> None: + """Sets `Reservation Based On` based on `Has Serial No` and `Has Batch No`.""" + + if (self.reservation_based_on == "Serial and Batch") and ( + not self.has_serial_no and not self.has_batch_no + ): + self.db_set("reservation_based_on", "Qty") + + def validate_reservation_based_on_qty(self) -> None: + """Validates `Reserved Qty` when `Reservation Based On` is `Qty`.""" + + if self.reservation_based_on == "Qty": + self.validate_with_max_reserved_qty(self.reserved_qty) + + def auto_reserve_serial_and_batch(self, based_on: str = None) -> None: + """Auto pick Serial and Batch Nos to reserve when `Reservation Based On` is `Serial and Batch`.""" + + if ( + not self.against_pick_list + and (self.get("_action") == "submit") + and (self.has_serial_no or self.has_batch_no) + and cint(frappe.db.get_single_value("Stock Settings", "auto_reserve_serial_and_batch")) + ): + from erpnext.stock.doctype.batch.batch import get_available_batches + from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos_for_outward + from erpnext.stock.serial_batch_bundle import get_serial_nos_batch + + self.reservation_based_on = "Serial and Batch" + self.sb_entries.clear() + kwargs = frappe._dict( + { + "item_code": self.item_code, + "warehouse": self.warehouse, + "qty": abs(self.reserved_qty) or 0, + "based_on": based_on + or frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"), + } ) - def update_status(self, status: str = None, update_modified: bool = True) -> None: - """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty.""" + serial_nos, batch_nos = [], [] + if self.has_serial_no: + serial_nos = get_serial_nos_for_outward(kwargs) + if self.has_batch_no: + batch_nos = get_available_batches(kwargs) + + if serial_nos: + serial_no_wise_batch = frappe._dict({}) + + if self.has_batch_no: + serial_no_wise_batch = get_serial_nos_batch(serial_nos) + + for serial_no in serial_nos: + self.append( + "sb_entries", + { + "serial_no": serial_no, + "qty": 1, + "batch_no": serial_no_wise_batch.get(serial_no), + "warehouse": self.warehouse, + }, + ) + elif batch_nos: + for batch_no, batch_qty in batch_nos.items(): + self.append( + "sb_entries", + { + "batch_no": batch_no, + "qty": batch_qty, + "warehouse": self.warehouse, + }, + ) + + def validate_reservation_based_on_serial_and_batch(self) -> None: + """Validates `Reserved Qty`, `Serial and Batch Nos` when `Reservation Based On` is `Serial and Batch`.""" + + if self.reservation_based_on == "Serial and Batch": + allow_partial_reservation = frappe.db.get_single_value( + "Stock Settings", "allow_partial_reservation" + ) - if not status: - if self.docstatus == 2: - status = "Cancelled" - elif self.docstatus == 1: - if self.reserved_qty == self.delivered_qty: - status = "Delivered" - elif self.delivered_qty and self.delivered_qty < self.reserved_qty: - status = "Partially Delivered" - elif self.reserved_qty == self.voucher_qty: - status = "Reserved" - else: - status = "Partially Reserved" - else: - status = "Draft" + available_serial_nos = [] + if self.has_serial_no: + available_serial_nos = get_available_serial_nos_to_reserve( + self.item_code, self.warehouse, self.has_batch_no, ignore_sre=self.name + ) - frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified) + if not available_serial_nos: + msg = _("Stock not available for Item {0} in Warehouse {1}.").format( + frappe.bold(self.item_code), frappe.bold(self.warehouse) + ) + frappe.throw(msg) + + qty_to_be_reserved = 0 + selected_batch_nos, selected_serial_nos = [], [] + for entry in self.sb_entries: + entry.warehouse = self.warehouse + + if self.has_serial_no: + entry.qty = 1 + + key = ( + (entry.serial_no, self.warehouse, entry.batch_no) + if self.has_batch_no + else (entry.serial_no, self.warehouse) + ) + if key not in available_serial_nos: + msg = _( + "Row #{0}: Serial No {1} for Item {2} is not available in {3} {4} or might be reserved in another {5}." + ).format( + entry.idx, + frappe.bold(entry.serial_no), + frappe.bold(self.item_code), + _("Batch {0} and Warehouse").format(frappe.bold(entry.batch_no)) + if self.has_batch_no + else _("Warehouse"), + frappe.bold(self.warehouse), + frappe.bold("Stock Reservation Entry"), + ) + + frappe.throw(msg) + + if entry.serial_no in selected_serial_nos: + msg = _("Row #{0}: Serial No {1} is already selected.").format( + entry.idx, frappe.bold(entry.serial_no) + ) + frappe.throw(msg) + else: + selected_serial_nos.append(entry.serial_no) + + elif self.has_batch_no: + if cint(frappe.db.get_value("Batch", entry.batch_no, "disabled")): + msg = _( + "Row #{0}: Stock cannot be reserved for Item {1} against a disabled Batch {2}." + ).format( + entry.idx, frappe.bold(self.item_code), frappe.bold(entry.batch_no) + ) + frappe.throw(msg) + + available_qty_to_reserve = get_available_qty_to_reserve( + self.item_code, self.warehouse, entry.batch_no, ignore_sre=self.name + ) + + if available_qty_to_reserve <= 0: + msg = _( + "Row #{0}: Stock not availabe to reserve for Item {1} against Batch {2} in Warehouse {3}." + ).format( + entry.idx, + frappe.bold(self.item_code), + frappe.bold(entry.batch_no), + frappe.bold(self.warehouse), + ) + frappe.throw(msg) + + if entry.qty > available_qty_to_reserve: + if allow_partial_reservation: + entry.qty = available_qty_to_reserve + if self.get("_action") == "update_after_submit": + entry.db_update() + else: + msg = _( + "Row #{0}: Qty should be less than or equal to Available Qty to Reserve (Actual Qty - Reserved Qty) {1} for Iem {2} against Batch {3} in Warehouse {4}." + ).format( + entry.idx, + frappe.bold(available_qty_to_reserve), + frappe.bold(self.item_code), + frappe.bold(entry.batch_no), + frappe.bold(self.warehouse), + ) + frappe.throw(msg) + + if entry.batch_no in selected_batch_nos: + msg = _("Row #{0}: Batch No {1} is already selected.").format( + entry.idx, frappe.bold(entry.batch_no) + ) + frappe.throw(msg) + else: + selected_batch_nos.append(entry.batch_no) + + qty_to_be_reserved += entry.qty + + if not qty_to_be_reserved: + msg = _("Please select Serial/Batch Nos to reserve or change Reservation Based On to Qty.") + frappe.throw(msg) + + # Should be called after validating Serial and Batch Nos. + self.validate_with_max_reserved_qty(qty_to_be_reserved) + self.db_set("reserved_qty", qty_to_be_reserved) def update_reserved_qty_in_voucher( self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True @@ -100,45 +311,177 @@ def update_reserved_qty_in_voucher( update_modified=update_modified, ) + def update_reserved_qty_in_pick_list( + self, reserved_qty_field: str = "stock_reserved_qty", update_modified: bool = True + ) -> None: + """Updates total reserved qty in the Pick List.""" + + if self.against_pick_list and self.against_pick_list_item: + sre = frappe.qb.DocType("Stock Reservation Entry") + reserved_qty = ( + frappe.qb.from_(sre) + .select(Sum(sre.reserved_qty)) + .where( + (sre.docstatus == 1) + & (sre.against_pick_list == self.against_pick_list) + & (sre.against_pick_list_item == self.against_pick_list_item) + ) + ).run(as_list=True)[0][0] or 0 + + frappe.db.set_value( + "Pick List Item", + self.against_pick_list_item, + reserved_qty_field, + reserved_qty, + update_modified=update_modified, + ) + + def update_status(self, status: str = None, update_modified: bool = True) -> None: + """Updates status based on Voucher Qty, Reserved Qty and Delivered Qty.""" + + if not status: + if self.docstatus == 2: + status = "Cancelled" + elif self.docstatus == 1: + if self.reserved_qty == self.delivered_qty: + status = "Delivered" + elif self.delivered_qty and self.delivered_qty < self.reserved_qty: + status = "Partially Delivered" + elif self.reserved_qty == self.voucher_qty: + status = "Reserved" + else: + status = "Partially Reserved" + else: + status = "Draft" + + frappe.db.set_value(self.doctype, self.name, "status", status, update_modified=update_modified) + + def can_be_updated(self) -> None: + """Raises an exception if `Stock Reservation Entry` is not allowed to be updated.""" + + if self.status in ("Partially Delivered", "Delivered"): + msg = _( + "{0} {1} cannot be updated. If you need to make changes, we recommend canceling the existing entry and creating a new one." + ).format(self.status, self.doctype) + frappe.throw(msg) + + if self.against_pick_list: + msg = _( + "Stock Reservation Entry created against a Pick List cannot be updated. If you need to make changes, we recommend canceling the existing entry and creating a new one." + ) + frappe.throw(msg) + + if self.delivered_qty > 0: + msg = _("Stock Reservation Entry cannot be updated as it has been delivered.") + frappe.throw(msg) + + def validate_with_max_reserved_qty(self, qty_to_be_reserved: float) -> None: + """Validates `Reserved Qty` with `Max Reserved Qty`.""" + + self.db_set( + "available_qty", + get_available_qty_to_reserve(self.item_code, self.warehouse, ignore_sre=self.name), + ) + + total_reserved_qty = get_sre_reserved_qty_for_voucher_detail_no( + self.voucher_type, self.voucher_no, self.voucher_detail_no, ignore_sre=self.name + ) + + voucher_delivered_qty = 0 + if self.voucher_type == "Sales Order": + delivered_qty, conversion_factor = frappe.db.get_value( + "Sales Order Item", self.voucher_detail_no, ["delivered_qty", "conversion_factor"] + ) + voucher_delivered_qty = flt(delivered_qty) * flt(conversion_factor) + + max_reserved_qty = min( + self.available_qty, (self.voucher_qty - voucher_delivered_qty - total_reserved_qty) + ) + + if max_reserved_qty <= 0 and self.voucher_type == "Sales Order": + msg = _("Item {0} is already delivered for Sales Order {1}.").format( + frappe.bold(self.item_code), frappe.bold(self.voucher_no) + ) + + if self.docstatus == 1: + self.cancel() + return frappe.msgprint(msg) + else: + frappe.throw(msg) + + if qty_to_be_reserved > max_reserved_qty: + msg = """ + Cannot reserve more than Max Reserved Qty {0} {1}.

+ The Max Reserved Qty is calculated as follows:
+
    +
  • Available Qty To Reserve = (Actual Stock Qty - Reserved Stock Qty)
  • +
  • Voucher Qty = Voucher Item Qty
  • +
  • Delivered Qty = Qty delivered against the Voucher Item
  • +
  • Total Reserved Qty = Qty reserved against the Voucher Item
  • +
  • Max Reserved Qty = Minimum of (Available Qty To Reserve, (Voucher Qty - Delivered Qty - Total Reserved Qty))
  • +
+ """.format( + frappe.bold(max_reserved_qty), self.stock_uom + ) + frappe.throw(msg) + + if qty_to_be_reserved <= self.delivered_qty: + msg = _("Reserved Qty should be greater than Delivered Qty.") + frappe.throw(msg) + def validate_stock_reservation_settings(voucher: object) -> None: """Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed.""" if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"): - frappe.throw( - _("Please enable {0} in the {1}.").format( - frappe.bold("Stock Reservation"), frappe.bold("Stock Settings") - ) + msg = _("Please enable {0} in the {1}.").format( + frappe.bold("Stock Reservation"), frappe.bold("Stock Settings") ) + frappe.throw(msg) # Voucher types allowed for stock reservation allowed_voucher_types = ["Sales Order"] if voucher.doctype not in allowed_voucher_types: - frappe.throw( - _("Stock Reservation can only be created against {0}.").format(", ".join(allowed_voucher_types)) + msg = _("Stock Reservation can only be created against {0}.").format( + ", ".join(allowed_voucher_types) ) + frappe.throw(msg) -def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float: - """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item and Warehouse combination.""" +def get_available_qty_to_reserve( + item_code: str, warehouse: str, batch_no: str = None, ignore_sre=None +) -> float: + """Returns `Available Qty to Reserve (Actual Qty - Reserved Qty)` for Item, Warehouse and Batch combination.""" + from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.utils import get_stock_balance + if batch_no: + return get_batch_qty( + item_code=item_code, warehouse=warehouse, batch_no=batch_no, ignore_voucher_nos=[ignore_sre] + ) + available_qty = get_stock_balance(item_code, warehouse) if available_qty: sre = frappe.qb.DocType("Stock Reservation Entry") - reserved_qty = ( + query = ( frappe.qb.from_(sre) .select(Sum(sre.reserved_qty - sre.delivered_qty)) .where( (sre.docstatus == 1) & (sre.item_code == item_code) & (sre.warehouse == warehouse) + & (sre.reserved_qty >= sre.delivered_qty) & (sre.status.notin(["Delivered", "Cancelled"])) ) - ).run()[0][0] or 0.0 + ) + + if ignore_sre: + query = query.where(sre.name != ignore_sre) + + reserved_qty = query.run()[0][0] or 0.0 if reserved_qty: return available_qty - reserved_qty @@ -146,93 +489,97 @@ def get_available_qty_to_reserve(item_code: str, warehouse: str) -> float: return available_qty -def get_stock_reservation_entries_for_voucher( - voucher_type: str, voucher_no: str, voucher_detail_no: str = None, fields: list[str] = None -) -> list[dict]: - """Returns list of Stock Reservation Entries against a Voucher.""" +def get_available_serial_nos_to_reserve( + item_code: str, warehouse: str, has_batch_no: bool = False, ignore_sre=None +) -> list[tuple]: + """Returns Available Serial Nos to Reserve (Available Serial Nos - Reserved Serial Nos)` for Item, Warehouse and Batch combination.""" - if not fields or not isinstance(fields, list): - fields = [ - "name", - "item_code", - "warehouse", - "voucher_detail_no", - "reserved_qty", - "delivered_qty", - "stock_uom", - ] + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_available_serial_nos, + ) - sre = frappe.qb.DocType("Stock Reservation Entry") - query = ( - frappe.qb.from_(sre) - .where( - (sre.docstatus == 1) - & (sre.voucher_type == voucher_type) - & (sre.voucher_no == voucher_no) - & (sre.status.notin(["Delivered", "Cancelled"])) + available_serial_nos = get_available_serial_nos( + frappe._dict( + { + "item_code": item_code, + "warehouse": warehouse, + "has_batch_no": has_batch_no, + "ignore_voucher_nos": [ignore_sre], + } ) - .orderby(sre.creation) ) - for field in fields: - query = query.select(sre[field]) - - if voucher_detail_no: - query = query.where(sre.voucher_detail_no == voucher_detail_no) - - return query.run(as_dict=True) - - -def get_sre_reserved_qty_details_for_item_and_warehouse( - item_code_list: list, warehouse_list: list -) -> dict: - """Returns a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" - - sre_details = {} + available_serial_nos_list = [] + if available_serial_nos: + available_serial_nos_list = [tuple(d.values()) for d in available_serial_nos] - if item_code_list and warehouse_list: sre = frappe.qb.DocType("Stock Reservation Entry") - sre_data = ( + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + query = ( frappe.qb.from_(sre) - .select( - sre.item_code, - sre.warehouse, - Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"), - ) + .left_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.serial_no, sre.warehouse) .where( (sre.docstatus == 1) - & (sre.item_code.isin(item_code_list)) - & (sre.warehouse.isin(warehouse_list)) + & (sre.item_code == item_code) + & (sre.warehouse == warehouse) + & (sre.reserved_qty >= sre.delivered_qty) & (sre.status.notin(["Delivered", "Cancelled"])) + & (sre.reservation_based_on == "Serial and Batch") ) - .groupby(sre.item_code, sre.warehouse) - ).run(as_dict=True) + ) - if sre_data: - sre_details = {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in sre_data} + if has_batch_no: + query = query.select(sb_entry.batch_no) - return sre_details + if ignore_sre: + query = query.where(sre.name != ignore_sre) + reserved_serial_nos = query.run() -def get_sre_reserved_qty_for_item_and_warehouse(item_code: str, warehouse: str) -> float: - """Returns `Reserved Qty` for Item and Warehouse combination.""" + if reserved_serial_nos: + return list(set(available_serial_nos_list) - set(reserved_serial_nos)) - reserved_qty = 0.0 + return available_serial_nos_list - if item_code and warehouse: - sre = frappe.qb.DocType("Stock Reservation Entry") - return ( - frappe.qb.from_(sre) - .select(Sum(sre.reserved_qty - sre.delivered_qty)) - .where( - (sre.docstatus == 1) - & (sre.item_code == item_code) - & (sre.warehouse == warehouse) - & (sre.status.notin(["Delivered", "Cancelled"])) - ) - ).run(as_list=True)[0][0] or 0.0 - return reserved_qty +def get_sre_reserved_qty_for_item_and_warehouse( + item_code: str | list, warehouse: str | list = None +) -> float | dict: + """Returns `Reserved Qty` for Item and Warehouse combination OR a dict like {("item_code", "warehouse"): "reserved_qty", ... }.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select( + sre.item_code, + sre.warehouse, + Sum(sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"), + ) + .where((sre.docstatus == 1) & (sre.status.notin(["Delivered", "Cancelled"]))) + .groupby(sre.item_code, sre.warehouse) + ) + + query = ( + query.where(sre.item_code.isin(item_code)) + if isinstance(item_code, list) + else query.where(sre.item_code == item_code) + ) + + if warehouse: + query = ( + query.where(sre.warehouse.isin(warehouse)) + if isinstance(warehouse, list) + else query.where(sre.warehouse == warehouse) + ) + + data = query.run(as_dict=True) + + if isinstance(item_code, str) and isinstance(warehouse, str): + return data[0]["reserved_qty"] if data else 0.0 + else: + return {(d["item_code"], d["warehouse"]): d["reserved_qty"] for d in data} if data else {} def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) -> dict: @@ -257,15 +604,44 @@ def get_sre_reserved_qty_details_for_voucher(voucher_type: str, voucher_no: str) return frappe._dict(data) -def get_sre_reserved_qty_details_for_voucher_detail_no( - voucher_type: str, voucher_no: str, voucher_detail_no: str +def get_sre_reserved_warehouses_for_voucher( + voucher_type: str, voucher_no: str, voucher_detail_no: str = None ) -> list: - """Returns a list like ["warehouse", "reserved_qty"].""" + """Returns a list of warehouses where the stock is reserved for the provided voucher.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select(sre.warehouse) + .distinct() + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .orderby(sre.creation) + ) + + if voucher_detail_no: + query = query.where(sre.voucher_detail_no == voucher_detail_no) + + warehouses = query.run(as_list=True) + + return [d[0] for d in warehouses] if warehouses else [] + + +def get_sre_reserved_qty_for_voucher_detail_no( + voucher_type: str, voucher_no: str, voucher_detail_no: str, ignore_sre=None +) -> float: + """Returns `Reserved Qty` against the Voucher.""" sre = frappe.qb.DocType("Stock Reservation Entry") - reserved_qty_details = ( + query = ( frappe.qb.from_(sre) - .select(sre.warehouse, (Sum(sre.reserved_qty) - Sum(sre.delivered_qty))) + .select( + (Sum(sre.reserved_qty) - Sum(sre.delivered_qty)), + ) .where( (sre.docstatus == 1) & (sre.voucher_type == voucher_type) @@ -273,40 +649,366 @@ def get_sre_reserved_qty_details_for_voucher_detail_no( & (sre.voucher_detail_no == voucher_detail_no) & (sre.status.notin(["Delivered", "Cancelled"])) ) + ) + + if ignore_sre: + query = query.where(sre.name != ignore_sre) + + reserved_qty = query.run(as_list=True) + + return flt(reserved_qty[0][0]) + + +def get_sre_details_for_voucher(voucher_type: str, voucher_no: str) -> list[dict]: + """Returns a list of SREs for the provided voucher.""" + + sre = frappe.qb.DocType("Stock Reservation Entry") + return ( + frappe.qb.from_(sre) + .select( + sre.name, + sre.item_code, + sre.warehouse, + sre.voucher_type, + sre.voucher_no, + sre.voucher_detail_no, + (sre.reserved_qty - sre.delivered_qty).as_("reserved_qty"), + sre.has_serial_no, + sre.has_batch_no, + sre.reservation_based_on, + ) + .where( + (sre.docstatus == 1) + & (sre.voucher_type == voucher_type) + & (sre.voucher_no == voucher_no) + & (sre.reserved_qty > sre.delivered_qty) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) .orderby(sre.creation) - .groupby(sre.warehouse) - ).run(as_list=True) + ).run(as_dict=True) + + +def get_serial_batch_entries_for_voucher(sre_name: str) -> list[dict]: + """Returns a list of `Serial and Batch Entries` for the provided voucher.""" - if reserved_qty_details: - return reserved_qty_details[0] + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + + return ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select( + sb_entry.serial_no, + sb_entry.batch_no, + (sb_entry.qty - sb_entry.delivered_qty).as_("qty"), + ) + .where( + (sre.docstatus == 1) & (sre.name == sre_name) & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .where(sb_entry.qty > sb_entry.delivered_qty) + .orderby(sb_entry.creation) + ).run(as_dict=True) + + +def get_ssb_bundle_for_voucher(sre: dict) -> object | None: + """Returns a new `Serial and Batch Bundle` against the provided SRE.""" - return reserved_qty_details + sb_entries = get_serial_batch_entries_for_voucher(sre["name"]) + + if sb_entries: + bundle = frappe.new_doc("Serial and Batch Bundle") + bundle.type_of_transaction = "Outward" + bundle.voucher_type = "Delivery Note" + + for field in ("item_code", "warehouse", "has_serial_no", "has_batch_no"): + setattr(bundle, field, sre[field]) + + for sb_entry in sb_entries: + bundle.append("entries", sb_entry) + + bundle.save() + + return bundle.name def has_reserved_stock(voucher_type: str, voucher_no: str, voucher_detail_no: str = None) -> bool: """Returns True if there is any Stock Reservation Entry for the given voucher.""" if get_stock_reservation_entries_for_voucher( - voucher_type, voucher_no, voucher_detail_no, fields=["name"] + voucher_type, voucher_no, voucher_detail_no, fields=["name"], ignore_status=True ): return True return False -@frappe.whitelist() -def cancel_stock_reservation_entries( - voucher_type: str, voucher_no: str, voucher_detail_no: str = None, notify: bool = True +def create_stock_reservation_entries_for_so_items( + so: object, + items_details: list[dict] = None, + against_pick_list: bool = False, + notify=True, ) -> None: - """Cancel Stock Reservation Entries for the given voucher.""" + """Creates Stock Reservation Entries for Sales Order Items.""" - sre_list = get_stock_reservation_entries_for_voucher( - voucher_type, voucher_no, voucher_detail_no, fields=["name"] + from erpnext.selling.doctype.sales_order.sales_order import get_unreserved_qty + + if not against_pick_list and ( + so.get("_action") == "submit" + and so.set_warehouse + and cint(frappe.get_cached_value("Warehouse", so.set_warehouse, "is_group")) + ): + return frappe.msgprint( + _("Stock cannot be reserved in the group warehouse {0}.").format(frappe.bold(so.set_warehouse)) + ) + + validate_stock_reservation_settings(so) + + allow_partial_reservation = frappe.db.get_single_value( + "Stock Settings", "allow_partial_reservation" ) + items = [] + if items_details: + for item in items_details: + so_item = frappe.get_doc( + "Sales Order Item", item.get("sales_order_item") if against_pick_list else item.get("name") + ) + so_item.reserve_stock = 1 + so_item.warehouse = item.get("warehouse") + so_item.qty_to_reserve = ( + item.get("picked_qty") - item.get("stock_reserved_qty", 0) + if against_pick_list + else (flt(item.get("qty_to_reserve")) * flt(so_item.conversion_factor, 1)) + ) + + if against_pick_list: + so_item.pick_list = item.get("parent") + so_item.pick_list_item = item.get("name") + so_item.pick_list_sbb = item.get("serial_and_batch_bundle") + + items.append(so_item) + + sre_count = 0 + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) + + for item in items if items_details else so.get("items"): + # Skip if `Reserved Stock` is not checked for the item. + if not item.get("reserve_stock"): + continue + + # Stock should be reserved from the Pick List if has Picked Qty. + if not against_pick_list and flt(item.picked_qty) > 0: + frappe.throw( + _( + "Row #{0}: Item {1} has been picked, please create a Stock Reservation from the Pick List." + ).format(item.idx, frappe.bold(item.item_code)) + ) + + is_stock_item, has_serial_no, has_batch_no = frappe.get_cached_value( + "Item", item.item_code, ["is_stock_item", "has_serial_no", "has_batch_no"] + ) + + # Skip if Non-Stock Item. + if not is_stock_item: + frappe.msgprint( + _("Row #{0}: Stock cannot be reserved for a non-stock Item {1}").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + item.db_set("reserve_stock", 0) + continue + + # Skip if Group Warehouse. + if frappe.get_cached_value("Warehouse", item.warehouse, "is_group"): + frappe.msgprint( + _("Row #{0}: Stock cannot be reserved in group warehouse {1}.").format( + item.idx, frappe.bold(item.warehouse) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + continue + + unreserved_qty = get_unreserved_qty(item, reserved_qty_details) + + # Stock is already reserved for the item, notify the user and skip the item. + if unreserved_qty <= 0: + frappe.msgprint( + _("Row #{0}: Stock is already reserved for the Item {1}.").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="yellow", + ) + continue + + available_qty_to_reserve = get_available_qty_to_reserve(item.item_code, item.warehouse) + + # No stock available to reserve, notify the user and skip the item. + if available_qty_to_reserve <= 0: + frappe.msgprint( + _("Row #{0}: No available stock to reserve for the Item {1} in Warehouse {2}.").format( + item.idx, frappe.bold(item.item_code), frappe.bold(item.warehouse) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + + # The quantity which can be reserved. + qty_to_be_reserved = min(unreserved_qty, available_qty_to_reserve) + + if hasattr(item, "qty_to_reserve"): + if item.qty_to_reserve <= 0: + frappe.msgprint( + _("Row #{0}: Quantity to reserve for the Item {1} should be greater than 0.").format( + item.idx, frappe.bold(item.item_code) + ), + title=_("Stock Reservation"), + indicator="orange", + ) + continue + else: + qty_to_be_reserved = min(qty_to_be_reserved, item.qty_to_reserve) + + # Partial Reservation + if qty_to_be_reserved < unreserved_qty: + if not item.get("qty_to_reserve") or qty_to_be_reserved < flt(item.get("qty_to_reserve")): + msg = _("Row #{0}: Only {1} available to reserve for the Item {2}").format( + item.idx, + frappe.bold(str(qty_to_be_reserved / item.conversion_factor) + " " + item.uom), + frappe.bold(item.item_code), + ) + frappe.msgprint(msg, title=_("Stock Reservation"), indicator="orange") + + # Skip the item if `Partial Reservation` is disabled in the Stock Settings. + if not allow_partial_reservation: + if qty_to_be_reserved == flt(item.get("qty_to_reserve")): + msg = _("Enable Allow Partial Reservation in the Stock Settings to reserve partial stock.") + frappe.msgprint(msg, title=_("Partial Stock Reservation"), indicator="yellow") + + continue + + sre = frappe.new_doc("Stock Reservation Entry") + + sre.item_code = item.item_code + sre.warehouse = item.warehouse + sre.has_serial_no = has_serial_no + sre.has_batch_no = has_batch_no + sre.voucher_type = so.doctype + sre.voucher_no = so.name + sre.voucher_detail_no = item.name + sre.available_qty = available_qty_to_reserve + sre.voucher_qty = item.stock_qty + sre.reserved_qty = qty_to_be_reserved + sre.company = so.company + sre.stock_uom = item.stock_uom + sre.project = so.project + + if against_pick_list: + sre.against_pick_list = item.pick_list + sre.against_pick_list_item = item.pick_list_item + + if item.pick_list_sbb: + sbb = frappe.get_doc("Serial and Batch Bundle", item.pick_list_sbb) + sre.reservation_based_on = "Serial and Batch" + for entry in sbb.entries: + sre.append( + "sb_entries", + { + "serial_no": entry.serial_no, + "batch_no": entry.batch_no, + "qty": 1 if has_serial_no else abs(entry.qty), + "warehouse": entry.warehouse, + }, + ) + + sre.save() + sre.submit() + + sre_count += 1 + + if sre_count and notify: + frappe.msgprint(_("Stock Reservation Entries Created"), alert=True, indicator="green") + + +def cancel_stock_reservation_entries( + voucher_type: str = None, + voucher_no: str = None, + voucher_detail_no: str = None, + against_pick_list: str = None, + sre_list: list[dict] = None, + notify: bool = True, +) -> None: + """Cancel Stock Reservation Entries.""" + + if not sre_list and against_pick_list: + sre = frappe.qb.DocType("Stock Reservation Entry") + sre_list = ( + frappe.qb.from_(sre) + .select(sre.name) + .where( + (sre.docstatus == 1) + & (sre.against_pick_list == against_pick_list) + & (sre.status.notin(["Delivered", "Cancelled"])) + ) + .orderby(sre.creation) + ).run(as_dict=True) + + elif not sre_list and (voucher_type and voucher_no): + sre_list = get_stock_reservation_entries_for_voucher( + voucher_type, voucher_no, voucher_detail_no, fields=["name"] + ) + if sre_list: for sre in sre_list: - frappe.get_doc("Stock Reservation Entry", sre.name).cancel() + frappe.get_doc("Stock Reservation Entry", sre["name"]).cancel() if notify: - frappe.msgprint(_("Stock Reservation Entries Cancelled"), alert=True, indicator="red") + msg = _("Stock Reservation Entries Cancelled") + frappe.msgprint(msg, alert=True, indicator="red") + + +@frappe.whitelist() +def get_stock_reservation_entries_for_voucher( + voucher_type: str, + voucher_no: str, + voucher_detail_no: str = None, + fields: list[str] = None, + ignore_status: bool = False, +) -> list[dict]: + """Returns list of Stock Reservation Entries against a Voucher.""" + + if not fields or not isinstance(fields, list): + fields = [ + "name", + "item_code", + "warehouse", + "voucher_detail_no", + "reserved_qty", + "delivered_qty", + "stock_uom", + ] + + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .where( + (sre.docstatus == 1) & (sre.voucher_type == voucher_type) & (sre.voucher_no == voucher_no) + ) + .orderby(sre.creation) + ) + + for field in fields: + query = query.select(sre[field]) + + if voucher_detail_no: + query = query.where(sre.voucher_detail_no == voucher_detail_no) + + if ignore_status: + query = query.where(sre.status.notin(["Delivered", "Cancelled"])) + + return query.run(as_dict=True) diff --git a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py index dff407f149db..1168a4e1c618 100644 --- a/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py +++ b/erpnext/stock/doctype/stock_reservation_entry/test_stock_reservation_entry.py @@ -1,23 +1,38 @@ # Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt +from random import randint + import frappe from frappe.tests.utils import FrappeTestCase, change_settings +from erpnext.selling.doctype.sales_order.sales_order import create_pick_list, make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.stock_entry.stock_entry import StockEntry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry +from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( + cancel_stock_reservation_entries, + get_sre_reserved_qty_details_for_voucher, + get_stock_reservation_entries_for_voucher, + has_reserved_stock, +) from erpnext.stock.utils import get_stock_balance class TestStockReservationEntry(FrappeTestCase): def setUp(self) -> None: - self.items = create_items() - create_material_receipt(self.items) + self.warehouse = "_Test Warehouse - _TC" + self.sr_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 100}) + create_material_receipt( + items={self.sr_item.name: self.sr_item}, warehouse=self.warehouse, qty=100 + ) def tearDown(self) -> None: + cancel_all_stock_reservation_entries() return super().tearDown() + @change_settings("Stock Settings", {"allow_negative_stock": 0}) def test_validate_stock_reservation_settings(self) -> None: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( validate_stock_reservation_settings, @@ -47,28 +62,29 @@ def test_get_available_qty_to_reserve(self) -> None: get_available_qty_to_reserve, ) - item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC" - # Case - 1: When `Reserved Qty` is `0`, Available Qty to Reserve = Actual Qty - cancel_all_stock_reservation_entries() - available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse) - expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse) + available_qty_to_reserve = get_available_qty_to_reserve(self.sr_item.name, self.warehouse) + expected_available_qty_to_reserve = get_stock_balance(self.sr_item.name, self.warehouse) self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve) # Case - 2: When `Reserved Qty` is `> 0`, Available Qty to Reserve = Actual Qty - Reserved Qty sre = make_stock_reservation_entry( - item_code=item_code, - warehouse=warehouse, + item_code=self.sr_item.name, + warehouse=self.warehouse, ignore_validate=True, ) - available_qty_to_reserve = get_available_qty_to_reserve(item_code, warehouse) - expected_available_qty_to_reserve = get_stock_balance(item_code, warehouse) - sre.reserved_qty + available_qty_to_reserve = get_available_qty_to_reserve(self.sr_item.name, self.warehouse) + expected_available_qty_to_reserve = ( + get_stock_balance(self.sr_item.name, self.warehouse) - sre.reserved_qty + ) self.assertEqual(available_qty_to_reserve, expected_available_qty_to_reserve) def test_update_status(self) -> None: sre = make_stock_reservation_entry( + item_code=self.sr_item.name, + warehouse=self.warehouse, reserved_qty=30, ignore_validate=True, do_not_submit=True, @@ -109,14 +125,12 @@ def test_update_status(self) -> None: sre.load_from_db() self.assertEqual(sre.status, "Cancelled") - @change_settings("Stock Settings", {"enable_stock_reservation": 1}) + @change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1}) def test_update_reserved_qty_in_voucher(self) -> None: - item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC" - # Step - 1: Create a `Sales Order` so = make_sales_order( - item_code=item_code, - warehouse=warehouse, + item_code=self.sr_item.name, + warehouse=self.warehouse, qty=50, rate=100, do_not_submit=True, @@ -128,8 +142,8 @@ def test_update_reserved_qty_in_voucher(self) -> None: # Step - 2: Create a `Stock Reservation Entry[1]` for the `Sales Order Item` sre1 = make_stock_reservation_entry( - item_code=item_code, - warehouse=warehouse, + item_code=self.sr_item.name, + warehouse=self.warehouse, voucher_type="Sales Order", voucher_no=so.name, voucher_detail_no=so.items[0].name, @@ -143,8 +157,8 @@ def test_update_reserved_qty_in_voucher(self) -> None: # Step - 3: Create a `Stock Reservation Entry[2]` for the `Sales Order Item` sre2 = make_stock_reservation_entry( - item_code=item_code, - warehouse=warehouse, + item_code=self.sr_item.name, + warehouse=self.warehouse, voucher_type="Sales Order", voucher_no=so.name, voucher_detail_no=so.items[0].name, @@ -163,26 +177,32 @@ def test_update_reserved_qty_in_voucher(self) -> None: self.assertEqual(sre1.status, "Cancelled") self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty) - # Step - 5: Cancel `Stock Reservation Entry[2]` + # Step - 5: Update `Stock Reservation Entry[2]` Reserved Qty + sre2.reserved_qty += sre1.reserved_qty + sre2.save() + so.load_from_db() + sre1.load_from_db() + self.assertEqual(sre2.status, "Reserved") + self.assertEqual(so.items[0].stock_reserved_qty, sre2.reserved_qty) + + # Step - 6: Cancel `Stock Reservation Entry[2]` sre2.cancel() so.load_from_db() sre2.load_from_db() self.assertEqual(sre1.status, "Cancelled") self.assertEqual(so.items[0].stock_reserved_qty, 0) - @change_settings("Stock Settings", {"enable_stock_reservation": 1}) + @change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1}) def test_cant_consume_reserved_stock(self) -> None: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( cancel_stock_reservation_entries, ) from erpnext.stock.stock_ledger import NegativeStockError - item_code, warehouse = "SR Item 1", "_Test Warehouse - _TC" - # Step - 1: Create a `Sales Order` so = make_sales_order( - item_code=item_code, - warehouse=warehouse, + item_code=self.sr_item.name, + warehouse=self.warehouse, qty=50, rate=100, do_not_submit=True, @@ -192,13 +212,13 @@ def test_cant_consume_reserved_stock(self) -> None: so.save() so.submit() - actual_qty = get_stock_balance(item_code, warehouse) + actual_qty = get_stock_balance(self.sr_item.name, self.warehouse) # Step - 2: Try to consume (Transfer/Issue/Deliver) the Available Qty via Stock Entry or Delivery Note, should throw `NegativeStockError`. se = make_stock_entry( - item_code=item_code, + item_code=self.sr_item.name, qty=actual_qty, - from_warehouse=warehouse, + from_warehouse=self.warehouse, rate=100, purpose="Material Issue", do_not_submit=True, @@ -210,9 +230,9 @@ def test_cant_consume_reserved_stock(self) -> None: cancel_stock_reservation_entries(so.doctype, so.name) se = make_stock_entry( - item_code=item_code, + item_code=self.sr_item.name, qty=actual_qty, - from_warehouse=warehouse, + from_warehouse=self.warehouse, rate=100, purpose="Material Issue", do_not_submit=True, @@ -220,52 +240,369 @@ def test_cant_consume_reserved_stock(self) -> None: se.submit() se.cancel() + @change_settings( + "Stock Settings", + { + "allow_negative_stock": 0, + "enable_stock_reservation": 1, + "auto_reserve_serial_and_batch": 0, + "pick_serial_and_batch_based_on": "FIFO", + "auto_create_serial_and_batch_bundle_for_outward": 1, + }, + ) + def test_stock_reservation_against_sales_order(self) -> None: + items_details = create_items() + se = create_material_receipt(items_details, self.warehouse, qty=10) + + item_list = [] + for item_code, properties in items_details.items(): + item_list.append( + { + "item_code": item_code, + "warehouse": self.warehouse, + "qty": randint(11, 100), + "uom": properties.stock_uom, + "rate": randint(10, 400), + } + ) + + so = make_sales_order( + item_list=item_list, + warehouse=self.warehouse, + ) + + # Test - 1: Stock should not be reserved if the Available Qty to Reserve is less than the Ordered Qty and Partial Reservation is disabled in Stock Settings. + with change_settings("Stock Settings", {"allow_partial_reservation": 0}): + so.create_stock_reservation_entries() + self.assertFalse(has_reserved_stock("Sales Order", so.name)) + + # Test - 2: Stock should be Partially Reserved if the Partial Reservation is enabled in Stock Settings. + with change_settings("Stock Settings", {"allow_partial_reservation": 1}): + so.create_stock_reservation_entries() + so.load_from_db() + self.assertTrue(has_reserved_stock("Sales Order", so.name)) + + for item in so.items: + sre_details = get_stock_reservation_entries_for_voucher( + "Sales Order", so.name, item.name, fields=["reserved_qty", "status"] + )[0] + self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty) + self.assertEqual(sre_details.status, "Partially Reserved") + + se.cancel() + + # Test - 3: Stock should be fully Reserved if the Available Qty to Reserve is greater than the Un-reserved Qty. + create_material_receipt(items_details, self.warehouse, qty=110) + so.create_stock_reservation_entries() + so.load_from_db() + + reserved_qty_details = get_sre_reserved_qty_details_for_voucher("Sales Order", so.name) + for item in so.items: + reserved_qty = reserved_qty_details[item.name] + self.assertEqual(item.stock_reserved_qty, reserved_qty) + self.assertEqual(item.stock_qty, item.stock_reserved_qty) + + # Test - 4: Stock should get unreserved on cancellation of Stock Reservation Entries. + cancel_stock_reservation_entries("Sales Order", so.name) + so.load_from_db() + self.assertFalse(has_reserved_stock("Sales Order", so.name)) + + for item in so.items: + self.assertEqual(item.stock_reserved_qty, 0) + + # Test - 5: Re-reserve the stock. + so.create_stock_reservation_entries() + self.assertTrue(has_reserved_stock("Sales Order", so.name)) + + # Test - 6: Stock should get unreserved on cancellation of Sales Order. + so.cancel() + so.load_from_db() + self.assertFalse(has_reserved_stock("Sales Order", so.name)) + + for item in so.items: + self.assertEqual(item.stock_reserved_qty, 0) + + # Create Sales Order and Reserve Stock. + so = make_sales_order( + item_list=item_list, + warehouse=self.warehouse, + ) + so.create_stock_reservation_entries() + + # Test - 7: Partial Delivery against Sales Order. + dn1 = make_delivery_note(so.name) + + for item in dn1.items: + item.qty = randint(1, 10) + + dn1.save() + dn1.submit() + + for item in so.items: + sre_details = get_stock_reservation_entries_for_voucher( + "Sales Order", so.name, item.name, fields=["delivered_qty", "status"] + )[0] + self.assertGreater(sre_details.delivered_qty, 0) + self.assertEqual(sre_details.status, "Partially Delivered") + + # Test - 8: Over Delivery against Sales Order, SRE Delivered Qty should not be greater than the SRE Reserved Qty. + with change_settings("Stock Settings", {"over_delivery_receipt_allowance": 100}): + dn2 = make_delivery_note(so.name) + + for item in dn2.items: + item.qty += randint(1, 10) + + dn2.save() + dn2.submit() + + for item in so.items: + sre_details = get_stock_reservation_entries_for_voucher( + "Sales Order", + so.name, + item.name, + fields=["reserved_qty", "delivered_qty"], + ignore_status=True, + ) + + for sre_detail in sre_details: + self.assertEqual(sre_detail.reserved_qty, sre_detail.delivered_qty) + + @change_settings( + "Stock Settings", + { + "allow_negative_stock": 0, + "enable_stock_reservation": 1, + "auto_reserve_serial_and_batch": 1, + "pick_serial_and_batch_based_on": "FIFO", + }, + ) + def test_auto_reserve_serial_and_batch(self) -> None: + items_details = create_items() + create_material_receipt(items_details, self.warehouse, qty=100) + + item_list = [] + for item_code, properties in items_details.items(): + item_list.append( + { + "item_code": item_code, + "warehouse": self.warehouse, + "qty": randint(11, 100), + "uom": properties.stock_uom, + "rate": randint(10, 400), + } + ) + + so = make_sales_order( + item_list=item_list, + warehouse=self.warehouse, + ) + so.create_stock_reservation_entries() + so.load_from_db() + + for item in so.items: + sre_details = get_stock_reservation_entries_for_voucher( + "Sales Order", so.name, item.name, fields=["status", "reserved_qty"] + )[0] + + # Test - 1: SRE Reserved Qty should be updated in Sales Order Item. + self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty) + + # Test - 2: SRE status should be `Reserved`. + self.assertEqual(sre_details.status, "Reserved") + + dn = make_delivery_note(so.name, kwargs={"for_reserved_stock": 1}) + dn.save() + dn.submit() + + for item in so.items: + sre_details = get_stock_reservation_entries_for_voucher( + "Sales Order", so.name, item.name, fields=["status", "delivered_qty", "reserved_qty"] + )[0] + + # Test - 3: After Delivery Note, SRE status should be `Delivered`. + self.assertEqual(sre_details.status, "Delivered") + + # Test - 4: After Delivery Note, SRE Delivered Qty should be equal to SRE Reserved Qty. + self.assertEqual(sre_details.delivered_qty, sre_details.reserved_qty) + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + for item in dn.items: + if item.serial_and_batch_bundle: + reserved_sb_entries = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.serial_no, sb_entry.batch_no, sb_entry.qty, sb_entry.delivered_qty) + .where( + (sre.voucher_type == "Sales Order") + & (sre.voucher_no == item.against_sales_order) + & (sre.voucher_detail_no == item.so_detail) + ) + ).run(as_dict=True) + + reserved_sb_details: set[tuple] = set() + for sb_details in reserved_sb_entries: + # Test - 5: After Delivery Note, SB Entry Delivered Qty should be equal to SB Entry Reserved Qty. + self.assertEqual(sb_details.qty, sb_details.delivered_qty) + + reserved_sb_details.add((sb_details.serial_no, sb_details.batch_no, -1 * sb_details.qty)) + + delivered_sb_entries = frappe.db.get_all( + "Serial and Batch Entry", + filters={"parent": item.serial_and_batch_bundle}, + fields=["serial_no", "batch_no", "qty"], + as_list=True, + ) + delivered_sb_details: set[tuple] = set(delivered_sb_entries) + + # Test - 6: Reserved Serial/Batch Nos should be equal to Delivered Serial/Batch Nos. + self.assertSetEqual(reserved_sb_details, delivered_sb_details) + + dn.cancel() + so.load_from_db() + + for item in so.items: + sre_details = get_stock_reservation_entries_for_voucher( + "Sales Order", + so.name, + item.name, + fields=["name", "status", "delivered_qty", "reservation_based_on"], + )[0] + + # Test - 7: After Delivery Note cancellation, SRE status should be `Reserved`. + self.assertEqual(sre_details.status, "Reserved") + + # Test - 8: After Delivery Note cancellation, SRE Delivered Qty should be `0`. + self.assertEqual(sre_details.delivered_qty, 0) + + if sre_details.reservation_based_on == "Serial and Batch": + sb_entries = frappe.db.get_all( + "Serial and Batch Entry", + filters={"parenttype": "Stock Reservation Entry", "parent": sre_details.name}, + fields=["delivered_qty"], + ) + + for sb_entry in sb_entries: + # Test - 9: After Delivery Note cancellation, SB Entry Delivered Qty should be `0`. + self.assertEqual(sb_entry.delivered_qty, 0) + + @change_settings( + "Stock Settings", + { + "allow_negative_stock": 0, + "enable_stock_reservation": 1, + "auto_reserve_serial_and_batch": 1, + "pick_serial_and_batch_based_on": "FIFO", + }, + ) + def test_stock_reservation_from_pick_list(self): + items_details = create_items() + create_material_receipt(items_details, self.warehouse, qty=100) + + item_list = [] + for item_code, properties in items_details.items(): + item_list.append( + { + "item_code": item_code, + "warehouse": self.warehouse, + "qty": randint(11, 100), + "uom": properties.stock_uom, + "rate": randint(10, 400), + } + ) + + so = make_sales_order( + item_list=item_list, + warehouse=self.warehouse, + ) + pl = create_pick_list(so.name) + pl.save() + pl.submit() + pl.create_stock_reservation_entries() + pl.load_from_db() + so.load_from_db() + + for item in so.items: + sre_details = get_stock_reservation_entries_for_voucher( + "Sales Order", so.name, item.name, fields=["reserved_qty"] + )[0] + + # Test - 1: SRE Reserved Qty should be updated in Sales Order Item. + self.assertEqual(item.stock_reserved_qty, sre_details.reserved_qty) + + sre = frappe.qb.DocType("Stock Reservation Entry") + sb_entry = frappe.qb.DocType("Serial and Batch Entry") + for location in pl.locations: + # Test - 2: Reserved Qty should be updated in Pick List Item. + self.assertEqual(location.stock_reserved_qty, location.qty) + + if location.serial_and_batch_bundle: + picked_sb_entries = frappe.db.get_all( + "Serial and Batch Entry", + filters={"parent": location.serial_and_batch_bundle}, + fields=["serial_no", "batch_no", "qty"], + as_list=True, + ) + picked_sb_details: set[tuple] = set(picked_sb_entries) + + reserved_sb_entries = ( + frappe.qb.from_(sre) + .inner_join(sb_entry) + .on(sre.name == sb_entry.parent) + .select(sb_entry.serial_no, sb_entry.batch_no, sb_entry.qty) + .where( + (sre.voucher_type == "Sales Order") + & (sre.voucher_no == location.sales_order) + & (sre.voucher_detail_no == location.sales_order_item) + & (sre.against_pick_list == pl.name) + & (sre.against_pick_list_item == location.name) + ) + ).run(as_dict=True) + reserved_sb_details: set[tuple] = { + (sb_details.serial_no, sb_details.batch_no, -1 * sb_details.qty) + for sb_details in reserved_sb_entries + } + + # Test - 3: Reserved Serial/Batch Nos should be equal to Picked Serial/Batch Nos. + self.assertSetEqual(picked_sb_details, reserved_sb_details) + def create_items() -> dict: - from erpnext.stock.doctype.item.test_item import make_item - - items_details = { - # Stock Items - "SR Item 1": {"is_stock_item": 1, "valuation_rate": 100}, - "SR Item 2": {"is_stock_item": 1, "valuation_rate": 200, "stock_uom": "Kg"}, - # Batch Items - "SR Batch Item 1": { + items_properties = [ + # SR STOCK ITEM + {"is_stock_item": 1, "valuation_rate": 100}, + # SR SERIAL ITEM + { "is_stock_item": 1, - "valuation_rate": 100, - "has_batch_no": 1, - "create_new_batch": 1, - "batch_number_series": "SRBI-1-.#####.", + "valuation_rate": 200, + "has_serial_no": 1, + "serial_no_series": "SRSI-.#####", }, - "SR Batch Item 2": { + # SR BATCH ITEM + { "is_stock_item": 1, - "valuation_rate": 200, + "valuation_rate": 300, "has_batch_no": 1, "create_new_batch": 1, - "batch_number_series": "SRBI-2-.#####.", - "stock_uom": "Kg", + "batch_number_series": "SRBI-.#####.", }, - # Serial Item - "SR Serial Item 1": { + # SR SERIAL AND BATCH ITEM + { "is_stock_item": 1, - "valuation_rate": 100, + "valuation_rate": 400, "has_serial_no": 1, - "serial_no_series": "SRSI-1-.#####", - }, - # Batch and Serial Item - "SR Batch and Serial Item 1": { - "is_stock_item": 1, - "valuation_rate": 100, + "serial_no_series": "SRSBI-.#####", "has_batch_no": 1, "create_new_batch": 1, - "batch_number_series": "SRBSI-1-.#####.", - "has_serial_no": 1, - "serial_no_series": "SRBSI-1-.#####", + "batch_number_series": "SRSBI-.#####.", }, - } + ] items = {} - for item_code, properties in items_details.items(): - items[item_code] = make_item(item_code, properties) + for properties in items_properties: + item = make_item(properties=properties) + items[item.name] = item return items @@ -313,7 +650,7 @@ def make_stock_reservation_entry(**args): doc = frappe.new_doc("Stock Reservation Entry") args = frappe._dict(args) - doc.item_code = args.item_code or "SR Item 1" + doc.item_code = args.item_code doc.warehouse = args.warehouse or "_Test Warehouse - _TC" doc.voucher_type = args.voucher_type doc.voucher_no = args.voucher_no diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index 9d67cf9d7a17..88b5575a3730 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -34,8 +34,10 @@ "stock_reservation_tab", "enable_stock_reservation", "column_break_rx3e", - "reserve_stock_on_sales_order_submission", + "auto_reserve_stock_for_sales_order", "allow_partial_reservation", + "serial_and_batch_reservation_section", + "auto_reserve_serial_and_batch", "serial_and_batch_item_settings_tab", "section_break_7", "auto_create_serial_and_batch_bundle_for_outward", @@ -59,7 +61,8 @@ "stock_frozen_upto_days", "column_break_26", "role_allowed_to_create_edit_back_dated_transactions", - "stock_auth_role" + "stock_auth_role", + "section_break_plhx" ], "fields": [ { @@ -337,18 +340,11 @@ }, { "default": "0", + "description": "Allows to keep aside a specific quantity of inventory for a particular order.", "fieldname": "enable_stock_reservation", "fieldtype": "Check", "label": "Enable Stock Reservation" }, - { - "default": "0", - "depends_on": "eval: doc.enable_stock_reservation", - "description": "If enabled, Stock Reservation Entries will be created on submission of Sales Order", - "fieldname": "reserve_stock_on_sales_order_submission", - "fieldtype": "Check", - "label": "Reserve Stock on Sales Order Submission" - }, { "fieldname": "column_break_rx3e", "fieldtype": "Column Break" @@ -356,7 +352,7 @@ { "default": "1", "depends_on": "eval: doc.enable_stock_reservation", - "description": "If enabled, Partial Stock Reservation Entries can be created. For example, If you have a Sales Order of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ", + "description": "If enabled, Partial Stock Reservation Entries can be created. For example, If you have a Sales Order of 100 units and the Available Stock is 90 units then a Stock Reservation Entry will be created for 90 units. ", "fieldname": "allow_partial_reservation", "fieldtype": "Check", "label": "Allow Partial Reservation" @@ -383,6 +379,27 @@ "fieldname": "auto_create_serial_and_batch_bundle_for_outward", "fieldtype": "Check", "label": "Auto Create Serial and Batch Bundle For Outward" + }, + { + "default": "1", + "depends_on": "eval: doc.enable_stock_reservation", + "description": "If enabled, Serial and Batch Nos will be auto-reserved based on Pick Serial / Batch Based On", + "fieldname": "auto_reserve_serial_and_batch", + "fieldtype": "Check", + "label": "Auto Reserve Serial and Batch Nos" + }, + { + "fieldname": "serial_and_batch_reservation_section", + "fieldtype": "Section Break", + "label": "Serial and Batch Reservation" + }, + { + "default": "0", + "depends_on": "eval: doc.enable_stock_reservation", + "description": "If enabled, Stock Reservation Entries will be created on submission of Sales Order", + "fieldname": "auto_reserve_stock_for_sales_order", + "fieldtype": "Check", + "label": "Auto Reserve Stock for Sales Order" } ], "icon": "icon-cog", @@ -390,7 +407,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-05-29 15:10:54.959411", + "modified": "2023-09-01 16:16:34.018947", "modified_by": "Administrator", "module": "Stock", "name": "Stock Settings", diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.py b/erpnext/stock/doctype/stock_settings/stock_settings.py index 3b6db64a308f..9ad3c9db2844 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -69,9 +69,9 @@ def validate_warehouses(self): ) def cant_change_valuation_method(self): - db_valuation_method = frappe.db.get_single_value("Stock Settings", "valuation_method") + previous_valuation_method = self.get_doc_before_save().get("valuation_method") - if db_valuation_method and db_valuation_method != self.valuation_method: + if previous_valuation_method and previous_valuation_method != self.valuation_method: # check if there are any stock ledger entries against items # which does not have it's own valuation method sle = frappe.db.sql( @@ -108,13 +108,8 @@ def validate_stock_reservation(self): if frappe.flags.in_test: return - db_allow_negative_stock = frappe.db.get_single_value("Stock Settings", "allow_negative_stock") - db_enable_stock_reservation = frappe.db.get_single_value( - "Stock Settings", "enable_stock_reservation" - ) - # Change in value of `Allow Negative Stock` - if db_allow_negative_stock != self.allow_negative_stock: + if self.has_value_changed("allow_negative_stock"): # Disable -> Enable: Don't allow if `Stock Reservation` is enabled if self.allow_negative_stock and self.enable_stock_reservation: @@ -125,7 +120,7 @@ def validate_stock_reservation(self): ) # Change in value of `Enable Stock Reservation` - if db_enable_stock_reservation != self.enable_stock_reservation: + if self.has_value_changed("enable_stock_reservation"): # Disable -> Enable if self.enable_stock_reservation: diff --git a/erpnext/stock/report/reserved_stock/__init__.py b/erpnext/stock/report/reserved_stock/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.js b/erpnext/stock/report/reserved_stock/reserved_stock.js new file mode 100644 index 000000000000..2199f52df039 --- /dev/null +++ b/erpnext/stock/report/reserved_stock/reserved_stock.js @@ -0,0 +1,170 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.query_reports["Reserved Stock"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + reqd: 1, + default: frappe.defaults.get_user_default("Company"), + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_months( + frappe.datetime.get_today(), + -1 + ), + reqd: 1, + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 1, + }, + { + fieldname: "item_code", + label: __("Item"), + fieldtype: "Link", + options: "Item", + get_query: () => ({ + filters: { + is_stock_item: 1, + }, + }), + }, + { + fieldname: "warehouse", + label: __("Warehouse"), + fieldtype: "Link", + options: "Warehouse", + get_query: () => ({ + filters: { + is_group: 0, + company: frappe.query_report.get_filter_value("company"), + }, + }), + }, + { + fieldname: "stock_reservation_entry", + label: __("Stock Reservation Entry"), + fieldtype: "Link", + options: "Stock Reservation Entry", + get_query: () => ({ + filters: { + docstatus: 1, + company: frappe.query_report.get_filter_value("company"), + }, + }), + }, + { + fieldname: "voucher_type", + label: __("Voucher Type"), + fieldtype: "Link", + options: "DocType", + default: "Sales Order", + get_query: () => ({ + filters: { + name: ["in", ["Sales Order"]], + } + }), + }, + { + fieldname: "voucher_no", + label: __("Voucher No"), + fieldtype: "Dynamic Link", + options: "voucher_type", + get_query: () => ({ + filters: { + docstatus: 1, + company: frappe.query_report.get_filter_value("company"), + }, + }), + get_options: function () { + return frappe.query_report.get_filter_value("voucher_type"); + }, + }, + { + fieldname: "against_pick_list", + label: __("Against Pick List"), + fieldtype: "Link", + options: "Pick List", + get_query: () => ({ + filters: { + docstatus: 1, + company: frappe.query_report.get_filter_value("company"), + }, + }), + }, + { + fieldname: "reservation_based_on", + label: __("Reservation Based On"), + fieldtype: "Select", + options: ["", "Qty", "Serial and Batch"], + }, + { + fieldname: "status", + label: __("Status"), + fieldtype: "Select", + options: [ + "", + "Partially Reserved", + "Reserved", + "Partially Delivered", + "Delivered", + ], + }, + { + fieldname: "project", + label: __("Project"), + fieldtype: "Link", + options: "Project", + get_query: () => ({ + filters: { + company: frappe.query_report.get_filter_value("company"), + }, + }), + }, + ], + formatter: (value, row, column, data, default_formatter) => { + value = default_formatter(value, row, column, data); + + if (column.fieldname == "status") { + switch (data.status) { + case "Partially Reserved": + value = "" + value + ""; + break; + case "Reserved": + value = "" + value + ""; + break; + case "Partially Delivered": + value = "" + value + ""; + break; + case "Delivered": + value = "" + value + ""; + break; + } + } + else if (column.fieldname == "delivered_qty") { + if (data.delivered_qty > 0) { + if (data.reserved_qty > data.delivered_qty) { + value = "" + value + ""; + } + else { + value = "" + value + ""; + } + } + else { + value = "" + value + ""; + } + } + + return value; + }, +}; diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.json b/erpnext/stock/report/reserved_stock/reserved_stock.json new file mode 100644 index 000000000000..17b916afda80 --- /dev/null +++ b/erpnext/stock/report/reserved_stock/reserved_stock.json @@ -0,0 +1,26 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2023-08-02 22:11:19.439620", + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2023-08-03 12:46:33.780222", + "modified_by": "Administrator", + "module": "Stock", + "name": "Reserved Stock", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Reservation Entry", + "report_name": "Reserved Stock", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/reserved_stock/reserved_stock.py b/erpnext/stock/report/reserved_stock/reserved_stock.py new file mode 100644 index 000000000000..d93ee1c88f8e --- /dev/null +++ b/erpnext/stock/report/reserved_stock/reserved_stock.py @@ -0,0 +1,191 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.query_builder.functions import Date + + +def execute(filters=None): + columns, data = [], [] + + validate_filters(filters) + + columns = get_columns() + data = get_data(filters) + + return columns, data + + +def validate_filters(filters): + if not filters: + frappe.throw(_("Please set filters")) + + for field in ["company", "from_date", "to_date"]: + if not filters.get(field): + frappe.throw(_("Please set {0}").format(field)) + + if filters.get("from_date") > filters.get("to_date"): + frappe.throw(_("From Date cannot be greater than To Date")) + + +def get_data(filters): + sre = frappe.qb.DocType("Stock Reservation Entry") + query = ( + frappe.qb.from_(sre) + .select( + sre.creation, + sre.warehouse, + sre.item_code, + sre.stock_uom, + sre.voucher_qty, + sre.reserved_qty, + sre.delivered_qty, + (sre.available_qty - sre.reserved_qty).as_("available_qty"), + sre.voucher_type, + sre.voucher_no, + sre.against_pick_list, + sre.name.as_("stock_reservation_entry"), + sre.status, + sre.project, + sre.company, + ) + .where( + (sre.docstatus == 1) + & (sre.company == filters.get("company")) + & ( + (Date(sre.creation) >= filters.get("from_date")) + & (Date(sre.creation) <= filters.get("to_date")) + ) + ) + ) + + for field in [ + "item_code", + "warehouse", + "voucher_type", + "voucher_no", + "against_pick_list", + "reservation_based_on", + "status", + "project", + ]: + if value := filters.get(field): + query = query.where((sre[field] == value)) + + if value := filters.get("stock_reservation_entry"): + query = query.where((sre.name == value)) + + data = query.run(as_list=True) + + return data + + +def get_columns(): + columns = [ + { + "label": _("Date"), + "fieldname": "date", + "fieldtype": "Datetime", + "width": 150, + }, + { + "fieldname": "warehouse", + "label": _("Warehouse"), + "fieldtype": "Link", + "options": "Warehouse", + "width": 150, + }, + { + "fieldname": "item_code", + "label": _("Item"), + "fieldtype": "Link", + "options": "Item", + "width": 100, + }, + { + "fieldname": "stock_uom", + "label": _("Stock UOM"), + "fieldtype": "Link", + "options": "UOM", + "width": 100, + }, + { + "fieldname": "voucher_qty", + "label": _("Voucher Qty"), + "fieldtype": "Float", + "width": 110, + "convertible": "qty", + }, + { + "fieldname": "reserved_qty", + "label": _("Reserved Qty"), + "fieldtype": "Float", + "width": 110, + "convertible": "qty", + }, + { + "fieldname": "delivered_qty", + "label": _("Delivered Qty"), + "fieldtype": "Float", + "width": 110, + "convertible": "qty", + }, + { + "fieldname": "available_qty", + "label": _("Available Qty to Reserve"), + "fieldtype": "Float", + "width": 120, + "convertible": "qty", + }, + { + "fieldname": "voucher_type", + "label": _("Voucher Type"), + "fieldtype": "Data", + "options": "Warehouse", + "width": 110, + }, + { + "fieldname": "voucher_no", + "label": _("Voucher No"), + "fieldtype": "Dynamic Link", + "options": "voucher_type", + "width": 120, + }, + { + "fieldname": "against_pick_list", + "label": _("Against Pick List"), + "fieldtype": "Link", + "options": "Pick List", + "width": 130, + }, + { + "fieldname": "stock_reservation_entry", + "label": _("Stock Reservation Entry"), + "fieldtype": "Link", + "options": "Stock Reservation Entry", + "width": 150, + }, + { + "fieldname": "status", + "label": _("Status"), + "fieldtype": "Data", + "width": 120, + }, + { + "fieldname": "project", + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 100, + }, + { + "fieldname": "company", + "label": _("Company"), + "fieldtype": "Link", + "options": "Company", + "width": 110, + }, + ] + + return columns diff --git a/erpnext/stock/report/reserved_stock/test_reserved_stock.py b/erpnext/stock/report/reserved_stock/test_reserved_stock.py new file mode 100644 index 000000000000..6ef89f9aecda --- /dev/null +++ b/erpnext/stock/report/reserved_stock/test_reserved_stock.py @@ -0,0 +1,54 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from random import randint + +import frappe +from frappe.tests.utils import FrappeTestCase, change_settings +from frappe.utils.data import today + +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.stock_reservation_entry.test_stock_reservation_entry import ( + cancel_all_stock_reservation_entries, + create_items, + create_material_receipt, +) +from erpnext.stock.report.reserved_stock.reserved_stock import get_data as reserved_stock_report + + +class TestReservedStock(FrappeTestCase): + def setUp(self) -> None: + super().setUp() + self.stock_qty = 100 + self.warehouse = "_Test Warehouse - _TC" + + def tearDown(self) -> None: + cancel_all_stock_reservation_entries() + return super().tearDown() + + @change_settings( + "Stock Settings", + { + "allow_negative_stock": 0, + "enable_stock_reservation": 1, + "auto_reserve_serial_and_batch": 1, + "pick_serial_and_batch_based_on": "FIFO", + }, + ) + def test_reserved_stock_report(self): + items_details = create_items() + create_material_receipt(items_details, self.warehouse, qty=self.stock_qty) + + for item_code, properties in items_details.items(): + so = make_sales_order( + item_code=item_code, qty=randint(11, 100), warehouse=self.warehouse, uom=properties.stock_uom + ) + so.create_stock_reservation_entries() + + data = reserved_stock_report( + filters={ + "company": so.company, + "from_date": today(), + "to_date": today(), + } + ) + self.assertEqual(len(data), len(items_details)) diff --git a/erpnext/stock/report/stock_balance/stock_balance.js b/erpnext/stock/report/stock_balance/stock_balance.js index 33ed955a5c48..6de5f00ece84 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.js +++ b/erpnext/stock/report/stock_balance/stock_balance.js @@ -71,6 +71,14 @@ frappe.query_reports["Stock Balance"] = { "width": "80", "options": "Warehouse Type" }, + { + "fieldname": "valuation_field_type", + "label": __("Valuation Field Type"), + "fieldtype": "Select", + "width": "80", + "options": "Currency\nFloat", + "default": "Currency" + }, { "fieldname":"include_uom", "label": __("Include UOM"), diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py index d60e9b57abec..102c3e14a8e5 100644 --- a/erpnext/stock/report/stock_balance/stock_balance.py +++ b/erpnext/stock/report/stock_balance/stock_balance.py @@ -165,7 +165,7 @@ def get_item_warehouse_map(self): def get_sre_reserved_qty_details(self) -> dict: from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import ( - get_sre_reserved_qty_details_for_item_and_warehouse as get_reserved_qty_details, + get_sre_reserved_qty_for_item_and_warehouse as get_reserved_qty_details, ) item_code_list, warehouse_list = [], [] @@ -446,9 +446,12 @@ def get_columns(self): { "label": _("Valuation Rate"), "fieldname": "val_rate", - "fieldtype": "Float", + "fieldtype": self.filters.valuation_field_type or "Currency", "width": 90, "convertible": "rate", + "options": "Company:company:default_currency" + if self.filters.valuation_field_type == "Currency" + else None, }, { "label": _("Reserved Stock"), diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index 0def161d2833..b00b422a67a3 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -82,7 +82,15 @@ frappe.query_reports["Stock Ledger"] = { "label": __("Include UOM"), "fieldtype": "Link", "options": "UOM" - } + }, + { + "fieldname": "valuation_field_type", + "label": __("Valuation Field Type"), + "fieldtype": "Select", + "width": "80", + "options": "Currency\nFloat", + "default": "Currency" + }, ], "formatter": function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py index ed28ed3ee46f..eeef39641b01 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.py +++ b/erpnext/stock/report/stock_ledger/stock_ledger.py @@ -196,17 +196,21 @@ def get_columns(filters): { "label": _("Avg Rate (Balance Stock)"), "fieldname": "valuation_rate", - "fieldtype": "Float", + "fieldtype": filters.valuation_field_type, "width": 180, - "options": "Company:company:default_currency", + "options": "Company:company:default_currency" + if filters.valuation_field_type == "Currency" + else None, "convertible": "rate", }, { "label": _("Valuation Rate"), "fieldname": "in_out_rate", - "fieldtype": "Float", + "fieldtype": filters.valuation_field_type, "width": 140, - "options": "Company:company:default_currency", + "options": "Company:company:default_currency" + if filters.valuation_field_type == "Currency" + else None, "convertible": "rate", }, { diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index d6c840f1339c..5998274bb768 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -862,7 +862,7 @@ def set_serial_batch_entries(self, doc): if self.get("serial_nos"): serial_no_wise_batch = frappe._dict({}) if self.has_batch_no: - serial_no_wise_batch = self.get_serial_nos_batch(self.serial_nos) + serial_no_wise_batch = get_serial_nos_batch(self.serial_nos) qty = -1 if self.type_of_transaction == "Outward" else 1 for serial_no in self.serial_nos: @@ -887,16 +887,6 @@ def set_serial_batch_entries(self, doc): }, ) - def get_serial_nos_batch(self, serial_nos): - return frappe._dict( - frappe.get_all( - "Serial No", - fields=["name", "batch_no"], - filters={"name": ("in", serial_nos)}, - as_list=1, - ) - ) - def create_batch(self): from erpnext.stock.doctype.batch.batch import make_batch @@ -974,3 +964,14 @@ def get_serial_or_batch_items(items): serial_or_batch_items = [d.name for d in serial_or_batch_items] return serial_or_batch_items + + +def get_serial_nos_batch(serial_nos): + return frappe._dict( + frappe.get_all( + "Serial No", + fields=["name", "batch_no"], + filters={"name": ("in", serial_nos)}, + as_list=1, + ) + ) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 258a503dd5f6..db71fe280ac2 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -1214,9 +1214,15 @@ def raise_exceptions(self): if msg: if self.reserved_stock: allowed_qty = abs(exceptions[0]["actual_qty"]) - abs(exceptions[0]["diff"]) - msg = "{0} As {1} units are reserved, you are allowed to consume only {2} units.".format( - msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty) - ) + + if allowed_qty > 0: + msg = "{0} As {1} units are reserved for other sales orders, you are allowed to consume only {2} units.".format( + msg, frappe.bold(self.reserved_stock), frappe.bold(allowed_qty) + ) + else: + msg = "{0} As the full stock is reserved for other sales orders, you're not allowed to consume the stock.".format( + msg, + ) msg_list.append(msg) diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/__init__.py b/erpnext/subcontracting/doctype/subcontracting_bom/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.js b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.js new file mode 100644 index 000000000000..a7f0d7a20b4e --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.js @@ -0,0 +1,40 @@ +// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Subcontracting BOM', { + setup: (frm) => { + frm.trigger('set_queries'); + }, + + set_queries: (frm) => { + frm.set_query('finished_good', () => { + return { + filters: { + disabled: 0, + is_stock_item: 1, + default_bom: ['!=', ''], + is_sub_contracted_item: 1, + } + } + }); + + frm.set_query('finished_good_bom', () => { + return { + filters: { + docstatus: 1, + is_active: 1, + item: frm.doc.finished_good, + } + } + }); + + frm.set_query('service_item', () => { + return { + filters: { + disabled: 0, + is_stock_item: 0, + } + } + }); + } +}); diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json new file mode 100644 index 000000000000..b258afc5b61e --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.json @@ -0,0 +1,155 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "format:SB-{####}", + "creation": "2023-08-29 12:43:20.417184", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "is_active", + "section_break_dsjm", + "finished_good", + "finished_good_qty", + "column_break_quoy", + "finished_good_uom", + "finished_good_bom", + "section_break_qdw9", + "service_item", + "service_item_qty", + "column_break_uzmw", + "service_item_uom", + "conversion_factor" + ], + "fields": [ + { + "default": "1", + "fieldname": "is_active", + "fieldtype": "Check", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Is Active", + "print_hide": 1 + }, + { + "fieldname": "section_break_dsjm", + "fieldtype": "Section Break" + }, + { + "fieldname": "finished_good", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Finished Good", + "options": "Item", + "reqd": 1, + "search_index": 1, + "set_only_once": 1 + }, + { + "default": "1", + "fieldname": "finished_good_qty", + "fieldtype": "Float", + "label": "Finished Good Qty", + "non_negative": 1, + "reqd": 1 + }, + { + "fetch_from": "finished_good.default_bom", + "fetch_if_empty": 1, + "fieldname": "finished_good_bom", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Finished Good BOM", + "options": "BOM", + "reqd": 1, + "search_index": 1 + }, + { + "fieldname": "section_break_qdw9", + "fieldtype": "Section Break" + }, + { + "fieldname": "service_item", + "fieldtype": "Link", + "in_list_view": 1, + "in_preview": 1, + "in_standard_filter": 1, + "label": "Service Item", + "options": "Item", + "reqd": 1, + "search_index": 1 + }, + { + "default": "1", + "fieldname": "service_item_qty", + "fieldtype": "Float", + "label": "Service Item Qty", + "non_negative": 1, + "reqd": 1 + }, + { + "fetch_from": "service_item.stock_uom", + "fetch_if_empty": 1, + "fieldname": "service_item_uom", + "fieldtype": "Link", + "label": "Service Item UOM", + "options": "UOM", + "reqd": 1 + }, + { + "description": "Service Item Qty / Finished Good Qty", + "fieldname": "conversion_factor", + "fieldtype": "Float", + "label": "Conversion Factor", + "read_only": 1 + }, + { + "fieldname": "column_break_quoy", + "fieldtype": "Column Break" + }, + { + "fetch_from": "finished_good.stock_uom", + "fieldname": "finished_good_uom", + "fieldtype": "Link", + "label": "Finished Good UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "column_break_uzmw", + "fieldtype": "Column Break" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2023-09-03 16:51:43.558295", + "modified_by": "Administrator", + "module": "Subcontracting", + "name": "Subcontracting BOM", + "naming_rule": "Expression", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.py b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.py new file mode 100644 index 000000000000..49ba98653f50 --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_bom/subcontracting_bom.py @@ -0,0 +1,97 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt + + +class SubcontractingBOM(Document): + def validate(self): + self.validate_finished_good() + self.validate_service_item() + self.validate_is_active() + + def before_save(self): + self.set_conversion_factor() + + def validate_finished_good(self): + disabled, is_stock_item, default_bom, is_sub_contracted_item = frappe.db.get_value( + "Item", + self.finished_good, + ["disabled", "is_stock_item", "default_bom", "is_sub_contracted_item"], + ) + + if disabled: + frappe.throw(_("Finished Good {0} is disabled.").format(frappe.bold(self.finished_good))) + if not is_stock_item: + frappe.throw( + _("Finished Good {0} must be a stock item.").format(frappe.bold(self.finished_good)) + ) + if not default_bom: + frappe.throw( + _("Finished Good {0} does not have a default BOM.").format(frappe.bold(self.finished_good)) + ) + if not is_sub_contracted_item: + frappe.throw( + _("Finished Good {0} must be a sub-contracted item.").format(frappe.bold(self.finished_good)) + ) + + def validate_service_item(self): + disabled, is_stock_item = frappe.db.get_value( + "Item", self.service_item, ["disabled", "is_stock_item"] + ) + + if disabled: + frappe.throw(_("Service Item {0} is disabled.").format(frappe.bold(self.service_item))) + if is_stock_item: + frappe.throw( + _("Service Item {0} must be a non-stock item.").format(frappe.bold(self.service_item)) + ) + + def validate_is_active(self): + if self.is_active: + if sb := frappe.db.exists( + "Subcontracting BOM", + {"finished_good": self.finished_good, "is_active": 1, "name": ["!=", self.name]}, + ): + frappe.throw( + _("There is already an active Subcontracting BOM {0} for the Finished Good {1}.").format( + frappe.bold(sb), frappe.bold(self.finished_good) + ) + ) + + def set_conversion_factor(self): + self.conversion_factor = flt(self.service_item_qty) / flt(self.finished_good_qty) + + +@frappe.whitelist() +def get_subcontracting_boms_for_finished_goods(fg_items: str | list) -> dict: + if fg_items: + filters = {"is_active": 1} + + if isinstance(fg_items, list): + filters["finished_good"] = ["in", fg_items] + else: + filters["finished_good"] = fg_items + + if subcontracting_boms := frappe.get_all("Subcontracting BOM", filters=filters, fields=["*"]): + if isinstance(fg_items, list): + return {d.finished_good: d for d in subcontracting_boms} + else: + return subcontracting_boms[0] + + return {} + + +@frappe.whitelist() +def get_subcontracting_boms_for_service_item(service_item: str) -> dict: + if service_item: + filters = {"is_active": 1, "service_item": service_item} + Subcontracting_boms = frappe.db.get_all("Subcontracting BOM", filters=filters, fields=["*"]) + + if Subcontracting_boms: + return {d.finished_good: d for d in Subcontracting_boms} + + return {} diff --git a/erpnext/subcontracting/doctype/subcontracting_bom/test_subcontracting_bom.py b/erpnext/subcontracting/doctype/subcontracting_bom/test_subcontracting_bom.py new file mode 100644 index 000000000000..9335ac8cba0d --- /dev/null +++ b/erpnext/subcontracting/doctype/subcontracting_bom/test_subcontracting_bom.py @@ -0,0 +1,26 @@ +# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestSubcontractingBOM(FrappeTestCase): + pass + + +def create_subcontracting_bom(**kwargs): + kwargs = frappe._dict(kwargs) + + doc = frappe.new_doc("Subcontracting BOM") + doc.is_active = kwargs.is_active or 1 + doc.finished_good = kwargs.finished_good + doc.finished_good_uom = kwargs.finished_good_uom + doc.finished_good_qty = kwargs.finished_good_qty or 1 + doc.finished_good_bom = kwargs.finished_good_bom + doc.service_item = kwargs.service_item + doc.service_item_uom = kwargs.service_item_uom + doc.service_item_qty = kwargs.service_item_qty or 1 + doc.save() + + return doc diff --git a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py index b7b344584cfc..faf0cadb7550 100644 --- a/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py +++ b/erpnext/subcontracting/doctype/subcontracting_order/subcontracting_order.py @@ -128,8 +128,12 @@ def populate_items_table(self): for si in self.service_items: if si.fg_item: item = frappe.get_doc("Item", si.fg_item) - bom = frappe.db.get_value("BOM", {"item": item.item_code, "is_active": 1, "is_default": 1}) - + bom = ( + frappe.db.get_value( + "Subcontracting BOM", {"finished_good": item.item_code, "is_active": 1}, "finished_good_bom" + ) + or item.default_bom + ) items.append( { "item_code": item.item_code, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js index acf955305267..dd071e605103 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.js @@ -239,12 +239,6 @@ frappe.ui.form.on('Subcontracting Receipt Item', { set_missing_values(frm); }, - recalculate_rate(frm) { - if (frm.doc.recalculate_rate) { - set_missing_values(frm); - } - }, - items_remove: (frm) => { set_missing_values(frm); }, diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py index 8a12e3bcd03d..6aecaf98a5dd 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py +++ b/erpnext/subcontracting/doctype/subcontracting_receipt/subcontracting_receipt.py @@ -180,7 +180,6 @@ def get_scrap_items(self, recalculate_rate=False): "item_name": scrap_item.item_name, "qty": qty, "stock_uom": scrap_item.stock_uom, - "recalculate_rate": 0, "rate": rate, "rm_cost_per_qty": 0, "service_cost_per_qty": 0, @@ -277,13 +276,12 @@ def calculate_items_qty_and_amount(self): else: item.scrap_cost_per_qty = 0 - if item.recalculate_rate: - item.rate = ( - flt(item.rm_cost_per_qty) - + flt(item.service_cost_per_qty) - + flt(item.additional_cost_per_qty) - - flt(item.scrap_cost_per_qty) - ) + item.rate = ( + flt(item.rm_cost_per_qty) + + flt(item.service_cost_per_qty) + + flt(item.additional_cost_per_qty) + - flt(item.scrap_cost_per_qty) + ) item.received_qty = flt(item.qty) + flt(item.rejected_qty) item.amount = flt(item.qty) * flt(item.rate) diff --git a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json index c036390ba373..38432beb4411 100644 --- a/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json +++ b/erpnext/subcontracting/doctype/subcontracting_receipt_item/subcontracting_receipt_item.json @@ -28,7 +28,6 @@ "rate_and_amount", "rate", "amount", - "recalculate_rate", "column_break_19", "rm_cost_per_qty", "service_cost_per_qty", @@ -202,7 +201,6 @@ "options": "currency", "print_width": "100px", "read_only": 1, - "read_only_depends_on": "eval: doc.recalculate_rate", "width": "100px" }, { @@ -475,14 +473,6 @@ "fieldtype": "Section Break", "label": "Accounting Details" }, - { - "default": "1", - "depends_on": "eval: !doc.is_scrap_item", - "fieldname": "recalculate_rate", - "fieldtype": "Check", - "label": "Recalculate Rate", - "read_only_depends_on": "eval: doc.is_scrap_item" - }, { "fieldname": "serial_and_batch_bundle", "fieldtype": "Link", @@ -531,7 +521,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2023-08-25 20:09:03.069417", + "modified": "2023-09-03 17:04:21.214316", "modified_by": "Administrator", "module": "Subcontracting", "name": "Subcontracting Receipt Item", diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json index 1c6f24b23ce8..588203307584 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json @@ -192,7 +192,7 @@ } ], "links": [], - "modified": "2023-04-21 17:16:56.192560", + "modified": "2023-08-28 22:17:54.740924", "modified_by": "Administrator", "module": "Support", "name": "Service Level Agreement", @@ -213,7 +213,7 @@ }, { "read": 1, - "role": "All" + "role": "Desk User" } ], "sort_field": "modified",