Merged PR 1904: feat(utils-ean-validation, remission-list): add EAN validation library and im...

feat(utils-ean-validation, remission-list): add EAN validation library and implement exact search

Create new EAN validation utility library with validator function and isEan helper.
Implement exact search functionality for remission lists that bypasses filters
when scanning EAN codes or performing exact searches.

Changes:
- Add new utils/ean-validation library with EAN regex validation
- Export eanValidator for Angular reactive forms integration
- Export isEan utility function for EAN validation checks
- Configure library with Vitest for testing
- Update remission list resource to support exact search mode
- Clear filters and orderBy when performing EAN-based searches
- Add data attributes to product info component for E2E testing

Ref: #5128
This commit is contained in:
Nino Righi
2025-08-01 13:22:41 +00:00
committed by Patrick Brix
parent d7d535c10d
commit 14a5a67a1e
19 changed files with 34964 additions and 34729 deletions

View File

@@ -8,14 +8,7 @@ import {
signal,
viewChild,
} from '@angular/core';
import {
AbstractControl,
FormControl,
ReactiveFormsModule,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms';
import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
import {
Product,
ReturnProcessProductQuestion,
@@ -39,16 +32,7 @@ import { isaActionScanner } from '@isa/icons';
import { ScannerButtonComponent } from '@isa/shared/scanner';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { toSignal } from '@angular/core/rxjs-interop';
const eanValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const value = control.value;
if (value && !/^[0-9]{13}$/.test(value)) {
return { invalidEan: true };
}
return null;
};
import { eanValidator } from '@isa/utils/ean-validation';
@Component({
selector: 'oms-feature-return-process-product-question',

View File

@@ -248,8 +248,6 @@ export class RemissionSearchService {
* const response = await service.fetchList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 250,
* skip: 0,
* orderBy: 'itemName'
* });
* console.log(`Total items: ${response.totalCount}`);
@@ -267,8 +265,6 @@ export class RemissionSearchService {
this.#logger.info('Fetching remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: 250,
skip: parsed.skip,
}));
let req$ = this.#remiService.RemiPflichtremissionsartikel({
@@ -278,8 +274,6 @@ export class RemissionSearchService {
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: 250,
skip: parsed.skip,
},
});
@@ -325,8 +319,6 @@ export class RemissionSearchService {
* const departmentResponse = await service.fetchDepartmentList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 250,
* skip: 0
* });
*
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
@@ -344,8 +336,6 @@ export class RemissionSearchService {
this.#logger.info('Fetching department remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: 250,
skip: parsed.skip,
}));
let req$ = this.#remiService.RemiUeberlauf({
@@ -355,8 +345,6 @@ export class RemissionSearchService {
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: 250,
skip: parsed.skip,
},
});

View File

@@ -2,15 +2,15 @@ import { inject, resource } from '@angular/core';
import { ListResponseArgs, ResponseArgsError } from '@isa/common/data-access';
import {
QueryTokenInput,
RemissionItem,
RemissionListType,
RemissionSearchService,
RemissionStockService,
RemissionSupplierService,
ReturnItem,
ReturnSuggestion,
} from '@isa/remission/data-access';
import { SearchTrigger } from '@isa/shared/filter';
import { parseISO, compareDesc } from 'date-fns';
import { isEan } from '@isa/utils/ean-validation';
/**
* Creates an Angular resource for fetching remission lists.
@@ -36,7 +36,7 @@ import { parseISO, compareDesc } from 'date-fns';
* },
* searchTrigger: 'input'
* }));
*
*
* @remarks
* The searchTrigger parameter influences query behavior:
* - 'scan': Clears existing filters to show scan-specific results
@@ -70,19 +70,24 @@ export const createRemissionListResource = (
throw new Error('No Supplier available');
}
let res:
| ListResponseArgs<ReturnItem>
| ListResponseArgs<ReturnSuggestion>
| undefined;
let res: ListResponseArgs<RemissionItem> | undefined = undefined;
const queryToken = { ...params.queryToken };
if (params.searchTrigger === 'scan') {
// #5128 #5234 Bei Exact Search soll er über Alle Listen nur mit dem Input ohne aktive Filter / orderBy suchen
const isExactSearch =
params.searchTrigger === 'scan' || isEan(queryToken?.input?.['qs']);
if (isExactSearch) {
queryToken.filter = {};
queryToken.orderBy = [];
}
if (params.remissionListType === RemissionListType.Pflicht) {
res = await remissionSearchService.fetchList(
if (
isExactSearch ||
params.remissionListType === RemissionListType.Pflicht
) {
const fetchListResponse = await remissionSearchService.fetchList(
{
assignedStockId: assignedStock.id,
supplierId: firstSupplier.id,
@@ -90,17 +95,34 @@ export const createRemissionListResource = (
},
abortSignal,
);
res = fetchListResponse;
}
if (params.remissionListType === RemissionListType.Abteilung) {
res = await remissionSearchService.fetchDepartmentList(
{
assignedStockId: assignedStock.id,
supplierId: firstSupplier.id,
...params.queryToken,
},
abortSignal,
);
if (
isExactSearch ||
params.remissionListType === RemissionListType.Abteilung
) {
const fetchDepartmentListResponse =
await remissionSearchService.fetchDepartmentList(
{
assignedStockId: assignedStock.id,
supplierId: firstSupplier.id,
...queryToken,
},
abortSignal,
);
if (res) {
// Merge results if both lists are fetched
res.result = [
...(res.result || []),
...(fetchDepartmentListResponse.result || []),
];
res.hits += fetchDepartmentListResponse.hits;
res.skip += fetchDepartmentListResponse.skip;
res.take += fetchDepartmentListResponse.take;
} else {
res = fetchDepartmentListResponse;
}
}
if (res?.error) {

View File

@@ -27,7 +27,7 @@
{{ product.name }}
</div>
<div class="isa-text-body-2-bold" data-what="product-price">
{{ price.value.value | currency: price.value.currencySymbol }}
{{ price?.value?.value | currency: price?.value?.currencySymbol }}
</div>
</div>
<div class="flex flex-col w-full gap-2 items-start justify-end">

View File

@@ -0,0 +1,7 @@
# ean-validation
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test ean-validation` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
const nx = require('@nx/eslint-plugin');
const baseConfig = require('../../../eslint.config.js');
module.exports = [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,20 @@
{
"name": "ean-validation",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/utils/ean-validation/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/vite:test",
"outputs": ["{options.reportsDirectory}"],
"options": {
"reportsDirectory": "../../../coverage/libs/utils/ean-validation"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export * from './lib/ean-validation/ean-validation';

View File

@@ -0,0 +1,5 @@
/**
* Constants for EAN validation.
* EAN (European Article Number) is a 13-digit number used to identify products.
*/
export const EAN_REGEX = /^[0-9]{13}$/;

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { FormControl } from '@angular/forms';
import { eanValidator, isEan } from './ean-validation';
describe('eanValidator', () => {
it('should return null for valid EAN', () => {
// Arrange
const control = new FormControl('1234567890123');
// Act
const result = eanValidator(control);
// Assert
expect(result).toBeNull();
});
it('should return null for empty value', () => {
// Arrange
const control = new FormControl('');
// Act
const result = eanValidator(control);
// Assert
expect(result).toBeNull();
});
});
describe('isEan', () => {
it('should return true for valid EAN', () => {
// Arrange
const validEan = '1234567890123';
// Act
const result = isEan(validEan);
// Assert
expect(result).toBe(true);
});
it('should return false for undefined value', () => {
// Arrange
const undefinedValue = undefined;
// Act
const result = isEan(undefinedValue);
// Assert
expect(result).toBe(false);
});
});

View File

@@ -0,0 +1,33 @@
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
import { EAN_REGEX } from './constants';
/**
* Validator function to check if a control's value is a valid EAN (European Article Number).
* A valid EAN is a 13-digit number.
*
* @param {AbstractControl} control - The form control to validate
* @returns {ValidationErrors | null} Returns an error object if the value is invalid, otherwise null
*/
export const eanValidator: ValidatorFn = (
control: AbstractControl,
): ValidationErrors | null => {
const value = control.value;
if (value && !EAN_REGEX.test(value)) {
return { invalidEan: true };
}
return null;
};
/**
* Checks if a value is a valid EAN (European Article Number).
* A valid EAN is a 13-digit number.
*
* @param {string | undefined} value - The value to check
* @returns {boolean} True if the value is a valid EAN, false otherwise
*/
export const isEan = (value: string | undefined): boolean => {
if (!value) {
return false;
}
return EAN_REGEX.test(value);
};

View File

@@ -0,0 +1,13 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
getTestBed().initTestEnvironment(
BrowserTestingModule,
platformBrowserTesting(),
);

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"importHelpers": true,
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"typeCheckHostBindings": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,27 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../../node_modules/.vite/libs/utils/ean-validation',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../../coverage/libs/utils/ean-validation',
provider: 'v8' as const,
},
},
}));