diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py index 303633ac4bb..4294c4462b1 100644 --- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py @@ -398,7 +398,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"): frappe.get_doc( { "doctype": "Customer", - "customer_group": "All Customer Groups", + "customer_group": "Individual", "customer_type": "Company", "customer_name": "Poore Simon's", } @@ -429,7 +429,7 @@ def add_vouchers(gl_account="_Test Bank - _TC"): frappe.get_doc( { "doctype": "Customer", - "customer_group": "All Customer Groups", + "customer_group": "Individual", "customer_type": "Company", "customer_name": "Fayva", } diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index 378fbded863..bfab823f495 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -209,7 +209,7 @@ def make_customer(customer=None): { "doctype": "Customer", "customer_name": customer_name, - "customer_group": "All Customer Groups", + "customer_group": "Individual", "customer_type": "Company", "territory": "All Territories", } diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 60c8e47f8f0..42fdc5124bf 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -839,7 +839,7 @@ frappe.ui.form.on("Payment Entry", { paid_amount: function (frm) { frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate)); let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency; - if (!frm.doc.received_amount) { + if (frm.doc.paid_amount) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("received_amount", frm.doc.paid_amount); } else if (company_currency == frm.doc.paid_to_account_currency) { @@ -860,7 +860,7 @@ frappe.ui.form.on("Payment Entry", { flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate) ); - if (!frm.doc.paid_amount) { + if (frm.doc.received_amount) { if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) { frm.set_value("paid_amount", frm.doc.received_amount); if (frm.doc.target_exchange_rate) { diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index da6c2eefae4..bec4a8391e3 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -2043,6 +2043,7 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"): customer.customer_name = name customer.default_currency = currency customer.type = "Individual" + customer.customer_group = "Individual" customer.save() customer = customer.name return customer diff --git a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py index 9a33a7ccf6d..dd3a936f220 100644 --- a/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py +++ b/erpnext/accounts/doctype/payment_ledger_entry/test_payment_ledger_entry.py @@ -80,6 +80,7 @@ class TestPaymentLedgerEntry(FrappeTestCase): customer = frappe.new_doc("Customer") customer.customer_name = name customer.type = "Individual" + customer.customer_group = "Individual" customer.save() self.customer = customer.name diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 59c385855fa..b7d8fb44853 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -2546,6 +2546,7 @@ def make_customer(customer_name, currency=None): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.type = "Individual" + customer.customer_group = "Individual" if currency: customer.default_currency = currency diff --git a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js index 43261e4080a..88e5b5216b5 100644 --- a/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js +++ b/erpnext/accounts/doctype/promotional_scheme/promotional_scheme.js @@ -21,10 +21,12 @@ frappe.ui.form.on("Promotional Scheme", { selling: function (frm) { frm.trigger("set_options_for_applicable_for"); + frm.toggle_enable("buying", !frm.doc.selling); }, buying: function (frm) { frm.trigger("set_options_for_applicable_for"); + frm.toggle_enable("selling", !frm.doc.buying); }, set_options_for_applicable_for: function (frm) { diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index ea513e0cfd7..b80eadc7ae0 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -978,6 +978,10 @@ class PurchaseInvoice(BuyingController): if provisional_accounting_for_non_stock_items: self.get_provisional_accounts() + adjust_incoming_rate = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + for item in self.get("items"): if flt(item.base_net_amount) or (self.get("update_stock") and item.valuation_rate): if item.item_code: @@ -1146,7 +1150,11 @@ class PurchaseInvoice(BuyingController): ) # check if the exchange rate has changed - if item.get("purchase_receipt") and self.auto_accounting_for_stock: + if ( + not adjust_incoming_rate + and item.get("purchase_receipt") + and self.auto_accounting_for_stock + ): if ( exchange_rate_map[item.purchase_receipt] and self.conversion_rate != exchange_rate_map[item.purchase_receipt] @@ -1183,6 +1191,7 @@ class PurchaseInvoice(BuyingController): item=item, ) ) + if ( self.auto_accounting_for_stock and self.is_opening == "No" diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index f8e12eda182..bccd29c822a 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -356,6 +356,12 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): make_purchase_invoice as create_purchase_invoice, ) + original_value = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + + frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 0) + pr = make_purchase_receipt( company="_Test Company with perpetual inventory", warehouse="Stores - TCP1", @@ -376,12 +382,17 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin): amount = frappe.db.get_value( "GL Entry", {"account": exchange_gain_loss_account, "voucher_no": pi.name}, "debit" ) + discrepancy_caused_by_exchange_rate_diff = abs( pi.items[0].base_net_amount - pr.items[0].base_net_amount ) self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) + frappe.db.set_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value + ) + def test_purchase_invoice_with_exchange_rate_difference_for_non_stock_item(self): from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( make_purchase_invoice as create_purchase_invoice, diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json index f207b2079ab..a5abf3dd32f 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.json +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.json @@ -777,8 +777,7 @@ }, { "collapsible": 1, - "collapsible_depends_on": "eval:doc.total_billing_amount > 0", - "depends_on": "eval:!doc.is_return", + "collapsible_depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0", "fieldname": "time_sheet_list", "fieldtype": "Section Break", "hide_border": 1, @@ -792,7 +791,6 @@ "hide_days": 1, "hide_seconds": 1, "label": "Time Sheets", - "no_copy": 1, "options": "Sales Invoice Timesheet", "print_hide": 1 }, @@ -2112,7 +2110,7 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval:(!doc.is_return && doc.total_billing_amount > 0)", + "depends_on": "eval:doc.total_billing_amount > 0 || doc.total_billing_hours > 0", "fieldname": "section_break_104", "fieldtype": "Section Break" }, @@ -2200,7 +2198,7 @@ "link_fieldname": "consolidated_invoice" } ], - "modified": "2026-02-05 20:43:44.732805", + "modified": "2026-04-06 22:30:28.513139", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice", diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 6bc24633ca9..3e16c503e69 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -323,10 +323,22 @@ class SalesInvoice(SellingController): ) self.set_against_income_account() - self.validate_time_sheets_are_submitted() + + if self.is_return and not self.return_against and self.timesheets: + frappe.throw(_("Direct return is not allowed for Timesheet.")) + + if not self.is_return: + self.validate_time_sheets_are_submitted() + self.validate_multiple_billing("Delivery Note", "dn_detail", "amount") - if self.is_return: - self.timesheets = [] + + if self.is_return and self.return_against: + for row in self.timesheets: + if row.billing_hours: + row.billing_hours = -abs(row.billing_hours) + if row.billing_amount: + row.billing_amount = -abs(row.billing_amount) + self.update_packing_list() self.set_billing_hours_and_amount() self.update_timesheet_billing_for_project() @@ -494,7 +506,7 @@ class SalesInvoice(SellingController): if not cint(self.is_pos) == 1 and not self.is_return: self.update_against_document_in_jv() - self.update_time_sheet(self.name) + self.update_time_sheet(None if (self.is_return and self.return_against) else self.name) if frappe.db.get_single_value("Selling Settings", "sales_update_frequency") == "Each Transaction": update_company_current_month_sales(self.company) @@ -550,7 +562,7 @@ class SalesInvoice(SellingController): self.check_if_consolidated_invoice() super().before_cancel() - self.update_time_sheet(None) + self.update_time_sheet(self.return_against if (self.is_return and self.return_against) else None) def on_cancel(self): check_if_return_invoice_linked_with_payment_entry(self) @@ -735,8 +747,20 @@ class SalesInvoice(SellingController): for data in timesheet.time_logs: if ( (self.project and args.timesheet_detail == data.name) - or (not self.project and not data.sales_invoice) - or (not sales_invoice and data.sales_invoice == self.name) + or (not self.project and not data.sales_invoice and args.timesheet_detail == data.name) + or ( + not sales_invoice + and data.sales_invoice == self.name + and args.timesheet_detail == data.name + ) + or ( + self.is_return + and self.return_against + and data.sales_invoice + and data.sales_invoice == self.return_against + and not sales_invoice + and args.timesheet_detail == data.name + ) ): data.sales_invoice = sales_invoice @@ -776,11 +800,25 @@ class SalesInvoice(SellingController): payment.account = get_bank_cash_account(payment.mode_of_payment, self.company).get("account") def validate_time_sheets_are_submitted(self): + # Note: This validation is skipped for return invoices + # to allow returns to reference already-billed timesheet details for data in self.timesheets: + # Handle invoice duplication + if data.time_sheet and data.timesheet_detail: + if sales_invoice := frappe.db.get_value( + "Timesheet Detail", data.timesheet_detail, "sales_invoice" + ): + frappe.throw( + _("Row {0}: Sales Invoice {1} is already created for {2}").format( + data.idx, frappe.bold(sales_invoice), frappe.bold(data.time_sheet) + ) + ) if data.time_sheet: status = frappe.db.get_value("Timesheet", data.time_sheet, "status") - if status not in ["Submitted", "Payslip"]: - frappe.throw(_("Timesheet {0} is already completed or cancelled").format(data.time_sheet)) + if status not in ["Submitted", "Payslip", "Partially Billed"]: + frappe.throw( + _("Timesheet {0} cannot be invoiced in its current state").format(data.time_sheet) + ) def set_pos_fields(self, for_validate=False): """Set retail related fields from POS Profiles""" @@ -1112,7 +1150,12 @@ class SalesInvoice(SellingController): timesheet.billing_amount = ts_doc.total_billable_amount def update_timesheet_billing_for_project(self): - if not self.timesheets and self.project and self.is_auto_fetch_timesheet_enabled(): + if ( + not self.is_return + and not self.timesheets + and self.project + and self.is_auto_fetch_timesheet_enabled() + ): self.add_timesheet_data() else: self.calculate_billing_amount_for_timesheet() diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index c515322348a..a8fb4ab93a0 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -3230,7 +3230,7 @@ class TestSalesInvoice(FrappeTestCase): calculate_depreciation=1, submit=1, ) - post_depreciation_entries() + post_depreciation_entries(date="2025-04-01") si = create_sales_invoice( item_code="Macbook Pro", asset=asset.name, qty=1, rate=10000, posting_date=getdate("2025-05-01") diff --git a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json index 69b7c129f09..f959054f4a2 100644 --- a/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json +++ b/erpnext/accounts/doctype/sales_invoice_timesheet/sales_invoice_timesheet.json @@ -52,7 +52,6 @@ "fieldtype": "Data", "hidden": 1, "label": "Timesheet Detail", - "no_copy": 1, "print_hide": 1, "read_only": 1 }, @@ -117,7 +116,7 @@ ], "istable": 1, "links": [], - "modified": "2021-10-02 03:48:44.979777", + "modified": "2026-04-06 22:30:28.513139", "modified_by": "Administrator", "module": "Accounts", "name": "Sales Invoice Timesheet", diff --git a/erpnext/accounts/doctype/subscription/test_subscription.py b/erpnext/accounts/doctype/subscription/test_subscription.py index 1d906fb9276..aba51ac5c6a 100644 --- a/erpnext/accounts/doctype/subscription/test_subscription.py +++ b/erpnext/accounts/doctype/subscription/test_subscription.py @@ -629,18 +629,21 @@ def create_parties(): customer.customer_name = "_Test Subscription Customer" customer.default_currency = "USD" customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}) + customer.customer_group = "Individual" customer.insert() if not frappe.db.exists("Customer", "_Test Subscription Customer Multi Currency"): customer = frappe.new_doc("Customer") customer.customer_name = "Test Subscription Customer Multi Currency" customer.default_currency = "USD" + customer.customer_group = "Individual" customer.insert() if not frappe.db.exists("Customer", "_Test Subscription Customer John Doe"): customer = frappe.new_doc("Customer") customer.customer_name = "_Test Subscription Customer John Doe" customer.append("accounts", {"company": "_Test Company", "account": "_Test Receivable - _TC"}) + customer.customer_group = "Individual" customer.insert() diff --git a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py index 4dfcd3bf259..88a3b818196 100644 --- a/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/test_accounts_receivable.py @@ -779,6 +779,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): "customer_name": "Jane Doe", "type": "Individual", "default_currency": "USD", + "customer_group": "Individual", } ) .insert() @@ -1002,6 +1003,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase): "customer_name": "Jane Doe", "type": "Individual", "default_currency": "USD", + "customer_group": "Individual", } ) .insert() diff --git a/erpnext/accounts/report/gross_profit/test_gross_profit.py b/erpnext/accounts/report/gross_profit/test_gross_profit.py index 35f24df015f..9a0a9cc5174 100644 --- a/erpnext/accounts/report/gross_profit/test_gross_profit.py +++ b/erpnext/accounts/report/gross_profit/test_gross_profit.py @@ -82,6 +82,7 @@ class TestGrossProfit(FrappeTestCase): customer = frappe.new_doc("Customer") customer.customer_name = name customer.type = "Individual" + customer.customer_group = "Individual" customer.save() self.customer = customer.name diff --git a/erpnext/accounts/test/accounts_mixin.py b/erpnext/accounts/test/accounts_mixin.py index 3cad657553e..2bce9b46835 100644 --- a/erpnext/accounts/test/accounts_mixin.py +++ b/erpnext/accounts/test/accounts_mixin.py @@ -12,6 +12,7 @@ class AccountsTestMixin: customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.type = "Individual" + customer.customer_group = "Individual" if currency: customer.default_currency = currency @@ -36,6 +37,7 @@ class AccountsTestMixin: "account": default_account, }, ) + customer.customer_group = "Individual" customer.save() self.customer = customer_name diff --git a/erpnext/accounts/test_party.py b/erpnext/accounts/test_party.py index 9d3de5e8282..8b4a52603bf 100644 --- a/erpnext/accounts/test_party.py +++ b/erpnext/accounts/test_party.py @@ -7,12 +7,8 @@ from erpnext.accounts.party import get_default_price_list class PartyTestCase(FrappeTestCase): def test_get_default_price_list_should_return_none_for_invalid_group(self): customer = frappe.get_doc( - { - "doctype": "Customer", - "customer_name": "test customer", - } + {"doctype": "Customer", "customer_name": "test customer", "customer_group": "Individual"} ).insert(ignore_permissions=True, ignore_mandatory=True) - customer.customer_group = None customer.save() price_list = get_default_price_list(customer) assert price_list is None diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py index ae7898f9a07..c4394c066e0 100644 --- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py +++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py @@ -289,6 +289,30 @@ class TestPurchaseOrder(FrappeTestCase): # ordered qty should decrease (back to initial) on row deletion self.assertEqual(get_ordered_qty(), existing_ordered_qty) + def test_discount_amount_partial_purchase_receipt(self): + po = create_purchase_order(qty=4, rate=100, do_not_save=1) + po.apply_discount_on = "Grand Total" + po.discount_amount = 120 + po.save() + po.submit() + + self.assertEqual(po.grand_total, 280) + + pr1 = make_purchase_receipt(po.name) + pr1.items[0].qty = 3 + pr1.save() + pr1.submit() + + self.assertEqual(pr1.discount_amount, 120) + self.assertEqual(pr1.grand_total, 180) + + pr2 = make_purchase_receipt(po.name) + pr2.save() + pr2.submit() + + self.assertEqual(pr2.discount_amount, 0) + self.assertEqual(pr2.grand_total, 100) + def test_update_child_perm(self): po = create_purchase_order(item_code="_Test Item", qty=4) diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py index e4475c7ee38..04983721a64 100644 --- a/erpnext/buying/doctype/supplier/test_supplier.py +++ b/erpnext/buying/doctype/supplier/test_supplier.py @@ -175,6 +175,15 @@ def create_supplier(**args): if not args.without_supplier_group: doc.supplier_group = args.supplier_group or "Services" + if args.get("party_account"): + doc.append( + "accounts", + { + "company": frappe.db.get_value("Account", args.get("party_account"), "company"), + "account": args.get("party_account"), + }, + ) + doc.insert() return doc diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index dea76428d90..eedb0ce6eba 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -364,7 +364,7 @@ class BuyingController(SubcontractingController): get_conversion_factor(item.item_code, item.uom).get("conversion_factor") or 1.0 ) - net_rate = item.base_net_amount + net_rate = item.qty * item.base_net_rate if item.sales_incoming_rate: # for internal transfer net_rate = item.qty * item.sales_incoming_rate diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 337ffbfeb0c..e75dd3dacd2 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -183,6 +183,9 @@ class calculate_taxes_and_totals: return if not self.discount_amount_applied: + bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) for item in self.doc.items: self.doc.round_floats_in(item) @@ -238,7 +241,13 @@ class calculate_taxes_and_totals: elif not item.qty and self.doc.get("is_debit_note"): item.amount = flt(item.rate, item.precision("amount")) else: - item.amount = flt(item.rate * item.qty, item.precision("amount")) + qty = ( + (item.qty + item.rejected_qty) + if bill_for_rejected_quantity_in_purchase_invoice + and self.doc.doctype == "Purchase Receipt" + else item.qty + ) + item.amount = flt(item.rate * qty, item.precision("amount")) item.net_amount = item.amount @@ -370,9 +379,16 @@ class calculate_taxes_and_totals: self.doc.total ) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0 + bill_for_rejected_quantity_in_purchase_invoice = frappe.get_single_value( + "Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice" + ) for item in self._items: self.doc.total += item.amount - self.doc.total_qty += item.qty + self.doc.total_qty += ( + (item.qty + item.rejected_qty) + if bill_for_rejected_quantity_in_purchase_invoice and self.doc.doctype == "Purchase Receipt" + else item.qty + ) self.doc.base_total += item.base_amount self.doc.net_total += item.net_amount self.doc.base_net_total += item.base_net_amount @@ -724,7 +740,8 @@ class calculate_taxes_and_totals: discount_amount += total_return_discount # validate that discount amount cannot exceed the total before discount - if ( + # only during save (i.e. when `_action` is set) + if self.doc.get("_action") and ( (grand_total >= 0 and discount_amount > grand_total) or (grand_total < 0 and discount_amount < grand_total) # returns ): diff --git a/erpnext/controllers/tests/test_accounts_controller.py b/erpnext/controllers/tests/test_accounts_controller.py index a120da6f852..3ecf3d8b0af 100644 --- a/erpnext/controllers/tests/test_accounts_controller.py +++ b/erpnext/controllers/tests/test_accounts_controller.py @@ -29,6 +29,7 @@ def make_customer(customer_name, currency=None): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.customer_type = "Individual" + customer.customer_group = "Individual" if currency: customer.default_currency = currency diff --git a/erpnext/controllers/tests/test_qty_based_taxes.py b/erpnext/controllers/tests/test_qty_based_taxes.py index e7896b57f23..6233f6af9f4 100644 --- a/erpnext/controllers/tests/test_qty_based_taxes.py +++ b/erpnext/controllers/tests/test_qty_based_taxes.py @@ -66,7 +66,7 @@ class TestTaxes(unittest.TestCase): { "doctype": "Customer", "customer_name": uuid4(), - "customer_group": "All Customer Groups", + "customer_group": "Individual", } ).insert() self.supplier = frappe.get_doc( diff --git a/erpnext/crm/doctype/opportunity/test_opportunity.py b/erpnext/crm/doctype/opportunity/test_opportunity.py index 6ec3ca4a6c1..f346946568e 100644 --- a/erpnext/crm/doctype/opportunity/test_opportunity.py +++ b/erpnext/crm/doctype/opportunity/test_opportunity.py @@ -35,7 +35,9 @@ class TestOpportunity(unittest.TestCase): self.assertEqual(frappe.db.get_value("Lead", opp_doc.party_name, "email_id"), opp_doc.contact_email) # create new customer and create new contact against 'new.opportunity@example.com' - customer = make_customer(opp_doc.party_name).insert(ignore_permissions=True) + customer = make_customer(opp_doc.party_name) + customer.customer_group = "Individual" + customer.insert(ignore_permissions=True) contact = frappe.get_doc( { "doctype": "Contact", diff --git a/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py index bf3f946d6ab..7622c38f94c 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/test_sales_pipeline_analytics.py @@ -196,6 +196,7 @@ def create_customer(): if not doc: doc = frappe.new_doc("Customer") doc.customer_name = "_Test NC" + doc.customer_group = "Individual" doc.insert() diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 525b6aecee7..ed1628ded5f 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -264,6 +264,7 @@ frappe.ui.form.on("BOM", { reqd: 1, default: 1, onchange: () => { + if (!cur_dialog) return; const { quantity, items: rm } = frm.doc; const variant_items_map = rm.reduce((acc, item) => { acc[item.item_code] = item.qty; diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index 277200af310..64a1c870ce4 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -15,6 +15,7 @@ "bom_section", "update_bom_costs_automatically", "column_break_lhyt", + "allow_editing_of_items_and_quantities_in_work_order", "section_break_6", "default_wip_warehouse", "default_fg_warehouse", @@ -243,13 +244,20 @@ "fieldname": "enforce_time_logs", "fieldtype": "Check", "label": "Enforce Time Logs" + }, + { + "default": "0", + "description": "If enabled, the system will allow users to edit the raw materials and their quantities in the Work Order. The system will not reset the quantities as per the BOM, if the user has changed them.", + "fieldname": "allow_editing_of_items_and_quantities_in_work_order", + "fieldtype": "Check", + "label": "Allow Editing of Items and Quantities in Work Order" } ], "icon": "icon-wrench", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-05-16 11:23:16.916512", + "modified": "2025-11-07 14:52:56.241459", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py index e9f011f78ba..44d42cccae0 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.py @@ -18,6 +18,7 @@ class ManufacturingSettings(Document): from frappe.types import DF add_corrective_operation_cost_in_finished_good_valuation: DF.Check + allow_editing_of_items_and_quantities_in_work_order: DF.Check allow_overtime: DF.Check allow_production_on_holidays: DF.Check backflush_raw_materials_based_on: DF.Literal["BOM", "Material Transferred for Manufacture"] diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index c23db9aa682..374caf369ea 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -4,7 +4,7 @@ import frappe from frappe.tests.utils import FrappeTestCase, change_settings, timeout -from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, today +from frappe.utils import add_days, add_months, add_to_date, cint, flt, now, nowdate, nowtime, today from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError from erpnext.manufacturing.doctype.job_card.job_card import make_stock_entry as make_stock_entry_from_jc @@ -2395,7 +2395,7 @@ class TestWorkOrder(FrappeTestCase): stock_entry.submit() - def test_disassembly_order_with_qty_behavior(self): + def test_disassembly_order_with_qty_from_wo_behavior(self): # Create raw material and FG item raw_item = make_item("Test Raw for Disassembly", {"is_stock_item": 1}).name fg_item = make_item("Test FG for Disassembly", {"is_stock_item": 1}).name @@ -2435,27 +2435,9 @@ class TestWorkOrder(FrappeTestCase): se_for_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty)) se_for_manufacture.submit() - # Simulate a disassembly stock entry + # Disassembly via WO required_items path (no source_stock_entry) disassemble_qty = 4 stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) - stock_entry.append( - "items", - { - "item_code": fg_item, - "qty": disassemble_qty, - "s_warehouse": wo.fg_warehouse, - }, - ) - - for bom_item in bom.items: - stock_entry.append( - "items", - { - "item_code": bom_item.item_code, - "qty": (bom_item.qty / bom.quantity) * disassemble_qty, - "t_warehouse": wo.source_warehouse, - }, - ) wo.reload() stock_entry.save() @@ -2470,7 +2452,7 @@ class TestWorkOrder(FrappeTestCase): f"Expected FG qty {disassemble_qty}, found {finished_good_entry.qty}", ) - # Assert raw materials + # Assert raw materials - qty scaled from WO required_items for item in stock_entry.items: if item.item_code == fg_item: continue @@ -2494,10 +2476,35 @@ class TestWorkOrder(FrappeTestCase): f"Work Order disassembled_qty mismatch: expected {disassemble_qty}, got {wo.disassembled_qty}", ) + # Second disassembly: explicitly linked to manufacture SE — verifies SE-linked path + # (first disassembly auto-set source_stock_entry since there's only one manufacture entry) + disassemble_qty_2 = 2 + stock_entry_2 = frappe.get_doc( + make_stock_entry( + wo.name, "Disassemble", disassemble_qty_2, source_stock_entry=se_for_manufacture.name + ) + ) + stock_entry_2.save() + stock_entry_2.submit() + + # All rows must trace back to se_for_manufacture + for item in stock_entry_2.items: + self.assertEqual(item.against_stock_entry, se_for_manufacture.name) + self.assertTrue(item.ste_detail) + + # RM qty scaled from the manufacture SE rows + rm_row = next((i for i in stock_entry_2.items if i.item_code == raw_item), None) + expected_rm_qty = (bom.items[0].qty / bom.quantity) * disassemble_qty_2 + self.assertAlmostEqual(rm_row.qty, expected_rm_qty, places=3) + + wo.reload() + self.assertEqual(wo.disassembled_qty, disassemble_qty + disassemble_qty_2) + def test_disassembly_with_multiple_manufacture_entries(self): """ Test that disassembly does not create duplicate items when manufacturing - is done in multiple batches (multiple manufacture stock entries). + is done in multiple batches (multiple manufacture stock entries), including + secondary/scrap items. Scenario: 1. Create Work Order for 10 units @@ -2506,11 +2513,17 @@ class TestWorkOrder(FrappeTestCase): 4. Create Disassembly for 4 units 5. Verify no duplicate items in the disassembly stock entry """ - # Create RM and FG item + # Create RM, scrap and FG item raw_item1 = make_item("Test Raw for Multi Batch Disassembly 1", {"is_stock_item": 1}).name raw_item2 = make_item("Test Raw for Multi Batch Disassembly 2", {"is_stock_item": 1}).name + scrap_item = make_item("Test Scrap for Multi Batch Disassembly", {"is_stock_item": 1}).name fg_item = make_item("Test FG for Multi Batch Disassembly", {"is_stock_item": 1}).name - bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2) + bom = make_bom( + item=fg_item, quantity=1, raw_materials=[raw_item1, raw_item2], rm_qty=2, do_not_submit=True + ) + # add scrap item + bom.append("scrap_items", {"item_code": scrap_item, "stock_qty": 10}) + bom.submit() # Create WO wo = make_wo_order_test_record(production_item=fg_item, qty=10, bom_no=bom.name, status="Not Started") @@ -2585,7 +2598,7 @@ class TestWorkOrder(FrappeTestCase): f"Found duplicate items in disassembly stock entry: {duplicates}", ) - expected_items = 3 # FG item + 2 raw materials + expected_items = 4 # FG item + 2 raw materials + 1 scrap item self.assertEqual( len(stock_entry.items), expected_items, @@ -2596,6 +2609,16 @@ class TestWorkOrder(FrappeTestCase): fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) self.assertEqual(fg_item_row.qty, disassemble_qty) + # Scrap item: should be taken from scrap warehouse in disassembly + scrap_row = next((i for i in stock_entry.items if i.item_code == scrap_item), None) + self.assertIsNotNone(scrap_row) + self.assertEqual(scrap_row.is_scrap_item, 1) + self.assertTrue(scrap_row.s_warehouse) + self.assertFalse(scrap_row.t_warehouse) + self.assertEqual(scrap_row.s_warehouse, wo.scrap_warehouse) + # BOM has scrap_qty=10/FG, total produced = 10*10 = 100, disassemble 4/10 → 40 + self.assertEqual(scrap_row.qty, 40) + # RM quantities for bom_item in bom.items: expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty @@ -2607,19 +2630,57 @@ class TestWorkOrder(FrappeTestCase): msg=f"Raw material {bom_item.item_code} qty mismatch", ) + # -- BOM-path disassembly (no source_stock_entry, no work_order) -- + + make_stock_entry_test_record( + item_code=scrap_item, + purpose="Material Receipt", + target=wo.fg_warehouse, + qty=50, + basic_rate=10, + ) + + bom_disassemble_qty = 2 + bom_se = frappe.get_doc( + { + "doctype": "Stock Entry", + "stock_entry_type": "Disassemble", + "purpose": "Disassemble", + "from_bom": 1, + "bom_no": bom.name, + "fg_completed_qty": bom_disassemble_qty, + "from_warehouse": wo.fg_warehouse, + "to_warehouse": wo.wip_warehouse, + "company": wo.company, + "posting_date": nowdate(), + "posting_time": nowtime(), + } + ) + bom_se.get_items() + bom_se.save() + bom_se.submit() + + bom_scrap_row = next((i for i in bom_se.items if i.item_code == scrap_item), None) + self.assertIsNotNone(bom_scrap_row, "Scrap item must appear in BOM-path disassembly") + # v15: BOM scrap_qty=10/FG, no process_loss_per field → qty = 10 * 2 = 20 + self.assertEqual( + bom_scrap_row.qty, + 20, + f"BOM-path disassembly scrap qty mismatch; expected 20, got {bom_scrap_row.qty}", + ) + def test_disassembly_with_additional_rm_not_in_bom(self): """ - Test that disassembly correctly handles additional raw materials that were - manually added during manufacturing (not part of the BOM). + Test that SE-linked disassembly includes additional raw materials + that were manually added during manufacturing (not part of the BOM). Scenario: 1. Create Work Order for 10 units with 2 raw materials in BOM 2. Transfer raw materials for manufacture 3. Manufacture in 2 parts (3 units, then 7 units) 4. In each manufacture entry, manually add an extra consumable item - (not in BOM) in proportion to the manufactured qty - 5. Create Disassembly for 4 units - 6. Verify that the additional RM is included in disassembly with proportional qty + 5. Disassemble 3 units linked to first manufacture entry + 6. Verify additional RM is included with correct proportional qty from SE1 """ from erpnext.stock.doctype.stock_entry.test_stock_entry import ( make_stock_entry as make_stock_entry_test_record, @@ -2655,9 +2716,8 @@ class TestWorkOrder(FrappeTestCase): se_for_material_transfer.save() se_for_material_transfer.submit() - # First Manufacture Entry - 3 units + # First Manufacture Entry - 3 units with additional RM se_manufacture1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) - # Additional RM se_manufacture1.append( "items", { @@ -2670,9 +2730,8 @@ class TestWorkOrder(FrappeTestCase): se_manufacture1.save() se_manufacture1.submit() - # Second Manufacture Entry - 7 units + # Second Manufacture Entry - 7 units with additional RM se_manufacture2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 7)) - # AAdditional RM se_manufacture2.append( "items", { @@ -2688,13 +2747,15 @@ class TestWorkOrder(FrappeTestCase): wo.reload() self.assertEqual(wo.produced_qty, 10) - # Disassembly for 4 units - disassemble_qty = 4 - stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", disassemble_qty)) + # Disassemble 3 units linked to first manufacture entry + disassemble_qty = 3 + stock_entry = frappe.get_doc( + make_stock_entry(wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture1.name) + ) stock_entry.save() stock_entry.submit() - # No duplicate + # No duplicates item_counts = {} for item in stock_entry.items: item_code = item.item_code @@ -2707,16 +2768,15 @@ class TestWorkOrder(FrappeTestCase): f"Found duplicate items in disassembly stock entry: {duplicates}", ) - # Additional RM qty + # Additional RM should be included — qty proportional to SE1 (3 units -> 3 additional RM) additional_rm_row = next((i for i in stock_entry.items if i.item_code == additional_rm), None) self.assertIsNotNone( additional_rm_row, f"Additional raw material {additional_rm} not found in disassembly", ) - # intentional full reversal as not part of BOM - # eg: dies or consumables used during manufacturing - expected_additional_rm_qty = 3 + 7 + # SE1 had 3 additional RM for 3 manufactured units, disassembling all 3 + expected_additional_rm_qty = 3 self.assertAlmostEqual( additional_rm_row.qty, expected_additional_rm_qty, @@ -2724,7 +2784,7 @@ class TestWorkOrder(FrappeTestCase): msg=f"Additional RM qty mismatch: expected {expected_additional_rm_qty}, got {additional_rm_row.qty}", ) - # RM qty + # BOM RM qty — scaled from SE1's rows for bom_item in bom.items: expected_qty = (bom_item.qty / bom.quantity) * disassemble_qty rm_row = next((i for i in stock_entry.items if i.item_code == bom_item.item_code), None) @@ -2740,6 +2800,7 @@ class TestWorkOrder(FrappeTestCase): fg_item_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) self.assertEqual(fg_item_row.qty, disassemble_qty) + # FG + 2 BOM RM + 1 additional RM = 4 items expected_items = 4 self.assertEqual( len(stock_entry.items), @@ -2747,6 +2808,282 @@ class TestWorkOrder(FrappeTestCase): f"Expected {expected_items} items, found {len(stock_entry.items)}", ) + # Verify traceability + for item in stock_entry.items: + self.assertEqual(item.against_stock_entry, se_manufacture1.name) + self.assertTrue(item.ste_detail) + + def test_disassembly_auto_sets_source_stock_entry(self): + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + raw_item = make_item("Test Raw Auto Set Disassembly", {"is_stock_item": 1}).name + fg_item = make_item("Test FG Auto Set Disassembly", {"is_stock_item": 1}).name + bom = make_bom(item=fg_item, quantity=1, raw_materials=[raw_item], rm_qty=2) + + wo = make_wo_order_test_record(production_item=fg_item, qty=5, bom_no=bom.name, status="Not Started") + + make_stock_entry_test_record( + item_code=raw_item, purpose="Material Receipt", target=wo.wip_warehouse, qty=50, basic_rate=100 + ) + + se_transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", wo.qty)) + for item in se_transfer.items: + item.s_warehouse = wo.wip_warehouse + se_transfer.save() + se_transfer.submit() + + se_manufacture = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", wo.qty)) + se_manufacture.submit() + + # Disassemble without specifying source_stock_entry + stock_entry = frappe.get_doc(make_stock_entry(wo.name, "Disassemble", 3)) + stock_entry.save() + + # source_stock_entry should be auto-set since only one manufacture entry + self.assertEqual(stock_entry.source_stock_entry, se_manufacture.name) + + # All items should have against_stock_entry linked + for item in stock_entry.items: + self.assertEqual(item.against_stock_entry, se_manufacture.name) + self.assertTrue(item.ste_detail) + + stock_entry.submit() + + def test_disassembly_batch_tracked_items(self): + from erpnext.stock.doctype.batch.batch import make_batch + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + wip_wh = "_Test Warehouse - _TC" + + rm_item = make_item( + "Test Batch RM for Disassembly SB", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBRD-RM-.###", + }, + ).name + fg_item = make_item( + "Test Batch FG for Disassembly SB", + { + "is_stock_item": 1, + "has_batch_no": 1, + "create_new_batch": 1, + "batch_number_series": "TBRD-FG-.###", + }, + ).name + + bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2) + wo = make_wo_order_test_record( + production_item=fg_item, + qty=6, + bom_no=bom.name, + skip_transfer=1, + source_warehouse=wip_wh, + status="Not Started", + ) + + # Two separate RM receipts → two distinct batches (batch_1, batch_2) + rm_receipt_1 = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 + ) + rm_batch_1 = get_batch_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_1.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) + ) + + rm_receipt_2 = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 + ) + rm_batch_2 = get_batch_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_2.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) + ) + + self.assertNotEqual(rm_batch_1, rm_batch_2, "Two receipts must create two distinct RM batches") + + fg_batch_1 = make_batch(frappe._dict(item=fg_item)) + fg_batch_2 = make_batch(frappe._dict(item=fg_item)) + + # Manufacture entry 1 — 3 FG using batch_1 RM/FG + se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_1.items: + if row.item_code == rm_item: + row.batch_no = rm_batch_1 + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.batch_no = fg_batch_1 + row.use_serial_batch_fields = 1 + se_manufacture_1.save() + se_manufacture_1.submit() + + # Manufacture entry 2 — 3 FG using batch_2 RM/FG + se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_2.items: + if row.item_code == rm_item: + row.batch_no = rm_batch_2 + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.batch_no = fg_batch_2 + row.use_serial_batch_fields = 1 + se_manufacture_2.save() + se_manufacture_2.submit() + + # Disassemble 2 units from SE_1 only — must use SE_1's batches, not SE_2's + disassemble_qty = 2 + stock_entry = frappe.get_doc( + make_stock_entry( + wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name + ) + ) + stock_entry.save() + stock_entry.submit() + + # FG row: must use fg_batch_1 exclusively (fg_batch_2 must not appear) + fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) + self.assertIsNotNone(fg_row) + self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle") + self.assertEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_1) + self.assertNotEqual(get_batch_from_bundle(fg_row.serial_and_batch_bundle), fg_batch_2) + + # RM row: must use rm_batch_1 exclusively (rm_batch_2 must not appear) + rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None) + self.assertIsNotNone(rm_row) + self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle") + self.assertEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_1) + self.assertNotEqual(get_batch_from_bundle(rm_row.serial_and_batch_bundle), rm_batch_2) + + # RM qty: 2 FG disassembled x 2 RM per FG = 4 + self.assertAlmostEqual(rm_row.qty, 4.0, places=3) + + def test_disassembly_serial_tracked_items(self): + from frappe.model.naming import make_autoname + + from erpnext.stock.doctype.stock_entry.test_stock_entry import ( + make_stock_entry as make_stock_entry_test_record, + ) + + wip_wh = "_Test Warehouse - _TC" + + rm_item = make_item( + "Test Serial RM for Disassembly SB", + {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-RM-.####"}, + ).name + fg_item = make_item( + "Test Serial FG for Disassembly SB", + {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TSRD-FG-.####"}, + ).name + + bom = make_bom(item=fg_item, quantity=1, raw_materials=[rm_item], rm_qty=2) + wo = make_wo_order_test_record( + production_item=fg_item, + qty=6, + bom_no=bom.name, + skip_transfer=1, + source_warehouse=wip_wh, + status="Not Started", + ) + + # Two separate RM receipts → two disjoint sets of serial numbers + rm_receipt_1 = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 + ) + rm_serials_1 = get_serial_nos_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_1.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) + ) + self.assertEqual(len(rm_serials_1), 6) + + rm_receipt_2 = make_stock_entry_test_record( + item_code=rm_item, purpose="Material Receipt", target=wip_wh, qty=6, basic_rate=100 + ) + rm_serials_2 = get_serial_nos_from_bundle( + frappe.db.get_value( + "Stock Entry Detail", + {"parent": rm_receipt_2.name, "item_code": rm_item}, + "serial_and_batch_bundle", + ) + ) + self.assertEqual(len(rm_serials_2), 6) + self.assertFalse( + set(rm_serials_1) & set(rm_serials_2), "Two receipts must produce disjoint RM serial sets" + ) + + # Pre-generate two sets of FG serial numbers + series = frappe.db.get_value("Item", fg_item, "serial_no_series") + fg_serials_1 = [make_autoname(series) for _ in range(3)] + fg_serials_2 = [make_autoname(series) for _ in range(3)] + + # Manufacture entry 1 — consumes rm_serials_1, produces fg_serials_1 + se_manufacture_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_1.items: + if row.item_code == rm_item: + row.serial_no = "\n".join(rm_serials_1) + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.serial_no = "\n".join(fg_serials_1) + row.use_serial_batch_fields = 1 + se_manufacture_1.save() + se_manufacture_1.submit() + + # Manufacture entry 2 — consumes rm_serials_2, produces fg_serials_2 + se_manufacture_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 3)) + for row in se_manufacture_2.items: + if row.item_code == rm_item: + row.serial_no = "\n".join(rm_serials_2) + row.use_serial_batch_fields = 1 + elif row.item_code == fg_item: + row.serial_no = "\n".join(fg_serials_2) + row.use_serial_batch_fields = 1 + se_manufacture_2.save() + se_manufacture_2.submit() + + # Disassemble 2 units from SE_1 only — must use SE_1's serials, not SE_2's + disassemble_qty = 2 + stock_entry = frappe.get_doc( + make_stock_entry( + wo.name, "Disassemble", disassemble_qty, source_stock_entry=se_manufacture_1.name + ) + ) + stock_entry.save() + stock_entry.submit() + + # FG row: 2 serials consumed — must be subset of fg_serials_1, disjoint from fg_serials_2 + fg_row = next((i for i in stock_entry.items if i.item_code == fg_item), None) + self.assertIsNotNone(fg_row) + self.assertTrue(fg_row.serial_and_batch_bundle, "FG row must have a serial_and_batch_bundle") + fg_dasm_serials = get_serial_nos_from_bundle(fg_row.serial_and_batch_bundle) + self.assertEqual(len(fg_dasm_serials), disassemble_qty) + self.assertTrue(set(fg_dasm_serials).issubset(set(fg_serials_1))) + self.assertFalse( + set(fg_dasm_serials) & set(fg_serials_2), "Disassembly must not use SE_2's FG serials" + ) + + # RM row: 4 serials returned (2 FG x 2 RM each) — subset of rm_serials_1, disjoint from rm_serials_2 + rm_row = next((i for i in stock_entry.items if i.item_code == rm_item), None) + self.assertIsNotNone(rm_row) + self.assertTrue(rm_row.serial_and_batch_bundle, "RM row must have a serial_and_batch_bundle") + rm_dasm_serials = get_serial_nos_from_bundle(rm_row.serial_and_batch_bundle) + self.assertEqual(len(rm_dasm_serials), disassemble_qty * 2) + self.assertTrue(set(rm_dasm_serials).issubset(set(rm_serials_1))) + self.assertFalse( + set(rm_dasm_serials) & set(rm_serials_2), "Disassembly must not use SE_2's RM serials" + ) + def test_components_alternate_item_for_bom_based_manufacture_entry(self): frappe.db.set_single_value("Manufacturing Settings", "backflush_raw_materials_based_on", "BOM") frappe.db.set_single_value("Manufacturing Settings", "validate_components_quantities_per_bom", 1) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index b6206cefcbb..6e20e789899 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -243,6 +243,20 @@ frappe.ui.form.on("Work Order", { frm.trigger("add_custom_button_to_return_components"); frm.trigger("allow_alternative_item"); + frm.trigger("toggle_items_editable"); + }, + + toggle_items_editable(frm) { + let allow_edit = true; + if (!frm.doc.__onload?.allow_editing_items) allow_edit = false; + + frm.set_df_property("required_items", "cannot_delete_rows", !allow_edit); + frm.set_df_property("required_items", "cannot_add_rows", !allow_edit); + + const grid = frm.fields_dict["required_items"].grid; + grid.update_docfield_property("item_code", "read_only", !allow_edit); + grid.update_docfield_property("required_qty", "read_only", !allow_edit); + grid.refresh(); }, add_custom_button_to_return_components: function (frm) { @@ -401,7 +415,7 @@ frappe.ui.form.on("Work Order", { make_disassembly_order(frm) { erpnext.work_order - .show_prompt_for_qty_input(frm, "Disassemble") + .show_disassembly_prompt(frm) .then((data) => { if (flt(data.qty) <= 0) { frappe.msgprint(__("Disassemble Qty cannot be less than or equal to 0.")); @@ -411,11 +425,14 @@ frappe.ui.form.on("Work Order", { work_order_id: frm.doc.name, purpose: "Disassemble", qty: data.qty, + source_stock_entry: data.source_stock_entry, }); }) .then((stock_entry) => { - frappe.model.sync(stock_entry); - frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + if (stock_entry) { + frappe.model.sync(stock_entry); + frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + } }); }, @@ -865,6 +882,60 @@ erpnext.work_order = { return flt(max, precision("qty")); }, + show_disassembly_prompt: function (frm) { + let max_qty = flt(frm.doc.produced_qty - frm.doc.disassembled_qty); + + let fields = [ + { + fieldtype: "Link", + label: __("Source Manufacture Entry"), + fieldname: "source_stock_entry", + options: "Stock Entry", + description: __("Optional. Select a specific manufacture entry to reverse."), + get_query: () => { + return { + filters: { + work_order: frm.doc.name, + purpose: "Manufacture", + docstatus: 1, + }, + }; + }, + onchange: async function () { + if (!frm.disassembly_prompt) return; + + let se_name = this.value; + let qty = max_qty; + if (se_name) { + qty = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty", + { stock_entry_name: se_name } + ); + } + + frm.disassembly_prompt.set_value("qty", qty); + frm.disassembly_prompt.fields_dict.qty.set_description(__("Max: {0}", [qty])); + }, + }, + { + fieldtype: "Float", + label: __("Qty for {0}", [__("Disassemble")]), + fieldname: "qty", + description: __("Max: {0}", [max_qty]), + default: max_qty, + }, + ]; + + return new Promise((resolve, reject) => { + frm.disassembly_prompt = frappe.prompt( + fields, + (data) => resolve(data), + __("Disassemble"), + __("Create") + ); + }); + }, + show_prompt_for_qty_input: function (frm, purpose) { let max = this.get_max_transferable_qty(frm, purpose); diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index bc3def1186f..8bc2e7c1953 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -141,6 +141,7 @@ class WorkOrder(Document): def onload(self): ms = frappe.get_doc("Manufacturing Settings") + self.set_onload("allow_editing_items", ms.allow_editing_of_items_and_quantities_in_work_order) self.set_onload("material_consumption", ms.material_consumption) self.set_onload("backflush_raw_materials_based_on", ms.backflush_raw_materials_based_on) self.set_onload("overproduction_percentage", ms.overproduction_percentage_for_work_order) @@ -167,7 +168,11 @@ class WorkOrder(Document): validate_uom_is_integer(self, "stock_uom", ["required_qty"]) - self.set_required_items(reset_only_qty=len(self.get("required_items"))) + if not len(self.get("required_items")) or not frappe.db.get_single_value( + "Manufacturing Settings", "allow_editing_of_items_and_quantities_in_work_order" + ): + self.set_required_items(reset_only_qty=len(self.get("required_items"))) + self.validate_operations_sequence() def validate_operations_sequence(self): @@ -1480,7 +1485,13 @@ def set_work_order_ops(name): @frappe.whitelist() -def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): +def make_stock_entry( + work_order_id: str, + purpose: str, + qty: float | None = None, + target_warehouse: str | None = None, + source_stock_entry: str | None = None, +): work_order = frappe.get_doc("Work Order", work_order_id) if not frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group"): wip_warehouse = work_order.wip_warehouse @@ -1517,6 +1528,8 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): if purpose == "Disassemble": stock_entry.from_warehouse = work_order.fg_warehouse stock_entry.to_warehouse = target_warehouse or work_order.source_warehouse + if source_stock_entry: + stock_entry.source_stock_entry = source_stock_entry stock_entry.set_stock_entry_type() stock_entry.get_items() @@ -1527,6 +1540,28 @@ def make_stock_entry(work_order_id, purpose, qty=None, target_warehouse=None): return stock_entry.as_dict() +@frappe.whitelist() +def get_disassembly_available_qty(stock_entry_name: str, current_se_name: str | None = None) -> float: + se = frappe.db.get_value("Stock Entry", stock_entry_name, ["fg_completed_qty"], as_dict=True) + if not se: + return 0.0 + + filters = { + "source_stock_entry": stock_entry_name, + "purpose": "Disassemble", + "docstatus": 1, + } + + if current_se_name: + filters["name"] = ("!=", current_se_name) + + already_disassembled = flt( + frappe.db.get_value("Stock Entry", filters, "sum(fg_completed_qty)", order_by=None) + ) + + return flt(se.fg_completed_qty) - already_disassembled + + @frappe.whitelist() def get_default_warehouse(): doc = frappe.get_cached_doc("Manufacturing Settings") diff --git a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json index 580168180a7..98ee0a63d53 100644 --- a/erpnext/manufacturing/doctype/work_order_item/work_order_item.json +++ b/erpnext/manufacturing/doctype/work_order_item/work_order_item.json @@ -151,7 +151,7 @@ ], "istable": 1, "links": [], - "modified": "2024-11-19 15:48:16.823384", + "modified": "2025-12-02 11:16:05.081613", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Item", diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index e17fa3d622c..cd20cd340c2 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -8,6 +8,7 @@ import frappe from frappe.tests.utils import change_settings from frappe.utils import add_to_date, now_datetime, nowdate +from erpnext.accounts.doctype.sales_invoice.sales_invoice import make_sales_return from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.projects.doctype.timesheet.timesheet import OverlapError, make_sales_invoice from erpnext.setup.doctype.employee.test_employee import make_employee @@ -202,6 +203,58 @@ class TestTimesheet(unittest.TestCase): ts.calculate_percentage_billed() self.assertEqual(ts.per_billed, 100) + def test_partial_billing_and_return(self): + """ + Test Timesheet status transitions during partial billing, full billing, + sales return, and return cancellation. + Scenario: + 1. Create a Timesheet with two billable time logs. + 2. Create a Sales Invoice billing only one time log → Timesheet becomes Partially Billed. + 3. Create another Sales Invoice billing the remaining time log → Timesheet becomes Billed. + 4. Create a Sales Return against the second invoice → Timesheet reverts to Partially Billed. + 5. Cancel the Sales Return → Timesheet returns to Billed status. + This test ensures Timesheet status is recalculated correctly + across billing and return lifecycle events. + """ + emp = make_employee("test_employee_6@salary.com") + + timesheet = make_timesheet(emp, simulate=True, is_billable=1, do_not_submit=True) + timesheet_detail = timesheet.append("time_logs", {}) + timesheet_detail.is_billable = 1 + timesheet_detail.activity_type = "_Test Activity Type" + timesheet_detail.from_time = timesheet.time_logs[0].to_time + datetime.timedelta(minutes=1) + timesheet_detail.hours = 2 + timesheet_detail.to_time = timesheet_detail.from_time + datetime.timedelta( + hours=timesheet_detail.hours + ) + timesheet.save().submit() + + sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR") + sales_invoice.due_date = nowdate() + sales_invoice.timesheets.pop() + sales_invoice.submit() + + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Partially Billed") + + sales_invoice2 = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer", currency="INR") + sales_invoice2.due_date = nowdate() + sales_invoice2.submit() + + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Billed") + + sales_return = make_sales_return(sales_invoice2.name).submit() + timesheet_status = frappe.get_value("Timesheet", timesheet.name, "status") + self.assertEqual(timesheet_status, "Partially Billed") + + sales_return.load_from_db() + sales_return.cancel() + + timesheet.load_from_db() + self.assertEqual(timesheet.time_logs[1].sales_invoice, sales_invoice2.name) + self.assertEqual(timesheet.status, "Billed") + def make_timesheet( employee, @@ -211,6 +264,7 @@ def make_timesheet( project=None, task=None, company=None, + do_not_submit=False, ): update_activity_type(activity_type) timesheet = frappe.new_doc("Timesheet") @@ -237,7 +291,8 @@ def make_timesheet( else: timesheet.save(ignore_permissions=True) - timesheet.submit() + if not do_not_submit: + timesheet.submit() return timesheet diff --git a/erpnext/projects/doctype/timesheet/timesheet.json b/erpnext/projects/doctype/timesheet/timesheet.json index ba6262dc3de..6f16266e0f3 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.json +++ b/erpnext/projects/doctype/timesheet/timesheet.json @@ -91,7 +91,7 @@ "in_standard_filter": 1, "label": "Status", "no_copy": 1, - "options": "Draft\nSubmitted\nBilled\nPayslip\nCompleted\nCancelled", + "options": "Draft\nSubmitted\nPartially Billed\nBilled\nPayslip\nCompleted\nCancelled", "print_hide": 1, "read_only": 1 }, @@ -310,7 +310,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2023-04-20 15:59:11.107831", + "modified": "2026-04-06 22:30:28.513139", "modified_by": "Administrator", "module": "Projects", "name": "Timesheet", diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py index ec58c55f020..bf3116cb409 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.py +++ b/erpnext/projects/doctype/timesheet/timesheet.py @@ -50,7 +50,9 @@ class Timesheet(Document): per_billed: DF.Percent sales_invoice: DF.Link | None start_date: DF.Date | None - status: DF.Literal["Draft", "Submitted", "Billed", "Payslip", "Completed", "Cancelled"] + status: DF.Literal[ + "Draft", "Submitted", "Partially Billed", "Billed", "Payslip", "Completed", "Cancelled" + ] time_logs: DF.Table[TimesheetDetail] title: DF.Data | None total_billable_amount: DF.Currency @@ -126,6 +128,9 @@ class Timesheet(Document): if flt(self.per_billed, self.precision("per_billed")) >= 100.0: self.status = "Billed" + if 0.0 < flt(self.per_billed, self.precision("per_billed")) < 100.0: + self.status = "Partially Billed" + if self.sales_invoice: self.status = "Completed" @@ -423,7 +428,9 @@ def get_timesheet_data(name, project): @frappe.whitelist() -def make_sales_invoice(source_name, item_code=None, customer=None, currency=None): +def make_sales_invoice( + source_name: str, item_code: str | None = None, customer: str | None = None, currency: str | None = None +): target = frappe.new_doc("Sales Invoice") timesheet = frappe.get_doc("Timesheet", source_name) @@ -452,7 +459,7 @@ def make_sales_invoice(source_name, item_code=None, customer=None, currency=None target.append("items", {"item_code": item_code, "qty": hours, "rate": billing_rate}) for time_log in timesheet.time_logs: - if time_log.is_billable: + if time_log.is_billable and not time_log.sales_invoice: target.append( "timesheets", { diff --git a/erpnext/projects/doctype/timesheet/timesheet_list.js b/erpnext/projects/doctype/timesheet/timesheet_list.js index 0de568ce589..ceca47209e1 100644 --- a/erpnext/projects/doctype/timesheet/timesheet_list.js +++ b/erpnext/projects/doctype/timesheet/timesheet_list.js @@ -1,6 +1,9 @@ frappe.listview_settings["Timesheet"] = { add_fields: ["status", "total_hours", "start_date", "end_date"], get_indicator: function (doc) { + if (doc.status == "Partially Billed") { + return [__("Partially Billed"), "orange", "status,=," + "Partially Billed"]; + } if (doc.status == "Billed") { return [__("Billed"), "green", "status,=," + "Billed"]; } diff --git a/erpnext/regional/address_template/templates/croatia.html b/erpnext/regional/address_template/templates/croatia.html new file mode 100644 index 00000000000..0c2ed73f0ae --- /dev/null +++ b/erpnext/regional/address_template/templates/croatia.html @@ -0,0 +1,4 @@ +{{ address_line1 }}
+{% if address_line2 %}{{ address_line2 }}
{% endif -%} +{{ pincode }} {{ city | upper }}
+{{ country | upper }} \ No newline at end of file diff --git a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py index cab84024417..8c2b559fe52 100644 --- a/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py +++ b/erpnext/regional/report/uae_vat_201/test_uae_vat_201.py @@ -148,6 +148,7 @@ def make_customer(): "doctype": "Customer", "customer_name": "_Test UAE Customer", "customer_type": "Company", + "customer_group": "Individual", } ) customer.insert() diff --git a/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py b/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py index a898a251043..26b2969e2c5 100644 --- a/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py +++ b/erpnext/regional/report/vat_audit_report/test_vat_audit_report.py @@ -115,6 +115,7 @@ def make_customer(): "doctype": "Customer", "customer_name": "_Test SA Customer", "customer_type": "Company", + "customer_group": "Individual", } ).insert() diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 1ce480fbd2e..8b5761f5930 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -145,6 +145,7 @@ class Customer(TransactionBase): def validate(self): self.flags.is_new_doc = self.is_new() self.flags.old_lead = self.lead_name + self.validate_customer_group() validate_party_accounts(self) self.validate_credit_limit_on_change() self.set_loyalty_program() @@ -324,6 +325,17 @@ class Customer(TransactionBase): frappe.NameError, ) + def validate_customer_group(self): + if not self.customer_group: + return + + is_group = frappe.db.get_value("Customer Group", self.customer_group, "is_group") + if is_group: + frappe.throw( + _("Cannot select a Group type Customer Group. Please select a non-group Customer Group."), + title=_("Invalid Customer Group"), + ) + def validate_credit_limit_on_change(self): if self.get("__islocal") or not self.credit_limits: return diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index dfb4a5b4445..a8fd5ed76ca 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -450,6 +450,7 @@ def make_customer(customer_name): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.customer_type = "Individual" + customer.customer_group = "Individual" customer.insert() return customer.name else: diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 38334cc29bc..1316252005f 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -63,6 +63,13 @@ frappe.ui.form.on("Sales Order", { }); } }, + transaction_date(frm) { + prevent_past_delivery_dates(frm); + frm.set_value("delivery_date", ""); + frm.doc.items.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "delivery_date", ""); + }); + }, refresh: function (frm) { if (frm.doc.docstatus === 1) { diff --git a/erpnext/setup/doctype/employee/employee.py b/erpnext/setup/doctype/employee/employee.py index 543a8a194b8..db4446cce77 100755 --- a/erpnext/setup/doctype/employee/employee.py +++ b/erpnext/setup/doctype/employee/employee.py @@ -189,7 +189,7 @@ class Employee(NestedSet): frappe.throw(_("User {0} does not exist").format(self.user_id)) if self.status != "Active" and enabled or self.status == "Active" and enabled == 0: - frappe.set_value("User", self.user_id, "enabled", not enabled) + frappe.db.set_value("User", self.user_id, "enabled", not enabled) def validate_duplicate_user_id(self): Employee = frappe.qb.DocType("Employee") diff --git a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py index 7349838e816..fbef891b745 100644 --- a/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py +++ b/erpnext/stock/doctype/inventory_dimension/inventory_dimension.py @@ -181,6 +181,7 @@ class InventoryDimension(Document): insert_after="inventory_dimension", options=self.reference_document, label=_(label), + depends_on="eval:doc.s_warehouse" if doctype == "Stock Entry Detail" else "", search_index=1, reqd=self.reqd, mandatory_depends_on=self.mandatory_depends_on, @@ -273,7 +274,7 @@ class InventoryDimension(Document): elif doctype != "Stock Entry Detail": display_depends_on = "eval:parent.is_internal_customer == 1" elif doctype == "Stock Entry Detail": - display_depends_on = "eval:parent.purpose != 'Material Issue'" + display_depends_on = "eval:doc.t_warehouse" fieldname = f"{fieldname_start_with}_{self.source_fieldname}" label = f"{label_start_with} {self.dimension_name}" diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index e9291e10baf..f200294a777 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -510,7 +510,7 @@ class PurchaseReceipt(BuyingController): else flt(item.net_amount, item.precision("net_amount")) ) - outgoing_amount = item.base_net_amount + outgoing_amount = item.qty * item.base_net_rate if self.is_internal_transfer() and item.valuation_rate: outgoing_amount = abs(get_stock_value_difference(self.name, item.name, item.from_warehouse)) credit_amount = outgoing_amount @@ -1135,9 +1135,11 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate total_amount, total_billed_amount, pi_landed_cost_amount = 0, 0, 0 item_wise_returned_qty = get_item_wise_returned_qty(pr_doc) + billed_qty_amt = frappe._dict() if adjust_incoming_rate: - item_wise_billed_qty = get_billed_qty_against_purchase_receipt(pr_doc) + billed_qty_amt = get_billed_qty_amount_against_purchase_receipt(pr_doc) + billed_qty_amt_based_on_po = get_billed_qty_amount_against_purchase_order(pr_doc) for item in pr_doc.items: returned_qty = flt(item_wise_returned_qty.get(item.name)) @@ -1166,13 +1168,47 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate if ( item.billed_amt is not None and item.amount is not None - and item_wise_billed_qty.get(item.name) + and ( + billed_qty_amt.get(item.name) or billed_qty_amt_based_on_po.get(item.purchase_order_item) + ) ): - adjusted_amt = ( - flt(item.billed_amt / item_wise_billed_qty.get(item.name)) - flt(item.rate) - ) * item.qty + qty = None + if billed_qty_amt.get(item.name): + qty = billed_qty_amt.get(item.name).get("qty") - adjusted_amt = flt(adjusted_amt * flt(pr_doc.conversion_rate), item.precision("amount")) + if not qty and billed_qty_amt_based_on_po.get(item.purchase_order_item): + if item.qty < billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"]: + qty = item.qty + else: + qty = billed_qty_amt_based_on_po.get(item.purchase_order_item)["qty"] + + billed_qty_amt_based_on_po[item.purchase_order_item]["qty"] -= qty + + billed_amt = item.billed_amt + if billed_qty_amt.get(item.name): + billed_amt = flt(billed_qty_amt.get(item.name).get("amount")) + elif billed_qty_amt_based_on_po.get(item.purchase_order_item): + total_billed_qty = ( + billed_qty_amt_based_on_po.get(item.purchase_order_item).get("qty") + qty + ) + + if total_billed_qty: + billed_amt = flt( + flt(billed_qty_amt_based_on_po.get(item.purchase_order_item).get("amount")) + * (qty / total_billed_qty) + ) + else: + billed_amt = 0.0 + + # Reduce billed amount based on PO for next iterations + billed_qty_amt_based_on_po[item.purchase_order_item]["amount"] -= billed_amt + + if qty: + adjusted_amt = ( + flt(billed_amt / qty) - (flt(item.rate) * flt(pr_doc.conversion_rate)) + ) * item.qty + + adjusted_amt = flt(adjusted_amt, item.precision("amount")) pi_landed_cost_amount += adjusted_amt item.db_set("amount_difference_with_purchase_invoice", adjusted_amt, update_modified=False) elif amount and item.billed_amt > amount: @@ -1201,20 +1237,80 @@ def update_billing_percentage(pr_doc, update_modified=True, adjust_incoming_rate adjust_incoming_rate_for_pr(pr_doc) -def get_billed_qty_against_purchase_receipt(pr_doc): +def get_billed_qty_amount_against_purchase_receipt(pr_doc): pr_names = [d.name for d in pr_doc.items] + parent_table = frappe.qb.DocType("Purchase Invoice") table = frappe.qb.DocType("Purchase Invoice Item") query = ( - frappe.qb.from_(table) - .select(table.pr_detail, fn.Sum(table.qty).as_("qty")) + frappe.qb.from_(parent_table) + .inner_join(table) + .on(parent_table.name == table.parent) + .select( + table.pr_detail, + fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + fn.Sum(table.qty).as_("qty"), + ) .where((table.pr_detail.isin(pr_names)) & (table.docstatus == 1)) .groupby(table.pr_detail) ) - invoice_data = query.run(as_list=1) + invoice_data = query.run(as_dict=1) if not invoice_data: return frappe._dict() - return frappe._dict(invoice_data) + + billed_qty_amt = frappe._dict() + + for row in invoice_data: + if row.pr_detail not in billed_qty_amt: + billed_qty_amt[row.pr_detail] = {"amount": 0, "qty": 0} + + billed_qty_amt[row.pr_detail]["amount"] += flt(row.amount) + billed_qty_amt[row.pr_detail]["qty"] += flt(row.qty) + + return billed_qty_amt + + +def get_billed_qty_amount_against_purchase_order(pr_doc): + po_names = list( + set( + [ + d.purchase_order_item + for d in pr_doc.items + if d.purchase_order_item and not d.purchase_invoice_item + ] + ) + ) + + invoice_data_po_based = frappe._dict() + if po_names: + parent_table = frappe.qb.DocType("Purchase Invoice") + table = frappe.qb.DocType("Purchase Invoice Item") + + query = ( + frappe.qb.from_(parent_table) + .inner_join(table) + .on(parent_table.name == table.parent) + .select( + table.po_detail, + fn.Sum(table.qty).as_("qty"), + fn.Sum(table.amount * parent_table.conversion_rate).as_("amount"), + ) + .where((table.po_detail.isin(po_names)) & (table.docstatus == 1) & (table.pr_detail.isnull())) + .groupby(table.po_detail) + ) + + invoice_data = query.run(as_dict=1) + if not invoice_data: + return frappe._dict() + + for row in invoice_data: + if row.po_detail not in invoice_data_po_based: + invoice_data_po_based[row.po_detail] = {"amount": 0, "qty": 0} + + invoice_data_po_based[row.po_detail]["amount"] += flt(row.amount) + invoice_data_po_based[row.po_detail]["qty"] += flt(row.qty) + + return invoice_data_po_based def adjust_incoming_rate_for_pr(doc): diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 8508031578d..2d2e68bb0ea 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -4539,7 +4539,7 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(srbnb_cost, 1500) - def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self): + def test_valuation_rate_for_rejected_materials_without_accepted_materials(self): item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1}) company = "_Test Company with perpetual inventory" @@ -5106,6 +5106,97 @@ class TestPurchaseReceipt(FrappeTestCase): self.assertEqual(row.warehouse, "_Test Warehouse 1 - _TC") self.assertEqual(row.incoming_rate, 100) + def test_bill_for_rejected_quantity_in_purchase_invoice(self): + item_code = make_item("Test Rejected Qty", {"is_stock_item": 1}).name + + frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 0) + pr = make_purchase_receipt( + item_code=item_code, + qty=10, + rejected_qty=2, + rate=10, + warehouse="_Test Warehouse - _TC", + ) + + self.assertEqual(pr.total_qty, 10) + self.assertEqual(pr.total, 100) + + frappe.db.set_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice", 1) + pr = make_purchase_receipt( + item_code=item_code, + qty=10, + rejected_qty=2, + rate=10, + warehouse="_Test Warehouse - _TC", + ) + + self.assertEqual(pr.total_qty, 12) + self.assertEqual(pr.total, 120) + + def test_different_exchange_rate_in_pr_and_pi(self): + from erpnext.accounts.doctype.account.test_account import create_account + + original_value = frappe.db.get_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate" + ) + + frappe.db.set_single_value("Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", 1) + + party_account = create_account( + account_name="USD Party Account Creditors", + parent_account="Accounts Payable - TCP1", + account_type="Payable", + company="_Test Company with perpetual inventory", + account_currency="USD", + ) + + supplier = create_supplier( + supplier_name="_Test USD Supplier New 1", default_currency="USD", party_account=party_account + ).name + item_code = make_item("Test Item for Different Exchange Rate", {"is_stock_item": 1}).name + + pr = make_purchase_receipt( + item_code=item_code, + qty=1, + currency="USD", + conversion_rate=80, + rate=100, + company="_Test Company with perpetual inventory", + warehouse=frappe.get_value( + "Warehouse", {"company": "_Test Company with perpetual inventory"}, "name" + ), + supplier=supplier, + ) + + self.assertEqual(pr.currency, "USD") + self.assertEqual(pr.conversion_rate, 80) + + gl_entries = get_gl_entries(pr.doctype, pr.name) + self.assertTrue(len(gl_entries) == 2) + for row in gl_entries: + amount = row.credit or row.debit + self.assertEqual(amount, 8000.0) + + pi = make_purchase_invoice(pr.name) + pi.conversion_rate = 90 + pi.currency = "USD" + + pi.save() + pi.submit() + + gl_entries = get_gl_entries(pi.doctype, pi.name) + self.assertTrue(len(gl_entries) == 2) + + accounts = ["USD Party Account Creditors - TCP1", "Stock Received But Not Billed - TCP1"] + for row in gl_entries: + amount = row.credit or row.debit + self.assertEqual(amount, 9000.0) + self.assertTrue(row.account in accounts) + + frappe.db.set_single_value( + "Buying Settings", "set_landed_cost_based_on_purchase_invoice_rate", original_value + ) + def prepare_data_for_internal_transfer(): from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier @@ -5276,6 +5367,9 @@ def make_purchase_receipt(**args): pr.return_against = args.return_against pr.apply_putaway_rule = args.apply_putaway_rule + if args.get("conversion_rate") is not None: + pr.conversion_rate = args.conversion_rate + qty = args.qty if args.qty is not None else 5 rejected_qty = args.rejected_qty or 0 received_qty = args.received_qty or flt(rejected_qty) + flt(qty) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js index e6547ad6f35..c514b25c8ef 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.js @@ -67,9 +67,15 @@ frappe.ui.form.on("Repost Item Valuation", { } if (frm.doc.status == "In Progress") { - frm.doc.current_index = data.current_index; - frm.doc.items_to_be_repost = data.items_to_be_repost; - frm.doc.total_reposting_count = data.total_reposting_count; + if (data.current_index) { + frm.doc.current_index = data.current_index; + frm.doc.items_to_be_repost = data.items_to_be_repost; + } + + if (data.vouchers_posted) { + frm.doc.total_vouchers = data.total_vouchers; + frm.doc.vouchers_posted = data.vouchers_posted; + } frm.dashboard.reset(); frm.trigger("show_reposting_progress"); @@ -104,15 +110,31 @@ frappe.ui.form.on("Repost Item Valuation", { show_reposting_progress: function (frm) { var bars = []; - + let title = ""; + let progress = 0.0; let total_count = frm.doc.items_to_be_repost ? JSON.parse(frm.doc.items_to_be_repost).length : 0; - if (frm.doc?.total_reposting_count) { - total_count = frm.doc.total_reposting_count; + if (total_count > 1) { + progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5; + title = __("Reposting for Item-Wh Completed {0}%", [progress]); + + bars.push({ + title: title, + width: progress + "%", + progress_class: "progress-bar-success", + }); + + frm.dashboard.add_progress(__("Reposting Progress"), bars); } - let progress = flt((cint(frm.doc.current_index) / total_count) * 100, 2) || 0.5; - var title = __("Reposting Completed {0}%", [progress]); + if (!frm.doc.vouchers_posted) { + return; + } + + // Show voucher posting progress if vouchers are being reposted + bars = []; + progress = flt((cint(frm.doc.vouchers_posted) / cint(frm.doc.total_vouchers)) * 100, 2) || 0.5; + title = __("Reposting for Vouchers Completed {0}%", [progress]); bars.push({ title: title, @@ -120,7 +142,7 @@ frappe.ui.form.on("Repost Item Valuation", { progress_class: "progress-bar-success", }); - frm.dashboard.add_progress(__("Reposting Progress"), bars); + frm.dashboard.add_progress(__("Reposting Vouchers Progress"), bars); }, restart_reposting: function (frm) { diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json index bd70072e4bd..3affd1e4be9 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.json @@ -24,14 +24,16 @@ "error_section", "error_log", "reposting_info_section", - "reposting_data_file", "items_to_be_repost", - "distinct_item_and_warehouse", "column_break_o1sj", "total_reposting_count", "current_index", "gl_reposting_index", - "affected_transactions" + "reposting_data_file", + "vouchers_based_on_item_and_warehouse_section", + "total_vouchers", + "column_break_yqwo", + "vouchers_posted" ], "fields": [ { @@ -164,15 +166,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "distinct_item_and_warehouse", - "fieldtype": "Code", - "hidden": 1, - "label": "Distinct Item and Warehouse", - "no_copy": 1, - "print_hide": 1, - "read_only": 1 - }, { "fieldname": "current_index", "fieldtype": "Int", @@ -182,14 +175,6 @@ "print_hide": 1, "read_only": 1 }, - { - "fieldname": "affected_transactions", - "fieldtype": "Code", - "hidden": 1, - "label": "Affected Transactions", - "no_copy": 1, - "read_only": 1 - }, { "default": "0", "fieldname": "gl_reposting_index", @@ -202,7 +187,7 @@ { "fieldname": "reposting_info_section", "fieldtype": "Section Break", - "label": "Reposting Info" + "label": "Reposting Item and Warehouse" }, { "fieldname": "column_break_o1sj", @@ -211,14 +196,7 @@ { "fieldname": "total_reposting_count", "fieldtype": "Int", - "label": "Total Reposting Count", - "no_copy": 1, - "read_only": 1 - }, - { - "fieldname": "reposting_data_file", - "fieldtype": "Attach", - "label": "Reposting Data File", + "label": "No of Items to Repost", "no_copy": 1, "read_only": 1 }, @@ -228,13 +206,44 @@ "fieldname": "recreate_stock_ledgers", "fieldtype": "Check", "label": "Recreate Stock Ledgers" + }, + { + "fieldname": "vouchers_based_on_item_and_warehouse_section", + "fieldtype": "Section Break", + "hidden": 1, + "label": "Reposting Vouchers" + }, + { + "fieldname": "total_vouchers", + "fieldtype": "Int", + "label": "Total Ledgers", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "column_break_yqwo", + "fieldtype": "Column Break" + }, + { + "fieldname": "vouchers_posted", + "fieldtype": "Int", + "label": "Ledgers Posted", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "reposting_data_file", + "fieldtype": "Attach", + "label": "Reposting Data File", + "no_copy": 1, + "read_only": 1 } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2025-03-31 12:38:20.566196", + "modified": "2026-03-27 19:59:58.637964", "modified_by": "Administrator", "module": "Stock", "name": "Repost Item Valuation", diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index e3b1b330fad..44cf9280780 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -33,14 +33,12 @@ class RepostItemValuation(Document): if TYPE_CHECKING: from frappe.types import DF - affected_transactions: DF.Code | None allow_negative_stock: DF.Check allow_zero_rate: DF.Check amended_from: DF.Link | None based_on: DF.Literal["Transaction", "Item and Warehouse"] company: DF.Link | None current_index: DF.Int - distinct_item_and_warehouse: DF.Code | None error_log: DF.LongText | None gl_reposting_index: DF.Int item_code: DF.Link | None @@ -49,11 +47,14 @@ class RepostItemValuation(Document): posting_time: DF.Time | None recreate_stock_ledgers: DF.Check reposting_data_file: DF.Attach | None - status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed"] + reposting_reference: DF.Data | None + status: DF.Literal["Queued", "In Progress", "Completed", "Skipped", "Failed", "Cancelled"] total_reposting_count: DF.Int + total_vouchers: DF.Int via_landed_cost_voucher: DF.Check voucher_no: DF.DynamicLink | None voucher_type: DF.Link | None + vouchers_posted: DF.Int warehouse: DF.Link | None # end: auto-generated types @@ -74,6 +75,7 @@ class RepostItemValuation(Document): def validate(self): self.set_company() + self.validate_update_stock() self.validate_period_closing_voucher() self.set_status(write=False) self.reset_field_values() @@ -81,6 +83,15 @@ class RepostItemValuation(Document): self.reset_recreate_stock_ledgers() self.validate_recreate_stock_ledgers() + def validate_update_stock(self): + if self.voucher_type in ["Sales Invoice", "Purchase Invoice"]: + update_stock = frappe.get_value(self.voucher_type, self.voucher_no, "update_stock") + if not update_stock: + msg = _( + "Since {0} has 'Update Stock' disabled, you cannot create repost item valuation against it" + ).format(get_link_to_form(self.voucher_type, self.voucher_no)) + frappe.throw(msg) + def validate_recreate_stock_ledgers(self): if not self.recreate_stock_ledgers: return @@ -250,6 +261,9 @@ class RepostItemValuation(Document): self.distinct_item_and_warehouse = None self.items_to_be_repost = None self.gl_reposting_index = 0 + self.total_reposting_count = 0 + self.total_vouchers = 0 + self.vouchers_posted = 0 self.clear_attachment() self.db_update() @@ -381,7 +395,7 @@ def repost_sl_entries(doc): ) else: repost_future_sle( - args=[ + items_to_be_repost=[ frappe._dict( { "item_code": doc.item_code, diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index c36d799ba37..5cc3736fb0f 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -137,19 +137,14 @@ class TestRepostItemValuation(FrappeTestCase, StockTestMixin): item_code="_Test Item", warehouse="_Test Warehouse - _TC", based_on="Item and Warehouse", - voucher_type="Sales Invoice", - voucher_no="SI-1", posting_date="2021-01-02", posting_time="00:01:00", ) - # new repost without any duplicates riv1 = frappe.get_doc(riv_args) riv1.flags.dont_run_in_test = True riv1.submit() _assert_status(riv1, "Queued") - self.assertEqual(riv1.voucher_type, "Sales Invoice") # traceability - self.assertEqual(riv1.voucher_no, "SI-1") # newer than existing duplicate - riv1 riv2 = frappe.get_doc(riv_args.update({"posting_date": "2021-01-03"})) 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 4de6ebc6a00..b0b8c221f8e 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 @@ -400,6 +400,25 @@ class SerialandBatchBundle(Document): def set_valuation_rate_for_return_entry(self, return_against, row, save=False, prev_sle=None): if valuation_details := self.get_valuation_rate_for_return_entry(return_against): + from erpnext.stock.utils import get_valuation_method + + valuation_method = get_valuation_method(self.item_code) + + stock_queue = [] + non_batchwise_batches = [] + if not self.has_serial_no and valuation_method == "FIFO": + non_batchwise_batches = frappe.get_all( + "Batch", + filters={ + "name": ("in", [d.batch_no for d in self.entries if d.batch_no]), + "use_batchwise_valuation": 0, + }, + pluck="name", + ) + + if non_batchwise_batches and prev_sle and prev_sle.stock_queue: + stock_queue = parse_json(prev_sle.stock_queue) + for row in self.entries: if valuation_details: self.validate_returned_serial_batch_no(return_against, row, valuation_details) @@ -421,11 +440,25 @@ class SerialandBatchBundle(Document): row.incoming_rate = flt(valuation_rate) row.stock_value_difference = flt(row.qty) * flt(row.incoming_rate) + if ( + non_batchwise_batches + and row.batch_no in non_batchwise_batches + and row.incoming_rate is not None + ): + if flt(row.qty) > 0: + stock_queue.append([row.qty, row.incoming_rate]) + elif flt(row.qty) < 0: + stock_queue = FIFOValuation(stock_queue) + stock_queue.remove_stock(qty=abs(row.qty)) + stock_queue = stock_queue.state + row.stock_queue = json.dumps(stock_queue) + if save: row.db_set( { "incoming_rate": row.incoming_rate, "stock_value_difference": row.stock_value_difference, + "stock_queue": row.get("stock_queue"), } ) @@ -1446,6 +1479,7 @@ class SerialandBatchBundle(Document): def on_cancel(self): self.validate_voucher_no_docstatus() self.validate_batch_quantity() + self.remove_source_document_no() def validate_batch_quantity(self): if not self.has_batch_no: @@ -1464,6 +1498,19 @@ class SerialandBatchBundle(Document): if flt(available_qty, precision) < 0: self.throw_negative_batch(d.batch_no, available_qty, precision) + def remove_source_document_no(self): + if not self.has_serial_no: + return + + if self.total_qty > 0: + serial_nos = [d.serial_no for d in self.entries if d.serial_no] + sn_table = frappe.qb.DocType("Serial No") + ( + frappe.qb.update(sn_table) + .set(sn_table.purchase_document_no, None) + .where((sn_table.name.isin(serial_nos)) & (sn_table.purchase_document_no == self.voucher_no)) + ).run() + def throw_negative_batch(self, batch_no, available_qty, precision, posting_datetime=None): from erpnext.stock.stock_ledger import NegativeStockError diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py index 51b939c343d..5e33852badc 100644 --- a/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py +++ b/erpnext/stock/doctype/serial_and_batch_bundle/test_serial_and_batch_bundle.py @@ -1071,6 +1071,126 @@ class TestSerialandBatchBundle(FrappeTestCase): self.assertTrue(bundle_doc.docstatus == 0) self.assertRaises(frappe.ValidationError, bundle_doc.submit) + def test_stock_queue_for_return_entry_with_non_batchwise_valuation(self): + from erpnext.controllers.sales_and_purchase_return import make_return_doc + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + + batch_item_code = "Old Batch Return Queue Test" + make_item( + batch_item_code, + { + "has_batch_no": 1, + "batch_number_series": "TEST-RET-Q-.#####", + "create_new_batch": 1, + "is_stock_item": 1, + "valuation_method": "FIFO", + }, + ) + + batch_id = "Old Batch Return Queue 1" + if not frappe.db.exists("Batch", batch_id): + batch_doc = frappe.get_doc( + { + "doctype": "Batch", + "batch_id": batch_id, + "item": batch_item_code, + "use_batchwise_valuation": 0, + } + ).insert(ignore_permissions=True) + + batch_doc.db_set( + { + "use_batchwise_valuation": 0, + "batch_qty": 0, + } + ) + + # Create initial stock with FIFO queue: [[10, 100], [20, 200]] + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=10, + rate=100, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + make_stock_entry( + item_code=batch_item_code, + target="_Test Warehouse - _TC", + qty=20, + rate=200, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + # Purchase Receipt: inward 5 @ 300 + pr = make_purchase_receipt( + item_code=batch_item_code, + warehouse="_Test Warehouse - _TC", + qty=5, + rate=300, + batch_no=batch_id, + use_serial_batch_fields=True, + ) + + sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": pr.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should now be [[10, 100], [20, 200], [5, 300]] + self.assertEqual(json.loads(sle.stock_queue), [[10, 100], [20, 200], [5, 300]]) + + # Purchase Return: return 5 against the PR + return_pr = make_return_doc("Purchase Receipt", pr.name) + return_pr.submit() + + return_sle = frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": batch_item_code, "is_cancelled": 0, "voucher_no": return_pr.name}, + ["stock_queue"], + as_dict=True, + ) + + # Stock queue should have 5 removed via FIFO from [[10, 100], [20, 200], [5, 300]] + # FIFO removes from front: [10, 100] -> [5, 100], rest unchanged + self.assertEqual(json.loads(return_sle.stock_queue), [[5, 100], [20, 200], [5, 300]]) + + def test_reference_voucher_on_cancel(self): + """ + When a source document is cancelled, the reference voucher field + in the respective serial or batch document should be nullified. + """ + + item_code = make_item( + "Serial Item", + properties={ + "is_stock_item": 1, + "has_serial_no": 1, + "serial_no_series": "SERIAL.#####", + }, + ).name + + se = make_stock_entry( + item_code=item_code, + qty=1, + target="_Test Warehouse - _TC", + ) + serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0] + self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se.name) + + se.cancel() + self.assertIsNone(frappe.get_value("Serial No", serial_no, "purchase_document_no")) + + se1 = frappe.copy_doc(se, ignore_no_copy=False) + se1.items[0].serial_no = serial_no + se1.submit() + + self.assertEqual(frappe.get_value("Serial No", serial_no, "purchase_document_no"), se1.name) + def get_batch_from_bundle(bundle): from erpnext.stock.serial_batch_bundle import get_batch_nos diff --git a/erpnext/stock/doctype/shipment/test_shipment.py b/erpnext/stock/doctype/shipment/test_shipment.py index 1c91a054ebc..41ddc6ea52e 100644 --- a/erpnext/stock/doctype/shipment/test_shipment.py +++ b/erpnext/stock/doctype/shipment/test_shipment.py @@ -177,7 +177,7 @@ def create_shipment_customer(customer_name): customer = frappe.new_doc("Customer") customer.customer_name = customer_name customer.customer_type = "Company" - customer.customer_group = "All Customer Groups" + customer.customer_group = "Individual" customer.territory = "All Territories" customer.insert() return customer diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js index fd2860a6f58..3e17748bb8f 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.js +++ b/erpnext/stock/doctype/stock_entry/stock_entry.js @@ -36,6 +36,16 @@ frappe.ui.form.on("Stock Entry", { }; }); + frm.set_query("source_stock_entry", function () { + return { + filters: { + purpose: "Manufacture", + docstatus: 1, + work_order: frm.doc.work_order || undefined, + }, + }; + }); + frm.set_query("source_warehouse_address", function () { return { query: "erpnext.controllers.queries.get_warehouse_address", @@ -219,6 +229,30 @@ frappe.ui.form.on("Stock Entry", { }); }, + source_stock_entry: async function (frm) { + if (!frm.doc.source_stock_entry || frm.doc.purpose !== "Disassemble") return; + + if (frm._via_source_stock_entry) { + frm.call({ + doc: frm.doc, + method: "get_items", + callback: function (r) { + if (!r.exc) refresh_field("items"); + }, + }); + frm._via_source_stock_entry = false; + return; + } + + let available_qty = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty", + { stock_entry_name: frm.doc.source_stock_entry } + ); + + // triggers get_items() via its onchange + await frm.set_value("fg_completed_qty", available_qty); + }, + outgoing_stock_entry: function (frm) { frappe.call({ doc: frm.doc, @@ -315,6 +349,59 @@ frappe.ui.form.on("Stock Entry", { __("View") ); } + + if (frm.doc.purpose === "Manufacture") { + frm.add_custom_button( + __("Disassemble"), + async function () { + let available_qty = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.get_disassembly_available_qty", + { stock_entry_name: frm.doc.name } + ); + frappe.prompt( + { + fieldtype: "Float", + label: __("Qty to Disassemble"), + fieldname: "qty", + default: available_qty, + description: __("Max: {0}", [available_qty]), + }, + async (data) => { + if (frm.doc.work_order) { + let stock_entry = await frappe.xcall( + "erpnext.manufacturing.doctype.work_order.work_order.make_stock_entry", + { + work_order_id: frm.doc.work_order, + purpose: "Disassemble", + qty: data.qty, + source_stock_entry: frm.doc.name, + } + ); + if (stock_entry) { + frappe.model.sync(stock_entry); + frappe.set_route("Form", stock_entry.doctype, stock_entry.name); + } + } else { + let se = frappe.model.get_new_doc("Stock Entry"); + se.company = frm.doc.company; + se.stock_entry_type = "Disassemble"; + se.purpose = "Disassemble"; + se.source_stock_entry = frm.doc.name; + se.from_bom = frm.doc.from_bom; + se.bom_no = frm.doc.bom_no; + se.fg_completed_qty = data.qty; + frm._via_source_stock_entry = true; + + frappe.set_route("Form", "Stock Entry", se.name); + } + }, + __("Disassemble"), + __("Create") + ); + }, + __("Create") + ); + } } if (frm.doc.docstatus === 0) { @@ -1279,8 +1366,11 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle if (!this.frm.doc.fg_completed_qty || !this.frm.doc.bom_no) frappe.throw(__("BOM and Manufacturing Quantity are required")); - if (this.frm.doc.work_order || this.frm.doc.bom_no) { - // if work order / bom is mentioned, get items + if ( + this.frm.doc.work_order || + this.frm.doc.bom_no || + (this.frm.doc.purpose === "Disassemble" && this.frm.doc.source_stock_entry) + ) { return this.frm.call({ doc: me.frm.doc, freeze: true, diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index f37a2785252..797bb8ac5dd 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -11,6 +11,7 @@ "naming_series", "stock_entry_type", "outgoing_stock_entry", + "source_stock_entry", "purpose", "add_to_transit", "work_order", @@ -120,6 +121,15 @@ "options": "Stock Entry", "read_only": 1 }, + { + "depends_on": "eval:doc.purpose == 'Disassemble'", + "fieldname": "source_stock_entry", + "fieldtype": "Link", + "label": "Source Stock Entry (Manufacture)", + "no_copy": 1, + "options": "Stock Entry", + "print_hide": 1 + }, { "bold": 1, "fetch_from": "stock_entry_type.purpose", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 24e704e07b2..5f9e55cee50 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -28,7 +28,6 @@ from erpnext.buying.utils import check_on_hold_or_closed_status from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals from erpnext.manufacturing.doctype.bom.bom import ( add_additional_cost, - get_bom_items_as_dict, get_op_cost_from_sub_assemblies, get_scrap_items_from_sub_assemblies, validate_bom_no, @@ -143,6 +142,7 @@ class StockEntry(StockController): select_print_heading: DF.Link | None set_posting_time: DF.Check source_address_display: DF.SmallText | None + source_stock_entry: DF.Link | None source_warehouse_address: DF.Link | None stock_entry_type: DF.Link subcontracting_order: DF.Link | None @@ -183,6 +183,13 @@ class StockEntry(StockController): ) def onload(self): + self.update_items_from_bin_details() + + def before_print(self, settings=None): + super().before_print(settings) + self.update_items_from_bin_details() + + def update_items_from_bin_details(self): for item in self.get("items"): item.update(get_bin_details(item.item_code, item.s_warehouse)) @@ -210,6 +217,7 @@ class StockEntry(StockController): self.validate_warehouse() self.validate_warehouse_of_sabb() self.validate_work_order() + self.validate_source_stock_entry() self.validate_bom() self.set_process_loss_qty() self.validate_purchase_order() @@ -286,6 +294,56 @@ class StockEntry(StockController): if self.purpose != "Disassemble": return + if self.get("source_stock_entry"): + self._set_serial_batch_for_disassembly_from_stock_entry() + else: + self._set_serial_batch_for_disassembly_from_available_materials() + + def _set_serial_batch_for_disassembly_from_stock_entry(self): + from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( + get_voucher_wise_serial_batch_from_bundle, + ) + + source_fg_qty = flt(frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty")) + scale_factor = flt(self.fg_completed_qty) / source_fg_qty if source_fg_qty else 0 + + bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=[self.source_stock_entry]) + source_rows_by_name = {r.name: r for r in self.get_items_from_manufacture_stock_entry()} + + for row in self.items: + if not row.ste_detail: + continue + + source_row = source_rows_by_name.get(row.ste_detail) + if not source_row: + continue + + source_warehouse = source_row.s_warehouse or source_row.t_warehouse + key = (source_row.item_code, source_warehouse, self.source_stock_entry) + source_bundle = bundle_data.get(key, {}) + + batches = defaultdict(float) + serial_nos = [] + + if source_bundle.get("batch_nos"): + qty_remaining = row.transfer_qty + for batch_no, batch_qty in source_bundle["batch_nos"].items(): + if qty_remaining <= 0: + break + alloc = min(abs(flt(batch_qty)) * scale_factor, qty_remaining) + batches[batch_no] = alloc + qty_remaining -= alloc + elif source_row.batch_no: + batches[source_row.batch_no] = row.transfer_qty + + if source_bundle.get("serial_nos"): + serial_nos = get_serial_nos(source_bundle["serial_nos"])[: int(row.transfer_qty)] + elif source_row.serial_no: + serial_nos = get_serial_nos(source_row.serial_no)[: int(row.transfer_qty)] + + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) + + def _set_serial_batch_for_disassembly_from_available_materials(self): available_materials = get_available_materials(self.work_order, self) for row in self.items: warehouse = row.s_warehouse or row.t_warehouse @@ -311,34 +369,38 @@ class StockEntry(StockController): if materials.serial_nos: serial_nos = materials.serial_nos[: int(row.transfer_qty)] - if not serial_nos and not batches: - continue + self._set_serial_batch_bundle_for_disassembly_row(row, serial_nos, batches) - bundle_doc = SerialBatchCreation( - { - "item_code": row.item_code, - "warehouse": warehouse, - "posting_date": self.posting_date, - "posting_time": self.posting_time, - "voucher_type": self.doctype, - "voucher_no": self.name, - "voucher_detail_no": row.name, - "qty": row.transfer_qty, - "type_of_transaction": "Inward" if row.t_warehouse else "Outward", - "company": self.company, - "do_not_submit": True, - } - ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) + def _set_serial_batch_bundle_for_disassembly_row(self, row, serial_nos, batches): + if not serial_nos and not batches: + return - row.serial_and_batch_bundle = bundle_doc.name - row.use_serial_batch_fields = 0 + warehouse = row.s_warehouse or row.t_warehouse + bundle_doc = SerialBatchCreation( + { + "item_code": row.item_code, + "warehouse": warehouse, + "posting_date": self.posting_date, + "posting_time": self.posting_time, + "voucher_type": self.doctype, + "voucher_no": self.name, + "voucher_detail_no": row.name, + "qty": row.transfer_qty, + "type_of_transaction": "Inward" if row.t_warehouse else "Outward", + "company": self.company, + "do_not_submit": True, + } + ).make_serial_and_batch_bundle(serial_nos=serial_nos, batch_nos=batches) - row.db_set( - { - "serial_and_batch_bundle": bundle_doc.name, - "use_serial_batch_fields": 0, - } - ) + row.serial_and_batch_bundle = bundle_doc.name + row.use_serial_batch_fields = 0 + + row.db_set( + { + "serial_and_batch_bundle": bundle_doc.name, + "use_serial_batch_fields": 0, + } + ) def on_submit(self): self.set_serial_batch_for_disassembly() @@ -722,7 +784,7 @@ class StockEntry(StockController): if self.purpose == "Disassemble": if has_bom: - if d.is_finished_item: + if d.is_finished_item or d.is_scrap_item: d.t_warehouse = None if not d.s_warehouse: frappe.throw(_("Source warehouse is mandatory for row {0}").format(d.idx)) @@ -759,6 +821,36 @@ class StockEntry(StockController): elif self.purpose != "Material Transfer": self.work_order = None + def validate_source_stock_entry(self): + if not self.get("source_stock_entry"): + return + + if self.work_order: + source_wo = frappe.db.get_value("Stock Entry", self.source_stock_entry, "work_order") + if source_wo and source_wo != self.work_order: + frappe.throw( + _( + "Source Stock Entry {0} belongs to Work Order {1}, not {2}. Please use a manufacture entry from the same Work Order." + ).format(self.source_stock_entry, source_wo, self.work_order), + title=_("Work Order Mismatch"), + ) + + from erpnext.manufacturing.doctype.work_order.work_order import get_disassembly_available_qty + + available_qty = get_disassembly_available_qty(self.source_stock_entry, self.name) + + if flt(self.fg_completed_qty) > available_qty: + frappe.throw( + _( + "Cannot disassemble {0} qty against Stock Entry {1}. Only {2} qty available to disassemble." + ).format( + self.fg_completed_qty, + self.source_stock_entry, + available_qty, + ), + title=_("Excess Disassembly"), + ) + def check_if_operations_completed(self): """Check if Time Sheets are completed against before manufacturing to capture operating costs.""" prod_order = frappe.get_doc("Work Order", self.work_order) @@ -1951,44 +2043,114 @@ class StockEntry(StockController): ) def get_items_for_disassembly(self): - """Get items for Disassembly Order""" + """Get items for Disassembly Order. + + Priority: + 1. From a specific Manufacture Stock Entry (exact reversal) + 2. From Work Order Manufacture Stock Entries (averaged reversal) + 3. From BOM (standalone disassembly) + """ + + # Auto-set source_stock_entry if WO has exactly one manufacture entry + if not self.get("source_stock_entry") and self.work_order: + manufacture_entries = frappe.get_all( + "Stock Entry", + filters={ + "work_order": self.work_order, + "purpose": "Manufacture", + "docstatus": 1, + }, + pluck="name", + limit_page_length=2, + ) + if len(manufacture_entries) == 1: + self.source_stock_entry = manufacture_entries[0] + + if self.get("source_stock_entry"): + return self._add_items_for_disassembly_from_stock_entry() if self.work_order: return self._add_items_for_disassembly_from_work_order() return self._add_items_for_disassembly_from_bom() - def _add_items_for_disassembly_from_work_order(self): - items = self.get_items_from_manufacture_entry() + def _add_items_for_disassembly_from_stock_entry(self): + source_fg_qty = frappe.db.get_value("Stock Entry", self.source_stock_entry, "fg_completed_qty") + if not source_fg_qty: + frappe.throw( + _("Source Stock Entry {0} has no finished goods quantity").format(self.source_stock_entry) + ) - s_warehouse = frappe.db.get_value("Work Order", self.work_order, "fg_warehouse") + disassemble_qty = flt(self.fg_completed_qty) + scale_factor = disassemble_qty / flt(source_fg_qty) - items_dict = get_bom_items_as_dict( - self.bom_no, - self.company, - self.fg_completed_qty, - fetch_exploded=self.use_multi_level_bom, - fetch_qty_in_stock_uom=False, + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, ) - for row in items: - child_row = self.append("items", {}) - for field, value in row.items(): - if value is not None: - child_row.set(field, value) + def _add_items_for_disassembly_from_work_order(self): + wo_produced_qty = frappe.db.get_value("Work Order", self.work_order, "produced_qty") - # update qty and amount from BOM items - bom_items = items_dict.get(row.item_code) - if bom_items: - child_row.qty = bom_items.get("qty", child_row.qty) - child_row.amount = bom_items.get("amount", child_row.amount) + wo_produced_qty = flt(wo_produced_qty) + if wo_produced_qty <= 0: + frappe.throw(_("Work Order {0} has no produced qty").format(self.work_order)) - if row.is_finished_item: - child_row.qty = self.fg_completed_qty + disassemble_qty = flt(self.fg_completed_qty) + if disassemble_qty <= 0: + frappe.throw(_("Disassemble Qty cannot be less than or equal to 0.")) - child_row.s_warehouse = (self.from_warehouse or s_warehouse) if row.is_finished_item else "" - child_row.t_warehouse = row.s_warehouse - child_row.is_finished_item = 0 if row.is_finished_item else 1 + scale_factor = disassemble_qty / wo_produced_qty + + self._append_disassembly_row_from_source( + disassemble_qty=disassemble_qty, + scale_factor=scale_factor, + ) + + def _append_disassembly_row_from_source(self, disassemble_qty, scale_factor): + for source_row in self.get_items_from_manufacture_stock_entry(): + if source_row.is_finished_item: + qty = disassemble_qty + s_warehouse = self.from_warehouse or source_row.t_warehouse + t_warehouse = "" + elif source_row.s_warehouse: + # RM: was consumed FROM s_warehouse -> return TO s_warehouse + qty = flt(source_row.qty * scale_factor) + s_warehouse = "" + t_warehouse = self.to_warehouse or source_row.s_warehouse + else: + # Scrap/secondary: was produced TO t_warehouse -> take FROM t_warehouse + qty = flt(source_row.qty * scale_factor) + s_warehouse = source_row.t_warehouse + t_warehouse = "" + + item = { + "item_code": source_row.item_code, + "item_name": source_row.item_name, + "description": source_row.description, + "stock_uom": source_row.stock_uom, + "uom": source_row.uom, + "conversion_factor": source_row.conversion_factor, + "basic_rate": source_row.basic_rate, + "qty": qty, + "s_warehouse": s_warehouse, + "t_warehouse": t_warehouse, + "is_finished_item": source_row.is_finished_item, + "is_scrap_item": source_row.is_scrap_item, + "bom_no": source_row.bom_no, + # batch and serial bundles built on submit + "use_serial_batch_fields": 1 if (source_row.batch_no or source_row.serial_no) else 0, + } + + if self.source_stock_entry: + item.update( + { + "against_stock_entry": self.source_stock_entry, + "ste_detail": source_row.name, + } + ) + + self.append("items", item) def _add_items_for_disassembly_from_bom(self): if not self.bom_no or not self.fg_completed_qty: @@ -2004,37 +2166,64 @@ class StockEntry(StockController): self.add_to_stock_entry_detail(item_dict) + # Scrap items (reverse: take scrap FROM scrap warehouse instead of producing TO it) + scrap_items = self.get_bom_scrap_material(self.fg_completed_qty) + if scrap_items: + scrap_warehouse = self.from_warehouse + if self.work_order: + wo_values = frappe.db.get_value( + "Work Order", self.work_order, ["scrap_warehouse", "fg_warehouse"], as_dict=True + ) + scrap_warehouse = wo_values.scrap_warehouse or scrap_warehouse or wo_values.fg_warehouse + + for item in scrap_items.values(): + item["from_warehouse"] = scrap_warehouse + item["to_warehouse"] = "" + item["is_finished_item"] = 0 + + self.add_to_stock_entry_detail(scrap_items, bom_no=self.bom_no) + # Finished goods self.load_items_from_bom() - def get_items_from_manufacture_entry(self): - return frappe.get_all( - "Stock Entry", - fields=[ - "`tabStock Entry Detail`.`item_code`", - "`tabStock Entry Detail`.`item_name`", - "`tabStock Entry Detail`.`description`", - "sum(`tabStock Entry Detail`.qty) as qty", - "sum(`tabStock Entry Detail`.transfer_qty) as transfer_qty", - "`tabStock Entry Detail`.`stock_uom`", - "`tabStock Entry Detail`.`uom`", - "`tabStock Entry Detail`.`basic_rate`", - "`tabStock Entry Detail`.`conversion_factor`", - "`tabStock Entry Detail`.`is_finished_item`", - "`tabStock Entry Detail`.`batch_no`", - "`tabStock Entry Detail`.`serial_no`", - "`tabStock Entry Detail`.`s_warehouse`", - "`tabStock Entry Detail`.`t_warehouse`", - "`tabStock Entry Detail`.`use_serial_batch_fields`", - ], - filters=[ - ["Stock Entry", "purpose", "=", "Manufacture"], - ["Stock Entry", "work_order", "=", self.work_order], - ["Stock Entry", "docstatus", "=", 1], - ["Stock Entry Detail", "docstatus", "=", 1], - ], - order_by="`tabStock Entry Detail`.`idx` desc, `tabStock Entry Detail`.`is_finished_item` desc", - group_by="`tabStock Entry Detail`.`item_code`", + def get_items_from_manufacture_stock_entry(self): + SE = frappe.qb.DocType("Stock Entry") + SED = frappe.qb.DocType("Stock Entry Detail") + query = frappe.qb.from_(SED).join(SE).on(SED.parent == SE.name).where(SE.docstatus == 1) + + common_fields = [ + SED.item_code, + SED.item_name, + SED.description, + SED.stock_uom, + SED.uom, + SED.basic_rate, + SED.conversion_factor, + SED.is_finished_item, + SED.is_scrap_item, + SED.batch_no, + SED.serial_no, + SED.use_serial_batch_fields, + SED.s_warehouse, + SED.t_warehouse, + SED.bom_no, + ] + + if self.source_stock_entry: + return ( + query.select(SED.name, SED.qty, SED.transfer_qty, *common_fields) + .where(SE.name == self.source_stock_entry) + .orderby(SED.idx) + .run(as_dict=True) + ) + + return ( + query.select(Sum(SED.qty).as_("qty"), Sum(SED.transfer_qty).as_("transfer_qty"), *common_fields) + .where(SE.purpose == "Manufacture") + .where(SE.work_order == self.work_order) + .groupby(SED.item_code) + .orderby(SED.idx) + .run(as_dict=True) ) @frappe.whitelist() @@ -2396,7 +2585,7 @@ class StockEntry(StockController): return item_dict def get_scrap_items_from_job_card(self): - if not self.pro_doc: + if not getattr(self, "pro_doc", None): self.set_work_order_details() if not self.pro_doc.operations: diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json index a9a3fcbfae4..58ea8083087 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.json +++ b/erpnext/stock/doctype/stock_settings/stock_settings.json @@ -73,10 +73,6 @@ "auto_indent", "column_break_27", "reorder_email_notify", - "inter_warehouse_transfer_settings_section", - "allow_from_dn", - "column_break_31", - "allow_from_pr", "stock_closing_tab", "control_historical_stock_transactions_section", "stock_frozen_upto", @@ -223,23 +219,6 @@ "fieldtype": "Data", "label": "Naming Series Prefix" }, - { - "fieldname": "inter_warehouse_transfer_settings_section", - "fieldtype": "Section Break", - "label": "Inter Warehouse Transfer Settings" - }, - { - "default": "0", - "fieldname": "allow_from_dn", - "fieldtype": "Check", - "label": "Allow Material Transfer from Delivery Note to Sales Invoice" - }, - { - "default": "0", - "fieldname": "allow_from_pr", - "fieldtype": "Check", - "label": "Allow Material Transfer from Purchase Receipt to Purchase Invoice" - }, { "description": "If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.", "fieldname": "role_allowed_to_create_edit_back_dated_transactions", @@ -287,10 +266,6 @@ "fieldname": "column_break_27", "fieldtype": "Column Break" }, - { - "fieldname": "column_break_31", - "fieldtype": "Column Break" - }, { "fieldname": "quality_inspection_settings_section", "fieldtype": "Section Break", @@ -553,7 +528,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2026-02-25 09:56:34.105949", + "modified": "2026-03-27 22:39:16.812184", "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 69e626db1ba..c6c6bd49488 100644 --- a/erpnext/stock/doctype/stock_settings/stock_settings.py +++ b/erpnext/stock/doctype/stock_settings/stock_settings.py @@ -26,8 +26,6 @@ class StockSettings(Document): action_if_quality_inspection_is_not_submitted: DF.Literal["Stop", "Warn"] action_if_quality_inspection_is_rejected: DF.Literal["Stop", "Warn"] allow_existing_serial_no: DF.Check - allow_from_dn: DF.Check - allow_from_pr: DF.Check allow_internal_transfer_at_arms_length_price: DF.Check allow_negative_stock: DF.Check allow_negative_stock_for_batch: DF.Check @@ -235,9 +233,6 @@ class StockSettings(Document): ) ) - def on_update(self): - self.toggle_warehouse_field_for_inter_warehouse_transfer() - def change_precision_for_for_sales(self): doc_before_save = self.get_doc_before_save() if doc_before_save and ( @@ -288,40 +283,6 @@ class StockSettings(Document): validate_fields_for_doctype=False, ) - def toggle_warehouse_field_for_inter_warehouse_transfer(self): - make_property_setter( - "Sales Invoice Item", - "target_warehouse", - "hidden", - 1 - cint(self.allow_from_dn), - "Check", - validate_fields_for_doctype=False, - ) - make_property_setter( - "Delivery Note Item", - "target_warehouse", - "hidden", - 1 - cint(self.allow_from_dn), - "Check", - validate_fields_for_doctype=False, - ) - make_property_setter( - "Purchase Invoice Item", - "from_warehouse", - "hidden", - 1 - cint(self.allow_from_pr), - "Check", - validate_fields_for_doctype=False, - ) - make_property_setter( - "Purchase Receipt Item", - "from_warehouse", - "hidden", - 1 - cint(self.allow_from_pr), - "Check", - validate_fields_for_doctype=False, - ) - def clean_all_descriptions(): for item in frappe.get_all("Item", ["name", "description"]): diff --git a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html index adab4786403..5a69c405364 100644 --- a/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html +++ b/erpnext/stock/page/warehouse_capacity_summary/warehouse_capacity_summary.html @@ -32,8 +32,8 @@ class="btn btn-default btn-xs btn-edit" style="margin: 4px 0; float: left;" data-warehouse="{{ d.warehouse }}" - data-item="{{ escape(d.item_code) }}" - data-company="{{ escape(d.company) }}"> + data-item="{{ d.item_code }}" + data-company="{{ d.company }}"> {{ __("Edit Capacity") }} diff --git a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py index 808afadd05a..327f158e3f6 100644 --- a/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py +++ b/erpnext/stock/report/stock_ledger_variance/stock_ledger_variance.py @@ -248,12 +248,7 @@ def get_item_warehouse_combinations(filters: dict | None = None) -> dict: bin.warehouse, item.valuation_method, ) - .where( - (item.is_stock_item == 1) - & (item.has_serial_no == 0) - & (warehouse.is_group == 0) - & (warehouse.company == filters.company) - ) + .where((item.is_stock_item == 1) & (warehouse.is_group == 0) & (warehouse.company == filters.company)) ) if filters.item_code: diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index d50d7fc5dba..ccc65a1e329 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -4,16 +4,19 @@ import copy import gzip import json +from collections import deque import frappe from frappe import _, bold, scrub from frappe.model.meta import get_field_precision +from frappe.query_builder import Order from frappe.query_builder.functions import Sum from frappe.utils import ( cint, cstr, flt, format_date, + get_datetime, get_link_to_form, getdate, now, @@ -66,8 +69,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc from erpnext.controllers.stock_controller import future_sle_exists if sl_entries: - cancel = sl_entries[0].get("is_cancelled") - if cancel: + cancelled = sl_entries[0].get("is_cancelled") + if cancelled: validate_cancellation(sl_entries) set_as_cancel(sl_entries[0].get("voucher_type"), sl_entries[0].get("voucher_no")) @@ -75,10 +78,7 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc future_sle_exists(args, sl_entries) for sle in sl_entries: - if sle.serial_no and not via_landed_cost_voucher: - validate_serial_no(sle) - - if cancel: + if cancelled: sle["actual_qty"] = -flt(sle.get("actual_qty")) if sle["actual_qty"] < 0 and not sle.get("outgoing_rate"): @@ -155,35 +155,6 @@ def get_args_for_future_sle(row): ) -def validate_serial_no(sle): - from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos - - for sn in get_serial_nos(sle.serial_no): - args = copy.deepcopy(sle) - args.serial_no = sn - args.warehouse = "" - - vouchers = [] - for row in get_stock_ledger_entries(args, ">"): - voucher_type = frappe.bold(row.voucher_type) - voucher_no = frappe.bold(get_link_to_form(row.voucher_type, row.voucher_no)) - vouchers.append(f"{voucher_type} {voucher_no}") - - if vouchers: - serial_no = frappe.bold(sn) - msg = ( - f"""The serial no {serial_no} has been used in the future transactions so you need to cancel them first. - The list of the transactions are as below.""" - + "

" - - title = "Cannot Submit" if not sle.get("is_cancelled") else "Cannot Cancel" - frappe.throw(_(msg), title=_(title), exc=SerialNoExistsInFutureTransaction) - - def validate_cancellation(kargs): if kargs[0].get("is_cancelled"): repost_entry = frappe.db.get_value( @@ -237,146 +208,96 @@ def make_entry(args, allow_negative_stock=False, via_landed_cost_voucher=False): def repost_future_sle( - args=None, + items_to_be_repost=None, voucher_type=None, voucher_no=None, allow_negative_stock=None, via_landed_cost_voucher=False, doc=None, ): - if not args: - args = [] # set args to empty list if None to avoid enumerate error - reposting_data = {} + if not items_to_be_repost: + items_to_be_repost = get_items_to_be_repost( + voucher_type=voucher_type, voucher_no=voucher_no, doc=doc, reposting_data=reposting_data + ) + if doc and doc.reposting_data_file: reposting_data = get_reposting_data(doc.reposting_data_file) - items_to_be_repost = get_items_to_be_repost( - voucher_type=voucher_type, voucher_no=voucher_no, doc=doc, reposting_data=reposting_data + repost_affected_transaction = get_affected_transactions(doc, reposting_data) or set() + resume_item_wh_wise_last_posted_sle = ( + get_item_wh_wise_last_posted_sle_from_reposting_data(doc, reposting_data) or {} ) - if items_to_be_repost: - args = items_to_be_repost - - distinct_item_warehouses = get_distinct_item_warehouse(args, doc, reposting_data=reposting_data) - affected_transactions = get_affected_transactions(doc, reposting_data=reposting_data) - - i = get_current_index(doc) or 0 - while i < len(args): - validate_item_warehouse(args[i]) + if not items_to_be_repost: + return + index = get_current_index(doc) or 0 + while index < len(items_to_be_repost): obj = update_entries_after( { - "item_code": args[i].get("item_code"), - "warehouse": args[i].get("warehouse"), - "posting_date": args[i].get("posting_date"), - "posting_time": args[i].get("posting_time"), - "creation": args[i].get("creation"), - "distinct_item_warehouses": distinct_item_warehouses, - "items_to_be_repost": args, - "current_index": i, + "item_code": items_to_be_repost[index].get("item_code"), + "warehouse": items_to_be_repost[index].get("warehouse"), + "posting_date": items_to_be_repost[index].get("posting_date"), + "posting_time": items_to_be_repost[index].get("posting_time"), + "creation": items_to_be_repost[index].get("creation"), + "current_idx": index, + "items_to_be_repost": items_to_be_repost, + "repost_doc": doc, + "repost_affected_transaction": repost_affected_transaction, + "item_wh_wise_last_posted_sle": resume_item_wh_wise_last_posted_sle, }, allow_negative_stock=allow_negative_stock, via_landed_cost_voucher=via_landed_cost_voucher, ) - affected_transactions.update(obj.affected_transactions) - key = (args[i].get("item_code"), args[i].get("warehouse")) - if distinct_item_warehouses.get(key): - distinct_item_warehouses[key].reposting_status = True + index += 1 - if obj.new_items_found: - for _item_wh, data in distinct_item_warehouses.items(): - if ("args_idx" not in data and not data.reposting_status) or ( - data.sle_changed and data.reposting_status - ): - data.args_idx = len(args) - args.append(data.sle) - elif data.sle_changed and not data.reposting_status: - args[data.args_idx] = data.sle - - data.sle_changed = False - i += 1 - - if doc: - update_args_in_repost_item_valuation( - doc, i, args, distinct_item_warehouses, affected_transactions - ) + resume_item_wh_wise_last_posted_sle = {} + repost_affected_transaction.update(obj.repost_affected_transaction) + update_args_in_repost_item_valuation(doc, index, items_to_be_repost, repost_affected_transaction) -def get_reposting_data(file_path) -> dict: - file_name = frappe.db.get_value( - "File", +def update_args_in_repost_item_valuation( + doc, + index, + items_to_be_repost, + repost_affected_transaction, + item_wh_wise_last_posted_sle=None, + only_affected_transaction=False, +): + file_name = "" + has_file = False + + if not item_wh_wise_last_posted_sle: + item_wh_wise_last_posted_sle = {} + + if doc.reposting_data_file: + has_file = True + + if doc.reposting_data_file: + file_name = get_reposting_file_name(doc.doctype, doc.name) + # frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True) + + doc.reposting_data_file = create_json_gz_file( { - "file_url": file_path, - "attached_to_field": "reposting_data_file", + "repost_affected_transaction": repost_affected_transaction, + "item_wh_wise_last_posted_sle": {str(k): v for k, v in item_wh_wise_last_posted_sle.items()} + or {}, }, - "name", + doc, + file_name, ) - if not file_name: - return frappe._dict() - - attached_file = frappe.get_doc("File", file_name) - - content = attached_file.get_content() - if isinstance(content, str): - content = content.encode("utf-8") - - try: - data = gzip.decompress(content) - except Exception: - return frappe._dict() - - if data := json.loads(data.decode("utf-8")): - data = data - - return parse_json(data) - - -def validate_item_warehouse(args): - for field in ["item_code", "warehouse", "posting_date", "posting_time"]: - if args.get(field) in [None, ""]: - validation_msg = f"The field {frappe.unscrub(field)} is required for the reposting" - frappe.throw(_(validation_msg)) - - -def update_args_in_repost_item_valuation(doc, index, args, distinct_item_warehouses, affected_transactions): - if not doc.items_to_be_repost: - file_name = "" - if doc.reposting_data_file: - file_name = get_reposting_file_name(doc.doctype, doc.name) - # frappe.delete_doc("File", file_name, ignore_permissions=True, delete_permanently=True) - - doc.reposting_data_file = create_json_gz_file( - { - "items_to_be_repost": args, - "distinct_item_and_warehouse": {str(k): v for k, v in distinct_item_warehouses.items()}, - "affected_transactions": affected_transactions, - }, - doc, - file_name, - ) - + if not only_affected_transaction or not has_file: doc.db_set( { "current_index": index, - "total_reposting_count": len(args), + "items_to_be_repost": frappe.as_json(items_to_be_repost), + "total_reposting_count": len(items_to_be_repost), "reposting_data_file": doc.reposting_data_file, } ) - else: - doc.db_set( - { - "items_to_be_repost": json.dumps(args, default=str), - "distinct_item_and_warehouse": json.dumps( - {str(k): v for k, v in distinct_item_warehouses.items()}, default=str - ), - "current_index": index, - "affected_transactions": frappe.as_json(affected_transactions), - } - ) - if not frappe.flags.in_test: frappe.db.commit() @@ -384,9 +305,8 @@ def update_args_in_repost_item_valuation(doc, index, args, distinct_item_warehou "item_reposting_progress", { "name": doc.name, - "items_to_be_repost": json.dumps(args, default=str), "current_index": index, - "total_reposting_count": len(args), + "total_reposting_count": len(items_to_be_repost), }, doctype=doc.doctype, docname=doc.name, @@ -443,23 +363,27 @@ def create_file(doc, compressed_content): return _file.file_url -def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposting_data=None): - if not reposting_data and doc and doc.reposting_data_file: - reposting_data = get_reposting_data(doc.reposting_data_file) +def validate_item_warehouse(args): + for field in ["item_code", "warehouse", "posting_date", "posting_time"]: + if args.get(field) in [None, ""]: + validation_msg = f"The field {frappe.unscrub(field)} is required for the reposting" + frappe.throw(_(validation_msg)) + +def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposting_data=None): if reposting_data and reposting_data.items_to_be_repost: return reposting_data.items_to_be_repost items_to_be_repost = [] if doc and doc.items_to_be_repost: - items_to_be_repost = json.loads(doc.items_to_be_repost) or [] + items_to_be_repost = json.loads(doc.items_to_be_repost) if not items_to_be_repost and voucher_type and voucher_no: items_to_be_repost = frappe.db.get_all( "Stock Ledger Entry", filters={"voucher_type": voucher_type, "voucher_no": voucher_no}, - fields=["item_code", "warehouse", "posting_date", "posting_time", "creation"], + fields=["item_code", "warehouse", "posting_date", "posting_time", "creation", "posting_datetime"], order_by="creation asc", group_by="item_code, warehouse", ) @@ -467,51 +391,54 @@ def get_items_to_be_repost(voucher_type=None, voucher_no=None, doc=None, reposti return items_to_be_repost or [] -def get_distinct_item_warehouse(args=None, doc=None, reposting_data=None): - if not reposting_data and doc and doc.reposting_data_file: - reposting_data = get_reposting_data(doc.reposting_data_file) - - if reposting_data and reposting_data.distinct_item_and_warehouse: - return parse_distinct_items_and_warehouses(reposting_data.distinct_item_and_warehouse) - - distinct_item_warehouses = {} - - if doc and doc.distinct_item_and_warehouse: - distinct_item_warehouses = json.loads(doc.distinct_item_and_warehouse) - distinct_item_warehouses = { - frappe.safe_eval(k): frappe._dict(v) for k, v in distinct_item_warehouses.items() - } - else: - for i, d in enumerate(args): - distinct_item_warehouses.setdefault( - (d.item_code, d.warehouse), frappe._dict({"reposting_status": False, "sle": d, "args_idx": i}) - ) - - return distinct_item_warehouses - - -def parse_distinct_items_and_warehouses(distinct_items_and_warehouses): - new_dict = frappe._dict({}) - - # convert string keys to tuple - for k, v in distinct_items_and_warehouses.items(): - new_dict[frappe.safe_eval(k)] = frappe._dict(v) - - return new_dict - - def get_affected_transactions(doc, reposting_data=None) -> set[tuple[str, str]]: if not reposting_data and doc and doc.reposting_data_file: reposting_data = get_reposting_data(doc.reposting_data_file) - if reposting_data and reposting_data.affected_transactions: - return {tuple(transaction) for transaction in reposting_data.affected_transactions} + if reposting_data and reposting_data.repost_affected_transaction: + return {tuple(transaction) for transaction in reposting_data.repost_affected_transaction} - if not doc.affected_transactions: - return set() + return set() - transactions = frappe.parse_json(doc.affected_transactions) - return {tuple(transaction) for transaction in transactions} + +def get_item_wh_wise_last_posted_sle_from_reposting_data(doc, reposting_data=None): + if not reposting_data and doc and doc.reposting_data_file: + reposting_data = get_reposting_data(doc.reposting_data_file) + + if reposting_data and reposting_data.item_wh_wise_last_posted_sle: + return frappe._dict(reposting_data.item_wh_wise_last_posted_sle) + + return frappe._dict() + + +def get_reposting_data(file_path) -> dict: + file_name = frappe.db.get_value( + "File", + { + "file_url": file_path, + "attached_to_field": "reposting_data_file", + }, + "name", + ) + + if not file_name: + return frappe._dict() + + attached_file = frappe.get_doc("File", file_name) + + content = attached_file.get_content() + if isinstance(content, str): + content = content.encode("utf-8") + + try: + data = gzip.decompress(content) + except Exception: + return frappe._dict() + + if data := json.loads(data.decode("utf-8")): + data = data + + return parse_json(data) def get_current_index(doc=None): @@ -547,6 +474,10 @@ class update_entries_after: self.allow_zero_rate = allow_zero_rate self.via_landed_cost_voucher = via_landed_cost_voucher self.item_code = args.get("item_code") + self.stock_ledgers_to_repost = [] + self.current_idx = args.get("current_idx", 0) + self.repost_doc = args.get("repost_doc") or None + self.items_to_be_repost = args.get("items_to_be_repost") or None self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed( item_code=self.item_code @@ -556,17 +487,20 @@ class update_entries_after: if self.args.sle_id: self.args["name"] = self.args.sle_id + self.prev_sle_dict = frappe._dict({}) self.company = frappe.get_cached_value("Warehouse", self.args.warehouse, "company") self.set_precision() self.valuation_method = get_valuation_method(self.item_code) + self.repost_affected_transaction = args.get("repost_affected_transaction") or set() self.new_items_found = False - self.distinct_item_warehouses = args.get("distinct_item_warehouses", frappe._dict()) - self.affected_transactions: set[tuple[str, str]] = set() self.reserved_stock = self.get_reserved_stock() self.data = frappe._dict() - self.initialize_previous_data(self.args) + + if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"): + self.initialize_previous_data(self.args) + self.build() def get_reserved_stock(self): @@ -613,7 +547,14 @@ class update_entries_after: """ self.data.setdefault(args.warehouse, frappe._dict()) warehouse_dict = self.data[args.warehouse] + + if self.stock_ledgers_to_repost: + return + previous_sle = get_previous_sle_of_current_voucher(args) + if previous_sle: + self.prev_sle_dict[(args.get("item_code"), args.get("warehouse"))] = previous_sle + warehouse_dict.previous_sle = previous_sle for key in ("qty_after_transaction", "valuation_rate", "stock_value"): @@ -635,27 +576,182 @@ class update_entries_after: if not future_sle_exists(self.args): self.update_bin() else: - entries_to_fix = self.get_future_entries_to_fix() + self.item_wh_wise_last_posted_sle = self.get_item_wh_wise_last_posted_sle() + _item_wh_sle = self.sort_sles(self.item_wh_wise_last_posted_sle.values()) - i = 0 - while i < len(entries_to_fix): - sle = entries_to_fix[i] - i += 1 + while _item_wh_sle: + self.initialize_reposting() + sle_dict = _item_wh_sle.pop(0) + self.repost_stock_ledgers(sle_dict) - self.process_sle(sle) - self.update_bin_data(sle) - - if sle.dependant_sle_voucher_detail_no: - entries_to_fix = self.get_dependent_entries_to_fix(entries_to_fix, sle) - if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no): - # for repack entries, we need to repost both source and target warehouses - self.update_distinct_item_warehouses_for_repack(sle) + self.update_bin() + self.reset_vouchers_and_idx() + self.update_data_in_repost() if self.exceptions: self.raise_exceptions() - def update_distinct_item_warehouses_for_repack(self, sle): - sles = ( + def initialize_reposting(self): + self._sles = [] + self.distinct_sles = set() + self.distinct_dependant_item_wh = set() + self.prev_sle_dict = frappe._dict({}) + + def get_item_wh_wise_last_posted_sle(self): + if self.args and self.args.get("item_wh_wise_last_posted_sle"): + _sles = {} + for key, sle in self.args.get("item_wh_wise_last_posted_sle").items(): + _sles[frappe.safe_eval(key)] = frappe._dict(sle) + + return _sles + + return { + (self.args.item_code, self.args.warehouse): frappe._dict( + { + "item_code": self.args.item_code, + "warehouse": self.args.warehouse, + "posting_datetime": get_combine_datetime(self.args.posting_date, self.args.posting_time), + "posting_date": self.args.posting_date, + "posting_time": self.args.posting_time, + "creation": self.args.creation, + } + ) + } + + def repost_stock_ledgers(self, sle_dict=None): + self._sles = self.get_future_entries_to_repost(sle_dict) + + if not isinstance(self._sles, deque): + self._sles = deque(self._sles) + + i = 0 + while self._sles: + sle = self._sles.popleft() + i += 1 + if sle.name in self.distinct_sles: + continue + + item_wh_key = (sle.item_code, sle.warehouse) + if item_wh_key not in self.prev_sle_dict: + self.prev_sle_dict[item_wh_key] = get_previous_sle_of_current_voucher(sle) + + self.repost_stock_ledger_entry(sle) + + # To avoid duplicate reposting of same sle in case of multiple dependant sle + self.distinct_sles.add(sle.name) + + if sle.dependant_sle_voucher_detail_no: + self.include_dependant_sle_in_reposting(sle) + self.update_item_wh_wise_last_posted_sle(sle) + + if i % 1000 == 0: + self.update_data_in_repost(len(self._sles), i) + + def sort_sles(self, sles): + return sorted( + sles, + key=lambda d: ( + get_datetime(d.posting_datetime), + get_datetime(d.creation), + ), + ) + + def include_dependant_sle_in_reposting(self, sle): + repost_dependant_sle = False + if sle.voucher_type == "Stock Entry" and is_repack_entry(sle.voucher_no): + repack_sles = self.get_sles_for_repack(sle) + for repack_sle in repack_sles: + if (repack_sle.item_code, repack_sle.warehouse) in self.distinct_dependant_item_wh: + continue + + repost_dependant_sle = True + self.distinct_dependant_item_wh.add((repack_sle.item_code, repack_sle.warehouse)) + self._sles.extend(self.get_future_entries_to_repost(repack_sle)) + else: + dependant_sles = get_sle_by_voucher_detail_no(sle.dependant_sle_voucher_detail_no) + for depend_sle in dependant_sles: + if (depend_sle.item_code, depend_sle.warehouse) in self.distinct_dependant_item_wh: + continue + + repost_dependant_sle = True + self.distinct_dependant_item_wh.add((depend_sle.item_code, depend_sle.warehouse)) + self._sles.extend(self.get_future_entries_to_repost(depend_sle)) + + if repost_dependant_sle: + self._sles = deque(self.sort_sles(self._sles)) + + def repost_stock_ledger_entry(self, sle): + if isinstance(sle, dict): + sle = frappe._dict(sle) + + self.process_sle(sle) + self.update_item_wh_wise_last_posted_sle(sle) + + def update_item_wh_wise_last_posted_sle(self, sle): + if not self._sles: + self.item_wh_wise_last_posted_sle = frappe._dict() + return + + self.item_wh_wise_last_posted_sle[(sle.item_code, sle.warehouse)] = frappe._dict( + { + "item_code": sle.item_code, + "warehouse": sle.warehouse, + "posting_date": sle.posting_date, + "posting_time": sle.posting_time, + "posting_datetime": sle.posting_datetime + or get_combine_datetime(sle.posting_date, sle.posting_time), + "creation": sle.creation, + } + ) + + def reset_vouchers_and_idx(self): + self.stock_ledgers_to_repost = [] + self.prev_sle_dict = frappe._dict() + self.item_wh_wise_last_posted_sle = frappe._dict() + + def update_data_in_repost(self, total_sles=None, index=None): + if not self.repost_doc: + return + + values_to_update = { + "total_vouchers": cint(total_sles) + cint(index), + "vouchers_posted": index or 0, + } + + self.repost_doc.db_set(values_to_update) + + update_args_in_repost_item_valuation( + self.repost_doc, + self.current_idx, + self.items_to_be_repost, + self.repost_affected_transaction, + self.item_wh_wise_last_posted_sle, + only_affected_transaction=True, + ) + + if not frappe.flags.in_test: + # To maintain the state of the reposting, so if timeout happens, it can be resumed from the last posted voucher + frappe.db.commit() # nosemgrep + + self.publish_real_time_progress(total_sles=total_sles, index=index) + + def publish_real_time_progress(self, total_sles=None, index=None): + frappe.publish_realtime( + "item_reposting_progress", + { + "name": self.repost_doc.name, + "total_vouchers": cint(total_sles) + cint(index), + "vouchers_posted": index or 0, + }, + doctype=self.repost_doc.doctype, + docname=self.repost_doc.name, + ) + + def get_future_entries_to_repost(self, kwargs): + return get_stock_ledger_entries(kwargs, ">=", "asc", for_update=True, check_serial_no=False) + + def get_sles_for_repack(self, sle): + return ( frappe.get_all( "Stock Ledger Entry", filters={ @@ -663,16 +759,20 @@ class update_entries_after: "voucher_no": sle.voucher_no, "actual_qty": (">", 0), "is_cancelled": 0, - "voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no), + "dependant_sle_voucher_detail_no": ("!=", sle.dependant_sle_voucher_detail_no), }, - fields=["*"], + fields=[ + "item_code", + "warehouse", + "posting_date", + "posting_time", + "posting_datetime", + "creation", + ], ) or [] ) - for dependant_sle in sles: - self.update_distinct_item_warehouses(dependant_sle) - def has_stock_reco_with_serial_batch(self, sle): if ( sle.voucher_type == "Stock Reconciliation" @@ -683,35 +783,10 @@ class update_entries_after: return False def process_sle_against_current_timestamp(self): - sl_entries = self.get_sle_against_current_voucher() + sl_entries = get_sle_against_current_voucher(self.args) for sle in sl_entries: self.process_sle(sle) - def get_sle_against_current_voucher(self): - self.args["posting_datetime"] = get_combine_datetime(self.args.posting_date, self.args.posting_time) - - return frappe.db.sql( - """ - select - *, posting_datetime as "timestamp" - from - `tabStock Ledger Entry` - where - item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and ( - posting_datetime = %(posting_datetime)s - ) - and creation = %(creation)s - order by - creation ASC - for update - """, - self.args, - as_dict=1, - ) - def get_future_entries_to_fix(self): # includes current entry! args = self.data[self.args.warehouse].previous_sle or frappe._dict( @@ -720,78 +795,8 @@ class update_entries_after: return list(self.get_sle_after_datetime(args)) - def get_dependent_entries_to_fix(self, entries_to_fix, sle): - dependant_sle = get_sle_by_voucher_detail_no( - sle.dependant_sle_voucher_detail_no, excluded_sle=sle.name - ) - - if not dependant_sle: - return entries_to_fix - elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse == self.args.warehouse: - return entries_to_fix - elif dependant_sle.item_code != self.item_code: - self.update_distinct_item_warehouses(dependant_sle) - return entries_to_fix - elif dependant_sle.item_code == self.item_code and dependant_sle.warehouse in self.data: - return entries_to_fix - else: - self.initialize_previous_data(dependant_sle) - self.update_distinct_item_warehouses(dependant_sle) - return entries_to_fix - - def update_distinct_item_warehouses(self, dependant_sle): - key = (dependant_sle.item_code, dependant_sle.warehouse) - val = frappe._dict({"sle": dependant_sle}) - - if key not in self.distinct_item_warehouses: - self.distinct_item_warehouses[key] = val - self.new_items_found = True - else: - existing_sle = self.distinct_item_warehouses[key].get("sle", {}) - if getdate(existing_sle.get("posting_date")) > getdate(dependant_sle.posting_date): - self.distinct_item_warehouses[key] = val - self.new_items_found = True - elif ( - dependant_sle.actual_qty > 0 - and dependant_sle.voucher_type == "Stock Entry" - and is_transfer_stock_entry(dependant_sle.voucher_no) - ): - if self.distinct_item_warehouses[key].get("transfer_entry_to_repost"): - return - - val["transfer_entry_to_repost"] = True - self.distinct_item_warehouses[key] = val - self.new_items_found = True - - def is_dependent_voucher_reposted(self, dependant_sle) -> bool: - # Return False if the dependent voucher is not reposted - - if self.args.items_to_be_repost and self.args.current_index: - index = self.args.current_index - while index < len(self.args.items_to_be_repost): - if ( - self.args.items_to_be_repost[index].get("item_code") == dependant_sle.item_code - and self.args.items_to_be_repost[index].get("warehouse") == dependant_sle.warehouse - ): - if getdate(self.args.items_to_be_repost[index].get("posting_date")) > getdate( - dependant_sle.posting_date - ): - self.args.items_to_be_repost[index]["posting_date"] = dependant_sle.posting_date - - return False - - index += 1 - - return True - - def get_dependent_voucher_detail_nos(self, key): - if "dependent_voucher_detail_nos" not in self.distinct_item_warehouses[key]: - self.distinct_item_warehouses[key].dependent_voucher_detail_nos = [] - - return self.distinct_item_warehouses[key].dependent_voucher_detail_nos - def validate_previous_sle_qty(self, sle): - previous_sle = self.data[sle.warehouse].previous_sle + previous_sle = self.prev_sle_dict.get((sle.item_code, sle.warehouse)) if previous_sle and previous_sle.get("qty_after_transaction") < 0 and sle.get("actual_qty") > 0: frappe.msgprint( _( @@ -810,10 +815,32 @@ class update_entries_after: def process_sle(self, sle): # previous sle data for this warehouse - self.wh_data = self.data[sle.warehouse] + key = (sle.item_code, sle.warehouse) + if key not in self.prev_sle_dict: + prev_sle = get_previous_sle_of_current_voucher(sle) + if prev_sle: + self.prev_sle_dict[key] = prev_sle + + if not self.prev_sle_dict.get(key): + self.prev_sle_dict[key] = frappe._dict( + { + "qty_after_transaction": 0.0, + "valuation_rate": 0.0, + "stock_value": 0.0, + "prev_stock_value": 0.0, + "stock_queue": [], + } + ) + + self.wh_data = self.prev_sle_dict.get(key) + + if self.wh_data.stock_queue and isinstance(self.wh_data.stock_queue, str): + self.wh_data.stock_queue = json.loads(self.wh_data.stock_queue) + + if not self.wh_data.prev_stock_value: + self.wh_data.prev_stock_value = self.wh_data.stock_value self.validate_previous_sle_qty(sle) - self.affected_transactions.add((sle.voucher_type, sle.voucher_no)) if (sle.serial_no and not self.via_landed_cost_voucher) or not cint(self.allow_negative_stock): # validate negative stock for serialized items, fifo valuation @@ -915,7 +942,10 @@ class update_entries_after: sle.stock_value = self.wh_data.stock_value sle.stock_queue = json.dumps(self.wh_data.stock_queue) + old_stock_value_difference = sle.stock_value_difference + sle.stock_value_difference = stock_value_difference + if ( sle.is_adjustment_entry and flt(sle.qty_after_transaction, self.flt_precision) == 0 @@ -940,11 +970,21 @@ class update_entries_after: sle.modified = now() frappe.get_doc(sle).db_update() + self.prev_sle_dict[key] = sle + if not self.args.get("sle_id") or ( sle.serial_and_batch_bundle and sle.auto_created_serial_and_batch_bundle ): self.update_outgoing_rate_on_transaction(sle) + if flt(old_stock_value_difference, self.currency_precision) == flt( + sle.stock_value_difference, self.currency_precision + ): + return + + if self.args.item_code != sle.item_code or self.args.warehouse != sle.warehouse: + self.repost_affected_transaction.add((sle.voucher_type, sle.voucher_no)) + def get_serialized_values(self, sle): from erpnext.stock.serial_batch_bundle import SerialNoValuation @@ -1713,15 +1753,42 @@ class update_entries_after: def update_bin(self): # update bin for each warehouse - for warehouse, data in self.data.items(): - bin_name = get_or_make_bin(self.item_code, warehouse) + for (item_code, warehouse), data in self.prev_sle_dict.items(): + bin_name = get_or_make_bin(item_code, warehouse) - updated_values = {"actual_qty": data.qty_after_transaction, "stock_value": data.stock_value} + updated_values = { + "actual_qty": flt(data.qty_after_transaction), + "stock_value": flt(data.stock_value), + } if data.valuation_rate is not None: - updated_values["valuation_rate"] = data.valuation_rate + updated_values["valuation_rate"] = flt(data.valuation_rate) + frappe.db.set_value("Bin", bin_name, updated_values, update_modified=True) +def get_sle_against_current_voucher(kwargs): + kwargs["posting_datetime"] = get_combine_datetime(kwargs.posting_date, kwargs.posting_time) + doctype = frappe.qb.DocType("Stock Ledger Entry") + + query = ( + frappe.qb.from_(doctype) + .select("*") + .where( + (doctype.item_code == kwargs.item_code) + & (doctype.warehouse == kwargs.warehouse) + & (doctype.is_cancelled == 0) + & (doctype.posting_datetime == kwargs.posting_datetime) + ) + .orderby(doctype.creation, order=Order.asc) + .for_update() + ) + + if not kwargs.get("cancelled"): + query = query.where(doctype.creation == kwargs.creation) + + return query.run(as_dict=True) + + def get_previous_sle_of_current_voucher(args, operator="<", exclude_current_voucher=False): """get stock ledger entries filtered by specific posting datetime conditions""" @@ -1874,23 +1941,15 @@ def get_stock_ledger_entries( ) -def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None): - return frappe.db.get_value( +def get_sle_by_voucher_detail_no(voucher_detail_no): + return frappe.get_all( "Stock Ledger Entry", - {"voucher_detail_no": voucher_detail_no, "name": ["!=", excluded_sle], "is_cancelled": 0}, - [ - "item_code", - "warehouse", - "actual_qty", - "qty_after_transaction", - "posting_date", - "posting_time", - "voucher_detail_no", - "posting_datetime as timestamp", - "voucher_type", - "voucher_no", - ], - as_dict=1, + filters={ + "voucher_detail_no": voucher_detail_no, + "is_cancelled": 0, + "dependant_sle_voucher_detail_no": ("is", "not set"), + }, + fields=["item_code", "warehouse", "posting_date", "posting_time", "posting_datetime", "creation"], )