From 297d7bdc37661565b57fdbfb4a8bd1ad09f623e1 Mon Sep 17 00:00:00 2001 From: Alan Nadolny Date: Thu, 31 Jul 2025 18:10:24 +0200 Subject: [PATCH 1/5] OBPIH-6971 Add partial receipt status enum --- src/constants/PartialReceiptStatus.ts | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/constants/PartialReceiptStatus.ts diff --git a/src/constants/PartialReceiptStatus.ts b/src/constants/PartialReceiptStatus.ts new file mode 100644 index 0000000..9fb75ba --- /dev/null +++ b/src/constants/PartialReceiptStatus.ts @@ -0,0 +1,6 @@ +export enum PartialReceiptStatus { + PENDING = 'PENDING', + CHECKING = 'CHECKING', + COMPLETED = 'COMPLETED', + ROLLBACK = 'ROLLBACK', +} From 8dc43db1a467ce1a405a484184146a1507a809ce Mon Sep 17 00:00:00 2001 From: Alan Nadolny Date: Thu, 31 Jul 2025 18:10:34 +0200 Subject: [PATCH 2/5] OBPIH-6971 Add new types --- src/types.d.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/src/types.d.ts b/src/types.d.ts index 349e2d2..9bc5b82 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -226,6 +226,118 @@ type UpdateStockMovementPayload = { trackingNumber?: string; }; +type ReceiptResponse = { + receiptId: string | null; + receiptStatus: string; + shipmentId: string; + shipmentName: string; + shipmentNumber: string; + shipmentStatus: string; + originId: string; + originName: string; + destinationId: string; + destinationName: string; + dateShipped: string; + dateDelivered: string; + containers: Container[]; + requisition: unknown; + description: string; + recipient: Recipient; + isShipmentFromPurchaseOrder: boolean; +} + +type Container = { + containerId: string | null; + containerName: string | null; + parentContainerId: string | null; + parentContainerName: string | null; + containerType: string | null; + shipmentItems: ShipmentItem[]; +} + +type ContainerInfo = { + id: string; + name: string; + type: string; +}; + +type ParentContainerInfo = { + id: string; + name: string; +}; + +export type UnflattenContainer = { + container: ContainerInfo; + parentContainer: ParentContainerInfo; + shipmentItems: ShipmentItem[]; +}; + +type ShipmentItem = { + receiptItemId: string | null; + shipmentItemId: string; + containerId: string | null; + containerName: string | null; + parentContainerId: string | null; + parentContainerName: string | null; + productId: string; + productCode: string; + productName: string; + productDisplayNames: { + default: string | null; + fr?: string; + }; + productLotAndExpiryControl: unknown; + productHandlingIcons: string[]; + lotNumber: string | null; + expirationDate: string | null; + binLocationId: string; + binLocationName: string; + binLocationZoneId: string | null; + binLocationZoneName: string | null; + recipientId: string | null; + recipientName: string | null; + quantityShipped: number; + quantityReceived: number; + quantityCanceled: number; + quantityReceiving: number | null; + quantityRemaining: number; + cancelRemaining: boolean; + quantityOnHand: number; + comment: string | null; + unitOfMeasure: string; + packSize: number; + packsRequested: number; + original?: boolean, +} + +type Recipient = { + id: string; + name: string; + firstName: string; + lastName: string; + email: string; + username: string; + roles: string[]; +} + +type ReceivingItemPayload = { + shipmentItemId: string, + quantityReceiving?: number, + quantityShipped?: number, + comment?: string, + binLocationId?: string, + lotNumber?: string, + expirationDate?: string, + original?: boolean, + newLine?: boolean, + receiptItemId?: string | null, +} + +type ReceiptPayload = Omit & { + containers: UnflattenContainer[]; + recipient: string; +}; + type AppContextResponse = { location: LocationResponse; user: User; From b05380d0c6f1a46a1227039c9a6a21fd132567b0 Mon Sep 17 00:00:00 2001 From: Alan Nadolny Date: Thu, 31 Jul 2025 18:10:47 +0200 Subject: [PATCH 3/5] OBPIH-6971 Add receiving service --- src/api/ReceivingService.ts | 274 ++++++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 src/api/ReceivingService.ts diff --git a/src/api/ReceivingService.ts b/src/api/ReceivingService.ts new file mode 100644 index 0000000..57410ac --- /dev/null +++ b/src/api/ReceivingService.ts @@ -0,0 +1,274 @@ +import { APIRequestContext } from '@playwright/test'; +import _ from 'lodash'; + +import BaseServiceModel from '@/api/BaseServiceModel'; +import { PartialReceiptStatus } from '@/constants/PartialReceiptStatus'; +import { + ApiResponse, + Container, ReceiptPayload, + ReceiptResponse, + ReceivingItemPayload, + ShipmentItem, + UnflattenContainer, +} from '@/types'; +import { parseRequestToJSON, unflatten } from '@/utils/ServiceUtils'; + +class ReceivingService extends BaseServiceModel { + constructor(request: APIRequestContext) { + super(request); + } + + /* + As an argument takes the shipment id, returns the receipt object. + */ + async getReceipt(id: string): Promise> { + try { + const apiResponse = await this.request.get( + `./api/partialReceiving/${id}` + ); + return await parseRequestToJSON(apiResponse); + } catch (error) { + throw new Error('Problem fetching partial receipt'); + } + } + + /* + As an argument takes the shipment id, changes the receipt status + to completed. + */ + async completeReceipt(id: string): Promise { + try { + await this.changeReceiptStatus(id, PartialReceiptStatus.COMPLETED); + } catch (error) { + throw new Error('Problem completing partial receipt'); + } + } + + /* + As an argument takes the shipment id, rolls back the receipt (changes + the receipt status). + */ + async rollbackReceipt(id: string): Promise { + try { + await this.changeReceiptStatus(id, PartialReceiptStatus.ROLLBACK); + } catch (error) { + throw new Error('Problem rolling back partial receipt') + } + } + + /* + As arguments take the shipment id and items that need to be updated. + It updates the receiving now quantity, comment and bin location field. + Usage: + await receivingService.updateReceivingItems( + 'shipmentId', [ + { + shipmentItemId: 'firstShipmentItemId', + quantityReceiving: quantityReceiving, + comment: 'comment', + binLocation: 'binLocationId' + }, + { + shipmentItemId: 'secondShipmentItemId', + quantityReceiving: quantityReceiving, + comment: 'comment', + binLocation: 'binLocationId' + } + ]); + */ + async updateReceivingItems( + id: string, + items: ReceivingItemPayload[], + ): Promise { + try { + const receipt = await this.getReceipt(id); + const shipmentItemsToUpdate = this.extractShipmentItemIds(items); + const containers = this.buildUpdatedContainers(receipt.data.containers, items, shipmentItemsToUpdate); + const payload: ReceiptPayload = { + ...receipt.data, + containers: containers, + recipient: receipt?.data?.recipient?.id, + } + await this.request.post(`./api/partialReceiving/${id}`, { + data: payload, + }); + } catch (error) { + throw new Error('Problem updating items'); + } + } + + /* + As arguments take the shipment id, id of receipt item that needs to be split, + new lines are an array filled with new items, including the original one, that is split. + The sum of all quantities in that batch should be equal to the quantity of the original item. + This function does exactly the same that can be done using the split line modal. + For new line lot number, expiration date and quantity shipped can be passed. + Usage: + await receivingService.splitReceivingLine( + 'shipmentId', + 'id of receipt item that will be split', [ + { + shipmentItemId: 'shipment item id', + receiptItemId: 'receipt item id', <- this line is treated as an original + quantityShipped: new quantity shipped, + }, + { + shipmentItemId: 'shipment item id', + receiptItemId: null, <- receipt item id should be set to null for all new items (backend requirement) + quantityShipped: new quantity shipped, + lotNumber: 'new lot number', + expirationDate: 'new expiration date', + newLine: true, <- new line should be set to true for all new items (backend requirement) + }, + { + shipmentItemId: 'shipment item id', + receiptItemId: null, + quantityShipped: new quantity shipped, + lotNumber: 'new lot number', + expirationDate: 'new expiration date', + newLine: true, + } + ]); + */ + async splitReceivingLine( + id: string, + originalReceiptItemId: string, + newLines: ReceivingItemPayload[], + ) { + try { + const receipt = await this.getReceipt(id); + + const originalShipmentItem = this.findOriginalShipmentItem(receipt.data.containers, originalReceiptItemId); + if (!originalShipmentItem) { + throw new Error(`Original shipment item with ID ${originalReceiptItemId} not found`); + } + + this.validateQuantity(originalShipmentItem.quantityShipped, newLines); + + const containers = this.buildSplitContainers( + receipt.data.containers, + originalReceiptItemId, + newLines + ); + + const payload = this.buildPayload(receipt.data, containers); + + await this.saveSplitLines(id, payload); + } catch (error) { + throw new Error('Problem splitting lines'); + } + } + + private findOriginalShipmentItem(containers: Container[], receiptItemId: string): ShipmentItem | undefined { + return _.flatten(containers.map(c => c.shipmentItems)) + .find(item => item.receiptItemId === receiptItemId); + } + + private validateQuantity(originalQuantityShipped: number | undefined, newLines: ReceivingItemPayload[]) { + const sumOfQuantityShipped = _.sumBy(newLines, 'quantityShipped'); + const originalQty = originalQuantityShipped || 0; + + if (originalQty < sumOfQuantityShipped) { + throw new Error('Sum of quantity shipped is greater than the original quantity shipped'); + } + } + + private buildSplitContainers( + containers: Container[], + originalReceiptItemId: string, + newLines: ReceivingItemPayload[] + ): UnflattenContainer[] { + const splittedItem = newLines.find(line => line.receiptItemId === originalReceiptItemId); + const linesToSave = newLines.filter(line => !line.receiptItemId); + + return containers.map(container => { + const updatedShipmentItems = _.flatten( + container.shipmentItems.map((shipmentItem: ShipmentItem) => { + if (shipmentItem.receiptItemId === originalReceiptItemId) { + return [ + { ...shipmentItem, ...splittedItem }, + ...linesToSave + ]; + } + return shipmentItem; + }) + ); + + return unflatten({ ...container, shipmentItems: updatedShipmentItems }) as UnflattenContainer; + }); + } + + private buildPayload(receiptData: ReceiptResponse, containers: UnflattenContainer[]): ReceiptPayload { + return { + ...receiptData, + containers, + recipient: receiptData.recipient.id + }; + } + + private async saveSplitLines(id: string, payload: ReceiptPayload) { + await this.request.post(`./api/partialReceiving/${id}`, { + data: payload + }); + } + + + private async changeReceiptStatus( + id: string, + status: PartialReceiptStatus + ): Promise { + await this.request.post(`./api/partialReceiving/${id}`, { + data: { + id, + stepNumber: 2, + receiptStatus: status, + }, + }); + } + + private extractShipmentItemIds(items: ReceivingItemPayload[]): string[] { + return items.map((item) => item.shipmentItemId); + } + + private buildUpdatedContainers( + containers: Container[], + items: ReceivingItemPayload[], + shipmentItemsToUpdate: string[], + ): UnflattenContainer[] { + return containers.map((container) => { + return unflatten({ + ...container, + shipmentItems: container.shipmentItems.map((shipmentItem: ShipmentItem) => { + if (shipmentItemsToUpdate.includes(shipmentItem.shipmentItemId)) { + const updatedItem = this.findItemToUpdate(items, shipmentItem.shipmentItemId); + return this.mergeShipmentItem(shipmentItem, updatedItem); + } + + return shipmentItem; + }), + }) as UnflattenContainer; + }); + } + + private findItemToUpdate( + items: ReceivingItemPayload[], + shipmentItemId: string, + ): ReceivingItemPayload | undefined { + return items.find((item) => item.shipmentItemId === shipmentItemId); + } + + private mergeShipmentItem( + original: ShipmentItem, + update?: ReceivingItemPayload, + ): ShipmentItem { + return { + ...original, + binLocationId: update?.binLocationId ?? original.binLocationId, + quantityReceiving: update?.quantityReceiving ?? original.quantityReceiving, + comment: update?.comment ?? original.comment, + }; + } + +} + +export default ReceivingService; From 2e4ff3b62deecd7b4bbe1d034f5f2f391eb8e5a5 Mon Sep 17 00:00:00 2001 From: Alan Nadolny Date: Thu, 31 Jul 2025 18:10:56 +0200 Subject: [PATCH 4/5] OBPIH-6971 Add unflatten util --- src/utils/ServiceUtils.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/utils/ServiceUtils.ts b/src/utils/ServiceUtils.ts index e3ea58e..b626eb1 100644 --- a/src/utils/ServiceUtils.ts +++ b/src/utils/ServiceUtils.ts @@ -1,4 +1,5 @@ import { APIResponse } from '@playwright/test'; +import _ from 'lodash' export async function parseRequestToJSON(response: APIResponse) { try { @@ -7,3 +8,13 @@ export async function parseRequestToJSON(response: APIResponse) { throw new Error('Problem parsing JSON'); } } + + +export function unflatten(obj: object) { + const result = {}; + _.forOwn(obj, (value, key) => { + _.set(result, key, value); + }); + + return result; +} From 15010b4718ad698b297cfc79727cbd0c1eba1a02 Mon Sep 17 00:00:00 2001 From: Alan Nadolny Date: Thu, 31 Jul 2025 18:11:07 +0200 Subject: [PATCH 5/5] OBPIH-6971 Add receiving service to fixtures --- src/fixtures/fixtures.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/fixtures/fixtures.ts b/src/fixtures/fixtures.ts index 131516d..164f4d7 100644 --- a/src/fixtures/fixtures.ts +++ b/src/fixtures/fixtures.ts @@ -3,6 +3,7 @@ import { BrowserContext, test as baseTest } from '@playwright/test'; import AuthService from '@/api/AuthService'; import GenericService from '@/api/GenericService'; import LocationService from '@/api/LocationService'; +import ReceivingService from '@/api/ReceivingService'; import StockMovementService from '@/api/StockMovementService'; import ImpersonateBanner from '@/components/ImpersonateBanner'; import LocationChooser from '@/components/LocationChooser'; @@ -71,6 +72,7 @@ type Fixtures = { locationService: LocationService; authService: AuthService; stockMovementService: StockMovementService; + receivingService: ReceivingService; // LOCATIONS DATA mainLocationService: LocationData; noManageInventoryDepotService: LocationData; @@ -139,6 +141,8 @@ export const test = baseTest.extend({ authService: async ({ page }, use) => use(new AuthService(page.request)), stockMovementService: async ({ page }, use) => use(new StockMovementService(page.request)), + receivingService: async ({ page }, use) => + use(new ReceivingService(page.request)), // LOCATIONS mainLocationService: async ({ page }, use) => use(new LocationData(LOCATION_KEY.MAIN, page.request)),