mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 1879: Warenbegleitschein Übersicht und Details
Related work items: #5137, #5138
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -70,3 +70,6 @@ storybook-static
|
||||
|
||||
vite.config.*.timestamp*
|
||||
vitest.config.*.timestamp*
|
||||
|
||||
.mcp.json
|
||||
.memory.json
|
||||
|
||||
@@ -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()],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
277
libs/common/decorators/README.md
Normal file
277
libs/common/decorators/README.md
Normal 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`
|
||||
34
libs/common/decorators/eslint.config.cjs
Normal file
34
libs/common/decorators/eslint.config.cjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
20
libs/common/decorators/project.json
Normal file
20
libs/common/decorators/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
libs/common/decorators/src/index.ts
Normal file
1
libs/common/decorators/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/in-flight.decorator';
|
||||
321
libs/common/decorators/src/lib/in-flight.decorator.spec.ts
Normal file
321
libs/common/decorators/src/lib/in-flight.decorator.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
251
libs/common/decorators/src/lib/in-flight.decorator.ts
Normal file
251
libs/common/decorators/src/lib/in-flight.decorator.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
13
libs/common/decorators/src/test-setup.ts
Normal file
13
libs/common/decorators/src/test-setup.ts
Normal 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(),
|
||||
);
|
||||
30
libs/common/decorators/tsconfig.json
Normal file
30
libs/common/decorators/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
libs/common/decorators/tsconfig.lib.json
Normal file
27
libs/common/decorators/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
29
libs/common/decorators/tsconfig.spec.json
Normal file
29
libs/common/decorators/tsconfig.spec.json
Normal 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"]
|
||||
}
|
||||
27
libs/common/decorators/vite.config.mts
Normal file
27
libs/common/decorators/vite.config.mts
Normal 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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './lib/services';
|
||||
export * from './lib/schemas';
|
||||
export * from './lib/models';
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
export const ASSIGNED_STOCK_STORAGE_KEY =
|
||||
'd8a11dd9-1f32-4646-881d-6ec856cbe9d0';
|
||||
|
||||
export const SUPPLIER_STORAGE_KEY = '48872c78-ad7f-455d-b775-07b00920f80d';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { KeyValueDTOOfStringAndString } from '@generated/swagger/inventory-api';
|
||||
|
||||
export interface KeyValueStringAndString extends KeyValueDTOOfStringAndString {}
|
||||
export type KeyValueStringAndString = KeyValueDTOOfStringAndString;
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { QuerySettingsDTO } from '@generated/swagger/inventory-api';
|
||||
|
||||
export interface QuerySettings extends QuerySettingsDTO {}
|
||||
export type QuerySettings = QuerySettingsDTO;
|
||||
|
||||
35
libs/remission/data-access/src/lib/models/receipt-item.ts
Normal file
35
libs/remission/data-access/src/lib/models/receipt-item.ts
Normal 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;
|
||||
}
|
||||
44
libs/remission/data-access/src/lib/models/receipt.ts
Normal file
44
libs/remission/data-access/src/lib/models/receipt.ts
Normal 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>[];
|
||||
}
|
||||
36
libs/remission/data-access/src/lib/models/return.ts
Normal file
36
libs/remission/data-access/src/lib/models/return.ts
Normal 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>[];
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
import { StockInfoDTO } from '@generated/swagger/inventory-api';
|
||||
|
||||
export interface StockInfo extends StockInfoDTO {}
|
||||
export type StockInfo = StockInfoDTO;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { SupplierDTO } from '@generated/swagger/inventory-api';
|
||||
|
||||
export interface Supplier extends SupplierDTO {}
|
||||
export type Supplier = SupplierDTO;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FetchProductGroupsSchema = z.object({
|
||||
assignedStockId: z.number(),
|
||||
});
|
||||
|
||||
export type FetchProductGroups = z.infer<typeof FetchProductGroupsSchema>;
|
||||
@@ -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
|
||||
>;
|
||||
@@ -1,7 +0,0 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FetchSuppliersSchema = z.object({
|
||||
assignedStockId: z.number(),
|
||||
});
|
||||
|
||||
export type FetchSuppliers = z.infer<typeof FetchSuppliersSchema>;
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/remission-return-receipt-details.component';
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(', ') || ''
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply grid grid-cols-[3.5rem,15.5rem,1fr,auto] gap-6 p-4 text-isa-neutral-900;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 || ''
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply grid grid-flow-row gap-4 p-4 text-isa-neutral-900;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './product-group.resource';
|
||||
export * from './remission-return-receipt.resource';
|
||||
export * from './supplier.resource';
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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.
|
||||
@@ -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: {},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './lib/routes';
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply grid grid-flow-row gap-8 p-6;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)"
|
||||
|
||||
7
libs/ui/bullet-list/README.md
Normal file
7
libs/ui/bullet-list/README.md
Normal 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.
|
||||
34
libs/ui/bullet-list/eslint.config.cjs
Normal file
34
libs/ui/bullet-list/eslint.config.cjs
Normal 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
Reference in New Issue
Block a user