diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 58c14a2bc5d..b0c69be4a1c 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -273,6 +273,8 @@ class POSInvoice(SalesInvoice): against_psi_doc.delete_loyalty_point_entry() against_psi_doc.make_loyalty_point_entry() + self.db_set("status", "Cancelled") + if self.coupon_code: from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 1c4bb66357f..714ed623796 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -129,8 +129,8 @@ def get_statement_dict(doc, get_statement_dict=False): tax_id = frappe.get_doc("Customer", entry.customer).tax_id presentation_currency = ( - get_party_account_currency("Customer", entry.customer, doc.company) - or doc.currency + doc.currency + or get_party_account_currency("Customer", entry.customer, doc.company) or get_company_currency(doc.company) ) diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py index 92dbb5ef273..ebfe96e771e 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/test_process_statement_of_accounts.py @@ -97,6 +97,7 @@ def create_process_soa(**args): company=args.company or "_Test Company", customers=args.customers or [{"customer": "_Test Customer"}], enable_auto_email=1 if args.enable_auto_email else 0, + currency=args.currency or "", frequency=args.frequency or "Weekly", report=args.report or "General Ledger", from_date=args.from_date or getdate(today()), diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index 13430b46449..c3935029670 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -425,6 +425,8 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying. this.frm.set_value("is_paid", 0); frappe.msgprint(__("Please specify Company to proceed")); } + } else { + this.frm.set_value("paid_amount", 0); } this.calculate_outstanding_amount(); this.frm.refresh_fields(); diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index e45802ca9f2..fa9b226374c 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -671,6 +671,7 @@ def get_tcs_amount(parties, inv, tax_details, vouchers, adv_vouchers): conditions.append(ple.party.isin(parties)) conditions.append(ple.voucher_no == ple.against_voucher_no) conditions.append(ple.company == inv.company) + conditions.append(ple.posting_date[tax_details.from_date : tax_details.to_date]) advance_amt = ( qb.from_(ple).select(Abs(Sum(ple.amount))).where(Criterion.all(conditions)).run()[0][0] or 0.0 diff --git a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py index ae0a098137a..3ea6801ad35 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/test_tax_withholding_category.py @@ -288,17 +288,18 @@ class TestTaxWithholdingCategory(FrappeTestCase): frappe.db.set_value( "Customer", "Test TCS Customer", "tax_withholding_category", "Cumulative Threshold TCS" ) + fiscal_year = get_fiscal_year(today(), company="_Test Company") vouchers = [] # create advance payment - pe = create_payment_entry( + pe1 = create_payment_entry( payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=20000 ) - pe.paid_from = "Debtors - _TC" - pe.paid_to = "Cash - _TC" - pe.submit() - vouchers.append(pe) + pe1.paid_from = "Debtors - _TC" + pe1.paid_to = "Cash - _TC" + pe1.submit() + vouchers.append(pe1) # create invoice si1 = create_sales_invoice(customer="Test TCS Customer", rate=5000) @@ -320,6 +321,17 @@ class TestTaxWithholdingCategory(FrappeTestCase): # make another invoice # sum of unallocated amount from payment entry and this sales invoice will breach cumulative threashold # TDS should be calculated + + # this payment should not be considered for TCS calculation as it is outside of fiscal year + pe2 = create_payment_entry( + payment_type="Receive", party_type="Customer", party="Test TCS Customer", paid_amount=10000 + ) + pe2.paid_from = "Debtors - _TC" + pe2.paid_to = "Cash - _TC" + pe2.posting_date = add_days(fiscal_year[1], -10) + pe2.submit() + vouchers.append(pe2) + si2 = create_sales_invoice(customer="Test TCS Customer", rate=15000) si2.submit() vouchers.append(si2) diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 419baaa3d47..52550f148b7 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -460,6 +460,12 @@ 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) + # get default account on the basis of party type + if not account: + account_type = frappe.get_cached_value("Party Type", party_type, "account_type") + default_account_name = "default_" + account_type.lower() + "_account" + account = frappe.get_cached_value("Company", company, default_account_name) + if include_advance and party_type in ["Customer", "Supplier", "Student"]: advance_account = get_party_advance_account(party_type, party, company) if advance_account: diff --git a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py index 963fb3556a6..b90f922d82b 100644 --- a/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py +++ b/erpnext/accounts/report/customer_ledger_summary/customer_ledger_summary.py @@ -8,6 +8,7 @@ from frappe.query_builder import Criterion, Tuple from frappe.query_builder.functions import IfNull from frappe.utils import getdate, nowdate from frappe.utils.nestedset import get_descendants_of +from pypika.terms import LiteralValue from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_accounting_dimensions, @@ -15,7 +16,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( ) TREE_DOCTYPES = frozenset( - ["Customer Group", "Terrirtory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"] + ["Customer Group", "Territory", "Supplier Group", "Sales Partner", "Sales Person", "Cost Center"] ) @@ -77,13 +78,12 @@ class PartyLedgerSummaryReport: from frappe.desk.reportview import build_match_conditions - query, params = query.walk() match_conditions = build_match_conditions(party_type) if match_conditions: - query += "and" + match_conditions + query = query.where(LiteralValue(match_conditions)) - party_details = frappe.db.sql(query, params, as_dict=True) + party_details = query.run(as_dict=True) for row in party_details: self.parties.append(row.party) @@ -458,9 +458,16 @@ class PartyLedgerSummaryReport: def get_children(doctype, value): - children = get_descendants_of(doctype, value) + if not isinstance(value, list): + value = [d.strip() for d in value.strip().split(",") if d] - return [value, *children] + all_children = [] + + for d in value: + all_children += get_descendants_of(doctype, value) + all_children.append(d) + + return list(set(all_children)) def execute(filters=None): diff --git a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js index 2afc1ecb439..5d47cc13e5b 100644 --- a/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js +++ b/erpnext/assets/doctype/asset_capitalization/asset_capitalization.js @@ -402,7 +402,7 @@ erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.s args: { item_code: item.item_code, warehouse: cstr(item.warehouse), - qty: flt(item.stock_qty), + qty: -1 * flt(item.stock_qty), serial_no: item.serial_no, posting_date: me.frm.doc.posting_date, posting_time: me.frm.doc.posting_time, diff --git a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py index 5bddea56183..f9c4913b1a2 100644 --- a/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py +++ b/erpnext/assets/doctype/asset_depreciation_schedule/asset_depreciation_schedule.py @@ -255,8 +255,10 @@ class AssetDepreciationSchedule(Document): value_after_depreciation, ): asset_doc.validate_asset_finance_books(row) - - if not value_after_depreciation: + if ( + not value_after_depreciation + and not asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment + ): value_after_depreciation = _get_value_after_depreciation_for_making_schedule(asset_doc, row) row.value_after_depreciation = value_after_depreciation @@ -1068,8 +1070,6 @@ def make_new_active_asset_depr_schedules_and_cancel_current_ones( ) new_asset_depr_schedule_doc = frappe.copy_doc(current_asset_depr_schedule_doc) - if asset_doc.flags.decrease_in_asset_value_due_to_value_adjustment and not value_after_depreciation: - value_after_depreciation = row.value_after_depreciation - difference_amount if asset_doc.flags.increase_in_asset_value_due_to_repair and row.depreciation_method in ( "Written Down Value", diff --git a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py index 6766b827f7f..0f4d8a9ae95 100644 --- a/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py +++ b/erpnext/assets/doctype/asset_value_adjustment/asset_value_adjustment.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import flt, formatdate, get_link_to_form, getdate +from frappe.utils import cstr, flt, formatdate, get_link_to_form, getdate from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import ( get_checks_for_pl_and_bs_accounts, @@ -188,12 +188,21 @@ class AssetValueAdjustment(Document): get_link_to_form(self.get("doctype"), self.get("name")), ) + difference_amount = self.difference_amount if self.docstatus == 1 else -1 * self.difference_amount + if asset.calculate_depreciation: + for row in asset.finance_books: + if cstr(row.finance_book) == cstr(self.finance_book): + row.value_after_depreciation += flt(difference_amount) + row.db_update() + + asset.db_update() + make_new_active_asset_depr_schedules_and_cancel_current_ones( asset, notes, value_after_depreciation=asset_value, ignore_booked_entry=True, - difference_amount=self.difference_amount, + difference_amount=difference_amount, ) asset.flags.ignore_validate_update_after_submit = True asset.save() diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index ae854f29343..1b1df3fa1e0 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -25,6 +25,9 @@ "disable_last_purchase_rate", "show_pay_button", "use_transaction_date_exchange_rate", + "allow_zero_qty_in_request_for_quotation", + "allow_zero_qty_in_supplier_quotation", + "allow_zero_qty_in_purchase_order", "subcontract", "backflush_raw_materials_of_subcontract_based_on", "column_break_11", @@ -207,14 +210,36 @@ "fieldtype": "Select", "label": "Update frequency of Project", "options": "Each Transaction\nManual" + }, + { + "default": "0", + "description": "Allows users to submit Purchase Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_purchase_order", + "fieldtype": "Check", + "label": "Allow Purchase Order with Zero Quantity" + }, + { + "default": "0", + "description": "Allows users to submit Request for Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_request_for_quotation", + "fieldtype": "Check", + "label": "Allow Request for Quotation with Zero Quantity" + }, + { + "default": "0", + "description": "Allows users to submit Supplier Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_supplier_quotation", + "fieldtype": "Check", + "label": "Allow Supplier Quotation with Zero Quantity" } ], + "grid_page_length": 50, "icon": "fa fa-cog", "idx": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-01-31 13:34:18.101256", + "modified": "2025-05-06 15:21:49.639642", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.py b/erpnext/buying/doctype/buying_settings/buying_settings.py index ec9b88888b7..4dde7c8dabf 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.py +++ b/erpnext/buying/doctype/buying_settings/buying_settings.py @@ -18,6 +18,9 @@ class BuyingSettings(Document): from frappe.types import DF allow_multiple_items: DF.Check + allow_zero_qty_in_purchase_order: DF.Check + allow_zero_qty_in_request_for_quotation: DF.Check + allow_zero_qty_in_supplier_quotation: DF.Check auto_create_purchase_receipt: DF.Check auto_create_subcontracting_order: DF.Check backflush_raw_materials_of_subcontract_based_on: DF.Literal[ diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js index 0b7c9de467a..7925d59d25a 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.js +++ b/erpnext/buying/doctype/purchase_order/purchase_order.js @@ -26,7 +26,15 @@ frappe.ui.form.on("Purchase Order", { } frm.set_indicator_formatter("item_code", function (doc) { - return doc.qty <= doc.received_qty ? "green" : "orange"; + let color; + if (!doc.qty && frm.doc.has_unit_price_items) { + color = "yellow"; + } else if (doc.qty <= doc.received_qty) { + color = "green"; + } else { + color = "orange"; + } + return color; }); frm.set_query("expense_account", "items", function () { @@ -63,6 +71,10 @@ frappe.ui.form.on("Purchase Order", { } }); } + + if (frm.doc.docstatus == 0) { + erpnext.set_unit_price_items_note(frm); + } }, supplier: function (frm) { diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 7c9362ebaf9..59b44a22e61 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -24,6 +24,7 @@ "apply_tds", "tax_withholding_category", "is_subcontracted", + "has_unit_price_items", "supplier_warehouse", "amended_from", "accounting_dimensions_section", @@ -1285,6 +1286,14 @@ "label": "Dispatch Address Details", "print_hide": 1, "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], "grid_page_length": 50, diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py index a18d9fce186..43ef287854e 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/purchase_order.py @@ -97,6 +97,7 @@ class PurchaseOrder(BuyingController): from_date: DF.Date | None grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -191,6 +192,10 @@ class PurchaseOrder(BuyingController): self.set_onload("supplier_tds", supplier_tds) self.set_onload("can_update_items", self.can_update_items()) + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): super().validate() @@ -223,6 +228,17 @@ class PurchaseOrder(BuyingController): ) self.reset_default_field_value("set_warehouse", "items", "warehouse") + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the PO has unit price items. + """ + if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def validate_with_previous_doc(self): mri_compare_fields = [["project", "="], ["item_code", "="]] if self.is_subcontracted: @@ -707,8 +723,13 @@ def set_missing_values(source, target): @frappe.whitelist() def make_purchase_receipt(source_name, target_doc=None): + has_unit_price_items = frappe.db.get_value("Purchase Order", source_name, "has_unit_price_items") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + def update_item(obj, target, source_parent): - target.qty = flt(obj.qty) - flt(obj.received_qty) + target.qty = flt(obj.qty) if is_unit_price_row(obj) else flt(obj.qty) - flt(obj.received_qty) target.stock_qty = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.conversion_factor) target.amount = (flt(obj.qty) - flt(obj.received_qty)) * flt(obj.rate) target.base_amount = ( @@ -739,7 +760,9 @@ def make_purchase_receipt(source_name, target_doc=None): "wip_composite_asset": "wip_composite_asset", }, "postprocess": update_item, - "condition": lambda doc: abs(doc.received_qty) < abs(doc.qty) + "condition": lambda doc: ( + True if is_unit_price_row(doc) else abs(doc.received_qty) < abs(doc.qty) + ) and doc.delivered_by_supplier != 1, }, "Purchase Taxes and Charges": {"doctype": "Purchase Taxes and Charges", "reset_value": True}, diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index 272f76816f8..c6ee35d6090 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -52,6 +52,13 @@ class TestPurchaseOrder(FrappeTestCase): po.save() self.assertEqual(po.items[1].qty, 1) + def test_purchase_order_zero_qty(self): + po = create_purchase_order(qty=0, do_not_save=True) + + with change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}): + po.save() + self.assertEqual(po.items[0].qty, 0) + def test_make_purchase_receipt(self): po = create_purchase_order(do_not_submit=True) self.assertRaises(frappe.ValidationError, make_purchase_receipt, po.name) @@ -801,8 +808,6 @@ class TestPurchaseOrder(FrappeTestCase): po_doc.reload() self.assertEqual(po_doc.advance_paid, 5000) - from erpnext.buying.doctype.purchase_order.purchase_order import make_purchase_invoice - company_doc.book_advance_payments_in_separate_party_account = False company_doc.save() @@ -1207,6 +1212,80 @@ class TestPurchaseOrder(FrappeTestCase): po.reload() self.assertEqual(po.per_billed, 100) + @change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}) + def test_receive_zero_qty_purchase_order(self): + """ + Test the flow of a Unit Price PO and PR creation against it until completion. + Flow: + PO Qty 0 -> Receive +5 -> Receive +5 -> Update PO Qty +10 -> PO is 100% received + """ + po = create_purchase_order(qty=0) + pr = make_purchase_receipt(po.name) + + self.assertEqual(pr.items[0].qty, 0) + pr.items[0].qty = 5 + pr.submit() + + po.reload() + self.assertEqual(po.items[0].received_qty, 5) + self.assertFalse(po.per_received) + self.assertEqual(po.status, "To Receive and Bill") + + # Update PO Item Qty to 10 after receipt of items + first_item_of_po = po.items[0] + trans_item = json.dumps( + [ + { + "item_code": first_item_of_po.item_code, + "rate": first_item_of_po.rate, + "qty": 10, + "docname": first_item_of_po.name, + } + ] + ) + update_child_qty_rate("Purchase Order", trans_item, po.name) + + # Test: PR can be made against PO as long PO qty is 0 OR PO qty > received qty + pr2 = make_purchase_receipt(po.name) + + po.reload() + self.assertEqual(po.items[0].qty, 10) + self.assertEqual(pr2.items[0].qty, 5) + + pr2.submit() + + # PO should be updated to 100% received + po.reload() + self.assertEqual(po.items[0].qty, 10) + self.assertEqual(po.items[0].received_qty, 10) + self.assertEqual(po.per_received, 100.0) + self.assertEqual(po.status, "To Bill") + + @change_settings("Buying Settings", {"allow_zero_qty_in_purchase_order": 1}) + def test_bill_zero_qty_purchase_order(self): + po = create_purchase_order(qty=0) + + self.assertEqual(po.grand_total, 0) + self.assertFalse(po.per_billed) + self.assertEqual(po.items[0].qty, 0) + self.assertEqual(po.items[0].rate, 500) + + pi = make_pi_from_po(po.name) + self.assertEqual(pi.items[0].qty, 0) + self.assertEqual(pi.items[0].rate, 500) + + pi.items[0].qty = 5 + pi.submit() + + self.assertEqual(pi.grand_total, 2500) + + po.reload() + self.assertEqual(po.items[0].amount, 0) + self.assertEqual(po.items[0].billed_amt, 2500) + # PO still has qty 0, so billed % should be unset + self.assertFalse(po.per_billed) + self.assertEqual(po.status, "To Receive and Bill") + def create_po_for_sc_testing(): from erpnext.controllers.tests.test_subcontracting_controller import ( diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js index 96597bd9753..e88a98759d0 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.js @@ -28,6 +28,10 @@ frappe.ui.form.on("Request for Quotation", { is_group: 0, }, })); + + frm.set_indicator_formatter("item_code", function (doc) { + return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : ""; + }); }, onload: function (frm) { @@ -163,6 +167,10 @@ frappe.ui.form.on("Request for Quotation", { __("View") ); } + + if (frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(frm); + } }, show_supplier_quotation_comparison(frm) { diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json index fd73f77ff8f..824484f9c20 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.json @@ -16,6 +16,7 @@ "transaction_date", "schedule_date", "status", + "has_unit_price_items", "amended_from", "suppliers_section", "suppliers", @@ -306,13 +307,22 @@ "fieldtype": "Small Text", "label": "Billing Address Details", "read_only": 1 + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], + "grid_page_length": 50, "icon": "fa fa-shopping-cart", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2023-11-06 12:45:28.898706", + "modified": "2025-03-03 16:48:39.856779", "modified_by": "Administrator", "module": "Buying", "name": "Request for Quotation", @@ -377,6 +387,7 @@ "role": "All" } ], + "row_format": "Dynamic", "search_fields": "status, transaction_date", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py index 650251ed7de..27793236dc3 100644 --- a/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/request_for_quotation.py @@ -42,6 +42,7 @@ class RequestforQuotation(BuyingController): billing_address_display: DF.SmallText | None company: DF.Link email_template: DF.Link | None + has_unit_price_items: DF.Check incoterm: DF.Link | None items: DF.Table[RequestforQuotationItem] letter_head: DF.Link | None @@ -61,6 +62,10 @@ class RequestforQuotation(BuyingController): vendor: DF.Link | None # end: auto-generated types + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): self.validate_duplicate_supplier() self.validate_supplier_list() @@ -73,6 +78,17 @@ class RequestforQuotation(BuyingController): # after amend and save, status still shows as cancelled, until submit self.db_set("status", "Draft") + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the RFQ has unit price items. + """ + if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_request_for_quotation"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def validate_duplicate_supplier(self): supplier_list = [d.supplier for d in self.suppliers] if len(supplier_list) != len(set(supplier_list)): @@ -440,11 +456,10 @@ def create_supplier_quotation(doc): def add_items(sq_doc, supplier, items): for data in items: - if data.get("qty") > 0: - if isinstance(data, dict): - data = frappe._dict(data) + if isinstance(data, dict): + data = frappe._dict(data) - create_rfq_items(sq_doc, supplier, data) + create_rfq_items(sq_doc, supplier, data) def create_rfq_items(sq_doc, supplier, data): diff --git a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py index 4effe13d02c..1a8b3a8ac47 100644 --- a/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py +++ b/erpnext/buying/doctype/request_for_quotation/test_request_for_quotation.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import nowdate from erpnext.buying.doctype.request_for_quotation.request_for_quotation import ( @@ -32,6 +32,16 @@ class TestRequestforQuotation(FrappeTestCase): rfq.save() self.assertEqual(rfq.items[0].qty, 1) + def test_rfq_zero_qty(self): + """ + Test if RFQ with zero qty (Unit Price Item) is conditionally allowed. + """ + rfq = make_request_for_quotation(qty=0, do_not_save=True) + + with change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}): + rfq.save() + self.assertEqual(rfq.items[0].qty, 0) + def test_quote_status(self): rfq = make_request_for_quotation() @@ -172,6 +182,32 @@ class TestRequestforQuotation(FrappeTestCase): supplier_doc.reload() self.assertTrue(supplier_doc.portal_users[0].user) + @change_settings("Buying Settings", {"allow_zero_qty_in_request_for_quotation": 1}) + def test_supplier_quotation_from_zero_qty_rfq(self): + rfq = make_request_for_quotation(qty=0) + sq = make_supplier_quotation_from_rfq(rfq.name, for_supplier=rfq.get("suppliers")[0].supplier) + + self.assertEqual(len(sq.items), 1) + self.assertEqual(sq.items[0].qty, 0) + self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code) + + @change_settings( + "Buying Settings", + { + "allow_zero_qty_in_request_for_quotation": 1, + "allow_zero_qty_in_supplier_quotation": 1, + }, + ) + def test_supplier_quotation_from_zero_qty_rfq_in_portal(self): + rfq = make_request_for_quotation(qty=0) + rfq.supplier = rfq.suppliers[0].supplier + sq_name = create_supplier_quotation(rfq) + + sq = frappe.get_doc("Supplier Quotation", sq_name) + self.assertEqual(len(sq.items), 1) + self.assertEqual(sq.items[0].qty, 0) + self.assertEqual(sq.items[0].item_code, rfq.items[0].item_code) + def make_request_for_quotation(**args) -> "RequestforQuotation": """ diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js index c1698710135..fccca81f8ce 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.js @@ -11,6 +11,11 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e Quotation: "Quotation", }; + const me = this; + this.frm.set_indicator_formatter("item_code", function (doc) { + return !doc.qty && me.frm.doc.has_unit_price_items ? "yellow" : ""; + }); + super.setup(); } @@ -26,6 +31,8 @@ erpnext.buying.SupplierQuotationController = class SupplierQuotationController e cur_frm.page.set_inner_btn_group_as_primary(__("Create")); cur_frm.add_custom_button(__("Quotation"), this.make_quotation, __("Create")); } else if (this.frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(this.frm); + this.frm.add_custom_button( __("Material Request"), function () { diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json index 4a8cd8bf9e6..6682c7e3585 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json @@ -19,6 +19,7 @@ "transaction_date", "valid_till", "quotation_number", + "has_unit_price_items", "amended_from", "accounting_dimensions_section", "cost_center", @@ -921,14 +922,23 @@ "fieldname": "accounting_dimensions_section", "fieldtype": "Section Break", "label": "Accounting Dimensions" + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], + "grid_page_length": 50, "icon": "fa fa-shopping-cart", "idx": 29, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-03-28 10:20:30.231915", + "modified": "2025-03-03 17:39:38.459977", "modified_by": "Administrator", "module": "Buying", "name": "Supplier Quotation", @@ -989,6 +999,7 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "status, transaction_date, supplier,grand_total", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py index 215022e18a6..5557f1a80ae 100644 --- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py @@ -60,6 +60,7 @@ class SupplierQuotation(BuyingController): discount_amount: DF.Currency grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -103,6 +104,10 @@ class SupplierQuotation(BuyingController): valid_till: DF.Date | None # end: auto-generated types + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): super().validate() @@ -129,6 +134,17 @@ class SupplierQuotation(BuyingController): def on_trash(self): pass + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the SQ has unit price items. + """ + if not frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_supplier_quotation"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def validate_with_previous_doc(self): super().validate_with_previous_doc( { diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index 88249335b5a..60c82bbc05f 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -3,9 +3,10 @@ import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, today +from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order from erpnext.controllers.accounts_controller import InvalidQtyError @@ -22,8 +23,6 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(sq.items[0].qty, 1) def test_make_purchase_order(self): - from erpnext.buying.doctype.supplier_quotation.supplier_quotation import make_purchase_order - sq = frappe.copy_doc(test_records[0]).insert() self.assertRaises(frappe.ValidationError, make_purchase_order, sq.name) @@ -43,5 +42,16 @@ class TestPurchaseOrder(FrappeTestCase): po.insert() + @change_settings("Buying Settings", {"allow_zero_qty_in_supplier_quotation": 1}) + def test_map_purchase_order_from_zero_qty_supplier_quotation(self): + sq = frappe.copy_doc(test_records[0]).insert() + sq.items[0].qty = 0 + sq.submit() + + po = make_purchase_order(sq.name) + self.assertEqual(len(po.get("items")), 1) + self.assertEqual(po.get("items")[0].qty, 0) + self.assertEqual(po.get("items")[0].item_code, sq.get("items")[0].item_code) + test_records = frappe.get_test_records("Supplier Quotation") diff --git a/erpnext/buying/utils.py b/erpnext/buying/utils.py index 259c262d0d7..54f00417a89 100644 --- a/erpnext/buying/utils.py +++ b/erpnext/buying/utils.py @@ -20,6 +20,9 @@ def update_last_purchase_rate(doc, is_submit) -> None: this_purchase_date = getdate(doc.get("posting_date") or doc.get("transaction_date")) for d in doc.get("items"): + if d.get("is_free_item"): + continue + # get last purchase details last_purchase_details = get_last_purchase_details(d.item_code, doc.name) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 9e1a05d66a5..237f19274e3 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -649,6 +649,9 @@ class AccountsController(TransactionBase): self.base_paid_amount = flt( self.paid_amount * self.conversion_rate, self.precision("base_paid_amount") ) + else: + self.paid_amount = 0 + self.base_paid_amount = 0 def set_missing_values(self, for_validate=False): if frappe.flags.in_test: @@ -1252,6 +1255,9 @@ class AccountsController(TransactionBase): ) def validate_qty_is_not_zero(self): + if self.flags.allow_zero_qty: + return + for item in self.items: if self.doctype == "Purchase Receipt" and item.rejected_qty: continue @@ -3732,9 +3738,9 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ) if amount_below_billed_amt and row_rate > 0.0: frappe.throw( - _("Row #{0}: Cannot set Rate if amount is greater than billed amount for Item {1}.").format( - child_item.idx, child_item.item_code - ) + _( + "Row #{0}: Cannot set Rate if the billed amount is greater than the amount for Item {1}." + ).format(child_item.idx, child_item.item_code) ) else: child_item.rate = row_rate diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 3fdf92e7990..385dce625fb 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -70,6 +70,14 @@ class BuyingController(SubcontractingController): frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on"), ) + if self.docstatus == 1 and self.doctype in ["Purchase Receipt", "Purchase Invoice"]: + self.set_onload( + "allow_to_make_qc_after_submission", + frappe.db.get_single_value( + "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" + ), + ) + def create_package_for_transfer(self) -> None: """Create serial and batch package for Sourece Warehouse in case of inter transfer.""" diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 26ac65589f1..73ce4c83e4c 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -31,6 +31,14 @@ class SellingController(StockController): ) ) + if self.docstatus == 1 and self.doctype in ["Delivery Note", "Sales Invoice"]: + self.set_onload( + "allow_to_make_qc_after_submission", + frappe.db.get_single_value( + "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" + ), + ) + def validate(self): super().validate() self.validate_items() diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index f079274528c..3654ad5e6c5 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -892,7 +892,7 @@ class StockController(AccountsController): or sl_dict.actual_qty < 0 and self.get("is_return") ) - and self.doctype in ["Purchase Invoice", "Purchase Receipt"] + and self.doctype in ["Purchase Invoice", "Purchase Receipt", "Stock Entry"] ) or ( ( sl_dict.actual_qty < 0 @@ -902,6 +902,15 @@ class StockController(AccountsController): ) and self.doctype in ["Sales Invoice", "Delivery Note", "Stock Entry"] ): + if self.doctype == "Stock Entry": + if row.get("t_warehouse") == sl_dict.warehouse and sl_dict.get("actual_qty") > 0: + fieldname = f"to_{dimension.source_fieldname}" + if dimension.source_fieldname.startswith("to_"): + fieldname = f"{dimension.source_fieldname}" + + sl_dict[dimension.target_fieldname] = row.get(fieldname) + return + sl_dict[dimension.target_fieldname] = row.get(dimension.source_fieldname) else: fieldname_start_with = "to" diff --git a/erpnext/crm/doctype/contract/contract.js b/erpnext/crm/doctype/contract/contract.js index 8d44c22db28..42bd7f9b769 100644 --- a/erpnext/crm/doctype/contract/contract.js +++ b/erpnext/crm/doctype/contract/contract.js @@ -29,4 +29,10 @@ frappe.ui.form.on("Contract", { }); } }, + party_name: function (frm) { + let field = frm.doc.party_type.toLowerCase() + "_name"; + frappe.db.get_value(frm.doc.party_type, frm.doc.party_name, field, (r) => { + frm.set_value("party_full_name", r[field]); + }); + }, }); diff --git a/erpnext/crm/doctype/contract/contract.json b/erpnext/crm/doctype/contract/contract.json index de3230f0e67..948243402fe 100755 --- a/erpnext/crm/doctype/contract/contract.json +++ b/erpnext/crm/doctype/contract/contract.json @@ -14,6 +14,7 @@ "party_user", "status", "fulfilment_status", + "party_full_name", "sb_terms", "start_date", "cb_date", @@ -244,11 +245,18 @@ "fieldname": "authorised_by_section", "fieldtype": "Section Break", "label": "Authorised By" + }, + { + "fieldname": "party_full_name", + "fieldtype": "Data", + "label": "Party Full Name", + "read_only": 1 } ], + "grid_page_length": 50, "is_submittable": 1, "links": [], - "modified": "2020-12-07 11:15:58.385521", + "modified": "2025-05-23 13:54:03.346537", "modified_by": "Administrator", "module": "CRM", "name": "Contract", @@ -315,9 +323,10 @@ "write": 1 } ], + "row_format": "Dynamic", "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/erpnext/crm/doctype/contract/contract.py b/erpnext/crm/doctype/contract/contract.py index 6c3aace6fd8..64f89552062 100644 --- a/erpnext/crm/doctype/contract/contract.py +++ b/erpnext/crm/doctype/contract/contract.py @@ -34,6 +34,7 @@ class Contract(Document): fulfilment_terms: DF.Table[ContractFulfilmentChecklist] ip_address: DF.Data | None is_signed: DF.Check + party_full_name: DF.Data | None party_name: DF.DynamicLink party_type: DF.Literal["Customer", "Supplier", "Employee"] party_user: DF.Link | None @@ -59,10 +60,17 @@ class Contract(Document): self.name = _(name) def validate(self): + self.set_missing_values() self.validate_dates() self.update_contract_status() self.update_fulfilment_status() + def set_missing_values(self): + if not self.party_full_name: + field = self.party_type.lower() + "_name" + if res := frappe.db.get_value(self.party_type, self.party_name, field): + self.party_full_name = res + def before_submit(self): self.signed_by_company = frappe.session.user diff --git a/erpnext/patches.txt b/erpnext/patches.txt index cf8ae44a8d7..d78e86bd1d7 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -404,3 +404,6 @@ erpnext.patches.v15_0.update_payment_schedule_fields_in_invoices erpnext.patches.v15_0.rename_group_by_to_categorize_by execute:frappe.db.set_single_value("Accounts Settings", "receivable_payable_fetch_method", "Buffered Cursor") erpnext.patches.v14_0.set_update_price_list_based_on +erpnext.patches.v15_0.set_cancelled_status_to_cancelled_pos_invoice +erpnext.patches.v15_0.rename_group_by_to_categorize_by_in_custom_reports +erpnext.patches.v14_0.update_full_name_in_contract diff --git a/erpnext/patches/v14_0/update_full_name_in_contract.py b/erpnext/patches/v14_0/update_full_name_in_contract.py new file mode 100644 index 00000000000..19ee055ad12 --- /dev/null +++ b/erpnext/patches/v14_0/update_full_name_in_contract.py @@ -0,0 +1,15 @@ +import frappe +from frappe import qb + + +def execute(): + con = qb.DocType("Contract") + for c in ( + qb.from_(con) + .select(con.name, con.party_type, con.party_name) + .where(con.party_full_name.isnull()) + .run(as_dict=True) + ): + field = c.party_type.lower() + "_name" + if res := frappe.db.get_value(c.party_type, c.party_name, field): + frappe.db.set_value("Contract", c.name, "party_full_name", res) diff --git a/erpnext/patches/v15_0/rename_group_by_to_categorize_by_in_custom_reports.py b/erpnext/patches/v15_0/rename_group_by_to_categorize_by_in_custom_reports.py new file mode 100644 index 00000000000..bc671a4a6ac --- /dev/null +++ b/erpnext/patches/v15_0/rename_group_by_to_categorize_by_in_custom_reports.py @@ -0,0 +1,24 @@ +import json + +import frappe + + +def execute(): + custom_reports = frappe.get_all( + "Report", + filters={ + "report_type": "Custom Report", + "reference_report": ["in", ["General Ledger", "Supplier Quotation Comparison"]], + }, + fields=["name", "json"], + ) + + for report in custom_reports: + report_json = json.loads(report.json) + + if "filters" in report_json and "group_by" in report_json["filters"]: + report_json["filters"]["categorize_by"] = ( + report_json["filters"].pop("group_by").replace("Group", "Categorize") + ) + + frappe.db.set_value("Report", report.name, "json", json.dumps(report_json)) diff --git a/erpnext/patches/v15_0/set_cancelled_status_to_cancelled_pos_invoice.py b/erpnext/patches/v15_0/set_cancelled_status_to_cancelled_pos_invoice.py new file mode 100644 index 00000000000..a4141ec20e6 --- /dev/null +++ b/erpnext/patches/v15_0/set_cancelled_status_to_cancelled_pos_invoice.py @@ -0,0 +1,8 @@ +import frappe +from frappe.query_builder import DocType + + +def execute(): + POSInvoice = DocType("POS Invoice") + + frappe.qb.update(POSInvoice).set(POSInvoice.status, "Cancelled").where(POSInvoice.docstatus == 2).run() diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 1fcdd459a3f..db4783582ec 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -42,6 +42,29 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } item.base_rate_with_margin = item.rate_with_margin * flt(frm.doc.conversion_rate); + if (item.item_code && item.rate) { + frappe.call({ + method: "frappe.client.get_value", + args: { + doctype: "Item Tax", + parent: "Item", + filters: { + parent: item.item_code, + minimum_net_rate: ["<=", item.rate], + maximum_net_rate: [">=", item.rate] + }, + fieldname: "item_tax_template" + }, + callback: function(r) { + const tax_rule = r.message; + + let matched_template = tax_rule ? tax_rule.item_tax_template : null; + + frappe.model.set_value(cdt, cdn, 'item_tax_template', matched_template); + } + }); + } + cur_frm.cscript.set_gross_profit(item); cur_frm.cscript.calculate_taxes_and_totals(); cur_frm.cscript.calculate_stock_uom_rate(frm, cdt, cdn); @@ -330,7 +353,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } const me = this; - if (!this.frm.is_new() && this.frm.doc.docstatus === 0 && frappe.model.can_create("Quality Inspection") && show_qc_button) { + if (!this.frm.is_new() + && (this.frm.doc.docstatus === 0 || this.frm.doc.__onload?.allow_to_make_qc_after_submission) + && frappe.model.can_create("Quality Inspection") + && show_qc_button) { this.frm.add_custom_button(__("Quality Inspection(s)"), () => { me.make_quality_inspection(); }, __("Create")); @@ -2773,3 +2799,19 @@ erpnext.apply_putaway_rule = (frm, purpose=null) => { } }); }; + +erpnext.set_unit_price_items_note = (frm) => { + if (frm.doc.has_unit_price_items && !frm.is_new()) { + // Remove existing note + const $note = $(frm.layout.wrapper.find(".unit-price-items-note")); + if ($note.length) { $note.parent().remove(); } + + frm.layout.show_message( + `
+ ${__("The {0} contains Unit Price Items.", [__(frm.doc.doctype)])} +
`, + "yellow", + true + ); + } +}; diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js index 2a33dd5bf72..685b27ab3ef 100644 --- a/erpnext/selling/doctype/quotation/quotation.js +++ b/erpnext/selling/doctype/quotation/quotation.js @@ -35,12 +35,20 @@ frappe.ui.form.on("Quotation", { }, }; }); + + frm.set_indicator_formatter("item_code", function (doc) { + return !doc.qty && frm.doc.has_unit_price_items ? "yellow" : ""; + }); }, refresh: function (frm) { frm.trigger("set_label"); frm.trigger("set_dynamic_field_label"); + if (frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(frm); + } + let sbb_field = frm.get_docfield("packed_items", "serial_and_batch_bundle"); if (sbb_field) { sbb_field.get_route_options_for_new_doc = (row) => { diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index 4d257ff69e7..b3aaae99c4d 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -21,6 +21,7 @@ "column_break1", "order_type", "company", + "has_unit_price_items", "amended_from", "currency_and_price_list", "currency", @@ -935,6 +936,7 @@ "fieldname": "lost_reasons", "fieldtype": "Table MultiSelect", "label": "Lost Reasons", + "no_copy": 1, "options": "Quotation Lost Reason Detail", "read_only": 1 }, @@ -1084,13 +1086,21 @@ "label": "Company Contact Person", "options": "Contact", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], "icon": "fa fa-shopping-cart", "idx": 82, "is_submittable": 1, "links": [], - "modified": "2024-11-26 12:43:29.293637", + "modified": "2025-05-27 16:04:39.208077", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", @@ -1181,6 +1191,7 @@ "role": "Maintenance User" } ], + "row_format": "Dynamic", "search_fields": "status,transaction_date,party_name,order_type", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py index 48614671da2..7816485a9f0 100644 --- a/erpnext/selling/doctype/quotation/quotation.py +++ b/erpnext/selling/doctype/quotation/quotation.py @@ -67,6 +67,7 @@ class Quotation(SellingController): enq_det: DF.Text | None grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -126,6 +127,10 @@ class Quotation(SellingController): self.indicator_color = "gray" self.indicator_title = "Expired" + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): super().validate() self.set_status() @@ -157,6 +162,17 @@ class Quotation(SellingController): if not row.is_alternative and row.name in items_with_alternatives: row.has_alternative_item = 1 + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the SO has unit price items. + """ + if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_quotation"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def get_ordered_status(self): status = "Open" ordered_items = frappe._dict( @@ -367,6 +383,12 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])] + # 0 qty is accepted, as the qty uncertain for some items + has_unit_price_items = frappe.db.get_value("Quotation", source_name, "has_unit_price_items") + + def is_unit_price_row(source) -> bool: + return has_unit_price_items and source.qty == 0 + def set_missing_values(source, target): if customer: target.customer = customer.name @@ -395,7 +417,7 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): target.run_method("calculate_taxes_and_totals") def update_item(obj, target, source_parent): - balance_qty = obj.qty - ordered_items.get(obj.item_code, 0.0) + balance_qty = obj.qty if is_unit_price_row(obj) else obj.qty - ordered_items.get(obj.item_code, 0.0) target.qty = balance_qty if balance_qty > 0 else 0 target.stock_qty = flt(target.qty) * flt(obj.conversion_factor) @@ -409,22 +431,22 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False): Row mapping from Quotation to Sales order: 1. If no selections, map all non-alternative rows (that sum up to the grand total) 2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty - 3. If selections: Simple row: Map if adequate qty + 3. If no selections: Simple row: Map if adequate qty """ balance_qty = item.qty - ordered_items.get(item.item_code, 0.0) - if balance_qty <= 0: - return False + has_valid_qty: bool = (balance_qty > 0) or is_unit_price_row(item) - has_qty = balance_qty + if not has_valid_qty: + return False if not selected_rows: return not item.is_alternative if selected_rows and (item.is_alternative or item.has_alternative_item): - return (item.name in selected_rows) and has_qty + return item.name in selected_rows # Simple row - return has_qty + return True doclist = get_mapped_doc( "Quotation", diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index f8899183113..f6f4d98d541 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -2,7 +2,7 @@ # License: GNU General Public License v3. See license.txt import frappe -from frappe.tests.utils import FrappeTestCase +from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_days, add_months, flt, getdate, nowdate from erpnext.controllers.accounts_controller import InvalidQtyError @@ -21,6 +21,15 @@ class TestQuotation(FrappeTestCase): qo.save() self.assertEqual(qo.items[0].qty, 1) + def test_quotation_zero_qty(self): + """ + Test if Quote with zero qty (Unit Price Item) is conditionally allowed. + """ + qo = make_quotation(qty=0, do_not_save=True) + with change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1}): + qo.save() + self.assertEqual(qo.items[0].qty, 0) + def test_make_quotation_without_terms(self): quotation = make_quotation(do_not_save=1) self.assertFalse(quotation.get("payment_schedule")) @@ -773,6 +782,39 @@ class TestQuotation(FrappeTestCase): self.assertEqual(quotation.rounding_adjustment, 0) self.assertEqual(quotation.rounded_total, 0) + @change_settings("Selling Settings", {"allow_zero_qty_in_quotation": 1}) + def test_so_from_zero_qty_quotation(self): + from erpnext.selling.doctype.quotation.quotation import make_sales_order + from erpnext.stock.doctype.item.test_item import make_item + + make_item("_Test Item 2", {"is_stock_item": 1}) + quotation = make_quotation(qty=0, do_not_save=1) + quotation.append("items", {"item_code": "_Test Item 2", "qty": 10, "rate": 100}) + quotation.submit() + + sales_order = make_sales_order(quotation.name) + sales_order.delivery_date = nowdate() + self.assertEqual(sales_order.items[0].qty, 0) + self.assertEqual(sales_order.items[1].qty, 10) + + sales_order.items[0].qty = 10 + sales_order.items[1].qty = 5 + sales_order.submit() + + quotation.reload() + self.assertEqual(quotation.status, "Partially Ordered") + + sales_order_2 = make_sales_order(quotation.name) + sales_order_2.delivery_date = nowdate() + self.assertEqual(sales_order_2.items[0].qty, 0) + self.assertEqual(sales_order_2.items[1].qty, 5) + + del sales_order_2.items[0] + sales_order_2.submit() + + quotation.reload() + self.assertEqual(quotation.status, "Ordered") + test_records = frappe.get_test_records("Quotation") diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 400acd3c6f1..0c4cd43ec36 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -23,7 +23,16 @@ frappe.ui.form.on("Sales Order", { // formatter for material request item frm.set_indicator_formatter("item_code", function (doc) { - return doc.stock_qty <= doc.delivered_qty ? "green" : "orange"; + let color; + if (!doc.qty && frm.doc.has_unit_price_items) { + color = "yellow"; + } else if (doc.stock_qty <= doc.delivered_qty) { + color = "green"; + } else { + color = "orange"; + } + + return color; }); frm.set_query("bom_no", "items", function (doc, cdt, cdn) { @@ -97,6 +106,8 @@ frappe.ui.form.on("Sales Order", { } if (frm.doc.docstatus === 0) { + erpnext.set_unit_price_items_note(frm); + if (frm.doc.is_internal_customer) { frm.events.get_items_from_internal_purchase_order(frm); } @@ -586,10 +597,12 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex } if (doc.status !== "Closed") { if (doc.status !== "On Hold") { + const items_are_deliverable = this.frm.doc.items.some( + (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty) + ); allow_delivery = - this.frm.doc.items.some( - (item) => item.delivered_by_supplier === 0 && item.qty > flt(item.delivered_qty) - ) && !this.frm.doc.skip_delivery_note; + (this.frm.doc.has_unit_price_items || items_are_deliverable) && + !this.frm.doc.skip_delivery_note; if (this.frm.has_perm("submit")) { if (flt(doc.per_delivered) < 100 || flt(doc.per_billed) < 100) { diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index 4db7bf6f003..801648b4479 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -25,6 +25,7 @@ "po_date", "company", "skip_delivery_note", + "has_unit_price_items", "amended_from", "accounting_dimensions_section", "cost_center", @@ -1649,13 +1650,21 @@ "label": "Company Contact Person", "options": "Contact", "print_hide": 1 + }, + { + "default": "0", + "fieldname": "has_unit_price_items", + "fieldtype": "Check", + "hidden": 1, + "label": "Has Unit Price Items", + "no_copy": 1 } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2025-02-06 16:02:20.320877", + "modified": "2025-03-03 16:49:00.676927", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1724,6 +1733,7 @@ "write": 1 } ], + "row_format": "Dynamic", "search_fields": "status,transaction_date,customer,customer_name, territory,order_type,company", "show_name_in_global_search": 1, "sort_field": "modified", diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 63168d6ffaf..c7597c80fc6 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -110,6 +110,7 @@ class SalesOrder(SellingController): from_date: DF.Date | None grand_total: DF.Currency group_same_items: DF.Check + has_unit_price_items: DF.Check ignore_pricing_rule: DF.Check in_words: DF.Data | None incoterm: DF.Link | None @@ -195,6 +196,10 @@ class SalesOrder(SellingController): if has_reserved_stock(self.doctype, self.name): self.set_onload("has_reserved_stock", True) + def before_validate(self): + self.set_has_unit_price_items() + self.flags.allow_zero_qty = self.has_unit_price_items + def validate(self): super().validate() self.validate_delivery_date() @@ -231,6 +236,17 @@ class SalesOrder(SellingController): self.reset_default_field_value("set_warehouse", "items", "warehouse") + def set_has_unit_price_items(self): + """ + If permitted in settings and any item has 0 qty, the SO has unit price items. + """ + if not frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order"): + return + + self.has_unit_price_items = any( + not row.qty for row in self.get("items") if (row.item_code and not row.qty) + ) + def validate_po(self): # validate p.o date v/s delivery date if self.po_date and not self.skip_delivery_note: @@ -946,6 +962,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): "Sales Team": {"doctype": "Sales Team", "add_if_empty": True}, } + # 0 qty is accepted, as the qty is uncertain for some items + has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + def set_missing_values(source, target): if kwargs.get("ignore_pricing_rule"): # Skip pricing rule when the dn is creating from the pick list @@ -982,12 +1004,16 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): 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 + return ( + (abs(doc.delivered_qty) < abs(doc.qty)) or is_unit_price_row(doc) + ) 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) - target.qty = flt(source.qty) - flt(source.delivered_qty) + target.qty = ( + flt(source.qty) if is_unit_price_row(source) else flt(source.qty) - flt(source.delivered_qty) + ) item = get_item_defaults(target.item_code, source_parent.company) item_group = get_item_group_defaults(target.item_code, source_parent.company) @@ -1064,6 +1090,12 @@ def make_delivery_note(source_name, target_doc=None, kwargs=None): @frappe.whitelist() def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): + # 0 qty is accepted, as the qty is uncertain for some items + has_unit_price_items = frappe.db.get_value("Sales Order", source_name, "has_unit_price_items") + + def is_unit_price_row(source): + return has_unit_price_items and source.qty == 0 + def postprocess(source, target): set_missing_values(source, target) # Get the advance paid Journal Entries in Sales Invoice Advance @@ -1093,12 +1125,18 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): target.debit_to = get_party_account("Customer", source.customer, source.company) def update_item(source, target, source_parent): - target.amount = flt(source.amount) - flt(source.billed_amt) + if source_parent.has_unit_price_items: + # 0 Amount rows (as seen in Unit Price Items) should be mapped as it is + pending_amount = flt(source.amount) - flt(source.billed_amt) + target.amount = pending_amount if flt(source.amount) else 0 + else: + target.amount = flt(source.amount) - flt(source.billed_amt) + target.base_amount = target.amount * flt(source_parent.conversion_rate) target.qty = ( target.amount / flt(source.rate) if (source.rate and source.billed_amt) - else source.qty - source.returned_qty + else (source.qty if is_unit_price_row(source) else source.qty - source.returned_qty) ) if source_parent.project: @@ -1131,8 +1169,11 @@ def make_sales_invoice(source_name, target_doc=None, ignore_permissions=False): "parent": "sales_order", }, "postprocess": update_item, - "condition": lambda doc: doc.qty - and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount)), + "condition": lambda doc: ( + True + if is_unit_price_row(doc) + else (doc.qty and (doc.base_amount == 0 or abs(doc.billed_amt) < abs(doc.amount))) + ), }, "Sales Taxes and Charges": { "doctype": "Sales Taxes and Charges", diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 164d1157258..edac4f70f19 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -109,6 +109,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): so.save() self.assertEqual(so.items[0].qty, 1) + def test_sales_order_zero_qty(self): + po = make_sales_order(qty=0, do_not_save=True) + + with change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}): + po.save() + self.assertEqual(po.items[0].qty, 0) + def test_make_material_request(self): so = make_sales_order(do_not_submit=True) @@ -2306,6 +2313,77 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase): po.submit() self.assertEqual(po.taxes[0].tax_amount, 2) + @change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}) + def test_deliver_zero_qty_purchase_order(self): + """ + Test the flow of a Unit Price SO and DN creation against it until completion. + Flow: + SO Qty 0 -> Deliver +5 -> Update SO Qty +10 -> Deliver +5 -> SO is 100% delivered + """ + so = make_sales_order(qty=0) + dn = make_delivery_note(so.name) + + self.assertEqual(dn.items[0].qty, 0) + dn.items[0].qty = 5 + dn.submit() + + # Test SO impact after DN + so.reload() + self.assertEqual(so.items[0].delivered_qty, 5) + self.assertFalse(so.per_delivered) + self.assertEqual(so.status, "To Deliver and Bill") + + # Update SO Qty to final qty + first_item_of_so = so.items[0] + trans_item = json.dumps( + [ + { + "item_code": first_item_of_so.item_code, + "rate": first_item_of_so.rate, + "qty": 10, + "docname": first_item_of_so.name, + } + ] + ) + update_child_qty_rate("Sales Order", trans_item, so.name) + + # Test: DN maps pending qty from SO + dn2 = make_delivery_note(so.name) + + so.reload() + self.assertEqual(so.items[0].qty, 10) + self.assertEqual(dn2.items[0].qty, 5) + + dn2.submit() + + so.reload() + self.assertEqual(so.items[0].delivered_qty, 10) + self.assertEqual(so.per_delivered, 100.0) + self.assertEqual(so.status, "To Bill") + + @change_settings("Selling Settings", {"allow_zero_qty_in_sales_order": 1}) + def test_bill_zero_qty_sales_order(self): + so = make_sales_order(qty=0) + + self.assertEqual(so.grand_total, 0) + self.assertFalse(so.per_billed) + self.assertEqual(so.items[0].qty, 0) + self.assertEqual(so.items[0].rate, 100) + + si = make_sales_invoice(so.name) + self.assertEqual(si.items[0].qty, 0) + self.assertEqual(si.items[0].rate, 100) + + si.items[0].qty = 5 + si.submit() + + so.reload() + self.assertEqual(so.items[0].amount, 0) + self.assertEqual(so.items[0].billed_amt, si.grand_total) + # SO still has qty 0, so billed % should be unset + self.assertFalse(so.per_billed) + self.assertEqual(so.status, "To Deliver and Bill") + def automatically_fetch_payment_terms(enable=1): accounts_settings = frappe.get_doc("Accounts Settings") diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json index d6829ce24b0..0553ef44980 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.json +++ b/erpnext/selling/doctype/selling_settings/selling_settings.json @@ -32,7 +32,9 @@ "allow_sales_order_creation_for_expired_quotation", "dont_reserve_sales_order_qty_on_sales_return", "hide_tax_id", - "enable_discount_accounting" + "enable_discount_accounting", + "allow_zero_qty_in_quotation", + "allow_zero_qty_in_sales_order" ], "fields": [ { @@ -200,14 +202,29 @@ "fieldname": "blanket_order_allowance", "fieldtype": "Float", "label": "Blanket Order Allowance (%)" + }, + { + "default": "0", + "description": "Allows users to submit Sales Orders with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_sales_order", + "fieldtype": "Check", + "label": "Allow Sales Order with Zero Quantity" + }, + { + "default": "0", + "description": "Allows users to submit Quotations with zero quantity. Useful when rates are fixed but the quantities are not. Eg. Rate Contracts.", + "fieldname": "allow_zero_qty_in_quotation", + "fieldtype": "Check", + "label": "Allow Quotation with Zero Quantity" } ], + "grid_page_length": 50, "icon": "fa fa-cog", "idx": 1, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2023-10-25 14:03:03.966701", + "modified": "2025-05-06 15:23:14.332971", "modified_by": "Administrator", "module": "Selling", "name": "Selling Settings", @@ -236,4 +253,4 @@ "sort_order": "DESC", "states": [], "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.py b/erpnext/selling/doctype/selling_settings/selling_settings.py index 24fe909a7a6..2954c353a77 100644 --- a/erpnext/selling/doctype/selling_settings/selling_settings.py +++ b/erpnext/selling/doctype/selling_settings/selling_settings.py @@ -23,6 +23,8 @@ class SellingSettings(Document): allow_multiple_items: DF.Check allow_negative_rates_for_items: DF.Check allow_sales_order_creation_for_expired_quotation: DF.Check + allow_zero_qty_in_quotation: DF.Check + allow_zero_qty_in_sales_order: DF.Check blanket_order_allowance: DF.Float cust_master_name: DF.Literal["Customer Name", "Naming Series", "Auto Name"] customer_group: DF.Link | None diff --git a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js index cf775176c07..0ad25b0a473 100644 --- a/erpnext/selling/page/point_of_sale/pos_past_order_summary.js +++ b/erpnext/selling/page/point_of_sale/pos_past_order_summary.js @@ -321,17 +321,25 @@ erpnext.PointOfSale.PastOrderSummary = class { get_condition_btn_map(after_submission) { if (after_submission) - return [{ condition: true, visible_btns: ["Print Receipt", "Email Receipt", "New Order"] }]; + return [ + { + condition: true, + visible_btns: [__("Print Receipt"), __("Email Receipt"), __("New Order")], + }, + ]; return [ - { condition: this.doc.docstatus === 0, visible_btns: ["Edit Order", "Delete Order"] }, + { + condition: this.doc.docstatus === 0, + visible_btns: [__("Edit Order"), __("Delete Order")], + }, { condition: !this.doc.is_return && this.doc.docstatus === 1, - visible_btns: ["Print Receipt", "Email Receipt", "Return"], + visible_btns: [__("Print Receipt"), __("Email Receipt"), __("Return")], }, { condition: this.doc.is_return && this.doc.docstatus === 1, - visible_btns: ["Print Receipt", "Email Receipt"], + visible_btns: [__("Print Receipt"), __("Email Receipt")], }, ]; } diff --git a/erpnext/stock/dashboard_chart/warehouse_wise_stock_value/warehouse_wise_stock_value.json b/erpnext/stock/dashboard_chart/warehouse_wise_stock_value/warehouse_wise_stock_value.json index 400ec5e0e64..b12d2339397 100644 --- a/erpnext/stock/dashboard_chart/warehouse_wise_stock_value/warehouse_wise_stock_value.json +++ b/erpnext/stock/dashboard_chart/warehouse_wise_stock_value/warehouse_wise_stock_value.json @@ -2,14 +2,15 @@ "chart_name": "Warehouse wise Stock Value", "chart_type": "Custom", "creation": "2022-03-30 00:58:02.018824", + "custom_options": "{\n \"fieldtype\": \"Currency\"\n}", "docstatus": 0, "doctype": "Dashboard Chart", "filters_json": "{}", "idx": 0, "is_public": 1, "is_standard": 1, - "last_synced_on": "2024-12-23 18:44:46.822164", - "modified": "2024-12-23 19:31:17.003946", + "last_synced_on": "2025-04-25 11:38:43.644402", + "modified": "2025-04-25 11:39:12.585542", "modified_by": "Administrator", "module": "Stock", "name": "Warehouse wise Stock Value", diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 5f46ef968b0..18d8919a668 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -560,25 +560,23 @@ erpnext.buying.MaterialRequestController = class MaterialRequestController exten onload() { this.frm.set_query("item_code", "items", function (doc, cdt, cdn) { + let filters = { is_stock_item: 1 }; + if (doc.material_request_type == "Customer Provided") { - return { - query: "erpnext.controllers.queries.item_query", - filters: { - customer: doc.customer, - is_stock_item: 1, - }, - }; - } else if (doc.material_request_type == "Purchase") { - return { - query: "erpnext.controllers.queries.item_query", - filters: { is_purchase_item: 1 }, - }; - } else { - return { - query: "erpnext.controllers.queries.item_query", - filters: { is_stock_item: 1 }, - }; + filters.customer = doc.customer; + } else if ( + doc.material_request_type == "Purchase" || + doc.material_request_type == "Subcontracting" + ) { + filters = { is_purchase_item: 1 }; + } else if (doc.material_request_type == "Manufacture") { + filters.include_item_in_manufacturing = 1; } + + return { + query: "erpnext.controllers.queries.item_query", + filters: filters, + }; }); } diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 54b3e17641e..956f47bb978 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -643,6 +643,7 @@ def make_supplier_quotation(source_name, target_doc=None): postprocess, ) + doclist.set_onload("load_after_mapping", False) return doclist diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index 6d4a348ef9d..29481473d7f 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -1106,6 +1106,10 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate if pr_doc.get("is_return") and not total_amount and total_billed_amount: total_amount = total_billed_amount + amount = item.amount + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + amount += flt(item.rejected_qty * item.rate, item.precision("amount")) + if adjust_incoming_rate: adjusted_amt = 0.0 @@ -1120,8 +1124,8 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount")) item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False) - elif item.billed_amt > item.amount: - per_over_billed = (flt(item.billed_amt / item.amount, 2) * 100) - 100 + elif item.billed_amt > amount: + per_over_billed = (flt(item.billed_amt / amount, 2) * 100) - 100 if per_over_billed > over_billing_allowance: frappe.throw( _("Over Billing Allowance exceeded for Purchase Receipt Item {0} ({1}) by {2}%").format( diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index ebf4973b066..0cda4baaadd 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -882,6 +882,12 @@ class update_entries_after: self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt( self.wh_data.valuation_rate ) + + if sle.actual_qty < 0 and self.wh_data.qty_after_transaction != 0: + self.wh_data.valuation_rate = flt( + self.wh_data.stock_value, self.currency_precision + ) / flt(self.wh_data.qty_after_transaction, self.flt_precision) + else: self.update_queue_values(sle) diff --git a/erpnext/utilities/transaction_base.py b/erpnext/utilities/transaction_base.py index 2e4bdac6aab..b7283529772 100644 --- a/erpnext/utilities/transaction_base.py +++ b/erpnext/utilities/transaction_base.py @@ -163,6 +163,9 @@ class TransactionBase(StatusUpdater): child_table_values = set() for row in self.get(child_table): + if default_field == "set_warehouse" and row.get("delivered_by_supplier"): + continue + child_table_values.add(row.get(child_table_field)) if len(child_table_values) > 1: