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); + }); +});