diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py index 43c72938f64..a8e073c353c 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py @@ -728,9 +728,10 @@ class PurchaseInvoice(BuyingController): for item in self.get("items"): if item.purchase_receipt: frappe.throw( - _("Stock cannot be updated against Purchase Receipt {0}").format( - item.purchase_receipt - ) + _( + "Stock cannot be updated for Purchase Invoice {0} because a Purchase Receipt {1} has already been created for this transaction. Please disable the 'Update Stock' checkbox in the Purchase Invoice and save the invoice." + ).format(self.name, item.purchase_receipt), + title=_("Stock Update Not Allowed"), ) def validate_for_repost(self): diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py index 802705f3470..cf4377bd0df 100644 --- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py @@ -1199,6 +1199,9 @@ class SalesInvoice(SellingController): throw(_("Delivery Note {0} is not submitted").format(d.delivery_note)) def process_asset_depreciation(self): + if self.is_internal_transfer(): + return + if (self.is_return and self.docstatus == 2) or (not self.is_return and self.docstatus == 1): self.depreciate_asset_on_sale() else: diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js index 4eadf342be8..fa651541696 100644 --- a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.js @@ -37,6 +37,20 @@ function get_filters() { }); }, }, + { + fieldname: "party_type", + label: __("Party Type"), + fieldtype: "Link", + options: "Party Type", + width: 100, + }, + { + fieldname: "party", + label: __("Party"), + fieldtype: "Dynamic Link", + options: "party_type", + width: 100, + }, { fieldname: "voucher_no", label: __("Voucher No"), diff --git a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py index 9d079eb9ebd..e28bd4edefe 100644 --- a/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py +++ b/erpnext/accounts/report/general_and_payment_ledger_comparison/general_and_payment_ledger_comparison.py @@ -68,6 +68,12 @@ class General_Payment_Ledger_Comparison: if self.filters.period_end_date: filter_criterion.append(gle.posting_date.lte(self.filters.period_end_date)) + if self.filters.party_type: + filter_criterion.append(gle.party_type.eq(self.filters.party_type)) + + if self.filters.party: + filter_criterion.append(gle.party.eq(self.filters.party)) + if acc_type == "receivable": outstanding = (Sum(gle.debit) - Sum(gle.credit)).as_("outstanding") else: @@ -111,6 +117,12 @@ class General_Payment_Ledger_Comparison: if self.filters.period_end_date: filter_criterion.append(ple.posting_date.lte(self.filters.period_end_date)) + if self.filters.party_type: + filter_criterion.append(ple.party_type.eq(self.filters.party_type)) + + if self.filters.party: + filter_criterion.append(ple.party.eq(self.filters.party)) + self.account_types[acc_type].ple = ( qb.from_(ple) .select( diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py index 55ab95ac662..a53c2134e3f 100644 --- a/erpnext/accounts/report/gross_profit/gross_profit.py +++ b/erpnext/accounts/report/gross_profit/gross_profit.py @@ -649,7 +649,7 @@ class GrossProfitGenerator: new_row = row self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion) else: - new_row.qty += flt(row.qty) + new_row.qty = flt((new_row.qty + row.qty), self.float_precision) self.set_average_based_on_payment_term_portion(new_row, row, invoice_portion, True) new_row = self.set_average_rate(new_row) @@ -659,11 +659,17 @@ class GrossProfitGenerator: if i == 0: new_row = row else: - new_row.qty += flt(row.qty) - new_row.buying_amount += flt(row.buying_amount, self.currency_precision) - new_row.base_amount += flt(row.base_amount, self.currency_precision) + new_row.qty = flt((new_row.qty + row.qty), self.float_precision) + new_row.buying_amount = flt( + (new_row.buying_amount + row.buying_amount), self.currency_precision + ) + new_row.base_amount = flt( + (new_row.base_amount + row.base_amount), self.currency_precision + ) if self.filters.get("group_by") == "Sales Person": - new_row.allocated_amount += flt(row.allocated_amount, self.currency_precision) + new_row.allocated_amount = flt( + (new_row.allocated_amount + row.allocated_amount), self.currency_precision + ) new_row = self.set_average_rate(new_row) self.grouped_data.append(new_row) diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.js b/erpnext/assets/doctype/asset_repair/asset_repair.js index 48bf4ff478d..646a7eee7ef 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.js +++ b/erpnext/assets/doctype/asset_repair/asset_repair.js @@ -111,16 +111,12 @@ frappe.ui.form.on("Asset Repair", { purchase_invoice: function (frm) { if (frm.doc.purchase_invoice) { frappe.call({ - method: "frappe.client.get_value", + method: "erpnext.assets.doctype.asset_repair.asset_repair.get_repair_cost_for_purchase_invoice", args: { - doctype: "Purchase Invoice", - fieldname: "base_net_total", - filters: { name: frm.doc.purchase_invoice }, + purchase_invoice: frm.doc.purchase_invoice, }, callback: function (r) { - if (r.message) { - frm.set_value("repair_cost", r.message.base_net_total); - } + frm.set_value("repair_cost", r.message || 0); }, }); } else { diff --git a/erpnext/assets/doctype/asset_repair/asset_repair.py b/erpnext/assets/doctype/asset_repair/asset_repair.py index e0d41919c4a..7e17dd70e46 100644 --- a/erpnext/assets/doctype/asset_repair/asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/asset_repair.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.query_builder.functions import Sum from frappe.utils import add_months, cint, flt, get_link_to_form, getdate, time_diff_in_hours import erpnext @@ -308,9 +309,14 @@ class AssetRepair(AccountsController): if flt(self.repair_cost) <= 0: return - pi_expense_account = ( - frappe.get_doc("Purchase Invoice", self.purchase_invoice).items[0].expense_account - ) + expense_accounts = _get_expense_accounts_for_purchase_invoice(self.purchase_invoice) + + if not expense_accounts: + frappe.throw( + _("No expense accounts found for Purchase Invoice {0}").format(self.purchase_invoice) + ) + + pi_expense_account = expense_accounts[0] gl_entries.append( self.get_gl_dict( @@ -473,3 +479,84 @@ class AssetRepair(AccountsController): def get_downtime(failure_date, completion_date): downtime = time_diff_in_hours(completion_date, failure_date) return round(downtime, 2) + + +@frappe.whitelist() +def get_repair_cost_for_purchase_invoice(purchase_invoice: str) -> float: + """ + Get the total repair cost from GL entries for a purchase invoice. + Only considers expense accounts for non-stock, non-fixed-asset items. + """ + if not purchase_invoice: + return 0.0 + + frappe.has_permission("Purchase Invoice", "read", purchase_invoice, throw=True) + + expense_accounts = _get_expense_accounts_for_purchase_invoice(purchase_invoice) + + if not expense_accounts: + return 0.0 + + return _get_total_expense_amount(purchase_invoice, expense_accounts) + + +def _get_expense_accounts_for_purchase_invoice(purchase_invoice: str) -> list[str]: + """ + Get expense accounts for non-stock items from the purchase invoice. + """ + pi_items = frappe.get_all( + "Purchase Invoice Item", + filters={"parent": purchase_invoice}, + fields=["item_code", "expense_account", "is_fixed_asset"], + ) + + if not pi_items: + return [] + + # Get list of stock item codes from the invoice + item_codes = {item.item_code for item in pi_items if item.item_code} + stock_items = set() + if item_codes: + stock_items = set( + frappe.db.get_all( + "Item", filters={"name": ["in", list(item_codes)], "is_stock_item": 1}, pluck="name" + ) + ) + + expense_accounts = set() + + for item in pi_items: + # Skip stock items - they use warehouse accounts + if item.item_code and item.item_code in stock_items: + continue + + # Skip fixed assets - they use asset accounts + if item.is_fixed_asset: + continue + + # Use expense account from Purchase Invoice Item + if item.expense_account: + expense_accounts.add(item.expense_account) + + return list(expense_accounts) + + +def _get_total_expense_amount(purchase_invoice: str, expense_accounts: list[str]) -> float: + """Get the total expense amount from GL entries for a purchase invoice and accounts.""" + if not expense_accounts: + return 0.0 + + gl_entry = frappe.qb.DocType("GL Entry") + + result = ( + frappe.qb.from_(gl_entry) + .select((Sum(gl_entry.debit) - Sum(gl_entry.credit)).as_("total")) + .where( + (gl_entry.voucher_type == "Purchase Invoice") + & (gl_entry.voucher_no == purchase_invoice) + & (gl_entry.account.isin(expense_accounts)) + & (gl_entry.is_cancelled == 0) + ) + ).run(as_dict=True) + + return flt(result[0].total) if result else 0.0 diff --git a/erpnext/assets/doctype/asset_repair/test_asset_repair.py b/erpnext/assets/doctype/asset_repair/test_asset_repair.py index 4cd304fbfd0..3a92f0ec71a 100644 --- a/erpnext/assets/doctype/asset_repair/test_asset_repair.py +++ b/erpnext/assets/doctype/asset_repair/test_asset_repair.py @@ -8,6 +8,7 @@ from frappe import qb from frappe.query_builder.functions import Sum from frappe.utils import add_days, add_months, flt, get_first_day, nowdate, nowtime, today +from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.assets.doctype.asset.asset import ( get_asset_account, get_asset_value_after_depreciation, @@ -21,6 +22,7 @@ from erpnext.assets.doctype.asset.test_asset import ( from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_schedule import ( get_asset_depr_schedule_doc, ) +from erpnext.assets.doctype.asset_repair.asset_repair import get_repair_cost_for_purchase_invoice from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import ( get_serial_nos_from_bundle, @@ -321,6 +323,59 @@ class TestAssetRepair(unittest.TestCase): self.assertEqual(asset.additional_asset_cost, asset_repair.repair_cost) self.assertEqual(booked_value, asset_repair.repair_cost) + def test_repair_cost_fetches_only_service_item_amount(self): + """Test that repair cost only includes service (non-stock) item amounts from purchase invoice.""" + + company = "_Test Company with perpetual inventory" + warehouse = "Stores - TCP1" + + service_item = create_item( + "_Test Service Item for Repair", + is_stock_item=0, + warehouse=warehouse, + company=company, + ) + + stock_item = create_item( + "_Test Stock Item for Repair", + is_stock_item=1, + warehouse=warehouse, + company=company, + ) + + service_expense_account = "Miscellaneous Expenses - TCP1" + cost_center = frappe.db.get_value("Company", company, "cost_center") + + pi = make_purchase_invoice( + item_code=service_item.name, + qty=1, + rate=500, + expense_account=service_expense_account, + cost_center=cost_center, + warehouse=warehouse, + update_stock=0, + do_not_submit=1, + company=company, + ) + + pi.update_stock = 1 + pi.append( + "items", + { + "item_code": stock_item.name, + "qty": 2, + "rate": 300, + "warehouse": warehouse, + "cost_center": cost_center, + }, + ) + pi.save() + pi.submit() + + repair_cost = get_repair_cost_for_purchase_invoice(pi.name) + + self.assertEqual(repair_cost, 500) + def num_of_depreciations(asset): return asset.finance_books[0].total_number_of_depreciations @@ -411,6 +466,7 @@ def create_asset_repair(**args): if asset.calculate_depreciation: asset_repair.increase_in_asset_life = 12 pi = make_purchase_invoice( + item=args.item or "_Test Non Stock Item", company=asset.company, expense_account=frappe.db.get_value("Company", asset.company, "default_expense_account"), cost_center=asset_repair.cost_center, diff --git a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py index da4c78347f3..47e2cbee338 100644 --- a/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py +++ b/erpnext/buying/doctype/supplier_quotation/test_supplier_quotation.py @@ -37,7 +37,7 @@ class TestPurchaseOrder(FrappeTestCase): self.assertEqual(sq.get("items")[0].qty, 5) self.assertEqual(sq.get("items")[1].rate, 300) - def test_update_supplier_quotation_child_rate_disallow(self): + def test_update_supplier_quotation_child_rate(self): sq = frappe.copy_doc(test_records[0]) sq.submit() trans_item = json.dumps( @@ -50,6 +50,22 @@ class TestPurchaseOrder(FrappeTestCase): }, ] ) + update_child_qty_rate("Supplier Quotation", trans_item, sq.name) + sq.reload() + self.assertEqual(sq.get("items")[0].rate, 300) + po = make_purchase_order(sq.name) + po.schedule_date = add_days(today(), 1) + po.submit() + trans_item = json.dumps( + [ + { + "item_code": sq.items[0].item_code, + "rate": 20, + "qty": sq.items[0].qty, + "docname": sq.items[0].name, + }, + ] + ) self.assertRaises( frappe.ValidationError, update_child_qty_rate, "Supplier Quotation", trans_item, sq.name ) diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 51f19b0dd27..008402eeb53 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -3838,20 +3838,28 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False return False - def validate_quantity(child_item, new_data): + def validate_quantity_and_rate(child_item, new_data): if not flt(new_data.get("qty")) and not is_allowed_zero_qty(): frappe.throw( - _("Row #{0}: Quantity for Item {1} cannot be zero.").format( + _("Row #{0}:Quantity for Item {1} cannot be zero.").format( new_data.get("idx"), frappe.bold(new_data.get("item_code")) ), title=_("Invalid Qty"), ) - if parent_doctype == "Sales Order" and flt(new_data.get("qty")) < flt(child_item.delivered_qty): - frappe.throw(_("Cannot set quantity less than delivered quantity")) + qty_limits = { + "Sales Order": ("delivered_qty", _("Cannot set quantity less than delivered quantity")), + "Purchase Order": ("received_qty", _("Cannot set quantity less than received quantity")), + } - if parent_doctype == "Purchase Order" and flt(new_data.get("qty")) < flt(child_item.received_qty): - frappe.throw(_("Cannot set quantity less than received quantity")) + if parent_doctype in qty_limits: + qty_field, error_message = qty_limits[parent_doctype] + if flt(new_data.get("qty")) < flt(child_item.get(qty_field)): + frappe.throw( + _("Row #{0}:").format(new_data.get("idx")) + + error_message.format(frappe.bold(new_data.get("item_code"))), + title=_("Invalid Qty"), + ) if parent_doctype in ["Quotation", "Supplier Quotation"]: if (parent_doctype == "Quotation" and not ordered_items) or ( @@ -3864,7 +3872,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil if parent_doctype == "Quotation" else purchased_items.get(child_item.name) ) + if qty_to_check: + if not rate_unchanged: + frappe.throw( + _( + "Cannot update rate as item {0} is already ordered or purchased against this quotation" + ).format(frappe.bold(new_data.get("item_code"))) + ) + if flt(new_data.get("qty")) < qty_to_check: frappe.throw(_("Cannot reduce quantity than ordered or purchased quantity")) @@ -3980,10 +3996,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil ): continue - validate_quantity(child_item, d) - if parent_doctype in ["Quotation", "Supplier Quotation"]: - if not rate_unchanged: - frappe.throw(_("Rates cannot be modified for quoted items")) + validate_quantity_and_rate(child_item, d) if flt(child_item.get("qty")) != flt(d.get("qty")): any_qty_changed = True diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index 5d1af9ea394..a4ee4daadb9 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -1135,6 +1135,16 @@ class StockController(AccountsController): continue if qi_required: # validate row only if inspection is required on item level + if self.doctype in [ + "Purchase Receipt", + "Purchase Invoice", + "Sales Invoice", + "Delivery Note", + ] and frappe.get_single_value( + "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" + ): + return + self.validate_qi_presence(row) if self.docstatus == 1: self.validate_qi_submission(row) @@ -1142,16 +1152,6 @@ class StockController(AccountsController): def validate_qi_presence(self, row): """Check if QI is present on row level. Warn on save and stop on submit if missing.""" - if self.doctype in [ - "Purchase Receipt", - "Purchase Invoice", - "Sales Invoice", - "Delivery Note", - ] and frappe.db.get_single_value( - "Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery" - ): - return - if not row.quality_inspection: msg = _("Row #{0}: Quality Inspection is required for Item {1}").format( row.idx, frappe.bold(row.item_code) diff --git a/erpnext/controllers/subcontracting_controller.py b/erpnext/controllers/subcontracting_controller.py index 246693d2d01..66109479d8c 100644 --- a/erpnext/controllers/subcontracting_controller.py +++ b/erpnext/controllers/subcontracting_controller.py @@ -1297,6 +1297,55 @@ def make_rm_stock_entry( if target_doc and target_doc.get("items"): target_doc.items = [] + def post_process(source_doc, target_doc): + target_doc.purpose = "Send to Subcontractor" + + if order_doctype == "Purchase Order": + target_doc.purchase_order = source_doc.name + else: + target_doc.subcontracting_order = source_doc.name + + target_doc.set_stock_entry_type() + + for fg_item_code in fg_item_code_list: + for rm_item in rm_items: + if ( + rm_item.get("main_item_code") == fg_item_code + or rm_item.get("item_code") == fg_item_code + ): + rm_item_code = rm_item.get("rm_item_code") + + items_dict = { + rm_item_code: { + rm_detail_field: rm_item.get("name"), + "item_name": rm_item.get("item_name") + or item_wh.get(rm_item_code, {}).get("item_name", ""), + "description": item_wh.get(rm_item_code, {}).get("description", ""), + "qty": rm_item.get("qty") + or max( + rm_item.get("required_qty") - rm_item.get("total_supplied_qty"), 0 + ), + "from_warehouse": rm_item.get("warehouse") + or rm_item.get("reserve_warehouse"), + "to_warehouse": source_doc.supplier_warehouse, + "stock_uom": rm_item.get("stock_uom"), + "serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"), + "main_item_code": fg_item_code, + "allow_alternative_item": item_wh.get(rm_item_code, {}).get( + "allow_alternative_item" + ), + "use_serial_batch_fields": rm_item.get("use_serial_batch_fields"), + "serial_no": rm_item.get("serial_no") + if rm_item.get("use_serial_batch_fields") + else None, + "batch_no": rm_item.get("batch_no") + if rm_item.get("use_serial_batch_fields") + else None, + } + } + + target_doc.add_to_stock_entry_detail(items_dict) + stock_entry = get_mapped_doc( order_doctype, subcontract_order.name, @@ -1317,53 +1366,9 @@ def make_rm_stock_entry( }, target_doc, ignore_child_tables=True, + postprocess=post_process, ) - stock_entry.purpose = "Send to Subcontractor" - - if order_doctype == "Purchase Order": - stock_entry.purchase_order = subcontract_order.name - else: - stock_entry.subcontracting_order = subcontract_order.name - - stock_entry.set_stock_entry_type() - - for fg_item_code in fg_item_code_list: - for rm_item in rm_items: - if ( - rm_item.get("main_item_code") == fg_item_code - or rm_item.get("item_code") == fg_item_code - ): - rm_item_code = rm_item.get("rm_item_code") - items_dict = { - rm_item_code: { - rm_detail_field: rm_item.get("name"), - "item_name": rm_item.get("item_name") - or item_wh.get(rm_item_code, {}).get("item_name", ""), - "description": item_wh.get(rm_item_code, {}).get("description", ""), - "qty": rm_item.get("qty") - or max(rm_item.get("required_qty") - rm_item.get("total_supplied_qty"), 0), - "from_warehouse": rm_item.get("warehouse") - or rm_item.get("reserve_warehouse"), - "to_warehouse": subcontract_order.supplier_warehouse, - "stock_uom": rm_item.get("stock_uom"), - "serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"), - "main_item_code": fg_item_code, - "allow_alternative_item": item_wh.get(rm_item_code, {}).get( - "allow_alternative_item" - ), - "use_serial_batch_fields": rm_item.get("use_serial_batch_fields"), - "serial_no": rm_item.get("serial_no") - if rm_item.get("use_serial_batch_fields") - else None, - "batch_no": rm_item.get("batch_no") - if rm_item.get("use_serial_batch_fields") - else None, - } - } - - stock_entry.add_to_stock_entry_detail(items_dict) - if target_doc: return stock_entry else: @@ -1395,6 +1400,8 @@ def add_items_in_ste(ste_doc, row, qty, rm_details, rm_detail_field="sco_rm_deta def make_return_stock_entry_for_subcontract( available_materials, order_doc, rm_details, order_doctype="Subcontracting Order" ): + rm_detail_field = "po_detail" if order_doctype == "Purchase Order" else "sco_rm_detail" + def post_process(source_doc, target_doc): target_doc.purpose = "Material Transfer" @@ -1405,6 +1412,21 @@ def make_return_stock_entry_for_subcontract( target_doc.company = source_doc.company target_doc.is_return = 1 + for _key, value in available_materials.items(): + if not value.qty: + continue + + if item_details := value.get("item_details"): + item_details["serial_and_batch_bundle"] = None + + if value.batch_no: + for batch_no, qty in value.batch_no.items(): + if qty > 0: + add_items_in_ste(target_doc, value, qty, rm_details, rm_detail_field, batch_no) + else: + add_items_in_ste(target_doc, value, value.qty, rm_details, rm_detail_field) + + target_doc.set_stock_entry_type() ste_doc = get_mapped_doc( order_doctype, @@ -1419,27 +1441,6 @@ def make_return_stock_entry_for_subcontract( postprocess=post_process, ) - if order_doctype == "Purchase Order": - rm_detail_field = "po_detail" - else: - rm_detail_field = "sco_rm_detail" - - for _key, value in available_materials.items(): - if not value.qty: - continue - - if item_details := value.get("item_details"): - item_details["serial_and_batch_bundle"] = None - - if value.batch_no: - for batch_no, qty in value.batch_no.items(): - if qty > 0: - add_items_in_ste(ste_doc, value, qty, rm_details, rm_detail_field, batch_no) - else: - add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field) - - ste_doc.set_stock_entry_type() - return ste_doc diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index e3ee2baaa53..549493d4b92 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -1076,9 +1076,9 @@ class JobCard(Document): def is_work_order_closed(self): if self.work_order: - status = frappe.get_value("Work Order", self.work_order) + status = frappe.get_value("Work Order", self.work_order, "status") - if status == "Closed": + if status in ["Closed", "Stopped"]: return True return False diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 971b811fbcc..b6206cefcbb 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -425,10 +425,11 @@ frappe.ui.form.on("Work Order", { var added_min = false; // produced qty - var title = __("{0} items produced", [frm.doc.produced_qty]); + let produced_qty = frm.doc.produced_qty - frm.doc.disassembled_qty; + var title = __("{0} items produced", [produced_qty]); bars.push({ title: title, - width: (frm.doc.produced_qty / frm.doc.qty) * 100 + "%", + width: (flt(produced_qty) / frm.doc.qty) * 100 + "%", progress_class: "progress-bar-success", }); if (bars[0].width == "0%") { @@ -445,14 +446,27 @@ frappe.ui.form.on("Work Order", { if (pending_complete > 0) { var width = (pending_complete / frm.doc.qty) * 100 - added_min; title = __("{0} items in progress", [pending_complete]); + let progress_class = "progress-bar-warning"; + if (frm.doc.status == "Closed") { + if (frm.doc.required_items.find((d) => d.returned_qty > 0)) { + title = __("{0} items returned", [pending_complete]); + progress_class = "progress-bar-warning"; + } else { + title = __("{0} items to return", [pending_complete]); + progress_class = "progress-bar-info"; + } + } + bars.push({ title: title, width: (width > 100 ? "99.5" : width) + "%", - progress_class: "progress-bar-warning", + progress_class: progress_class, }); message = message + ". " + title; } } + + //process loss qty if (frm.doc.process_loss_qty) { var process_loss_width = (frm.doc.process_loss_qty / frm.doc.qty) * 100; title = __("{0} items lost during process.", [frm.doc.process_loss_qty]); @@ -463,6 +477,19 @@ frappe.ui.form.on("Work Order", { }); message = message + ". " + title; } + + // disassembled qty + if (frm.doc.disassembled_qty) { + var disassembled_width = (frm.doc.disassembled_qty / frm.doc.qty) * 100; + title = __("{0} items disassembled", [frm.doc.disassembled_qty]); + bars.push({ + title: title, + width: disassembled_width + "%", + progress_class: "progress-bar-secondary", + }); + message = message + ". " + title; + } + frm.dashboard.add_progress(__("Status"), bars, message); }, diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 14c458015be..590708ac275 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -220,39 +220,52 @@ class WorkOrder(Document): ) def validate_sales_order(self): + if self.production_plan_sub_assembly_item: + return + if self.sales_order: self.check_sales_order_on_hold_or_close() - so = frappe.db.sql( - """ - select so.name, so_item.delivery_date, so.project - from `tabSales Order` so - inner join `tabSales Order Item` so_item on so_item.parent = so.name - left join `tabProduct Bundle Item` pk_item on so_item.item_code = pk_item.parent - where so.name=%s and so.docstatus = 1 - and so.skip_delivery_note = 0 and ( - so_item.item_code=%s or - pk_item.item_code=%s ) - """, - (self.sales_order, self.production_item, self.production_item), - as_dict=1, + + SalesOrder = frappe.qb.DocType("Sales Order") + SalesOrderItem = frappe.qb.DocType("Sales Order Item") + PackedItem = frappe.qb.DocType("Packed Item") + ProductBundleItem = frappe.qb.DocType("Product Bundle Item") + + so = ( + frappe.qb.from_(SalesOrder) + .inner_join(SalesOrderItem) + .on(SalesOrderItem.parent == SalesOrder.name) + .left_join(ProductBundleItem) + .on(ProductBundleItem.parent == SalesOrderItem.item_code) + .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) + .where( + (SalesOrder.skip_delivery_note == 0) + & (SalesOrder.docstatus == 1) + & (SalesOrder.name == self.sales_order) + & ( + (SalesOrderItem.item_code == self.production_item) + | (ProductBundleItem.item_code == self.production_item) + ) + ) + .run(as_dict=1) ) if not so: - so = frappe.db.sql( - """ - select - so.name, so_item.delivery_date, so.project - from - `tabSales Order` so, `tabSales Order Item` so_item, `tabPacked Item` packed_item - where so.name=%s - and so.name=so_item.parent - and so.name=packed_item.parent - and so.skip_delivery_note = 0 - and so_item.item_code = packed_item.parent_item - and so.docstatus = 1 and packed_item.item_code=%s - """, - (self.sales_order, self.production_item), - as_dict=1, + so = ( + frappe.qb.from_(SalesOrder) + .inner_join(SalesOrderItem) + .on(SalesOrderItem.parent == SalesOrder.name) + .inner_join(PackedItem) + .on(PackedItem.parent == SalesOrder.name) + .select(SalesOrder.name, SalesOrder.project, SalesOrderItem.delivery_date) + .where( + (SalesOrder.name == self.sales_order) + & (SalesOrder.skip_delivery_note == 0) + & (SalesOrderItem.item_code == PackedItem.parent_item) + & (SalesOrder.docstatus == 1) + & (PackedItem.item_code == self.production_item) + ) + .run(as_dict=1) ) if len(so): @@ -426,7 +439,7 @@ class WorkOrder(Document): from erpnext.selling.doctype.sales_order.sales_order import update_produced_qty_in_so_item - if self.sales_order and self.sales_order_item: + if self.sales_order and self.sales_order_item and not self.production_plan_sub_assembly_item: update_produced_qty_in_so_item(self.sales_order, self.sales_order_item) if self.production_plan: @@ -818,7 +831,7 @@ class WorkOrder(Document): doc.db_set("status", doc.status) def update_work_order_qty_in_so(self): - if not self.sales_order and not self.sales_order_item: + if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item: return total_bundle_qty = 1 diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 9e36329daa4..7d2c1757bda 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -75,7 +75,6 @@ erpnext.patches.v12_0.make_item_manufacturer erpnext.patches.v12_0.move_item_tax_to_item_tax_template erpnext.patches.v11_1.set_variant_based_on erpnext.patches.v11_1.woocommerce_set_creation_user -erpnext.patches.v11_1.rename_depends_on_lwp execute:frappe.delete_doc("Report", "Inactive Items") erpnext.patches.v11_1.delete_scheduling_tool erpnext.patches.v12_0.rename_tolerance_fields @@ -432,3 +431,4 @@ erpnext.patches.v15_0.create_accounting_dimensions_in_advance_taxes_and_charges erpnext.patches.v16_0.set_ordered_qty_in_quotation_item erpnext.patches.v15_0.replace_http_with_https_in_sales_partner erpnext.patches.v16_0.add_portal_redirects +erpnext.patches.v16_0.update_order_qty_and_requested_qty_based_on_mr_and_po diff --git a/erpnext/patches/v16_0/update_order_qty_and_requested_qty_based_on_mr_and_po.py b/erpnext/patches/v16_0/update_order_qty_and_requested_qty_based_on_mr_and_po.py new file mode 100644 index 00000000000..59a84ec11d0 --- /dev/null +++ b/erpnext/patches/v16_0/update_order_qty_and_requested_qty_based_on_mr_and_po.py @@ -0,0 +1,33 @@ +import frappe +from frappe.query_builder import DocType +from frappe.query_builder.functions import Sum + + +def execute(): + PurchaseOrderItem = DocType("Purchase Order Item") + MaterialRequestItem = DocType("Material Request Item") + + poi_query = ( + frappe.qb.from_(PurchaseOrderItem) + .select(PurchaseOrderItem.sales_order_item, Sum(PurchaseOrderItem.stock_qty)) + .where(PurchaseOrderItem.sales_order_item.isnotnull() & PurchaseOrderItem.docstatus == 1) + .groupby(PurchaseOrderItem.sales_order_item) + ) + + mri_query = ( + frappe.qb.from_(MaterialRequestItem) + .select(MaterialRequestItem.sales_order_item, Sum(MaterialRequestItem.stock_qty)) + .where(MaterialRequestItem.sales_order_item.isnotnull() & MaterialRequestItem.docstatus == 1) + .groupby(MaterialRequestItem.sales_order_item) + ) + + poi_data = poi_query.run() + mri_data = mri_query.run() + + updates_against_poi = {data[0]: {"ordered_qty": data[1]} for data in poi_data} + updates_against_mri = {data[0]: {"requested_qty": data[1], "ordered_qty": 0} for data in mri_data} + + frappe.db.auto_commit_on_many_writes = 1 + frappe.db.bulk_update("Sales Order Item", updates_against_mri) + frappe.db.bulk_update("Sales Order Item", updates_against_poi) + frappe.db.auto_commit_on_many_writes = 0 diff --git a/erpnext/projects/doctype/timesheet/timesheet.js b/erpnext/projects/doctype/timesheet/timesheet.js index 9b01d1c429f..e9d868e108a 100644 --- a/erpnext/projects/doctype/timesheet/timesheet.js +++ b/erpnext/projects/doctype/timesheet/timesheet.js @@ -260,6 +260,33 @@ frappe.ui.form.on("Timesheet", { parent_project: function (frm) { set_project_in_timelog(frm); }, + + employee: function (frm) { + if (frm.doc.employee && frm.doc.time_logs) { + const selected_employee = frm.doc.employee; + frm.doc.time_logs.forEach((row) => { + if (row.activity_type) { + frappe.call({ + method: "erpnext.projects.doctype.timesheet.timesheet.get_activity_cost", + args: { + employee: frm.doc.employee, + activity_type: row.activity_type, + currency: frm.doc.currency, + }, + callback: function (r) { + if (r.message) { + if (selected_employee !== frm.doc.employee) return; + row.billing_rate = r.message["billing_rate"]; + row.costing_rate = r.message["costing_rate"]; + frm.refresh_fields("time_logs"); + calculate_billing_costing_amount(frm, row.doctype, row.name); + } + }, + }); + } + }); + } + }, }); frappe.ui.form.on("Timesheet Detail", { diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index a4b4de19303..4f327cebd5a 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -173,9 +173,15 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (!tax.dont_recompute_tax) { tax.item_wise_tax_detail = {}; } - var tax_fields = ["total", "tax_amount_after_discount_amount", - "tax_amount_for_current_item", "grand_total_for_current_item", - "tax_fraction_for_current_item", "grand_total_fraction_for_current_item"]; + var tax_fields = [ + "net_amount", + "total", + "tax_amount_after_discount_amount", + "tax_amount_for_current_item", + "grand_total_for_current_item", + "tax_fraction_for_current_item", + "grand_total_fraction_for_current_item", + ]; if (cstr(tax.charge_type) != "Actual" && !(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) { @@ -363,9 +369,14 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { var item_tax_map = me._load_item_tax_rate(item.item_tax_rate); $.each(doc.taxes, function(i, tax) { // tax_amount represents the amount of tax for the current step - var current_tax_amount = me.get_current_tax_amount(item, tax, item_tax_map); + var [current_net_amount, current_tax_amount] = me.get_current_tax_amount( + item, + tax, + item_tax_map + ); if (frappe.flags.round_row_wise_tax) { current_tax_amount = flt(current_tax_amount, precision("tax_amount", tax)); + current_net_amount = flt(current_net_amount, precision("net_amount", tax)); } // Adjust divisional loss to the last item @@ -380,6 +391,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { if (tax.charge_type != "Actual" && !(me.discount_amount_applied && me.frm.doc.apply_discount_on=="Grand Total")) { tax.tax_amount += current_tax_amount; + tax.net_amount += current_net_amount; } // store tax_amount for current item as it will be used for @@ -430,8 +442,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { for (const [i, tax] of doc.taxes.entries()) { me.round_off_totals(tax); - me.set_in_company_currency(tax, - ["tax_amount", "tax_amount_after_discount_amount"]); + me.set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount", "net_amount"]); me.round_off_base_values(tax); @@ -464,6 +475,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { get_current_tax_amount(item, tax, item_tax_map) { var tax_rate = this._get_tax_rate(tax, item_tax_map); var current_tax_amount = 0.0; + var current_net_amount = 0.0; // To set row_id by default as previous row. if(["On Previous Row Amount", "On Previous Row Total"].includes(tax.charge_type)) { @@ -476,21 +488,27 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } } if(tax.charge_type == "Actual") { + current_net_amount = item.net_amount // distribute the tax amount proportionally to each item row var actual = flt(tax.tax_amount, precision("tax_amount", tax)); current_tax_amount = this.frm.doc.net_total ? ((item.net_amount / this.frm.doc.net_total) * actual) : 0.0; } else if(tax.charge_type == "On Net Total") { + if (tax.account_head in item_tax_map) { + current_net_amount = item.net_amount + }; current_tax_amount = (tax_rate / 100.0) * item.net_amount; } else if(tax.charge_type == "On Previous Row Amount") { + current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item current_tax_amount = (tax_rate / 100.0) * this.frm.doc["taxes"][cint(tax.row_id) - 1].tax_amount_for_current_item; - } else if(tax.charge_type == "On Previous Row Total") { + current_net_amount = this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item current_tax_amount = (tax_rate / 100.0) * this.frm.doc["taxes"][cint(tax.row_id) - 1].grand_total_for_current_item; } else if (tax.charge_type == "On Item Quantity") { + // don't sum current net amount due to the field being a currency field current_tax_amount = tax_rate * item.qty; } @@ -498,7 +516,7 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { this.set_item_wise_tax(item, tax, tax_rate, current_tax_amount); } - return current_tax_amount; + return [current_net_amount, current_tax_amount]; } set_item_wise_tax(item, tax, tax_rate, current_tax_amount) { @@ -532,7 +550,11 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments { } tax.tax_amount = flt(tax.tax_amount, precision("tax_amount", tax)); - tax.tax_amount_after_discount_amount = flt(tax.tax_amount_after_discount_amount, precision("tax_amount", tax)); + tax.net_amount = flt(tax.net_amount, precision("net_amount", tax)); + tax.tax_amount_after_discount_amount = flt( + tax.tax_amount_after_discount_amount, + precision("tax_amount", tax) + ); } round_off_base_values(tax) { diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py index 96d26b3e703..2d1da049653 100644 --- a/erpnext/selling/doctype/quotation/test_quotation.py +++ b/erpnext/selling/doctype/quotation/test_quotation.py @@ -52,8 +52,22 @@ class TestQuotation(FrappeTestCase): self.assertEqual(qo.get("items")[0].qty, 11) self.assertEqual(qo.get("items")[-1].rate, 100) - def test_update_child_disallow_rate_change(self): - qo = make_quotation(qty=4) + def test_update_child_rate_change(self): + from erpnext.stock.doctype.item.test_item import make_item + + item_1 = make_item("_Test Item") + item_2 = make_item("_Test Item 1") + + item_list = [ + {"item_code": item_1.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 10, "rate": 300}, + {"item_code": item_2.item_code, "warehouse": "_Test Warehouse - _TC", "qty": 5, "rate": 400}, + ] + + qo = make_quotation(item_list=item_list) + so = make_sales_order(qo.name, args={"filtered_children": [qo.items[0].name]}) + so.delivery_date = nowdate() + so.submit() + qo.reload() trans_item = json.dumps( [ { @@ -61,10 +75,35 @@ class TestQuotation(FrappeTestCase): "rate": 5000, "qty": qo.items[0].qty, "docname": qo.items[0].name, - } + }, + { + "item_code": qo.items[1].item_code, + "rate": qo.items[1].rate, + "qty": qo.items[1].qty, + "docname": qo.items[1].name, + }, ] ) self.assertRaises(frappe.ValidationError, update_child_qty_rate, "Quotation", trans_item, qo.name) + trans_item = json.dumps( + [ + { + "item_code": qo.items[0].item_code, + "rate": qo.items[0].rate, + "qty": qo.items[0].qty, + "docname": qo.items[0].name, + }, + { + "item_code": qo.items[1].item_code, + "rate": 50, + "qty": qo.items[1].qty, + "docname": qo.items[1].name, + }, + ] + ) + update_child_qty_rate("Quotation", trans_item, qo.name) + qo.reload() + self.assertEqual(qo.items[1].rate, 50) def test_update_child_removing_item(self): qo = make_quotation(qty=10) diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index 18b4bab4188..bbad2fe4fae 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -56,6 +56,13 @@ frappe.ui.form.on("Sales Order", { frm.set_df_property("packed_items", "cannot_add_rows", true); frm.set_df_property("packed_items", "cannot_delete_rows", true); }, + delivery_date(frm) { + if (frm.doc.delivery_date) { + frm.doc.items.forEach((d) => { + frappe.model.set_value(d.doctype, d.name, "delivery_date", frm.doc.delivery_date); + }); + } + }, refresh: function (frm) { if (frm.doc.docstatus === 1) { @@ -145,7 +152,7 @@ frappe.ui.form.on("Sales Order", { }); } } - + prevent_past_delivery_dates(frm); // Hide `Reserve Stock` field description in submitted or cancelled Sales Order. if (frm.doc.docstatus > 0) { frm.set_df_property("reserve_stock", "description", null); @@ -224,13 +231,6 @@ frappe.ui.form.on("Sales Order", { ]; }, - delivery_date: function (frm) { - $.each(frm.doc.items || [], function (i, d) { - if (!d.delivery_date) d.delivery_date = frm.doc.delivery_date; - }); - refresh_field("items"); - }, - create_stock_reservation_entries(frm) { const dialog = new frappe.ui.Dialog({ title: __("Stock Reservation"), @@ -1400,3 +1400,11 @@ erpnext.selling.SalesOrderController = class SalesOrderController extends erpnex }; extend_cscript(cur_frm.cscript, new erpnext.selling.SalesOrderController({ frm: cur_frm })); + +function prevent_past_delivery_dates(frm) { + if (frm.doc.transaction_date) { + frm.fields_dict["delivery_date"].datepicker?.update({ + minDate: new Date(frm.doc.transaction_date), + }); + } +} diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index 7edb81de48b..b8ca7343a4d 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -90,6 +90,7 @@ "ordered_qty", "planned_qty", "production_plan_qty", + "requested_qty", "column_break_69", "work_order_qty", "delivered_qty", @@ -966,12 +967,56 @@ "label": "Project", "options": "Project", "search_index": 1 + }, + { + "fieldname": "sales_order_schedule_section", + "fieldtype": "Section Break", + "label": "Sales Order Schedule" + }, + { + "fieldname": "add_schedule", + "fieldtype": "Button", + "label": "Add Schedule" + }, + { + "allow_on_submit": 1, + "default": "0", + "depends_on": "eval:parent.is_subcontracted", + "fieldname": "subcontracted_qty", + "fieldtype": "Float", + "label": "Subcontracted Quantity", + "no_copy": 1, + "non_negative": 1, + "read_only": 1 + }, + { + "depends_on": "eval:parent.is_subcontracted", + "fieldname": "fg_item", + "fieldtype": "Link", + "label": "Finished Good", + "mandatory_depends_on": "eval:parent.is_subcontracted", + "options": "Item" + }, + { + "depends_on": "eval:parent.is_subcontracted", + "fieldname": "fg_item_qty", + "fieldtype": "Float", + "label": "Finished Good Qty", + "mandatory_depends_on": "eval:parent.is_subcontracted" + }, + { + "fieldname": "requested_qty", + "fieldtype": "Float", + "label": "Requested Qty", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2026-02-20 16:39:00.200328", + "modified": "2026-02-21 16:39:00.200328", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.py b/erpnext/selling/doctype/sales_order_item/sales_order_item.py index 731cff665da..07a81c81f02 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.py +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.py @@ -78,6 +78,7 @@ class SalesOrderItem(Document): quotation_item: DF.Data | None rate: DF.Currency rate_with_margin: DF.Currency + requested_qty: DF.Float reserve_stock: DF.Check returned_qty: DF.Float stock_qty: DF.Float diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index b9aa7065c5d..068daeae4f1 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -81,7 +81,7 @@ class MaterialRequest(BuyingController): { "source_dt": "Material Request Item", "target_dt": "Sales Order Item", - "target_field": "ordered_qty", + "target_field": "requested_qty", "target_parent_dt": "Sales Order", "target_parent_field": "", "join_field": "sales_order_item", @@ -248,6 +248,8 @@ class MaterialRequest(BuyingController): def on_cancel(self): self.update_requested_qty_in_production_plan() self.update_requested_qty() + if self.material_request_type == "Purchase": + self.update_prevdoc_status() def get_mr_items_ordered_qty(self, mr_items): mr_items_ordered_qty = {} diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py index 896b43088ad..84535372b4b 100644 --- a/erpnext/stock/serial_batch_bundle.py +++ b/erpnext/stock/serial_batch_bundle.py @@ -333,7 +333,7 @@ class SerialBatchBundle: "Serial and Batch Entry", {"parent": self.sle.serial_and_batch_bundle, "docstatus": 0} ) > 0 - ): + ) and not self.sle.is_cancelled: frappe.throw( _("Serial and Batch Bundle {0} is not submitted").format( bold(self.sle.serial_and_batch_bundle) diff --git a/erpnext/templates/pages/help.html b/erpnext/templates/pages/help.html index 1cfe358efd6..726d5e1b881 100644 --- a/erpnext/templates/pages/help.html +++ b/erpnext/templates/pages/help.html @@ -8,7 +8,7 @@