From 25cc8f67a547ab37438c14022342fe02e440393d Mon Sep 17 00:00:00 2001 From: Zuzanna Elzbieta Szalaty Szalaty Date: Mon, 26 Oct 2026 11:14:03 +0000 Subject: [PATCH] [FIX] barcode_stock: keep the locations of the line picked Every confirmation from the scanner wrote the move's source and destination on the move line it filled, so a picking reserved in sublocations took its stock from the move's location on validation (negative quants there, the shelves untouched), and a reception lost its putaway shelf. A line now keeps the locations Odoo gave it unless the operator chooses another in the move wizard, which shows the line's. For each reserved line to be picked where it was reserved, a move is picked line by line outside receptions, with lots or without: the wizard works on the first reserved line with something left, and a pick is spread over the reserved lines in turn, each up to what it holds. A lot the reservation does not carry is taken by the server from its own quants under the move's source (removal strategy, least packages included, stock nothing holds first, one line per shelf, package and owner, to the destination the operator chose), as much of the other lots' reservation as it replaces released, rather than written over another lot's line; with no stock there, it is only picked from a source the operator chooses. --- barcode_stock/models/stock_move.py | 145 +++++- barcode_stock/models/stock_quant.py | 33 +- .../src/js/screens/move_wizard_screen.esm.js | 271 ++++++++-- .../src/js/screens/picking_screen.esm.js | 5 - .../js/services/barcode_scanner_state.esm.js | 151 ++++-- .../js/services/barcode_scanner_sync.esm.js | 2 +- .../src/xml/barcode_stock_templates.xml | 16 +- .../static/tests/move_line_locations.test.js | 465 ++++++++++++++++++ .../static/tests/move_wizard_lot.test.js | 1 + barcode_stock/tests/__init__.py | 2 + barcode_stock/tests/test_lot_location.py | 74 +++ barcode_stock/tests/test_pick_lot.py | 319 ++++++++++++ 12 files changed, 1381 insertions(+), 103 deletions(-) create mode 100644 barcode_stock/static/tests/move_line_locations.test.js create mode 100644 barcode_stock/tests/test_lot_location.py create mode 100644 barcode_stock/tests/test_pick_lot.py diff --git a/barcode_stock/models/stock_move.py b/barcode_stock/models/stock_move.py index ebfa761..563fc62 100644 --- a/barcode_stock/models/stock_move.py +++ b/barcode_stock/models/stock_move.py @@ -1,7 +1,9 @@ # Copyright 2026 Binhex # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import api, fields, models +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.tools import float_compare, float_is_zero class StockMove(models.Model): @@ -49,3 +51,144 @@ def _compute_qty_progress(self): move.qty_done_total = done move.qty_remaining = move.product_uom_qty - done move.is_fully_picked = move.qty_remaining <= 0 + + def barcode_pick_lot( + self, lot_id, quantity, location_id=False, location_dest_id=False + ): + """Pick a lot the move's reservation does not carry, the way Odoo + reserves stock rather than by rewriting another lot's line. As much of + the other lots' reservation as the lot replaces is released first. The + lot is then taken from its own quants under the move's source (or the + one the operator chose), in the order of the removal strategy: stock + nothing holds first, then stock other moves hold, whose reservation + Odoo frees on validation. Each quant gives a line, with its shelf, + package and owner, marked picked and sent to the destination the + operator chose, if any. + + Picking more than the lot holds there is allowed, as Odoo allows it: + what is over goes on the last line, and validating leaves a negative + quant that shows the gap. With no stock at all, the lot is only picked + from a source the operator chose. + """ + self.ensure_one() + lot = self.env["stock.lot"].browse(lot_id) + source = ( + self.env["stock.location"].browse(location_id) + if location_id + else self.location_id + ) + self._barcode_release_other_lots(lot, quantity) + takes = self._barcode_lot_takes(lot, quantity, source) + vals_list = [ + self._prepare_move_line_vals(quantity=taken, reserved_quant=quant) + for quant, taken in takes.items() + ] + if not vals_list: + vals_list = [ + self._barcode_chosen_source_vals(lot, quantity, source, location_id) + ] + if location_dest_id: + for vals in vals_list: + vals["location_dest_id"] = location_dest_id + lines = self.env["stock.move.line"].create(vals_list) + uom = self.product_id.uom_id + over = quantity - sum(takes.values()) if takes else 0 + for line in lines: + extra = over if line == lines[-1] else 0 + line.qty_picked = line.quantity + uom._compute_quantity( + extra, line.product_uom_id + ) + self._recompute_state() + return True + + def _barcode_release_other_lots(self, lot, quantity): + """Release, from the other lots' lines, what the move would hold over + its demand once ``quantity`` of ``lot`` is taken: from the lines + nothing was picked on first, the last ones first, then what partly + picked lines hold beyond what was picked on them.""" + uom = self.product_id.uom_id + excess = ( + sum(self.move_line_ids.mapped("quantity_product_uom")) + + quantity + - self.product_qty + ) + others = self.move_line_ids.filtered(lambda line: line.lot_id != lot) + untouched = others.filtered(lambda line: not line.picked).sorted( + "id", reverse=True + ) + started = (others - untouched).sorted("id", reverse=True) + for line in untouched + started: + if float_compare(excess, 0, precision_rounding=uom.rounding) <= 0: + break + held = line.quantity_product_uom + picked = line.product_uom_id._compute_quantity(line.qty_picked, uom) + release = min(held - picked, excess) + if float_compare(release, 0, precision_rounding=uom.rounding) <= 0: + continue + excess -= release + if float_is_zero(held - release, precision_rounding=uom.rounding): + line.unlink() + else: + line.quantity = uom._compute_quantity( + held - release, line.product_uom_id + ) + + def _barcode_lot_takes(self, lot, quantity, source): + """How much to take from each quant of the lot under the source, in the + order of the removal strategy: stock nothing holds first, out of the + packages the strategy picks for the quantity (least packages) and then + the rest, then stock other moves hold. Quants without a lot, which + Odoo's own reservation of a lot may also take, are left alone. + + :return: the quantity taken, by quant + """ + rounding = self.product_id.uom_id.rounding + + def lot_quants(qty=0): + quants = self.env["stock.quant"]._gather( + self.product_id, source, lot_id=lot, qty=qty + ) + return quants.filtered( + lambda quant: quant.lot_id == lot + and float_compare(quant.quantity, 0, precision_rounding=rounding) > 0 + ) + + def free(quant): + return quant.quantity - quant.reserved_quantity - takes[quant] + + quants = lot_quants() + takes = dict.fromkeys(quants, 0.0) + rest = quantity + # Given the quantity, least packages narrows the quants to the packages + # it picks out of the free stock, so the free stock elsewhere and the + # stock other moves hold are gathered without it. + for group, room in ( + (lot_quants(quantity), free), + (quants, free), + (quants, lambda quant: quant.quantity - takes[quant]), + ): + for quant in group: + take = min(rest, room(quant)) + if float_compare(take, 0, precision_rounding=rounding) > 0: + takes[quant] += take + rest -= take + return { + quant: taken + for quant, taken in takes.items() + if float_compare(taken, 0, precision_rounding=rounding) > 0 + } + + def _barcode_chosen_source_vals(self, lot, quantity, source, chosen): + """A line for a lot with no stock under the source: only from a source + the operator chose.""" + if not chosen: + raise UserError( + _( + "Lot %(lot)s has no stock in %(location)s.", + lot=lot.name, + location=source.display_name, + ) + ) + vals = self._prepare_move_line_vals(quantity=quantity) + vals.update(lot_id=lot.id, location_id=source.id) + return vals diff --git a/barcode_stock/models/stock_quant.py b/barcode_stock/models/stock_quant.py index 551af62..8093243 100644 --- a/barcode_stock/models/stock_quant.py +++ b/barcode_stock/models/stock_quant.py @@ -1,8 +1,9 @@ # Copyright 2026 Binhex # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import _, models +from odoo import _, api, models from odoo.exceptions import UserError +from odoo.tools import float_compare class StockQuant(models.Model): @@ -26,3 +27,33 @@ def action_apply_inventory_from_scanner(self): # Go through action_apply_inventory so the scanner path enforces the same # guards as the back office (lot requirement plus core's checks). return self.action_apply_inventory() + + @api.model + def barcode_lot_location(self, product_id, lot_id, location_id): + """Where a lot would be picked from under a location, as Odoo's own + reservation takes it: the first of its quants there in the order of + the removal strategy (FIFO, LIFO, closest, FEFO...), one with stock not + reserved yet if any; otherwise one with stock, whose reservation Odoo + frees on validation. + + :return: the location as ``[id, display_name]``, or False when the lot + has no stock there + """ + product = self.env["product.product"].browse(product_id) + lot = self.env["stock.lot"].browse(lot_id) + location = self.env["stock.location"].browse(location_id) + rounding = product.uom_id.rounding + quants = self._gather(product, location, lot_id=lot).filtered( + lambda quant: quant.lot_id == lot + and float_compare(quant.quantity, 0, precision_rounding=rounding) > 0 + ) + free = quants.filtered( + lambda quant: float_compare( + quant.available_quantity, 0, precision_rounding=rounding + ) + > 0 + ) + quant = (free or quants)[:1] + if not quant: + return False + return [quant.location_id.id, quant.location_id.display_name] diff --git a/barcode_stock/static/src/js/screens/move_wizard_screen.esm.js b/barcode_stock/static/src/js/screens/move_wizard_screen.esm.js index a54ad80..d026f55 100644 --- a/barcode_stock/static/src/js/screens/move_wizard_screen.esm.js +++ b/barcode_stock/static/src/js/screens/move_wizard_screen.esm.js @@ -6,6 +6,10 @@ import {useBarcodeScanner} from "@barcode_scanner/js/hooks/use_inventory.esm"; import {useService} from "@web/core/utils/hooks"; import {user} from "@web/core/user"; +function lineLotId(line) { + return line.lot_id?.[0] || false; +} + export class MoveWizardScreen extends Component { setup() { this.inventory = useBarcodeScanner(); @@ -40,11 +44,15 @@ export class MoveWizardScreen extends Component { lots: [], useExistingLots: true, useCreateLots: true, - // Editable source/destination. Carried across the location-picker - // round-trip via params (the wizard remounts on return); otherwise - // seeded from the move in loadData. + // The source/destination the operator chose, carried across the + // location-picker round-trip via params (the wizard remounts on + // return). None chosen, the line keeps its own. sourceLocation: this.props.params?.moveSourceLocation || null, destLocation: this.props.params?.moveDestLocation || null, + // Where a lot the reservation does not carry lies, looked up, and + // whether it lies nowhere under the move's source. + lotLocation: null, + lotMissing: false, }); useBarcodeHandler({ @@ -88,7 +96,7 @@ export class MoveWizardScreen extends Component { this.onProductScanned(product); return; } - this.applyScannedLot(normalized); + await this.applyScannedLot(normalized); this.adjustQty(normalized.quantity); this.feedback.success(); } @@ -97,7 +105,7 @@ export class MoveWizardScreen extends Component { * Apply the lot or serial a scan carries: select it when it already exists, * otherwise prefill the create-lot form where creating one is allowed. */ - applyScannedLot({lot, lotName, expiration}) { + async applyScannedLot({lot, lotName, expiration}) { const name = lot?.name || lotName; if (!name || !this.isTracked) { return; @@ -107,6 +115,7 @@ export class MoveWizardScreen extends Component { this.state.lots = [lot, ...this.state.lots]; } this.state.selectedLotId = String(lot.id); + await this.locateLot(); return; } if (!this.canCreateLot) { @@ -142,12 +151,6 @@ export class MoveWizardScreen extends Component { return lots; } this.state.selectedLotId = String(reservedLotId); - // Opened without a quantity (tapped, not scanned): no more than what the - // reserved line still holds. - const left = this.pickableQty; - if (!this.props.params?.defaultQty && left > 0) { - this.state.qtyPicked = Math.min(this.state.qtyPicked, left); - } const reservedLot = this.barcodeScannerState.lotsById[reservedLotId]; if (reservedLot && !lots.some((lot) => lot.id === reservedLotId)) { return [reservedLot, ...lots]; @@ -163,7 +166,7 @@ export class MoveWizardScreen extends Component { * @returns {Object|null} the line, if any */ reservedLine(moveLines) { - if (!this.followsReservation) { + if (!this.isTracked || !this.followsReservation) { return null; } return ( @@ -178,38 +181,190 @@ export class MoveWizardScreen extends Component { /** * The reserved line a confirmation goes on: the first line of the lot - * chosen that still has something to pick, while the lot is not covered. - * A lot reserved over several lines is so picked line by line, as Odoo - * reserved it. + * chosen (of the move, for a product without lots) that still has + * something to pick, while that lot is not covered. A move reserved over + * several lines is so picked line by line, each where Odoo reserved it. * * @returns {Object|null} the line, if any */ get targetLine() { - const lotId = this.selectedLotIdNumber; + const lotId = this.isTracked ? this.selectedLotIdNumber : false; const lines = this.state.moveLines || []; if ( - !lotId || !this.followsReservation || + (this.isTracked && !lotId) || this.reservedLeft(lines, lotId) <= 0 ) { return null; } return ( lines.find( - (line) => line.lot_id?.[0] === lotId && this.lineLeft(line) > 0 + (line) => lineLotId(line) === lotId && this.lineLeft(line) > 0 ) || null ); } /** - * Whether the move is picked lot by lot as reserved: outside receptions, - * where Odoo reserves stock against lots, for a tracked product, when the - * operation picks from existing lots. + * Whether the move is picked line by line as reserved: outside receptions, + * where Odoo reserves the stock where it lies, unless a tracked product's + * operation does not pick from existing lots. * * @returns {Boolean} */ get followsReservation() { - return !this.isIncoming && this.isTracked && this.state.useExistingLots; + return !this.isIncoming && (!this.isTracked || this.state.useExistingLots); + } + + /** + * Opened without a quantity (tapped, not scanned), the pick starts at no + * more than what the reserved line worked on still holds. + */ + fitStartToLine() { + const left = this.pickableQty; + if (!this.props.params?.defaultQty && this.targetLine && left > 0) { + this.state.qtyPicked = Math.min(this.state.qtyPicked, left); + } + } + + /** + * Where the pick comes from, as shown: the location the operator chose, + * otherwise where a lot the reservation does not carry lies, otherwise that + * of the line it goes on. + * + * @returns {Object|null} the location, as id and display_name + */ + get shownSourceLocation() { + return ( + this.state.sourceLocation || + this.state.lotLocation || + this.lineLocation("location_id") + ); + } + + /** + * The source to write on the line: the one the operator chose. Otherwise + * none, and the line keeps where Odoo reserved its stock. + * + * @returns {Number|false} the location's id + */ + get sourceLocationId() { + return this.state.sourceLocation?.id || false; + } + + /** + * Whether the lot chosen is one the move's reservation does not carry: + * Odoo reserves it then, where it lies (see pickUnreservedLot). + * + * @returns {Boolean} + */ + get unreservedLot() { + const lotId = this.selectedLotIdNumber; + return Boolean( + lotId && + this.isTracked && + this.followsReservation && + this.state.mode === "pick" && + !(this.state.moveLines || []).some((line) => lineLotId(line) === lotId) + ); + } + + /** + * Have Odoo pick a lot the reservation does not carry: it takes it from its + * own quants under the move's source (or the one the operator chose), one + * line per shelf, package and owner it lies in, marked picked and sent to + * the destination the operator chose. What the wizard staged so far is + * sent first. + * + * @returns {Promise} whether it was picked + */ + async pickUnreservedLot() { + await this.barcodeScannerSync.flush(); + try { + await this.inventory.call("stock.move", "barcode_pick_lot", [ + [this.moveId], + this.selectedLotIdNumber, + this.state.qtyPicked, + this.sourceLocationId, + this.state.destLocation?.id || false, + ]); + } catch { + // The API wrapper already told the operator why. + return false; + } + return true; + } + + /** + * A lot the move's reservation does not carry takes over a reserved line of + * another lot, on that lot's shelf: look up where it lies under the move's + * source, so it is picked from there. + */ + async locateLot() { + const lotId = this.selectedLotIdNumber; + this.state.lotLocation = null; + this.state.lotMissing = false; + if (!this.unreservedLot) { + return; + } + const location = await this.barcodeScannerState.lotSourceLocation( + this.state.move.product_id[0], + lotId, + this.state.move.location_id[0] + ); + // The operator may have chosen another lot meanwhile. + if (this.selectedLotIdNumber !== lotId) { + return; + } + if (location) { + this.state.lotLocation = {id: location[0], display_name: location[1]}; + } else { + this.state.lotMissing = true; + } + } + + /** + * Why the lot chosen cannot be picked: the reservation does not carry it and + * it has no stock under the move's source. Choosing where it comes from (in + * an internal transfer) is the operator's way past it. + * + * @returns {String} the reason, empty when there is none + */ + get missingLotMessage() { + if (!this.state.lotMissing || this.state.sourceLocation) { + return ""; + } + return _t("Lot %(lot)s has no stock in %(location)s.", { + lot: this.selectedLot?.name || "", + location: this.state.move?.location_id?.[1] || "", + }); + } + + /** + * Where the pick goes to, as shown: the location the operator chose, + * otherwise that of the line it goes on (a putaway shelf, say). + * + * @returns {Object|null} the location, as id and display_name + */ + get shownDestLocation() { + return this.state.destLocation || this.lineLocation("location_dest_id"); + } + + /** + * A location of the line the pick goes on: the reserved line worked on, + * otherwise the move's first line with nothing picked yet; the move's when + * it has none. + * + * @param {String} field location_id or location_dest_id + * @returns {Object|null} the location, as id and display_name + */ + lineLocation(field) { + const line = + this.targetLine || + (this.state.moveLines || []).find( + (moveLine) => !(Number(moveLine.qty_picked) > 0) + ); + const location = [line?.[field], this.state.move?.[field]].find(Array.isArray); + return location ? {id: location[0], display_name: location[1]} : null; } /** @@ -218,12 +373,12 @@ export class MoveWizardScreen extends Component { * one line is covered, whatever its other lines still reserve. * * @param {Object[]} moveLines the move's lines - * @param {Number} lotId the lot's id + * @param {Number|false} lotId the lot's id, false for no lot * @returns {Number} the quantity still to pick of it */ reservedLeft(moveLines, lotId) { return moveLines - .filter((line) => line.lot_id?.[0] === lotId) + .filter((line) => lineLotId(line) === lotId) .reduce((sum, line) => sum + this.lineLeft(line), 0); } @@ -533,6 +688,7 @@ export class MoveWizardScreen extends Component { onLotChange(ev) { this.state.selectedLotId = ev.target.value || null; + return this.locateLot(); } /** @@ -628,21 +784,8 @@ export class MoveWizardScreen extends Component { this.state.moveLines = moveLines; this.state.tracking = product.tracking; lots = this.preselectReservedLot(moveLines, lots); - - // Seed the editable locations from the move unless the operator already - // picked one (preserved across the location-picker round-trip). - if (!this.state.sourceLocation && move.location_id) { - this.state.sourceLocation = { - id: move.location_id[0], - display_name: move.location_id[1], - }; - } - if (!this.state.destLocation && move.location_dest_id) { - this.state.destLocation = { - id: move.location_dest_id[0], - display_name: move.location_dest_id[1], - }; - } + this.fitStartToLine(); + await this.locateLot(); if (this.isTracked && this.isIncoming) { if (this.state.useCreateLots && !this.state.useExistingLots) { @@ -721,6 +864,11 @@ export class MoveWizardScreen extends Component { this.inventory.notify(_t("Please select a lot"), {type: "danger"}); return; } + const missingLot = this.missingLotMessage; + if (missingLot) { + this.inventory.notify(missingLot, {type: "danger"}); + return; + } const guardrailError = this.barcodeScannerSync.validateConfirmMovePayload({ productId: this.state.move.product_id[0], @@ -745,20 +893,13 @@ export class MoveWizardScreen extends Component { return; } - await this.barcodeScannerSync.confirmMove({ - moveId: this.moveId, - moveLineId: this.targetLineId, - pickingId: this.pickingId, - productId: this.state.move.product_id[0], - qtyPicked: this.state.qtyPicked, - lotId: this.selectedLotIdNumber || false, - lotName: lot ? lot.name : this.state.newLotName || false, - createLot: this.state.mode === "create_lot", - expirationDate: this.state.newLotExpirationDate || false, - locationId: this.state.sourceLocation?.id || this.state.move.location_id[0], - locationDestId: - this.state.destLocation?.id || this.state.move.location_dest_id[0], - }); + if (this.unreservedLot) { + if (!(await this.pickUnreservedLot())) { + return; + } + } else { + await this.confirmOnLine(lot); + } // Hand the move back so the picking screen opens on the tab that // now holds it: still "To Do" while quantity is missing, "Done" once // this confirmation completed it. @@ -774,6 +915,30 @@ export class MoveWizardScreen extends Component { ); } + /** + * Stage the pick on the move's lines: the reserved one the wizard works on + * and those after it, or a line of the lot. + * + * @param {Object|null} lot the lot chosen + */ + async confirmOnLine(lot) { + await this.barcodeScannerSync.confirmMove({ + moveId: this.moveId, + moveLineId: this.targetLineId, + pickingId: this.pickingId, + productId: this.state.move.product_id[0], + qtyPicked: this.state.qtyPicked, + lotId: this.selectedLotIdNumber || false, + lotName: lot ? lot.name : this.state.newLotName || false, + createLot: this.state.mode === "create_lot", + expirationDate: this.state.newLotExpirationDate || false, + // Only a location the operator chose: otherwise the line keeps + // where Odoo reserved its stock, and its putaway shelf. + locationId: this.sourceLocationId, + locationDestId: this.state.destLocation?.id || false, + }); + } + async goBack() { if (this.state.mode === "create_lot" && this.canUseExistingLot) { this.setMode("pick"); diff --git a/barcode_stock/static/src/js/screens/picking_screen.esm.js b/barcode_stock/static/src/js/screens/picking_screen.esm.js index 40b7971..14d078f 100644 --- a/barcode_stock/static/src/js/screens/picking_screen.esm.js +++ b/barcode_stock/static/src/js/screens/picking_screen.esm.js @@ -483,7 +483,6 @@ export class PickingScreen extends Component { } async createMoveLine(moveId, productId, qty, lotId = null) { - const move = this.barcodeScannerState.getMove(moveId); const lot = lotId ? this.barcodeScannerState.lotsById[lotId] : null; await this.barcodeScannerSync.confirmMove({ moveId, @@ -492,8 +491,6 @@ export class PickingScreen extends Component { qtyPicked: qty, lotId: lotId || false, lotName: lot?.name || false, - locationId: move?.location_id?.[0] || false, - locationDestId: move?.location_dest_id?.[0] || false, }); await this._reloadMoves(); @@ -699,8 +696,6 @@ export class PickingScreen extends Component { qtyPicked: scannedQty, lotId: false, lotName: false, - locationId: candidateMove.location_id?.[0] || false, - locationDestId: candidateMove.location_dest_id?.[0] || false, }); this.setLastScanContext({ barcode, diff --git a/barcode_stock/static/src/js/services/barcode_scanner_state.esm.js b/barcode_stock/static/src/js/services/barcode_scanner_state.esm.js index a138f0d..52c1cac 100644 --- a/barcode_stock/static/src/js/services/barcode_scanner_state.esm.js +++ b/barcode_stock/static/src/js/services/barcode_scanner_state.esm.js @@ -17,6 +17,13 @@ function sameLotId(left, right) { return (left || false) === (right || false); } +function lineLeft(line) { + return normalizeQty(line.quantity) - normalizeQty(line.qty_picked); +} + +// What is left of a pick spread over lines once float arithmetic is done with. +const QTY_EPSILON = 1e-9; + export class BarcodeScannerState extends Reactive { constructor(orm) { super(); @@ -122,6 +129,8 @@ export class BarcodeScannerState extends Reactive { "lot_name", "qty_picked", "quantity", + "location_id", + "location_dest_id", ] ); @@ -568,9 +577,89 @@ export class BarcodeScannerState extends Reactive { this.buildIndexes(); } + /** + * Where a lot would be picked from under a location, as Odoo's own + * reservation takes it (stock.quant.barcode_lot_location). + * + * @param {Number} productId the product + * @param {Number} lotId the lot + * @param {Number} locationId the location it is picked under + * @returns {Promise} the location, as [id, name], if any + */ + lotSourceLocation(productId, lotId, locationId) { + return this.orm.call("stock.quant", "barcode_lot_location", [ + productId, + lotId, + locationId, + ]); + } + + /** + * The move's lines of a lot (or, by its name, of a lot not resolved yet). + * + * @param {Object} pick the move, product and lot picked + * @returns {Object[]} the lines + */ + linesOfLot({moveId, productId, lotId = false, lotName = false}) { + const wantedLotName = (lotName || "").trim().toUpperCase(); + return this.moveLines.filter((line) => { + if (line.move_id?.[0] !== moveId || line.product_id?.[0] !== productId) { + return false; + } + if (sameLotId(line.lot_id?.[0], lotId)) { + return true; + } + // Fall back to the lot NAME so a re-scan lands on the existing line + // even when the lot resolved to a different record, instead of + // spawning a duplicate line for the same lot. + const lineLotName = (line.lot_name || line.lot_id?.[1] || "") + .trim() + .toUpperCase(); + return Boolean(wantedLotName) && lineLotName === wantedLotName; + }); + } + + /** + * Stage a pick. Outside receptions it is spread over the move's lines of + * its lot that still hold something, in turn, each taking what it holds, + * so the stock leaves every shelf it was reserved on; what is over goes on + * the last of them, as Odoo lets a move be over-picked. + * + * @param {Object} pick what stageMoveLine takes + * @returns {Object} the last line staged + */ + stagePick(pick) { + if (this.pickingTypeCode === "incoming" || pick.mode === "set") { + return this.stageMoveLine(pick); + } + const lines = this.linesOfLot(pick); + const reserved = lines.filter((line) => lineLeft(line) > 0); + if (!reserved.length) { + // Every line of the lot picked already: what is over goes on the + // last of them too. + return this.stageMoveLine( + lines.length ? {...pick, moveLineId: lines.at(-1).id} : pick + ); + } + let rest = normalizeQty(pick.qtyPicked); + let staged = null; + for (const [index, line] of reserved.entries()) { + const qty = + index === reserved.length - 1 ? rest : Math.min(rest, lineLeft(line)); + staged = this.stageMoveLine({...pick, moveLineId: line.id, qtyPicked: qty}); + rest -= qty; + if (rest <= QTY_EPSILON) { + break; + } + } + return staged; + } + /** * The line a pick goes on first: the one a screen working on a reserved - * line names, otherwise a line of its lot with nothing picked yet. + * line names; outside receptions, the first line of its lot that still has + * something to pick, so a reservation is filled line by line where Odoo + * made it; otherwise a line of its lot with nothing picked yet. * * @param {Number} moveId the move picked * @param {Number} [moveLineId] the line named, if any @@ -583,8 +672,13 @@ export class BarcodeScannerState extends Reactive { this.moveLines.find( (line) => line.id === moveLineId && line.move_id?.[0] === moveId ); + const reserved = + this.pickingTypeCode !== "incoming" && + matchingLines.find((line) => lineLeft(line) > 0); return ( - named || matchingLines.find((line) => normalizeQty(line.qty_picked) === 0) + named || + reserved || + matchingLines.find((line) => normalizeQty(line.qty_picked) === 0) ); } @@ -602,51 +696,28 @@ export class BarcodeScannerState extends Reactive { extraValues = {}, }) { const wantedLotName = (lotName || "").trim().toUpperCase(); - const matchingLines = this.moveLines.filter((line) => { - if (line.move_id?.[0] !== moveId || line.product_id?.[0] !== productId) { - return false; - } - if (sameLotId(line.lot_id?.[0], lotId)) { - return true; - } - // Fall back to the lot NAME so a re-scan lands on the existing line - // even when the lot resolved to a different record, instead of - // spawning a duplicate line for the same lot. - const lineLotName = (line.lot_name || line.lot_id?.[1] || "") - .trim() - .toUpperCase(); - return Boolean(wantedLotName) && lineLotName === wantedLotName; - }); + const matchingLines = this.linesOfLot({moveId, productId, lotId, lotName}); let targetLine = this.firstPickLine(moveId, moveLineId, matchingLines); if (!targetLine && matchingLines.length) { [targetLine] = matchingLines; } - // No line carries this lot yet: take over an already-reserved line - // (same move and product, nothing picked on it) instead of spawning a - // new line beside the untouched reservation. The scan then fills the + // No line carries this lot yet: take over a lotless reserved line (a + // reception's, nothing picked on it) instead of spawning a new line + // beside the untouched reservation. The scan then fills the // reservation — "534343: 2.5 / 10" — rather than leaving a reserved // line dangling next to a fresh lot line that reserves nothing. + // Outside receptions, a lot the reservation does not carry is reserved + // by Odoo itself (stock.move.barcode_pick_lot), not written over + // another lot's line. if (!targetLine && (lotId || wantedLotName)) { - const untouched = this.moveLines.filter( + targetLine = this.moveLines.find( (line) => line.move_id?.[0] === moveId && line.product_id?.[0] === productId && - normalizeQty(line.qty_picked) === 0 - ); - // Prefer a lotless reserved line (the reception case). - targetLine = untouched.find( - (line) => !line.lot_id?.[0] && !(line.lot_name || "").trim() + normalizeQty(line.qty_picked) === 0 && + !line.lot_id?.[0] && + !(line.lot_name || "").trim() ); - // Outside receptions, internal/outgoing pickings reserve against a - // specific lot, so a fresh line for the scanned lot would reserve - // nothing (its quantity stays 0 and _pick_qty won't raise it once - // the move is fully reserved). Take over such a reserved line and - // reassign it to the scanned lot instead: writing lot_id makes Odoo - // move the quant reservation onto it (see stock.move.line.write), - // which is what "I'm moving THIS lot" means. - if (!targetLine && this.pickingTypeCode !== "incoming") { - targetLine = untouched.find((line) => normalizeQty(line.quantity) > 0); - } } const nextQty = targetLine @@ -665,8 +736,12 @@ export class BarcodeScannerState extends Reactive { picked: true, lot_id: lotId || false, lot_name: lotName || false, - location_id: locationId || false, - location_dest_id: locationDestId || false, + // Where the stock comes from and goes to only when the screen says + // so (the operator chose it). Otherwise the line keeps the + // locations Odoo gave it -- where its stock was reserved, a putaway + // shelf -- and a new line takes its move's. + ...(locationId ? {location_id: locationId} : {}), + ...(locationDestId ? {location_dest_id: locationDestId} : {}), ...(productUomId ? {product_uom_id: productUomId} : {}), ...extraValues, }; diff --git a/barcode_stock/static/src/js/services/barcode_scanner_sync.esm.js b/barcode_stock/static/src/js/services/barcode_scanner_sync.esm.js index 46c1c6d..f925384 100644 --- a/barcode_stock/static/src/js/services/barcode_scanner_sync.esm.js +++ b/barcode_stock/static/src/js/services/barcode_scanner_sync.esm.js @@ -57,7 +57,7 @@ export class BarcodeScannerSync { lot = this.state.lotsById[payload.lotId] || null; } - const moveLine = this.state.stageMoveLine({ + const moveLine = this.state.stagePick({ moveId: payload.moveId, moveLineId: payload.moveLineId, pickingId: payload.pickingId, diff --git a/barcode_stock/static/src/xml/barcode_stock_templates.xml b/barcode_stock/static/src/xml/barcode_stock_templates.xml index f0c516f..a55b907 100644 --- a/barcode_stock/static/src/xml/barcode_stock_templates.xml +++ b/barcode_stock/static/src/xml/barcode_stock_templates.xml @@ -787,6 +787,14 @@ +
@@ -857,8 +865,8 @@
- - + + Select source
@@ -877,8 +885,8 @@