Merged PR 1879: Warenbegleitschein Übersicht und Details

Related work items: #5137, #5138
This commit is contained in:
Lorenz Hilpert
2025-07-10 16:00:16 +00:00
parent f6b2b554bb
commit a36d746fb8
115 changed files with 37648 additions and 32935 deletions

3
.gitignore vendored
View File

@@ -70,3 +70,6 @@ storybook-static
vite.config.*.timestamp*
vitest.config.*.timestamp*
.mcp.json
.memory.json

View File

@@ -192,8 +192,22 @@ const routes: Routes = [
},
{
path: 'remission',
children: [
{
path: 'return-receipt',
loadChildren: () =>
import('@isa/remission/feature/remission-list').then((m) => m.routes),
import(
'@isa/remission/feature/remission-return-receipt-list'
).then((m) => m.routes),
},
{
path: '',
loadChildren: () =>
import('@isa/remission/feature/remission-list').then(
(m) => m.routes,
),
},
],
},
],
},
@@ -207,7 +221,10 @@ if (isDevMode()) {
}
@NgModule({
imports: [RouterModule.forRoot(routes), TokenLoginModule],
imports: [
RouterModule.forRoot(routes, { bindToComponentInputs: true }),
TokenLoginModule,
],
exports: [RouterModule],
providers: [provideScrollPositionRestoration()],
})

View File

@@ -299,5 +299,21 @@
</span>
<span class="side-menu-group-item-label">Remission</span>
</a>
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
'return-receipt',
]"
(isActiveChange)="focusSearchBox()"
>
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
<ng-icon name="isaNavigationRemission2"></ng-icon>
</span>
<span class="side-menu-group-item-label">Warenbegleitscheine</span>
</a>
</nav>
</div>

View File

@@ -9,6 +9,7 @@
@layer components {
@import "../../../libs/ui/buttons/src/buttons.scss";
@import "../../../libs/ui/bullet-list/src/bullet-list.scss";
@import "../../../libs/ui/datepicker/src/datepicker.scss";
@import "../../../libs/ui/dialog/src/dialog.scss";
@import "../../../libs/ui/input-controls/src/input-controls.scss";

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,277 @@
# Common Decorators Library
A collection of TypeScript decorators for common cross-cutting concerns in Angular applications.
## Installation
This library is already configured in the project's `tsconfig.base.json`. Import decorators using:
```typescript
import { InFlight, InFlightWithKey, InFlightWithCache } from '@isa/common/decorators';
```
## Available Decorators
### 🚀 InFlight Decorators
Prevent multiple simultaneous calls to the same async method. All concurrent calls receive the same Promise result.
#### Basic Usage
```typescript
import { InFlight } from '@isa/common/decorators';
@Injectable()
class DataService {
@InFlight()
async fetchData(): Promise<Data> {
// Even if called multiple times simultaneously,
// only one API call will be made
return await this.http.get<Data>('/api/data').toPromise();
}
}
```
**Benefits:**
- Prevents duplicate API calls
- Reduces server load
- Improves application performance
- All callers receive the same result
### 🔑 InFlightWithKey
Prevents duplicate calls while considering method arguments. Each unique set of arguments gets its own in-flight tracking.
```typescript
import { InFlightWithKey } from '@isa/common/decorators';
@Injectable()
class UserService {
@InFlightWithKey({
keyGenerator: (userId: string) => userId
})
async fetchUser(userId: string): Promise<User> {
// Multiple calls with same userId share the same request
// Different userIds can execute simultaneously
return await this.http.get<User>(`/api/users/${userId}`).toPromise();
}
@InFlightWithKey() // Uses JSON.stringify by default
async searchUsers(query: string, page: number): Promise<User[]> {
return await this.http.get<User[]>(`/api/users/search`, {
params: { query, page: page.toString() }
}).toPromise();
}
}
```
**Configuration Options:**
- `keyGenerator?: (...args) => string` - Custom key generation function
- If not provided, uses `JSON.stringify(args)` as the key
### 🗄️ InFlightWithCache
Combines in-flight request deduplication with result caching.
```typescript
import { InFlightWithCache } from '@isa/common/decorators';
@Injectable()
class ProductService {
@InFlightWithCache({
cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
keyGenerator: (productId: string) => productId
})
async getProduct(productId: string): Promise<Product> {
// Results are cached for 5 minutes
// Multiple calls within cache time return cached result
return await this.http.get<Product>(`/api/products/${productId}`).toPromise();
}
}
```
**Configuration Options:**
- `cacheTime?: number` - Cache duration in milliseconds
- `keyGenerator?: (...args) => string` - Custom key generation function
## How It Works
### Memory Management
All decorators use `WeakMap` for memory efficiency:
- Automatic garbage collection when instances are destroyed
- No memory leaks
- Per-instance state isolation
### Error Handling
- Failed requests are not cached
- In-flight tracking is cleaned up on both success and error
- All concurrent callers receive the same error
### Thread Safety
- Decorators are instance-aware
- Each service instance has its own in-flight tracking
- No shared state between instances
## Real-World Examples
### Solving Your Original Problem
```typescript
// Before: Multiple simultaneous calls
@Injectable({ providedIn: 'root' })
export class RemissionProductGroupService {
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
// Multiple calls = multiple API requests
return await this.apiCall();
}
}
// After: Using InFlight decorator
@Injectable({ providedIn: 'root' })
export class RemissionProductGroupService {
@InFlight()
async fetchProductGroups(): Promise<KeyValueStringAndString[]> {
// Multiple simultaneous calls = single API request
return await this.apiCall();
}
}
```
### Advanced Scenarios
```typescript
@Injectable()
class OrderService {
// Different cache times for different data types
@InFlightWithCache({ cacheTime: 30 * 1000 }) // 30 seconds
async getOrderStatus(orderId: string): Promise<OrderStatus> {
return await this.http.get<OrderStatus>(`/api/orders/${orderId}/status`).toPromise();
}
@InFlightWithCache({ cacheTime: 10 * 60 * 1000 }) // 10 minutes
async getOrderHistory(customerId: string): Promise<Order[]> {
return await this.http.get<Order[]>(`/api/customers/${customerId}/orders`).toPromise();
}
// Custom key generation for complex parameters
@InFlightWithKey({
keyGenerator: (filter: OrderFilter) =>
`${filter.status}-${filter.dateFrom}-${filter.dateTo}`
})
async searchOrders(filter: OrderFilter): Promise<Order[]> {
return await this.http.post<Order[]>('/api/orders/search', filter).toPromise();
}
}
```
## Best Practices
### ✅ Do
- Use `@InFlight()` for simple methods without parameters
- Use `@InFlightWithKey()` for methods with parameters
- Use `@InFlightWithCache()` for expensive operations with stable results
- Provide custom `keyGenerator` for complex parameter objects
- Set appropriate cache times based on data volatility
### ❌ Don't
- Use on methods that return different results for the same input
- Use excessively long cache times for dynamic data
- Use on methods that have side effects (POST, PUT, DELETE)
- Rely on argument order for default key generation
## Performance Considerations
### Memory Usage
- `InFlight`: Minimal memory overhead (one Promise per instance)
- `InFlightWithKey`: Memory usage scales with unique parameter combinations
- `InFlightWithCache`: Additional memory for cached results
### Cleanup
- In-flight requests are automatically cleaned up on completion
- Cache entries are cleaned up on expiry
- WeakMap ensures instances can be garbage collected
## Testing
The decorators are fully tested with comprehensive unit tests. Key test scenarios include:
- Multiple simultaneous calls deduplication
- Error handling and cleanup
- Cache expiration
- Instance isolation
- Key generation
Run tests with:
```bash
npx nx test common-decorators
```
## Migration Guide
### From Manual Implementation
```typescript
// Before: Manual in-flight tracking
class MyService {
private inFlight: Promise<Data> | null = null;
async fetchData(): Promise<Data> {
if (this.inFlight) {
return this.inFlight;
}
this.inFlight = this.doFetch();
try {
return await this.inFlight;
} finally {
this.inFlight = null;
}
}
}
// After: Using decorator
class MyService {
@InFlight()
async fetchData(): Promise<Data> {
return await this.doFetch();
}
}
```
### From RxJS shareReplay
```typescript
// Before: RxJS approach
class MyService {
private data$ = this.http.get<Data>('/api/data').pipe(
shareReplay({ bufferSize: 1, refCount: true })
);
getData(): Observable<Data> {
return this.data$;
}
}
// After: Promise-based with decorator
class MyService {
@InFlightWithCache({ cacheTime: 5 * 60 * 1000 })
async getData(): Promise<Data> {
return await this.http.get<Data>('/api/data').toPromise();
}
}
```
## Contributing
When adding new decorators:
1. Add implementation in `src/lib/`
2. Include comprehensive unit tests
3. Update this documentation
4. Export from `src/index.ts`

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: 'common',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'common',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

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

View File

@@ -0,0 +1 @@
export * from './lib/in-flight.decorator';

View File

@@ -0,0 +1,321 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InFlight, InFlightWithKey, InFlightWithCache } from './in-flight.decorator';
describe('InFlight Decorators', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
});
describe('InFlight', () => {
class TestService {
callCount = 0;
@InFlight()
async fetchData(delay = 100): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, delay));
return `result-${this.callCount}`;
}
@InFlight()
async fetchWithError(delay = 100): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, delay));
throw new Error('Test error');
}
}
it('should prevent multiple simultaneous calls', async () => {
const service = new TestService();
// Make three simultaneous calls
const promise1 = service.fetchData();
const promise2 = service.fetchData();
const promise3 = service.fetchData();
// Advance timers to complete the async operation
await vi.runAllTimersAsync();
// All promises should resolve to the same value
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
expect(result1).toBe('result-1');
expect(result2).toBe('result-1');
expect(result3).toBe('result-1');
expect(service.callCount).toBe(1);
});
it('should allow subsequent calls after completion', async () => {
const service = new TestService();
// First call
const promise1 = service.fetchData();
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toBe('result-1');
// Second call after first completes
const promise2 = service.fetchData();
await vi.runAllTimersAsync();
const result2 = await promise2;
expect(result2).toBe('result-2');
expect(service.callCount).toBe(2);
});
it('should handle errors properly', async () => {
const service = new TestService();
// Make multiple calls that will error
const promise1 = service.fetchWithError();
const promise2 = service.fetchWithError();
await vi.runAllTimersAsync();
// Both should reject with the same error
await expect(promise1).rejects.toThrow('Test error');
await expect(promise2).rejects.toThrow('Test error');
expect(service.callCount).toBe(1);
// Should allow new call after error
const promise3 = service.fetchWithError();
await vi.runAllTimersAsync();
await expect(promise3).rejects.toThrow('Test error');
expect(service.callCount).toBe(2);
});
it('should maintain separate state per instance', async () => {
const service1 = new TestService();
const service2 = new TestService();
// Make simultaneous calls on different instances
const promise1 = service1.fetchData();
const promise2 = service2.fetchData();
await vi.runAllTimersAsync();
const [result1, result2] = await Promise.all([promise1, promise2]);
// Each instance should have made its own call
expect(result1).toBe('result-1');
expect(result2).toBe('result-1');
expect(service1.callCount).toBe(1);
expect(service2.callCount).toBe(1);
});
});
describe('InFlightWithKey', () => {
class UserService {
callCounts = new Map<string, number>();
@InFlightWithKey({
keyGenerator: (userId: string) => userId
})
async fetchUser(userId: string, delay = 100): Promise<{ id: string; name: string }> {
const count = (this.callCounts.get(userId) || 0) + 1;
this.callCounts.set(userId, count);
await new Promise(resolve => setTimeout(resolve, delay));
return { id: userId, name: `User ${userId} - Call ${count}` };
}
@InFlightWithKey()
async fetchWithDefaultKey(param1: string, param2: number): Promise<string> {
const key = `${param1}-${param2}`;
const count = (this.callCounts.get(key) || 0) + 1;
this.callCounts.set(key, count);
await new Promise(resolve => setTimeout(resolve, 100));
return `Result ${count}`;
}
}
it('should deduplicate calls with same key', async () => {
const service = new UserService();
// Multiple calls with same userId
const promise1 = service.fetchUser('user1');
const promise2 = service.fetchUser('user1');
const promise3 = service.fetchUser('user1');
await vi.runAllTimersAsync();
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
expect(result2).toEqual(result1);
expect(result3).toEqual(result1);
expect(service.callCounts.get('user1')).toBe(1);
});
it('should allow simultaneous calls with different keys', async () => {
const service = new UserService();
// Calls with different userIds
const promise1 = service.fetchUser('user1');
const promise2 = service.fetchUser('user2');
const promise3 = service.fetchUser('user1'); // Duplicate of first
await vi.runAllTimersAsync();
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
expect(result1).toEqual({ id: 'user1', name: 'User user1 - Call 1' });
expect(result2).toEqual({ id: 'user2', name: 'User user2 - Call 1' });
expect(result3).toEqual(result1); // Same as first call
expect(service.callCounts.get('user1')).toBe(1);
expect(service.callCounts.get('user2')).toBe(1);
});
it('should use JSON.stringify as default key generator', async () => {
const service = new UserService();
// Multiple calls with same arguments
const promise1 = service.fetchWithDefaultKey('test', 123);
const promise2 = service.fetchWithDefaultKey('test', 123);
// Different arguments
const promise3 = service.fetchWithDefaultKey('test', 456);
await vi.runAllTimersAsync();
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
expect(result1).toBe('Result 1');
expect(result2).toBe('Result 1'); // Same as first
expect(result3).toBe('Result 1'); // Different key, separate call
expect(service.callCounts.get('test-123')).toBe(1);
expect(service.callCounts.get('test-456')).toBe(1);
});
});
describe('InFlightWithCache', () => {
class DataService {
callCount = 0;
@InFlightWithCache({
cacheTime: 1000, // 1 second cache
keyGenerator: (query: string) => query
})
async search(query: string): Promise<string[]> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 100));
return [`result-${query}-${this.callCount}`];
}
@InFlightWithCache({
cacheTime: 500
})
async fetchWithExpiry(id: number): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 50));
return `data-${id}-${this.callCount}`;
}
}
it('should cache results for specified time', async () => {
const service = new DataService();
// First call
const promise1 = service.search('test');
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toEqual(['result-test-1']);
expect(service.callCount).toBe(1);
// Second call within cache time - should return cached result
const result2 = await service.search('test');
expect(result2).toEqual(['result-test-1']);
expect(service.callCount).toBe(1); // No new call
// Advance time past cache expiry
vi.advanceTimersByTime(1100);
// Third call after cache expiry - should make new call
const promise3 = service.search('test');
await vi.runAllTimersAsync();
const result3 = await promise3;
expect(result3).toEqual(['result-test-2']);
expect(service.callCount).toBe(2);
});
it('should handle in-flight deduplication with caching', async () => {
const service = new DataService();
// Multiple simultaneous calls
const promise1 = service.search('query1');
const promise2 = service.search('query1');
const promise3 = service.search('query1');
await vi.runAllTimersAsync();
const [result1, result2, result3] = await Promise.all([promise1, promise2, promise3]);
// All should get same result
expect(result1).toEqual(['result-query1-1']);
expect(result2).toEqual(result1);
expect(result3).toEqual(result1);
expect(service.callCount).toBe(1);
// Subsequent call should use cache
const result4 = await service.search('query1');
expect(result4).toEqual(['result-query1-1']);
expect(service.callCount).toBe(1);
});
it('should clean up expired cache entries', async () => {
const service = new DataService();
// Make a call
const promise1 = service.fetchWithExpiry(1);
await vi.runAllTimersAsync();
await promise1;
// Advance time past cache expiry
vi.advanceTimersByTime(600);
// Make another call - should not use expired cache
service.callCount = 0; // Reset for clarity
const promise2 = service.fetchWithExpiry(1);
await vi.runAllTimersAsync();
const result2 = await promise2;
expect(result2).toBe('data-1-1');
expect(service.callCount).toBe(1); // New call was made
});
it('should handle errors without caching them', async () => {
class ErrorService {
callCount = 0;
@InFlightWithCache({ cacheTime: 1000 })
async fetchWithError(): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 50));
throw new Error('API Error');
}
}
const service = new ErrorService();
// First call that errors
const promise1 = service.fetchWithError();
await vi.runAllTimersAsync();
await expect(promise1).rejects.toThrow('API Error');
expect(service.callCount).toBe(1);
// Second call should not use cache (errors aren't cached)
const promise2 = service.fetchWithError();
await vi.runAllTimersAsync();
await expect(promise2).rejects.toThrow('API Error');
expect(service.callCount).toBe(2);
});
});
});

View File

@@ -0,0 +1,251 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Decorator that prevents multiple simultaneous calls to the same async method.
* All concurrent calls will receive the same Promise result.
*
* @example
* ```typescript
* class MyService {
* @InFlight()
* async fetchData(): Promise<Data> {
* // This method will only execute once even if called multiple times simultaneously
* return await api.getData();
* }
* }
* ```
*/
export function InFlight<
T extends (...args: any[]) => Promise<any>,
>(): MethodDecorator {
const inFlightMap = new WeakMap<object, Promise<any>>();
return function (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = async function (
this: any,
...args: Parameters<T>
): Promise<ReturnType<T>> {
// Check if there's already an in-flight request for this instance
const existingRequest = inFlightMap.get(this);
if (existingRequest) {
return existingRequest;
}
// Create new request and store it
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
// Clean up after successful completion
inFlightMap.delete(this);
return result;
})
.catch((error: any) => {
// Clean up after error
inFlightMap.delete(this);
throw error;
});
inFlightMap.set(this, promise);
return promise;
};
return descriptor;
};
}
/**
* Decorator that prevents multiple simultaneous calls to the same async method
* while considering method arguments. Each unique set of arguments gets its own
* in-flight tracking.
*
* @param options Configuration options for the decorator
* @example
* ```typescript
* class UserService {
* @InFlightWithKey({
* keyGenerator: (userId: string) => userId
* })
* async fetchUser(userId: string): Promise<User> {
* // Calls with different userIds can execute simultaneously
* // Calls with the same userId will share the same promise
* return await api.getUser(userId);
* }
* }
* ```
*/
export interface InFlightWithKeyOptions<T extends (...args: any[]) => any> {
/**
* Generate a cache key from the method arguments.
* If not provided, uses JSON.stringify on all arguments.
*/
keyGenerator?: (...args: Parameters<T>) => string;
}
export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
options: InFlightWithKeyOptions<T> = {},
): MethodDecorator {
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
return function (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = async function (
this: any,
...args: Parameters<T>
): Promise<ReturnType<T>> {
// Initialize map for this instance if needed
if (!inFlightMap.has(this)) {
inFlightMap.set(this, new Map());
}
const instanceMap = inFlightMap.get(this)!;
// Generate cache key
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
// Check if there's already an in-flight request for this key
const existingRequest = instanceMap.get(key);
if (existingRequest) {
return existingRequest;
}
// Create new request and store it
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
// Clean up after successful completion
instanceMap.delete(key);
return result;
})
.catch((error: any) => {
// Clean up after error
instanceMap.delete(key);
throw error;
});
instanceMap.set(key, promise);
return promise;
};
return descriptor;
};
}
/**
* Decorator that prevents multiple simultaneous calls to the same async method
* with additional caching capabilities.
*
* @param options Configuration options for the decorator
* @example
* ```typescript
* class DataService {
* @InFlightWithCache({
* cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
* keyGenerator: (params: QueryParams) => params.query
* })
* async searchData(params: QueryParams): Promise<SearchResult> {
* return await api.search(params);
* }
* }
* ```
*/
export interface InFlightWithCacheOptions<T extends (...args: any[]) => any> {
/**
* Generate a cache key from the method arguments.
* If not provided, uses JSON.stringify on all arguments.
*/
keyGenerator?: (...args: Parameters<T>) => string;
/**
* Time in milliseconds to keep the result cached after completion.
* If not provided, result is not cached after completion.
*/
cacheTime?: number;
}
export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
options: InFlightWithCacheOptions<T> = {},
): MethodDecorator {
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
const cacheMap = new WeakMap<
object,
Map<string, { result: any; expiry: number }>
>();
return function (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = async function (
this: any,
...args: Parameters<T>
): Promise<ReturnType<T>> {
// Initialize maps for this instance if needed
if (!inFlightMap.has(this)) {
inFlightMap.set(this, new Map());
cacheMap.set(this, new Map());
}
const instanceInFlight = inFlightMap.get(this)!;
const instanceCache = cacheMap.get(this)!;
// Generate cache key
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
// Check cache first (if cacheTime is set)
if (options.cacheTime) {
const cached = instanceCache.get(key);
if (cached && cached.expiry > Date.now()) {
return Promise.resolve(cached.result);
}
// Clean up expired cache entry
if (cached) {
instanceCache.delete(key);
}
}
// Check if there's already an in-flight request
const existingRequest = instanceInFlight.get(key);
if (existingRequest) {
return existingRequest;
}
// Create new request
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
// Cache result if cacheTime is set
if (options.cacheTime) {
instanceCache.set(key, {
result,
expiry: Date.now() + options.cacheTime,
});
}
return result;
})
.finally(() => {
// Always clean up in-flight request
instanceInFlight.delete(key);
});
instanceInFlight.set(key, promise);
return promise;
};
return descriptor;
};
}

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/common/decorators',
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/common/decorators',
provider: 'v8' as const,
},
},
}));

View File

@@ -1,2 +1,3 @@
export * from './lib/services';
export * from './lib/schemas';
export * from './lib/models';

View File

@@ -1,4 +1 @@
export const ASSIGNED_STOCK_STORAGE_KEY =
'd8a11dd9-1f32-4646-881d-6ec856cbe9d0';
export const SUPPLIER_STORAGE_KEY = '48872c78-ad7f-455d-b775-07b00920f80d';

View File

@@ -1,11 +1,14 @@
export * from './remission-list-category';
export * from './key-value-string-and-string';
export * from './price-value';
export * from './price';
export * from './product';
export * from './return-item';
export * from './stock';
export * from './stock-info';
export * from './supplier';
export * from './query-settings';
export * from './key-value-string-and-string';
export * from './receipt-item';
export * from './receipt';
export * from './remission-list-category';
export * from './return-item';
export * from './return-suggestion';
export * from './return';
export * from './stock-info';
export * from './stock';
export * from './supplier';

View File

@@ -1,3 +1,3 @@
import { KeyValueDTOOfStringAndString } from '@generated/swagger/inventory-api';
export interface KeyValueStringAndString extends KeyValueDTOOfStringAndString {}
export type KeyValueStringAndString = KeyValueDTOOfStringAndString;

View File

@@ -1,3 +1,3 @@
import { QuerySettingsDTO } from '@generated/swagger/inventory-api';
export interface QuerySettings extends QuerySettingsDTO {}
export type QuerySettings = QuerySettingsDTO;

View File

@@ -0,0 +1,35 @@
import { ReceiptItemDTO } from '@generated/swagger/inventory-api';
import { Product } from './product';
/**
* Represents an individual item within a remission return receipt.
* Extends the base ReceiptItemDTO with additional product information.
*
* @interface ReceiptItem
* @extends {ReceiptItemDTO}
*
* @example
* const receiptItem: ReceiptItem = {
* id: 123,
* product: {
* id: 456,
* name: 'Sample Product',
* // ... other product properties
* },
* // ... other ReceiptItemDTO properties
* };
*/
export interface ReceiptItem extends ReceiptItemDTO {
/**
* Unique identifier for the receipt item.
* @type {number}
*/
id: number;
/**
* The product associated with this receipt item.
* Contains detailed product information including name, SKU, and other attributes.
* @type {Product}
*/
product: Product;
}

View File

@@ -0,0 +1,44 @@
import { ReceiptDTO } from '@generated/swagger/inventory-api';
import { EntityContainer } from '@isa/common/data-access';
import { ReceiptItem } from './receipt-item';
/**
* Represents a remission return receipt containing multiple receipt items.
* Extends the base ReceiptDTO with additional properties for local processing.
*
* @interface Receipt
* @extends {ReceiptDTO}
*
* @example
* const receipt: Receipt = {
* id: 1001,
* receiptNumber: 'RR-2024-001',
* items: [
* { id: 123, data: receiptItem1 }, // When eager loading is active
* { id: 124, data: undefined } // When only ID is available
* ],
* // ... other ReceiptDTO properties
* };
*/
export interface Receipt extends ReceiptDTO {
/**
* Unique identifier for the receipt.
* @type {number}
*/
id: number;
/**
* The receipt number used for tracking and reference.
* Typically follows a standardized format (e.g., 'RR-YYYY-###').
* @type {string}
*/
receiptNumber: string;
/**
* Collection of receipt items wrapped in EntityContainer.
* Each container holds an ID and optionally the resolved ReceiptItem data
* when eager loading is active.
* @type {EntityContainer<ReceiptItem>[]}
*/
items: EntityContainer<ReceiptItem>[];
}

View File

@@ -0,0 +1,36 @@
import { ReturnDTO } from '@generated/swagger/inventory-api';
import { EntityContainer } from '@isa/common/data-access';
import { Receipt } from './receipt';
/**
* Represents a remission return containing multiple receipts.
* A return groups related receipts together for processing and tracking.
*
* @interface Return
* @extends {ReturnDTO}
*
* @example
* const returnEntity: Return = {
* id: 5001,
* receipts: [
* { id: 101, data: receipt1 }, // When eager loading is active
* { id: 102, data: undefined } // When only ID is available
* ],
* // ... other ReturnDTO properties like status, date, supplier info
* };
*/
export interface Return extends ReturnDTO {
/**
* Unique identifier for the return.
* @type {number}
*/
id: number;
/**
* Collection of receipts associated with this return.
* Each container holds an ID and optionally the resolved Receipt data
* when eager loading is active.
* @type {EntityContainer<Receipt>[]}
*/
receipts: EntityContainer<Receipt>[];
}

View File

@@ -1,3 +1,3 @@
import { StockInfoDTO } from '@generated/swagger/inventory-api';
export interface StockInfo extends StockInfoDTO {}
export type StockInfo = StockInfoDTO;

View File

@@ -1,3 +1,25 @@
import { StockDTO } from '@generated/swagger/inventory-api';
export interface Stock extends StockDTO {}
/**
* Represents stock information for products in the remission system.
* Extends the base StockDTO with a unique identifier.
*
* @interface Stock
* @extends {StockDTO}
*
* @example
* const stock: Stock = {
* id: 101,
* quantity: 150,
* availableQuantity: 120,
* reservedQuantity: 30,
* // ... other StockDTO properties like location, warehouse info
* };
*/
export interface Stock extends StockDTO {
/**
* Unique identifier for the stock entry.
* @type {number}
*/
id: number;
}

View File

@@ -1,3 +1,3 @@
import { SupplierDTO } from '@generated/swagger/inventory-api';
export interface Supplier extends SupplierDTO {}
export type Supplier = SupplierDTO;

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
export const FetchProductGroupsSchema = z.object({
assignedStockId: z.number(),
});
export type FetchProductGroups = z.infer<typeof FetchProductGroupsSchema>;

View File

@@ -0,0 +1,51 @@
import { z } from 'zod';
/**
* Zod schema for validating remission return receipt fetch parameters.
* Ensures both receiptId and returnId are valid numbers.
*
* @constant
* @type {z.ZodObject}
*
* @example
* const params = FetchRemissionReturnReceiptSchema.parse({
* receiptId: '123',
* returnId: '456'
* });
* // Result: { receiptId: 123, returnId: 456 }
*/
export const FetchRemissionReturnReceiptSchema = z.object({
/**
* The receipt identifier - coerced to number for flexibility.
*/
receiptId: z.coerce.number(),
/**
* The return identifier - coerced to number for flexibility.
*/
returnId: z.coerce.number(),
});
/**
* Type representing the parsed output of FetchRemissionReturnReceiptSchema.
* Contains validated and coerced receiptId and returnId as numbers.
*
* @typedef {Object} FetchRemissionReturnReceipt
* @property {number} receiptId - The validated receipt identifier
* @property {number} returnId - The validated return identifier
*/
export type FetchRemissionReturnReceipt = z.infer<
typeof FetchRemissionReturnReceiptSchema
>;
/**
* Type representing the input parameters for FetchRemissionReturnReceiptSchema.
* Accepts string or number values that can be coerced to numbers.
*
* @typedef {Object} FetchRemissionReturnParams
* @property {string | number} receiptId - The receipt identifier (can be string or number)
* @property {string | number} returnId - The return identifier (can be string or number)
*/
export type FetchRemissionReturnParams = z.input<
typeof FetchRemissionReturnReceiptSchema
>;

View File

@@ -1,7 +0,0 @@
import { z } from 'zod';
export const FetchSuppliersSchema = z.object({
assignedStockId: z.number(),
});
export type FetchSuppliers = z.infer<typeof FetchSuppliersSchema>;

View File

@@ -1,6 +1,5 @@
export * from './fetch-product-groups.schema';
export * from './fetch-query-settings.schema';
export * from './fetch-remission-return-receipt.schema';
export * from './fetch-return-reason.schema';
export * from './fetch-stock-in-stock.schema';
export * from './fetch-suppliers.schema';
export * from './query-token.schema';

View File

@@ -1,5 +1,6 @@
export * from './remission-product-group.service';
export * from './remission-reason.service';
export * from './remission-return-receipt.service';
export * from './remission-search.service';
export * from './remission-stock.service';
export * from './remission-supplier.service';

View File

@@ -1,28 +1,94 @@
import { inject, Injectable } from '@angular/core';
import { RemiService } from '@generated/swagger/inventory-api';
import { FetchProductGroups, FetchProductGroupsSchema } from '../schemas';
import { KeyValueStringAndString } from '../models';
import { firstValueFrom } from 'rxjs';
import { logger } from '@isa/core/logging';
import { RemissionStockService } from './remission-stock.service';
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
import {
DataAccessError,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { InFlightWithCache } from '@isa/common/decorators';
/**
* Service responsible for managing remission product groups.
* Handles fetching product group data from the remission API.
*
* @class RemissionProductGroupService
* @injectable
*
* @example
* // Inject the service
* constructor(private productGroupService: RemissionProductGroupService) {}
*
* // Fetch product groups for a stock
* const groups = await this.productGroupService.fetchProductGroups({
* assignedStockId: 'stock123'
* });
*/
@Injectable({ providedIn: 'root' })
export class RemissionProductGroupService {
#remiService = inject(RemiService);
#stockService = inject(RemissionStockService);
#logger = logger(() => ({ service: 'RemissionProductGroupService' }));
/**
* Fetches all available product groups for the specified stock.
* Validates input parameters using FetchProductGroupsSchema.
*
* @async
* @param {FetchProductGroups} params - Parameters for the product groups query
* @param {string} params.assignedStockId - ID of the assigned stock
* @returns {Promise<KeyValueStringAndString[]>} Array of key-value pairs representing product groups
* @throws {Error} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* try {
* const productGroups = await service.fetchProductGroups({
* assignedStockId: 'stock123'
* });
* productGroups.forEach(group => {
* console.log(`${group.key}: ${group.value}`);
* });
* } catch (error) {
* console.error('Failed to fetch product groups:', error);
* }
*/
@InFlightWithCache()
async fetchProductGroups(
params: FetchProductGroups,
abortSignal?: AbortSignal,
): Promise<KeyValueStringAndString[]> {
const parsed = FetchProductGroupsSchema.parse(params);
this.#logger.debug('Fetching product groups');
const req$ = this.#remiService.RemiProductgroups({
stockId: parsed.assignedStockId,
const assignedStock = await this.#stockService.fetchAssignedStock();
this.#logger.info('Fetching product groups from API', () => ({
stockId: assignedStock.id,
}));
let req$ = this.#remiService.RemiProductgroups({
stockId: assignedStock.id,
});
if (abortSignal) {
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch Product Groups');
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch product groups', error);
throw error;
}
this.#logger.debug('Successfully fetched product groups', () => ({
groupCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
}
}

View File

@@ -4,29 +4,82 @@ import { FetchReturnReasonParams, FetchReturnReasonSchema } from '../schemas';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { KeyValueStringAndString } from '../models';
import { logger } from '@isa/core/logging';
/**
* Service responsible for managing remission return reasons.
* Handles fetching return reason data from the inventory API.
*
* @class RemissionReasonService
* @injectable
*
* @example
* // Inject the service
* constructor(private reasonService: RemissionReasonService) {}
*
* // Fetch return reasons for a stock
* const reasons = await this.reasonService.fetchReturnReasons({
* stockId: 'stock123'
* });
*/
@Injectable({ providedIn: 'root' })
export class RemissionReasonService {
#returnService = inject(ReturnService);
#logger = logger(() => ({ service: 'RemissionReasonService' }));
/**
* Fetches all available return reasons for the specified stock.
* Validates input parameters using FetchReturnReasonSchema.
*
* @async
* @param {FetchReturnReasonParams} params - Parameters for the return reasons query
* @param {string} params.stockId - ID of the stock to fetch reasons for
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<KeyValueStringAndString[]>} Array of key-value pairs representing return reasons
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const controller = new AbortController();
* try {
* const reasons = await service.fetchReturnReasons(
* { stockId: 'stock123' },
* controller.signal
* );
* reasons.forEach(reason => {
* console.log(`${reason.key}: ${reason.value}`);
* });
* } catch (error) {
* console.error('Failed to fetch return reasons:', error);
* }
*/
async fetchReturnReasons(
params: FetchReturnReasonParams,
abortSignal?: AbortSignal,
): Promise<KeyValueStringAndString[]> {
this.#logger.debug('Fetching return reasons', () => ({ params }));
const { stockId } = FetchReturnReasonSchema.parse(params);
this.#logger.info('Fetching return reasons from API', () => ({ stockId }));
let req$ = this.#returnService.ReturnGetReturnReasons({ stockId });
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error) {
this.#logger.error('Failed to fetch return reasons', new Error(res.message || 'Unknown error'));
throw new ResponseArgsError(res);
}
this.#logger.debug('Successfully fetched return reasons', () => ({
reasonCount: res.result?.length || 0
}));
return res.result as KeyValueStringAndString[];
}
}

View File

@@ -0,0 +1,415 @@
import { TestBed } from '@angular/core/testing';
import { RemissionReturnReceiptService } from './remission-return-receipt.service';
import { ReturnService } from '@generated/swagger/inventory-api';
import { RemissionStockService } from './remission-stock.service';
import { ResponseArgsError } from '@isa/common/data-access';
import { Return, Stock, Receipt } from '../models';
import { subDays } from 'date-fns';
import { of, throwError } from 'rxjs';
jest.mock('@generated/swagger/inventory-api', () => ({
ReturnService: jest.fn(),
}));
jest.mock('./remission-stock.service');
describe('RemissionReturnReceiptService', () => {
let service: RemissionReturnReceiptService;
let mockReturnService: {
ReturnQueryReturns: jest.Mock;
ReturnGetReturnReceipt: jest.Mock;
};
let mockRemissionStockService: {
fetchAssignedStock: jest.Mock;
};
const mockStock: Stock = {
id: 123,
name: 'Test Stock',
description: 'Test Description',
} as Stock;
const mockReturns: Return[] = [
{
id: 1,
receipts: [
{
id: 101,
data: {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return,
{
id: 2,
receipts: [
{
id: 102,
data: {
id: 102,
receiptNumber: 'REC-2024-002',
completed: undefined,
created: '2024-01-16T13:00:00.000Z',
items: [],
} as Receipt,
},
],
} as unknown as Return,
];
beforeEach(() => {
mockReturnService = {
ReturnQueryReturns: jest.fn(),
ReturnGetReturnReceipt: jest.fn(),
};
mockRemissionStockService = {
fetchAssignedStock: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
RemissionReturnReceiptService,
{ provide: ReturnService, useValue: mockReturnService },
{ provide: RemissionStockService, useValue: mockRemissionStockService },
],
});
service = TestBed.inject(RemissionReturnReceiptService);
});
describe('fetchCompletedRemissionReturnReceipts', () => {
beforeEach(() => {
mockRemissionStockService.fetchAssignedStock.mockResolvedValue(mockStock);
});
it('should fetch completed return receipts successfully', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: mockReturns, error: null }),
);
const result = await service.fetchCompletedRemissionReturnReceipts();
expect(result).toEqual(mockReturns);
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
undefined,
);
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalledWith({
stockId: 123,
queryToken: {
input: { returncompleted: 'true' },
start: expect.any(String),
eagerLoading: 3,
},
});
});
it('should use correct date range (7 days ago)', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: mockReturns, error: null }),
);
await service.fetchCompletedRemissionReturnReceipts();
const callArgs = mockReturnService.ReturnQueryReturns.mock.calls[0][0];
const startDate = new Date(callArgs.queryToken.start);
const expectedDate = subDays(new Date(), 7);
// Check that dates are within 1 second of each other (to handle timing differences)
expect(
Math.abs(startDate.getTime() - expectedDate.getTime()),
).toBeLessThan(1000);
});
it('should handle abort signal', async () => {
const abortController = new AbortController();
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: mockReturns, error: null }),
);
await service.fetchCompletedRemissionReturnReceipts(
abortController.signal,
);
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
abortController.signal,
);
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalled();
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnQueryReturns.mockReturnValue(of(errorResponse));
await expect(
service.fetchCompletedRemissionReturnReceipts(),
).rejects.toThrow(ResponseArgsError);
});
it('should return empty array when result is null', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: null, error: null }),
);
const result = await service.fetchCompletedRemissionReturnReceipts();
expect(result).toEqual([]);
});
it('should return empty array when result is undefined', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(of({ error: null }));
const result = await service.fetchCompletedRemissionReturnReceipts();
expect(result).toEqual([]);
});
it('should handle stock service errors', async () => {
mockRemissionStockService.fetchAssignedStock.mockRejectedValue(
new Error('Stock error'),
);
await expect(
service.fetchCompletedRemissionReturnReceipts(),
).rejects.toThrow('Stock error');
expect(mockReturnService.ReturnQueryReturns).not.toHaveBeenCalled();
});
});
describe('fetchIncompletedRemissionReturnReceipts', () => {
beforeEach(() => {
mockRemissionStockService.fetchAssignedStock.mockResolvedValue(mockStock);
});
it('should fetch incompleted return receipts successfully', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: mockReturns, error: null }),
);
const result = await service.fetchIncompletedRemissionReturnReceipts();
expect(result).toEqual(mockReturns);
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
undefined,
);
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalledWith({
stockId: 123,
queryToken: {
input: { returncompleted: 'false' },
start: expect.any(String),
eagerLoading: 3,
},
});
});
it('should use correct date range (7 days ago)', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: mockReturns, error: null }),
);
await service.fetchIncompletedRemissionReturnReceipts();
const callArgs = mockReturnService.ReturnQueryReturns.mock.calls[0][0];
const startDate = new Date(callArgs.queryToken.start);
const expectedDate = subDays(new Date(), 7);
// Check that dates are within 1 second of each other (to handle timing differences)
expect(
Math.abs(startDate.getTime() - expectedDate.getTime()),
).toBeLessThan(1000);
});
it('should handle abort signal', async () => {
const abortController = new AbortController();
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: mockReturns, error: null }),
);
await service.fetchIncompletedRemissionReturnReceipts(
abortController.signal,
);
expect(mockRemissionStockService.fetchAssignedStock).toHaveBeenCalledWith(
abortController.signal,
);
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalled();
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnQueryReturns.mockReturnValue(of(errorResponse));
await expect(
service.fetchIncompletedRemissionReturnReceipts(),
).rejects.toThrow(ResponseArgsError);
});
it('should return empty array when result is null', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: null, error: null }),
);
const result = await service.fetchIncompletedRemissionReturnReceipts();
expect(result).toEqual([]);
});
it('should return empty array when result is undefined', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(of({ error: null }));
const result = await service.fetchIncompletedRemissionReturnReceipts();
expect(result).toEqual([]);
});
it('should handle stock service errors', async () => {
mockRemissionStockService.fetchAssignedStock.mockRejectedValue(
new Error('Stock error'),
);
await expect(
service.fetchIncompletedRemissionReturnReceipts(),
).rejects.toThrow('Stock error');
expect(mockReturnService.ReturnQueryReturns).not.toHaveBeenCalled();
});
it('should handle observable errors', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
throwError(() => new Error('Observable error')),
);
await expect(
service.fetchIncompletedRemissionReturnReceipts(),
).rejects.toThrow('Observable error');
});
});
describe('edge cases', () => {
beforeEach(() => {
mockRemissionStockService.fetchAssignedStock.mockResolvedValue(mockStock);
});
it('should handle empty returns array', async () => {
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: [], error: null }),
);
const completedResult =
await service.fetchCompletedRemissionReturnReceipts();
const incompletedResult =
await service.fetchIncompletedRemissionReturnReceipts();
expect(completedResult).toEqual([]);
expect(incompletedResult).toEqual([]);
});
it('should handle stock with no id', async () => {
mockRemissionStockService.fetchAssignedStock.mockResolvedValue({
...mockStock,
id: undefined,
});
mockReturnService.ReturnQueryReturns.mockReturnValue(
of({ result: mockReturns, error: null }),
);
await service.fetchCompletedRemissionReturnReceipts();
expect(mockReturnService.ReturnQueryReturns).toHaveBeenCalledWith({
stockId: undefined,
queryToken: expect.any(Object),
});
});
});
describe('fetchRemissionReturnReceipt', () => {
const mockReceipt: Receipt = {
id: 101,
receiptNumber: 'REC-2024-001',
completed: '2024-01-15T10:30:00.000Z',
created: '2024-01-15T09:00:00.000Z',
items: [],
} as Receipt;
beforeEach(() => {
mockReturnService.ReturnGetReturnReceipt = jest.fn();
});
it('should fetch return receipt successfully', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ result: mockReceipt, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
expect(result).toEqual(mockReceipt);
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalledWith({
receiptId: 101,
returnId: 1,
eagerLoading: 2,
});
});
it('should handle abort signal', async () => {
const abortController = new AbortController();
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ result: mockReceipt, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
await service.fetchRemissionReturnReceipt(params, abortController.signal);
expect(mockReturnService.ReturnGetReturnReceipt).toHaveBeenCalled();
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of(errorResponse),
);
const params = { receiptId: 101, returnId: 1 };
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
ResponseArgsError,
);
});
it('should return null when result is null', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ result: null, error: null }),
);
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
expect(result).toBeNull();
});
it('should return undefined when result is undefined', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
of({ error: null }),
);
const params = { receiptId: 101, returnId: 1 };
const result = await service.fetchRemissionReturnReceipt(params);
expect(result).toBeUndefined();
});
it('should handle observable errors', async () => {
mockReturnService.ReturnGetReturnReceipt.mockReturnValue(
throwError(() => new Error('Observable error')),
);
const params = { receiptId: 101, returnId: 1 };
await expect(service.fetchRemissionReturnReceipt(params)).rejects.toThrow(
'Observable error',
);
});
});
});

View File

@@ -0,0 +1,208 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { subDays } from 'date-fns';
import { firstValueFrom } from 'rxjs';
import { RemissionStockService } from './remission-stock.service';
import { Return } from '../models/return';
import {
FetchRemissionReturnParams,
FetchRemissionReturnReceiptSchema,
} from '../schemas';
import { Receipt } from '../models';
import { logger } from '@isa/core/logging';
/**
* Service responsible for managing remission return receipts.
* Handles fetching completed and incomplete return receipts from the inventory API.
*
* @class RemissionReturnReceiptService
* @injectable
*
* @example
* // Inject the service
* constructor(private remissionReturnReceiptService: RemissionReturnReceiptService) {}
*
* // Fetch completed receipts
* const completedReceipts = await this.remissionReturnReceiptService
* .fetchCompletedRemissionReturnReceipts();
*/
@Injectable({ providedIn: 'root' })
export class RemissionReturnReceiptService {
/** Private instance of the inventory return service */
#returnService = inject(ReturnService);
/** Private instance of the remission stock service */
#remissionStockService = inject(RemissionStockService);
/** Private logger instance */
#logger = logger(() => ({ service: 'RemissionReturnReceiptService' }));
/**
* Fetches all completed remission return receipts for the assigned stock.
* Returns receipts marked as completed within the last 7 days.
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return[]>} Array of completed return objects with receipts
* @throws {ResponseArgsError} When the API request fails
*
* @example
* const controller = new AbortController();
* const completedReturns = await service
* .fetchCompletedRemissionReturnReceipts(controller.signal);
*/
async fetchCompletedRemissionReturnReceipts(
abortSignal?: AbortSignal,
): Promise<Return[]> {
this.#logger.debug('Fetching completed remission return receipts');
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching completed returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 7).toISOString()
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
input: { returncompleted: 'true' },
start: subDays(new Date(), 7).toISOString(),
eagerLoading: 3,
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch completed returns', new Error(res.message || 'Unknown error'));
throw new ResponseArgsError(res);
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched completed returns', () => ({
returnCount: returns.length
}));
return returns;
}
/**
* Fetches all incomplete remission return receipts for the assigned stock.
* Returns receipts not yet marked as completed within the last 7 days.
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return[]>} Array of incomplete return objects with receipts
* @throws {ResponseArgsError} When the API request fails
*
* @example
* const incompleteReturns = await service
* .fetchIncompletedRemissionReturnReceipts();
*/
async fetchIncompletedRemissionReturnReceipts(
abortSignal?: AbortSignal,
): Promise<Return[]> {
this.#logger.debug('Fetching incomplete remission return receipts');
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching incomplete returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 7).toISOString()
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
input: { returncompleted: 'false' },
start: subDays(new Date(), 7).toISOString(),
eagerLoading: 3,
},
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch incomplete returns', new Error(res.message || 'Unknown error'));
throw new ResponseArgsError(res);
}
const returns = (res?.result as Return[]) || [];
this.#logger.debug('Successfully fetched incomplete returns', () => ({
returnCount: returns.length
}));
return returns;
}
/**
* Fetches a specific remission return receipt by receipt and return IDs.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
*
* @async
* @param {FetchRemissionReturnParams} params - The receipt and return identifiers
* @param {FetchRemissionReturnParams} params.receiptId - ID of the receipt to fetch
* @param {FetchRemissionReturnParams} params.returnId - ID of the return containing the receipt
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Receipt | undefined>} The receipt object if found, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const receipt = await service.fetchRemissionReturnReceipt({
* receiptId: '123',
* returnId: '456'
* });
*/
async fetchRemissionReturnReceipt(
params: FetchRemissionReturnParams,
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
this.#logger.debug('Fetching remission return receipt', () => ({ params }));
const { receiptId, returnId } =
FetchRemissionReturnReceiptSchema.parse(params);
this.#logger.info('Fetching return receipt from API', () => ({
receiptId,
returnId
}));
let req$ = this.#returnService.ReturnGetReturnReceipt({
receiptId,
returnId,
eagerLoading: 2,
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch return receipt', new Error(res.message || 'Unknown error'));
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
this.#logger.debug('Successfully fetched return receipt', () => ({
found: !!receipt
}));
return receipt;
}
}

View File

@@ -15,15 +15,52 @@ import {
} from '../schemas';
import { firstValueFrom } from 'rxjs';
import { ListResponseArgs } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
/**
* Service responsible for remission search operations.
* Handles fetching remission lists, query settings, and managing remission list types.
*
* @class RemissionSearchService
* @injectable
*
* @example
* // Inject the service
* constructor(private searchService: RemissionSearchService) {}
*
* // Get available remission list types
* const listTypes = this.searchService.remissionListType();
*
* // Fetch remission list
* const items = await this.searchService.fetchList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 20,
* skip: 0
* });
*/
@Injectable({ providedIn: 'root' })
export class RemissionSearchService {
#remiService = inject(RemiService);
#logger = logger(() => ({ service: 'RemissionSearchService' }));
remissionListType(): {
/**
* Returns all available remission list types as key-value pairs.
* This method provides a mapping between RemissionListTypeKey and RemissionListType values.
*
* @returns {Array<{key: RemissionListTypeKey, value: RemissionListType}>} Array of remission list type mappings
*
* @example
* const types = service.remissionListType();
* types.forEach(type => {
* console.log(`Type ${type.key}: ${type.value}`);
* });
*/
remissionListType(): Array<{
key: RemissionListTypeKey;
value: RemissionListType;
}[] {
}> {
this.#logger.debug('Getting remission list types');
return (Object.keys(RemissionListType) as RemissionListTypeKey[]).map(
(key) => ({
key,
@@ -32,9 +69,33 @@ export class RemissionSearchService {
);
}
/**
* Fetches query settings for mandatory remission articles.
* Validates input parameters using FetchQuerySettingsSchema.
*
* @async
* @param {FetchQuerySettings} params - Parameters for the query settings request
* @param {string} params.supplierId - ID of the supplier
* @param {string} params.assignedStockId - ID of the assigned stock
* @returns {Promise<QuerySettings>} Query settings for the specified supplier and stock
* @throws {Error} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const settings = await service.fetchQuerySettings({
* supplierId: 'supplier123',
* assignedStockId: 'stock456'
* });
*/
async fetchQuerySettings(params: FetchQuerySettings): Promise<QuerySettings> {
this.#logger.debug('Fetching query settings', () => ({ params }));
const parsed = FetchQuerySettingsSchema.parse(params);
this.#logger.info('Fetching query settings from API', () => ({
supplierId: parsed.supplierId,
stockId: parsed.assignedStockId
}));
const req$ = this.#remiService.RemiPflichtremissionsartikelSettings({
supplierId: parsed.supplierId,
stockId: parsed.assignedStockId,
@@ -43,17 +104,44 @@ export class RemissionSearchService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch Query Settings');
const error = new Error(res.message || 'Failed to fetch Query Settings');
this.#logger.error('Failed to fetch query settings', error);
throw error;
}
this.#logger.debug('Successfully fetched query settings');
return res.result as QuerySettings;
}
/**
* Fetches query settings for department overflow remission articles.
* Validates input parameters using FetchQuerySettingsSchema.
*
* @async
* @param {FetchQuerySettings} params - Parameters for the department query settings request
* @param {string} params.supplierId - ID of the supplier
* @param {string} params.assignedStockId - ID of the assigned stock
* @returns {Promise<QuerySettings>} Department query settings for the specified supplier and stock
* @throws {Error} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const departmentSettings = await service.fetchQueryDepartmentSettings({
* supplierId: 'supplier123',
* assignedStockId: 'stock456'
* });
*/
async fetchQueryDepartmentSettings(
params: FetchQuerySettings,
): Promise<QuerySettings> {
this.#logger.debug('Fetching department query settings', () => ({ params }));
const parsed = FetchQuerySettingsSchema.parse(params);
this.#logger.info('Fetching department query settings from API', () => ({
supplierId: parsed.supplierId,
stockId: parsed.assignedStockId
}));
const req$ = this.#remiService.RemiUeberlaufSettings({
supplierId: parsed.supplierId,
stockId: parsed.assignedStockId,
@@ -62,20 +150,60 @@ export class RemissionSearchService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(
const error = new Error(
res.message || 'Failed to fetch Query Department Settings',
);
this.#logger.error('Failed to fetch department query settings', error);
throw error;
}
this.#logger.debug('Successfully fetched department query settings');
return res.result as QuerySettings;
}
/**
* Fetches a paginated list of mandatory remission return items.
* Validates input parameters using RemissionQueryTokenSchema.
*
* @async
* @param {RemissionQueryTokenInput} params - Query parameters for the list request
* @param {string} params.assignedStockId - ID of the assigned stock
* @param {string} params.supplierId - ID of the supplier
* @param {string} [params.filter] - Optional filter string
* @param {Object} [params.input] - Optional input parameters for filtering
* @param {string} [params.orderBy] - Optional field to order results by
* @param {number} [params.take] - Number of items to fetch (pagination)
* @param {number} [params.skip] - Number of items to skip (pagination)
* @returns {Promise<ListResponseArgs<ReturnItem>>} Paginated list response with return items
* @throws {Error} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const response = await service.fetchList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 20,
* skip: 0,
* orderBy: 'itemName'
* });
* console.log(`Total items: ${response.totalCount}`);
*
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
*/
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
async fetchList(
params: RemissionQueryTokenInput,
): Promise<ListResponseArgs<ReturnItem>> {
this.#logger.debug('Fetching remission list', () => ({ params }));
const parsed = RemissionQueryTokenSchema.parse(params);
this.#logger.info('Fetching remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: parsed.take,
skip: parsed.skip
}));
const req$ = this.#remiService.RemiPflichtremissionsartikel({
queryToken: {
stockId: parsed.assignedStockId,
@@ -91,18 +219,60 @@ export class RemissionSearchService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch Remission List');
const error = new Error(res.message || 'Failed to fetch Remission List');
this.#logger.error('Failed to fetch remission list', error);
throw error;
}
this.#logger.debug('Successfully fetched remission list', () => ({
itemCount: res.result?.length || 0,
totalCount: (res as any).totalCount
}));
return res as ListResponseArgs<ReturnItem>;
}
/**
* Fetches a paginated list of department overflow remission suggestions.
* Validates input parameters using RemissionQueryTokenSchema.
*
* @async
* @param {RemissionQueryTokenInput} params - Query parameters for the department list request
* @param {string} params.assignedStockId - ID of the assigned stock
* @param {string} params.supplierId - ID of the supplier
* @param {string} [params.filter] - Optional filter string
* @param {Object} [params.input] - Optional input parameters for filtering
* @param {string} [params.orderBy] - Optional field to order results by
* @param {number} [params.take] - Number of items to fetch (pagination)
* @param {number} [params.skip] - Number of items to skip (pagination)
* @returns {Promise<ListResponseArgs<ReturnSuggestion>>} Paginated list response with return suggestions
* @throws {Error} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const departmentResponse = await service.fetchDepartmentList({
* assignedStockId: 'stock123',
* supplierId: 'supplier456',
* take: 50,
* skip: 0
* });
*
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
*/
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
async fetchDepartmentList(
params: RemissionQueryTokenInput,
): Promise<ListResponseArgs<ReturnSuggestion>> {
this.#logger.debug('Fetching department remission list', () => ({ params }));
const parsed = RemissionQueryTokenSchema.parse(params);
this.#logger.info('Fetching department remission list from API', () => ({
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
take: parsed.take,
skip: parsed.skip
}));
const req$ = this.#remiService.RemiUeberlauf({
queryToken: {
stockId: parsed.assignedStockId,
@@ -118,11 +288,18 @@ export class RemissionSearchService {
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(
const error = new Error(
res.message || 'Failed to fetch Remission Department List',
);
this.#logger.error('Failed to fetch department remission list', error);
throw error;
}
this.#logger.debug('Successfully fetched department remission list', () => ({
suggestionCount: res.result?.length || 0,
totalCount: (res as any).totalCount
}));
return res as ListResponseArgs<ReturnSuggestion>;
}
}

View File

@@ -0,0 +1,245 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { RemissionStockService } from './remission-stock.service';
import { StockService } from '@generated/swagger/inventory-api';
import { ResponseArgsError } from '@isa/common/data-access';
import { Stock, StockInfo } from '../models';
jest.mock('@generated/swagger/inventory-api', () => ({
StockService: jest.fn(),
}));
/**
* Unit tests for RemissionStockService.
* Tests the service's ability to fetch and cache stock information.
*
* @group unit
* @group services
*/
describe('RemissionStockService', () => {
let service: RemissionStockService;
let mockStockService: {
StockCurrentStock: jest.Mock;
StockInStock: jest.Mock;
};
const mockStock: Stock = {
id: 123,
name: 'Test Stock',
description: 'Test Description',
} as Stock;
const mockStockInfo: StockInfo[] = [
{
id: 1,
itemId: 100,
quantity: 10,
stockId: 123,
} as StockInfo,
{
id: 2,
itemId: 101,
quantity: 5,
stockId: 123,
} as StockInfo,
];
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
mockStockService = {
StockCurrentStock: jest.fn(),
StockInStock: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
RemissionStockService,
{ provide: StockService, useValue: mockStockService },
],
});
service = TestBed.inject(RemissionStockService);
});
/**
* Tests for fetchAssignedStock method.
* Verifies caching behavior and API interaction.
*/
describe('fetchAssignedStock', () => {
it('should fetch stock from API', async () => {
mockStockService.StockCurrentStock.mockReturnValue(
of({ result: mockStock, error: null }),
);
const result = await service.fetchAssignedStock();
expect(result).toEqual(mockStock);
expect(mockStockService.StockCurrentStock).toHaveBeenCalled();
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockStockService.StockCurrentStock.mockReturnValue(of(errorResponse));
await expect(service.fetchAssignedStock()).rejects.toThrow(
ResponseArgsError,
);
});
it('should throw ResponseArgsError when API returns no result', async () => {
mockStockService.StockCurrentStock.mockReturnValue(
of({ error: null, result: null }),
);
await expect(service.fetchAssignedStock()).rejects.toThrow(
ResponseArgsError,
);
});
it('should handle abort signal', async () => {
const abortController = new AbortController();
const mockObservable = {
pipe: jest.fn().mockReturnThis(),
};
mockStockService.StockCurrentStock.mockReturnValue(mockObservable);
try {
await service.fetchAssignedStock(abortController.signal);
} catch {
// Expected to fail since we're not properly mocking the observable
}
expect(mockObservable.pipe).toHaveBeenCalled();
});
it('should handle API observable errors', async () => {
mockStockService.StockCurrentStock.mockReturnValue(
throwError(() => new Error('Network error')),
);
await expect(service.fetchAssignedStock()).rejects.toThrow(
'Network error',
);
});
});
/**
* Tests for fetchStock method.
* Verifies stock information fetching with item IDs.
*/
describe('fetchStock', () => {
/**
* Valid test parameters for stock fetching.
*/
const validParams = {
assignedStockId: 123,
itemIds: [100, 101],
};
it('should fetch stock info successfully', async () => {
mockStockService.StockInStock.mockReturnValue(
of({ result: mockStockInfo, error: null }),
);
const result = await service.fetchStock(validParams);
expect(result).toEqual(mockStockInfo);
expect(mockStockService.StockInStock).toHaveBeenCalledWith({
stockId: 123,
articleIds: [100, 101],
});
});
it('should throw ResponseArgsError when API returns error', async () => {
const errorResponse = { error: 'API Error', result: null };
mockStockService.StockInStock.mockReturnValue(of(errorResponse));
await expect(service.fetchStock(validParams)).rejects.toThrow(
ResponseArgsError,
);
});
it('should throw ResponseArgsError when API returns no result', async () => {
mockStockService.StockInStock.mockReturnValue(
of({ error: null, result: null }),
);
await expect(service.fetchStock(validParams)).rejects.toThrow(
ResponseArgsError,
);
});
it('should handle abort signal', async () => {
const abortController = new AbortController();
const mockObservable = {
pipe: jest.fn().mockReturnThis(),
};
mockStockService.StockInStock.mockReturnValue(mockObservable);
try {
await service.fetchStock(validParams, abortController.signal);
} catch {
// Expected to fail since we're not properly mocking the observable
}
expect(mockObservable.pipe).toHaveBeenCalled();
});
it('should validate params with schema', async () => {
// This will be validated by the schema
const invalidParams = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assignedStockId: 'invalid' as any, // Should be number
itemIds: [100, 101],
};
mockStockService.StockInStock.mockReturnValue(
of({ result: mockStockInfo, error: null }),
);
// The schema parsing should throw an error for invalid params
await expect(service.fetchStock(invalidParams)).rejects.toThrow();
});
it('should handle empty itemIds array', async () => {
const paramsWithEmptyItems = {
assignedStockId: 123,
itemIds: [],
};
mockStockService.StockInStock.mockReturnValue(
of({ result: [], error: null }),
);
const result = await service.fetchStock(paramsWithEmptyItems);
expect(result).toEqual([]);
expect(mockStockService.StockInStock).toHaveBeenCalledWith({
stockId: 123,
articleIds: [],
});
});
it('should handle API observable errors', async () => {
mockStockService.StockInStock.mockReturnValue(
throwError(() => new Error('Network error')),
);
await expect(service.fetchStock(validParams)).rejects.toThrow(
'Network error',
);
});
it('should return empty array when API returns empty result', async () => {
mockStockService.StockInStock.mockReturnValue(
of({ result: [], error: null }),
);
const result = await service.fetchStock(validParams);
expect(result).toEqual([]);
});
});
});

View File

@@ -3,61 +3,141 @@ import { StockService } from '@generated/swagger/inventory-api';
import { firstValueFrom } from 'rxjs';
import { Stock, StockInfo } from '../models';
import { FetchStockInStock, FetchStockInStockSchema } from '../schemas';
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
import { ASSIGNED_STOCK_STORAGE_KEY } from '../constants';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { InFlightWithCache } from '@isa/common/decorators';
/**
* Service responsible for managing remission stock operations.
* Handles fetching assigned stock and stock information from the inventory API.
*
* @class RemissionStockService
* @injectable
*
* @example
* // Inject the service
* constructor(private stockService: RemissionStockService) {}
*
* // Fetch assigned stock
* const assignedStock = await this.stockService.fetchAssignedStock();
*
* // Fetch stock info for specific items
* const stockInfo = await this.stockService.fetchStock({
* assignedStockId: 'stock123',
* itemIds: ['item1', 'item2']
* });
*/
@Injectable({ providedIn: 'root' })
export class RemissionStockService {
#stockService = inject(StockService);
#memoryStorage = injectStorage(MemoryStorageProvider);
#logger = logger(() => ({ service: 'RemissionStockService' }));
/**
* Fetches the currently assigned stock for the user.
* Results are cached in memory to reduce API calls.
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Stock>} The assigned stock object
* @throws {ResponseArgsError} When the API request fails or returns an error
*
* @example
* const controller = new AbortController();
* try {
* const stock = await service.fetchAssignedStock(controller.signal);
* console.log('Assigned stock:', stock.id);
* } catch (error) {
* console.error('Failed to fetch assigned stock:', error);
* }
*
* @todo Remove caching from data-access services
*/
@InFlightWithCache()
async fetchAssignedStock(abortSignal?: AbortSignal): Promise<Stock> {
// TODO: No caching in data-access services. Remove caching.
const cached = await this.#memoryStorage.get(ASSIGNED_STOCK_STORAGE_KEY);
if (cached) {
return cached as Stock;
}
const req$ = this.#stockService.StockCurrentStock();
this.#logger.info('Fetching assigned stock from API');
let req$ = this.#stockService.StockCurrentStock();
if (abortSignal) {
req$.pipe(takeUntilAborted(abortSignal));
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch assigned stock',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#memoryStorage.set(ASSIGNED_STOCK_STORAGE_KEY, res.result);
this.#logger.debug('Successfully fetched assigned stock', () => ({
stockId: res.result?.id,
}));
return res.result as Stock;
}
/**
* Fetches stock information for specific items in the assigned stock.
* Validates input parameters using FetchStockInStockSchema.
*
* @async
* @param {FetchStockInStock} params - Parameters for the stock query
* @param {string} params.assignedStockId - ID of the assigned stock
* @param {string[]} params.itemIds - Array of item IDs to fetch stock info for
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<StockInfo[]>} Array of stock information for the requested items
* @throws {ResponseArgsError} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const stockInfo = await service.fetchStock({
* assignedStockId: 'stock123',
* itemIds: ['item1', 'item2', 'item3']
* });
*
* stockInfo.forEach(info => {
* console.log(`Item ${info.itemId}: ${info.quantity} in stock`);
* });
*/
async fetchStock(
params: FetchStockInStock,
abortSignal?: AbortSignal,
): Promise<StockInfo[]> {
this.#logger.debug('Fetching stock info', () => ({ params }));
const parsed = FetchStockInStockSchema.parse(params);
const req$ = this.#stockService.StockInStock({
this.#logger.info('Fetching stock info from API', () => ({
stockId: parsed.assignedStockId,
itemCount: parsed.itemIds.length,
}));
let req$ = this.#stockService.StockInStock({
stockId: parsed.assignedStockId,
articleIds: parsed.itemIds,
});
if (abortSignal) {
req$.pipe(takeUntilAborted(abortSignal));
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
this.#logger.error(
'Failed to fetch stock info',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.debug('Successfully fetched stock info', () => ({
itemCount: res.result?.length || 0,
}));
return res.result as StockInfo[];
}
}

View File

@@ -0,0 +1,256 @@
import { TestBed } from '@angular/core/testing';
import { RemissionSupplierService } from './remission-supplier.service';
import { SupplierService } from '@generated/swagger/inventory-api';
import { RemissionStockService } from './remission-stock.service';
import { DataAccessError } from '@isa/common/data-access';
import { Supplier, Stock } from '../models';
import { SUPPLIER_STORAGE_KEY } from '../constants';
import { of, throwError } from 'rxjs';
// Mock injectStorage at the module level
const mockMemoryStorage = {
get: jest.fn(),
set: jest.fn(),
};
jest.mock('@isa/core/storage', () => ({
injectStorage: jest.fn(() => mockMemoryStorage),
MemoryStorageProvider: jest.fn(),
}));
describe('RemissionSupplierService', () => {
let service: RemissionSupplierService;
let mockSupplierService: jest.Mocked<SupplierService>;
let mockStockService: jest.Mocked<RemissionStockService>;
const mockSuppliers: Supplier[] = [
{
id: 123,
name: 'Test Supplier GmbH',
active: true,
} as Supplier,
{
id: 456,
name: 'Another Supplier Ltd',
active: true,
} as Supplier,
];
const mockStock: Stock = {
id: 789,
name: 'Test Stock',
active: true,
} as Stock;
beforeEach(() => {
// Clear all mocks before each test
jest.clearAllMocks();
const supplierServiceSpy = {
SupplierGetSuppliers: jest.fn(),
};
const stockServiceSpy = {
fetchAssignedStock: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
RemissionSupplierService,
{ provide: SupplierService, useValue: supplierServiceSpy },
{ provide: RemissionStockService, useValue: stockServiceSpy },
],
});
service = TestBed.inject(RemissionSupplierService);
mockSupplierService = TestBed.inject(
SupplierService,
) as jest.Mocked<SupplierService>;
mockStockService = TestBed.inject(
RemissionStockService,
) as jest.Mocked<RemissionStockService>;
});
describe('Service Creation', () => {
it('should be created', () => {
expect(service).toBeTruthy();
});
});
describe('fetchSuppliers - Cache Hit', () => {
it('should return cached suppliers when available', async () => {
mockMemoryStorage.get.mockResolvedValue(mockSuppliers);
const result = await service.fetchSuppliers();
expect(result).toEqual(mockSuppliers);
expect(mockMemoryStorage.get).toHaveBeenCalledWith(SUPPLIER_STORAGE_KEY);
expect(mockStockService.fetchAssignedStock).not.toHaveBeenCalled();
expect(mockSupplierService.SupplierGetSuppliers).not.toHaveBeenCalled();
});
it('should use cached data with abortSignal', async () => {
const abortController = new AbortController();
mockMemoryStorage.get.mockResolvedValue(mockSuppliers);
const result = await service.fetchSuppliers(abortController.signal);
expect(result).toEqual(mockSuppliers);
expect(mockMemoryStorage.get).toHaveBeenCalledWith(SUPPLIER_STORAGE_KEY);
expect(mockStockService.fetchAssignedStock).not.toHaveBeenCalled();
});
});
describe('fetchSuppliers - API Success', () => {
beforeEach(() => {
mockMemoryStorage.get.mockResolvedValue(null);
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
});
it('should fetch suppliers from API when cache is empty', async () => {
const apiResponse = {
result: mockSuppliers,
error: false,
message: undefined,
};
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
const result = await service.fetchSuppliers();
expect(result).toEqual(mockSuppliers);
expect(mockSupplierService.SupplierGetSuppliers).toHaveBeenCalledWith({
stockId: mockStock.id,
});
expect(mockMemoryStorage.set).toHaveBeenCalledWith(
SUPPLIER_STORAGE_KEY,
mockSuppliers,
);
});
it('should handle empty suppliers from API', async () => {
const emptySuppliers: Supplier[] = [];
const apiResponse = {
result: emptySuppliers,
error: false,
message: undefined,
};
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
const result = await service.fetchSuppliers();
expect(result).toEqual([]);
expect(mockMemoryStorage.set).toHaveBeenCalledWith(
SUPPLIER_STORAGE_KEY,
emptySuppliers,
);
});
it('should pass abortSignal to stock service', async () => {
const abortController = new AbortController();
const apiResponse = {
result: mockSuppliers,
error: false,
message: undefined,
};
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
await service.fetchSuppliers(abortController.signal);
expect(mockStockService.fetchAssignedStock).toHaveBeenCalledWith(
abortController.signal,
);
});
});
describe('fetchSuppliers - Error Cases', () => {
beforeEach(() => {
mockMemoryStorage.get.mockResolvedValue(null);
});
it('should throw DataAccessError when no assigned stock', async () => {
mockStockService.fetchAssignedStock.mockResolvedValue(null as unknown as Stock);
await expect(service.fetchSuppliers()).rejects.toThrow(DataAccessError);
await expect(service.fetchSuppliers()).rejects.toThrow(
'No assigned stock found',
);
});
it('should throw error when API returns error', async () => {
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
const apiResponse = {
result: undefined,
error: true,
message: 'API Error',
};
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
await expect(service.fetchSuppliers()).rejects.toThrow('API Error');
});
it('should throw default error when no message', async () => {
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
const apiResponse = {
result: undefined,
error: true,
message: undefined,
};
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
await expect(service.fetchSuppliers()).rejects.toThrow(
'Failed to fetch Suppliers',
);
});
it('should throw error when result is undefined', async () => {
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
const apiResponse = {
result: undefined,
error: false,
message: 'No data',
};
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
await expect(service.fetchSuppliers()).rejects.toThrow('No data');
});
it('should handle service errors', async () => {
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
const error = new Error('Service error');
mockSupplierService.SupplierGetSuppliers.mockReturnValue(
throwError(() => error),
);
await expect(service.fetchSuppliers()).rejects.toThrow('Service error');
});
});
describe('Integration', () => {
it('should properly cache API responses', async () => {
mockMemoryStorage.get.mockResolvedValue(null);
mockStockService.fetchAssignedStock.mockResolvedValue(mockStock);
const apiResponse = {
result: mockSuppliers,
error: false,
message: undefined,
};
mockSupplierService.SupplierGetSuppliers.mockReturnValue(of(apiResponse));
const result = await service.fetchSuppliers();
expect(result).toEqual(mockSuppliers);
expect(mockMemoryStorage.set).toHaveBeenCalledWith(
SUPPLIER_STORAGE_KEY,
mockSuppliers,
);
});
it('should handle storage errors', async () => {
const storageError = new Error('Storage error');
mockMemoryStorage.get.mockRejectedValue(storageError);
await expect(service.fetchSuppliers()).rejects.toThrow('Storage error');
});
});
});

View File

@@ -1,35 +1,110 @@
import { inject, Injectable } from '@angular/core';
import { SupplierService } from '@generated/swagger/inventory-api';
import { Supplier } from '../models';
import { FetchSuppliers, FetchSuppliersSchema } from '../schemas';
import { firstValueFrom } from 'rxjs';
import { injectStorage, MemoryStorageProvider } from '@isa/core/storage';
import { SUPPLIER_STORAGE_KEY } from '../constants';
import { RemissionStockService } from './remission-stock.service';
import { DataAccessError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
/**
* Service for managing remission suppliers.
* Handles fetching and caching supplier data for the assigned stock.
*
* @class RemissionSupplierService
* @injectable
*
* @example
* // Inject the service
* constructor(private supplierService: RemissionSupplierService) {}
*
* // Fetch suppliers
* const suppliers = await this.supplierService.fetchSuppliers();
*/
@Injectable({ providedIn: 'root' })
export class RemissionSupplierService {
/** Private instance of the supplier service from inventory API */
#supplierService = inject(SupplierService);
/** Private instance of the remission stock service */
#stockService = inject(RemissionStockService);
/** Private memory storage for caching suppliers */
#memoryStorage = injectStorage(MemoryStorageProvider);
/** Private logger instance */
#logger = logger(() => ({ service: 'RemissionSupplierService' }));
/**
* Fetches all suppliers for the currently assigned stock.
* Results are cached in memory storage to reduce API calls.
*
* @async
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Supplier[]>} Array of suppliers for the assigned stock
* @throws {DataAccessError} When no assigned stock is found
* @throws {Error} When the API request fails
*
* @example
* const controller = new AbortController();
* try {
* const suppliers = await service.fetchSuppliers(controller.signal);
* // Process suppliers...
* } catch (error) {
* // Handle error...
* }
*
* @todo Add schema validation for cached data
*/
async fetchSuppliers(abortSignal?: AbortSignal): Promise<Supplier[]> {
this.#logger.debug('Fetching suppliers');
async fetchSuppliers(params: FetchSuppliers): Promise<Supplier[]> {
const cached = await this.#memoryStorage.get(SUPPLIER_STORAGE_KEY); // TODO: Schema Validierung erstellen
if (cached) {
this.#logger.debug('Returning cached suppliers', () => ({
supplierCount: (cached as Supplier[]).length
}));
return cached as Supplier[];
}
const parsed = FetchSuppliersSchema.parse(params);
this.#logger.info('Fetching assigned stock for suppliers');
const assignedStock =
await this.#stockService.fetchAssignedStock(abortSignal);
const req$ = this.#supplierService.SupplierGetSuppliers({
stockId: parsed.assignedStockId,
if (!assignedStock) {
const error = new DataAccessError(
'NO_ASSIGNED_STOCK_FOUND',
'No assigned stock found',
null,
);
this.#logger.error('No assigned stock found', error);
throw error;
}
this.#logger.info('Fetching suppliers from API', () => ({
stockId: assignedStock.id
}));
let req$ = this.#supplierService.SupplierGetSuppliers({
stockId: assignedStock.id,
});
if (abortSignal) {
this.#logger.debug('Request configured with abort signal');
req$ = req$.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
throw new Error(res.message || 'Failed to fetch Suppliers');
const error = new Error(res.message || 'Failed to fetch Suppliers');
this.#logger.error('Failed to fetch suppliers', error);
throw error;
}
this.#logger.debug('Successfully fetched suppliers', () => ({
supplierCount: res.result?.length || 0
}));
this.#memoryStorage.set(SUPPLIER_STORAGE_KEY, res.result);
return res.result as Supplier[];

View File

@@ -1,10 +1,6 @@
// @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment
globalThis.ngJest = {
testEnvironmentOptions: {
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
},
};
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv();
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,307 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RemissionListItemComponent } from './remission-list-item.component';
import { ReturnItem, ReturnSuggestion, StockInfo } from '@isa/remission/data-access';
import { ProductInfoComponent, ProductStockInfoComponent } from '@isa/remission/shared/product';
import { MockComponent } from 'ng-mocks';
describe('RemissionListItemComponent', () => {
let component: RemissionListItemComponent;
let fixture: ComponentFixture<RemissionListItemComponent>;
const createMockReturnItem = (overrides: Partial<ReturnItem> = {}): ReturnItem => ({
id: 1,
predefinedReturnQuantity: 5,
...overrides,
} as ReturnItem);
const createMockReturnSuggestion = (overrides: Partial<ReturnSuggestion> = {}): ReturnSuggestion => ({
id: 1,
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: 10,
},
},
...overrides,
} as ReturnSuggestion);
const createMockStockInfo = (overrides: Partial<StockInfo> = {}): StockInfo => ({
id: 1,
quantity: 100,
...overrides,
} as StockInfo);
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
RemissionListItemComponent,
MockComponent(ProductInfoComponent),
MockComponent(ProductStockInfoComponent),
],
}).compileComponents();
fixture = TestBed.createComponent(RemissionListItemComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
expect(component).toBeTruthy();
});
it('should have item as required input', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.item()).toBeDefined();
});
it('should have stock as required input', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.stock()).toBeDefined();
});
});
describe('predefinedReturnQuantity computed signal', () => {
describe('with ReturnItem', () => {
it('should return predefinedReturnQuantity when available', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 15 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(15);
});
it('should return 0 when predefinedReturnQuantity is null', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: null as any });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
it('should return 0 when predefinedReturnQuantity is undefined', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: undefined });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
it('should return 0 when predefinedReturnQuantity is 0', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 0 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
});
describe('with ReturnSuggestion', () => {
it('should return predefinedReturnQuantity from returnItem.data when available', () => {
const mockSuggestion = createMockReturnSuggestion({
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: 25,
},
},
});
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(25);
});
it('should return 0 when returnItem.data.predefinedReturnQuantity is null', () => {
const mockSuggestion = createMockReturnSuggestion({
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: null as any,
},
},
});
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
it('should return 0 when returnItem.data.predefinedReturnQuantity is undefined', () => {
const mockSuggestion = createMockReturnSuggestion({
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: undefined,
},
},
});
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
it('should return 0 when returnItem is null', () => {
const mockSuggestion = createMockReturnSuggestion({
returnItem: null as any,
});
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
it('should return 0 when returnItem.data is null', () => {
const mockSuggestion = createMockReturnSuggestion({
returnItem: {
data: null as any,
},
});
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
});
describe('Type detection', () => {
it('should correctly identify ReturnSuggestion type', () => {
const mockSuggestion = createMockReturnSuggestion();
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
const item = component.item();
expect('returnItem' in item).toBe(true);
expect('predefinedReturnQuantity' in item).toBe(false);
});
it('should correctly identify ReturnItem type', () => {
const mockItem = createMockReturnItem();
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
const item = component.item();
expect('returnItem' in item).toBe(false);
expect('predefinedReturnQuantity' in item).toBe(true);
});
});
});
describe('Component reactivity', () => {
it('should update predefinedReturnQuantity when input changes from ReturnItem to ReturnSuggestion', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 5 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(5);
const mockSuggestion = createMockReturnSuggestion({
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: 20,
},
},
});
fixture.componentRef.setInput('item', mockSuggestion);
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(20);
});
it('should update predefinedReturnQuantity when input changes from ReturnSuggestion to ReturnItem', () => {
const mockSuggestion = createMockReturnSuggestion({
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: 30,
},
},
});
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(30);
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 8 });
fixture.componentRef.setInput('item', mockItem);
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(8);
});
});
describe('Edge cases', () => {
it('should handle negative predefinedReturnQuantity values', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: -5 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(-5);
});
it('should handle very large predefinedReturnQuantity values', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 999999 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(999999);
});
it('should handle decimal predefinedReturnQuantity values', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 3.5 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(3.5);
});
it('should handle deeply nested null values in ReturnSuggestion', () => {
const mockSuggestion = {
id: 1,
returnItem: {
data: null as any,
},
} as ReturnSuggestion;
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
it('should handle item with unexpected structure', () => {
const unexpectedItem = {
id: 1,
// Missing both returnItem and predefinedReturnQuantity
} as any;
fixture.componentRef.setInput('item', unexpectedItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
});
});
});

View File

@@ -16,7 +16,7 @@ import {
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
@Component({
selector: 'remission-feature-remission-list-item',
selector: 'remi-feature-remission-list-item',
templateUrl: './remission-list-item.component.html',
styleUrl: './remission-list-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,10 +1,12 @@
<ui-dropdown
class="remission-feature-remission-list-select__dropdown"
class="remi-feature-remission-list-select__dropdown"
[value]="selectedRemissionListType()"
[appearance]="DropdownAppearance.Grey"
(valueChange)="changeRemissionType($event)"
data-which="remission-list-select-dropdown"
[attr.data-what]="`remission-list-selected-value-${selectedRemissionListType()}`"
[attr.data-what]="
`remission-list-selected-value-${selectedRemissionListType()}`
"
>
@for (kv of remissionListTypes; track kv.key) {
<ui-dropdown-option

View File

@@ -13,7 +13,7 @@ import { remissionListTypeRouteMapping } from './remission-list-type-route.mappi
import { injectRemissionListType } from '../injects/inject-remission-list-type';
@Component({
selector: 'remission-feature-remission-list-select',
selector: 'remi-feature-remission-list-select',
templateUrl: './remission-list-select.component.html',
styleUrl: './remission-list-select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,6 +1,6 @@
<remission-feature-remission-start-card></remission-feature-remission-start-card>
<remission-feature-remission-list-select></remission-feature-remission-list-select>
<remi-feature-remission-list-select></remi-feature-remission-list-select>
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
@@ -15,11 +15,11 @@
@for (item of items(); track item.id) {
@defer (on viewport) {
<a [routerLink]="['../', 'return', item.id]" class="w-full">
<remission-feature-remission-list-item
<remi-feature-remission-list-item
#listElement
[item]="item"
[stock]="getStockForItem(item)"
></remission-feature-remission-list-item>
></remi-feature-remission-list-item>
</a>
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">

View File

@@ -7,6 +7,24 @@ import {
RemissionSupplierService,
} from '@isa/remission/data-access';
/**
* Resolver function that fetches department-specific query settings for the remission list.
* Similar to querySettingsResolverFn but fetches department-specific settings.
*
* @function querySettingsDepartmentResolverFn
* @type {ResolveFn<QuerySettings>}
* @returns {Promise<QuerySettings>} The department query settings for the current stock and supplier
* @throws {Error} When no assigned stock is available
* @throws {Error} When no supplier is available
*
* @example
* // In route configuration
* {
* path: 'remissions/department',
* resolve: { querySettings: querySettingsDepartmentResolverFn },
* component: RemissionDepartmentListComponent
* }
*/
export const querySettingsDepartmentResolverFn: ResolveFn<
QuerySettings
> = async () => {
@@ -20,9 +38,7 @@ export const querySettingsDepartmentResolverFn: ResolveFn<
throw new Error('No assigned stock available');
}
const suppliers = await remissionSupplierService.fetchSuppliers({
assignedStockId: assignedStock.id,
});
const suppliers = await remissionSupplierService.fetchSuppliers();
const firstSupplier = suppliers[0]; // Currently only one supplier exists

View File

@@ -7,6 +7,25 @@ import {
RemissionSupplierService,
} from '@isa/remission/data-access';
/**
* Resolver function that fetches query settings for the remission list.
* Retrieves the assigned stock and supplier information, then fetches
* the corresponding query settings for filtering and sorting.
*
* @function querySettingsResolverFn
* @type {ResolveFn<QuerySettings>}
* @returns {Promise<QuerySettings>} The query settings for the current stock and supplier
* @throws {Error} When no assigned stock is available
* @throws {Error} When no supplier is available
*
* @example
* // In route configuration
* {
* path: 'remissions',
* resolve: { querySettings: querySettingsResolverFn },
* component: RemissionListComponent
* }
*/
export const querySettingsResolverFn: ResolveFn<QuerySettings> = async () => {
const remissionSearchService = inject(RemissionSearchService);
const remissionStockService = inject(RemissionStockService);
@@ -18,9 +37,7 @@ export const querySettingsResolverFn: ResolveFn<QuerySettings> = async () => {
throw new Error('No assigned stock available');
}
const suppliers = await remissionSupplierService.fetchSuppliers({
assignedStockId: assignedStock.id,
});
const suppliers = await remissionSupplierService.fetchSuppliers();
const firstSupplier = suppliers[0]; // Currently only one supplier exists

View File

@@ -7,6 +7,29 @@ import {
} from '@isa/remission/data-access';
import { QueryTokenInput } from 'libs/remission/data-access/src/lib/schemas';
/**
* Creates an Angular resource for fetching remission lists.
* Handles both standard (Pflicht) and department (Abteilung) remission lists.
* Automatically fetches the assigned stock and supplier before loading the list.
*
* @function createRemissionListResource
* @param {Function} params - Function that returns parameters for the resource
* @param {RemissionListType} params.remissionListType - Type of remission list to fetch
* @param {QueryTokenInput} params.queryToken - Query parameters for filtering and sorting
* @returns {Resource} Angular resource that manages the remission list data
* @throws {Error} When no current stock is available
* @throws {Error} When no supplier is available
*
* @example
* const remissionListResource = createRemissionListResource(() => ({
* remissionListType: RemissionListType.Pflicht,
* queryToken: {
* filter: { status: 'open' },
* orderBy: 'date',
* // ... other query parameters
* }
* }));
*/
export const createRemissionListResource = (
params: () => {
remissionListType: RemissionListType;
@@ -25,9 +48,8 @@ export const createRemissionListResource = (
throw new Error('No current stock available');
}
const suppliers = await remissionSupplierService.fetchSuppliers({
assignedStockId: assignedStock.id,
});
const suppliers =
await remissionSupplierService.fetchSuppliers(abortSignal);
const firstSupplier = suppliers[0]; // Es gibt aktuell nur Blank als Supplier

View File

@@ -0,0 +1,7 @@
# remission-feature-remission-return-receipt-details
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test remission-feature-remission-return-receipt-details` 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: 'remi',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'remi',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

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

View File

@@ -0,0 +1 @@
export * from './lib/remission-return-receipt-details.component';

View File

@@ -0,0 +1,45 @@
<div>
<div>Status</div>
<div
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); width: '5rem'; height: '1.5rem'"
>
{{ status() }}
</div>
</div>
<div>
<div>Anzahl Positionen</div>
<div
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); height: '1.5rem'"
>
{{ itemCount() }}
</div>
</div>
<div>
<div>Lieferant</div>
<div
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); width: '5rem'; height: '1.5rem'"
>
{{ supplier() }}
</div>
</div>
<div>
<div>Remissionsdatum</div>
<div
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); width: '12rem'; height: '1.5rem'"
>
{{ remiDate() | date: 'dd.MM.yy' }}
</div>
</div>
<div>
<div>Wannennummer</div>
<div
class="isa-text-body-1-bold"
*uiSkeletonLoader="loading(); width: '15rem'; height: '1.5rem'"
>
{{ packageNumber() }}
</div>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply p-6 bg-isa-neutral-400 rounded-2xl grid grid-cols-3 gap-6 isa-text-body-1-regular;
}

View File

@@ -0,0 +1,304 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { signal } from '@angular/core';
import { DatePipe } from '@angular/common';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { Receipt, Supplier } from '@isa/remission/data-access';
// Mock the supplier resource
vi.mock('./resources', () => ({
createSupplierResource: vi.fn(() => ({
value: signal([]),
isLoading: signal(false),
error: signal(null),
})),
}));
describe('RemissionReturnReceiptDetailsCardComponent', () => {
let component: RemissionReturnReceiptDetailsCardComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsCardComponent>;
const mockSuppliers: Supplier[] = [
{
id: 123,
name: 'Test Supplier GmbH',
address: 'Test Street 1',
} as Supplier,
{
id: 456,
name: 'Another Supplier Ltd',
address: 'Another Street 2',
} as Supplier,
];
const mockReceipt: Receipt = {
id: 789,
receiptNumber: 'RR-2024-001234-ABC',
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
supplier: {
id: 123,
name: 'Test Supplier GmbH',
},
items: [
{
id: 1,
data: {
id: 1,
quantity: 5,
product: { id: 1, name: 'Product 1' },
},
},
{
id: 2,
data: {
id: 2,
quantity: 3,
product: { id: 2, name: 'Product 2' },
},
},
],
packages: [
{
id: 1,
data: {
id: 1,
packageNumber: 'PKG-001',
},
},
{
id: 2,
data: {
id: 2,
packageNumber: 'PKG-002',
},
},
],
} as Receipt;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsCardComponent, DatePipe],
}).compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsCardComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have default loading state', () => {
expect(component.loading()).toBe(true);
});
it('should accept receipt input', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.receipt()).toEqual(mockReceipt);
});
it('should accept loading input', () => {
fixture.componentRef.setInput('loading', false);
expect(component.loading()).toBe(false);
});
});
describe('status computed signal', () => {
it('should return "Abgeschlossen" when receipt is completed', () => {
const completedReceipt = { ...mockReceipt, completed: true };
fixture.componentRef.setInput('receipt', completedReceipt);
expect(component.status()).toBe('Abgeschlossen');
});
it('should return "Offen" when receipt is not completed', () => {
const openReceipt = { ...mockReceipt, completed: false };
fixture.componentRef.setInput('receipt', openReceipt);
expect(component.status()).toBe('Offen');
});
it('should return "Offen" when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.status()).toBe('Offen');
});
});
describe('itemCount computed signal', () => {
it('should calculate total quantity from items', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
// mockReceipt has items with quantities 5 and 3 = 8 total
expect(component.itemCount()).toBe(8);
});
it('should return 0 when no items', () => {
const receiptWithoutItems = { ...mockReceipt, items: [] };
fixture.componentRef.setInput('receipt', receiptWithoutItems);
expect(component.itemCount()).toBe(0);
});
it('should return undefined when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.itemCount()).toBeUndefined();
});
it('should handle items with undefined data', () => {
const receiptWithUndefinedItems = {
...mockReceipt,
items: [
{ id: 1, data: undefined },
{ id: 2, data: { id: 2, quantity: 5 } },
],
};
fixture.componentRef.setInput('receipt', receiptWithUndefinedItems);
expect(component.itemCount()).toBe(5);
});
});
describe('supplier computed signal', () => {
it('should return supplier name when found', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Test Supplier GmbH');
});
it('should return "Unbekannt" when supplier not found', () => {
const receiptWithUnknownSupplier = {
...mockReceipt,
supplier: { id: 999, name: 'Unknown' },
};
fixture.componentRef.setInput('receipt', receiptWithUnknownSupplier);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Unbekannt');
});
it('should return "Unbekannt" when no suppliers loaded', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal([]);
expect(component.supplier()).toBe('Unbekannt');
});
it('should return "Unbekannt" when no receipt provided', () => {
fixture.componentRef.setInput('receipt', undefined);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.supplier()).toBe('Unbekannt');
});
});
describe('completedAt computed signal', () => {
it('should return created date', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.completedAt()).toEqual(mockReceipt.created);
});
it('should return undefined when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.completedAt()).toBeUndefined();
});
});
describe('remiDate computed signal', () => {
it('should return completed date when available', () => {
const completedDate = new Date('2024-01-20T15:45:00Z');
const receiptWithCompleted = {
...mockReceipt,
completed: completedDate,
created: new Date('2024-01-15T10:30:00Z'),
};
fixture.componentRef.setInput('receipt', receiptWithCompleted);
expect(component.remiDate()).toEqual(completedDate);
});
it('should return created date when completed date not available', () => {
const receiptWithoutCompleted = {
...mockReceipt,
completed: false,
};
fixture.componentRef.setInput('receipt', receiptWithoutCompleted);
expect(component.remiDate()).toEqual(mockReceipt.created);
});
it('should return undefined when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.remiDate()).toBeUndefined();
});
});
describe('packageNumber computed signal', () => {
it('should return comma-separated package numbers', () => {
fixture.componentRef.setInput('receipt', mockReceipt);
expect(component.packageNumber()).toBe('PKG-001, PKG-002');
});
it('should return empty string when no packages', () => {
const receiptWithoutPackages = { ...mockReceipt, packages: [] };
fixture.componentRef.setInput('receipt', receiptWithoutPackages);
expect(component.packageNumber()).toBe('');
});
it('should return empty string when no receipt', () => {
fixture.componentRef.setInput('receipt', undefined);
expect(component.packageNumber()).toBe('');
});
it('should handle packages with undefined data', () => {
const receiptWithUndefinedPackages = {
...mockReceipt,
packages: [
{ id: 1, data: undefined },
{ id: 2, data: { id: 2, packageNumber: 'PKG-002' } },
],
};
fixture.componentRef.setInput('receipt', receiptWithUndefinedPackages);
// packageNumber maps undefined values, which join as ', PKG-002'
expect(component.packageNumber()).toBe(', PKG-002');
});
});
describe('Component reactivity', () => {
it('should update computed signals when receipt changes', () => {
// Initial receipt
fixture.componentRef.setInput('receipt', mockReceipt);
(component.supplierResource as any).value = signal(mockSuppliers);
expect(component.status()).toBe('Abgeschlossen');
expect(component.itemCount()).toBe(8);
// Change receipt
const newReceipt = {
...mockReceipt,
completed: false,
items: [{ id: 1, data: { id: 1, quantity: 10 } }],
};
fixture.componentRef.setInput('receipt', newReceipt);
expect(component.status()).toBe('Offen');
expect(component.itemCount()).toBe(10);
});
it('should create supplier resource on initialization', () => {
expect(component.supplierResource).toBeDefined();
expect(component.supplierResource.value).toBeDefined();
});
});
});

View File

@@ -0,0 +1,115 @@
import { DatePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Receipt } from '@isa/remission/data-access';
import { SkeletonLoaderDirective } from '@isa/ui/skeleton-loader';
import { createSupplierResource } from './resources';
/**
* Component that displays detailed information about a remission return receipt in a card format.
* Shows supplier information, status, dates, item counts, and package numbers.
*
* @component
* @selector remi-remission-return-receipt-details-card
* @standalone
*
* @example
* <remi-remission-return-receipt-details-card
* [receipt]="receiptData"
* [loading]="isLoading">
* </remi-remission-return-receipt-details-card>
*/
@Component({
selector: 'remi-remission-return-receipt-details-card',
templateUrl: './remission-return-receipt-details-card.component.html',
styleUrls: ['./remission-return-receipt-details-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SkeletonLoaderDirective, DatePipe],
})
export class RemissionReturnReceiptDetailsCardComponent {
/**
* Input for the receipt data to display.
* @input
*/
receipt = input<Receipt>();
/**
* Input to control the loading state of the card.
* @input
* @default true
*/
loading = input(true);
/**
* Resource for fetching supplier data.
*/
supplierResource = createSupplierResource();
/**
* Computed signal that determines the receipt status text.
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
*/
status = computed(() => {
return this.receipt()?.completed ? 'Abgeschlossen' : 'Offen';
});
/**
* Computed signal that calculates the total quantity of all items in the receipt.
* @returns {number} Sum of all item quantities
*/
itemCount = computed(() => {
const receipt = this.receipt();
return receipt?.items?.reduce(
(acc, item) => acc + (item.data?.quantity || 0),
0,
);
});
/**
* Computed signal that finds and returns the supplier name.
* @returns {string} Supplier name or 'Unbekannt' if not found
*/
supplier = computed(() => {
const receipt = this.receipt();
const supplier = this.supplierResource.value();
return (
supplier?.find((s) => s.id === receipt?.supplier?.id)?.name || 'Unbekannt'
);
});
/**
* Computed signal for the receipt completion date.
* @returns {Date | undefined} The creation date of the receipt
*/
completedAt = computed(() => {
const receipt = this.receipt();
return receipt?.created;
});
/**
* Computed signal for the remission date.
* Prioritizes completed date over created date.
* @returns {Date | undefined} The remission date
*/
remiDate = computed(() => {
const receipt = this.receipt();
return receipt?.completed || receipt?.created;
});
/**
* Computed signal that concatenates all package numbers.
* @returns {string} Comma-separated list of package numbers
*/
packageNumber = computed(() => {
const receipt = this.receipt();
return (
receipt?.packages?.map((p) => p.data?.packageNumber).join(', ') || ''
);
});
}

View File

@@ -0,0 +1,32 @@
<div>
<img
class="w-full max-h-[5.125rem] object-contain"
sharedProductRouterLink
sharedProductImage
[ean]="item().product.ean"
[alt]="item().product.name"
/>
</div>
<div class="grid grid-flow-row gap-2">
<div class="isa-text-body-2-bold">{{ item().product.contributors }}</div>
<div class="isa-text-body-2-regular">{{ item().product.name }}</div>
<shared-product-format
[format]="item().product.format"
[formatDetail]="item().product.formatDetail"
></shared-product-format>
</div>
<ui-bullet-list>
<ui-bullet-list-item>
{{ item().product.productGroup }}:{{ productGroupDetail() }}
</ui-bullet-list-item>
<ui-bullet-list-item> Remi Menge: {{ item().quantity }} </ui-bullet-list-item>
</ui-bullet-list>
<div class="flex justify-center items-center">
@if (removeable()) {
<ui-icon-button
[name]="'isaActionClose'"
[size]="'large'"
[color]="'secondary'"
></ui-icon-button>
}
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-cols-[3.5rem,15.5rem,1fr,auto] gap-6 p-4 text-isa-neutral-900;
}

View File

@@ -0,0 +1,390 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockComponent, MockDirective, MockProvider } from 'ng-mocks';
import { of } from 'rxjs';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import {
ReceiptItem,
RemissionProductGroupService,
} from '@isa/remission/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import {
BulletListComponent,
BulletListItemComponent,
} from '@isa/ui/bullet-list';
describe('RemissionReturnReceiptDetailsItemComponent', () => {
let component: RemissionReturnReceiptDetailsItemComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsItemComponent>;
const mockReceiptItem: ReceiptItem = {
id: 1,
quantity: 5,
product: {
id: 123,
name: 'Test Product',
contributors: 'Test Author',
ean: '1234567890123',
format: 'Hardcover',
formatDetail: '200 pages',
productGroup: 'BOOK',
},
} as ReceiptItem;
const mockProductGroups = [
{ key: 'BOOK', value: 'Books' },
{ key: 'MAGAZINE', value: 'Magazines' },
{ key: 'DVD', value: 'DVDs' },
];
const mockRemissionProductGroupService = {
fetchProductGroups: vi.fn().mockResolvedValue(mockProductGroups),
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsItemComponent],
providers: [
{
provide: RemissionProductGroupService,
useValue: mockRemissionProductGroupService,
},
],
})
.overrideComponent(RemissionReturnReceiptDetailsItemComponent, {
remove: {
imports: [
ProductImageDirective,
ProductRouterLinkDirective,
ProductFormatComponent,
IconButtonComponent,
],
},
add: {
imports: [
MockDirective(ProductImageDirective),
MockDirective(ProductRouterLinkDirective),
MockComponent(ProductFormatComponent),
MockComponent(IconButtonComponent),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(
RemissionReturnReceiptDetailsItemComponent,
);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component).toBeTruthy();
});
it('should have required item input', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.item()).toEqual(mockReceiptItem);
});
});
describe('Component with valid receipt item', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
});
it('should display receipt item data', () => {
expect(component.item()).toEqual(mockReceiptItem);
expect(component.item().id).toBe(1);
expect(component.item().quantity).toBe(5);
expect(component.item().product.name).toBe('Test Product');
});
it('should handle product information correctly', () => {
const item = component.item();
expect(item.product.name).toBe('Test Product');
expect(item.product.contributors).toBe('Test Author');
expect(item.product.ean).toBe('1234567890123');
expect(item.product.format).toBe('Hardcover');
expect(item.product.formatDetail).toBe('200 pages');
expect(item.product.productGroup).toBe('BOOK');
});
it('should handle quantity correctly', () => {
expect(component.item().quantity).toBe(5);
});
it('should have default removeable value', () => {
expect(component.removeable()).toBe(false);
});
});
describe('Component with different receipt item data', () => {
it('should handle different quantity values', () => {
const differentItem = {
...mockReceiptItem,
quantity: 10,
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().quantity).toBe(10);
});
it('should handle different product information', () => {
const differentItem: ReceiptItem = {
...mockReceiptItem,
product: {
...mockReceiptItem.product,
name: 'Different Product',
contributors: 'Different Author',
productGroup: 'MAGAZINE',
},
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().product.name).toBe('Different Product');
expect(component.item().product.contributors).toBe('Different Author');
expect(component.item().product.productGroup).toBe('MAGAZINE');
});
it('should handle item with different ID', () => {
const differentItem = {
...mockReceiptItem,
id: 999,
};
fixture.componentRef.setInput('item', differentItem);
expect(component.item().id).toBe(999);
});
});
describe('Component reactivity', () => {
it('should update when item input changes', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.item().quantity).toBe(5);
expect(component.item().product.name).toBe('Test Product');
// Change the item
const newItem = {
...mockReceiptItem,
id: 2,
quantity: 3,
product: {
...mockReceiptItem.product,
name: 'Updated Product',
},
};
fixture.componentRef.setInput('item', newItem);
expect(component.item().id).toBe(2);
expect(component.item().quantity).toBe(3);
expect(component.item().product.name).toBe('Updated Product');
});
});
describe('Removeable input', () => {
it('should default to false when not provided', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
expect(component.removeable()).toBe(false);
});
it('should accept true value', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
expect(component.removeable()).toBe(true);
});
it('should accept false value', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', false);
expect(component.removeable()).toBe(false);
});
});
describe('Product Group functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
});
it('should initialize productGroupResource', () => {
expect(component.productGroupResource).toBeDefined();
});
it('should compute productGroupDetail correctly when resource has data', () => {
// Mock the resource value directly
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
mockProductGroups,
);
// The productGroupDetail should find the matching product group
expect(component.productGroupDetail()).toBe('Books');
});
it('should return empty string when resource value is undefined', () => {
// Mock the resource to return undefined
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
undefined,
);
expect(component.productGroupDetail()).toBe('');
});
it('should return empty string when product group not found', () => {
const differentItem: ReceiptItem = {
...mockReceiptItem,
product: {
...mockReceiptItem.product,
productGroup: 'UNKNOWN',
},
};
fixture.componentRef.setInput('item', differentItem);
// Mock the resource value
vi.spyOn(component.productGroupResource, 'value').mockReturnValue(
mockProductGroups,
);
expect(component.productGroupDetail()).toBe('');
});
});
describe('Icon button rendering', () => {
it('should render icon button when removeable is true', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
fixture.detectChanges();
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
expect(iconButton).toBeTruthy();
});
it('should not render icon button when removeable is false', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', false);
fixture.detectChanges();
const iconButton = fixture.nativeElement.querySelector('ui-icon-button');
expect(iconButton).toBeFalsy();
});
it('should render icon button with correct properties', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.componentRef.setInput('removeable', true);
fixture.detectChanges();
const iconButton = fixture.debugElement.query(
By.css('ui-icon-button'),
)?.componentInstance;
expect(iconButton).toBeTruthy();
expect(iconButton.name).toBe('isaActionClose');
expect(iconButton.size).toBe('large');
expect(iconButton.color).toBe('secondary');
});
});
describe('Template rendering', () => {
beforeEach(() => {
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.detectChanges();
});
it('should render product image with correct attributes', () => {
const img = fixture.nativeElement.querySelector('img');
expect(img).toBeTruthy();
expect(img.getAttribute('alt')).toBe('Test Product');
expect(img.classList.contains('w-full')).toBe(true);
expect(img.classList.contains('max-h-[5.125rem]')).toBe(true);
expect(img.classList.contains('object-contain')).toBe(true);
});
it('should render product contributors', () => {
const contributorsElement = fixture.nativeElement.querySelector(
'.isa-text-body-2-bold',
);
expect(contributorsElement).toBeTruthy();
expect(contributorsElement.textContent).toBe('Test Author');
});
it('should render product name', () => {
const nameElement = fixture.nativeElement.querySelector(
'.isa-text-body-2-regular',
);
expect(nameElement).toBeTruthy();
expect(nameElement.textContent).toBe('Test Product');
});
it('should render bullet list items', () => {
const bulletListItems = fixture.nativeElement.querySelectorAll(
'ui-bullet-list-item',
);
expect(bulletListItems.length).toBe(2);
});
});
describe('Component imports', () => {
it('should have ProductImageDirective import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
it('should have ProductRouterLinkDirective import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
it('should have ProductFormatComponent import', () => {
fixture.componentRef.setInput('item', mockReceiptItem);
// Component should be created successfully with mocked imports
expect(component).toBeTruthy();
});
});
describe('E2E Testing Attributes', () => {
it('should consider adding data-what and data-which attributes for E2E testing', () => {
// This test serves as a reminder that E2E testing attributes
// should be added to the template for better testability.
// Currently the template does not have these attributes.
fixture.componentRef.setInput('item', mockReceiptItem);
fixture.detectChanges();
const hostElement = fixture.nativeElement;
// Verify the component renders (basic check)
expect(hostElement).toBeTruthy();
// Note: In a future update, the template should include:
// - data-what="receipt-item" on the host or main container
// - data-which="receipt-item-details"
// - [attr.data-item-id]="item().id" for dynamic identification
// This would improve E2E test reliability and maintainability
});
});
});

View File

@@ -0,0 +1,69 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { ReceiptItem } from '@isa/remission/data-access';
import { ProductFormatComponent } from '@isa/shared/product-foramt';
import { ProductImageDirective } from '@isa/shared/product-image';
import { ProductRouterLinkDirective } from '@isa/shared/product-router-link';
import { UiBulletList } from '@isa/ui/bullet-list';
import { productGroupResource } from './resources';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
/**
* Component for displaying a single receipt item within the remission return receipt details.
* Shows product information including image, name, and formatted product details.
*
* @component
* @selector remi-remission-return-receipt-details-item
* @standalone
*
* @example
* <remi-remission-return-receipt-details-item
* [item]="receiptItem">
* </remi-remission-return-receipt-details-item>
*/
@Component({
selector: 'remi-remission-return-receipt-details-item',
templateUrl: './remission-return-receipt-details-item.component.html',
styleUrls: ['./remission-return-receipt-details-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
ProductImageDirective,
ProductRouterLinkDirective,
ProductFormatComponent,
UiBulletList,
IconButtonComponent,
],
providers: [provideIcons({ isaActionClose })],
})
export class RemissionReturnReceiptDetailsItemComponent {
/**
* Required input for the receipt item to display.
* Contains product information and quantity details.
* @input
* @required
*/
item = input.required<ReceiptItem>();
removeable = input<boolean>(false);
productGroupResource = productGroupResource();
productGroupDetail = computed(() => {
const value = this.productGroupResource.value();
if (!value) {
return '';
}
return (
value.find((group) => group.key === this.item().product.productGroup)
?.value || ''
);
});
}

View File

@@ -0,0 +1,52 @@
<div>
<button
type="button"
uiButton
[color]="'tertiary'"
size="small"
class="px-[0.875rem] py-1 min-w-0 bg-white gap-1"
(click)="location.back()"
>
<ng-icon name="isaActionChevronLeft"></ng-icon>
zurück
</button>
</div>
<div>
<h1 class="text-center isa-text-subtitle-1-regular">Warenbegleitschein</h1>
<h2 class="text-center isa-text-subtitle-1-bold">#{{ receiptNumber() }}</h2>
</div>
<div></div>
<remi-remission-return-receipt-details-card
[receipt]="returnResource.value()"
[loading]="returnResource.isLoading()"
></remi-remission-return-receipt-details-card>
@let items = returnResource.value()?.items;
@if (returnResource.isLoading()) {
<div class="text-center">
<ui-icon-button
class="animate-spin"
name="isaLoading"
size="large"
color="neutral"
></ui-icon-button>
</div>
} @else if (items.length === 0) {
<div class="isa-text-body-2-regular">
Keine Artikel auf dem Warenbegleitschein.
</div>
} @else {
<div class="bg-isa-white rounded-2xl p-6 grid grid-flow-row gap-6">
@for (item of items; track item.id; let last = $last) {
<remi-remission-return-receipt-details-item
[item]="item.data"
[removeable]="canRemoveItems()"
></remi-remission-return-receipt-details-item>
@if (!last) {
<hr class="border-isa-neutral-300" />
}
}
</div>
}

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row gap-4 p-4 text-isa-neutral-900;
}

View File

@@ -0,0 +1,160 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockComponent, MockProvider } from 'ng-mocks';
import { signal } from '@angular/core';
import { Location } from '@angular/common';
import { RemissionReturnReceiptDetailsComponent } from './remission-return-receipt-details.component';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { Receipt } from '@isa/remission/data-access';
// Mock the resource function
vi.mock('./resources/remission-return-receipt.resource', () => ({
createRemissionReturnReceiptResource: vi.fn(() => ({
value: signal(null),
isLoading: signal(false),
error: signal(null),
})),
}));
describe('RemissionReturnReceiptDetailsComponent', () => {
let component: RemissionReturnReceiptDetailsComponent;
let fixture: ComponentFixture<RemissionReturnReceiptDetailsComponent>;
const mockReceipt: Receipt = {
id: 123,
receiptNumber: 'RR-2024-001234-ABC',
items: [],
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
} as Receipt;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptDetailsComponent],
providers: [
MockProvider(Location, { back: vi.fn() }),
],
})
.overrideComponent(RemissionReturnReceiptDetailsComponent, {
remove: {
imports: [
RemissionReturnReceiptDetailsCardComponent,
RemissionReturnReceiptDetailsItemComponent,
],
},
add: {
imports: [
MockComponent(RemissionReturnReceiptDetailsCardComponent),
MockComponent(RemissionReturnReceiptDetailsItemComponent),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptDetailsComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component).toBeTruthy();
});
it('should have required inputs', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
});
it('should coerce string inputs to numbers', () => {
fixture.componentRef.setInput('returnId', '123');
fixture.componentRef.setInput('receiptId', '456');
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
});
});
describe('Dependencies', () => {
it('should inject Location service', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.location).toBeDefined();
});
it('should create return resource', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
expect(component.returnResource).toBeDefined();
});
});
describe('receiptNumber computed signal', () => {
it('should return empty string when no receipt data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock empty resource
(component.returnResource as any).value = signal(null);
expect(component.receiptNumber()).toBe('');
});
it('should extract receipt number substring correctly', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock resource with receipt data
(component.returnResource as any).value = signal(mockReceipt);
// substring(6, 12) on 'RR-2024-001234-ABC' should return '4-0012'
expect(component.receiptNumber()).toBe('4-0012');
});
it('should handle undefined receipt number', () => {
const receiptWithoutNumber = {
...mockReceipt,
receiptNumber: undefined,
};
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
(component.returnResource as any).value = signal(receiptWithoutNumber);
expect(component.receiptNumber()).toBe('');
});
});
describe('Resource reactivity', () => {
it('should handle resource loading state', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock loading resource
(component.returnResource as any).isLoading = signal(true);
expect(component.returnResource.isLoading()).toBe(true);
});
it('should handle resource with data', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
// Mock resource with data
(component.returnResource as any).value = signal(mockReceipt);
(component.returnResource as any).isLoading = signal(false);
expect(component.returnResource.value()).toEqual(mockReceipt);
expect(component.returnResource.isLoading()).toBe(false);
});
});
});

View File

@@ -0,0 +1,97 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { coerceNumberProperty, NumberInput } from '@angular/cdk/coercion';
import { ButtonComponent, IconButtonComponent } from '@isa/ui/buttons';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft, isaLoading } from '@isa/icons';
import { RemissionReturnReceiptDetailsCardComponent } from './remission-return-receipt-details-card.component';
import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-receipt-details-item.component';
import { Location } from '@angular/common';
import { createRemissionReturnReceiptResource } from './resources/remission-return-receipt.resource';
/**
* Component for displaying detailed information about a remission return receipt.
* Shows receipt header information and individual receipt items.
*
* @component
* @selector remi-remission-return-receipt-details
* @standalone
*
* @example
* <remi-remission-return-receipt-details
* [returnId]="123"
* [receiptId]="456">
* </remi-remission-return-receipt-details>
*/
@Component({
selector: 'remi-remission-return-receipt-details',
templateUrl: './remission-return-receipt-details.component.html',
styleUrls: ['./remission-return-receipt-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
ButtonComponent,
IconButtonComponent,
NgIcon,
RemissionReturnReceiptDetailsCardComponent,
RemissionReturnReceiptDetailsItemComponent,
],
providers: [provideIcons({ isaActionChevronLeft, isaLoading })],
})
export class RemissionReturnReceiptDetailsComponent {
/** Angular Location service for navigation */
location = inject(Location);
/**
* Required input for the return ID.
* Automatically coerced to a number from string input.
* @input
* @required
*/
returnId = input.required<number, NumberInput>({
transform: coerceNumberProperty,
});
/**
* Required input for the receipt ID.
* Automatically coerced to a number from string input.
* @input
* @required
*/
receiptId = input.required<number, NumberInput>({
transform: coerceNumberProperty,
});
/**
* Resource that fetches the return receipt data based on the provided IDs.
* Automatically updates when input IDs change.
*/
returnResource = createRemissionReturnReceiptResource(() => ({
returnId: this.returnId(),
receiptId: this.receiptId(),
}));
/**
* Computed signal that extracts the receipt number from the resource.
* Returns a substring of the receipt number (characters 6-12) for display.
* @returns {string} The formatted receipt number or empty string if not available
*/
receiptNumber = computed(() => {
const ret = this.returnResource.value();
if (!ret) {
return '';
}
return ret.receiptNumber?.substring(6, 12) || '';
});
canRemoveItems = computed(() => {
const ret = this.returnResource.value();
return !ret?.completed;
});
}

View File

@@ -0,0 +1,3 @@
export * from './product-group.resource';
export * from './remission-return-receipt.resource';
export * from './supplier.resource';

View File

@@ -0,0 +1,10 @@
import { inject, resource } from '@angular/core';
import { RemissionProductGroupService } from '@isa/remission/data-access';
export const productGroupResource = () => {
const remissionProductGroupService = inject(RemissionProductGroupService);
return resource({
loader: ({ abortSignal }) =>
remissionProductGroupService.fetchProductGroups(abortSignal),
});
};

View File

@@ -0,0 +1,184 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { runInInjectionContext, Injector } from '@angular/core';
import { MockProvider } from 'ng-mocks';
import { createRemissionReturnReceiptResource } from './remission-return-receipt.resource';
import { RemissionReturnReceiptService } from '@isa/remission/data-access';
import { Receipt } from '@isa/remission/data-access';
describe('createRemissionReturnReceiptResource', () => {
let mockService: any;
let mockReceipt: Receipt;
beforeEach(() => {
mockReceipt = {
id: 123,
receiptNumber: 'RR-2024-001234-ABC',
completed: true,
created: new Date('2024-01-15T10:30:00Z'),
supplier: {
id: 456,
name: 'Test Supplier',
},
items: [
{
id: 1,
data: {
id: 1,
quantity: 5,
product: { id: 1, name: 'Product 1' },
},
},
],
packages: [
{
id: 1,
data: {
id: 1,
packageNumber: 'PKG-001',
},
},
],
} as Receipt;
mockService = {
fetchRemissionReturnReceipt: vi.fn().mockResolvedValue(mockReceipt),
};
TestBed.configureTestingModule({
providers: [
MockProvider(RemissionReturnReceiptService, mockService),
],
});
});
describe('Resource Creation', () => {
it('should create resource successfully', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
it('should inject RemissionReturnReceiptService', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
expect(mockService).toBeDefined();
});
});
describe('Resource Parameters', () => {
it('should handle numeric parameters', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource).toBeDefined();
});
it('should handle string parameters', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: '123',
returnId: '456',
}))
);
expect(resource).toBeDefined();
});
it('should handle mixed parameter types', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: '456',
}))
);
expect(resource).toBeDefined();
});
});
describe('Resource State Management', () => {
it('should provide loading state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.isLoading).toBeDefined();
expect(typeof resource.isLoading).toBe('function');
});
it('should provide error state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.error).toBeDefined();
expect(typeof resource.error).toBe('function');
});
it('should provide value state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}))
);
expect(resource.value).toBeDefined();
expect(typeof resource.value).toBe('function');
});
});
describe('Resource Function', () => {
it('should create resource function correctly', () => {
const createResourceFn = () => createRemissionReturnReceiptResource(() => ({
receiptId: 123,
returnId: 456,
}));
const resource = runInInjectionContext(TestBed.inject(Injector), createResourceFn);
expect(resource).toBeDefined();
expect(typeof resource.value).toBe('function');
expect(typeof resource.isLoading).toBe('function');
expect(typeof resource.error).toBe('function');
});
it('should handle resource initialization', () => {
const params = { receiptId: 123, returnId: 456 };
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createRemissionReturnReceiptResource(() => params)
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
});
});

View File

@@ -0,0 +1,39 @@
import { resource, inject } from '@angular/core';
import {
RemissionReturnReceiptService,
FetchRemissionReturnParams,
} from '@isa/remission/data-access';
/**
* Creates an Angular resource for fetching a specific remission return receipt.
* The resource automatically manages loading state and caching.
*
* @function createRemissionReturnReceiptResource
* @param {Function} params - Function that returns the receipt and return IDs
* @param {string | number} params.receiptId - ID of the receipt to fetch
* @param {string | number} params.returnId - ID of the return containing the receipt
* @returns {Resource} Angular resource that manages the receipt data
*
* @example
* const receiptResource = createRemissionReturnReceiptResource(() => ({
* receiptId: '123',
* returnId: '456'
* }));
*
* // Access the resource value
* const receipt = receiptResource.value();
* const isLoading = receiptResource.isLoading();
*/
export const createRemissionReturnReceiptResource = (
params: () => FetchRemissionReturnParams,
) => {
const remissionReturnReceiptService = inject(RemissionReturnReceiptService);
return resource({
params,
loader: ({ abortSignal, params }) =>
remissionReturnReceiptService.fetchRemissionReturnReceipt(
params,
abortSignal,
),
});
};

View File

@@ -0,0 +1,248 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { runInInjectionContext, Injector } from '@angular/core';
import { MockProvider } from 'ng-mocks';
import { createSupplierResource } from './supplier.resource';
import { RemissionSupplierService, Supplier } from '@isa/remission/data-access';
describe('createSupplierResource', () => {
let mockService: any;
let mockSuppliers: Supplier[];
beforeEach(() => {
mockSuppliers = [
{
id: 123,
name: 'Test Supplier GmbH',
address: 'Test Street 1',
contactPerson: 'John Doe',
email: 'john@testsupplier.com',
phone: '+49123456789',
active: true,
} as Supplier,
{
id: 456,
name: 'Another Supplier Ltd',
address: 'Another Street 2',
contactPerson: 'Jane Smith',
email: 'jane@anothersupplier.com',
phone: '+49987654321',
active: true,
} as Supplier,
{
id: 789,
name: 'Inactive Supplier Corp',
address: 'Inactive Street 3',
contactPerson: 'Bob Wilson',
email: 'bob@inactivesupplier.com',
phone: '+49555666777',
active: false,
} as Supplier,
];
mockService = {
fetchSuppliers: vi.fn().mockResolvedValue(mockSuppliers),
};
TestBed.configureTestingModule({
providers: [
MockProvider(RemissionSupplierService, mockService),
],
});
});
describe('Resource Creation', () => {
it('should create resource successfully', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
it('should inject RemissionSupplierService', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(mockService).toBeDefined();
});
});
describe('Resource State Management', () => {
it('should provide loading state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource.isLoading).toBeDefined();
expect(typeof resource.isLoading).toBe('function');
});
it('should provide error state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource.error).toBeDefined();
expect(typeof resource.error).toBe('function');
});
it('should provide value state', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource.value).toBeDefined();
expect(typeof resource.value).toBe('function');
});
});
describe('Resource Loader', () => {
it('should call service fetchSuppliers method', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(mockService.fetchSuppliers).toBeDefined();
});
it('should handle successful service response', () => {
mockService.fetchSuppliers.mockResolvedValue(mockSuppliers);
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
});
it('should handle service returning empty array', () => {
mockService.fetchSuppliers.mockResolvedValue([]);
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
});
it('should handle service errors', () => {
const error = new Error('Service error');
mockService.fetchSuppliers.mockRejectedValue(error);
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(resource.error).toBeDefined();
});
});
describe('AbortSignal handling', () => {
it('should pass abortSignal to service', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(mockService.fetchSuppliers).toBeDefined();
});
it('should handle aborted requests', () => {
const abortError = new Error('Request aborted');
mockService.fetchSuppliers.mockRejectedValue(abortError);
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(resource.error).toBeDefined();
});
});
describe('Resource without parameters', () => {
it('should create resource without requiring parameters', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
expect(resource.isLoading).toBeDefined();
expect(resource.error).toBeDefined();
});
it('should automatically load suppliers on creation', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(mockService.fetchSuppliers).toBeDefined();
});
});
describe('Resource Function', () => {
it('should create resource function correctly', () => {
const createResourceFn = () => createSupplierResource();
const resource = runInInjectionContext(TestBed.inject(Injector), createResourceFn);
expect(resource).toBeDefined();
expect(typeof resource.value).toBe('function');
expect(typeof resource.isLoading).toBe('function');
expect(typeof resource.error).toBe('function');
});
it('should handle multiple resource instances', () => {
const resource1 = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
const resource2 = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource1).toBeDefined();
expect(resource2).toBeDefined();
expect(resource1).not.toBe(resource2);
});
});
describe('Service Integration', () => {
it('should integrate correctly with RemissionSupplierService', () => {
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(mockService.fetchSuppliers).toBeDefined();
});
it('should handle different supplier data types', () => {
const differentSuppliers = [
{ id: 1, name: 'Supplier One', active: true },
{ id: 2, name: 'Supplier Two', active: false },
] as Supplier[];
mockService.fetchSuppliers.mockResolvedValue(differentSuppliers);
const resource = runInInjectionContext(TestBed.inject(Injector), () =>
createSupplierResource()
);
expect(resource).toBeDefined();
expect(resource.value).toBeDefined();
});
});
});

View File

@@ -0,0 +1,28 @@
import { resource, inject } from '@angular/core';
import { RemissionSupplierService } from '@isa/remission/data-access';
/**
* Creates an Angular resource for fetching supplier data.
* The resource automatically loads all suppliers for the assigned stock.
* Results are cached for performance.
*
* @function createSupplierResource
* @returns {Resource} Angular resource that manages supplier data
*
* @example
* const supplierResource = createSupplierResource();
*
* // Access the suppliers
* const suppliers = supplierResource.value();
* const isLoading = supplierResource.isLoading();
*
* // Find a specific supplier
* const supplier = suppliers?.find(s => s.id === supplierId);
*/
export const createSupplierResource = () => {
const remissionSupplierService = inject(RemissionSupplierService);
return resource({
loader: ({ abortSignal }) =>
remissionSupplierService.fetchSuppliers(abortSignal),
});
};

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,29 @@
/// <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/remission/feature/remission-return-receipt-details',
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/remission/feature/remission-return-receipt-details',
provider: 'v8' as const,
},
},
}));

View File

@@ -0,0 +1,7 @@
# remission-feature-remission-return-receipt-list
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test remission-feature-remission-return-receipt-list` 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: 'remi',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'remi',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

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

View File

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

View File

@@ -0,0 +1,13 @@
<div class="flex flex-rows justify-end">
<filter-order-by-toolbar class="w-[44.375rem]"></filter-order-by-toolbar>
</div>
<div class="grid grid-flow-rows grid-cols-1 gap-4">
@for (remissionReturn of returns(); track remissionReturn[1].id) {
<a [routerLink]="[remissionReturn[0].id, remissionReturn[1].id]">
<remi-return-receipt-list-item
[remissionReturn]="remissionReturn[0]"
></remi-return-receipt-list-item>
</a>
}
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply grid grid-flow-row gap-8 p-6;
}

View File

@@ -0,0 +1,514 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MockComponent, MockProvider } from 'ng-mocks';
import { RemissionReturnReceiptListComponent } from './remission-return-receipt-list.component';
import { ReturnReceiptListItemComponent } from './return-receipt-list-item/return-receipt-list-item.component';
import {
RemissionReturnReceiptService,
Return,
} from '@isa/remission/data-access';
import { OrderByToolbarComponent, FilterService } from '@isa/shared/filter';
import { signal } from '@angular/core';
// Mock the filter providers
vi.mock('@isa/shared/filter', async () => {
const actual = await vi.importActual('@isa/shared/filter');
return {
...actual,
provideFilter: vi.fn(() => []),
withQueryParamsSync: vi.fn(() => ({})),
withQuerySettings: vi.fn(() => ({})),
};
});
describe('RemissionReturnReceiptListComponent', () => {
let component: RemissionReturnReceiptListComponent;
let fixture: ComponentFixture<RemissionReturnReceiptListComponent>;
let mockRemissionReturnReceiptService: {
fetchCompletedRemissionReturnReceipts: ReturnType<typeof vi.fn>;
fetchIncompletedRemissionReturnReceipts: ReturnType<typeof vi.fn>;
};
let mockFilterService: {
orderBy: any;
};
const mockCompletedReturns: Return[] = [
{
id: 1,
receipts: [
{
id: 101,
data: {
id: 101,
receiptNumber: 'REC-2024-001',
created: '2024-01-15T09:00:00.000Z',
completed: '2024-01-15T10:30:00.000Z',
items: [],
},
},
],
} as Return,
{
id: 2,
receipts: [
{
id: 102,
data: {
id: 102,
receiptNumber: 'REC-2024-002',
created: '2024-01-16T13:00:00.000Z',
completed: '2024-01-16T14:45:00.000Z',
items: [],
},
},
],
} as Return,
];
const mockIncompletedReturns: Return[] = [
{
id: 3,
receipts: [
{
id: 103,
data: {
id: 103,
receiptNumber: 'REC-2024-003',
created: '2024-01-17T08:00:00.000Z',
completed: undefined,
items: [],
},
},
],
} as Return,
];
beforeEach(async () => {
mockRemissionReturnReceiptService = {
fetchCompletedRemissionReturnReceipts: vi
.fn()
.mockResolvedValue(mockCompletedReturns),
fetchIncompletedRemissionReturnReceipts: vi
.fn()
.mockResolvedValue(mockIncompletedReturns),
};
mockFilterService = {
orderBy: signal([{ selected: false, by: 'created', dir: 'asc' }]),
};
await TestBed.configureTestingModule({
imports: [RemissionReturnReceiptListComponent],
providers: [
MockProvider(
RemissionReturnReceiptService,
mockRemissionReturnReceiptService,
),
MockProvider(FilterService, mockFilterService),
],
})
.overrideComponent(RemissionReturnReceiptListComponent, {
remove: {
imports: [ReturnReceiptListItemComponent, OrderByToolbarComponent],
},
add: {
imports: [
MockComponent(ReturnReceiptListItemComponent),
MockComponent(OrderByToolbarComponent),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(RemissionReturnReceiptListComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should have correct configuration', () => {
expect(component).toBeDefined();
expect(fixture.componentInstance).toBe(component);
});
});
describe('Resources Loading', () => {
it('should initialize resources on creation', () => {
// Resources are created in the component constructor
expect(component.completedRemissionReturnsResource).toBeDefined();
expect(component.incompletedRemissionReturnsResource).toBeDefined();
});
it('should call service methods when resources load', async () => {
// Create a new component instance to test fresh loading
const newFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent,
);
// Clear previous calls
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockClear();
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockClear();
// Initialize the component to trigger resource loading
newFixture.detectChanges();
await newFixture.whenStable();
// Verify that both service methods were called when resources load
expect(
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts,
).toHaveBeenCalled();
expect(
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts,
).toHaveBeenCalled();
});
it('should handle loading state', () => {
// Check loading state
expect(
component.completedRemissionReturnsResource.isLoading(),
).toBeDefined();
expect(
component.incompletedRemissionReturnsResource.isLoading(),
).toBeDefined();
});
it('should handle error state when service fails', async () => {
// Mock service to throw errors
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockRejectedValue(
new Error('Completed returns service failed')
);
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockRejectedValue(
new Error('Incomplete returns service failed')
);
// Create a new component to test error handling
const errorFixture = TestBed.createComponent(RemissionReturnReceiptListComponent);
const errorComponent = errorFixture.componentInstance;
// Trigger change detection to initiate resource loading
errorFixture.detectChanges();
await errorFixture.whenStable();
// Check that resources have error signals available
expect(errorComponent.completedRemissionReturnsResource.error).toBeDefined();
expect(errorComponent.incompletedRemissionReturnsResource.error).toBeDefined();
// Check that status signals indicate error states
expect(errorComponent.completedRemissionReturnsResource.status).toBeDefined();
expect(errorComponent.incompletedRemissionReturnsResource.status).toBeDefined();
});
});
describe('returns computed signal', () => {
it('should combine completed and incompleted returns with incompleted first', async () => {
// Mock the resource values
(component.completedRemissionReturnsResource as any).value =
signal(mockCompletedReturns);
(component.incompletedRemissionReturnsResource as any).value = signal(
mockIncompletedReturns,
);
const returns = component.returns();
expect(returns).toHaveLength(3);
// Returns should be tuples [Return, Receipt]
expect(returns[0][0]).toBe(mockIncompletedReturns[0]); // Incompleted first
expect(returns[0][1]).toBe(mockIncompletedReturns[0].receipts[0].data);
expect(returns[1][0]).toBe(mockCompletedReturns[0]);
expect(returns[1][1]).toBe(mockCompletedReturns[0].receipts[0].data);
expect(returns[2][0]).toBe(mockCompletedReturns[1]);
expect(returns[2][1]).toBe(mockCompletedReturns[1].receipts[0].data);
});
it('should handle empty completed returns', () => {
(component.completedRemissionReturnsResource as any).value = signal([]);
(component.incompletedRemissionReturnsResource as any).value = signal(
mockIncompletedReturns,
);
const returns = component.returns();
expect(returns).toHaveLength(1);
expect(returns[0][0]).toBe(mockIncompletedReturns[0]);
expect(returns[0][1]).toBe(mockIncompletedReturns[0].receipts[0].data);
});
it('should handle empty incompleted returns', () => {
(component.completedRemissionReturnsResource as any).value =
signal(mockCompletedReturns);
(component.incompletedRemissionReturnsResource as any).value = signal([]);
const returns = component.returns();
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(mockCompletedReturns[0]);
expect(returns[0][1]).toBe(mockCompletedReturns[0].receipts[0].data);
expect(returns[1][0]).toBe(mockCompletedReturns[1]);
expect(returns[1][1]).toBe(mockCompletedReturns[1].receipts[0].data);
});
it('should handle both empty returns', () => {
(component.completedRemissionReturnsResource as any).value = signal([]);
(component.incompletedRemissionReturnsResource as any).value = signal([]);
const returns = component.returns();
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should handle null values from resources', () => {
(component.completedRemissionReturnsResource as any).value = signal(null);
(component.incompletedRemissionReturnsResource as any).value =
signal(null);
const returns = component.returns();
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should handle undefined values from resources', () => {
(component.completedRemissionReturnsResource as any).value =
signal(undefined);
(component.incompletedRemissionReturnsResource as any).value =
signal(undefined);
const returns = component.returns();
expect(returns).toHaveLength(0);
expect(returns).toEqual([]);
});
it('should handle mixed null and valid values', () => {
(component.completedRemissionReturnsResource as any).value =
signal(mockCompletedReturns);
(component.incompletedRemissionReturnsResource as any).value =
signal(null);
const returns = component.returns();
expect(returns).toHaveLength(2);
expect(returns[0][0]).toBe(mockCompletedReturns[0]);
expect(returns[0][1]).toBe(mockCompletedReturns[0].receipts[0].data);
expect(returns[1][0]).toBe(mockCompletedReturns[1]);
expect(returns[1][1]).toBe(mockCompletedReturns[1].receipts[0].data);
});
});
describe('Query Settings', () => {
it('should have correct filter configuration', () => {
// This is tested indirectly through the component setup
// The actual filter behavior would be tested in integration tests
expect(component).toBeDefined();
});
it('should define order by options', () => {
// The QUERY_SETTINGS constant is private, but we can verify
// that the component is configured with filter providers
expect(component).toBeDefined();
});
});
describe('Component Lifecycle', () => {
it('should handle component destruction', () => {
fixture.destroy();
expect(component).toBeDefined();
});
it('should update when input changes', async () => {
// Simulate resource updates
const newCompletedReturns = [
{
id: 4,
receipts: [
{
id: 104,
data: {
id: 104,
receiptNumber: 'REC-2024-004',
completed: true,
},
},
],
} as Return,
];
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockResolvedValue(
newCompletedReturns,
);
// Mock resource value update
(component.completedRemissionReturnsResource as any).value =
signal(newCompletedReturns);
(component.incompletedRemissionReturnsResource as any).value = signal(
mockIncompletedReturns,
);
const returns = component.returns();
// Check that the tuple contains the new return
expect(
returns.some(
([returnData, _]) => returnData === newCompletedReturns[0],
),
).toBe(true);
});
});
describe('Error Handling', () => {
it('should handle service errors gracefully', async () => {
// Mock one service to succeed and one to fail
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockRejectedValue(
new Error('Network error')
);
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
mockIncompletedReturns
);
// Create a new component to test graceful error handling
const errorFixture = TestBed.createComponent(RemissionReturnReceiptListComponent);
const errorComponent = errorFixture.componentInstance;
// Trigger resource loading
errorFixture.detectChanges();
await errorFixture.whenStable();
// Verify that the component handles errors gracefully
// The component should still function with partial data
expect(errorComponent).toBeTruthy();
expect(errorComponent.completedRemissionReturnsResource).toBeDefined();
expect(errorComponent.incompletedRemissionReturnsResource).toBeDefined();
// Mock successful resource values for the returns computed signal test
(errorComponent.completedRemissionReturnsResource as any).value = signal(null);
(errorComponent.incompletedRemissionReturnsResource as any).value = signal(mockIncompletedReturns);
// The returns computed signal should handle null/error states gracefully
const returns = errorComponent.returns();
expect(returns).toHaveLength(1);
expect(returns[0][0]).toBe(mockIncompletedReturns[0]);
});
it('should handle partial failures', async () => {
mockRemissionReturnReceiptService.fetchCompletedRemissionReturnReceipts.mockRejectedValue(
new Error('Failed'),
);
mockRemissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts.mockResolvedValue(
mockIncompletedReturns,
);
const newFixture = TestBed.createComponent(
RemissionReturnReceiptListComponent,
);
const newComponent = newFixture.componentInstance;
await newFixture.whenStable();
// Mock the resource values for testing
(newComponent.completedRemissionReturnsResource as any).value =
signal(null);
(newComponent.incompletedRemissionReturnsResource as any).value = signal(
mockIncompletedReturns,
);
const returns = newComponent.returns();
expect(returns).toHaveLength(1);
expect(returns[0][0]).toBe(mockIncompletedReturns[0]);
expect(returns[0][1]).toBe(mockIncompletedReturns[0].receipts[0].data);
});
});
describe('Edge Cases', () => {
it('should handle very large return lists', () => {
const largeCompletedReturns = Array.from(
{ length: 1000 },
(_, i) =>
({
id: i,
receipts: [],
}) as Return,
);
const largeIncompletedReturns = Array.from(
{ length: 500 },
(_, i) =>
({
id: i + 1000,
receipts: [],
}) as Return,
);
(component.completedRemissionReturnsResource as any).value = signal(
largeCompletedReturns,
);
(component.incompletedRemissionReturnsResource as any).value = signal(
largeIncompletedReturns,
);
const returns = component.returns();
// With no receipts, the flattened result should be empty
expect(returns).toHaveLength(0);
});
it('should maintain order when resources update', () => {
// Test that the order logic correctly maintains incompleted first, then completed
const newCompletedReturns: Return[] = [
{
id: 5,
receipts: [
{
id: 105,
data: {
id: 105,
receiptNumber: 'REC-2024-005',
created: '2024-01-18T10:00:00.000Z',
completed: '2024-01-18T11:00:00.000Z',
items: [],
},
},
],
} as Return,
];
const newIncompletedReturns: Return[] = [
{
id: 6,
receipts: [
{
id: 106,
data: {
id: 106,
receiptNumber: 'REC-2024-006',
created: '2024-01-19T08:00:00.000Z',
completed: undefined,
items: [],
},
},
],
} as Return,
];
// Simulate resource updates by mocking the resource values
(component.completedRemissionReturnsResource as any).value = signal(newCompletedReturns);
(component.incompletedRemissionReturnsResource as any).value = signal(newIncompletedReturns);
const returns = component.returns();
expect(returns).toHaveLength(2);
// Verify that incompleted returns come first
expect(returns[0][0]).toBe(newIncompletedReturns[0]);
expect(returns[0][1]).toBe(newIncompletedReturns[0].receipts[0].data);
// Then completed returns
expect(returns[1][0]).toBe(newCompletedReturns[0]);
expect(returns[1][1]).toBe(newCompletedReturns[0].receipts[0].data);
});
});
});

View File

@@ -0,0 +1,116 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
resource,
} from '@angular/core';
import { ReturnReceiptListItemComponent } from './return-receipt-list-item/return-receipt-list-item.component';
import {
Receipt,
RemissionReturnReceiptService,
Return,
} from '@isa/remission/data-access';
import {
provideFilter,
withQueryParamsSync,
withQuerySettings,
OrderByToolbarComponent,
FilterService,
} from '@isa/shared/filter';
import { RouterLink } from '@angular/router';
import { compareAsc, compareDesc } from 'date-fns';
import { RETURN_RECEIPT_QUERY_SETTINGS } from './remission-return-receipt-list.query-settings';
/**
* Component that displays a list of remission return receipts.
* Fetches both completed and incomplete receipts and combines them for display.
* Supports filtering and sorting through query parameters.
*
* @component
* @selector remi-remission-return-receipt-list
* @standalone
*
* @example
* <remi-remission-return-receipt-list></remi-remission-return-receipt-list>
*/
@Component({
selector: 'remi-remission-return-receipt-list',
templateUrl: './remission-return-receipt-list.component.html',
styleUrls: ['./remission-return-receipt-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
ReturnReceiptListItemComponent,
OrderByToolbarComponent,
RouterLink,
],
providers: [
provideFilter(
withQuerySettings(RETURN_RECEIPT_QUERY_SETTINGS),
withQueryParamsSync(),
),
],
})
export class RemissionReturnReceiptListComponent {
/** Private instance of the remission return receipt service */
#remissionReturnReceiptService = inject(RemissionReturnReceiptService);
#filter = inject(FilterService);
orderDateBy = computed(() => this.#filter.orderBy().find((o) => o.selected));
/**
* Resource that fetches completed remission return receipts.
* Automatically loads when the component is initialized.
*/
completedRemissionReturnsResource = resource({
loader: () =>
this.#remissionReturnReceiptService.fetchCompletedRemissionReturnReceipts(),
});
/**
* Resource that fetches incomplete remission return receipts.
* Automatically loads when the component is initialized.
*/
incompletedRemissionReturnsResource = resource({
loader: () =>
this.#remissionReturnReceiptService.fetchIncompletedRemissionReturnReceipts(),
});
/**
* Computed signal that combines completed and incomplete returns.
* Maps each return with its receipts into tuples for display.
* When date ordering is selected, sorts by completion date with incomplete items first.
* @returns {Array<[Return, Receipt]>} Array of tuples containing return and receipt pairs
*/
returns = computed(() => {
const completed = this.completedRemissionReturnsResource.value() || [];
const incompleted = this.incompletedRemissionReturnsResource.value() || [];
const orderBy = this.orderDateBy();
const allReturnReceiptTuples = [...incompleted, ...completed].flatMap(
(ret) =>
ret.receipts
.filter((rec) => rec.data != null)
.map((rec) => [ret, rec.data] as [Return, Receipt]),
);
if (!orderBy) {
return allReturnReceiptTuples;
}
const orderByField = orderBy.by as 'created' | 'completed';
const compareFn = orderBy.dir === 'desc' ? compareDesc : compareAsc;
return allReturnReceiptTuples.sort((a, b) => {
const dateA = a[1][orderByField];
const dateB = b[1][orderByField];
if (!dateA) return -1;
if (!dateB) return 1;
return compareFn(dateA, dateB);
});
});
}

View File

@@ -0,0 +1,33 @@
import { QuerySettings } from '@isa/shared/filter';
/**
* Query settings configuration for filtering and sorting return receipts.
* Provides options to sort by date in ascending or descending order.
* @constant
*/
export const RETURN_RECEIPT_QUERY_SETTINGS: QuerySettings = {
filter: [],
input: [],
orderBy: [
// {
// by: 'completed',
// label: 'Remissiondatum',
// desc: true,
// },
// {
// by: 'completed',
// label: 'Remissiondatum',
// desc: false,
// },
{
by: 'created',
label: 'Datum',
desc: true,
},
{
by: 'created',
label: 'Datum',
desc: false,
},
],
};

View File

@@ -0,0 +1,14 @@
<div class="flex flex-col">
<div>Warenbegleitschein</div>
<div class="isa-text-body-1-bold">#{{ receiptNumber() }}</div>
</div>
<div class="flex flex-col">
<div>Anzahl Positionen</div>
<div class="isa-text-body-1-bold">{{ itemQuantity() }}</div>
</div>
<div class="flex-grow"></div>
<div class="flex flex-col">
<div>Status</div>
<div class="isa-text-body-1-bold">{{ status() }}</div>
</div>

View File

@@ -0,0 +1,3 @@
:host {
@apply flex flex-row justify-start gap-6 p-6 bg-isa-neutral-400 rounded-2xl text-isa-neutral-900 isa-text-body-1-regular;
}

View File

@@ -0,0 +1,597 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { describe, it, expect, beforeEach } from 'vitest';
import { ReturnReceiptListItemComponent } from './return-receipt-list-item.component';
import { Return } from '@isa/remission/data-access';
describe('ReturnReceiptListItemComponent', () => {
let component: ReturnReceiptListItemComponent;
let fixture: ComponentFixture<ReturnReceiptListItemComponent>;
const createMockReturn = (overrides: Partial<Return> = {}): Return => ({
id: 1,
receipts: [],
...overrides,
} as Return);
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ReturnReceiptListItemComponent],
}).compileComponents();
fixture = TestBed.createComponent(ReturnReceiptListItemComponent);
component = fixture.componentInstance;
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('remissionReturn', createMockReturn());
expect(component).toBeTruthy();
});
it('should have remissionReturn as required input', () => {
fixture.componentRef.setInput('remissionReturn', createMockReturn());
fixture.detectChanges();
expect(component.remissionReturn()).toBeDefined();
});
});
describe('receiptNumber computed signal', () => {
it('should return "Keine Belege vorhanden" when no receipts', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('Keine Belege vorhanden');
});
it('should return single receipt number substring', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-001');
});
it('should return multiple receipt numbers joined with comma', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
},
},
{
id: 3,
data: {
id: 3,
receiptNumber: 'REC-2024-003-GHI',
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-001, 24-002, 24-003');
});
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002');
});
it('should handle receipts with undefined receiptNumber', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: undefined as any,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-002-DEF',
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002');
});
it('should handle short receipt numbers', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'SHORT',
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('');
});
});
describe('itemQuantity computed signal', () => {
it('should return 0 when no receipts', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(0);
});
it('should return sum of all items across receipts', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
items: new Array(5),
},
},
{
id: 2,
data: {
id: 2,
items: new Array(3),
},
},
{
id: 3,
data: {
id: 3,
items: new Array(7),
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(15);
});
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
items: new Array(3),
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(3);
});
it('should handle receipts with undefined items', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
items: undefined,
},
},
{
id: 2,
data: {
id: 2,
items: new Array(5),
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(5);
});
it('should handle receipts with empty items array', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
items: [],
},
},
{
id: 2,
data: {
id: 2,
items: new Array(2),
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(2);
});
});
describe('completed computed signal', () => {
it('should return "Offen" when no receipts are completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
it('should return "Abgeschlossen" when at least one receipt is completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
{
id: 3,
data: {
id: 3,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
});
it('should return "Abgeschlossen" when all receipts are completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: true,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
});
it('should return "Offen" when no receipts exist', () => {
const mockReturn = createMockReturn({ receipts: [] });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
it('should handle receipts with null data', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Offen');
});
it('should handle receipts with undefined completed status', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: undefined as any,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.completed()).toBe('Abgeschlossen');
});
});
describe('Component reactivity', () => {
it('should update computed signals when input changes', () => {
const initialReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-001-ABC',
items: new Array(3),
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', initialReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-001');
expect(component.itemQuantity()).toBe(3);
expect(component.completed()).toBe('Offen');
const updatedReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'REC-2024-002-DEF',
items: new Array(5),
completed: true,
},
},
{
id: 2,
data: {
id: 2,
receiptNumber: 'REC-2024-003-GHI',
items: new Array(2),
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', updatedReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('24-002, 24-003');
expect(component.itemQuantity()).toBe(7);
expect(component.completed()).toBe('Abgeschlossen');
});
});
describe('status alias', () => {
it('should have status as an alias for completed', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
{
id: 2,
data: {
id: 2,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.status()).toBe(component.completed());
expect(component.status()).toBe('Abgeschlossen');
});
it('should update status when completed changes', () => {
const initialReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: false,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', initialReturn);
fixture.detectChanges();
expect(component.status()).toBe('Offen');
const updatedReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
completed: true,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', updatedReturn);
fixture.detectChanges();
expect(component.status()).toBe('Abgeschlossen');
});
});
describe('Edge cases', () => {
it('should handle return with deeply nested null values', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: null as any,
},
{
id: 2,
data: {
id: 2,
receiptNumber: null as any,
items: null as any,
completed: null as any,
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('');
expect(component.itemQuantity()).toBe(0);
expect(component.completed()).toBe('Offen');
});
it('should handle very long receipt numbers', () => {
const mockReturn = createMockReturn({
receipts: [
{
id: 1,
data: {
id: 1,
receiptNumber: 'PREFIX-VERY-LONG-RECEIPT-NUMBER-THAT-EXCEEDS-NORMAL-LENGTH',
},
},
],
});
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.receiptNumber()).toBe('-VERY-');
});
it('should handle large number of receipts', () => {
const receipts = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
data: {
id: i + 1,
receiptNumber: `REC-2024-${String(i + 1).padStart(3, '0')}-ABC`,
items: new Array(2),
completed: i % 2 === 0,
},
}));
const mockReturn = createMockReturn({ receipts });
fixture.componentRef.setInput('remissionReturn', mockReturn);
fixture.detectChanges();
expect(component.itemQuantity()).toBe(200);
expect(component.completed()).toBe('Abgeschlossen');
expect(component.receiptNumber()).toContain('24-001');
expect(component.receiptNumber()).toContain('24-100');
});
});
});

View File

@@ -0,0 +1,100 @@
import {
ChangeDetectionStrategy,
Component,
computed,
input,
} from '@angular/core';
import { Receipt, Return } from '@isa/remission/data-access';
/**
* Component that displays a single return receipt item in the list view.
* Shows receipt number, item quantity, and status information.
*
* @component
* @selector remi-return-receipt-list-item
* @standalone
*
* @example
* <remi-return-receipt-list-item
* [remissionReturn]="returnData"
* [returnReceipt]="receiptData">
* </remi-return-receipt-list-item>
*/
@Component({
selector: 'remi-return-receipt-list-item',
templateUrl: './return-receipt-list-item.component.html',
styleUrls: ['./return-receipt-list-item.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [],
})
export class ReturnReceiptListItemComponent {
/**
* Required input for the return data.
* @input
* @required
*/
remissionReturn = input.required<Return>();
/**
* Computed signal that extracts and formats receipt numbers from all receipts.
* Returns "Keine Belege vorhanden" if no receipts, otherwise returns formatted receipt numbers.
* @returns {string} The formatted receipt numbers or message
*/
receiptNumber = computed(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 'Keine Belege vorhanden';
}
const receiptNumbers = returnData.receipts
.map((receipt) => receipt.data?.receiptNumber)
.filter((receiptNumber) => receiptNumber && receiptNumber.length >= 12)
.map((receiptNumber) => receiptNumber!.substring(6, 12));
return receiptNumbers.length > 0 ? receiptNumbers.join(', ') : '';
});
/**
* Computed signal that calculates the total quantity of all items across all receipts.
* @returns {number} Total quantity of items
*/
itemQuantity = computed(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 0;
}
return returnData.receipts.reduce((totalItems, receipt) => {
const items = receipt.data?.items;
return totalItems + (items ? items.length : 0);
}, 0);
});
/**
* Computed signal that determines the completion status.
* Returns "Abgeschlossen" if any receipt is completed, "Offen" otherwise.
* @returns {'Abgeschlossen' | 'Offen'} Status text based on completion state
*/
completed = computed(() => {
const returnData = this.remissionReturn();
if (!returnData.receipts || returnData.receipts.length === 0) {
return 'Offen';
}
const hasCompletedReceipt = returnData.receipts.some(
(receipt) => receipt.data?.completed,
);
return hasCompletedReceipt ? 'Abgeschlossen' : 'Offen';
});
/**
* Alias for completed for backward compatibility with tests.
* @deprecated Use completed() instead
*/
status = this.completed;
}

View File

@@ -0,0 +1,18 @@
import { Routes } from '@angular/router';
import { RemissionReturnReceiptListComponent } from './remission-return-receipt-list.component';
export const routes: Routes = [
{
path: '',
component: RemissionReturnReceiptListComponent,
data: {
scrollPositionRestoration: true,
},
},
{
path: ':returnId/:receiptId',
loadComponent: () =>
import('@isa/remission/feature/remission-return-receipt-details').then(
(m) => m.RemissionReturnReceiptDetailsComponent,
),
},
];

View File

@@ -0,0 +1,20 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-zone';
import {
BrowserTestingModule,
platformBrowserTesting,
} from '@angular/platform-browser/testing';
import { getTestBed } from '@angular/core/testing';
import { vi } from 'vitest';
// Mock URL.createObjectURL to prevent scanner service errors
Object.defineProperty(URL, 'createObjectURL', {
writable: true,
value: vi.fn()
});
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,29 @@
/// <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/remission/feature/remission-return-receipt-list',
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/remission/feature/remission-return-receipt-list',
provider: 'v8' as const,
},
},
}));

View File

@@ -1,111 +1,116 @@
import { createComponentFactory, Spectator } from '@ngneat/spectator/jest';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ProductStockInfoComponent } from './product-stock-info.component';
describe('ProductStockInfoComponent', () => {
let spectator: Spectator<ProductStockInfoComponent>;
const createComponent = createComponentFactory(ProductStockInfoComponent);
let component: ProductStockInfoComponent;
let fixture: ComponentFixture<ProductStockInfoComponent>;
beforeEach(() => {
spectator = createComponent();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductStockInfoComponent],
}).compileComponents();
fixture = TestBed.createComponent(ProductStockInfoComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(spectator.component).toBeTruthy();
expect(component).toBeTruthy();
});
it('should display the current stock', () => {
// Arrange
spectator.setInput('stock', 42);
fixture.componentRef.setInput('stock', 42);
// Act
spectator.detectChanges();
const value = spectator.query(
fixture.detectChanges();
const value = fixture.nativeElement.querySelector(
'[data-what="stock-value"][data-which="current-stock"]',
);
// Assert
expect(value).toHaveText('42x');
expect(value?.textContent?.trim()).toBe('42x');
});
it('should display the remit amount (computed)', () => {
// Arrange
spectator.setInput('stock', 20);
spectator.setInput('removedFromStock', 5);
spectator.setInput('remainingQuantityInStock', 10);
fixture.componentRef.setInput('stock', 20);
fixture.componentRef.setInput('removedFromStock', 5);
fixture.componentRef.setInput('remainingQuantityInStock', 10);
// Act
spectator.detectChanges();
const value = spectator.query(
fixture.detectChanges();
const value = fixture.nativeElement.querySelector(
'[data-what="stock-value"][data-which="remit-amount"]',
);
// Assert
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
expect(value).toHaveText('5x');
expect(value?.textContent?.trim()).toBe('5x');
});
it('should display the remit amount as 0 when remainingQuantityInStock > availableStock', () => {
// Arrange
spectator.setInput('stock', 5);
spectator.setInput('removedFromStock', 2);
spectator.setInput('remainingQuantityInStock', 10);
fixture.componentRef.setInput('stock', 5);
fixture.componentRef.setInput('removedFromStock', 2);
fixture.componentRef.setInput('remainingQuantityInStock', 10);
// Act
spectator.detectChanges();
const value = spectator.query(
fixture.detectChanges();
const value = fixture.nativeElement.querySelector(
'[data-what="stock-value"][data-which="remit-amount"]',
);
// Assert
expect(value).toHaveText('0x');
expect(value?.textContent?.trim()).toBe('0x');
});
it('should display the remaining stock (targetStock, computed)', () => {
// Arrange
spectator.setInput('stock', 20);
spectator.setInput('removedFromStock', 5);
spectator.setInput('predefinedReturnQuantity', 5);
fixture.componentRef.setInput('stock', 20);
fixture.componentRef.setInput('removedFromStock', 5);
fixture.componentRef.setInput('predefinedReturnQuantity', 5);
// Act
spectator.detectChanges();
const value = spectator.query(
fixture.detectChanges();
const value = fixture.nativeElement.querySelector(
'[data-what="stock-value"][data-which="remaining-stock"]',
);
// Assert
// availableStock = 20 - 5 = 15; targetStock = 15 - 5 = 10
expect(value).toHaveText('10x');
expect(value?.textContent?.trim()).toBe('10x');
});
it('should display the remaining stock as 0 when predefinedReturnQuantity > availableStock', () => {
// Arrange
spectator.setInput('stock', 8);
spectator.setInput('removedFromStock', 3);
spectator.setInput('predefinedReturnQuantity', 10);
fixture.componentRef.setInput('stock', 8);
fixture.componentRef.setInput('removedFromStock', 3);
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
// Act
spectator.detectChanges();
const value = spectator.query(
fixture.detectChanges();
const value = fixture.nativeElement.querySelector(
'[data-what="stock-value"][data-which="remaining-stock"]',
);
// Assert
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
expect(value).toHaveText('0x');
expect(value?.textContent?.trim()).toBe('0x');
});
it('should display the zob value', () => {
// Arrange
spectator.setInput('zob', 99);
fixture.componentRef.setInput('zob', 99);
// Act
spectator.detectChanges();
const value = spectator.query(
fixture.detectChanges();
const value = fixture.nativeElement.querySelector(
'[data-what="stock-value"][data-which="zob"]',
);
// Assert
expect(value).toHaveText('99x');
expect(value?.textContent?.trim()).toBe('99x');
});
it('should render all labels with correct e2e attributes', () => {
@@ -117,22 +122,25 @@ describe('ProductStockInfoComponent', () => {
{ which: 'zob', text: 'ZOB' },
];
// Act & Assert
// Act
fixture.detectChanges();
// Assert
labels.forEach(({ which, text }) => {
const label = spectator.query(
const label = fixture.nativeElement.querySelector(
`[data-what="stock-label"][data-which="${which}"]`,
);
expect(label).toHaveText(text);
expect(label?.textContent?.trim()).toBe(text);
});
});
it('should compute availableStock correctly (stock > removedFromStock)', () => {
// Arrange
spectator.setInput('stock', 10);
spectator.setInput('removedFromStock', 3);
fixture.componentRef.setInput('stock', 10);
fixture.componentRef.setInput('removedFromStock', 3);
// Act
const result = spectator.component.availableStock();
const result = component.availableStock();
// Assert
expect(result).toBe(7);
@@ -140,11 +148,11 @@ describe('ProductStockInfoComponent', () => {
it('should compute availableStock as 0 when removedFromStock > stock', () => {
// Arrange
spectator.setInput('stock', 5);
spectator.setInput('removedFromStock', 10);
fixture.componentRef.setInput('stock', 5);
fixture.componentRef.setInput('removedFromStock', 10);
// Act
const result = spectator.component.availableStock();
const result = component.availableStock();
// Assert
expect(result).toBe(0);
@@ -152,12 +160,12 @@ describe('ProductStockInfoComponent', () => {
it('should compute stockToRemit correctly (positive result)', () => {
// Arrange
spectator.setInput('stock', 20);
spectator.setInput('removedFromStock', 5);
spectator.setInput('remainingQuantityInStock', 10);
fixture.componentRef.setInput('stock', 20);
fixture.componentRef.setInput('removedFromStock', 5);
fixture.componentRef.setInput('remainingQuantityInStock', 10);
// Act
const result = spectator.component.stockToRemit();
const result = component.stockToRemit();
// Assert
// availableStock = 20 - 5 = 15; stockToRemit = 15 - 10 = 5
@@ -166,12 +174,12 @@ describe('ProductStockInfoComponent', () => {
it('should compute stockToRemit as 0 when remainingQuantityInStock > availableStock', () => {
// Arrange
spectator.setInput('stock', 5);
spectator.setInput('removedFromStock', 2);
spectator.setInput('remainingQuantityInStock', 10);
fixture.componentRef.setInput('stock', 5);
fixture.componentRef.setInput('removedFromStock', 2);
fixture.componentRef.setInput('remainingQuantityInStock', 10);
// Act
const result = spectator.component.stockToRemit();
const result = component.stockToRemit();
// Assert
// availableStock = 5 - 2 = 3; stockToRemit = 3 - 10 = -7 => 0
@@ -180,12 +188,12 @@ describe('ProductStockInfoComponent', () => {
it('should compute targetStock correctly (positive result)', () => {
// Arrange
spectator.setInput('stock', 30);
spectator.setInput('removedFromStock', 5);
spectator.setInput('predefinedReturnQuantity', 10);
fixture.componentRef.setInput('stock', 30);
fixture.componentRef.setInput('removedFromStock', 5);
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
// Act
const result = spectator.component.targetStock();
const result = component.targetStock();
// Assert
// availableStock = 30 - 5 = 25; targetStock = 25 - 10 = 15
@@ -194,12 +202,12 @@ describe('ProductStockInfoComponent', () => {
it('should compute targetStock as 0 when predefinedReturnQuantity > availableStock', () => {
// Arrange
spectator.setInput('stock', 8);
spectator.setInput('removedFromStock', 3);
spectator.setInput('predefinedReturnQuantity', 10);
fixture.componentRef.setInput('stock', 8);
fixture.componentRef.setInput('removedFromStock', 3);
fixture.componentRef.setInput('predefinedReturnQuantity', 10);
// Act
const result = spectator.component.targetStock();
const result = component.targetStock();
// Assert
// availableStock = 8 - 3 = 5; targetStock = 5 - 10 = -5 => 0
@@ -208,13 +216,13 @@ describe('ProductStockInfoComponent', () => {
it('should compute targetStock using stockToRemit when remainingQuantityInStock is zero or falsy', () => {
// Arrange
spectator.setInput('stock', 15);
spectator.setInput('removedFromStock', 5);
spectator.setInput('predefinedReturnQuantity', 0);
spectator.setInput('remainingQuantityInStock', 0);
fixture.componentRef.setInput('stock', 15);
fixture.componentRef.setInput('removedFromStock', 5);
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
fixture.componentRef.setInput('remainingQuantityInStock', 0);
// Act
const result = spectator.component.targetStock();
const result = component.targetStock();
// Assert
// availableStock = 15 - 5 = 10
@@ -225,12 +233,12 @@ describe('ProductStockInfoComponent', () => {
it('should compute targetStock using remainingQuantityInStock when it is set (non-zero)', () => {
// Arrange
spectator.setInput('stock', 20);
spectator.setInput('removedFromStock', 5);
spectator.setInput('remainingQuantityInStock', 7);
fixture.componentRef.setInput('stock', 20);
fixture.componentRef.setInput('removedFromStock', 5);
fixture.componentRef.setInput('remainingQuantityInStock', 7);
// Act
const result = spectator.component.targetStock();
const result = component.targetStock();
// Assert
// Should return remainingQuantityInStock directly
@@ -239,13 +247,13 @@ describe('ProductStockInfoComponent', () => {
it('should compute targetStock as 0 if stockToRemit is greater than availableStock', () => {
// Arrange
spectator.setInput('stock', 5);
spectator.setInput('removedFromStock', 2);
spectator.setInput('remainingQuantityInStock', 0);
spectator.setInput('predefinedReturnQuantity', 0);
fixture.componentRef.setInput('stock', 5);
fixture.componentRef.setInput('removedFromStock', 2);
fixture.componentRef.setInput('remainingQuantityInStock', 0);
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
// Act
const result = spectator.component.targetStock();
const result = component.targetStock();
// Assert
// availableStock = 5 - 2 = 3
@@ -256,13 +264,13 @@ describe('ProductStockInfoComponent', () => {
it('should compute stockToRemit as predefinedReturnQuantity if set (non-zero)', () => {
// Arrange
spectator.setInput('stock', 10);
spectator.setInput('removedFromStock', 2);
spectator.setInput('predefinedReturnQuantity', 4);
spectator.setInput('remainingQuantityInStock', 5);
fixture.componentRef.setInput('stock', 10);
fixture.componentRef.setInput('removedFromStock', 2);
fixture.componentRef.setInput('predefinedReturnQuantity', 4);
fixture.componentRef.setInput('remainingQuantityInStock', 5);
// Act
const result = spectator.component.stockToRemit();
const result = component.stockToRemit();
// Assert
// Should return predefinedReturnQuantity directly
@@ -271,13 +279,13 @@ describe('ProductStockInfoComponent', () => {
it('should compute stockToRemit as 0 if availableStock and remainingQuantityInStock are both zero', () => {
// Arrange
spectator.setInput('stock', 0);
spectator.setInput('removedFromStock', 0);
spectator.setInput('predefinedReturnQuantity', 0);
spectator.setInput('remainingQuantityInStock', 0);
fixture.componentRef.setInput('stock', 0);
fixture.componentRef.setInput('removedFromStock', 0);
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
fixture.componentRef.setInput('remainingQuantityInStock', 0);
// Act
const result = spectator.component.stockToRemit();
const result = component.stockToRemit();
// Assert
expect(result).toBe(0);
@@ -285,55 +293,53 @@ describe('ProductStockInfoComponent', () => {
it('should handle all-zero inputs for computed properties', () => {
// Arrange
spectator.setInput('stock', 0);
spectator.setInput('removedFromStock', 0);
spectator.setInput('remainingQuantityInStock', 0);
spectator.setInput('predefinedReturnQuantity', 0);
fixture.componentRef.setInput('stock', 0);
fixture.componentRef.setInput('removedFromStock', 0);
fixture.componentRef.setInput('remainingQuantityInStock', 0);
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
// Act & Assert
expect(spectator.component.availableStock()).toBe(0);
expect(spectator.component.stockToRemit()).toBe(0);
expect(spectator.component.targetStock()).toBe(0);
expect(component.availableStock()).toBe(0);
expect(component.stockToRemit()).toBe(0);
expect(component.targetStock()).toBe(0);
});
it('should display all values as 0x when all inputs are zero', () => {
// Arrange
spectator.setInput('stock', 0);
spectator.setInput('removedFromStock', 0);
spectator.setInput('remainingQuantityInStock', 0);
spectator.setInput('predefinedReturnQuantity', 0);
spectator.setInput('zob', 0);
fixture.componentRef.setInput('stock', 0);
fixture.componentRef.setInput('removedFromStock', 0);
fixture.componentRef.setInput('remainingQuantityInStock', 0);
fixture.componentRef.setInput('predefinedReturnQuantity', 0);
fixture.componentRef.setInput('zob', 0);
// Act
spectator.detectChanges();
fixture.detectChanges();
// Assert
expect(
spectator.query('[data-what="stock-value"][data-which="current-stock"]'),
).toHaveText('0x');
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="current-stock"]')?.textContent?.trim()
).toBe('0x');
expect(
spectator.query('[data-what="stock-value"][data-which="remit-amount"]'),
).toHaveText('0x');
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="remit-amount"]')?.textContent?.trim()
).toBe('0x');
expect(
spectator.query(
'[data-what="stock-value"][data-which="remaining-stock"]',
),
).toHaveText('0x');
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="remaining-stock"]')?.textContent?.trim()
).toBe('0x');
expect(
spectator.query('[data-what="stock-value"][data-which="zob"]'),
).toHaveText('0x');
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="zob"]')?.textContent?.trim()
).toBe('0x');
});
it('should display correct values when only zob is set', () => {
// Arrange
spectator.setInput('zob', 123);
fixture.componentRef.setInput('zob', 123);
// Act
spectator.detectChanges();
fixture.detectChanges();
// Assert
expect(
spectator.query('[data-what="stock-value"][data-which="zob"]'),
).toHaveText('123x');
fixture.nativeElement.querySelector('[data-what="stock-value"][data-which="zob"]')?.textContent?.trim()
).toBe('123x');
});
});

View File

@@ -3,7 +3,7 @@
<div class="flex-grow"></div>
@for (orderBy of orderByOptions(); track orderBy.by) {
<button
class="flex flex-1 gap-1 items-center text-nowrap"
class="flex flex-grow-0 gap-1 items-center text-nowrap"
uiTextButton
type="button"
(click)="toggleOrderBy(orderBy)"

View File

@@ -0,0 +1,7 @@
# ui-bullet-list
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test ui-bullet-list` 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: 'ui',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'ui',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

Some files were not shown because too many files have changed in this diff Show More