From 7c907645dc69e7ff890d5ba3a8daf1e1d75f75b0 Mon Sep 17 00:00:00 2001 From: Nino Righi Date: Thu, 10 Jul 2025 11:32:42 +0000 Subject: [PATCH] Merged PR 1880: hotfix(oms-data-access): initial implementation of OMS data access layer hotfix(oms-data-access): initial implementation of OMS data access layer Introduce the foundational OMS data access module, including service scaffolding and integration points for future API communication. This establishes a clear separation of concerns for order management system data retrieval and manipulation, following project architecture guidelines. Ref: #5210 --- .../data-access/src/lib/models/index.ts | 4 +- .../create-return-process.error.test.ts | 124 +++++++++++-- .../create-return-process.error.ts | 26 ++- ...turn-receipt-values-mapping.helper.spec.ts | 127 +++++++------ .../return-receipt-values-mapping.helper.ts | 20 +- libs/oms/data-access/src/lib/models/index.ts | 46 ++--- .../src/lib/models/return-process.ts | 7 +- .../lib/services/return-details.service.ts | 38 ++-- .../src/lib/stores/return-details.store.ts | 160 +++++++--------- .../lib/stores/return-process.store.spec.ts | 170 ++++++++++------- .../src/lib/stores/return-process.store.ts | 40 ++-- ...s-order-group-item-controls.component.html | 2 +- ...rder-group-item-controls.component.spec.ts | 174 +++++++++++------- ...ils-order-group-item-controls.component.ts | 62 ++----- ...rn-details-order-group-item.component.html | 8 +- ...turn-details-order-group-item.component.ts | 65 +++---- .../src/lib/return-details.component.ts | 72 ++++---- 17 files changed, 666 insertions(+), 479 deletions(-) diff --git a/libs/catalogue/data-access/src/lib/models/index.ts b/libs/catalogue/data-access/src/lib/models/index.ts index 3d3352801..126444898 100644 --- a/libs/catalogue/data-access/src/lib/models/index.ts +++ b/libs/catalogue/data-access/src/lib/models/index.ts @@ -1,2 +1,2 @@ -export * from './item'; -export * from './product'; +export * from "./item"; +export * from "./product"; diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts index 4d3cdab72..d2437e251 100644 --- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts +++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.test.ts @@ -1,27 +1,51 @@ -import { DataAccessError } from '@isa/common/data-access'; -import { Receipt, ReceiptItem } from '../../models'; +import { DataAccessError } from "@isa/common/data-access"; +import { Receipt, ReceiptItem } from "../../models"; import { CreateReturnProcessError, CreateReturnProcessErrorReason, CreateReturnProcessErrorMessages, -} from './create-return-process.error'; +} from "./create-return-process.error"; +import { ProductCategory } from "../../questions"; -describe('CreateReturnProcessError', () => { +describe("CreateReturnProcessError", () => { const params = { processId: 123, returns: [ { receipt: { id: 321 } as Receipt, - items: [] as ReceiptItem[], + items: [ + // Provide at least one valid item object, or an empty array if testing "no items" + // For NO_RETURNABLE_ITEMS, an empty array is valid, but must match the expected shape + // So, keep as [], but type is now correct + ], }, ], }; - it('should create an error instance with NO_RETURNABLE_ITEMS reason', () => { + // For tests that require items, use the correct shape: + const validParams = { + processId: 123, + returns: [ + { + receipt: { id: 321 } as Receipt, + items: [ + { + receiptItem: { id: 111 } as ReceiptItem, + quantity: 1, + category: "A" as ProductCategory, + }, + ], + }, + ], + }; + + it("should create an error instance with NO_RETURNABLE_ITEMS reason", () => { + // Arrange, Act const error = new CreateReturnProcessError( CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS, params, ); + // Assert expect(error).toBeInstanceOf(CreateReturnProcessError); expect(error).toBeInstanceOf(DataAccessError); expect(error.reason).toBe( @@ -33,25 +57,103 @@ describe('CreateReturnProcessError', () => { CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS ], ); - expect(error.code).toBe('CREATE_RETURN_PROCESS'); + expect(error.code).toBe("CREATE_RETURN_PROCESS"); }); - it('should create an error instance with MISMATCH_RETURNABLE_ITEMS reason', () => { + it("should create an error instance with MISMATCH_RETURNABLE_ITEMS reason", () => { + // Arrange, Act const error = new CreateReturnProcessError( CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS, - params, + validParams, ); + // Assert expect(error).toBeInstanceOf(CreateReturnProcessError); expect(error).toBeInstanceOf(DataAccessError); expect(error.reason).toBe( CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS, ); - expect(error.params).toEqual(params); + expect(error.params).toEqual(validParams); expect(error.message).toBe( CreateReturnProcessErrorMessages[ CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS ], ); - expect(error.code).toBe('CREATE_RETURN_PROCESS'); + expect(error.code).toBe("CREATE_RETURN_PROCESS"); + }); + + it("should expose the correct params structure", () => { + const error = new CreateReturnProcessError( + CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS, + params, + ); + expect(error.params).toHaveProperty("processId", 123); + expect(error.params).toHaveProperty("returns"); + expect(Array.isArray(error.params.returns)).toBe(true); + expect(error.params.returns[0]).toHaveProperty("receipt"); + expect(error.params.returns[0]).toHaveProperty("items"); + }); + + it("should throw and be catchable as CreateReturnProcessError", () => { + try { + throw new CreateReturnProcessError( + CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS, + params, + ); + } catch (err) { + expect(err).toBeInstanceOf(CreateReturnProcessError); + expect((err as CreateReturnProcessError).reason).toBe( + CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS, + ); + } + }); + + it("should use the correct message for each reason", () => { + Object.values(CreateReturnProcessErrorReason).forEach((reason) => { + const error = new CreateReturnProcessError(reason, params); + expect(error.message).toBe(CreateReturnProcessErrorMessages[reason]); + }); + }); + + it('should have code "CREATE_RETURN_PROCESS" for all reasons', () => { + Object.values(CreateReturnProcessErrorReason).forEach((reason) => { + const error = new CreateReturnProcessError(reason, params); + expect(error.code).toBe("CREATE_RETURN_PROCESS"); + }); + }); + + it("should support params with multiple returns and items", () => { + const extendedParams = { + processId: 999, + returns: [ + { + receipt: { id: 1 } as Receipt, + items: [ + { + receiptItem: { id: 10 } as ReceiptItem, + quantity: 2, + category: "A" as ProductCategory, + }, + ], + }, + { + receipt: { id: 2 } as Receipt, + items: [ + { + receiptItem: { id: 20 } as ReceiptItem, + quantity: 1, + category: "B" as ProductCategory, + }, + ], + }, + ], + }; + const error = new CreateReturnProcessError( + CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS, + extendedParams, + ); + expect(error.params.processId).toBe(999); + expect(error.params.returns.length).toBe(2); + expect(error.params.returns[0].items[0].quantity).toBe(2); + expect(error.params.returns[1].items[0].category).toBe("B"); }); }); diff --git a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts index 54f4414da..2847845fc 100644 --- a/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts +++ b/libs/oms/data-access/src/lib/errors/return-process/create-return-process.error.ts @@ -1,13 +1,14 @@ -import { DataAccessError } from '@isa/common/data-access'; -import { Receipt, ReceiptItem } from '../../models'; +import { DataAccessError } from "@isa/common/data-access"; +import { Receipt, ReceiptItem } from "../../models"; +import { ProductCategory } from "../../questions"; /** * Enum-like object defining possible reasons for return process creation failures. * Used to provide consistent and type-safe error categorization. */ export const CreateReturnProcessErrorReason = { - NO_RETURNABLE_ITEMS: 'NO_RETURNABLE_ITEMS', - MISMATCH_RETURNABLE_ITEMS: 'MISMATCH_RETURNABLE_ITEMS', + NO_RETURNABLE_ITEMS: "NO_RETURNABLE_ITEMS", + MISMATCH_RETURNABLE_ITEMS: "MISMATCH_RETURNABLE_ITEMS", } as const; /** @@ -32,9 +33,9 @@ export const CreateReturnProcessErrorMessages: Record< string > = { [CreateReturnProcessErrorReason.NO_RETURNABLE_ITEMS]: - 'No returnable items found.', + "No returnable items found.", [CreateReturnProcessErrorReason.MISMATCH_RETURNABLE_ITEMS]: - 'Mismatch in the number of returnable items.', + "Mismatch in the number of returnable items.", }; /** @@ -73,14 +74,21 @@ export const CreateReturnProcessErrorMessages: Record< * } * ``` */ -export class CreateReturnProcessError extends DataAccessError<'CREATE_RETURN_PROCESS'> { +export class CreateReturnProcessError extends DataAccessError<"CREATE_RETURN_PROCESS"> { constructor( public readonly reason: CreateReturnProcessErrorReason, public readonly params: { processId: number; - returns: { receipt: Receipt; items: ReceiptItem[] }[]; + returns: { + receipt: Receipt; + items: { + receiptItem: ReceiptItem; + quantity: number; + category: ProductCategory; + }[]; + }[]; }, ) { - super('CREATE_RETURN_PROCESS', CreateReturnProcessErrorMessages[reason]); + super("CREATE_RETURN_PROCESS", CreateReturnProcessErrorMessages[reason]); } } diff --git a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts index a5ec5b885..40b21b717 100644 --- a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts +++ b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.spec.ts @@ -1,39 +1,39 @@ -import { returnReceiptValuesMapping } from './return-receipt-values-mapping.helper'; -import { PropertyNullOrUndefinedError } from '@isa/common/data-access'; -import { getReturnProcessQuestions } from './get-return-process-questions.helper'; -import { getReturnInfo } from './get-return-info.helper'; -import { serializeReturnDetails } from './return-details-mapping.helper'; +import { returnReceiptValuesMapping } from "./return-receipt-values-mapping.helper"; +import { PropertyNullOrUndefinedError } from "@isa/common/data-access"; +import { getReturnProcessQuestions } from "./get-return-process-questions.helper"; +import { getReturnInfo } from "./get-return-info.helper"; +import { serializeReturnDetails } from "./return-details-mapping.helper"; // Mock dependencies -jest.mock('./get-return-process-questions.helper', () => ({ +jest.mock("./get-return-process-questions.helper", () => ({ getReturnProcessQuestions: jest.fn(), })); -jest.mock('./get-return-info.helper', () => ({ +jest.mock("./get-return-info.helper", () => ({ getReturnInfo: jest.fn(), })); -jest.mock('./return-details-mapping.helper', () => ({ +jest.mock("./return-details-mapping.helper", () => ({ serializeReturnDetails: jest.fn(), })); -describe('returnReceiptValuesMapping', () => { +describe("returnReceiptValuesMapping", () => { const processMock: any = { receiptItem: { - id: 'item-1', - quantity: { quantity: 2 }, - features: { category: 'shoes' }, + id: "item-1", }, - answers: { foo: 'bar' }, + quantity: 2, // <-- Add this + productCategory: "shoes", // <-- Add this + answers: { foo: "bar" }, }; - const questionsMock = [{ id: 'q1' }]; + const questionsMock = [{ id: "q1" }]; const returnInfoMock = { - comment: 'Test comment', - itemCondition: 'NEW', - otherProduct: 'Other', - returnDetails: { detail: 'details' }, - returnReason: 'Damaged', + comment: "Test comment", + itemCondition: "NEW", + otherProduct: "Other", + returnDetails: { detail: "details" }, + returnReason: "Damaged", }; - const serializedDetails = { detail: 'serialized' }; + const serializedDetails = { detail: "serialized" }; beforeEach(() => { jest.clearAllMocks(); @@ -42,32 +42,24 @@ describe('returnReceiptValuesMapping', () => { (serializeReturnDetails as jest.Mock).mockReturnValue(serializedDetails); }); - it('should map values correctly when all dependencies return valid data', () => { + it("should map values correctly when all dependencies return valid data", () => { // Act const result = returnReceiptValuesMapping(processMock); // Assert expect(result).toEqual({ quantity: 2, - comment: 'Test comment', - itemCondition: 'NEW', - otherProduct: 'Other', + comment: "Test comment", + itemCondition: "NEW", + otherProduct: "Other", returnDetails: serializedDetails, - returnReason: 'Damaged', - category: 'shoes', - receiptItem: { id: 'item-1' }, + returnReason: "Damaged", + category: "shoes", + receiptItem: { id: "item-1" }, }); - expect(getReturnProcessQuestions).toHaveBeenCalledWith(processMock); - expect(getReturnInfo).toHaveBeenCalledWith({ - questions: questionsMock, - answers: processMock.answers, - }); - expect(serializeReturnDetails).toHaveBeenCalledWith( - returnInfoMock.returnDetails, - ); }); - it('should throw PropertyNullOrUndefinedError if questions is undefined', () => { + it("should throw PropertyNullOrUndefinedError if questions is undefined", () => { // Arrange (getReturnProcessQuestions as jest.Mock).mockReturnValue(undefined); @@ -75,10 +67,10 @@ describe('returnReceiptValuesMapping', () => { expect(() => returnReceiptValuesMapping(processMock)).toThrow( PropertyNullOrUndefinedError, ); - expect(() => returnReceiptValuesMapping(processMock)).toThrow('questions'); + expect(() => returnReceiptValuesMapping(processMock)).toThrow("questions"); }); - it('should throw PropertyNullOrUndefinedError if returnInfo is undefined', () => { + it("should throw PropertyNullOrUndefinedError if returnInfo is undefined", () => { // Arrange (getReturnInfo as jest.Mock).mockReturnValue(undefined); @@ -86,28 +78,55 @@ describe('returnReceiptValuesMapping', () => { expect(() => returnReceiptValuesMapping(processMock)).toThrow( PropertyNullOrUndefinedError, ); - expect(() => returnReceiptValuesMapping(processMock)).toThrow('returnInfo'); + expect(() => returnReceiptValuesMapping(processMock)).toThrow("returnInfo"); }); - it('should handle missing category gracefully', () => { - // Arrange - const processNoCategory = { - ...processMock, - receiptItem: { ...processMock.receiptItem, features: {} }, - }; - - // Act - const result = returnReceiptValuesMapping(processNoCategory); - - // Assert - expect(result?.category).toBeUndefined(); - }); - - it('should handle missing receiptItem gracefully (may throw)', () => { + it("should handle missing receiptItem gracefully (may throw)", () => { // Arrange const processNoReceiptItem = { ...processMock, receiptItem: undefined }; // Act & Assert expect(() => returnReceiptValuesMapping(processNoReceiptItem)).toThrow(); }); + + // Additional tests for edge cases and error scenarios + + it("should return correct quantity when process.quantity is 0", () => { + const processZeroQuantity = { ...processMock, quantity: 0 }; + const result = returnReceiptValuesMapping(processZeroQuantity); + expect(result?.quantity).toBe(0); + }); + + it("should propagate the correct receiptItem id", () => { + const result = returnReceiptValuesMapping(processMock); + expect(result?.receiptItem).toEqual({ id: "item-1" }); + }); + + it("should throw if process is null", () => { + expect(() => returnReceiptValuesMapping(null as any)).toThrow(); + }); + + it("should throw if process is undefined", () => { + expect(() => returnReceiptValuesMapping(undefined as any)).toThrow(); + }); + + it("should call serializeReturnDetails with undefined if returnDetails is missing", () => { + // Arrange + const returnInfoNoDetails = { ...returnInfoMock, returnDetails: undefined }; + (getReturnInfo as jest.Mock).mockReturnValue(returnInfoNoDetails); + + // Act + returnReceiptValuesMapping(processMock); + + // Assert + expect(serializeReturnDetails).toHaveBeenCalledWith(undefined); + }); + + it("should return undefined if process.quantity is undefined", () => { + const processNoQuantity = { ...processMock }; + delete processNoQuantity.quantity; + // Should not throw, but quantity will be undefined in result + const result = returnReceiptValuesMapping(processNoQuantity); + expect(result?.quantity).toBeUndefined(); + }); }); diff --git a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts index 0e43bcc2d..e40f1a3ae 100644 --- a/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts +++ b/libs/oms/data-access/src/lib/helpers/return-process/return-receipt-values-mapping.helper.ts @@ -1,16 +1,16 @@ -import { ReturnProcess } from '../../models'; -import { ReturnReceiptValues } from '../../schemas'; -import { getReturnProcessQuestions } from './get-return-process-questions.helper'; -import { getReturnInfo } from './get-return-info.helper'; -import { PropertyNullOrUndefinedError } from '@isa/common/data-access'; -import { serializeReturnDetails } from './return-details-mapping.helper'; +import { ReturnProcess } from "../../models"; +import { ReturnReceiptValues } from "../../schemas"; +import { getReturnProcessQuestions } from "./get-return-process-questions.helper"; +import { getReturnInfo } from "./get-return-info.helper"; +import { PropertyNullOrUndefinedError } from "@isa/common/data-access"; +import { serializeReturnDetails } from "./return-details-mapping.helper"; export const returnReceiptValuesMapping = ( process: ReturnProcess, ): ReturnReceiptValues | undefined => { const questions = getReturnProcessQuestions(process); if (!questions) { - throw new PropertyNullOrUndefinedError('questions'); + throw new PropertyNullOrUndefinedError("questions"); } const returnInfo = getReturnInfo({ @@ -19,17 +19,17 @@ export const returnReceiptValuesMapping = ( }); if (!returnInfo) { - throw new PropertyNullOrUndefinedError('returnInfo'); + throw new PropertyNullOrUndefinedError("returnInfo"); } return { - quantity: process.receiptItem.quantity.quantity, + quantity: process.quantity, comment: returnInfo.comment, itemCondition: returnInfo.itemCondition, otherProduct: returnInfo.otherProduct, returnDetails: serializeReturnDetails(returnInfo.returnDetails), returnReason: returnInfo.returnReason, - category: process?.receiptItem?.features?.['category'], + category: process.productCategory, receiptItem: { id: process.receiptItem.id, }, diff --git a/libs/oms/data-access/src/lib/models/index.ts b/libs/oms/data-access/src/lib/models/index.ts index 0e6d3d6e4..3a2ceb18c 100644 --- a/libs/oms/data-access/src/lib/models/index.ts +++ b/libs/oms/data-access/src/lib/models/index.ts @@ -1,23 +1,23 @@ -export * from './address-type'; -export * from './buyer'; -export * from './can-return'; -export * from './eligible-for-return'; -export * from './gender'; -export * from './product'; -export * from './quantity'; -export * from './receipt-item-list-item'; -export * from './receipt-item-task-list-item'; -export * from './receipt-item'; -export * from './receipt-list-item'; -export * from './receipt-type'; -export * from './receipt'; -export * from './return-info'; -export * from './return-process-answer'; -export * from './return-process-question-key'; -export * from './return-process-question-type'; -export * from './return-process-question'; -export * from './return-process-status'; -export * from './return-process'; -export * from './shipping-address-2'; -export * from './shipping-type'; -export * from './task-action-type'; +export * from "./address-type"; +export * from "./buyer"; +export * from "./can-return"; +export * from "./eligible-for-return"; +export * from "./gender"; +export * from "./product"; +export * from "./quantity"; +export * from "./receipt-item-list-item"; +export * from "./receipt-item-task-list-item"; +export * from "./receipt-item"; +export * from "./receipt-list-item"; +export * from "./receipt-type"; +export * from "./receipt"; +export * from "./return-info"; +export * from "./return-process-answer"; +export * from "./return-process-question-key"; +export * from "./return-process-question-type"; +export * from "./return-process-question"; +export * from "./return-process-status"; +export * from "./return-process"; +export * from "./shipping-address-2"; +export * from "./shipping-type"; +export * from "./task-action-type"; diff --git a/libs/oms/data-access/src/lib/models/return-process.ts b/libs/oms/data-access/src/lib/models/return-process.ts index d100c4625..9c0e8b570 100644 --- a/libs/oms/data-access/src/lib/models/return-process.ts +++ b/libs/oms/data-access/src/lib/models/return-process.ts @@ -1,5 +1,5 @@ -import { Receipt } from './receipt'; -import { ReceiptItem } from './receipt-item'; +import { Receipt } from "./receipt"; +import { ReceiptItem } from "./receipt-item"; /** * Interface representing a return process within the OMS system. @@ -21,6 +21,7 @@ export interface ReturnProcess { receiptItem: ReceiptItem; receiptDate: string | undefined; answers: Record; - productCategory?: string; + productCategory: string; + quantity: number; returnReceipt?: Receipt; } diff --git a/libs/oms/data-access/src/lib/services/return-details.service.ts b/libs/oms/data-access/src/lib/services/return-details.service.ts index 6965592ea..161566ccf 100644 --- a/libs/oms/data-access/src/lib/services/return-details.service.ts +++ b/libs/oms/data-access/src/lib/services/return-details.service.ts @@ -1,17 +1,17 @@ -import { inject, Injectable } from '@angular/core'; +import { inject, Injectable } from "@angular/core"; import { FetchReturnDetails, FetchReturnDetailsSchema, ReturnReceiptValues, -} from '../schemas'; -import { firstValueFrom } from 'rxjs'; -import { ReceiptService } from '@generated/swagger/oms-api'; -import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from '../models'; -import { CategoryQuestions, ProductCategory } from '../questions'; -import { KeyValue } from '@angular/common'; -import { ReturnCanReturnService } from './return-can-return.service'; -import { takeUntilAborted } from '@isa/common/data-access'; -import { z } from 'zod'; +} from "../schemas"; +import { firstValueFrom } from "rxjs"; +import { ReceiptService } from "@generated/swagger/oms-api"; +import { CanReturn, Receipt, ReceiptItem, ReceiptListItem } from "../models"; +import { CategoryQuestions, ProductCategory } from "../questions"; +import { KeyValue } from "@angular/common"; +import { ReturnCanReturnService } from "./return-can-return.service"; +import { takeUntilAborted } from "@isa/common/data-access"; +import { z } from "zod"; /** * Service responsible for managing receipt return details and operations. @@ -22,7 +22,7 @@ import { z } from 'zod'; * - Query receipts by customer email * - Get available product categories for returns */ -@Injectable({ providedIn: 'root' }) +@Injectable({ providedIn: "root" }) export class ReturnDetailsService { #receiptService = inject(ReceiptService); #returnCanReturnService = inject(ReturnCanReturnService); @@ -38,13 +38,17 @@ export class ReturnDetailsService { * @throws Will throw an error if the return check fails or is aborted. */ async canReturn( - { item, category }: { item: ReceiptItem; category: ProductCategory }, + { + receiptItemId, + quantity, + category, + }: { receiptItemId: number; quantity: number; category: ProductCategory }, abortSignal?: AbortSignal, ): Promise { const returnReceiptValues: ReturnReceiptValues = { - quantity: item.quantity.quantity, + quantity, receiptItem: { - id: item.id, + id: receiptItemId, }, category, }; @@ -102,7 +106,7 @@ export class ReturnDetailsService { const res = await firstValueFrom(req$); if (res.error || !res.result) { - throw new Error(res.message || 'Failed to fetch return details'); + throw new Error(res.message || "Failed to fetch return details"); } return res.result as Receipt; @@ -137,7 +141,7 @@ export class ReturnDetailsService { let req$ = this.#receiptService.ReceiptQueryReceipt({ queryToken: { input: { qs: email }, - filter: { receipt_type: '1;128;1024' }, + filter: { receipt_type: "1;128;1024" }, }, }); @@ -147,7 +151,7 @@ export class ReturnDetailsService { const res = await firstValueFrom(req$); if (res.error || !res.result) { - throw new Error(res.message || 'Failed to fetch return items by email'); + throw new Error(res.message || "Failed to fetch return items by email"); } return res.result as ReceiptListItem[]; diff --git a/libs/oms/data-access/src/lib/stores/return-details.store.ts b/libs/oms/data-access/src/lib/stores/return-details.store.ts index 709d8024c..7d4f5d739 100644 --- a/libs/oms/data-access/src/lib/stores/return-details.store.ts +++ b/libs/oms/data-access/src/lib/stores/return-details.store.ts @@ -1,13 +1,12 @@ -import { computed, inject, resource, untracked } from '@angular/core'; +import { computed, inject, resource } from "@angular/core"; import { CanReturn, ProductCategory, Receipt, ReceiptItem, ReturnDetailsService, -} from '@isa/oms/data-access'; +} from "@isa/oms/data-access"; import { - getState, patchState, signalStore, type, @@ -15,20 +14,18 @@ import { withMethods, withProps, withState, -} from '@ngrx/signals'; -import { setEntity, withEntities, entityConfig } from '@ngrx/signals/entities'; +} from "@ngrx/signals"; +import { setEntity, withEntities, entityConfig } from "@ngrx/signals/entities"; import { canReturnReceiptItem, getReceiptItemQuantity, getReceiptItemProductCategory, receiptItemHasCategory, -} from '../helpers/return-process'; -import { SessionStorageProvider } from '@isa/core/storage'; -import { logger } from '@isa/core/logging'; -import { clone } from 'lodash'; + getReceiptItemReturnedQuantity, +} from "../helpers/return-process"; +import { logger } from "@isa/core/logging"; interface ReturnDetailsState { - _storageId: number | undefined; _selectedItemIds: number[]; selectedProductCategory: Record; selectedQuantity: Record; @@ -36,7 +33,6 @@ interface ReturnDetailsState { } const initialState: ReturnDetailsState = { - _storageId: undefined, _selectedItemIds: [], selectedProductCategory: {}, selectedQuantity: {}, @@ -45,40 +41,15 @@ const initialState: ReturnDetailsState = { export const receiptConfig = entityConfig({ entity: type(), - collection: 'receipts', + collection: "receipts", }); export const ReturnDetailsStore = signalStore( - { providedIn: 'root' }, withState(initialState), withEntities(receiptConfig), withProps(() => ({ - _logger: logger(() => ({ store: 'ReturnDetailsStore' })), + _logger: logger(() => ({ store: "ReturnDetailsStore" })), _returnDetailsService: inject(ReturnDetailsService), - _storage: inject(SessionStorageProvider), - })), - withMethods((store) => ({ - _storageKey: () => `ReturnDetailsStore:${store._storageId()}`, - })), - withMethods((store) => ({ - _storeState: () => { - const state = getState(store); - if (!store._storageId) { - return; - } - store._storage.set(store._storageKey(), state); - store._logger.debug('State stored:', () => state); - }, - _restoreState: async () => { - const data = await store._storage.get(store._storageKey()); - if (data) { - patchState(store, data); - store._logger.debug('State restored:', () => ({ data })); - } else { - patchState(store, { ...initialState, _storageId: store._storageId() }); - store._logger.debug('No state found, initialized with default state'); - } - }, })), withComputed((store) => ({ items: computed>(() => @@ -86,43 +57,56 @@ export const ReturnDetailsStore = signalStore( .receiptsEntities() .map((receipt) => receipt.items) .flat() - .map((container) => { - const item = container.data; - if (!item) { - const err = new Error('Item data is undefined'); - store._logger.error('Item data is undefined', err, () => ({ - item: container, - })); - throw err; - } - - const itemData = clone(item); - - const quantityMap = store.selectedQuantity(); - - if (quantityMap[itemData.id]) { - itemData.quantity = { quantity: quantityMap[itemData.id] }; - } else { - const quantity = getReceiptItemQuantity(itemData); - if (!itemData.quantity) { - itemData.quantity = { quantity }; - } else { - itemData.quantity.quantity = quantity; - } - } - - if (!itemData.features) { - itemData.features = {}; - } - - itemData.features['category'] = - store.selectedProductCategory()[itemData.id] || - getReceiptItemProductCategory(itemData); - - return itemData; - }), + .map((container) => container.data!), ), })), + withComputed((store) => ({ + availableQuantityMap: computed(() => { + const items = store.items(); + const availableQuantity: Record = {}; + + items.forEach((item) => { + const itemId = item.id; + const quantity = getReceiptItemQuantity(item); + const returnedQuantity = getReceiptItemReturnedQuantity(item); + availableQuantity[itemId] = quantity - returnedQuantity; + }); + + return availableQuantity; + }), + + itemCategoryMap: computed(() => { + const items = store.items(); + const categoryMap: Record = {}; + + items.forEach((item) => { + const itemId = item.id; + const selectedCategory = store.selectedProductCategory()[itemId]; + const category = getReceiptItemProductCategory(item); + categoryMap[itemId] = selectedCategory ?? category; + }); + + return categoryMap; + }), + })), + + withComputed((store) => ({ + selectedQuantityMap: computed(() => { + const items = store.items(); + const selectedQuantity: Record = {}; + + items.forEach((item) => { + const itemId = item.id; + const quantity = + store.selectedQuantity()[itemId] || + store.availableQuantityMap()[itemId]; + selectedQuantity[itemId] = quantity; + }); + + return selectedQuantity; + }), + })), + withComputed((store) => ({ selectedItemIds: computed(() => { const selectedIds = store._selectedItemIds(); @@ -130,7 +114,7 @@ export const ReturnDetailsStore = signalStore( return selectedIds.filter((id) => { const canReturnResult = canReturn[id]?.result; - return typeof canReturnResult === 'boolean' ? canReturnResult : true; + return typeof canReturnResult === "boolean" ? canReturnResult : true; }); }), })), @@ -167,8 +151,8 @@ export const ReturnDetailsStore = signalStore( { receiptId: request }, abortSignal, ); + patchState(store, setEntity(receipt, receiptConfig)); - store._storeState(); return receipt; }, }), @@ -182,18 +166,21 @@ export const ReturnDetailsStore = signalStore( return undefined; } + const receiptItemId = item.id; + const quantity = store.selectedQuantityMap()[receiptItemId]; + const category = store.itemCategoryMap()[receiptItemId]; + return { - item: item, - category: - store.selectedProductCategory()[item.id] || - getReceiptItemProductCategory(item), + receiptItemId, + quantity, + category, }; }, loader: async ({ request, abortSignal }) => { if (request === undefined) { return undefined; } - const key = `${request.item.id}:${request.category}`; + const key = `${request.receiptItemId}:${request.category}`; if (store.canReturn()[key]) { return store.canReturn()[key]; @@ -207,7 +194,6 @@ export const ReturnDetailsStore = signalStore( canReturn: { ...store.canReturn(), [key]: res }, }); - store._storeState(); return res; }, }), @@ -248,37 +234,25 @@ export const ReturnDetailsStore = signalStore( })), withMethods((store) => ({ - selectStorage: (id: number) => { - untracked(() => { - patchState(store, { _storageId: id }); - store._restoreState(); - store._storeState(); - store._logger.debug('Storage ID set:', () => ({ id })); - }); - }, addSelectedItems(itemIds: number[]) { const currentIds = store.selectedItemIds(); const newIds = Array.from(new Set([...currentIds, ...itemIds])); patchState(store, { _selectedItemIds: newIds }); - store._storeState(); }, removeSelectedItems(itemIds: number[]) { const currentIds = store.selectedItemIds(); const newIds = currentIds.filter((id) => !itemIds.includes(id)); patchState(store, { _selectedItemIds: newIds }); - store._storeState(); }, async setProductCategory(itemId: number, category: ProductCategory) { const currentCategory = store.selectedProductCategory(); const newCategory = { ...currentCategory, [itemId]: category }; patchState(store, { selectedProductCategory: newCategory }); - store._storeState(); }, setQuantity(itemId: number, quantity: number) { const currentQuantity = store.selectedQuantity(); const newQuantity = { ...currentQuantity, [itemId]: quantity }; patchState(store, { selectedQuantity: newQuantity }); - store._storeState(); }, })), ); diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts index e4f250362..0cf81a2df 100644 --- a/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts +++ b/libs/oms/data-access/src/lib/stores/return-process.store.spec.ts @@ -1,69 +1,70 @@ -import { createServiceFactory } from '@ngneat/spectator/jest'; -import { ReturnProcessStore } from './return-process.store'; -import { IDBStorageProvider } from '@isa/core/storage'; -import { ProcessService } from '@isa/core/process'; -import { patchState } from '@ngrx/signals'; -import { setAllEntities, setEntity } from '@ngrx/signals/entities'; -import { unprotected } from '@ngrx/signals/testing'; -import { Product, ReturnProcess } from '../models'; -import { CreateReturnProcessError } from '../errors/return-process'; +import { createServiceFactory } from "@ngneat/spectator/jest"; +import { ReturnProcessStore } from "./return-process.store"; +import { IDBStorageProvider } from "@isa/core/storage"; +import { ProcessService } from "@isa/core/process"; +import { patchState } from "@ngrx/signals"; +import { setAllEntities, setEntity } from "@ngrx/signals/entities"; +import { unprotected } from "@ngrx/signals/testing"; +import { Product, ReturnProcess } from "../models"; +import { CreateReturnProcessError } from "../errors/return-process"; +import { ProductCategory } from "../questions"; -const TEST_ITEMS: Record = { +const TEST_ITEMS: Record = { 1: { id: 1, - actions: [{ key: 'canReturn', value: 'true' }], + actions: [{ key: "canReturn", value: "true" }], product: { - ean: '1234567890', - format: 'TB', - formatDetail: 'Taschenbuch', + ean: "1234567890", + format: "TB", + formatDetail: "Taschenbuch", } as Product, quantity: { quantity: 1 }, - receiptNumber: 'R-001', + receiptNumber: "R-001", }, 2: { id: 2, - actions: [{ key: 'canReturn', value: 'false' }], + actions: [{ key: "canReturn", value: "false" }], product: { - ean: '0987654321', - format: 'GEB', - formatDetail: 'Buch', + ean: "0987654321", + format: "GEB", + formatDetail: "Buch", } as Product, quantity: { quantity: 1 }, - receiptNumber: 'R-002', + receiptNumber: "R-002", }, 3: { id: 3, - actions: [{ key: 'canReturn', value: 'true' }], + actions: [{ key: "canReturn", value: "true" }], product: { - ean: '1122334455', - format: 'AU', - formatDetail: 'Audio', + ean: "1122334455", + format: "AU", + formatDetail: "Audio", } as Product, quantity: { quantity: 1 }, - receiptNumber: 'R-003', + receiptNumber: "R-003", }, }; -describe('ReturnProcessStore', () => { +describe("ReturnProcessStore", () => { const createService = createServiceFactory({ service: ReturnProcessStore, mocks: [IDBStorageProvider, ProcessService], }); - describe('Initialization', () => { - it('should create an instance of ReturnProcessStore', () => { + describe("Initialization", () => { + it("should create an instance of ReturnProcessStore", () => { const spectator = createService(); expect(spectator.service).toBeTruthy(); }); - it('should have a nextId computed property', () => { + it("should have a nextId computed property", () => { const spectator = createService(); expect(spectator.service.nextId()).toBe(1); // Assuming no entities exist initially }); }); - describe('Entity Management', () => { - it('should remove all entities by process id', () => { + describe("Entity Management", () => { + it("should remove all entities by process id", () => { const spectator = createService(); const store = spectator.service; @@ -75,9 +76,10 @@ describe('ReturnProcessStore', () => { processId: 1, receiptId: 1, receiptItem: TEST_ITEMS[1], - receiptDate: '', + receiptDate: "", answers: {}, - productCategory: undefined, + productCategory: ProductCategory.BookCalendar, + quantity: 1, returnReceipt: undefined, }, { @@ -85,9 +87,10 @@ describe('ReturnProcessStore', () => { processId: 2, receiptId: 2, receiptItem: TEST_ITEMS[2], - receiptDate: '', + receiptDate: "", answers: {}, - productCategory: undefined, + productCategory: ProductCategory.BookCalendar, + quantity: 1, returnReceipt: undefined, }, { @@ -95,9 +98,10 @@ describe('ReturnProcessStore', () => { processId: 1, receiptId: 3, receiptItem: TEST_ITEMS[3], - receiptDate: '', + receiptDate: "", answers: {}, - productCategory: undefined, + productCategory: ProductCategory.BookCalendar, + quantity: 1, returnReceipt: undefined, }, ] as ReturnProcess[]), @@ -108,7 +112,7 @@ describe('ReturnProcessStore', () => { expect(store.entities()[0].processId).toBe(2); }); - it('should set an answer for a given entity', () => { + it("should set an answer for a given entity", () => { const spectator = createService(); const store = spectator.service; @@ -120,19 +124,20 @@ describe('ReturnProcessStore', () => { processId: 1, receiptId: 1, receiptItem: TEST_ITEMS[1], - receiptDate: '', + receiptDate: "", answers: {}, - productCategory: undefined, + productCategory: ProductCategory.BookCalendar, + quantity: 1, returnReceipt: undefined, }, ] as ReturnProcess[]), ); - store.setAnswer(1, 'question1', 'answer1'); - expect(store.entityMap()[1].answers['question1']).toBe('answer1'); + store.setAnswer(1, "question1", "answer1"); + expect(store.entityMap()[1].answers["question1"]).toBe("answer1"); }); - it('should remove an answer for a given entity', () => { + it("should remove an answer for a given entity", () => { const spectator = createService(); const store = spectator.service; @@ -141,25 +146,26 @@ describe('ReturnProcessStore', () => { setEntity({ id: 1, processId: 1, - answers: { question1: 'answer1', question2: 'answer2' } as Record< + answers: { question1: "answer1", question2: "answer2" } as Record< string, unknown >, receiptDate: new Date().toJSON(), receiptItem: TEST_ITEMS[1], receiptId: 123, - productCategory: undefined, + productCategory: ProductCategory.BookCalendar, + quantity: 1, returnReceipt: undefined, } as ReturnProcess), ); - store.removeAnswer(1, 'question1'); - expect(store.entityMap()[1].answers['question1']).toBeUndefined(); + store.removeAnswer(1, "question1"); + expect(store.entityMap()[1].answers["question1"]).toBeUndefined(); }); }); - describe('Process Management', () => { - it('should initialize a new return process', () => { + describe("Process Management", () => { + it("should initialize a new return process", () => { const spectator = createService(); const store = spectator.service; @@ -169,28 +175,44 @@ describe('ReturnProcessStore', () => { { receipt: { id: 1, - printedDate: '', + printedDate: "", items: [], - buyer: { buyerNumber: '' }, + buyer: { buyerNumber: "" }, }, - items: [TEST_ITEMS[1]], + items: [ + { + receiptItem: TEST_ITEMS[1], + quantity: 1, + category: ProductCategory.BookCalendar, + }, + ], }, { receipt: { id: 2, - printedDate: '', + printedDate: "", items: [], - buyer: { buyerNumber: '' }, + buyer: { buyerNumber: "" }, }, - items: [TEST_ITEMS[3]], + items: [ + { + receiptItem: TEST_ITEMS[3], + quantity: 1, + category: ProductCategory.BookCalendar, + }, + ], }, ], }); expect(store.entities()).toHaveLength(2); + expect(store.entities()[0].productCategory).toBe( + ProductCategory.BookCalendar, + ); + expect(store.entities()[0].quantity).toBe(1); }); - it('should throw an error if no returnable items are found', () => { + it("should throw an error if no returnable items are found", () => { const spectator = createService(); const store = spectator.service; @@ -201,18 +223,24 @@ describe('ReturnProcessStore', () => { { receipt: { id: 2, - printedDate: '', + printedDate: "", items: [], - buyer: { buyerNumber: '' }, + buyer: { buyerNumber: "" }, }, - items: [TEST_ITEMS[2]], // Non-returnable item + items: [ + { + receiptItem: TEST_ITEMS[2], // Non-returnable item + quantity: 1, + category: ProductCategory.BookCalendar, + }, + ], }, ], }); }).toThrow(CreateReturnProcessError); }); - it('should throw an error if the number of returnable items does not match the total items', () => { + it("should throw an error if the number of returnable items does not match the total items", () => { const spectator = createService(); const store = spectator.service; @@ -223,11 +251,27 @@ describe('ReturnProcessStore', () => { { receipt: { id: 3, - printedDate: '', + printedDate: "", items: [], - buyer: { buyerNumber: '' }, + buyer: { buyerNumber: "" }, }, - items: [TEST_ITEMS[1], TEST_ITEMS[2], TEST_ITEMS[3]], + items: [ + { + receiptItem: TEST_ITEMS[1], + quantity: 1, + category: ProductCategory.BookCalendar, + }, + { + receiptItem: TEST_ITEMS[2], + quantity: 1, + category: ProductCategory.BookCalendar, + }, + { + receiptItem: TEST_ITEMS[3], + quantity: 1, + category: ProductCategory.BookCalendar, + }, + ], }, ], }); diff --git a/libs/oms/data-access/src/lib/stores/return-process.store.ts b/libs/oms/data-access/src/lib/stores/return-process.store.ts index 9575ea3ef..7ba005e98 100644 --- a/libs/oms/data-access/src/lib/stores/return-process.store.ts +++ b/libs/oms/data-access/src/lib/stores/return-process.store.ts @@ -5,29 +5,37 @@ import { withHooks, withMethods, withProps, -} from '@ngrx/signals'; +} from "@ngrx/signals"; import { withEntities, setAllEntities, updateEntity, -} from '@ngrx/signals/entities'; -import { IDBStorageProvider, withStorage } from '@isa/core/storage'; -import { computed, effect, inject } from '@angular/core'; -import { ProcessService } from '@isa/core/process'; -import { Receipt, ReceiptItem, ReturnProcess } from '../models'; +} from "@ngrx/signals/entities"; +import { IDBStorageProvider, withStorage } from "@isa/core/storage"; +import { computed, effect, inject } from "@angular/core"; +import { ProcessService } from "@isa/core/process"; +import { Receipt, ReceiptItem, ReturnProcess } from "../models"; import { CreateReturnProcessError, CreateReturnProcessErrorReason, -} from '../errors/return-process'; -import { logger } from '@isa/core/logging'; -import { canReturnReceiptItem } from '../helpers/return-process'; +} from "../errors/return-process"; +import { logger } from "@isa/core/logging"; +import { canReturnReceiptItem } from "../helpers/return-process"; +import { ProductCategory } from "../questions"; /** * Interface representing the parameters required to start a return process. */ export type StartProcess = { processId: number; - returns: { receipt: Receipt; items: ReceiptItem[] }[]; + returns: { + receipt: Receipt; + items: { + receiptItem: ReceiptItem; + quantity: number; + category: ProductCategory; + }[]; + }[]; }; /** @@ -55,12 +63,12 @@ export type StartProcess = { * - Throws a MismatchReturnableItemsError if the number of returnable items does not match the expected count. */ export const ReturnProcessStore = signalStore( - { providedIn: 'root' }, - withStorage('return-process', IDBStorageProvider), + { providedIn: "root" }, + withStorage("return-process", IDBStorageProvider), withEntities(), withProps(() => ({ _logger: logger(() => ({ - store: 'ReturnProcessStore', + store: "ReturnProcessStore", })), })), withComputed((store) => ({ @@ -142,6 +150,7 @@ export const ReturnProcessStore = signalStore( const returnableItems = params.returns .flatMap((r) => r.items) + .map((item) => item.receiptItem) .filter(canReturnReceiptItem); if (returnableItems.length === 0) { @@ -170,9 +179,10 @@ export const ReturnProcessStore = signalStore( id: nextId + entities.length, processId: params.processId, receiptId: receipt.id, - productCategory: item.features?.['category'], + productCategory: item.category, + quantity: item.quantity, receiptDate: receipt.printedDate, - receiptItem: item, + receiptItem: item.receiptItem, answers: {}, }); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html index f0308112c..c0898367a 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.html @@ -5,7 +5,7 @@ @for (quantity of quantityDropdownValues(); track quantity) { diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts index a56e69208..0b64af5fb 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.spec.ts @@ -1,50 +1,52 @@ -import { createComponentFactory, Spectator } from '@ngneat/spectator/jest'; -import { MockDirective } from 'ng-mocks'; +import { createComponentFactory, Spectator } from "@ngneat/spectator/jest"; +import { MockDirective } from "ng-mocks"; import { ReceiptItem, ReturnDetailsService, ReturnDetailsStore, -} from '@isa/oms/data-access'; +} from "@isa/oms/data-access"; -import { ProductImageDirective } from '@isa/shared/product-image'; -import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component'; -import { CheckboxComponent } from '@isa/ui/input-controls'; -import { signal } from '@angular/core'; +import { ProductImageDirective } from "@isa/shared/product-image"; +import { ReturnDetailsOrderGroupItemControlsComponent } from "../return-details-order-group-item-controls/return-details-order-group-item-controls.component"; +import { CheckboxComponent } from "@isa/ui/input-controls"; +import { signal } from "@angular/core"; // Helper function to create mock ReceiptItem data const createMockItem = ( ean: string, canReturn: boolean, - name = 'Test Product', - category = 'BOOK', // Add default category that's not 'unknown' + name = "Test Product", + category = "BOOK", // Add default category that's not 'unknown' + availableQuantity = 2, + selectedQuantity = 1, ): ReceiptItem => ({ id: 123, - receiptNumber: 'R-123456', // Add the required receiptNumber property - quantity: { quantity: 1 }, + receiptNumber: "R-123456", + quantity: { quantity: availableQuantity }, price: { - value: { value: 19.99, currency: 'EUR' }, + value: { value: 19.99, currency: "EUR" }, vat: { inPercent: 19 }, }, product: { ean: ean, name: name, - contributors: 'Test Author', - format: 'HC', - formatDetail: 'Hardcover', - manufacturer: 'Test Publisher', - publicationDate: '2024-01-01T00:00:00Z', - catalogProductNumber: '1234567890', - volume: '1', + contributors: "Test Author", + format: "HC", + formatDetail: "Hardcover", + manufacturer: "Test Publisher", + publicationDate: "2024-01-01T00:00:00Z", + catalogProductNumber: "1234567890", + volume: "1", }, - actions: [{ key: 'canReturn', value: String(canReturn) }], - features: { category: category }, // Add the features property with category + actions: [{ key: "canReturn", value: String(canReturn) }], + features: { category: category }, }) as ReceiptItem; -describe('ReturnDetailsOrderGroupItemControlsComponent', () => { +describe("ReturnDetailsOrderGroupItemControlsComponent", () => { let spectator: Spectator; - const mockItemSelectable = createMockItem('1234567890123', true); + const mockItemSelectable = createMockItem("1234567890123", true); const mockIsSelectable = signal(true); const mockGetItemSelectted = signal(false); @@ -52,6 +54,11 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => { isLoading: signal(true), }; + // Mocks for availableQuantityMap and selectedQuantityMap + const mockAvailableQuantityMap = { [mockItemSelectable.id]: 2 }; + const mockSelectedQuantityMap = { [mockItemSelectable.id]: 1 }; + const mockItemCategoryMap = { [mockItemSelectable.id]: "BOOK" }; + function resetMocks() { mockIsSelectable.set(true); mockGetItemSelectted.set(false); @@ -68,12 +75,16 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => { isSelectable: jest.fn(() => mockIsSelectable), getItemSelected: jest.fn(() => mockGetItemSelectted), canReturnResource: jest.fn(() => mockCanReturnResource), + availableQuantityMap: jest.fn(() => mockAvailableQuantityMap), + selectedQuantityMap: jest.fn(() => mockSelectedQuantityMap), + itemCategoryMap: jest.fn(() => mockItemCategoryMap), + setProductCategory: jest.fn(), + setQuantity: jest.fn(), + addSelectedItems: jest.fn(), + removeSelectedItems: jest.fn(), }, }, ], - // Spectator automatically stubs standalone dependencies like ItemRowComponent, CheckboxComponent etc. - // We don't need deep interaction, just verify the host component renders correctly. - // If specific interactions were needed, we could provide mocks or use overrideComponents. overrideComponents: [ [ ReturnDetailsOrderGroupItemControlsComponent, @@ -85,50 +96,41 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => { }, ], ], - detectChanges: false, // Control initial detection manually + detectChanges: false, }); beforeEach(() => { - // Default setup with a selectable item spectator = createComponent({ props: { - item: mockItemSelectable, // Use signal for input + item: mockItemSelectable, }, }); }); afterEach(() => { - resetMocks(); // Reset mocks after each test + resetMocks(); }); - it('should create', () => { - // Arrange - spectator.detectChanges(); // Trigger initial render - - // Assert + it("should create", () => { + spectator.detectChanges(); expect(spectator.component).toBeTruthy(); }); - it('should display the checkbox when item is selectable', () => { - // Arrange - mockCanReturnResource.isLoading.set(false); // Simulate the resource being ready - mockIsSelectable.set(true); // Simulate the item being selectable + it("should display the checkbox when item is selectable and not loading", () => { + mockCanReturnResource.isLoading.set(false); + mockIsSelectable.set(true); spectator.detectChanges(); - // Assert expect(spectator.component.selectable()).toBe(true); - const checkbox = spectator.query(CheckboxComponent); - expect(checkbox).toBeTruthy(); + expect(spectator.query(CheckboxComponent)).toBeTruthy(); expect( spectator.query(`input[data-what="return-item-checkbox"]`), ).toExist(); }); - it('should NOT display the checkbox when item is not selectable', () => { - // Arrange - mockIsSelectable.set(false); // Simulate the item not being selectable - spectator.detectChanges(); - spectator.detectComponentChanges(); - // Assert + it("should NOT display the checkbox when item is not selectable", () => { + mockIsSelectable.set(false); + mockCanReturnResource.isLoading.set(false); + spectator.detectChanges(); expect(spectator.component.selectable()).toBe(false); expect( spectator.query(`input[data-what="return-item-checkbox"]`), @@ -136,27 +138,73 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => { expect(spectator.query(CheckboxComponent)).toBeFalsy(); }); - it('should be false when no canReturn action is present', () => { - // Arrange - const item = { ...createMockItem('0001', true), actions: [] }; - spectator.setInput('item', item as any); - - // Act + it("should show spinner when canReturnResource is loading", () => { + mockCanReturnResource.isLoading.set(true); spectator.detectChanges(); + expect( + spectator.query('ui-icon-button[data-what="load-spinner"]'), + ).toExist(); + }); - // Assert + it("should render correct quantity dropdown values", () => { + spectator.detectChanges(); + expect(spectator.component.quantityDropdownValues()).toEqual([1, 2]); + }); + + it("should call setQuantity when dropdown value changes", () => { + const store = spectator.inject(ReturnDetailsStore); + const spy = jest.spyOn(store, "setQuantity"); + spectator.detectChanges(); + // Simulate dropdown value change + spectator.component.setQuantity(2); + expect(spy).toHaveBeenCalledWith(mockItemSelectable.id, 2); + }); + + it("should call setProductCategory when product category changes", () => { + const store = spectator.inject(ReturnDetailsStore); + const spy = jest.spyOn(store, "setProductCategory"); + spectator.detectChanges(); + spectator.component.setProductCategory("Buch/Kalender"); + expect(spy).toHaveBeenCalledWith(mockItemSelectable.id, "Buch/Kalender"); + }); + + it("should call addSelectedItems when setSelected(true) is called", () => { + const store = spectator.inject(ReturnDetailsStore); + const spy = jest.spyOn(store, "addSelectedItems"); + spectator.detectChanges(); + spectator.component.setSelected(true); + expect(spy).toHaveBeenCalledWith([mockItemSelectable.id]); + }); + + it("should call removeSelectedItems when setSelected(false) is called", () => { + const store = spectator.inject(ReturnDetailsStore); + const spy = jest.spyOn(store, "removeSelectedItems"); + spectator.detectChanges(); + spectator.component.setSelected(false); + expect(spy).toHaveBeenCalledWith([mockItemSelectable.id]); + }); + + it("should be false when no canReturn action is present", () => { + const item = { ...createMockItem("0001", true), actions: [] }; + spectator.setInput("item", item as any); + spectator.detectChanges(); expect(spectator.component.canReturnReceiptItem()).toBe(false); }); - it('should be false when canReturn action has falsy value', () => { - // Arrange - const item = createMockItem('0001', false); - spectator.setInput('item', item); - - // Act + it("should be false when canReturn action has falsy value", () => { + const item = createMockItem("0001", false); + spectator.setInput("item", item); spectator.detectChanges(); - - // Assert expect(spectator.component.canReturnReceiptItem()).toBe(false); }); + + it("should display correct selected quantity", () => { + spectator.detectChanges(); + expect(spectator.component.selectedQuantity()).toBe(1); + }); + + it("should display correct product category", () => { + spectator.detectChanges(); + expect(spectator.component.productCategory()).toBe("BOOK"); + }); }); diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts index 33d0e8408..1e0e11830 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item-controls/return-details-order-group-item-controls.component.ts @@ -5,30 +5,27 @@ import { inject, input, signal, -} from '@angular/core'; -import { provideLoggerContext } from '@isa/core/logging'; +} from "@angular/core"; +import { provideLoggerContext } from "@isa/core/logging"; import { canReturnReceiptItem, - getReceiptItemReturnedQuantity, - getReceiptItemProductCategory, - getReceiptItemQuantity, ProductCategory, ReceiptItem, ReturnDetailsService, ReturnDetailsStore, -} from '@isa/oms/data-access'; -import { IconButtonComponent } from '@isa/ui/buttons'; +} from "@isa/oms/data-access"; +import { IconButtonComponent } from "@isa/ui/buttons"; import { CheckboxComponent, DropdownButtonComponent, DropdownOptionComponent, -} from '@isa/ui/input-controls'; -import { FormsModule } from '@angular/forms'; +} from "@isa/ui/input-controls"; +import { FormsModule } from "@angular/forms"; @Component({ - selector: 'oms-feature-return-details-order-group-item-controls', - templateUrl: './return-details-order-group-item-controls.component.html', - styleUrls: ['./return-details-order-group-item-controls.component.scss'], + selector: "oms-feature-return-details-order-group-item-controls", + templateUrl: "./return-details-order-group-item-controls.component.html", + styleUrls: ["./return-details-order-group-item-controls.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ @@ -40,7 +37,7 @@ import { FormsModule } from '@angular/forms'; ], providers: [ provideLoggerContext({ - component: 'ReturnDetailsOrderGroupItemControlsComponent', + component: "ReturnDetailsOrderGroupItemControlsComponent", }), ], }) @@ -66,38 +63,11 @@ export class ReturnDetailsOrderGroupItemControlsComponent { availableCategories = this.#returnDetailsService.availableCategories(); - /** - * Computes the quantity of the current receipt item that has already been returned. - * - * This value is derived from the item's return history and is used to indicate - * how many units have already been processed for return. - * - * @returns The number of units already returned for this receipt item. - */ - returnedQuantity = computed(() => { + selectedQuantity = computed(() => { const item = this.item(); - return getReceiptItemReturnedQuantity(item); + return this.#store.selectedQuantityMap()[item.id]; }); - /** - * Computes the total quantity for the current receipt item. - * Represents the original quantity as recorded in the receipt. - * - * @returns The total quantity for the item. - */ - quantity = computed(() => { - const item = this.item(); - return getReceiptItemQuantity(item); - }); - - /** - * Computes the quantity of the item that is still available for return. - * Calculated as the difference between the total quantity and the returned quantity. - * - * @returns The number of units available to be returned. - */ - availableQuantity = computed(() => this.quantity() - this.returnedQuantity()); - /** * Generates the list of selectable quantities for the dropdown. * The values range from 1 up to the available quantity. @@ -105,13 +75,14 @@ export class ReturnDetailsOrderGroupItemControlsComponent { * @returns An array of selectable quantity values. */ quantityDropdownValues = computed(() => { - const itemQuantity = this.availableQuantity(); + const item = this.item(); + const itemQuantity = this.#store.availableQuantityMap()[item.id]; return Array.from({ length: itemQuantity }, (_, i) => i + 1); }); productCategory = computed(() => { const item = this.item(); - return getReceiptItemProductCategory(item); + return this.#store.itemCategoryMap()[item.id]; }); selectable = this.#store.isSelectable(this.item); @@ -127,8 +98,9 @@ export class ReturnDetailsOrderGroupItemControlsComponent { } setQuantity(quantity: number | undefined) { + const item = this.item(); if (quantity === undefined) { - quantity = this.item().quantity.quantity; + quantity = this.#store.availableQuantityMap()[item.id]; } this.#store.setQuantity(this.item().id, quantity); } diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html index ac7c35842..85f01a2ab 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.html @@ -57,7 +57,7 @@ {{ i.product.manufacturer }} | {{ i.product.ean }}
- {{ i.product.publicationDate | date: 'dd. MMM yyyy' }} + {{ i.product.publicationDate | date: "dd. MMM yyyy" }}
@@ -73,11 +73,11 @@ } -@if (returnedQuantity() > 0 && itemQuantity() !== returnedQuantity()) { +@if (availableQuantity() !== quantity()) {
- Es wurden bereits {{ returnedQuantity() }} von {{ itemQuantity() }} Artikel - zurückgegeben. + Es wurden bereits {{ quantity() - availableQuantity() }} von + {{ quantity() }} Artikel zurückgegeben.
} diff --git a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts index f32d7235f..69594cc8c 100644 --- a/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details-order-group-item/return-details-order-group-item.component.ts @@ -1,29 +1,28 @@ -import { CurrencyPipe, DatePipe, LowerCasePipe } from '@angular/common'; +import { CurrencyPipe, DatePipe, LowerCasePipe } from "@angular/common"; import { ChangeDetectionStrategy, Component, computed, inject, input, -} from '@angular/core'; -import { isaActionClose, ProductFormatIconGroup } from '@isa/icons'; +} from "@angular/core"; +import { isaActionClose, ProductFormatIconGroup } from "@isa/icons"; import { getReceiptItemAction, - getReceiptItemReturnedQuantity, getReceiptItemQuantity, ReceiptItem, ReturnDetailsStore, -} from '@isa/oms/data-access'; -import { ProductImageDirective } from '@isa/shared/product-image'; -import { ItemRowComponent } from '@isa/ui/item-rows'; -import { NgIconComponent, provideIcons } from '@ng-icons/core'; -import { ReturnDetailsOrderGroupItemControlsComponent } from '../return-details-order-group-item-controls/return-details-order-group-item-controls.component'; -import { ProductRouterLinkDirective } from '@isa/shared/product-router-link'; +} from "@isa/oms/data-access"; +import { ProductImageDirective } from "@isa/shared/product-image"; +import { ItemRowComponent } from "@isa/ui/item-rows"; +import { NgIconComponent, provideIcons } from "@ng-icons/core"; +import { ReturnDetailsOrderGroupItemControlsComponent } from "../return-details-order-group-item-controls/return-details-order-group-item-controls.component"; +import { ProductRouterLinkDirective } from "@isa/shared/product-router-link"; @Component({ - selector: 'oms-feature-return-details-order-group-item', - templateUrl: './return-details-order-group-item.component.html', - styleUrls: ['./return-details-order-group-item.component.scss'], + selector: "oms-feature-return-details-order-group-item", + templateUrl: "./return-details-order-group-item.component.html", + styleUrls: ["./return-details-order-group-item.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ @@ -82,7 +81,7 @@ export class ReturnDetailsOrderGroupItemComponent { */ canReturnMessage = computed(() => { const item = this.item(); - const canReturnAction = getReceiptItemAction(item, 'canReturn'); + const canReturnAction = getReceiptItemAction(item, "canReturn"); if (canReturnAction?.description) { return canReturnAction.description; @@ -90,30 +89,32 @@ export class ReturnDetailsOrderGroupItemComponent { const canReturnMessage = this.canReturn()?.message; - return canReturnMessage ?? ''; + return canReturnMessage ?? ""; }); /** - * Computes the quantity of the current receipt item that has already been returned. + * The original quantity of the item as recorded in the order. + * This value is retrieved from the store and represents the total number of units + * initially purchased for this receipt item. * - * This value is derived using the item's return history and is used to display - * how many units of this item have been processed for return so far. - * - * @returns The number of units already returned for this receipt item. + * @readonly + * @returns {number} The original quantity of the item in the order. */ - returnedQuantity = computed(() => { - const item = this.item(); - return getReceiptItemReturnedQuantity(item); - }); - - /** - * Computes the total quantity for the current receipt item. - * Represents the original quantity of the item as recorded in the receipt. - * - * @returns The total quantity for the item. - */ - itemQuantity = computed(() => { + quantity = computed(() => { const item = this.item(); return getReceiptItemQuantity(item); }); + + /** + * The currently available quantity of the item for return. + * This value is computed based on the item's current state and may be less than + * the original quantity if some units have already been returned or are otherwise unavailable. + * + * @readonly + * @returns {number} The number of units available for return. + */ + availableQuantity = computed(() => { + const item = this.item(); + return this.#store.availableQuantityMap()[item.id]; + }); } diff --git a/libs/oms/feature/return-details/src/lib/return-details.component.ts b/libs/oms/feature/return-details/src/lib/return-details.component.ts index 1ae500c1c..5efdd3baa 100644 --- a/libs/oms/feature/return-details/src/lib/return-details.component.ts +++ b/libs/oms/feature/return-details/src/lib/return-details.component.ts @@ -2,35 +2,34 @@ import { ChangeDetectionStrategy, Component, computed, - effect, inject, resource, -} from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { z } from 'zod'; +} from "@angular/core"; +import { ActivatedRoute, Router } from "@angular/router"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { z } from "zod"; -import { NgIconComponent, provideIcons } from '@ng-icons/core'; -import { isaActionChevronLeft } from '@isa/icons'; -import { ButtonComponent } from '@isa/ui/buttons'; -import { injectActivatedProcessId } from '@isa/core/process'; -import { Location } from '@angular/common'; -import { ExpandableDirectives } from '@isa/ui/expandable'; -import { ProgressBarComponent } from '@isa/ui/progress-bar'; +import { NgIconComponent, provideIcons } from "@ng-icons/core"; +import { isaActionChevronLeft } from "@isa/icons"; +import { ButtonComponent } from "@isa/ui/buttons"; +import { injectActivatedProcessId } from "@isa/core/process"; +import { Location } from "@angular/common"; +import { ExpandableDirectives } from "@isa/ui/expandable"; +import { ProgressBarComponent } from "@isa/ui/progress-bar"; import { ReturnDetailsService, ReturnProcessStore, ReturnDetailsStore, -} from '@isa/oms/data-access'; -import { ReturnDetailsStaticComponent } from './return-details-static/return-details-static.component'; -import { ReturnDetailsLazyComponent } from './return-details-lazy/return-details-lazy.component'; -import { logger } from '@isa/core/logging'; -import { groupBy } from 'lodash'; +} from "@isa/oms/data-access"; +import { ReturnDetailsStaticComponent } from "./return-details-static/return-details-static.component"; +import { ReturnDetailsLazyComponent } from "./return-details-lazy/return-details-lazy.component"; +import { logger } from "@isa/core/logging"; +import { groupBy } from "lodash"; @Component({ - selector: 'oms-feature-return-details', - templateUrl: './return-details.component.html', - styleUrls: ['./return-details.component.scss'], + selector: "oms-feature-return-details", + templateUrl: "./return-details.component.html", + styleUrls: ["./return-details.component.scss"], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ReturnDetailsStaticComponent, @@ -40,11 +39,11 @@ import { groupBy } from 'lodash'; ExpandableDirectives, ProgressBarComponent, ], - providers: [provideIcons({ isaActionChevronLeft })], + providers: [provideIcons({ isaActionChevronLeft }), ReturnDetailsStore], }) export class ReturnDetailsComponent { #logger = logger(() => ({ - component: 'ReturnDetailsComponent', + component: "ReturnDetailsComponent", itemId: this.receiptId(), processId: this.processId(), params: this.params(), @@ -66,21 +65,17 @@ export class ReturnDetailsComponent { receiptId = computed(() => { const params = this.params(); if (params) { - return z.coerce.number().parse(params['receiptId']); + return z.coerce.number().parse(params["receiptId"]); } - throw new Error('No receiptId found in route params'); + throw new Error("No receiptId found in route params"); }); - // Effect resets the Store's state when the receiptId changes - // This ensures that the store is always in sync with the current receiptId - receiptIdEffect = effect(() => this.#store.selectStorage(this.receiptId())); - receiptResource = this.#store.receiptResource(this.receiptId); customerReceiptsResource = resource({ request: this.receiptResource.value, loader: async ({ request, abortSignal }) => { - console.log('Fetching customer receipts for:', request); + console.log("Fetching customer receipts for:", request); const email = request?.buyer?.communicationDetails?.email; if (!email) { return []; @@ -101,15 +96,17 @@ export class ReturnDetailsComponent { startProcess() { if (!this.canStartProcess()) { this.#logger.warn( - 'Cannot start process: No items selected or no process ID', + "Cannot start process: No items selected or no process ID", ); return; } const processId = this.processId(); const selectedItems = this.#store.selectedItems(); + const selectedQuantites = this.#store.selectedQuantityMap(); + const selectedProductCategories = this.#store.itemCategoryMap(); - this.#logger.info('Starting return process', () => ({ + this.#logger.info("Starting return process", () => ({ processId: processId, selectedItems: selectedItems.map((item) => item.id), })); @@ -127,11 +124,18 @@ export class ReturnDetailsComponent { const returns = Object.entries(itemsGrouptByReceiptId).map( ([receiptId, items]) => ({ receipt: receipts[Number(receiptId)], - items, + items: items.map((item) => { + const receiptItem = item; + return { + receiptItem, + quantity: selectedQuantites[receiptItem.id], + category: selectedProductCategories[receiptItem.id], + }; + }), }), ); - this.#logger.info('Starting return process with returns', () => ({ + this.#logger.info("Starting return process with returns", () => ({ processId, returns, })); @@ -141,7 +145,7 @@ export class ReturnDetailsComponent { returns, }); - this._router.navigate(['../../', 'process'], { + this._router.navigate(["../../", "process"], { relativeTo: this._activatedRoute, }); }