diff --git a/src/pages/putaway/components/CreatePutawayTable.ts b/src/pages/putaway/components/CreatePutawayTable.ts index 95cd307..c4f7328 100644 --- a/src/pages/putaway/components/CreatePutawayTable.ts +++ b/src/pages/putaway/components/CreatePutawayTable.ts @@ -19,6 +19,12 @@ class CreatePutawayTable extends BasePageModel { row(index: number) { return new Row(this.page, this.rows.nth(index)); } + + // Row containing the given product name. Use after flattening the table + // (Show by -> Product) so each item is its own row. + rowByProductName(name: string) { + return new Row(this.page, this.rows.filter({ hasText: name }).first()); + } } class Row extends BasePageModel { diff --git a/src/pages/putaway/components/StartPutawayTable.ts b/src/pages/putaway/components/StartPutawayTable.ts index fbd8305..fdf91e2 100644 --- a/src/pages/putaway/components/StartPutawayTable.ts +++ b/src/pages/putaway/components/StartPutawayTable.ts @@ -19,6 +19,23 @@ class StartPutawayTable extends BasePageModel { return new Row(this.page, this.rows.nth(index)); } + /* + Returns the passed product names ordered by their actual vertical position + in the table (top to bottom). + */ + async getProductsOrder(productNames: string[]): Promise { + const positions: { name: string; y: number }[] = []; + for (const name of productNames) { + const cell = this.table + .getByTestId('table-cell') + .filter({ hasText: name }) + .first(); + const box = await cell.boundingBox(); + positions.push({ name, y: box?.y ?? Number.MAX_SAFE_INTEGER }); + } + return positions.sort((a, b) => a.y - b.y).map((item) => item.name); + } + get qtyValidationTooltip() { return this.page.getByRole('tooltip'); } diff --git a/src/pages/putaway/steps/StartStep.ts b/src/pages/putaway/steps/StartStep.ts index 9cec2a8..3e9b8b4 100644 --- a/src/pages/putaway/steps/StartStep.ts +++ b/src/pages/putaway/steps/StartStep.ts @@ -30,7 +30,13 @@ class StartStep extends BasePageModel { return this.page.getByTestId('export-button'); } - get sortByCurrentBinButton() { + /* + A single button that cycles the putaway item order through three states: + original -> by current bins -> by preferred bin -> original. + Its label shows the next action in the cycle, i.e. "Sort by current bins", + then "Sort by preferred bin", then "Original order". + */ + get sortButton() { return this.page.getByTestId('sort-button'); } diff --git a/src/tests/putaway/assertAttemptToEditCompletedPutaway.test.ts b/src/tests/putaway/assertAttemptToEditCompletedPutaway.test.ts index 053673e..1ca9b65 100644 --- a/src/tests/putaway/assertAttemptToEditCompletedPutaway.test.ts +++ b/src/tests/putaway/assertAttemptToEditCompletedPutaway.test.ts @@ -158,7 +158,7 @@ test.describe('Assert attempt to edit completed putaway', () => { createPutawayPage.startStep.validationOnEditCompletedPutaway ).toBeVisible(); await createPutawayPage.startStep.closeDisplayedError(); - await createPutawayPage.startStep.sortByCurrentBinButton.click(); + await createPutawayPage.startStep.sortButton.click(); await expect( createPutawayPage.startStep.validationOnEditCompletedPutaway ).toBeVisible(); diff --git a/src/tests/putaway/sortPutawayByCurrentPreferredAndOriginalOrder.test.ts b/src/tests/putaway/sortPutawayByCurrentPreferredAndOriginalOrder.test.ts new file mode 100644 index 0000000..50684a6 --- /dev/null +++ b/src/tests/putaway/sortPutawayByCurrentPreferredAndOriginalOrder.test.ts @@ -0,0 +1,268 @@ +import { expect, test } from '@/fixtures/fixtures'; +import { Product } from '@/generated/ProductCodes.generated'; +import { + LocationResponse, + ProductResponse, + StockMovementResponse, +} from '@/types'; +import { assignPreferredBin } from '@/utils/productUtils'; +import RefreshCachesUtils from '@/utils/RefreshCaches'; +import { deleteReceivedShipment, receiveInbound } from '@/utils/shipmentUtils'; +import { byNameAsc } from '@/utils/sortUtils'; + +/* + Sort putaway items by current bin, preferred bin and original order. + + Data is designed so each sort returns a different order + + Orders by backend comparators: + - original -> product name => [A, B, C] + - currentBins -> current bins string asc => [B, C, A] + - preferredBin -> preferred bin name asc => [C, B, A] +*/ +test.describe('Sort putaway by current bin, preferred bin and original order', () => { + let inboundOne: StockMovementResponse; + let inboundTwo: StockMovementResponse; + + let productA: ProductResponse; + let productB: ProductResponse; + let productC: ProductResponse; + + let binOne: LocationResponse; + let binTwo: LocationResponse; + + test.beforeEach( + async ({ + supplierLocationService, + stockMovementService, + receivingService, + productService, + internalLocationService, + internalLocation2Service, + productShowPage, + productEditPage, + }) => { + [productA, productB, productC] = [ + await productService.getProduct(Product.ONE), + await productService.getProduct(Product.TWO), + await productService.getProduct(Product.THREE), + ].sort(byNameAsc); + + [binOne, binTwo] = [ + await internalLocationService.getLocation(), + await internalLocation2Service.getLocation(), + ].sort(byNameAsc); + + const supplierLocation = await supplierLocationService.getLocation(); + + // 1st inbound - only item A (will get a current bin via putaway). + inboundOne = await stockMovementService.createInbound({ + originId: supplierLocation.id, + }); + await stockMovementService.addItemsToInboundStockMovement(inboundOne.id, [ + { productId: productA.id, quantity: 10 }, + ]); + await receiveInbound( + { stockMovementService, receivingService }, + inboundOne, + [10] + ); + + // Assign preferred bins: A -> binTwo, B -> binOne (C has none). + await assignPreferredBin( + { productShowPage, productEditPage }, + productA, + binTwo + ); + await assignPreferredBin( + { productShowPage, productEditPage }, + productB, + binOne + ); + } + ); + + test.afterEach( + async ({ + stockMovementShowPage, + stockMovementService, + oldViewShipmentPage, + navbar, + transactionListPage, + putawayListPage, + productShowPage, + productEditPage, + }) => { + // Remove the pending 2nd putaway + await putawayListPage.goToPage(); + await putawayListPage.isLoaded(); + await putawayListPage.table.row(1).actionsButton.click(); + await putawayListPage.table.clickDeleteOrderButton(1); + + // Delete the 3 created transactions + await navbar.configurationButton.click(); + await navbar.transactions.click(); + for (let i = 0; i < 3; i++) { + await transactionListPage.deleteTransaction(1); + } + + await deleteReceivedShipment({ + stockMovementShowPage, + oldViewShipmentPage, + stockMovementService, + STOCK_MOVEMENT: inboundTwo, + }); + await deleteReceivedShipment({ + stockMovementShowPage, + oldViewShipmentPage, + stockMovementService, + STOCK_MOVEMENT: inboundOne, + }); + + // Remove preferred bins for A and B. + for (const product of [productA, productB]) { + await productShowPage.goToPage(product.id); + await productShowPage.editProductkButton.click(); + await productEditPage.inventoryLevelsTab.click(); + await productEditPage.inventoryLevelsTabSection + .row(1) + .editInventoryLevelButton.click(); + await expect( + productEditPage.inventoryLevelsTabSection.table + ).toBeVisible(); + await productEditPage.inventoryLevelsTabSection.createStockLevelModal.clickDeleteInventoryLevel(); + } + } + ); + + test('Sort putaway by current bin, preferred bin and original order', async ({ + stockMovementShowPage, + navbar, + createPutawayPage, + putawayDetailsPage, + stockMovementService, + receivingService, + supplierLocationService, + }) => { + // Extended timeout: those pages are loading slowly than others. + test.setTimeout(180_000); + + await test.step('create putaway for item A and complete it', async () => { + await stockMovementShowPage.goToPage(inboundOne.id); + await stockMovementShowPage.isLoaded(); + await RefreshCachesUtils.refreshCaches({ navbar }); + await navbar.inbound.click(); + await navbar.createPutaway.click(); + await createPutawayPage.isLoaded(); + + // Flatten the table (Show by -> Product) and select item A by name. + await createPutawayPage.showByStockMovementFilter.click(); + await createPutawayPage.table + .rowByProductName(productA.name) + .checkbox.click(); + await createPutawayPage.startPutawayButton.click(); + await createPutawayPage.startStep.isLoaded(); + + // Putaway A into binTwo (its preferred bin is auto-suggested). + await createPutawayPage.startStep.table.row(0).putawayBinSelect.click(); + await createPutawayPage.startStep.table + .row(0) + .getPutawayBin(binTwo.name) + .click(); + await createPutawayPage.startStep.nextButton.click(); + await createPutawayPage.completeStep.isLoaded(); + await createPutawayPage.completeStep.completePutawayButton.click(); + await putawayDetailsPage.isLoaded(); + await expect(putawayDetailsPage.statusTag).toHaveText('Completed'); + }); + + await test.step('create and receive 2nd inbound with items A, B and C', async () => { + const supplierLocation = await supplierLocationService.getLocation(); + inboundTwo = await stockMovementService.createInbound({ + originId: supplierLocation.id, + }); + await stockMovementService.addItemsToInboundStockMovement(inboundTwo.id, [ + { productId: productA.id, quantity: 10 }, + { productId: productB.id, quantity: 10 }, + { productId: productC.id, quantity: 10 }, + ]); + await receiveInbound( + { stockMovementService, receivingService }, + inboundTwo, + [10, 10, 10] + ); + }); + + const allProducts = [productA.name, productB.name, productC.name]; + + await test.step('go to create putaway page and start putaway for all items', async () => { + await stockMovementShowPage.goToPage(inboundTwo.id); + await stockMovementShowPage.isLoaded(); + await RefreshCachesUtils.refreshCaches({ navbar }); + await navbar.inbound.click(); + await navbar.createPutaway.click(); + await createPutawayPage.isLoaded(); + + // Flatten the table (Show by -> Product) and select items A, B, C by name. + await createPutawayPage.showByStockMovementFilter.click(); + await createPutawayPage.table + .rowByProductName(productA.name) + .checkbox.click(); + await createPutawayPage.table + .rowByProductName(productB.name) + .checkbox.click(); + await createPutawayPage.table + .rowByProductName(productC.name) + .checkbox.click(); + await createPutawayPage.startPutawayButton.click(); + await createPutawayPage.startStep.isLoaded(); + }); + + await test.step('assert original order of items', async () => { + await expect(createPutawayPage.startStep.sortButton).toContainText( + 'Sort by current bins' + ); + await expect + .poll(() => + createPutawayPage.startStep.table.getProductsOrder(allProducts) + ) + .toEqual([productA.name, productB.name, productC.name]); + }); + + await test.step('sort by current bin and assert order', async () => { + await createPutawayPage.startStep.sortButton.click(); + await expect(createPutawayPage.startStep.sortButton).toContainText( + 'Sort by preferred bin' + ); + await expect + .poll(() => + createPutawayPage.startStep.table.getProductsOrder(allProducts) + ) + .toEqual([productB.name, productC.name, productA.name]); + }); + + await test.step('sort by preferred bin and assert order', async () => { + await createPutawayPage.startStep.sortButton.click(); + await expect(createPutawayPage.startStep.sortButton).toContainText( + 'Original order' + ); + await expect + .poll(() => + createPutawayPage.startStep.table.getProductsOrder(allProducts) + ) + .toEqual([productC.name, productB.name, productA.name]); + }); + + await test.step('use original order button and assert order', async () => { + await createPutawayPage.startStep.sortButton.click(); + await expect(createPutawayPage.startStep.sortButton).toContainText( + 'Sort by current bins' + ); + await expect + .poll(() => + createPutawayPage.startStep.table.getProductsOrder(allProducts) + ) + .toEqual([productA.name, productB.name, productC.name]); + }); + }); +}); diff --git a/src/utils/productUtils.ts b/src/utils/productUtils.ts new file mode 100644 index 0000000..5edcc8d --- /dev/null +++ b/src/utils/productUtils.ts @@ -0,0 +1,26 @@ +import ProductEditPage from '@/pages/product/productEdit/ProductEditPage'; +import ProductShowPage from '@/pages/product/productShow/ProductShowPage'; +import { LocationResponse, ProductResponse } from '@/types'; + +export async function assignPreferredBin( + { + productShowPage, + productEditPage, + }: { + productShowPage: ProductShowPage; + productEditPage: ProductEditPage; + }, + product: ProductResponse, + bin: LocationResponse +) { + await productShowPage.goToPage(product.id); + await productShowPage.editProductkButton.click(); + await productEditPage.inventoryLevelsTab.click(); + await productEditPage.inventoryLevelsTabSection.createStockLevelButton.click(); + await productEditPage.inventoryLevelsTabSection.createStockLevelModal.receivingTab.click(); + await productEditPage.inventoryLevelsTabSection.createStockLevelModal.defaultPutawayLocation.click(); + await productEditPage.inventoryLevelsTabSection.createStockLevelModal + .getDefaultPutawayLocation(bin.name) + .click(); + await productEditPage.inventoryLevelsTabSection.createStockLevelModal.createButton.click(); +} diff --git a/src/utils/shipmentUtils.ts b/src/utils/shipmentUtils.ts index e33c8ea..f6ecb8b 100644 --- a/src/utils/shipmentUtils.ts +++ b/src/utils/shipmentUtils.ts @@ -1,4 +1,7 @@ +import ReceivingService from '@/api/ReceivingService'; import StockMovementService from '@/api/StockMovementService'; +import AppConfig from '@/config/AppConfig'; +import { ShipmentType } from '@/constants/ShipmentType'; import OldViewShipmentPage from '@/pages/stockMovementShow/OldViewShipmentPage'; import StockMovementShowPage from '@/pages/stockMovementShow/StockMovementShowPage'; import { ReceiptResponse, StockMovementResponse } from '@/types'; @@ -16,6 +19,46 @@ export const getShipmentItemId = ( .shipmentItemId; }; +/* + Sends an inbound stock movement and receives the given quantities (one per + line, in order) into a single receiving bin, then completes the receipt. +*/ +export async function receiveInbound( + { + stockMovementService, + receivingService, + }: { + stockMovementService: StockMovementService; + receivingService: ReceivingService; + }, + stockMovement: StockMovementResponse, + quantities: number[], + shipmentType: ShipmentType = ShipmentType.AIR +) { + await stockMovementService.sendInboundStockMovement(stockMovement.id, { + shipmentType, + }); + + const { data: refreshed } = await stockMovementService.getStockMovement( + stockMovement.id + ); + const shipmentId = getShipmentId(refreshed); + const { data: receipt } = await receivingService.getReceipt(shipmentId); + const receivingBin = + AppConfig.instance.receivingBinPrefix + stockMovement.identifier; + + await receivingService.createReceivingBin(shipmentId, receipt); + await receivingService.updateReceivingItems( + shipmentId, + quantities.map((quantity, index) => ({ + shipmentItemId: getShipmentItemId(receipt, 0, index), + quantityReceiving: quantity, + binLocationName: receivingBin, + })) + ); + await receivingService.completeReceipt(shipmentId); +} + export async function deleteReceivedShipment({ stockMovementShowPage, oldViewShipmentPage, diff --git a/src/utils/sortUtils.ts b/src/utils/sortUtils.ts new file mode 100644 index 0000000..9e41a09 --- /dev/null +++ b/src/utils/sortUtils.ts @@ -0,0 +1,17 @@ +/* + Case-insensitive ascending comparator by `name`. Mirrors the backend's + lexicographic (toLowerCase) ordering used for products and bin locations. +*/ +export const byNameAsc = (a: { name: string }, b: { name: string }) => { + const aName = a.name.toLowerCase(); + const bName = b.name.toLowerCase(); + if (aName < bName) { + return -1; + } + + if (aName > bName) { + return 1; + } + + return 0; +};