Merge branch 'release/4.0' into develop

This commit is contained in:
Nino
2025-07-10 14:16:29 +02:00
25 changed files with 611 additions and 392 deletions

View File

@@ -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");
});
});

View File

@@ -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]);
}
}

View File

@@ -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();
});
});

View File

@@ -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,
},

View File

@@ -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<string, unknown>;
productCategory?: string;
productCategory: string;
quantity: number;
returnReceipt?: Receipt;
}

View File

@@ -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<CanReturn> {
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[];

View File

@@ -1,4 +1,4 @@
import { computed, inject, resource, untracked } from '@angular/core';
import { computed, inject, resource } from '@angular/core';
import {
CanReturn,
ProductCategory,
@@ -7,7 +7,6 @@ import {
ReturnDetailsService,
} from '@isa/oms/data-access';
import {
getState,
patchState,
signalStore,
type,
@@ -22,13 +21,11 @@ import {
getReceiptItemQuantity,
getReceiptItemProductCategory,
receiptItemHasCategory,
getReceiptItemReturnedQuantity,
} from '../helpers/return-process';
import { SessionStorageProvider } from '@isa/core/storage';
import { logger } from '@isa/core/logging';
import { clone } from 'lodash';
interface ReturnDetailsState {
_storageId: number | undefined;
_selectedItemIds: number[];
selectedProductCategory: Record<number, ProductCategory>;
selectedQuantity: Record<number, number>;
@@ -36,7 +33,6 @@ interface ReturnDetailsState {
}
const initialState: ReturnDetailsState = {
_storageId: undefined,
_selectedItemIds: [],
selectedProductCategory: {},
selectedQuantity: {},
@@ -49,36 +45,11 @@ export const receiptConfig = entityConfig({
});
export const ReturnDetailsStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withEntities(receiptConfig),
withProps(() => ({
_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<Array<ReceiptItem>>(() =>
@@ -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<number, number> = {};
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<number, ProductCategory> = {};
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<number, number> = {};
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();
@@ -167,8 +151,8 @@ export const ReturnDetailsStore = signalStore(
{ receiptId: params },
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 ({ params, abortSignal }) => {
if (params === undefined) {
return undefined;
}
const key = `${params.item.id}:${params.category}`;
const key = `${params.receiptItemId}:${params.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();
},
})),
);

View File

@@ -7,6 +7,7 @@ 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<number, ReturnProcess['receiptItem']> = {
1: {
@@ -77,7 +78,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -87,7 +89,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[2],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
{
@@ -97,7 +100,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[3],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
@@ -122,7 +126,8 @@ describe('ReturnProcessStore', () => {
receiptItem: TEST_ITEMS[1],
receiptDate: '',
answers: {},
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
},
] as ReturnProcess[]),
@@ -148,7 +153,8 @@ describe('ReturnProcessStore', () => {
receiptDate: new Date().toJSON(),
receiptItem: TEST_ITEMS[1],
receiptId: 123,
productCategory: undefined,
productCategory: ProductCategory.BookCalendar,
quantity: 1,
returnReceipt: undefined,
} as ReturnProcess),
);
@@ -173,7 +179,13 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[1]],
items: [
{
receiptItem: TEST_ITEMS[1],
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
{
receipt: {
@@ -182,12 +194,22 @@ describe('ReturnProcessStore', () => {
items: [],
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', () => {
@@ -205,7 +227,13 @@ describe('ReturnProcessStore', () => {
items: [],
buyer: { buyerNumber: '' },
},
items: [TEST_ITEMS[2]], // Non-returnable item
items: [
{
receiptItem: TEST_ITEMS[2], // Non-returnable item
quantity: 1,
category: ProductCategory.BookCalendar,
},
],
},
],
});
@@ -227,7 +255,23 @@ describe('ReturnProcessStore', () => {
items: [],
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,
},
],
},
],
});

View File

@@ -21,13 +21,21 @@ import {
} 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;
}[];
}[];
};
/**
@@ -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: {},
});
}

View File

@@ -1,9 +1,11 @@
<div class="flex flex-row w-full">
<div
class="flex flex-row justify-end -mb-4 desktop:mb-0 w-[13.4375rem] desktop:w-full"
>
@if (quantityDropdownValues().length > 1) {
<ui-dropdown
class="quantity-dropdown"
[disabled]="!canReturnReceiptItem()"
[value]="availableQuantity()"
[value]="selectedQuantity()"
(valueChange)="setQuantity($event)"
>
@for (quantity of quantityDropdownValues(); track quantity) {

View File

@@ -1,9 +1,13 @@
:host {
@apply flex flex-col-reverse items-end desktop:flex-row desktop:justify-center desktop:items-center gap-4;
@apply flex flex-col-reverse items-end desktop:flex-row desktop:justify-center desktop:items-center gap-4;
.product-dropdown.ui-dropdown {
@apply max-w-[13.4375rem] desktop:max-w-full;
}
:has(.product-dropdown):has(.quantity-dropdown) {
.quantity-dropdown.ui-dropdown {
@apply border-r-0 pr-4;
@apply border-r-0 pr-4 pl-5 max-w-20 desktop:max-w-full;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@@ -15,7 +19,7 @@
}
.product-dropdown.ui-dropdown {
@apply border-l-0 pl-4;
@apply border-l-0 max-w-[8.75rem] desktop:max-w-full pr-5 pl-4;
border-top-left-radius: 0;
border-bottom-left-radius: 0;

View File

@@ -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<ReturnDetailsOrderGroupItemControlsComponent>;
const mockItemSelectable = createMockItem('1234567890123', true);
const mockItemSelectable = createMockItem("1234567890123", true);
const mockIsSelectable = signal<boolean>(true);
const mockGetItemSelectted = signal<boolean>(false);
@@ -52,6 +54,11 @@ describe('ReturnDetailsOrderGroupItemControlsComponent', () => {
isLoading: signal<boolean>(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");
});
});

View File

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

View File

@@ -57,7 +57,7 @@
{{ i.product.manufacturer }} | {{ i.product.ean }}
</div>
<div class="text-isa-neutral-600 isa-text-body-2-regular">
{{ i.product.publicationDate | date: 'dd. MMM yyyy' }}
{{ i.product.publicationDate | date: "dd. MMM yyyy" }}
</div>
</div>
<oms-feature-return-details-order-group-item-controls [item]="i">
@@ -73,11 +73,11 @@
</div>
}
@if (returnedQuantity() > 0 && itemQuantity() !== returnedQuantity()) {
@if (availableQuantity() !== quantity()) {
<div
class="flex items-center self-start text-isa-neutral-600 isa-text-body-2-bold pb-6"
>
Es wurden bereits {{ returnedQuantity() }} von {{ itemQuantity() }} Artikel
zurückgegeben.
Es wurden bereits {{ quantity() - availableQuantity() }} von
{{ quantity() }} Artikel zurückgegeben.
</div>
}

View File

@@ -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];
});
}

View File

@@ -2,7 +2,6 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
resource,
} from '@angular/core';
@@ -40,11 +39,11 @@ import { groupBy } from 'lodash';
ExpandableDirectives,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionChevronLeft })],
providers: [provideIcons({ isaActionChevronLeft }), ReturnDetailsStore],
})
export class ReturnDetailsComponent {
#logger = logger(() => ({
component: ReturnDetailsComponent.name,
component: 'ReturnDetailsComponent',
itemId: this.receiptId(),
processId: this.processId(),
params: this.params(),
@@ -71,10 +70,6 @@ export class ReturnDetailsComponent {
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({
@@ -107,6 +102,8 @@ export class ReturnDetailsComponent {
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', () => ({
processId: processId,
@@ -126,7 +123,14 @@ 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],
};
}),
}),
);

View File

@@ -77,10 +77,10 @@ export class ReturnSearchMainComponent {
}: CallbackResult<ListResponseArgs<ReceiptListItem>>) => {
if (data) {
if (data.result.length === 1) {
this.navigate(['receipt', data.result[0].id]);
} else if (data.result.length > 1) {
this.navigate(['receipts']);
return this.navigate(['receipt', data.result[0].id]);
}
return this.navigate(['receipts']);
}
};

View File

@@ -1,4 +1,7 @@
<ui-client-row data-what="search-result-item" [attr.data-which]="receiptNumber()">
<ui-client-row
data-what="search-result-item"
[attr.data-which]="receiptNumber()"
>
<ui-client-row-content>
<h3 class="isa-text-subtitle-1-regular">{{ name() }}</h3>
</ui-client-row-content>
@@ -7,12 +10,12 @@
<ui-item-row-data-label>Belegdatum</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold">
{{ receiptDate() | date: 'dd.MM.yy' }}
{{ receiptDate() | date: "dd.MM.yy" }}
</span>
</ui-item-row-data-value>
</ui-item-row-data-row>
<ui-item-row-data-row>
<ui-item-row-data-label>Rechnugsnr.</ui-item-row-data-label>
<ui-item-row-data-label>Beleg-Nr.</ui-item-row-data-label>
<ui-item-row-data-value>
<span class="isa-text-body-2-bold"> {{ receiptNumber() }} </span>
</ui-item-row-data-value>

View File

@@ -3,12 +3,35 @@
}
.oms-shared-return-task-list-item__review {
@apply grid grid-cols-[1fr,1fr] desktop:grid-cols-[1fr,1fr,minmax(20rem,auto)] gap-x-6 py-6 text-isa-secondary-900 items-center border-b border-solid border-isa-neutral-300 last:pb-0 last:border-none;
@apply grid grid-cols-[1fr,1fr] desktop:grid-cols-[1fr,1fr,minmax(20rem,auto)] gap-x-6 desktop:gap-y-6 py-6 text-isa-secondary-900 items-center border-b border-solid border-isa-neutral-300 last:pb-0 last:border-none;
&:has(.task-unknown-actions):has(.tolino-print-cta) {
.tolino-print-cta {
@apply desktop:justify-self-start;
}
}
@media screen and (max-width: 1024px) {
grid-template-areas:
'product infos'
'unknown-comment actions';
"product infos"
"unknown-comment actions";
.tolino-print-cta,
.task-unknown-actions {
@apply mt-6 desktop:mt-0;
grid-area: actions;
}
&:has(.task-unknown-actions):has(.tolino-print-cta) {
.tolino-print-cta {
@apply mt-0 self-start;
grid-area: print;
}
grid-template-areas:
"product print"
"unknown-comment actions";
}
.product-info {
grid-area: product;
@@ -18,12 +41,6 @@
grid-area: infos;
}
.tolino-print-cta,
.task-unknown-actions {
@apply mt-6 desktop:mt-0;
grid-area: actions;
}
.processing-comment-unknown {
@apply mt-6 desktop:mt-0;
grid-area: unknown-comment;
@@ -48,7 +65,7 @@
}
.task-unknown-actions {
@apply flex flex-row gap-3 h-full py-2 items-center;
@apply flex flex-row gap-3 h-full py-2 items-center justify-self-end;
}
.processing-comment {