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 @@