Skip to content

Instantly share code, notes, and snippets.

@ryanc-me
Last active April 15, 2024 03:22
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ryanc-me/632fd59639a8a68e041c876abe87168f to your computer and use it in GitHub Desktop.
Save ryanc-me/632fd59639a8a68e041c876abe87168f to your computer and use it in GitHub Desktop.
Update to support multiple Odoo versions, bugfixes and tweaks

Update Quant Reservation

"It is not possible to unreserve more products of <product name> than you have in stock"


Sometimes, the reserved_quantity on the stock.quant record for a product becomes out-of-sync with the sum of the reserved quantity in stock.move.lines for that product.

For example, consider:

  • Product A (quant): on-hand = 50, reserved = 10
  • Picking A (move): reserved = 10
  • Picking B (move): reserved = 5

As we can see, Product A thinks that 10 units are reserved, when in reality, 15 units are actually reserved in pickings. If we try to validate/cancel/unreserve Move A and Move B, we'll get an error like above, because un-reserving 15 units would leave us with -5 reserved! Note that this prevents cancel, unreserve, AND validate.

This seems to be an edge-case bug, that may have been fixed in a recent (circa 2021-05-01) patch to Odoo (for v14, at least).


Note: This script was inspired by amoyaux's gist, which will show any discrepancies, but won't update any DB tables. Link here: https://gist.github.com/amoyaux/279aee13eaddacbddb435dafbc0a6295

Fixing this issue is easy: We just need to update the reserved_quantity field on stock.quant to match all open stock.move.line records.


How do I fix it?

  • Ensure you're logged in as superuser (like this).
  • Settings -> Technical -> Server Actions -> Create
    • Action To Do = Execute Python Code
    • Model = ir.actions.server (copy/paste into the box, it will show up as Server Action)
  • Untick Active
  • Click Run

Logs can be found at: Settings -> Technical -> Database Structure -> Logging.


Note: This script should work with any Odoo version from 12.0 up to 17.0. If you run into any issues, please log them on the Gist.


# Available variables:
#  - env: Odoo Environment on which the action is triggered
#  - model: Odoo Model of the record on which the action is triggered; is a void recordset
#  - record: record on which the action is triggered; may be void
#  - records: recordset of all records on which the action is triggered in multi-mode; may be void
#  - time, datetime, dateutil, timezone: useful Python libraries
#  - log: log(message, level='info'): logging function to record debug information in ir.logging table
#  - Warning: Warning Exception to use with raise
# To return an action, assign: action = {...}
#
# Written by Ryan Cole (github.com/ryanc-me)
# Source Gist: https://gist.github.com/ryanc-me/632fd59639a8a68e041c876abe87168f/
#
# Setup:
# - Settings -> Technical -> Actions -> Server Actions
# - Create new
#   * Name: Update Quant Reservation
#   * Model: ir.actions.server (Server Action)
#   * Execute Python Code
#   * Add this code
# - Ensure you're logged in as user #1 (Superuser)
# - Click Run
#
# Disabling the 3 vars below will cause the script to run in "dry-run" mode


# "fix" reservation on locations who are "bypass reservation"? (suggest: False)
DO_FIX_BYPASS_RESERVATION_LOCATION = False

# create quants if one doesn't exist, but we have move lines with reserved quantity? (suggest: True)
DO_FIX_MISSING_QUANT = True

# fix mismatched reserved quantity on quants? (suggest: True)
DO_FIX_MISMATCH_RESERVED_QTY = True


def get_version_data():
    odoo_version = int(env['ir.module.module'].search([('name', '=', 'base')]).installed_version.split('.')[0])
    data = {
        'odoo_version': odoo_version,
    }
    if odoo_version < 12:
        raise Warning("This script is only for Odoo 12.0 and later")
    elif odoo_version in (12, 13, 14):
        data.update({
            'move_line_reserved_qty': "product_qty",
            'product_type': "type",
            'stock_lot': "stock.production.lot",
        })
    elif odoo_version in (15,):
        data.update({
            'move_line_reserved_qty': "product_qty",
            'product_type': "detailed_type",
            'stock_lot': "stock.production.lot",
        })
    elif odoo_version in (16, 17):
        data.update({
            'move_line_reserved_qty': "reserved_qty",
            'product_type': "detailed_type",
            'stock_lot': "stock.lot",
        })
    return data

def build_data():
    vdata = get_version_data()

    quants = env['stock.quant'].search([
        ('product_id.%s' % vdata['product_type'], '=', 'product')
    ])
    move_lines = env["stock.move.line"].search([
        (vdata['move_line_reserved_qty'], '!=', 0),
        ('state', 'in', ('waiting', 'confirmed', 'partially_available', 'assigned')),
        ('product_id.%s' % vdata['product_type'], '=', 'product'),
    ])

    data_by_key = {}
    for quant in quants:
        key = (quant.product_id.id, quant.location_id.id, quant.lot_id.id, quant.package_id.id, quant.owner_id.id, quant.company_id.id)
        if key not in data_by_key:
            data_by_key[key] = {
                'quant': env['stock.quant'],
                'move_lines': env['stock.move.line']
            }
        data_by_key[key]['quant'] |= quant
    for move_line in move_lines:
        key = (move_line.product_id.id, move_line.location_id.id, move_line.lot_id.id, move_line.package_id.id, move_line.owner_id.id, move_line.company_id.id)
        if key not in data_by_key:
            data_by_key[key] = {
                'quant': env['stock.quant'],
                'move_lines': env['stock.move.line']
            }
        data_by_key[key]['move_lines'] |= move_line
    return data_by_key

def build_logline(messages, key, quants, moves, extra_lines, header):
    vdata = get_version_data()
    location = env['stock.location'].browse([key[1]])
    product = env['product.product'].browse([key[0]])
    lot = env[vdata['stock_lot']].browse([key[2]])
    package = env['stock.quant.package'].browse([key[3]])
    owner = env['res.partner'].browse([key[4]])
    company = env['res.company'].browse([key[5]])
    adjust_msg = header
    adjust_msg += "\n - Company (%s): %s" % (str(company.id), company.display_name)
    adjust_msg += "\n - Location (%s): %s" % (str(location.id), location.display_name)
    adjust_msg += "\n - Product (%s): %s" % (str(product.id), product.display_name)
    adjust_msg += "\n - Lot: %s" % (lot.display_name if lot else "N/A")
    adjust_msg += "\n - Package: %s" % (package.display_name if package else "N/A")
    adjust_msg += "\n - Owner: %s" % (owner.display_name if owner else "N/A")
    adjust_msg += "\n - Quants: %s" % (', '.join([str(q.id) for q in quants]))
    adjust_msg += "\n - Moves: %s" % (', '.join([str(l.id) for l in moves]))
    if extra_lines:
        adjust_msg += extra_lines
    messages.append(adjust_msg)

def run():
    vdata = get_version_data()
    data_by_key = build_data()
    messages = []
    for key, data in data_by_key.items():
        try:
            quant = data['quant']
            move_lines = data['move_lines']
            location = env['stock.location'].browse([key[1]])
            product = env['product.product'].browse([key[0]])
            lot = env[vdata['stock_lot']].browse([key[2]])
            package = env['stock.quant.package'].browse([key[3]])
            owner = env['res.partner'].browse([key[4]])
            company = env['res.company'].browse([key[5]])

            # CASE #1: more than one quant - shouldn't be possible!
            if len(quant) > 1:
                build_logline(messages, key, quant, move_lines, '', 'Multiple Quants Found:')
                continue

            # CASE #2: location is not stockable - quant reservation should be 0
            if location.should_bypass_reservation():
                # the location should bypass reservation; if there is some reserved quantity there,
                # then we can zero it. (note that this is just a housekeeping task - this case will
                # not cause any direct errors in Odoo.)

                # only update if the non-stocked location actually has some reserved qty
                reserved_quant = sum([0] + [q.reserved_quantity for q in quant])
                if reserved_quant != 0:
                    if DO_FIX_BYPASS_RESERVATION_LOCATION:
                        quant.write({'reserved_quantity': 0})
                    extra = "\n - Quantity: (%s)" % str(reserved_quant)
                    build_logline(messages, key, quant, move_lines, extra, 'Adjusted bypass-reservation location:')
                continue

            # CASE #3: no quant, but reserved quantity - create a new quant
            reserved_moves = 0
            for move in move_lines:
                reserved_moves += move[vdata['move_line_reserved_qty']]
            if not quant and reserved_moves != 0.0:
                # the quant doesn't exist, but we have some move lines with reserved quantity
                # we need to create a new quant so we can assign the reserved quantity to it

                if not DO_FIX_MISSING_QUANT:
                    quant = env['stock.quant'].create({
                        'product_id': key[0],
                        'location_id': key[1],
                        'lot_id': key[2],
                        'package_id': key[3],
                        'owner_id': key[4],
                        'company_id': key[5],
                        'quantity': 0,
                        'reserved_quantity': 0,
                    })
                build_logline(messages, key, quant, move_lines, '', 'Created a new quant:')

            # we need to round qty after summing, but we can't import float_round
            # instead, use the _compute_qty() function. it's designed to convert from
            # one UoM to another, and will round to the UoM's precision. we can
            # provide the product UoM twice to 'convert' to/from the same UoM,
            # resulting in the same qty, but rounded!
            raw_reserved_qty = sum(move_lines.mapped(vdata['move_line_reserved_qty']))
            move_reserved_qty = move_lines.product_id.uom_id._compute_quantity(raw_reserved_qty, move_lines.product_id.uom_id)
            quant_reserved_qty = move_lines.product_id.uom_id._compute_quantity(quant.reserved_quantity, move_lines.product_id.uom_id)

            # CASE #4: quant reservation doesn't match move reservation - correct it
            if quant_reserved_qty != move_reserved_qty:
                # the quant reservation doesn't match the move reservation (either up or down)
                # we can adjust the quant to match
                
                if DO_FIX_MISMATCH_RESERVED_QTY:
                    quant.write({'reserved_quantity': move_reserved_qty})
                extra = "\n - Quantity: (quant=%s) (move=%s)" % (str(quant_reserved_qty), str(move_reserved_qty))
                extra += "\n - Details: \n    * %s" % ('\n    * '.join([('%s: %s (origin: %s)' % (str(l.id), l.product_qty, l.origin)) for l in move_lines]))
                build_logline(messages, key, quant, move_lines, extra, 'Adjusted reservation discrepancy:')
                continue

        except Exception as e:
            raise e
            try:
                extra = "\n - Exception: %s" % str(e)
                build_logline(messages, key, quant, move_lines, extra, 'Exception:')
            except Exception as e:
                pass

    log('Reservation fix done!\n\n' + '\n\n'.join(messages), level='info')

run()
@taufikid07
Copy link

The error I experienced occurred in version 16, I tried version 16 and it couldn't run.

@ryanc-me
Copy link
Author

ryanc-me commented Dec 11, 2023

@taufikid07 for Odoo 16.0, you need to replace product_qty with reserved_qty, e.g.:

Update: The base script has been adapted for multiple versions of Odoo.

@yediel
Copy link

yediel commented Apr 10, 2024

Tried to run on Odoo 17, got this error:
ValueError: Invalid field stock.move.line.reserved_qty in leaf ('reserved_qty', '!=', 0)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment