POS Issue: Stock Validation Not Working for Product Bundles in ERPNext

I am facing an issue with Stock Validation in ERPNext POS When Using Product Bundles in a Restaurant Setup

Scenario:

Stock Setup:

  • Burger Bun: 10 qty
  • Chicken Patty: 10 qty
  • Veg Patty: 10 qty

Product Bundles (Menu Items):

  • Chicken Burger (Bundle A) → Contains:
    • Burger Bun - 1 qty
    • Chicken Patty - 1 qty
  • Veg Burger (Bundle B) → Contains:
    • Burger Bun - 1 qty
    • Veg Patty - 1 qty

POS Transaction:

  1. A customer ordered 10 chicken burgers, and I created a POS invoice for 10 qty of bundle A.
  2. Since Bundle A consumes Burger Bun and Chicken Patty, their stock is deducted from the outlet.
  3. After submitting the invoice for 10 Chicken Burgers, the stock of Burger Bun is not physically available in the outlet.

Issue:

  • Even though Burger Bun stock in the outlet is 0, the system still allows submitting a POS Invoice for Veg Burger (Bundle B), which also requires Burger Bun.
  • This leads to incorrect stock validation, as the system does not prevent selling product bundles when a required component is out of stock.

Expected Behavior:

  • If any item in a Product Bundle is out of stock in the outlet, the system should prevent submitting a POS Invoice for another bundle that requires the same item.

Questions:

  1. Is this a known issue in ERPNext?
  2. Should stock validation for product bundles work differently in ERPNext POS?
  3. How can we fix this issue in the core system to prevent invoices from being submitted when bundle components are out of stock?

Versions:

  • Frappe: v15.53.0
  • ERPNext: v15.49.1

Thanks in advance for your help!

1 Like

I found a solution to prevent submitting a POS Invoice when a required component in a Product Bundle is out of stock. This ensures proper stock validation for product bundles in ERPNext POS.

Solution:

I implemented a custom stock validation method that:

  • Checks both direct stock reservations and reservations from product bundles.
  • Prevents submitting a POS Invoice if a component required in a bundle is unavailable.

Implementation Details

  1. Custom Event Hook for POS Invoice Validation
    Add the following hook in hooks.py to override stock validation:

    doc_events = {
        "POS Invoice": {
            "validate": "custom_app.custom_app.custom_py.validate_stock_available_qty",
        },
    }
    
  2. Stock Validation Logic for Product Bundles

    import frappe
    from erpnext.accounts.doctype.pos_invoice.pos_invoice import get_bin_qty, get_pos_reserved_qty
    
    def get_pos_bundle_reserved_qty(item_code, warehouse):
        reserved_qty = frappe.db.sql(
            """
            SELECT SUM(bundle_item.qty * pos_item.qty) AS qty
            FROM `tabPOS Invoice` AS pos
            INNER JOIN `tabPOS Invoice Item` AS pos_item ON pos.name = pos_item.parent
            INNER JOIN `tabProduct Bundle` AS bundle ON bundle.new_item_code = pos_item.item_code
            INNER JOIN `tabProduct Bundle Item` AS bundle_item ON bundle_item.parent = pos_item.item_code
            WHERE IFNULL(pos.consolidated_invoice, '') = ''
            AND pos_item.docstatus = 1
            AND bundle_item.item_code = %s
            AND pos_item.warehouse = %s
            """,
            (item_code, warehouse),
            as_dict=True,
        )
        return reserved_qty[0].qty if reserved_qty else 0
    
    @frappe.whitelist()
    def get_stock_available_qty(item_code, warehouse):
        if frappe.db.get_value("Item", item_code, "is_stock_item"):
            is_stock_item = True
            bin_qty = get_bin_qty(item_code, warehouse)
            pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
            
            return bin_qty - pos_sales_qty, is_stock_item
        else:
            is_stock_item = True
            if frappe.db.exists("Product Bundle", {"name": item_code, "disabled": 0}):
                return get_bundle_available_qty(item_code, warehouse), is_stock_item
            else:
                is_stock_item = False
                return 0, is_stock_item
    
    def get_bundle_available_qty(bundle_item_code, warehouse):
        product_bundle = frappe.get_doc("Product Bundle", bundle_item_code)
        
        bundle_bin_qty = 1000000
        for item in product_bundle.items:
            item_bin_qty = get_bin_qty(item.item_code, warehouse)
            pos_bundle_reserved_qty = get_pos_bundle_reserved_qty(item.item_code, warehouse) or 0
            item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse) or 0
            total_pos_reserved_qty = pos_bundle_reserved_qty + item_pos_reserved_qty
    
            available_qty = item_bin_qty - total_pos_reserved_qty
            
            max_available_bundles = available_qty / item.qty
            if bundle_bin_qty > max_available_bundles and frappe.get_value("Item", item.item_code, "is_stock_item"):
                bundle_bin_qty = max_available_bundles
    
        return bundle_bin_qty
    
    def validate_stock_available_qty(doc, event):
        if doc.is_return:
            return
            
        if doc.docstatus.is_draft() and not frappe.db.get_value(
            "POS Profile", doc.pos_profile, "validate_stock_on_save"
        ):
            return
        
        from erpnext.stock.stock_ledger import is_negative_stock_allowed
    
        for d in doc.get("items"):
            if not d.serial_and_batch_bundle:
                if is_negative_stock_allowed(item_code=d.item_code):
                    return
    
                available_stock, is_stock_item = get_stock_available_qty(d.item_code, d.warehouse)
    
                item_code, warehouse, _qty = (
                    frappe.bold(d.item_code),
                    frappe.bold(d.warehouse),
                    frappe.bold(d.qty),
                )
                if is_stock_item and available_stock <= 0:
                    frappe.throw(
                        ("Row #{}: Item Code: {} is not available under warehouse {}.").format(
                            d.idx, item_code, warehouse
                        ),
                        title=("Item Unavailable"),
                    )
                elif is_stock_item and available_stock < d.stock_qty:
                    frappe.throw(
                        (
                            "Row #{}: Stock quantity not enough for Item Code: {} under warehouse {}. Available quantity {}."
                        ).format(d.idx, item_code, warehouse, available_stock),
                        title=("Item Unavailable"),
                    )
    

How It Works:

  • Calculates Reserved Stock from Bundles: get_pos_bundle_reserved_qty() fetches reserved quantities for items used in bundles.
  • Checks Available Stock for Bundles: get_bundle_available_qty() ensures that an item is not overcommitted by multiple bundles.
  • Validates POS Invoice Before Submission: validate_stock_available_qty() runs before the POS Invoice is submitted, preventing transactions if required items are unavailable.

Hope this helps others facing the same issue!
If anyone has a better solution or if ERPNext has an official fix, please share it here.

3 Likes

Did you notice that Product Bundle is an item that is not Stock Controlled but rather it’s components are? Just askin’

Yes, you’re right! The Product Bundle itself isn’t stock-controlled, but its components are.

By default, ERPNext’s POS stock validation checks the stock of sub-items when selling a Product Bundle. However, after submitting a POS Invoice, it only reserves the bundle item itself, not its sub-items.

In my solution, I’ve added logic to also reserve the sub-items of Product Bundles. This ensures more accurate stock availability when selling bundles.

Let me know if you have any suggestions or if you’ve handled this differently! :blush:

1 Like