From 23714ea33fe647e820ea130c8f2d0b874399a904 Mon Sep 17 00:00:00 2001 From: Zuzanna Elzbieta Szalaty Szalaty Date: Fri, 23 Oct 2026 13:40:27 +0100 Subject: [PATCH] [FIX] barcode_stock: open a move reserved against a lot with that lot chosen A delivery or internal move that Odoo reserved against a lot opened with no lot chosen, so the operator had to pick from the list the very lot the reservation already named before confirming. It now opens with the lot of the first reserved line that still has something to pick, as Odoo's own barcode app does; a scanned lot, or one the operator chooses, still wins. Receptions are left as they were. The wizard then works on that reserved line: what is left to pick (shown, and filled by Fulfill) is what the line still holds, not the whole move, and confirming puts the pick on it. A move reserved over several lines or lots is so completed line by line, and one opened by tapping it starts at no more than its line holds. --- .../src/js/screens/move_wizard_screen.esm.js | 139 +++++++++- .../src/js/screens/picking_screen.esm.js | 3 +- .../js/services/barcode_scanner_state.esm.js | 25 +- .../js/services/barcode_scanner_sync.esm.js | 1 + .../static/tests/move_wizard_lot.test.js | 252 ++++++++++++++++++ 5 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 barcode_stock/static/tests/move_wizard_lot.test.js 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 bcea5e1..a54ad80 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 @@ -127,6 +127,116 @@ export class MoveWizardScreen extends Component { return this.props.params?.pickingTypeCode === "incoming"; } + /** + * Open a move reserved against a lot with that lot chosen, as Odoo's own + * barcode app does: the reservation already says which lot goes. A lot the + * scan carried, or the operator chose, still wins. + * + * @param {Object[]} moveLines the move's lines + * @param {Object[]} lots the lots offered + * @returns {Object[]} the lots offered, the reserved one among them + */ + preselectReservedLot(moveLines, lots) { + const reservedLotId = this.reservedLine(moveLines)?.lot_id[0]; + if (this.selectedLotIdNumber || !reservedLotId) { + 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]; + } + return lots; + } + + /** + * The reserved line the move goes on with: its first line reserved against + * a lot that still has something to pick, of a lot not covered yet. + * + * @param {Object[]} moveLines the move's lines + * @returns {Object|null} the line, if any + */ + reservedLine(moveLines) { + if (!this.followsReservation) { + return null; + } + return ( + moveLines.find( + (line) => + line.lot_id?.[0] && + this.lineLeft(line) > 0 && + this.reservedLeft(moveLines, line.lot_id[0]) > 0 + ) || null + ); + } + + /** + * 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. + * + * @returns {Object|null} the line, if any + */ + get targetLine() { + const lotId = this.selectedLotIdNumber; + const lines = this.state.moveLines || []; + if ( + !lotId || + !this.followsReservation || + this.reservedLeft(lines, lotId) <= 0 + ) { + return null; + } + return ( + lines.find( + (line) => line.lot_id?.[0] === 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. + * + * @returns {Boolean} + */ + get followsReservation() { + return !this.isIncoming && this.isTracked && this.state.useExistingLots; + } + + /** + * What the reservation still holds of a lot: what the move's lines reserve + * of it, less what was picked of it on any of them. A lot over-picked on + * one line is covered, whatever its other lines still reserve. + * + * @param {Object[]} moveLines the move's lines + * @param {Number} lotId the lot's id + * @returns {Number} the quantity still to pick of it + */ + reservedLeft(moveLines, lotId) { + return moveLines + .filter((line) => line.lot_id?.[0] === lotId) + .reduce((sum, line) => sum + this.lineLeft(line), 0); + } + + /** + * What a line still holds: what it reserves, less what was picked on it. + * + * @param {Object} line a move line + * @returns {Number} the quantity still to pick on it + */ + lineLeft(line) { + return (Number(line.quantity) || 0) - (Number(line.qty_picked) || 0); + } + get isOutgoing() { return this.props.params?.pickingTypeCode === "outgoing"; } @@ -179,15 +289,34 @@ export class MoveWizardScreen extends Component { return true; } + get targetLineId() { + return this.targetLine?.id || false; + } + + /** + * What is left to pick here: what the reserved line it goes on still holds, + * so that a move reserved over several lines is completed line by line; + * otherwise, and always in receptions, what the whole move lacks. + * + * @returns {Number} the quantity left to pick + */ + get pickableQty() { + const line = this.targetLine; + if (line) { + return Math.min(this.lineLeft(line), this.remainingQty); + } + return this.remainingQty; + } + get canFulfill() { if (this.isSerial) return false; // Shown while anything is still missing, including the last unit: the // shortcut disappearing at 1 reads as "nothing left to pick". - return this.remainingQty - (this.state.qtyPicked || 0) > 0; + return this.pickableQty - (this.state.qtyPicked || 0) > 0; } get fulfillIncrement() { - return Math.max(this.remainingQty - (this.state.qtyPicked || 0), 0); + return Math.max(this.pickableQty - (this.state.qtyPicked || 0), 0); } get isTracked() { @@ -256,7 +385,7 @@ export class MoveWizardScreen extends Component { if (this.isSerial) { return _t("Serial-tracked products can only process one unit."); } - return `${this.remainingQty} ${_t("remaining to process")}`; + return `${this.pickableQty} ${_t("remaining to process")}`; } get confirmLabel() { @@ -356,7 +485,7 @@ export class MoveWizardScreen extends Component { fulfillRemaining() { if (!this.canFulfill) return; - this.state.qtyPicked = this.remainingQty; + this.state.qtyPicked = this.pickableQty; this.state._lastInputSource = "tap"; this.feedback.success({ message: _t("Remaining quantity applied"), @@ -498,6 +627,7 @@ export class MoveWizardScreen extends Component { this.state.move = move; 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). @@ -617,6 +747,7 @@ export class MoveWizardScreen extends Component { await this.barcodeScannerSync.confirmMove({ moveId: this.moveId, + moveLineId: this.targetLineId, pickingId: this.pickingId, productId: this.state.move.product_id[0], qtyPicked: this.state.qtyPicked, 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 67ab08d..40b7971 100644 --- a/barcode_stock/static/src/js/screens/picking_screen.esm.js +++ b/barcode_stock/static/src/js/screens/picking_screen.esm.js @@ -453,7 +453,8 @@ export class PickingScreen extends Component { moveId: move.id, pickingId: this.state.picking.id, pickingTypeCode: this.state.pickingTypeCode, - defaultQty: defaults.qty || 1, + // A scan's quantity; a tapped move starts at one unit in the wizard. + defaultQty: defaults.qty || null, defaultLot: defaults.lot || null, lotName: defaults.lotName || null, expiration: defaults.expiration || null, 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 6ab3858..a138f0d 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 @@ -568,8 +568,29 @@ export class BarcodeScannerState extends Reactive { this.buildIndexes(); } + /** + * 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. + * + * @param {Number} moveId the move picked + * @param {Number} [moveLineId] the line named, if any + * @param {Object[]} matchingLines the move's lines of the lot picked + * @returns {Object|undefined} the line, if any + */ + firstPickLine(moveId, moveLineId, matchingLines) { + const named = + moveLineId && + this.moveLines.find( + (line) => line.id === moveLineId && line.move_id?.[0] === moveId + ); + return ( + named || matchingLines.find((line) => normalizeQty(line.qty_picked) === 0) + ); + } + stageMoveLine({ moveId, + moveLineId, pickingId, productId, qtyPicked, @@ -596,9 +617,7 @@ export class BarcodeScannerState extends Reactive { .toUpperCase(); return Boolean(wantedLotName) && lineLotName === wantedLotName; }); - let targetLine = matchingLines.find( - (line) => normalizeQty(line.qty_picked) === 0 - ); + let targetLine = this.firstPickLine(moveId, moveLineId, matchingLines); if (!targetLine && matchingLines.length) { [targetLine] = matchingLines; } 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 3793451..46c1c6d 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 @@ -59,6 +59,7 @@ export class BarcodeScannerSync { const moveLine = this.state.stageMoveLine({ moveId: payload.moveId, + moveLineId: payload.moveLineId, pickingId: payload.pickingId, productId: payload.productId, qtyPicked: payload.qtyPicked, diff --git a/barcode_stock/static/tests/move_wizard_lot.test.js b/barcode_stock/static/tests/move_wizard_lot.test.js new file mode 100644 index 0000000..891c6f6 --- /dev/null +++ b/barcode_stock/static/tests/move_wizard_lot.test.js @@ -0,0 +1,252 @@ +import {beforeEach, describe, expect, test} from "@odoo/hoot"; +import {BarcodeScannerState} from "@barcode_stock/js/services/barcode_scanner_state.esm"; +import {MoveWizardScreen} from "@barcode_stock/js/screens/move_wizard_screen.esm"; +import {PickingScreen} from "@barcode_stock/js/screens/picking_screen.esm"; +import {patchTranslations} from "@web/../tests/web_test_helpers"; + +const LOT_A = {id: 21, name: "6077102", product_id: [7, "Duck confit"], product_qty: 4}; +const LOT_B = {id: 22, name: "6077103", product_id: [7, "Duck confit"], product_qty: 9}; + +/** + * A move wizard on move 5 of picking 3, without mounting it. What it confirms + * lands in `confirmed`. + * + * @param {Object} [options] the operation, the move's lines and the lot and + * quantity given + * @returns {MoveWizardScreen} the wizard + */ +function moveWizard({ + type = "outgoing", + lines = [], + defaultLot = null, + defaultQty = null, + tracking = "lot", + remaining = 4, +} = {}) { + const wizard = Object.create(MoveWizardScreen.prototype); + wizard.props = { + params: { + moveId: 5, + pickingId: 3, + pickingTypeCode: type, + defaultLot, + defaultQty, + }, + }; + wizard.state = { + selectedLotId: defaultLot ? String(defaultLot) : null, + qtyPicked: defaultQty || 1, + tracking: "none", + mode: "pick", + lots: [], + useExistingLots: true, + useCreateLots: false, + sourceLocation: null, + destLocation: null, + }; + wizard.barcodeScannerState = { + ready: true, + activePickingId: 3, + useExistingLots: true, + useCreateLots: type === "incoming", + lotsById: {21: LOT_A, 22: LOT_B}, + getMove: () => ({ + id: 5, + product_id: [7, "Duck confit"], + product_uom_qty: 4, + location_id: [8, "WH/Stock"], + location_dest_id: [9, "Customers"], + }), + getProduct: () => ({id: 7, tracking}), + getMoveLinesForMove: () => lines, + getLotsForProduct: () => [LOT_B], + getRemainingQty: () => remaining, + }; + Object.assign(wizard, { + feedback: {success: () => undefined}, + barcodeScannerSync: { + validateConfirmMovePayload: () => null, + confirmMove: async (payload) => { + wizard.confirmed = payload; + }, + }, + store: {navigate: () => undefined}, + inventory: {notify: () => undefined}, + }); + return wizard; +} + +let lineId = 0; + +function line(lotId, quantity, qtyPicked = 0) { + lineId += 1; + return { + id: lineId, + move_id: [5, "Duck confit"], + product_id: [7, "Duck confit"], + lot_id: lotId ? [lotId, lotId === 21 ? "6077102" : "6077103"] : false, + quantity, + qty_picked: qtyPicked, + }; +} + +describe("BarcodeStock", () => { + // `_t()` texts become strings only once translations are loaded. + beforeEach(() => patchTranslations()); + + test("a move reserved against a lot opens with that lot chosen", async () => { + const wizard = moveWizard({lines: [line(21, 4)]}); + await wizard.loadData(); + expect(wizard.state.selectedLotId).toBe("21"); + // Listed even if the lots offered had left it out. + expect(wizard.state.lots.map((lot) => lot.id)).toEqual([21, 22]); + }); + + test("the next reserved lot once the first one is picked", async () => { + const wizard = moveWizard({lines: [line(21, 2, 2), line(22, 2)]}); + await wizard.loadData(); + expect(wizard.state.selectedLotId).toBe("22"); + // Everything picked: no lot is chosen for the operator. + const done = moveWizard({lines: [line(21, 2, 2), line(22, 2, 2)]}); + await done.loadData(); + expect(done.state.selectedLotId).toBe(null); + }); + + test("a scanned lot, a reception or an untracked product keep their way", async () => { + const scanned = moveWizard({lines: [line(21, 4)], defaultLot: 22}); + await scanned.loadData(); + expect(scanned.state.selectedLotId).toBe("22"); + const receipt = moveWizard({type: "incoming", lines: [line(21, 4)]}); + await receipt.loadData(); + expect(receipt.state.selectedLotId).toBe(null); + const untracked = moveWizard({tracking: "none", lines: [line(21, 4)]}); + await untracked.loadData(); + expect(untracked.state.selectedLotId).toBe(null); + const noLot = moveWizard({lines: [line(null, 4)]}); + await noLot.loadData(); + expect(noLot.state.selectedLotId).toBe(null); + }); + + test("Fulfill completes the chosen lot's reservation, not the whole move", async () => { + // 4 reserved over two lots, 2 each. + const wizard = moveWizard({lines: [line(21, 2), line(22, 2)]}); + await wizard.loadData(); + expect(wizard.state.selectedLotId).toBe("21"); + expect(wizard.pickableQty).toBe(2); + expect(wizard.fulfillIncrement).toBe(1); + expect(wizard.quantityHelper).toBe("2 remaining to process"); + wizard.fulfillRemaining(); + expect(wizard.state.qtyPicked).toBe(2); + expect(wizard.canFulfill).toBe(false); + // Lot A picked, the wizard opens on lot B with what is left of it. + const next = moveWizard({lines: [line(21, 2, 2), line(22, 2)], remaining: 2}); + await next.loadData(); + expect(next.state.selectedLotId).toBe("22"); + next.fulfillRemaining(); + expect(next.state.qtyPicked).toBe(2); + }); + + test("a lot with nothing reserved left fulfils what the move lacks", async () => { + // The operator picks lot A again, its reservation already picked. + const wizard = moveWizard({ + lines: [line(21, 2, 2), line(22, 2)], + defaultLot: 21, + remaining: 2, + }); + await wizard.loadData(); + expect(wizard.state.selectedLotId).toBe("21"); + expect(wizard.pickableQty).toBe(2); + // No lot chosen at all: the move's remaining, as before. + const none = moveWizard({tracking: "none", remaining: 3}); + await none.loadData(); + expect(none.pickableQty).toBe(3); + }); + + test("a lot reserved over several lines is picked line by line", async () => { + // 9 reserved: lot A over two lines (2 + 3), lot B on one. + const [a1, a2, b] = [line(21, 2), line(21, 3), line(22, 4)]; + const wizard = moveWizard({lines: [a1, a2, b], remaining: 9}); + await wizard.loadData(); + expect(wizard.state.selectedLotId).toBe("21"); + expect(wizard.targetLine).toBe(a1); + wizard.fulfillRemaining(); + expect(wizard.state.qtyPicked).toBe(2); + // Confirmed on that very line. + await wizard.confirmPick(); + expect(wizard.confirmed).toMatchObject({ + moveLineId: a1.id, + lotId: 21, + qtyPicked: 2, + }); + // Its first line picked, lot A goes on with its second one. + a1.qty_picked = 2; + const next = moveWizard({lines: [a1, a2, b], remaining: 7}); + await next.loadData(); + expect(next.state.selectedLotId).toBe("21"); + expect(next.targetLine).toBe(a2); + expect(next.pickableQty).toBe(3); + // Lot A covered, even if over-picked on its first line: on to lot B. + a1.qty_picked = 5; + const last = moveWizard({lines: [a1, a2, b], remaining: 4}); + await last.loadData(); + expect(last.state.selectedLotId).toBe("22"); + expect(last.targetLine).toBe(b); + expect(last.pickableQty).toBe(4); + }); + + test("a pick goes on the reserved line named", () => { + const state = new BarcodeScannerState({}); + const [a1, a2] = [line(21, 2), line(21, 3)]; + state.moveLines = [a1, a2]; + const pick = {moveId: 5, pickingId: 3, productId: 7, lotId: 21}; + state.stageMoveLine({...pick, qtyPicked: 3, moveLineId: a2.id}); + expect(a2.qty_picked).toBe(3); + expect(a1.qty_picked).toBe(0); + // None named: on the lot's line with nothing picked yet, as before. + state.stageMoveLine({...pick, qtyPicked: 1}); + expect(a1.qty_picked).toBe(1); + // One of another move is not taken. + state.stageMoveLine({...pick, moveId: 6, qtyPicked: 1, moveLineId: a1.id}); + expect(a1.qty_picked).toBe(1); + }); + + test("a tapped move starts at no more than its lot's reservation holds", async () => { + const tapped = moveWizard({lines: [line(21, 0.5)]}); + await tapped.loadData(); + expect(tapped.state.selectedLotId).toBe("21"); + expect(tapped.state.qtyPicked).toBe(0.5); + // Still one unit when the lot holds more. + const more = moveWizard({lines: [line(21, 3)]}); + await more.loadData(); + expect(more.state.qtyPicked).toBe(1); + // A scan's quantity stands. + const scanned = moveWizard({lines: [line(21, 0.5)], defaultQty: 1}); + await scanned.loadData(); + expect(scanned.state.qtyPicked).toBe(1); + }); + + test("only a scan hands the wizard a quantity", () => { + const screen = Object.create(PickingScreen.prototype); + const opened = []; + screen.props = {}; + screen.state = {picking: {id: 3}, pickingTypeCode: "outgoing"}; + screen.store = {navigate: (name, params) => opened.push(params.defaultQty)}; + const move = {id: 5, quantity: 4}; + screen.openMoveWizard(move); + screen.openMoveWizard(move, {qty: 6}); + expect(opened).toEqual([null, 6]); + }); + + test("a reception fulfils what the move lacks, whatever its lot's lines hold", async () => { + const receipt = moveWizard({ + type: "incoming", + lines: [line(21, 1)], + defaultLot: 21, + remaining: 4, + }); + await receipt.loadData(); + expect(receipt.pickableQty).toBe(4); + receipt.fulfillRemaining(); + expect(receipt.state.qtyPicked).toBe(4); + }); +});