diff --git a/src/components/Navbar.ts b/src/components/Navbar.ts index 42e07ab..35f62b3 100644 --- a/src/components/Navbar.ts +++ b/src/components/Navbar.ts @@ -116,6 +116,10 @@ class Navbar extends BasePageModel { get inventory() { return this.getNavItem('Inventory'); } + + get performCycleCount() { + return this.getNavItem('Perform Cycle Count'); + } } export default Navbar; diff --git a/src/pages/manageCycleCount/ManageCycleCountPage.ts b/src/pages/manageCycleCount/ManageCycleCountPage.ts index afe3333..54f1466 100644 --- a/src/pages/manageCycleCount/ManageCycleCountPage.ts +++ b/src/pages/manageCycleCount/ManageCycleCountPage.ts @@ -8,17 +8,23 @@ import ToCountTable from '@/pages/manageCycleCount/components/ToCountTable'; class ManageCycleCountPage extends BasePageModel { allProductsTable: AllProductsTable; toCountTable: ToCountTable; + toResolveTable: ToCountTable; constructor(page: Page) { super(page); this.allProductsTable = new AllProductsTable(page); this.toCountTable = new ToCountTable(page); + this.toResolveTable = new ToCountTable(page); } async goToPage() { await this.page.goto(CYCLE_COUNT_URL.base); } + async goToPerformCycleCount() { + await this.page.goto(`${CYCLE_COUNT_URL.base}?tab=TO_COUNT`); + } + async isLoaded() { await expect(this.allProductsTab).toBeVisible(); } @@ -64,10 +70,30 @@ class ManageCycleCountPage extends BasePageModel { } async searchProduct(productName: string) { - await this.searchInput.fill(productName); + await this.searchInput.click(); + await this.page.keyboard.press('ControlOrMeta+A'); + await this.searchInput.pressSequentially(productName, { delay: 20 }); + await expect(this.searchInput).toHaveValue(productName); await this.filterButton.click(); } + get negativeQuantityFilterGroup() { + return this.page.locator('.filter-group', { + has: this.page.locator('label[for="negativeQuantity"]'), + }); + } + + get negativeQuantityCheckbox() { + return this.negativeQuantityFilterGroup.getByRole('checkbox'); + } + + async openNegativeQuantityTooltip() { + const trigger = this.negativeQuantityFilterGroup.locator('[data-tooltipped]'); + await trigger.hover(); + const describedBy = await trigger.getAttribute('aria-describedby'); + return this.page.locator(`#${describedBy}`); + } + // ACTIONS get markAsToCountButton() { return this.page.getByRole('button', { name: 'Mark as To Count' }); @@ -76,6 +102,10 @@ class ManageCycleCountPage extends BasePageModel { get startCountButton() { return this.page.getByRole('button', { name: 'Start Count' }); } + + get startResolutionButton() { + return this.page.getByRole('button', { name: 'Start resolution' }); + } } export default ManageCycleCountPage; diff --git a/src/pages/manageCycleCount/components/CountStepTable.ts b/src/pages/manageCycleCount/components/CountStepTable.ts index 0571e1c..51e3007 100644 --- a/src/pages/manageCycleCount/components/CountStepTable.ts +++ b/src/pages/manageCycleCount/components/CountStepTable.ts @@ -14,6 +14,10 @@ class CountStepTable extends BasePageModel { row(index: number) { return new Row(this.page, this.rows.nth(index)); } + + rowByBinLocation(binLocationName: string) { + return new Row(this.page, this.rows.filter({ hasText: binLocationName }).first()); + } } class Row extends BasePageModel { @@ -59,6 +63,7 @@ class Row extends BasePageModel { await this.quantityCountedInput.pressSequentially(value, { delay: 100, }); + await this.page.waitForTimeout(300); await this.page.keyboard.press('Tab'); await expect(this.quantityCountedInput).toHaveValue(value, { timeout: 2000, diff --git a/src/pages/manageCycleCount/components/RecountStepTable.ts b/src/pages/manageCycleCount/components/RecountStepTable.ts index 194bdf0..f297749 100644 --- a/src/pages/manageCycleCount/components/RecountStepTable.ts +++ b/src/pages/manageCycleCount/components/RecountStepTable.ts @@ -14,6 +14,10 @@ class RecountStepTable extends BasePageModel { row(index: number) { return new Row(this.page, this.rows.nth(index)); } + + rowByBinLocation(binLocationName: string) { + return new Row(this.page, this.rows.filter({ hasText: binLocationName }).first()); + } } class Row extends BasePageModel { diff --git a/src/pages/product/productShow/tabs/InStockTabSection.ts b/src/pages/product/productShow/tabs/InStockTabSection.ts index 9b7ee9a..ee8489c 100644 --- a/src/pages/product/productShow/tabs/InStockTabSection.ts +++ b/src/pages/product/productShow/tabs/InStockTabSection.ts @@ -32,6 +32,10 @@ class InStockTabSection extends BasePageModel { return new Row(this.page, this.rows.nth(index)); } + rowByBinLocation(binLocationName: string) { + return new Row(this.page, this.rows.filter({ hasText: binLocationName }).first()); + } + get stockTransferButton() { return this.page.getByRole('link', { name: 'Transfer Stock' }); } diff --git a/src/setup/dataImport/cycleCountInventory.csv b/src/setup/dataImport/cycleCountInventory.csv index 34b3e0a..6ff6373 100644 --- a/src/setup/dataImport/cycleCountInventory.csv +++ b/src/setup/dataImport/cycleCountInventory.csv @@ -1,2 +1,3 @@ Product code,Product,Lot number,Expiration date,Bin location,Quantity,Comment 7,CC-product-one,,,CC-BIN-1,100, +9,CC-product-three,,,CC-BIN-2,10, diff --git a/src/setup/dataImport/products.csv b/src/setup/dataImport/products.csv index 1f7d960..ec90ba2 100644 --- a/src/setup/dataImport/products.csv +++ b/src/setup/dataImport/products.csv @@ -6,5 +6,6 @@ Id,Active,ProductCode,ProductType,Name,ProductFamily,Category,GLAccount,Descript ,true,5,Default,E2E-product-five,,ARVS,,,,,,,,,,,,,,,,,,,,, ,true,6,Default,E2E-product-six,,ARVS,,,,,,,,,,,,,,,,,,,,, ,true,7,Default,CC-product-one,,Anesteshia,,,,,,,,,,,,,,,,,,,,, - +,true,8,Default,CC-product-two,,Anesteshia,,,,,,,,,,,,,,,,,,,,, +,true,9,Default,CC-product-three,,ARVS,,,,,,,,,,,,,,,,,,,,, diff --git a/src/tests/cycleCount/performCC.test.ts b/src/tests/cycleCount/performCC.test.ts index 6fae34d..e452efa 100644 --- a/src/tests/cycleCount/performCC.test.ts +++ b/src/tests/cycleCount/performCC.test.ts @@ -46,6 +46,8 @@ test.describe('Perform cycle count for item', () => { confirmToRecountStepPage, productShowPage, }) => { + test.setTimeout(120_000); + const USER = await mainUserService.getUser(); await test.step('Assert content of inventory menu', async () => { @@ -69,8 +71,13 @@ test.describe('Perform cycle count for item', () => { await test.step('Search for product on All Products tab', async () => { await manageCycleCountPage.openAllProductsTab(); - await manageCycleCountPage.searchProduct(productName); - await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount(1); + + await expect(async () => { + await manageCycleCountPage.searchProduct(productName); + await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount( + 1 + ); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); }); await test.step('Assert Last Counted date on All Products tab matches stock card', async () => { diff --git a/src/tests/cycleCount/performCCitemWithNegativeInventory.test.ts b/src/tests/cycleCount/performCCitemWithNegativeInventory.test.ts new file mode 100644 index 0000000..b84b6c6 --- /dev/null +++ b/src/tests/cycleCount/performCCitemWithNegativeInventory.test.ts @@ -0,0 +1,421 @@ +import AppConfig from '@/config/AppConfig'; +import { expect, test } from '@/fixtures/fixtures'; +import { StockMovementResponse } from '@/types'; +import { deleteShipment, receiveInbound } from '@/utils/shipmentUtils'; + +test.describe('Perform cycle count when item has negative inventory', () => { + const productCode = '9'; + const productName = AppConfig.instance.products[productCode].name; + const facilityId = AppConfig.instance.locations.ccDepot.readId(); + + let STOCK_MOVEMENT: StockMovementResponse; + let cycleCountId: string | undefined; + + test.beforeEach( + async ({ + supplierLocationService, + stockMovementService, + receivingService, + }) => { + const supplierLocation = await supplierLocationService.getLocation(); + const productId = AppConfig.instance.products[productCode].readId(); + + STOCK_MOVEMENT = await stockMovementService.createInbound({ + originId: supplierLocation.id, + }); + + await stockMovementService.addItemsToInboundStockMovement( + STOCK_MOVEMENT.id, + [{ productId, quantity: 20 }] + ); + + await receiveInbound( + { stockMovementService, receivingService }, + STOCK_MOVEMENT, + [20] + ); + } + ); + + test.afterEach( + async ({ + cycleCountService, + stockMovementService, + productShowPage, + transactionService, + }) => { + if (cycleCountId) { + await cycleCountService.deleteCycleCount(facilityId, cycleCountId); + } + await deleteShipment({ stockMovementService, STOCK_MOVEMENT }); + + const productId = AppConfig.instance.products[productCode].readId(); + await productShowPage.goToPage(productId); + await productShowPage.stockHistoryTab.click(); + await productShowPage.stockHistoryTabSection.isLoaded(); + + const rows = productShowPage.stockHistoryTabSection.rows; + const count = await rows.count(); + + const transactionIds: string[] = []; + for (let i = 2; i < count; i++) { + const row = productShowPage.stockHistoryTabSection.row(i); + const links = await row.transactionLink.all(); + for (const link of links) { + const href = await link.getAttribute('href'); + const id = href?.match(/showTransaction\/(\w+)/)?.[1]; + if (id && !transactionIds.includes(id)) { + transactionIds.push(id); + } + } + } + + for (const id of transactionIds.reverse()) { + await transactionService.deleteTransaction(id); + } + } + ); + + test('Perform cycle count when item has negative inventory', async ({ + page, + navbar, + manageCycleCountPage, + countStepPage, + confirmToCountStepPage, + recountStepPage, + confirmToRecountStepPage, + productShowPage, + ccDepotService, + transactionService, + }) => { + test.setTimeout(120_000); + + const productId = AppConfig.instance.products[productCode].readId(); + const receivingBin = + AppConfig.instance.receivingBinPrefix + STOCK_MOVEMENT.identifier; + let inboundTransferInRowIndex: number; + + await test.step('Open All Products tab and assert product is not displayed first', async () => { + await expect(async () => { + await manageCycleCountPage.goToPage(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.openAllProductsTab(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).not.toContainText(productName); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Go to stock card and note the inbound Transfer In transaction', async () => { + await productShowPage.goToPage(productId); + await productShowPage.stockHistoryTab.click(); + await productShowPage.stockHistoryTabSection.isLoaded(); + + inboundTransferInRowIndex = + (await productShowPage.stockHistoryTabSection.rows.count()) - 1; + }); + + await test.step('Perform Stock Transfer of inventory from receiving bin to CC-BIN-1', async () => { + const ccDepot = await ccDepotService.getLocation(); + + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + + const row = + productShowPage.inStockTabSection.rowByBinLocation(receivingBin); + await row.actionsButton.click(); + await productShowPage.inStockTabSection.stockTransferButton.click(); + await productShowPage.inStockTabSection.stockTransferDialog.isLoaded(); + + await productShowPage.inStockTabSection.stockTransferDialog.locationSelect.click(); + await productShowPage.inStockTabSection.stockTransferDialog.selectLocation( + ccDepot.name + ); + await productShowPage.inStockTabSection.stockTransferDialog.binLocationSelect.click(); + await productShowPage.inStockTabSection.stockTransferDialog.selectLocation( + 'CC-BIN-1' + ); + await productShowPage.inStockTabSection.stockTransferDialog.transferStockButton.click(); + + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + await expect( + productShowPage.inStockTabSection.rowByBinLocation('CC-BIN-1') + .binLocation + ).toContainText('CC-BIN-1'); + }); + + await test.step('Delete the Transfer In transaction created by the inbound receipt', async () => { + await productShowPage.stockHistoryTab.click(); + await productShowPage.stockHistoryTabSection.isLoaded(); + + const row = productShowPage.stockHistoryTabSection.row( + inboundTransferInRowIndex + ); + const href = await row.transactionLink.getAttribute('href'); + const transactionId = href?.match(/showTransaction\/(\w+)/)?.[1]; + // eslint-disable-next-line playwright/no-conditional-in-test + if (!transactionId) { + throw new Error( + 'Could not find id of the inbound Transfer In transaction' + ); + } + await transactionService.deleteTransaction(transactionId); + }); + + await test.step('Assert negative inventory in the receiving bin', async () => { + await productShowPage.goToPage(productId); + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + + const row = + productShowPage.inStockTabSection.rowByBinLocation(receivingBin); + await expect(row.quantityOnHand).toHaveText('-20'); + }); + + await test.step('Assert product shows first on All Products tab due to negative inv', async () => { + await expect(async () => { + await manageCycleCountPage.goToPage(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.openAllProductsTab(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + await expect(row.binLocation).toContainText('CC-BIN-1'); + await expect(row.binLocation).toContainText('CC-BIN-2'); + await expect(row.binLocation).toContainText(receivingBin); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Assert Negative quantity filter tooltip and apply the filter', async () => { + const tooltip = await manageCycleCountPage.openNegativeQuantityTooltip(); + await expect(tooltip).toContainText( + 'Returns products with negative inventory items in stock. Unselected for all products.' + ); + + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + await manageCycleCountPage.allProductsTable.isLoaded(); + + await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount(1); + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + }); + + await test.step('Select item and mark as To Count', async () => { + const row = manageCycleCountPage.allProductsTable.row(0); + await row.checkbox.check(); + await manageCycleCountPage.markAsToCountButton.click(); + }); + + await test.step('Start count from the To Count tab', async () => { + await expect(manageCycleCountPage.toCountTab).toHaveClass('active-tab'); + await manageCycleCountPage.toCountTable.isLoaded(); + + const [response] = await Promise.all([ + page.waitForResponse((res) => res.url().includes('/start/batch')), + manageCycleCountPage.startCountButton.click(), + ]); + const body = await response.json(); + cycleCountId = body.data[0].id; + await countStepPage.isLoaded(); + }); + + await test.step('Assert bin locations on count step and fill quantities', async () => { + await expect(countStepPage.countStepTable.rows).toHaveCount(3); + + const ccBin1Row = + countStepPage.countStepTable.rowByBinLocation('CC-BIN-1'); + await expect(ccBin1Row.binLocation).toContainText('CC-BIN-1'); + await ccBin1Row.fillQuantityCounted('0'); + + const ccBin2Row = + countStepPage.countStepTable.rowByBinLocation('CC-BIN-2'); + await expect(ccBin2Row.binLocation).toContainText('CC-BIN-2'); + await ccBin2Row.fillQuantityCounted('10'); + + const receivingBinRow = + countStepPage.countStepTable.rowByBinLocation(receivingBin); + await expect(receivingBinRow.binLocation).toContainText(receivingBin); + await receivingBinRow.fillQuantityCounted('0'); + }); + + await test.step('Save progress and go to Perform Cycle Count', async () => { + await countStepPage.saveProgressButton.click(); + await navbar.inventory.click(); + await expect(navbar.performCycleCount).toBeVisible(); + await page.keyboard.press('Escape'); + + await manageCycleCountPage.goToPerformCycleCount(); + await expect(manageCycleCountPage.toCountTab).toHaveClass('active-tab'); + }); + + await test.step('Assert product present on To Count tab with In progress status', async () => { + await expect(async () => { + await manageCycleCountPage.goToPerformCycleCount(); + await manageCycleCountPage.toCountTable.isLoaded(); + await expect(manageCycleCountPage.toCountTable.rows).toHaveCount(1); + + const row = manageCycleCountPage.toCountTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.status).toHaveText('In progress'); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Apply Negative quantity filter on To Count tab', async () => { + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + + await expect(manageCycleCountPage.toCountTable.rows).toHaveCount(1); + const row = manageCycleCountPage.toCountTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + }); + + await test.step('Select item and start count again', async () => { + const row = manageCycleCountPage.toCountTable.row(0); + await row.checkbox.check(); + await manageCycleCountPage.startCountButton.click(); + await countStepPage.isLoaded(); + }); + + await test.step('Assert previously entered quantities were not lost', async () => { + await expect(async () => { + await page.reload(); + await countStepPage.isLoaded(); + + const ccBin1Row = + countStepPage.countStepTable.rowByBinLocation('CC-BIN-1'); + await expect(ccBin1Row.quantityCountedInput).toHaveValue('0'); + + const ccBin2Row = + countStepPage.countStepTable.rowByBinLocation('CC-BIN-2'); + await expect(ccBin2Row.quantityCountedInput).toHaveValue('10'); + + const receivingBinRow = + countStepPage.countStepTable.rowByBinLocation(receivingBin); + await expect(receivingBinRow.quantityCountedInput).toHaveValue('0'); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Save count and choose Not now on the resolve discrepancies dialog', async () => { + await countStepPage.nextButton.click(); + await confirmToCountStepPage.isLoaded(); + await confirmToCountStepPage.saveButton.click(); + + await confirmToCountStepPage.resolveDiscrepancyDialog.isLoaded(); + await confirmToCountStepPage.resolveDiscrepancyDialog.notNowButton.click(); + }); + + await test.step('Assert product present on To Resolve tab with To resolve status', async () => { + await expect(manageCycleCountPage.toResolveTab).toHaveClass('active-tab'); + await expect(manageCycleCountPage.toResolveTable.rows).toHaveCount(1); + + const row = manageCycleCountPage.toResolveTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.status).toHaveText('To resolve'); + }); + + await test.step('Apply Negative quantity filter on To Resolve tab', async () => { + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + + await expect(manageCycleCountPage.toResolveTable.rows).toHaveCount(1); + const row = manageCycleCountPage.toResolveTable.row(0); + await expect(row.product).toContainText(productName); + await expect(row.quantity).not.toBeEmpty(); + }); + + await test.step('Select item and start resolution', async () => { + const row = manageCycleCountPage.toResolveTable.row(0); + await row.checkbox.check(); + await manageCycleCountPage.startResolutionButton.click(); + await recountStepPage.isLoaded(); + }); + + await test.step('Assert bin locations and quantity counted on recount step, then fill quantity recounted', async () => { + await expect(recountStepPage.recountStepTable.rows).toHaveCount(3); + + const ccBin1Row = + recountStepPage.recountStepTable.rowByBinLocation('CC-BIN-1'); + await expect(ccBin1Row.binLocation).toContainText('CC-BIN-1'); + await expect(ccBin1Row.quantityCounted).toHaveText('0'); + await expect(ccBin1Row.countDifferenceValue).toHaveText('20'); + await expect(ccBin1Row.countDifferenceValue).toHaveCSS( + 'color', + 'rgb(199, 22, 16)' + ); + await expect(ccBin1Row.countDifferenceIcon).toBeVisible(); + await ccBin1Row.fillQuantityRecounted('0'); + + const ccBin2Row = + recountStepPage.recountStepTable.rowByBinLocation('CC-BIN-2'); + await expect(ccBin2Row.binLocation).toContainText('CC-BIN-2'); + await expect(ccBin2Row.quantityCounted).toHaveText('10'); + await expect(ccBin2Row.countDifferenceValue).toHaveText('EQUAL'); + await expect(ccBin2Row.countDifferenceValue).toHaveCSS( + 'color', + 'rgb(0, 82, 204)' + ); + await expect(ccBin2Row.countDifferenceIcon).toBeHidden(); + await ccBin2Row.fillQuantityRecounted('10'); + + const receivingBinRow = + recountStepPage.recountStepTable.rowByBinLocation(receivingBin); + await expect(receivingBinRow.binLocation).toContainText(receivingBin); + await expect(receivingBinRow.quantityCounted).toHaveText('0'); + await expect(receivingBinRow.countDifferenceValue).toHaveText('20'); + await expect(receivingBinRow.countDifferenceValue).toHaveCSS( + 'color', + 'rgb(19, 173, 96)' + ); + await expect(receivingBinRow.countDifferenceIcon).toBeVisible(); + await receivingBinRow.fillQuantityRecounted('0'); + }); + + await test.step('Click Next, validate empty root cause message, click Next again', async () => { + await recountStepPage.nextButton.click(); + + await expect(recountStepPage.emptyRootCauseAlert).toBeVisible(); + await recountStepPage.closeAlert(); + await recountStepPage.nextButton.click(); + await confirmToRecountStepPage.isLoaded(); + }); + + await test.step('Save recount to finalize the cycle count', async () => { + await confirmToRecountStepPage.saveButton.click(); + await manageCycleCountPage.isLoaded(); + }); + + await test.step('Assert only one inventory item in stock on the stock card', async () => { + await productShowPage.goToPage(productId); + await productShowPage.inStockTab.click(); + await productShowPage.inStockTabSection.isLoaded(); + + await expect(productShowPage.inStockTabSection.rows).toHaveCount(3); + const row = productShowPage.inStockTabSection.row(1); + await expect(row.binLocation).toContainText('CC-BIN-2'); + await expect(row.quantityOnHand).toHaveText('10'); + }); + + await test.step('Assert product no longer shows first on All Products tab', async () => { + await expect(async () => { + await manageCycleCountPage.goToPage(); + await manageCycleCountPage.isLoaded(); + await manageCycleCountPage.openAllProductsTab(); + + const row = manageCycleCountPage.allProductsTable.row(0); + await expect(row.product).not.toContainText(productName); + }).toPass({ timeout: 30_000, intervals: [2000, 3000, 5000] }); + }); + + await test.step('Assert Negative quantity filter now returns an empty table', async () => { + await manageCycleCountPage.negativeQuantityCheckbox.check(); + await manageCycleCountPage.filterButton.click(); + + await expect(manageCycleCountPage.allProductsTable.rows).toHaveCount(0); + }); + }); +});