Compare commits

...

37 Commits

Author SHA1 Message Date
Nino
61a09bfacc feat(utils-ean-validation, remission-list): add EAN validation library and implement exact search
Create new EAN validation utility library with validator function and isEan helper.
Implement exact search functionality for remission lists that bypasses filters
when scanning EAN codes or performing exact searches.

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

Ref: #5128
2025-08-01 12:45:01 +02:00
Nino Righi
d7d535c10d Merged PR 1903: fix(remission-list, product-info, search-item-to-remit): improve responsive l...
fix(remission-list, product-info, search-item-to-remit): improve responsive layout and fix orientation logic

- Fix grid layout responsiveness in remission-list-item component by updating breakpoint conditions from mobileBreakpoint to desktopBreakpoint
- Correct product-info orientation logic to properly apply horizontal/vertical layouts based on breakpoint state
- Add consistent orientation handling to search-item-to-remit component with proper breakpoint detection
- Update CSS classes to use desktop-large breakpoint for better grid column management
- Add bottom margin to remission list container to prevent overlap with fixed action button
- Enhance test coverage for new computed properties and breakpoint-dependent behavior

Ref: #5239
2025-07-31 16:44:06 +00:00
Nino Righi
ad00899b6e Merged PR 1902: feat(shared-filter-inputs-checkbox-input): add bulk toggle functionality for...
feat(shared-filter-inputs-checkbox-input): add bulk toggle functionality for checkbox options

Replace individual option iteration with new toggleAllCheckboxOptions method
in FilterService. This improves performance and provides cleaner API for
selecting/deselecting all checkbox options at once. Updates component logic
to use the new bulk operation and fixes test expectations accordingly.

Ref: #5231
2025-07-31 16:42:37 +00:00
Michael Auer
1e84223076 ~ azure-pipelines.yml: DockerTagSourceBranch _ ==> - 2025-07-30 17:46:32 +02:00
Nino Righi
244984b6cf Merged PR 1900: feat(remission): add getStockToRemit helper and improve stock calculation logic
feat(remission): add getStockToRemit helper and improve stock calculation logic

Add new getStockToRemit helper function that handles different remission list types
(Pflicht and Abteilung) for calculating stock to remit. Refactor existing logic
to use the centralized helper instead of duplicated calculation code.

Changes:
- Add getStockToRemit function to handle RemissionListType-specific logic
- Update calculateStockToRemit to use strict undefined check for predefinedReturnQuantity
- Refactor RemissionListItemComponent to use getStockToRemit helper
- Update RemissionListComponent to use getStockToRemit for consistent calculations
- Add comprehensive test coverage for both helper functions

This centralizes stock calculation logic and ensures consistent behavior
across all remission components.

Ref: #5252
2025-07-30 12:00:08 +00:00
Lorenz Hilpert
b39abe630d Merged PR 1899: feat(empty-state): enhance empty state component with new appearance options...
feat(empty-state): enhance empty state component with new appearance options and integration in remission details

Related work items: #5232
2025-07-30 08:54:09 +00:00
Lorenz Hilpert
239ab52890 Merged PR 1898: chore: update dependencies to latest versions
chore: update dependencies to latest versions

- Upgraded @ngrx packages from 19.2.1 to ^20.0.0
- Upgraded ngx-matomo-client from ^7.0.1 to ^8.0.0
- Upgraded jest and related packages from 30.0.4 to ^29.7.0
2025-07-30 08:52:36 +00:00
Nino
4732656a0f chore(remission, navigation): update routing and remove unused helpers 2025-07-29 12:18:07 +02:00
Nino Righi
0da9800ca0 Merged PR 1897: #5236 #4771 Abteilungsremission
- feat(remission-list): Added Tooltip and Static Toolbar
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- feat(remission-list, shared-filter, ui-input-controls): enhance department filtering and UI improvements
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- Merge branch 'develop' into feature/5236-Remission-Abteilungsremission-Offene-Punkte
- feat(remission-list, remission-data-access): add department capacity display functionality

#5236 #4771 Abteilungsremission
2025-07-28 19:28:14 +00:00
Lorenz Hilpert
baf4a0dfbc Merge branch 'develop' of https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend into develop 2025-07-28 19:11:36 +02:00
Lorenz Hilpert
da5a42280a feat(remission): enhance quantity input handling and error validation
Refactor quantity input to use a direct input field instead of a toggle button.
Add validation to ensure quantity does not exceed 999 and display relevant error messages.
Improve overall user experience in the remission process.

Refs: #5253
2025-07-28 19:11:08 +02:00
Lorenz Hilpert
4d29189c8d Merged PR 1896: feat(filter): add maximum selection limits for checkbox filters
feat(filter): add maximum selection limits for checkbox filters

Implement a maxOptions property to limit the number of selections in checkbox filters.
This feature includes FIFO behavior for managing selections and hides the "Select All"
control when limits are set to prevent user confusion. Update related documentation
and components to reflect these changes.

Refs: #5250
2025-07-28 16:01:34 +00:00
Lorenz Hilpert
32bd3e26d2 Merged PR 1895: feat(app): provide default currency code as EUR
feat(app): provide default currency code as EUR

Refs: #5247 #5248
2025-07-28 12:00:38 +00:00
Lorenz Hilpert
6d26f7f6c0 Merged PR 1894: feat(filter): enhance search trigger handling and event emissions
feat(filter): enhance search trigger handling and event emissions

Refactor search components to emit specific search trigger types,
improving tracking of user interactions. Update relevant components
to handle 'input', 'filter', 'scan', and 'order-by' triggers,
ensuring consistent behavior across the filter system.

Refs: #5234
2025-07-28 08:54:15 +00:00
Lorenz Hilpert
72bcacefb6 Merged PR 1893: feat(remission): add remission processed hint component and update schemas
feat(remission): add remission processed hint component and update schemas

- Introduced RemissionProcessedHintComponent to display hints based on remission processing status.
- Updated fetch-remission-return-receipts schema to include parameters for completed returns.
- Refactored remission return receipt service to handle completed and incomplete returns separately.
- Adjusted remission list component to utilize the new hint component and updated data fetching logic.

Refs: #5240 #5136
2025-07-28 08:30:04 +00:00
Lorenz Hilpert
71e9a6da0e Merged PR 1892: refactor(return-receipt-list-item): restructure styles and remove unused imports
refactor(return-receipt-list-item): restructure styles and remove unused imports

Updated the SCSS to separate background styles for different states and removed the unused Receipt import from the component.

Refs: #5225
2025-07-28 08:26:54 +00:00
Lorenz Hilpert
b339a6d79f Merged PR 1891: feat: implement multi-level checkbox filter with hierarchical selection
feat: implement multi-level checkbox filter with hierarchical selection

- Add support for hierarchical checkbox options with parent-child relationships
- Implement automatic child selection/deselection when parent is toggled
- Add checkbox-input-control component for individual option management
- Add isCheckboxSelected helper for determining selection states
- Extend FilterService with setInputCheckboxOptionSelected method
- Update checkbox schemas to support nested option structures
- Add comprehensive test coverage for new multi-level functionality

Ref: #5231
2025-07-25 13:49:44 +00:00
Lorenz Hilpert
0b4aef5f6c chore: add nx.instructions.md to .gitignore 2025-07-25 10:37:44 +02:00
Nino Righi
c5182809ac Merged PR 1890: #5230 #5233 Remi Starten Feedback
- feat(remission-data-access,remission-list,remission-return-receipt-details): improve remission list UX and persist store state
- feat(remission-list, remission-data-access): implement resource-based receipt data fetching
- Merge branch 'develop' into feature/5230-Feedback-Remi-Starten
- feat(remission-data-access, remission-list, ui-dialog, remission-start-dialog): consolidate remission workflow and enhance dialog system
- feat(remission-list-item): extract selection logic into dedicated component
Refs: #5230 #5233
2025-07-24 21:22:02 +00:00
Lorenz Hilpert
f4b541c7c0 chore: update package.json to include overrides for jest-environment-jsdom and stylus 2025-07-23 17:40:37 +02:00
Lorenz Hilpert
afe6c6abcc chore: update package.json to override stylus version to 0.64.0 and ensure jsdom is set to 26.0.0 2025-07-23 17:29:37 +02:00
Lorenz Hilpert
3f233f9580 Merge tag '4.0' into develop
Finish Release 4.0 4.0
2025-07-23 17:02:32 +02:00
Lorenz Hilpert
6f9d4d9218 Merge branch 'release/4.0' 2025-07-23 16:35:08 +02:00
Lorenz Hilpert
4111663d8c feat: add mock for ScannerButtonComponent and update feedback dialog
- Created a mock for ScannerButtonComponent in test-mocks.ts to facilitate testing.
- Updated test-setup.ts to mock browser APIs for the test environment.
- Refactored SelectRemiQuantityAndReasonComponent to simplify addToRemiList logic and update feedback dialog usage.
- Modified feedback-dialog.component.html to safely access message data.
- Cleaned up package-lock.json by removing deprecated and unnecessary dependencies.
2025-07-22 15:06:25 +02:00
Lorenz Hilpert
2beeba5c92 fix: resolve critical security vulnerability in form-data
- Updated form-data from 4.0.3 to 4.0.4
- Fixes GHSA-fjxv-7rqg-78g4: unsafe random function usage for boundary selection
- Applied npm audit fix --force due to peer dependency conflicts
2025-07-21 23:11:54 +02:00
Lorenz Hilpert
edab1322c8 chore: migrate nx to latest 2025-07-21 22:35:11 +02:00
Lorenz Hilpert
59ce736faa feat(remission-return-receipt-list): rewrite unit tests with Angular Testing Utilities
- Replace Spectator with Angular's official TestBed and ComponentFixture
- Implement isolated test approach with proper AAA pattern
- Fix TypeScript errors related to Return interface type mismatches
- Add comprehensive edge case testing and error handling
- Create proper mock components for child dependencies
- Ensure all 47 tests pass with improved maintainability
2025-07-21 20:07:02 +02:00
Nino Righi
3cd6f4bd58 Merged PR 1889: feat(remission-data-access, remission-list, remission-start-card): add remission item selection and quantity update logic
- Introduce `addReturnItem` and `addReturnSuggestionItem` methods to `RemissionReturnReceiptService` with full schema validation and error handling
- Add models and schemas for receipt-return tuples and add-return-item/suggestion operations
- Refactor `RemissionStore` (formerly `RemissionSelectionStore`) to support selection, quantity updates, and clearing of remission items; update all usages to new store name and API
- Update `RemissionListItemComponent` to support item selection via checkbox and quantity dialog, following workspace UX and state management guidelines
- Enhance `RemissionListComponent` to handle selected items, batch remission, and error/success feedback using new store and service APIs
- Fix and extend tests for new store and service logic, ensuring coverage for selection, quantity, and remission flows
- Update remission start dialog and assign package number components for improved validation and loading state handling

Ref: #5221
2025-07-21 10:28:12 +00:00
Lorenz Hilpert
594acaa5f5 feat(button): add disabled state input to stateful button component 2025-07-21 08:39:33 +02:00
Lorenz Hilpert
76ff54dd3a Merged PR 1887: feat(navigation): add collapsible submenu for remission navigation #5223
feat(navigation): add collapsible submenu for remission navigation #5223

- Convert remission navigation to expandable submenu with arrow toggle
- Add 'list' route for remission with redirect from empty path
- Separate navigation items for Remission and Warenbegleitscheine
- Refactor side-menu component to use inject() pattern
- Add remissionExpanded signal to track submenu state
2025-07-18 06:40:24 +00:00
Lorenz Hilpert
598df7d5ed Merged PR 1888: fix: improve sorting of remission return receipts
fix: improve sorting of remission return receipts

- Refactor data fetching to use a single API call for all returns
- Apply sorting separately to completed and incomplete returns
- Fix template tracking to use index instead of potentially undefined ID
- Remove redundant API calls for incomplete returns

This ensures proper sorting of remission return receipts while maintaining
the separation between completed and incomplete items in the display order.

Ref: #5224
2025-07-18 06:39:44 +00:00
Lorenz Hilpert
442670bdd0 Merged PR 1885: Remi Add Flow - ohne offener Remi
Related work items: #5135
2025-07-17 13:53:36 +00:00
Lorenz Hilpert
b015e97e1f Merged PR 1886: feat: add unit tests for remission return receipt functionality
feat: add unit tests for remission return receipt functionality

- Add tests for 4 new RemissionReturnReceiptService methods:
  - removeReturnItemFromReturnReceipt()
  - completeReturnReceipt()
  - completeReturn()
  - completeReturnReceiptAndReturn()
- Update RemissionReturnReceiptDetailsCardComponent tests for itemCount -> positionCount
- Add tests for new inputs and remove functionality in RemissionReturnReceiptDetailsItemComponent
- Add tests for canRemoveItems and completeReturn in RemissionReturnReceiptDetailsComponent
- All tests focus on happy path scenarios and isolated functionality

Refs: #5138
2025-07-17 13:46:32 +00:00
Nino Righi
65ab3bfc0a Merged PR 1884: #5213
- feat(dialog-feedback-dialog, remission-list-item): add feedback dialog and remission list item components
- feat(remission-list-item): implement remission list item component
Refs: #5213
2025-07-15 11:26:03 +00:00
Lorenz Hilpert
e674378080 Merged PR 1883: fix(return-details): update email validation and improve error handling
fix(return-details): update email validation and improve error handling

Refs: #5211
2025-07-14 14:57:41 +00:00
Lorenz Hilpert
40c9d51dfc Merged PR 1881: Stateful Remi Button
#5203

Related work items: #5203
2025-07-14 11:57:03 +00:00
Nino Righi
5f74c6ddf8 Merged PR 1878: Refs: #4769, #5196
- feat(remission-shared-produt-shelf-meta-info): Intermediate commit.
- feat(remission-shared-product-shelf-meta-info): improve template structure and data attributes
- feat(remission-list-item): add product shelf meta info and improve E2E selectors

Refs: #4769, #5196
2025-07-11 19:53:56 +00:00
275 changed files with 27626 additions and 10412 deletions

View File

@@ -4,6 +4,8 @@
You are Mentor, an AI assistant focused on ensuring code quality, strict adherence to best practices, and development efficiency. **Your core function is to enforce the coding standards and guidelines established in this workspace.** Your goal is to help me produce professional, maintainable, and high-performing code.
**Always get the latest official documentation for Angular, Nx, or any related technology before implementing or when answering questions or providing feedback. Use Context7:**
## Tone and Personality
Maintain a professional, objective, and direct tone consistently:

View File

@@ -1,41 +0,0 @@
---
applyTo: '**'
---
// This file is automatically generated by Nx Console
You are in an nx workspace using Nx 21.2.1 and npm as the package manager.
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
# General Guidelines
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
# Generation Guidelines
If the user wants to generate something, use the following flow:
- learn about the nx workspace and any specifics the user needs by using the 'nx_workspace' tool and the 'nx_project_details' tool if applicable
- get the available generators using the 'nx_generators' tool
- decide which generator to use. If no generators seem relevant, check the 'nx_available_plugins' tool to see if the user could install a plugin to help them
- get generator details using the 'nx_generator_schema' tool
- you may use the 'nx_docs' tool to learn more about a specific generator or technology if you're unsure
- decide which options to provide in order to best complete the user's request. Don't make any assumptions and keep the options minimalistic
- open the generator UI using the 'nx_open_generate_ui' tool
- wait for the user to finish the generator
- read the generator log file using the 'nx_read_generator_log' tool
- use the information provided in the log file to answer the user's question or continue with what they were doing
# Running Tasks Guidelines
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
- Use the 'nx_current_running_tasks_details' tool to get the list of tasks (this can include tasks that were completed, stopped or failed).
- If there are any tasks, ask the user if they would like help with a specific task then use the 'nx_current_running_task_output' tool to get the terminal output for that task/command
- Use the terminal output from 'nx_current_running_task_output' to see what's wrong and help the user fix their problem. Use the appropriate tools if necessary
- If the user would like to rerun the task or command, always use `nx run <taskId>` to rerun in the terminal. This will ensure that the task will run in the nx context and will be run the same way it originally executed
- If the task was marked as "continuous" do not offer to rerun the task. This task is already running and the user can see the output in the terminal. You can use 'nx_current_running_task_output' to get the output of the task to verify the output.

View File

@@ -2,8 +2,10 @@
## Framework and Tools
- Use **Jest** as the testing framework.
- For unit tests, utilize **Spectator** to simplify Angular component testing.
- **Vitest** is the recommended testing framework.
[Vitest Documentation (latest)](https://context7.com/vitest-dev/vitest/llms.txt?topic=getting+started)
- **Jest** and **Spectator** are **deprecated**.
Do not use them for new tests. Existing tests should be migrated to Vitest where possible.
## Guidelines
@@ -23,28 +25,31 @@
## Example Test Structure
```typescript
// Example using Jest and Spectator
import { createComponentFactory, Spectator } from '@ngneat/spectator';
// Example using Vitest (Jest and Spectator are deprecated)
import { describe, it, expect, beforeEach } from 'vitest';
import { render } from '@testing-library/angular';
import { MyComponent } from './my-component.component';
describe('MyComponent', () => {
let spectator: Spectator<MyComponent>;
const createComponent = createComponentFactory(MyComponent);
let component: MyComponent;
beforeEach(() => {
spectator = createComponent();
beforeEach(async () => {
const { fixture } = await render(MyComponent);
component = fixture.componentInstance;
});
it('should display the correct title', () => {
it('should display the correct title', async () => {
// Arrange
const expectedTitle = 'Hello World';
// Act
spectator.component.title = expectedTitle;
spectator.detectChanges();
component.title = expectedTitle;
// If using Angular, trigger change detection:
// fixture.detectChanges();
// Assert
expect(spectator.query('h1')).toHaveText(expectedTitle);
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toBe(expectedTitle);
});
it('should handle error cases gracefully', () => {
@@ -52,15 +57,17 @@ describe('MyComponent', () => {
const invalidInput = null;
// Act
spectator.component.input = invalidInput;
component.input = invalidInput;
// Assert
expect(() => spectator.component.processInput()).toThrowError('Invalid input');
expect(() => component.processInput()).toThrowError('Invalid input');
});
});
```
## Additional Resources
- [Jest Documentation](https://jestjs.io/docs/getting-started)
- [Spectator Documentation](https://ngneat.github.io/spectator/)
- [Vitest Documentation (latest)](https://context7.com/vitest-dev/vitest/llms.txt?topic=getting+started)
- [Vitest Official Guide](https://vitest.dev/guide/)
- [Testing Library for Angular](https://testing-library.com/docs/angular-testing-library/intro/)
- **Jest** and **Spectator** documentation are deprecated

2
.gitignore vendored
View File

@@ -73,3 +73,5 @@ vitest.config.*.timestamp*
.mcp.json
.memory.json
nx.instructions.md

View File

@@ -8,7 +8,7 @@ WORKDIR /app
COPY . .
RUN umask 0022
RUN npm version ${SEMVERSION}
RUN npm install --foreground-scripts --legacy-peer-deps
RUN npm install --foreground-scripts
RUN if [ "${IS_PRODUCTION}" = "true" ] ; then npm run-script build-prod ; else npm run-script build ; fi
# stage final

View File

@@ -1,162 +1,162 @@
{
"name": "isa-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/isa-app/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:application",
"options": {
"allowedCommonJsDependencies": [
"lodash",
"moment",
"jsrsasign",
"pdfjs-dist/build/pdf",
"pdfjs-dist/web/pdf_viewer",
"pdfjs-dist/es5/build/pdf",
"pdfjs-dist/es5/web/pdf_viewer"
],
"outputPath": "dist/isa-app",
"index": "apps/isa-app/src/index.html",
"browser": "apps/isa-app/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/isa-app/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"apps/isa-app/src/favicon.ico",
"apps/isa-app/src/assets",
"apps/isa-app/src/config",
"apps/isa-app/src/silent-refresh.html",
"apps/isa-app/src/manifest.webmanifest",
{
"glob": "**/*",
"input": "node_modules/scandit-web-datacapture-barcode/build/engine",
"output": "scandit"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "25kb"
}
],
"fileReplacements": [
{
"replace": "apps/isa-app/src/environments/environment.ts",
"with": "apps/isa-app/src/environments/environment.prod.ts"
}
],
"outputHashing": "all",
"serviceWorker": "apps/isa-app/ngsw-config.json"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production",
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "isa-app:build:production"
},
"development": {
"buildTarget": "isa-app:build:development"
}
},
"defaultConfiguration": "development",
"continuous": true
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "isa-app:build"
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/isa-app/jest.config.ts"
}
},
"serve-static": {
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "isa-app:build",
"staticFilePath": "dist/apps/isa-app/browser",
"spa": true
}
},
"storybook": {
"executor": "@storybook/angular:start-storybook",
"options": {
"port": 4400,
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"open": false,
"assets": [
{
"glob": "**/*",
"input": "apps/isa-app/src/assets",
"output": "/assets"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@storybook/angular:build-storybook",
"outputs": ["{options.outputDir}"],
"options": {
"outputDir": "dist/storybook/isa-app",
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}
{
"name": "isa-app",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"prefix": "app",
"sourceRoot": "apps/isa-app/src",
"tags": [],
"targets": {
"build": {
"executor": "@angular-devkit/build-angular:application",
"options": {
"allowedCommonJsDependencies": [
"lodash",
"moment",
"jsrsasign",
"pdfjs-dist/build/pdf",
"pdfjs-dist/web/pdf_viewer",
"pdfjs-dist/es5/build/pdf",
"pdfjs-dist/es5/web/pdf_viewer"
],
"outputPath": "dist/isa-app",
"index": "apps/isa-app/src/index.html",
"browser": "apps/isa-app/src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "apps/isa-app/tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
"apps/isa-app/src/favicon.ico",
"apps/isa-app/src/assets",
"apps/isa-app/src/config",
"apps/isa-app/src/silent-refresh.html",
"apps/isa-app/src/manifest.webmanifest",
{
"glob": "**/*",
"input": "node_modules/scandit-web-datacapture-barcode/build/engine",
"output": "scandit"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2mb",
"maximumError": "5mb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "25kb"
}
],
"fileReplacements": [
{
"replace": "apps/isa-app/src/environments/environment.ts",
"with": "apps/isa-app/src/environments/environment.prod.ts"
}
],
"outputHashing": "all",
"serviceWorker": "apps/isa-app/ngsw-config.json"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production",
"outputs": ["{options.outputPath}"]
},
"serve": {
"executor": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "isa-app:build:production"
},
"development": {
"buildTarget": "isa-app:build:development"
}
},
"defaultConfiguration": "development",
"continuous": true
},
"extract-i18n": {
"executor": "@angular-devkit/build-angular:extract-i18n",
"options": {
"buildTarget": "isa-app:build"
}
},
"lint": {
"executor": "@nx/eslint:lint"
},
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "apps/isa-app/jest.config.ts"
}
},
"serve-static": {
"executor": "@nx/web:file-server",
"options": {
"buildTarget": "isa-app:build",
"staticFilePath": "dist/apps/isa-app/browser",
"spa": true
}
},
"storybook": {
"executor": "@storybook/angular:start-storybook",
"options": {
"port": 4400,
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"open": false,
"assets": [
{
"glob": "**/*",
"input": "apps/isa-app/src/assets",
"output": "/assets"
}
],
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"executor": "@storybook/angular:build-storybook",
"outputs": ["{options.outputDir}"],
"options": {
"outputDir": "dist/storybook/isa-app",
"configDir": "apps/isa-app/.storybook",
"browserTarget": "isa-app:build",
"compodoc": false,
"styles": [
"@angular/cdk/overlay-prebuilt.css",
"apps/isa-app/src/tailwind.scss",
"apps/isa-app/src/styles.scss"
]
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
}

View File

@@ -4,6 +4,7 @@ import {
withInterceptorsFromDi,
} from '@angular/common/http';
import {
DEFAULT_CURRENCY_CODE,
ErrorHandler,
Injector,
LOCALE_ID,
@@ -228,6 +229,10 @@ export function _notificationsHubOptionsFactory(
withRouteData(),
),
provideLogging(withLogLevel(LogLevel.Debug), withSink(ConsoleLogSink)),
{
provide: DEFAULT_CURRENCY_CODE,
useValue: 'EUR',
},
],
})
export class AppModule {}

View File

@@ -1,11 +1,16 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ErrorHandler, Injectable } from '@angular/core';
import { AuthService } from '@core/auth';
import { DialogModel, UiDialogModalComponent, UiErrorModalComponent, UiModalService } from '@ui/modal';
import { IsaLogProvider } from './isa.log-provider';
import { LogLevel } from '@core/logger';
import { HttpErrorResponse } from "@angular/common/http";
import { ErrorHandler, Injectable } from "@angular/core";
import { AuthService } from "@core/auth";
import {
DialogModel,
UiDialogModalComponent,
UiErrorModalComponent,
UiModalService,
} from "@ui/modal";
import { IsaLogProvider } from "./isa.log-provider";
import { LogLevel } from "@core/logger";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class IsaErrorHandler implements ErrorHandler {
constructor(
private _modal: UiModalService,
@@ -17,7 +22,7 @@ export class IsaErrorHandler implements ErrorHandler {
console.error(error);
// Bei Klick auf Abbrechen auf der Login Seite erneut zur Login Seite weiterleiten
if (error?.type === 'token_error') {
if (error?.type === "token_error") {
this._authService.login();
return;
}
@@ -26,11 +31,14 @@ export class IsaErrorHandler implements ErrorHandler {
await this._modal
.open({
content: UiDialogModalComponent,
title: 'Sitzung abgelaufen',
title: "Sitzung abgelaufen",
data: {
handleCommand: false,
content: 'Sie waren zu lange nicht in der ISA aktiv. Bitte melden Sie sich erneut an',
actions: [{ command: 'CLOSE', selected: true, label: 'Erneut anmelden' }],
content:
"Sie waren zu lange nicht in der ISA aktiv. Bitte melden Sie sich erneut an",
actions: [
{ command: "CLOSE", selected: true, label: "Erneut anmelden" },
],
} as DialogModel,
})
.afterClosed$.toPromise();
@@ -39,7 +47,11 @@ export class IsaErrorHandler implements ErrorHandler {
return;
}
this._isaLogProvider.log(LogLevel.ERROR, 'Client Error', error);
try {
this._isaLogProvider.log(LogLevel.ERROR, "Client Error", error);
} catch (logError) {
console.error("Error logging to IsaLogProvider:", logError);
}
// this._modal.open({
// content: UiErrorModalComponent,

View File

@@ -1,28 +1,36 @@
import { Injectable, Injector } from '@angular/core';
import { LogLevel, LogProvider } from '@core/logger';
import { UserStateService } from '@generated/swagger/isa-api';
import { environment } from '../../environments/environment';
import { Injectable } from "@angular/core";
import { LogLevel, LogProvider } from "@core/logger";
import { UserStateService } from "@generated/swagger/isa-api";
import { environment } from "../../environments/environment";
@Injectable({ providedIn: 'root' })
@Injectable({ providedIn: "root" })
export class IsaLogProvider implements LogProvider {
static InfoService: UserStateService | undefined;
constructor() {}
log(logLevel: LogLevel, message: string, error: Error, ...optionalParams: any[]): void {
if (!environment.production && (logLevel === LogLevel.WARN || logLevel === LogLevel.ERROR)) {
IsaLogProvider.InfoService?.UserStateSaveLog({
logType: logLevel,
message: message,
content: JSON.stringify({
error: error?.name,
message: error?.message,
stack: error?.stack,
data: optionalParams,
}),
})
.toPromise()
.catch(() => {});
log(
logLevel: LogLevel,
message: string,
error: Error,
...optionalParams: any[]
): void {
try {
if (
!environment.production &&
(logLevel === LogLevel.WARN || logLevel === LogLevel.ERROR)
) {
IsaLogProvider.InfoService?.UserStateSaveLog({
logType: logLevel,
message: message,
content: JSON.stringify({
error: error?.name,
message: error?.message,
stack: error?.stack,
data: optionalParams,
}),
}).toPromise();
}
} catch (error) {
console.error("Error logging to InfoService:", error);
}
}
}

View File

@@ -1,5 +1,4 @@
import { Injectable } from '@angular/core';
import { LogLevel } from './log-level';
import { LogLevel } from "./log-level";
export interface LogProvider {
log(logLevel: LogLevel, message: string, ...optionalParams: any[]): void;

View File

@@ -283,37 +283,68 @@
<span class="side-menu-group-item-label">Wareneingang</span>
</a>
}
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
]"
(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">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>
<div class="side-menu-group-sub-item-wrapper">
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
]"
(isActiveChange)="focusSearchBox(); remissionExpanded.set($event)"
routerLinkActive="active"
#rlActive="routerLinkActive"
>
<span class="side-menu-group-item-icon w-[2.375rem] h-12">
<ng-icon name="isaNavigationRemission2" size="1.5rem"></ng-icon>
</span>
<span class="side-menu-group-item-label">Remission</span>
<button
class="side-menu-group-arrow"
[class.side-menu-item-rotate]="remissionExpanded()"
(click)="
$event.stopPropagation();
$event.preventDefault();
remissionExpanded.set(!remissionExpanded())
"
>
<shared-icon icon="keyboard-arrow-down"></shared-icon>
</button>
</a>
@if (remissionExpanded()) {
<div class="side-menu-group-sub-items">
<a
class="side-menu-group-item"
(click)="closeSideMenu(); focusSearchBox()"
[routerLink]="[
'/',
processService.activatedTab()?.id || processService.nextId(),
'remission',
]"
(isActiveChange)="focusSearchBox()"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon"> </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()"
routerLinkActive="active"
>
<span class="side-menu-group-item-icon"> </span>
<span class="side-menu-group-item-label">Warenbegleitscheine</span>
</a>
</div>
}
</div>
</nav>
</div>

View File

@@ -1,435 +1,434 @@
import {
Component,
ChangeDetectionStrategy,
Inject,
ChangeDetectorRef,
inject,
DOCUMENT,
} from '@angular/core';
import { AuthModule, AuthService } from '@core/auth';
import { StockService } from '@generated/swagger/wws-api';
import { first, map, retry, switchMap, take } from 'rxjs/operators';
import { ShellService } from '../shell.service';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { EnvironmentService } from '@core/environment';
import { CommonModule } from '@angular/common';
import { Config } from '@core/config';
import { BreadcrumbService } from '@core/breadcrumb';
import { IconComponent } from '@shared/components/icon';
import { RegexRouterLinkActiveDirective } from '@shared/directives/router-link-active';
import { WrongDestinationModalService } from '@modal/wrong-destination';
import {
CustomerCreateNavigation,
CustomerOrdersNavigationService,
CustomerSearchNavigation,
PickupShelfInNavigationService,
PickUpShelfOutNavigationService,
ProductCatalogNavigationService,
} from '@shared/services/navigation';
import { TabService } from '@isa/core/tabs';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaNavigationRemission2, isaNavigationReturn } from '@isa/icons';
@Component({
selector: 'shell-side-menu',
templateUrl: 'side-menu.component.html',
styleUrls: ['side-menu.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
NgIconComponent,
IconComponent,
RouterModule,
AuthModule,
RegexRouterLinkActiveDirective,
],
providers: [provideIcons({ isaNavigationReturn, isaNavigationRemission2 })],
})
export class ShellSideMenuComponent {
processService = inject(TabService);
branchKey$ = this._stockService.StockCurrentBranch().pipe(
retry(3),
map((x) => x.result.key),
);
section$ = this._app.getSection$();
processes$ = this.section$.pipe(
switchMap((section) => this._app.getProcesses$(section)),
);
processesCount$ = this.processes$.pipe(
map((processes) => processes?.length ?? 0),
);
activeProcess$ = this._app.activatedProcessId$.pipe(
switchMap((processId) => this._app.getProcessById$(processId)),
);
get isTablet() {
return this._environment.matchTablet();
}
customerBasePath$ = this.activeProcess$.pipe(
map((process) => {
if (
!!process &&
process.section === 'customer' &&
process.type !== 'cart-checkout'
) {
// Übernehme aktiven Prozess
return `/kunde/${process.id}`;
} else {
// Über Guards wird ein neuer Prozess erstellt
return '/kunde';
}
}),
);
customerSearchRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this._customerSearchNavigation.defaultRoute({ processId });
}),
);
customerCreateRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this._customerCreateNavigation.defaultRoute({ processId });
}),
);
pickUpShelfOutRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this._pickUpShelfOutNavigation.defaultRoute({ processId }).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this._pickUpShelfOutNavigation.defaultRoute({}).path;
}
}),
);
productRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this._catalogNavigationService.getArticleSearchBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this._catalogNavigationService.getArticleSearchBasePath().path;
}
}),
);
customerOrdersRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this._customerOrdersNavigationService.getCustomerOrdersBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this._customerOrdersNavigationService.getCustomerOrdersBasePath()
.path;
}
}),
);
taskCalenderNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.taskCalendar'),
{
path: ['/filiale', 'task-calendar'],
queryParams: {},
},
'/filiale/task-calendar',
);
assortmentNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.assortment'),
{
path: ['/filiale', 'assortment'],
queryParams: {},
},
);
pickUpShelfInRoutePath$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.pickupShelf'),
this._pickUpShelfInNavigation.defaultRoute(),
'/filiale/pickup-shelf',
);
// #4478 - RD // Abholfach - Routing löst Suche aus
// pickUpShelfInListRoutePath$ = this.getLastNavigationByProcessId(
// this._config.get('process.ids.pickupShelf'),
// this._pickUpShelfInNavigation.listRoute()
// );
remissionNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.remission'),
{
path: ['/filiale', 'remission'],
queryParams: {},
},
);
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
this._config.get('process.ids.packageInspection'),
{
path: ['/filiale', 'package-inspection'],
queryParams: {},
},
);
get currentShelfView$() {
return this._route.queryParams.pipe(map((params) => params.view));
}
shelfExpanded = false;
customerExpanded = false;
constructor(
private _shellService: ShellService,
private _authService: AuthService,
private _stockService: StockService,
private _app: ApplicationService,
private _router: Router,
private _route: ActivatedRoute,
private readonly _wrongDestinationModalService: WrongDestinationModalService,
private _environment: EnvironmentService,
private _catalogNavigationService: ProductCatalogNavigationService,
private _customerOrdersNavigationService: CustomerOrdersNavigationService,
private _config: Config,
private _breadcrumbService: BreadcrumbService,
private _customerSearchNavigation: CustomerSearchNavigation,
private _customerCreateNavigation: CustomerCreateNavigation,
private _pickUpShelfOutNavigation: PickUpShelfOutNavigationService,
private _pickUpShelfInNavigation: PickupShelfInNavigationService,
private _cdr: ChangeDetectorRef,
@Inject(DOCUMENT) private readonly _document: Document,
) {}
customerActive(isActive: boolean) {
if (isActive) {
this.expandCustomer();
}
}
shelfActive(isActive: boolean) {
if (isActive) {
this.expandShelf();
}
}
expandCustomer() {
this.customerExpanded = true;
this._cdr.markForCheck();
}
expandShelf() {
this.shelfExpanded = true;
this._cdr.markForCheck();
}
getLastNavigationByProcessId(
id: number,
fallback?: { path: string[]; queryParams: unknown },
pathContainsString?: string,
) {
return this._breadcrumbService.getBreadcrumbByKey$(id)?.pipe(
map((breadcrumbs) => {
const lastCrumb = breadcrumbs
.filter((breadcrumb) => {
/**
* #4532 - Der optionale Filter wurde hinzugefügt Breadcrumbs mit fehlerhaften Pfad auszuschließen.
* Dieser Filter kann entfernt werden, sobald die Breadcrumbs korrekt gesetzt werden. Jedoch konnte man bisher nicht feststellen,
* woher die fehlerhaften Breadcrumbs kommen.
*/
if (!pathContainsString) {
// Wenn kein Filter gesetzt ist, dann wird der letzte Breadcrumb zurückgegeben
return true;
}
const pathStr = Array.isArray(breadcrumb.path)
? breadcrumb.path.join('/')
: breadcrumb.path;
return pathStr.includes(pathContainsString);
})
// eslint-disable-next-line no-prototype-builtins
.filter((breadcrumb) => !breadcrumb?.params?.hasOwnProperty('view'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('reservation'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('cleanup'))
.filter(
(breadcrumb) => !breadcrumb?.tags?.includes('wareneingangsliste'),
)
.filter((breadcrumb) => !breadcrumb?.tags?.includes('preview'))
.reduce((last, current) => {
if (!last) return current;
if (last.changed > current.changed) {
return last;
} else {
return current;
}
}, undefined);
if (!lastCrumb) {
return fallback;
}
// #4692 Return Fallback if Values contain undefined or null values, regardless if path is from type string or array
if (typeof lastCrumb?.path === 'string') {
if (
lastCrumb?.path?.includes('undefined') ||
lastCrumb?.path?.includes('null')
) {
return fallback;
}
} else {
const valuesToCheck = [];
// eslint-disable-next-line no-unsafe-optional-chaining
for (const value of lastCrumb?.path) {
if (
value?.outlets &&
value?.outlets?.primary &&
value?.outlets?.side
) {
valuesToCheck.push(
...Object.values(value?.outlets?.primary),
...Object.values(value?.outlets?.side),
);
} else {
valuesToCheck.push(value);
}
}
if (this.checkIfArrayContainsUndefinedOrNull(valuesToCheck)) {
return fallback;
}
}
return { path: lastCrumb.path, queryParams: lastCrumb.params };
}),
);
}
checkIfArrayContainsUndefinedOrNull(array: unknown[]) {
return (
array?.includes(undefined) ||
array?.includes('undefined') ||
array?.includes(null) ||
array?.includes('null')
);
}
getLastActivatedCustomerProcessId$() {
return this._app.getProcesses$('customer').pipe(
map((processes) => {
const lastCustomerProcess = processes
.filter((process) => process.type === 'cart')
.reduce((last, current) => {
if (!last) return current;
if (last.activated > current.activated) {
return last;
} else {
return current;
}
}, undefined);
return lastCustomerProcess?.id ?? Date.now();
}),
);
}
closeSideMenu() {
this._shellService.closeSideMenu();
}
logout() {
this._authService.logout();
}
async resetBranch() {
const process = await this.activeProcess$.pipe(first()).toPromise();
if (process?.id) {
this._app.patchProcessData(process.id, { selectedBranch: undefined });
}
}
focusSearchBox() {
setTimeout(() => this._document.getElementById('searchbox')?.focus(), 0);
}
async createProcess() {
const process = await this.createCartProcess();
this.navigateToCatalog(process);
}
async createCartProcess() {
const nextProcessName = await this.getNextProcessName();
const process: ApplicationProcess = {
id: this.getNextProcessId(),
type: 'cart',
name: nextProcessName,
section: 'customer',
closeable: true,
};
this._app.createProcess(process);
return process;
}
async getNextProcessName() {
let processes = await this._app
.getProcesses$('customer')
.pipe(first())
.toPromise();
processes = processes.filter(
(x) => x.type === 'cart' && x.name.startsWith('Vorgang '),
);
const maxProcessNumber = processes.reduce((max, process) => {
const number = parseInt(process.name.replace('Vorgang ', ''), 10);
return number > max ? number : max;
}, 0);
return `Vorgang ${maxProcessNumber + 1}`;
}
getNextProcessId() {
return Date.now();
}
async navigateToCatalog(process: ApplicationProcess) {
await this._catalogNavigationService
.getArticleSearchBasePath(process.id)
.navigate();
}
navigateToDashboard() {
this._router.navigate(['/kunde', 'dashboard']);
}
async closeAllProcesses() {
const processes = await this.processes$.pipe(take(1)).toPromise();
processes.forEach((process) => this._app.removeProcess(process.id));
this.navigateToDashboard();
}
fetchAndOpenPackages = () =>
this._wrongDestinationModalService.fetchAndOpen();
}
import {
Component,
ChangeDetectionStrategy,
Inject,
ChangeDetectorRef,
inject,
DOCUMENT,
signal,
} from '@angular/core';
import { AuthModule, AuthService } from '@core/auth';
import { StockService } from '@generated/swagger/wws-api';
import { first, map, retry, switchMap, take } from 'rxjs/operators';
import { ShellService } from '../shell.service';
import { ApplicationProcess, ApplicationService } from '@core/application';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { EnvironmentService } from '@core/environment';
import { CommonModule } from '@angular/common';
import { Config } from '@core/config';
import { BreadcrumbService } from '@core/breadcrumb';
import { IconComponent } from '@shared/components/icon';
import { RegexRouterLinkActiveDirective } from '@shared/directives/router-link-active';
import { WrongDestinationModalService } from '@modal/wrong-destination';
import {
CustomerCreateNavigation,
CustomerOrdersNavigationService,
CustomerSearchNavigation,
PickupShelfInNavigationService,
PickUpShelfOutNavigationService,
ProductCatalogNavigationService,
} from '@shared/services/navigation';
import { TabService } from '@isa/core/tabs';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaNavigationRemission2, isaNavigationReturn } from '@isa/icons';
@Component({
selector: 'shell-side-menu',
templateUrl: 'side-menu.component.html',
styleUrls: ['side-menu.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
NgIconComponent,
IconComponent,
RouterModule,
AuthModule,
RegexRouterLinkActiveDirective,
],
providers: [provideIcons({ isaNavigationReturn, isaNavigationRemission2 })],
})
export class ShellSideMenuComponent {
#shellService = inject(ShellService);
#authService = inject(AuthService);
#stockService = inject(StockService);
#app = inject(ApplicationService);
#router = inject(Router);
#route = inject(ActivatedRoute);
#wrongDestinationModalService = inject(WrongDestinationModalService);
#environment = inject(EnvironmentService);
#catalogNavigationService = inject(ProductCatalogNavigationService);
#customerOrdersNavigationService = inject(CustomerOrdersNavigationService);
#config = inject(Config);
#breadcrumbService = inject(BreadcrumbService);
#customerSearchNavigation = inject(CustomerSearchNavigation);
#customerCreateNavigation = inject(CustomerCreateNavigation);
#pickUpShelfOutNavigation = inject(PickUpShelfOutNavigationService);
#pickUpShelfInNavigation = inject(PickupShelfInNavigationService);
#cdr = inject(ChangeDetectorRef);
#document = inject(DOCUMENT);
processService = inject(TabService);
branchKey$ = this.#stockService.StockCurrentBranch().pipe(
retry(3),
map((x) => x.result.key),
);
section$ = this.#app.getSection$();
processes$ = this.section$.pipe(
switchMap((section) => this.#app.getProcesses$(section)),
);
processesCount$ = this.processes$.pipe(
map((processes) => processes?.length ?? 0),
);
activeProcess$ = this.#app.activatedProcessId$.pipe(
switchMap((processId) => this.#app.getProcessById$(processId)),
);
get isTablet() {
return this.#environment.matchTablet();
}
customerBasePath$ = this.activeProcess$.pipe(
map((process) => {
if (
!!process &&
process.section === 'customer' &&
process.type !== 'cart-checkout'
) {
// Übernehme aktiven Prozess
return `/kunde/${process.id}`;
} else {
// Über Guards wird ein neuer Prozess erstellt
return '/kunde';
}
}),
);
customerSearchRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this.#customerSearchNavigation.defaultRoute({ processId });
}),
);
customerCreateRoute$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
return this.#customerCreateNavigation.defaultRoute({ processId });
}),
);
pickUpShelfOutRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this.#pickUpShelfOutNavigation.defaultRoute({ processId }).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this.#pickUpShelfOutNavigation.defaultRoute({}).path;
}
}),
);
productRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this.#catalogNavigationService.getArticleSearchBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this.#catalogNavigationService.getArticleSearchBasePath().path;
}
}),
);
customerOrdersRoutePath$ = this.getLastActivatedCustomerProcessId$().pipe(
map((processId) => {
if (processId) {
// Übernehme aktiven Prozess
return this.#customerOrdersNavigationService.getCustomerOrdersBasePath(
processId,
).path;
} else {
// Über Guards wird ein neuer Prozess erstellt
return this.#customerOrdersNavigationService.getCustomerOrdersBasePath()
.path;
}
}),
);
taskCalenderNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.taskCalendar'),
{
path: ['/filiale', 'task-calendar'],
queryParams: {},
},
'/filiale/task-calendar',
);
assortmentNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.assortment'),
{
path: ['/filiale', 'assortment'],
queryParams: {},
},
);
pickUpShelfInRoutePath$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.pickupShelf'),
this.#pickUpShelfInNavigation.defaultRoute(),
'/filiale/pickup-shelf',
);
// #4478 - RD // Abholfach - Routing löst Suche aus
// pickUpShelfInListRoutePath$ = this.getLastNavigationByProcessId(
// this._config.get('process.ids.pickupShelf'),
// this._pickUpShelfInNavigation.listRoute()
// );
remissionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.remission'),
{
path: ['/filiale', 'remission'],
queryParams: {},
},
);
packageInspectionNavigation$ = this.getLastNavigationByProcessId(
this.#config.get('process.ids.packageInspection'),
{
path: ['/filiale', 'package-inspection'],
queryParams: {},
},
);
get currentShelfView$() {
return this.#route.queryParams.pipe(map((params) => params.view));
}
shelfExpanded = false;
customerExpanded = false;
remissionExpanded = signal(false);
customerActive(isActive: boolean) {
if (isActive) {
this.expandCustomer();
}
}
shelfActive(isActive: boolean) {
if (isActive) {
this.expandShelf();
}
}
expandCustomer() {
this.customerExpanded = true;
this.#cdr.markForCheck();
}
expandShelf() {
this.shelfExpanded = true;
this.#cdr.markForCheck();
}
getLastNavigationByProcessId(
id: number,
fallback?: { path: string[]; queryParams: unknown },
pathContainsString?: string,
) {
return this.#breadcrumbService.getBreadcrumbByKey$(id)?.pipe(
map((breadcrumbs) => {
const lastCrumb = breadcrumbs
.filter((breadcrumb) => {
/**
* #4532 - Der optionale Filter wurde hinzugefügt Breadcrumbs mit fehlerhaften Pfad auszuschließen.
* Dieser Filter kann entfernt werden, sobald die Breadcrumbs korrekt gesetzt werden. Jedoch konnte man bisher nicht feststellen,
* woher die fehlerhaften Breadcrumbs kommen.
*/
if (!pathContainsString) {
// Wenn kein Filter gesetzt ist, dann wird der letzte Breadcrumb zurückgegeben
return true;
}
const pathStr = Array.isArray(breadcrumb.path)
? breadcrumb.path.join('/')
: breadcrumb.path;
return pathStr.includes(pathContainsString);
})
// eslint-disable-next-line no-prototype-builtins
.filter((breadcrumb) => !breadcrumb?.params?.hasOwnProperty('view'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('reservation'))
.filter((breadcrumb) => !breadcrumb?.tags?.includes('cleanup'))
.filter(
(breadcrumb) => !breadcrumb?.tags?.includes('wareneingangsliste'),
)
.filter((breadcrumb) => !breadcrumb?.tags?.includes('preview'))
.reduce((last, current) => {
if (!last) return current;
if (last.changed > current.changed) {
return last;
} else {
return current;
}
}, undefined);
if (!lastCrumb) {
return fallback;
}
// #4692 Return Fallback if Values contain undefined or null values, regardless if path is from type string or array
if (typeof lastCrumb?.path === 'string') {
if (
lastCrumb?.path?.includes('undefined') ||
lastCrumb?.path?.includes('null')
) {
return fallback;
}
} else {
const valuesToCheck = [];
// eslint-disable-next-line no-unsafe-optional-chaining
for (const value of lastCrumb?.path) {
if (
value?.outlets &&
value?.outlets?.primary &&
value?.outlets?.side
) {
valuesToCheck.push(
...Object.values(value?.outlets?.primary),
...Object.values(value?.outlets?.side),
);
} else {
valuesToCheck.push(value);
}
}
if (this.checkIfArrayContainsUndefinedOrNull(valuesToCheck)) {
return fallback;
}
}
return { path: lastCrumb.path, queryParams: lastCrumb.params };
}),
);
}
checkIfArrayContainsUndefinedOrNull(array: unknown[]) {
return (
array?.includes(undefined) ||
array?.includes('undefined') ||
array?.includes(null) ||
array?.includes('null')
);
}
getLastActivatedCustomerProcessId$() {
return this.#app.getProcesses$('customer').pipe(
map((processes) => {
const lastCustomerProcess = processes
.filter((process) => process.type === 'cart')
.reduce((last, current) => {
if (!last) return current;
if (last.activated > current.activated) {
return last;
} else {
return current;
}
}, undefined);
return lastCustomerProcess?.id ?? Date.now();
}),
);
}
closeSideMenu() {
this.#shellService.closeSideMenu();
}
logout() {
this.#authService.logout();
}
async resetBranch() {
const process = await this.activeProcess$.pipe(first()).toPromise();
if (process?.id) {
this.#app.patchProcessData(process.id, { selectedBranch: undefined });
}
}
focusSearchBox() {
setTimeout(() => this.#document.getElementById('searchbox')?.focus(), 0);
}
async createProcess() {
const process = await this.createCartProcess();
this.navigateToCatalog(process);
}
async createCartProcess() {
const nextProcessName = await this.getNextProcessName();
const process: ApplicationProcess = {
id: this.getNextProcessId(),
type: 'cart',
name: nextProcessName,
section: 'customer',
closeable: true,
};
this.#app.createProcess(process);
return process;
}
async getNextProcessName() {
let processes = await this.#app
.getProcesses$('customer')
.pipe(first())
.toPromise();
processes = processes.filter(
(x) => x.type === 'cart' && x.name.startsWith('Vorgang '),
);
const maxProcessNumber = processes.reduce((max, process) => {
const number = parseInt(process.name.replace('Vorgang ', ''), 10);
return number > max ? number : max;
}, 0);
return `Vorgang ${maxProcessNumber + 1}`;
}
getNextProcessId() {
return Date.now();
}
async navigateToCatalog(process: ApplicationProcess) {
await this.#catalogNavigationService
.getArticleSearchBasePath(process.id)
.navigate();
}
navigateToDashboard() {
this.#router.navigate(['/kunde', 'dashboard']);
}
async closeAllProcesses() {
const processes = await this.processes$.pipe(take(1)).toPromise();
processes.forEach((process) => this.#app.removeProcess(process.id));
this.navigateToDashboard();
}
fetchAndOpenPackages = () =>
this.#wrongDestinationModalService.fetchAndOpen();
}

View File

@@ -0,0 +1,66 @@
import { type Meta, type StoryObj, argsToTemplate } from '@storybook/angular';
import { ProductShelfMetaInfoComponent } from '@isa/remission/shared/product';
const meta: Meta<ProductShelfMetaInfoComponent> = {
component: ProductShelfMetaInfoComponent,
title: 'remission/shared/product/ProductShelfMetaInfoComponent',
args: {
department: 'Reise',
shelfLabel: 'Europa',
productGroupKey: '311',
productGroupValue: 'Romane TB',
assortment: 'Basissortiment|BPrämienartikel|n',
returnReason: 'Beschädigt',
},
argTypes: {
department: {
control: { type: 'text' },
description: 'The department of the product.',
defaultValue: undefined,
},
shelfLabel: {
control: { type: 'text' },
description: 'The shelf label of the product.',
defaultValue: undefined,
},
productGroupKey: {
control: { type: 'text' },
description: 'The key of the product group.',
defaultValue: undefined,
},
productGroupValue: {
control: { type: 'text' },
description: 'The value of the product group.',
defaultValue: undefined,
},
assortment: {
control: { type: 'text' },
description: 'The assortment of the product.',
defaultValue: undefined,
},
returnReason: {
control: { type: 'text' },
description: 'The reason for the return of the product.',
defaultValue: undefined,
},
},
render: (args) => ({
props: args,
template: `<remi-product-shelf-meta-info ${argsToTemplate(args)}></remi-product-shelf-meta-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductShelfMetaInfoComponent>;
export const Default: Story = {
args: {
department: 'Reise',
shelfLabel: 'Europa',
productGroupKey: '311',
productGroupValue: 'Romane TB',
assortment: 'Basissortiment|BPrämienartikel|n',
returnReason: 'Beschädigt',
},
};

View File

@@ -1,74 +1,64 @@
import {
type Meta,
type StoryObj,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { ProductStockInfoComponent } from '@isa/remission/shared/product';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
const meta: Meta<ProductStockInfoComponent> = {
component: ProductStockInfoComponent,
title: 'remission/shared/product/ProductStockInfoComponent',
decorators: [
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
args: {
stock: 92,
removedFromStock: 0,
predefinedReturnQuantity: 4,
remainingQuantityInStock: 0,
zob: 0,
},
argTypes: {
stock: {
control: { type: 'number' },
description: 'The current stock of the product.',
defaultValue: 0,
},
removedFromStock: {
control: { type: 'number' },
description: 'The amount of stock that has been removed.',
defaultValue: 0,
},
predefinedReturnQuantity: {
control: { type: 'number' },
description: 'The predefined return quantity for the product.',
defaultValue: 0,
},
remainingQuantityInStock: {
control: { type: 'number' },
description: 'The remaining quantity in stock after returns.',
defaultValue: 0,
},
zob: {
control: { type: 'number' },
description: 'Min Stock Category Management Information.',
defaultValue: 0,
},
},
render: (args) => ({
props: args,
template: `<remi-product-stock-info ${argsToTemplate(args)}></remi-product-stock-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductStockInfoComponent>;
export const Default: Story = {
args: {
stock: 92,
removedFromStock: 0,
predefinedReturnQuantity: 4,
remainingQuantityInStock: 0,
zob: 0,
},
};
import {
type Meta,
type StoryObj,
argsToTemplate,
moduleMetadata,
} from '@storybook/angular';
import { ProductStockInfoComponent } from '@isa/remission/shared/product';
import { provideProductImageUrl } from '@isa/shared/product-image';
import { provideProductRouterLinkBuilder } from '@isa/shared/product-router-link';
const meta: Meta<ProductStockInfoComponent> = {
component: ProductStockInfoComponent,
title: 'remission/shared/product/ProductStockInfoComponent',
decorators: [
moduleMetadata({
providers: [
provideProductImageUrl('https://produktbilder-test.paragon-data.net'),
provideProductRouterLinkBuilder((ean: string) => ean),
],
}),
],
args: {
availableStock: 92,
stockToRemit: 91,
targetStock: 1,
zob: 0,
},
argTypes: {
availableStock: {
control: { type: 'number' },
description: 'Total available stock for the product.',
},
stockToRemit: {
control: { type: 'number' },
description: 'Stock quantity to remit.',
},
targetStock: {
control: { type: 'number' },
description: 'Target stock level after remittance.',
},
zob: {
control: { type: 'number' },
description: 'Min Stock Category Management Information.',
defaultValue: 0,
},
},
render: (args) => ({
props: args,
template: `<remi-product-stock-info ${argsToTemplate(args)}></remi-product-stock-info>`,
}),
};
export default meta;
type Story = StoryObj<ProductStockInfoComponent>;
export const Default: Story = {
args: {
availableStock: 92,
stockToRemit: 91,
targetStock: 1,
zob: 0,
},
};

View File

@@ -1,10 +1,16 @@
import { argsToTemplate, type Meta, type StoryObj, moduleMetadata } from '@storybook/angular';
import { EmptyStateComponent } from '@isa/ui/empty-state';
import {
argsToTemplate,
type Meta,
type StoryObj,
moduleMetadata,
} from '@storybook/angular';
import { EmptyStateAppearance, EmptyStateComponent } from '@isa/ui/empty-state';
import { ButtonComponent } from '@isa/ui/buttons';
type EmptyStateComponentInputs = {
title: string;
description: string;
appearance: EmptyStateAppearance;
};
const meta: Meta<EmptyStateComponentInputs> = {
@@ -22,6 +28,10 @@ const meta: Meta<EmptyStateComponentInputs> = {
description: {
control: 'text',
},
appearance: {
control: 'select',
options: Object.values(EmptyStateAppearance),
},
},
render: (args) => ({
props: args,
@@ -40,5 +50,6 @@ export const Default: Story = {
args: {
title: 'Keine Suchergebnisse',
description: 'Suchen Sie nach einer Rechnungsnummer oder Kundennamen.',
appearance: EmptyStateAppearance.NoResults,
},
};

View File

@@ -89,7 +89,7 @@ jobs:
condition: and(ne(variables['Build.SourceBranch'], 'refs/heads/integration'), ne(variables['Build.SourceBranch'], 'refs/heads/master'), not(startsWith(variables['Build.SourceBranch'], 'refs/heads/hotfix/')), not(startsWith(variables['Build.SourceBranch'], 'refs/heads/release/')))
variables:
- name: DockerTagSourceBranch
value: $[replace(variables['Build.SourceBranch'], '/', '_')]
value: $[replace(variables['Build.SourceBranch'], '/', '-')]
- name: 'DockerTag'
value: |
$(Build.BuildNumber)-$(Build.SourceVersion)

View File

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

View File

@@ -0,0 +1,222 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { CatalougeSearchService } from './catalouge-search.service';
import { SearchService } from '@generated/swagger/cat-search-api';
import { Item } from '../models';
import { SearchByTermInput } from '../schemas/catalouge-search.schemas';
describe('CatalougeSearchService', () => {
let service: CatalougeSearchService;
let searchServiceSpy: jest.Mocked<SearchService>;
beforeEach(() => {
const searchServiceMock = {
SearchByEAN: jest.fn(),
SearchSearch: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
CatalougeSearchService,
{ provide: SearchService, useValue: searchServiceMock },
],
});
service = TestBed.inject(CatalougeSearchService);
searchServiceSpy = TestBed.inject(SearchService) as jest.Mocked<SearchService>;
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('searchByEans', () => {
it('should return items when search is successful', (done) => {
// Arrange
const mockItems: Item[] = [
{ id: 1, product: { name: 'Item 1' }, catalogAvailability: { available: true } } as unknown as Item,
{ id: 2, product: { name: 'Item 2' }, catalogAvailability: { available: true } } as unknown as Item,
];
const mockResponse = {
error: false,
result: mockItems,
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans('123456789', '987654321').subscribe({
next: (result) => {
// Assert
expect(result).toEqual(mockItems);
expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789', '987654321']);
done();
},
error: done.fail,
});
});
it('should throw error when response has error', (done) => {
// Arrange
const mockResponse = {
error: true,
message: 'Search failed',
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans('123456789').subscribe({
next: () => done.fail('Should have thrown error'),
error: (error) => {
// Assert
expect(error).toBeInstanceOf(Error);
expect(error.message).toBe('Search failed');
done();
},
});
});
it('should handle single EAN', (done) => {
// Arrange
const mockItems: Item[] = [{ id: 1, product: { name: 'Item 1' }, catalogAvailability: { available: true } } as unknown as Item];
const mockResponse = {
error: false,
result: mockItems,
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans('123456789').subscribe({
next: (result) => {
// Assert
expect(result).toEqual(mockItems);
expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith(['123456789']);
done();
},
error: done.fail,
});
});
it('should handle empty EAN array', (done) => {
// Arrange
const mockResponse = {
error: false,
result: [],
};
searchServiceSpy.SearchByEAN.mockReturnValue(of(mockResponse));
// Act
service.searchByEans().subscribe({
next: (result) => {
// Assert
expect(result).toEqual([]);
expect(searchServiceSpy.SearchByEAN).toHaveBeenCalledWith([]);
done();
},
error: done.fail,
});
});
});
describe('searchByTerm', () => {
it('should return search results when successful', async () => {
// Arrange
const mockItems: Item[] = [
{ id: 1, product: { name: 'Test Item' }, catalogAvailability: { available: true } } as unknown as Item,
];
const mockResponse = {
error: false,
result: mockItems,
total: 1,
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
skip: 0,
take: 10,
};
const abortController = new AbortController();
// Act
const result = await service.searchByTerm(params, abortController.signal);
// Assert
expect(result).toEqual(mockResponse);
expect(searchServiceSpy.SearchSearch).toHaveBeenCalledWith({
input: { qs: 'test' },
skip: 0,
take: 10,
doNotTrack: true,
});
});
it('should throw error when response has error', async () => {
// Arrange
const mockResponse = {
error: true,
message: 'Search failed',
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
skip: 0,
take: 10,
};
const abortController = new AbortController();
// Act & Assert
await expect(service.searchByTerm(params, abortController.signal))
.rejects
.toThrow('Search failed');
});
it('should handle abort signal', async () => {
// Arrange
const abortController = new AbortController();
const mockResponse = {
error: false,
result: [],
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
skip: 0,
take: 10,
};
// Act
const result = await service.searchByTerm(params, abortController.signal);
// Assert
expect(result).toEqual(mockResponse);
expect(searchServiceSpy.SearchSearch).toHaveBeenCalled();
});
it('should use default values when not provided', async () => {
// Arrange
const mockResponse = {
error: false,
result: [],
};
searchServiceSpy.SearchSearch.mockReturnValue(of(mockResponse));
const params: SearchByTermInput = {
searchTerm: 'test',
};
const abortController = new AbortController();
// Act
await service.searchByTerm(params, abortController.signal);
// Assert
expect(searchServiceSpy.SearchSearch).toHaveBeenCalledWith({
input: { qs: 'test' },
skip: 0,
take: 20,
doNotTrack: true,
});
});
});
});

View File

@@ -1,52 +1,53 @@
import { inject, Injectable } from '@angular/core';
import { SearchService } from '@generated/swagger/cat-search-api';
import { firstValueFrom, map, Observable } from 'rxjs';
import { takeUntilAborted } from '@isa/common/data-access';
import { Item } from '../models';
import {
SearchByTermInput,
SearchByTermSchema,
} from '../schemas/catalouge-search.schemas';
import { ListResponseArgs } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class CatalougeSearchService {
#searchService = inject(SearchService);
searchByEans(...ean: string[]): Observable<Item[]> {
return this.#searchService.SearchByEAN(ean).pipe(
map((res) => {
if (res.error) {
throw new Error(res.message);
}
return res.result as Item[];
}),
);
}
async searchByTerm(
params: SearchByTermInput,
abortSignal: AbortSignal,
): Promise<ListResponseArgs<Item>> {
const { searchTerm, skip, take } = SearchByTermSchema.parse(params);
const req$ = this.#searchService
.SearchSearch({
filter: {
qs: searchTerm,
},
skip,
take,
})
.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
throw new Error(res.message);
}
return res as ListResponseArgs<Item>;
}
}
import { inject, Injectable } from '@angular/core';
import { SearchService } from '@generated/swagger/cat-search-api';
import { firstValueFrom, map, Observable } from 'rxjs';
import { takeUntilAborted } from '@isa/common/data-access';
import { Item } from '../models';
import {
SearchByTermInput,
SearchByTermSchema,
} from '../schemas/catalouge-search.schemas';
import { ListResponseArgs } from '@isa/common/data-access';
@Injectable({ providedIn: 'root' })
export class CatalougeSearchService {
#searchService = inject(SearchService);
searchByEans(...ean: string[]): Observable<Item[]> {
return this.#searchService.SearchByEAN(ean).pipe(
map((res) => {
if (res.error) {
throw new Error(res.message);
}
return res.result as Item[];
}),
);
}
async searchByTerm(
params: SearchByTermInput,
abortSignal: AbortSignal,
): Promise<ListResponseArgs<Item>> {
const { searchTerm, skip, take } = SearchByTermSchema.parse(params);
const req$ = this.#searchService
.SearchSearch({
input: {
qs: searchTerm,
},
skip,
take,
doNotTrack: true,
})
.pipe(takeUntilAborted(abortSignal));
const res = await firstValueFrom(req$);
if (res.error) {
throw new Error(res.message);
}
return res as ListResponseArgs<Item>;
}
}

View File

@@ -1,2 +1,4 @@
export * from './errors';
export * from './models';
export * from './errors';
export * from './helpers';
export * from './models';
export * from './operators';

View File

@@ -0,0 +1,143 @@
import { BatchResponseArgs } from './batch-response-args';
import { ReturnValue } from './return-value';
describe('BatchResponseArgs', () => {
describe('interface structure', () => {
it('should support all properties', () => {
// Arrange
const testData: BatchResponseArgs<string> = {
alreadyProcessed: [
{ error: false, result: 'processed1' },
{ error: false, result: 'processed2' },
],
ambiguous: ['ambiguous1', 'ambiguous2'],
completed: true,
duplicates: [
{ key: 'key1', value: 1 },
{ key: 'key2', value: 2 },
],
error: false,
failed: [
{ error: true, message: 'Failed', result: 'failed1' },
],
invalidProperties: { field1: 'Invalid value' },
message: 'Success',
requestId: 12345,
successful: [
{ key: 'key1', value: 'value1' },
{ key: 'key2', value: 'value2' },
],
total: 10,
unknown: [
{ error: false, result: 'unknown1' },
],
};
// Assert
expect(testData.alreadyProcessed).toHaveLength(2);
expect(testData.ambiguous).toHaveLength(2);
expect(testData.completed).toBe(true);
expect(testData.duplicates).toHaveLength(2);
expect(testData.error).toBe(false);
expect(testData.failed).toHaveLength(1);
expect(testData.invalidProperties).toEqual({ field1: 'Invalid value' });
expect(testData.message).toBe('Success');
expect(testData.requestId).toBe(12345);
expect(testData.successful).toHaveLength(2);
expect(testData.total).toBe(10);
expect(testData.unknown).toHaveLength(1);
});
it('should support required properties only', () => {
// Arrange
const testData: BatchResponseArgs<number> = {
completed: false,
error: true,
total: 0,
};
// Assert
expect(testData.completed).toBe(false);
expect(testData.error).toBe(true);
expect(testData.total).toBe(0);
expect(testData.alreadyProcessed).toBeUndefined();
expect(testData.ambiguous).toBeUndefined();
expect(testData.duplicates).toBeUndefined();
expect(testData.failed).toBeUndefined();
expect(testData.invalidProperties).toBeUndefined();
expect(testData.message).toBeUndefined();
expect(testData.requestId).toBeUndefined();
expect(testData.successful).toBeUndefined();
expect(testData.unknown).toBeUndefined();
});
it('should support generic type parameter', () => {
// Arrange
interface TestObject {
id: number;
name: string;
}
const testData: BatchResponseArgs<TestObject> = {
completed: true,
error: false,
total: 1,
successful: [
{ key: { id: 1, name: 'test' }, value: { id: 1, name: 'test' } },
],
};
// Assert
expect(testData.successful?.[0].key.id).toBe(1);
expect(testData.successful?.[0].key.name).toBe('test');
expect(testData.successful?.[0].value.id).toBe(1);
expect(testData.successful?.[0].value.name).toBe('test');
});
it('should support ReturnValue arrays', () => {
// Arrange
const returnValue: ReturnValue<string> = {
error: false,
result: 'test result',
message: 'Success',
};
const testData: BatchResponseArgs<string> = {
completed: true,
error: false,
total: 1,
alreadyProcessed: [returnValue],
failed: [returnValue],
unknown: [returnValue],
};
// Assert
expect(testData.alreadyProcessed?.[0]).toEqual(returnValue);
expect(testData.failed?.[0]).toEqual(returnValue);
expect(testData.unknown?.[0]).toEqual(returnValue);
});
it('should support empty arrays', () => {
// Arrange
const testData: BatchResponseArgs<string> = {
completed: true,
error: false,
total: 0,
alreadyProcessed: [],
ambiguous: [],
duplicates: [],
failed: [],
successful: [],
unknown: [],
};
// Assert
expect(testData.alreadyProcessed).toHaveLength(0);
expect(testData.ambiguous).toHaveLength(0);
expect(testData.duplicates).toHaveLength(0);
expect(testData.failed).toHaveLength(0);
expect(testData.successful).toHaveLength(0);
expect(testData.unknown).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,16 @@
import { ReturnValue } from './return-value';
export interface BatchResponseArgs<T> {
alreadyProcessed?: Array<ReturnValue<T>>;
ambiguous?: Array<T>;
completed: boolean;
duplicates?: Array<{ key: T; value: number }>;
error: boolean;
failed?: Array<ReturnValue<T>>;
invalidProperties?: { [key: string]: string };
message?: string;
requestId?: number;
successful?: Array<{ key: T; value: T }>;
total: number;
unknown?: Array<ReturnValue<T>>;
}

View File

@@ -1,5 +1,7 @@
export * from './async-result';
export * from './callback-result';
export * from './entity-cotnainer';
export * from './list-response-args';
export * from './response-args';
export * from './async-result';
export * from './batch-response-args';
export * from './callback-result';
export * from './entity-cotnainer';
export * from './list-response-args';
export * from './response-args';
export * from './return-value';

View File

@@ -0,0 +1,128 @@
import { ReturnValue } from './return-value';
describe('ReturnValue', () => {
describe('interface structure', () => {
it('should support all properties', () => {
// Arrange
const testData: ReturnValue<string> = {
error: false,
invalidProperties: { field1: 'Invalid value', field2: 'Another error' },
message: 'Operation successful',
result: 'test result',
};
// Assert
expect(testData.error).toBe(false);
expect(testData.invalidProperties).toEqual({
field1: 'Invalid value',
field2: 'Another error'
});
expect(testData.message).toBe('Operation successful');
expect(testData.result).toBe('test result');
});
it('should support required properties only', () => {
// Arrange
const testData: ReturnValue<number> = {
error: true,
result: 42,
};
// Assert
expect(testData.error).toBe(true);
expect(testData.result).toBe(42);
expect(testData.invalidProperties).toBeUndefined();
expect(testData.message).toBeUndefined();
});
it('should support generic type parameter', () => {
// Arrange
interface TestObject {
id: number;
name: string;
}
const testObject: TestObject = { id: 1, name: 'test' };
const testData: ReturnValue<TestObject> = {
error: false,
result: testObject,
};
// Assert
expect(testData.result.id).toBe(1);
expect(testData.result.name).toBe('test');
});
it('should support arrays as generic type', () => {
// Arrange
const testData: ReturnValue<string[]> = {
error: false,
result: ['item1', 'item2', 'item3'],
message: 'Array operation successful',
};
// Assert
expect(testData.result).toHaveLength(3);
expect(testData.result[0]).toBe('item1');
expect(testData.result[1]).toBe('item2');
expect(testData.result[2]).toBe('item3');
});
it('should support null result', () => {
// Arrange
const testData: ReturnValue<string | null> = {
error: false,
result: null,
};
// Assert
expect(testData.result).toBeNull();
});
it('should support error state with message', () => {
// Arrange
const testData: ReturnValue<string> = {
error: true,
message: 'Operation failed',
result: '',
};
// Assert
expect(testData.error).toBe(true);
expect(testData.message).toBe('Operation failed');
expect(testData.result).toBe('');
});
it('should support complex invalidProperties', () => {
// Arrange
const testData: ReturnValue<any> = {
error: true,
invalidProperties: {
'user.email': 'Invalid email format',
'user.age': 'Age must be a positive number',
'nested.field.value': 'Required field missing',
},
result: null,
};
// Assert
expect(testData.invalidProperties).toEqual({
'user.email': 'Invalid email format',
'user.age': 'Age must be a positive number',
'nested.field.value': 'Required field missing',
});
});
it('should support empty invalidProperties', () => {
// Arrange
const testData: ReturnValue<string> = {
error: false,
invalidProperties: {},
result: 'success',
};
// Assert
expect(testData.invalidProperties).toEqual({});
});
});
});

View File

@@ -0,0 +1,6 @@
export interface ReturnValue<T> {
error: boolean;
invalidProperties?: { [key: string]: string };
message?: string;
result: T;
}

View File

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

View File

@@ -0,0 +1,383 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Cache } from './cache.decorator';
describe('Cache Decorator', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
});
describe('Cache', () => {
class DataService {
callCount = 0;
@Cache({
ttl: 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}`];
}
@Cache({
ttl: 500
})
async fetchWithExpiry(id: number): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 50));
return `data-${id}-${this.callCount}`;
}
@Cache()
async fetchWithNoExpiry(value: string): Promise<string> {
this.callCount++;
return `permanent-${value}-${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 cache without expiry when no ttl specified', async () => {
const service = new DataService();
// First call
const result1 = await service.fetchWithNoExpiry('test');
expect(result1).toBe('permanent-test-1');
expect(service.callCount).toBe(1);
// Advance time significantly
vi.advanceTimersByTime(10000);
// Second call should still use cache
const result2 = await service.fetchWithNoExpiry('test');
expect(result2).toBe('permanent-test-1');
expect(service.callCount).toBe(1);
});
it('should cache different keys separately', async () => {
const service = new DataService();
// First call
const promise1 = service.search('query1');
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toEqual(['result-query1-1']);
expect(service.callCount).toBe(1);
// Second call with different key
const promise2 = service.search('query2');
await vi.runAllTimersAsync();
const result2 = await promise2;
expect(result2).toEqual(['result-query2-2']);
expect(service.callCount).toBe(2);
// Subsequent calls should use cache
const result3 = await service.search('query1');
const result4 = await service.search('query2');
expect(result3).toEqual(['result-query1-1']);
expect(result4).toEqual(['result-query2-2']);
expect(service.callCount).toBe(2); // No new calls
});
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 not cache errors', async () => {
class ErrorService {
callCount = 0;
@Cache({ ttl: 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
try {
const promise1 = service.fetchWithError();
await vi.runAllTimersAsync();
await promise1;
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('API Error');
}
expect(service.callCount).toBe(1);
// Second call should not use cache (errors aren't cached)
try {
const promise2 = service.fetchWithError();
await vi.runAllTimersAsync();
await promise2;
expect.fail('Should have thrown an error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('API Error');
}
expect(service.callCount).toBe(2);
});
it('should use JSON.stringify as default key generator', async () => {
class DefaultKeyService {
callCount = 0;
@Cache({ ttl: 1000 })
async fetch(_param1: string, _param2: number): Promise<string> {
this.callCount++;
return `result-${this.callCount}`;
}
}
const service = new DefaultKeyService();
// First call with specific args
const result1 = await service.fetch('test', 123);
expect(result1).toBe('result-1');
// Same args should use cache
const result2 = await service.fetch('test', 123);
expect(result2).toBe('result-1');
expect(service.callCount).toBe(1);
// Different args should make new call
const result3 = await service.fetch('test', 456);
expect(result3).toBe('result-2');
expect(service.callCount).toBe(2);
});
});
describe('Sync function caching', () => {
class MathService {
callCount = 0;
@Cache({
ttl: 1000,
keyGenerator: (n: number) => n.toString()
})
fibonacci(n: number): number {
this.callCount++;
if (n <= 1) return n;
return this.fibonacci(n - 1) + this.fibonacci(n - 2);
}
@Cache({
ttl: 500
})
expensiveCalculation(a: number, b: number): number {
this.callCount++;
// Simulate expensive calculation
return a * b + Math.sqrt(a + b);
}
@Cache()
permanentCalculation(value: number): number {
this.callCount++;
return value * 2;
}
}
it('should cache sync function results', () => {
const service = new MathService();
// First call
const result1 = service.fibonacci(5);
expect(result1).toBe(5);
expect(service.callCount).toBe(6); // fibonacci(5) calls fibonacci(4), fibonacci(3), etc.
// Second call should use cache
const result2 = service.fibonacci(5);
expect(result2).toBe(5);
expect(service.callCount).toBe(6); // No new calls
});
it('should cache sync functions with multiple arguments', () => {
const service = new MathService();
// First call
const result1 = service.expensiveCalculation(10, 20);
expect(result1).toBe(10 * 20 + Math.sqrt(10 + 20));
expect(service.callCount).toBe(1);
// Second call with same arguments should use cache
const result2 = service.expensiveCalculation(10, 20);
expect(result2).toBe(result1);
expect(service.callCount).toBe(1);
// Call with different arguments should execute
const result3 = service.expensiveCalculation(5, 10);
expect(result3).toBe(5 * 10 + Math.sqrt(5 + 10));
expect(service.callCount).toBe(2);
});
it('should handle sync function cache expiry', () => {
const service = new MathService();
// First call
const result1 = service.expensiveCalculation(3, 4);
expect(result1).toBe(3 * 4 + Math.sqrt(3 + 4));
expect(service.callCount).toBe(1);
// Second call within TTL should use cache
const result2 = service.expensiveCalculation(3, 4);
expect(result2).toBe(result1);
expect(service.callCount).toBe(1);
// Advance time past TTL
vi.advanceTimersByTime(600);
// Call after expiry should execute again
const result3 = service.expensiveCalculation(3, 4);
expect(result3).toBe(3 * 4 + Math.sqrt(3 + 4));
expect(service.callCount).toBe(2);
});
it('should cache sync functions without expiry', () => {
const service = new MathService();
// First call
const result1 = service.permanentCalculation(42);
expect(result1).toBe(84);
expect(service.callCount).toBe(1);
// Advance time significantly
vi.advanceTimersByTime(10000);
// Second call should still use cache
const result2 = service.permanentCalculation(42);
expect(result2).toBe(84);
expect(service.callCount).toBe(1);
});
it('should not cache sync function errors', () => {
class ErrorService {
callCount = 0;
@Cache({ ttl: 1000 })
errorFunction(shouldError: boolean): string {
this.callCount++;
if (shouldError) {
throw new Error('Sync Error');
}
return 'success';
}
}
const service = new ErrorService();
// First call that errors
expect(() => service.errorFunction(true)).toThrow('Sync Error');
expect(service.callCount).toBe(1);
// Second call should not use cache (errors aren't cached)
expect(() => service.errorFunction(true)).toThrow('Sync Error');
expect(service.callCount).toBe(2);
// Call with success should work
const result = service.errorFunction(false);
expect(result).toBe('success');
expect(service.callCount).toBe(3);
// Subsequent success call should use cache
const result2 = service.errorFunction(false);
expect(result2).toBe('success');
expect(service.callCount).toBe(3);
});
});
describe('Mixed sync/async usage', () => {
class MixedService {
callCount = 0;
@Cache({ ttl: 1000 })
async asyncMethod(value: string): Promise<string> {
this.callCount++;
await new Promise(resolve => setTimeout(resolve, 10));
return `async-${value}`;
}
@Cache({ ttl: 1000 })
syncMethod(value: string): string {
this.callCount++;
return `sync-${value}`;
}
}
it('should handle mixed sync and async methods in same class', async () => {
const service = new MixedService();
// Test async method
const promise1 = service.asyncMethod('test');
await vi.runAllTimersAsync();
const result1 = await promise1;
expect(result1).toBe('async-test');
expect(service.callCount).toBe(1);
// Test sync method
const result2 = service.syncMethod('test');
expect(result2).toBe('sync-test');
expect(service.callCount).toBe(2);
// Test caching for both
const result3 = await service.asyncMethod('test');
expect(result3).toBe('async-test');
expect(service.callCount).toBe(2); // No new call
const result4 = service.syncMethod('test');
expect(result4).toBe('sync-test');
expect(service.callCount).toBe(2); // No new call
});
});
});

View File

@@ -0,0 +1,149 @@
export const CacheTimeToLive = {
oneMinute: 60 * 1000, // 1 minute
fiveMinutes: 5 * 60 * 1000, // 5 minutes
tenMinutes: 10 * 60 * 1000, // 10 minutes
thirtyMinutes: 30 * 60 * 1000, // 30 minutes
oneHour: 60 * 60 * 1000, // 1 hour
} as const;
export type CacheTimeToLive =
(typeof CacheTimeToLive)[keyof typeof CacheTimeToLive];
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Options for configuring the Cache decorator
*/
export interface CacheOptions<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.
* If not provided, cache never expires.
*/
ttl?: number | CacheTimeToLive;
}
/**
* Decorator that caches the results of both sync and async method calls.
* Results are cached based on method arguments and expire after the specified TTL.
*
* @param options Configuration options for the decorator
* @example
* ```typescript
* class DataService {
* // Async function caching
* @Cache({
* ttl: 5 * 60 * 1000, // Cache for 5 minutes
* keyGenerator: (params: QueryParams) => params.query
* })
* async searchData(params: QueryParams): Promise<SearchResult> {
* return await api.search(params);
* }
*
* // Sync function caching (heavy calculations)
* @Cache({
* ttl: 10 * 60 * 1000, // Cache for 10 minutes
* keyGenerator: (n: number) => n.toString()
* })
* fibonacci(n: number): number {
* if (n <= 1) return n;
* return this.fibonacci(n - 1) + this.fibonacci(n - 2);
* }
* }
* ```
*/
export function Cache<T extends (...args: any[]) => any>(
options: CacheOptions<T> = {},
): MethodDecorator {
const cacheMap = new WeakMap<
object,
Map<string, { result: any; expiry?: number; isAsync: boolean }>
>();
return function (
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = function (
this: any,
...args: Parameters<T>
): ReturnType<T> {
// Initialize cache for this instance if needed
if (!cacheMap.has(this)) {
cacheMap.set(this, new Map());
}
const instanceCache = cacheMap.get(this);
if (!instanceCache) {
throw new Error('Cache map not initialized properly');
}
// Generate cache key
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
// Check cache first
const cached = instanceCache.get(key);
if (cached) {
// If no TTL or not expired, return cached result
if (!cached.expiry || cached.expiry > Date.now()) {
// For async functions, wrap cached result in a Promise
if (cached.isAsync) {
return Promise.resolve(cached.result) as ReturnType<T>;
}
return cached.result;
}
// Clean up expired cache entry
instanceCache.delete(key);
}
// Execute original method
const result = originalMethod.apply(this, args);
// Handle both sync and async functions
// Use more robust Promise detection for testing environments
const isPromise =
result instanceof Promise ||
(result &&
typeof result.then === 'function' &&
typeof result.catch === 'function');
if (isPromise) {
// Async function: only cache successful results
const promise = result
.then((value: any) => {
instanceCache.set(key, {
result: value,
expiry: options.ttl ? Date.now() + options.ttl : undefined,
isAsync: true,
});
return value;
})
.catch((error: any) => {
// Don't cache errors - ensure cache is clean
instanceCache.delete(key);
throw error;
});
return promise as ReturnType<T>;
} else {
// Sync function: cache result directly
instanceCache.set(key, {
result,
expiry: options.ttl ? Date.now() + options.ttl : undefined,
isAsync: false,
});
return result;
}
};
return descriptor;
};
}

View File

@@ -1,321 +1,202 @@
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);
});
});
});
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InFlight } from './in-flight.decorator';
describe('InFlight Decorator', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('Basic usage (no options)', () => {
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 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('With key generator', () => {
class UserService {
callCounts = new Map<string, number>();
@InFlight({
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}` };
}
@InFlight({
keyGenerator: undefined, // Uses JSON.stringify
})
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 when keyGenerator is undefined', 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);
});
});
});

View File

@@ -1,99 +1,52 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Options for configuring the InFlight decorator
*/
export interface InFlightOptions<T extends (...args: any[]) => any> {
/**
* Generate a cache key from the method arguments.
* If not provided, uses JSON.stringify on all arguments.
* If omitted entirely, the decorator will not differentiate between calls with different arguments.
*/
keyGenerator?: (...args: Parameters<T>) => string;
}
/**
* 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({
* class MyService {
* // Basic usage - all calls share the same in-flight request
* @InFlight()
* async fetchData(): Promise<Data> {
* return await api.getData();
* }
*
* // With key generator - calls with different arguments can execute simultaneously
* @InFlight({
* 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> = {},
export function InFlight<T extends (...args: any[]) => Promise<any>>(
options: InFlightOptions<T> = {},
): MethodDecorator {
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
// If keyGenerator is explicitly provided (even if undefined), use keyed mode
const useKeys = 'keyGenerator' in options;
const simpleInFlightMap = new WeakMap<object, Promise<any>>();
const keyedInFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
return function (
target: any,
propertyKey: string | symbol,
_target: any,
_propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
@@ -102,148 +55,59 @@ export function InFlightWithKey<T extends (...args: any[]) => Promise<any>>(
this: any,
...args: Parameters<T>
): Promise<ReturnType<T>> {
// Initialize map for this instance if needed
if (!inFlightMap.has(this)) {
inFlightMap.set(this, new Map());
if (!useKeys) {
// Simple mode: one in-flight request per instance
const existingRequest = simpleInFlightMap.get(this);
if (existingRequest) {
return existingRequest;
}
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
simpleInFlightMap.delete(this);
return result;
})
.catch((error: any) => {
simpleInFlightMap.delete(this);
throw error;
});
simpleInFlightMap.set(this, promise);
return promise;
} else {
// Keyed mode: separate in-flight requests per key
if (!keyedInFlightMap.has(this)) {
keyedInFlightMap.set(this, new Map());
}
const instanceMap = keyedInFlightMap.get(this);
if (!instanceMap) {
throw new Error('In-flight map not initialized properly');
}
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
const existingRequest = instanceMap.get(key);
if (existingRequest) {
return existingRequest;
}
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
instanceMap.delete(key);
return result;
})
.catch((error: any) => {
instanceMap.delete(key);
throw error;
});
instanceMap.set(key, promise);
return promise;
}
const instanceMap = inFlightMap.get(this)!;
// Generate cache key
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
// Check if there's already an in-flight request for this key
const existingRequest = instanceMap.get(key);
if (existingRequest) {
return existingRequest;
}
// Create new request and store it
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
// Clean up after successful completion
instanceMap.delete(key);
return result;
})
.catch((error: any) => {
// Clean up after error
instanceMap.delete(key);
throw error;
});
instanceMap.set(key, promise);
return promise;
};
return descriptor;
};
}
/**
* Decorator that prevents multiple simultaneous calls to the same async method
* with additional caching capabilities.
*
* @param options Configuration options for the decorator
* @example
* ```typescript
* class DataService {
* @InFlightWithCache({
* cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
* keyGenerator: (params: QueryParams) => params.query
* })
* async searchData(params: QueryParams): Promise<SearchResult> {
* return await api.search(params);
* }
* }
* ```
*/
export interface InFlightWithCacheOptions<T extends (...args: any[]) => any> {
/**
* Generate a cache key from the method arguments.
* If not provided, uses JSON.stringify on all arguments.
*/
keyGenerator?: (...args: Parameters<T>) => string;
/**
* Time in milliseconds to keep the result cached after completion.
* If not provided, result is not cached after completion.
*/
cacheTime?: number;
}
export function InFlightWithCache<T extends (...args: any[]) => Promise<any>>(
options: InFlightWithCacheOptions<T> = {},
): MethodDecorator {
const inFlightMap = new WeakMap<object, Map<string, Promise<any>>>();
const cacheMap = new WeakMap<
object,
Map<string, { result: any; expiry: number }>
>();
return function (
target: any,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
): PropertyDescriptor {
const originalMethod = descriptor.value;
descriptor.value = async function (
this: any,
...args: Parameters<T>
): Promise<ReturnType<T>> {
// Initialize maps for this instance if needed
if (!inFlightMap.has(this)) {
inFlightMap.set(this, new Map());
cacheMap.set(this, new Map());
}
const instanceInFlight = inFlightMap.get(this)!;
const instanceCache = cacheMap.get(this)!;
// Generate cache key
const key = options.keyGenerator
? options.keyGenerator(...args)
: JSON.stringify(args);
// Check cache first (if cacheTime is set)
if (options.cacheTime) {
const cached = instanceCache.get(key);
if (cached && cached.expiry > Date.now()) {
return Promise.resolve(cached.result);
}
// Clean up expired cache entry
if (cached) {
instanceCache.delete(key);
}
}
// Check if there's already an in-flight request
const existingRequest = instanceInFlight.get(key);
if (existingRequest) {
return existingRequest;
}
// Create new request
const promise = originalMethod
.apply(this, args)
.then((result: any) => {
// Cache result if cacheTime is set
if (options.cacheTime) {
instanceCache.set(key, {
result,
expiry: Date.now() + options.cacheTime,
});
}
return result;
})
.finally(() => {
// Always clean up in-flight request
instanceInFlight.delete(key);
});
instanceInFlight.set(key, promise);
return promise;
};
return descriptor;

View File

@@ -1,166 +1,166 @@
import { LogLevel } from './log-level.enum';
import { Type } from '@angular/core';
/**
* Represents a destination where log messages are sent.
* Implement this interface to create custom logging destinations like
* console logging, remote logging services, or file logging.
*
* @example
* ```typescript
* @Injectable()
* export class CustomLogSink implements Sink {
* log(
* level: LogLevel,
* message: string,
* context?: LoggerContext,
* error?: Error
* ): void {
* // Custom logging implementation
* if (level === LogLevel.Error) {
* // Send to monitoring service
* this.monitoringService.reportError(message, error, context);
* }
* }
* }
* ```
*/
export interface Sink {
/**
* Method called by the LoggingService to send a log entry to this sink.
*
* @param level - The severity level of the log message
* @param message - The main log message content
* @param context - Optional structured data or metadata about the log event
* @param error - Optional error object when logging errors
*/
log(
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
): void;
}
/**
* A factory function that creates a logging sink function.
* Useful when the sink needs access to injected dependencies or
* requires initialization logic.
*
* @returns A function matching the Sink.log method signature
*
* @example
* ```typescript
* export const httpLogSink: SinkFn = () => {
* const http = inject(HttpClient);
* const config = inject(ConfigService);
*
* return (level, message, context?, error?) => {
* http.post(config.loggingEndpoint, {
* level,
* message,
* context,
* error: error && {
* name: error.name,
* message: error.message,
* stack: error.stack
* }
* }).subscribe();
* };
* };
* ```
*/
export type SinkFn = () => (
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
) => void;
/**
* Configuration options for the logging service.
* Used to set up the logging behavior during application initialization.
*/
export interface LoggingConfig {
/** The minimum log level to process. Messages below this level are ignored. */
level: LogLevel;
/**
* An array of logging destinations where messages will be sent.
* Can be sink instances, classes, or factory functions.
*/
sinks: (Sink | SinkFn | Type<Sink>)[];
/**
* Optional global context included with every log message.
* Useful for adding application-wide metadata like version or environment.
*/
context?: LoggerContext;
}
/**
* Represents the public API for logging operations.
* This interface is returned by the logger factory and provides
* methods for logging at different severity levels.
*/
export interface LoggerApi {
/**
* Logs a trace message with optional context.
* Use for fine-grained debugging information.
*/
trace(message: string, context?: () => LoggerContext): void;
/**
* Logs a debug message with optional context.
* Use for development-time debugging information.
*/
debug(message: string, context?: () => LoggerContext): void;
/**
* Logs an info message with optional context.
* Use for general runtime information.
*/
info(message: string, context?: () => LoggerContext): void;
/**
* Logs a warning message with optional context.
* Use for potentially harmful situations.
*/
warn(message: string, context?: () => LoggerContext): void;
/**
* Logs an error message with an optional error object and context.
* Use for error conditions that affect functionality.
*
* @param message - The error message to log
* @param error - Optional error object that caused this error condition
* @param context - Optional context data associated with the error
*/
error(message: string, error?: Error, context?: () => LoggerContext): void;
}
/**
* Represents context data associated with a log message.
* Context allows adding structured metadata to log messages,
* making them more informative and easier to filter/analyze.
*
* @example
* ```typescript
* // Component context
* const context: LoggerContext = {
* component: 'UserProfile',
* userId: '12345',
* action: 'save'
* };
*
* // Error context
* const errorContext: LoggerContext = {
* operationId: 'op-123',
* attemptNumber: 3,
* inputData: { ... }
* };
* ```
*/
export interface LoggerContext {
[key: string]: unknown;
}
import { LogLevel } from './log-level.enum';
import { Type } from '@angular/core';
/**
* Represents a destination where log messages are sent.
* Implement this interface to create custom logging destinations like
* console logging, remote logging services, or file logging.
*
* @example
* ```typescript
* @Injectable()
* export class CustomLogSink implements Sink {
* log(
* level: LogLevel,
* message: string,
* context?: LoggerContext,
* error?: Error
* ): void {
* // Custom logging implementation
* if (level === LogLevel.Error) {
* // Send to monitoring service
* this.monitoringService.reportError(message, error, context);
* }
* }
* }
* ```
*/
export interface Sink {
/**
* Method called by the LoggingService to send a log entry to this sink.
*
* @param level - The severity level of the log message
* @param message - The main log message content
* @param context - Optional structured data or metadata about the log event
* @param error - Optional error object when logging errors
*/
log(
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
): void;
}
/**
* A factory function that creates a logging sink function.
* Useful when the sink needs access to injected dependencies or
* requires initialization logic.
*
* @returns A function matching the Sink.log method signature
*
* @example
* ```typescript
* export const httpLogSink: SinkFn = () => {
* const http = inject(HttpClient);
* const config = inject(ConfigService);
*
* return (level, message, context?, error?) => {
* http.post(config.loggingEndpoint, {
* level,
* message,
* context,
* error: error && {
* name: error.name,
* message: error.message,
* stack: error.stack
* }
* }).subscribe();
* };
* };
* ```
*/
export type SinkFn = () => (
level: LogLevel,
message: string,
context?: LoggerContext,
error?: Error,
) => void;
/**
* Configuration options for the logging service.
* Used to set up the logging behavior during application initialization.
*/
export interface LoggingConfig {
/** The minimum log level to process. Messages below this level are ignored. */
level: LogLevel;
/**
* An array of logging destinations where messages will be sent.
* Can be sink instances, classes, or factory functions.
*/
sinks: (Sink | SinkFn | Type<Sink>)[];
/**
* Optional global context included with every log message.
* Useful for adding application-wide metadata like version or environment.
*/
context?: LoggerContext;
}
/**
* Represents the public API for logging operations.
* This interface is returned by the logger factory and provides
* methods for logging at different severity levels.
*/
export interface LoggerApi {
/**
* Logs a trace message with optional context.
* Use for fine-grained debugging information.
*/
trace(message: string, context?: () => LoggerContext): void;
/**
* Logs a debug message with optional context.
* Use for development-time debugging information.
*/
debug(message: string, context?: () => LoggerContext): void;
/**
* Logs an info message with optional context.
* Use for general runtime information.
*/
info(message: string, context?: () => LoggerContext): void;
/**
* Logs a warning message with optional context.
* Use for potentially harmful situations.
*/
warn(message: string, context?: () => LoggerContext): void;
/**
* Logs an error message with an optional error object and context.
* Use for error conditions that affect functionality.
*
* @param message - The error message to log
* @param error - Optional error object that caused this error condition
* @param context - Optional context data associated with the error
*/
error(message: string, error?: unknown, context?: () => LoggerContext): void;
}
/**
* Represents context data associated with a log message.
* Context allows adding structured metadata to log messages,
* making them more informative and easier to filter/analyze.
*
* @example
* ```typescript
* // Component context
* const context: LoggerContext = {
* component: 'UserProfile',
* userId: '12345',
* action: 'save'
* };
*
* // Error context
* const errorContext: LoggerContext = {
* operationId: 'op-123',
* attemptNumber: 3,
* inputData: { ... }
* };
* ```
*/
export interface LoggerContext {
[key: string]: unknown;
}

View File

@@ -1,11 +1,20 @@
import { Type } from '@angular/core';
import { getState, patchState, signalStoreFeature, withHooks, withMethods } from '@ngrx/signals';
import {
getState,
patchState,
signalStoreFeature,
withHooks,
withMethods,
} from '@ngrx/signals';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { StorageProvider } from './storage-provider';
import { injectStorage } from './storage';
import { debounceTime, pipe, switchMap } from 'rxjs';
export function withStorage(storageKey: string, storageProvider: Type<StorageProvider>) {
export function withStorage(
storageKey: string,
storageProvider: Type<StorageProvider>,
) {
return signalStoreFeature(
withMethods((store, storage = injectStorage(storageProvider)) => ({
storeState: rxMethod<void>(
@@ -16,7 +25,7 @@ export function withStorage(storageKey: string, storageProvider: Type<StoragePro
),
restoreState: async () => {
const data = await storage.get(storageKey);
if (data) {
if (data && typeof data === 'object') {
patchState(store, data);
}
},

View File

@@ -1,6 +1,18 @@
import { inject } from '@angular/core';
import { TabService } from './tab.service';
export function injectActivatedTabId() {
/**
* Injects the current activated tab as a signal.
* @returns A signal that emits the current activated tab or null if no tab is activated.
*/
export function injectTab() {
return inject(TabService).activatedTab;
}
/**
* Injects the current tab ID as a signal.
* @returns A signal that emits the current tab ID or null if no tab is activated.
*/
export function injectTabId() {
return inject(TabService).activatedTabId;
}

View File

@@ -114,7 +114,7 @@ export const isaFiliale =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path fill-rule="evenodd" clip-rule="evenodd" d="M11.4772 1.33637C11.8195 1.24369 12.1803 1.24369 12.5226 1.33637C12.92 1.44396 13.2545 1.70662 13.5215 1.91625C13.547 1.93627 13.5719 1.9558 13.5961 1.97466L20.3784 7.24979C20.4046 7.27009 20.4305 7.2902 20.4562 7.31015C20.8328 7.60242 21.1646 7.85993 21.4119 8.19422C21.6288 8.48761 21.7905 8.81812 21.8889 9.16952C22.0009 9.56993 22.0005 9.98994 22 10.4667C21.9999 10.4992 21.9999 10.532 21.9999 10.5651V17.8386C21.9999 18.3657 21.9999 18.8205 21.9693 19.195C21.937 19.5904 21.8657 19.9836 21.6729 20.362C21.3853 20.9265 20.9264 21.3854 20.3619 21.673C19.9835 21.8658 19.5903 21.9371 19.1949 21.9694C18.8204 22 18.3656 22 17.8385 22H6.16133C5.63419 22 5.17943 22 4.80487 21.9694C4.40952 21.9371 4.0163 21.8658 3.63792 21.673C3.07344 21.3854 2.6145 20.9265 2.32688 20.362C2.13408 19.9836 2.06277 19.5904 2.03046 19.195C1.99986 18.8205 1.99988 18.3657 1.99989 17.8385L1.9999 10.5651C1.9999 10.532 1.99986 10.4992 1.99982 10.4667C1.99931 9.98994 1.99886 9.56993 2.11094 9.16952C2.2093 8.81811 2.37094 8.48761 2.58794 8.19422C2.83519 7.85992 3.16701 7.60242 3.54364 7.31013C3.56934 7.29019 3.59524 7.27009 3.62134 7.24979L10.4037 1.97466C10.4279 1.9558 10.4528 1.93626 10.4783 1.91625C10.7453 1.70662 11.0798 1.44396 11.4772 1.33637ZM9.9999 20H13.9999V13.6C13.9999 13.3035 13.9991 13.1412 13.9896 13.0246C13.9892 13.02 13.9888 13.0156 13.9884 13.0114C13.9843 13.0111 13.9799 13.0107 13.9753 13.0103C13.8587 13.0008 13.6964 13 13.3999 13H10.5999C10.3034 13 10.1411 13.0008 10.0245 13.0103C10.0199 13.0107 10.0155 13.0111 10.0113 13.0114C10.011 13.0156 10.0106 13.02 10.0102 13.0246C10.0007 13.1412 9.9999 13.3035 9.9999 13.6V20ZM15.9999 20L15.9999 13.5681C15.9999 13.3157 16 13.0699 15.983 12.8618C15.9643 12.6332 15.9202 12.3634 15.7819 12.092C15.5902 11.7157 15.2842 11.4097 14.9079 11.218C14.6365 11.0797 14.3667 11.0356 14.1381 11.0169C13.93 10.9999 13.6842 11 13.4318 11H10.568C10.3156 11 10.0698 10.9999 9.86167 11.0169C9.63307 11.0356 9.36334 11.0797 9.09191 11.218C8.71559 11.4097 8.40963 11.7157 8.21788 12.092C8.07959 12.3634 8.03552 12.6332 8.01684 12.8618C7.99983 13.0699 7.99986 13.3157 7.99989 13.5681L7.9999 20H6.1999C5.62334 20 5.25107 19.9992 4.96773 19.9761C4.69607 19.9539 4.59535 19.9162 4.5459 19.891C4.35774 19.7951 4.20476 19.6422 4.10889 19.454C4.0837 19.4045 4.04602 19.3038 4.02382 19.0322C4.00067 18.7488 3.9999 18.3766 3.9999 17.8V10.5651C3.9999 9.93408 4.00858 9.80982 4.03691 9.70862C4.0697 9.59148 4.12358 9.48131 4.19591 9.38352C4.2584 9.29903 4.35115 9.21588 4.84923 8.82849L11.6315 3.55337C11.8184 3.40799 11.9174 3.33175 11.9926 3.28154C11.9951 3.27984 11.9976 3.27823 11.9999 3.27671C12.0022 3.27823 12.0046 3.27984 12.0072 3.28154C12.0823 3.33175 12.1814 3.40799 12.3683 3.55337L19.1506 8.82849C19.6486 9.21588 19.7414 9.29903 19.8039 9.38352C19.8762 9.48131 19.9301 9.59148 19.9629 9.70862C19.9912 9.80982 19.9999 9.93408 19.9999 10.5651V17.8C19.9999 18.3766 19.9991 18.7488 19.976 19.0322C19.9538 19.3038 19.9161 19.4045 19.8909 19.454C19.795 19.6422 19.642 19.7951 19.4539 19.891C19.4044 19.9162 19.3037 19.9539 19.0321 19.9761C18.7487 19.9992 18.3764 20 17.7999 20H15.9999Z" fill="currentColor"/></svg>';
export const isaFilialeLocation =
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 22C16 18 20 14.4183 20 10C20 5.58172 16.4183 2 12 2C7.58172 2 4 5.58172 4 10C4 14.4183 8 18 12 22Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M12 13C13.6569 13 15 11.6569 15 10C15 8.34315 13.6569 7 12 7C10.3431 7 9 8.34315 9 10C9 11.6569 10.3431 13 12 13Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M12 22C16 18 20 14.4183 20 10C20 5.58172 16.4183 2 12 2C7.58172 2 4 5.58172 4 10C4 14.4183 8 18 12 22Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
export const isaArtikelKartoniert = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.24787 2.5C7.25947 2.5 7.2711 2.5 7.28276 2.5L7.60047 2.5C8.34332 2.49999 8.95985 2.49998 9.46281 2.54033C9.98596 2.5823 10.4728 2.67271 10.9325 2.90269C11.3354 3.10427 11.6965 3.37467 12.0002 3.6995C12.3038 3.37467 12.665 3.10427 13.0679 2.90269C13.5275 2.67271 14.0144 2.5823 14.5375 2.54033C15.0405 2.49998 15.657 2.49999 16.3999 2.5L16.7525 2.5C17.111 2.49998 17.437 2.49996 17.7088 2.52177C18.0006 2.54518 18.3162 2.59847 18.6274 2.75419C19.0751 2.9782 19.4425 3.3374 19.6739 3.78334C19.8357 4.09512 19.891 4.41165 19.9152 4.70214C19.9376 4.97133 19.9376 5.29341 19.9375 5.64431L19.9375 15.0162C19.9375 15.3671 19.9375 15.6892 19.9151 15.9584C19.8909 16.2489 19.8356 16.5654 19.6739 16.8772C19.4425 17.3231 19.0751 17.6823 18.6273 17.9063C18.3161 18.062 18.0006 18.1153 17.7088 18.1387C17.4369 18.1605 17.111 18.1605 16.7524 18.1605L15.7637 18.1605C14.8337 18.1605 14.5735 18.1704 14.3521 18.2364C14.127 18.3035 13.9187 18.4132 13.7388 18.5585C13.5634 18.7 13.4135 18.9026 12.8969 19.6636L12.8274 19.7659C12.6413 20.04 12.3315 20.2042 12.0001 20.2042C11.6687 20.2042 11.3589 20.04 11.1728 19.7659L11.1033 19.6636C10.5867 18.9026 10.4368 18.7 10.2615 18.5585C10.0815 18.4132 9.87318 18.3035 9.64811 18.2364C9.42671 18.1704 9.1665 18.1605 8.23647 18.1605L7.24783 18.1605C6.88925 18.1605 6.56329 18.1605 6.29144 18.1387C5.99966 18.1153 5.68411 18.062 5.37287 17.9063C4.92515 17.6823 4.55774 17.3231 4.32635 16.8772C4.16457 16.5654 4.10926 16.2489 4.08509 15.9584C4.0627 15.6892 4.06272 15.3671 4.06275 15.0162L4.06281 5.67991C4.06281 5.67991 4.06281 5.67991 4.06281 5.67991C4.06281 5.66801 4.06281 5.65612 4.06281 5.64428C4.06279 5.29339 4.06276 4.97133 4.08516 4.70215C4.10933 4.41166 4.16464 4.09512 4.32642 3.78334C4.55781 3.33739 4.92522 2.9782 5.37293 2.75419C5.68418 2.59847 5.99972 2.54518 6.2915 2.52177C6.56335 2.49996 6.8893 2.49998 7.24787 2.5ZM6.26449 4.54428C6.26445 4.54427 6.26506 4.54398 6.26646 4.54347L6.26449 4.54428ZM6.26646 4.54347C6.27658 4.53983 6.32436 4.52556 6.45145 4.51536C6.63354 4.50075 6.87803 4.5 7.28276 4.5H7.56026C8.35352 4.5 8.88941 4.50075 9.30286 4.53392C9.70517 4.5662 9.90362 4.62429 10.0376 4.69131C10.3731 4.85916 10.6424 5.12525 10.8101 5.44839C10.8752 5.57377 10.9333 5.76196 10.9658 6.15304C10.9994 6.55634 11.0002 7.07998 11.0002 7.85982L11.0001 16.6511C10.7538 16.5122 10.492 16.401 10.2197 16.3198C9.68245 16.1596 9.10813 16.16 8.36282 16.1604C8.32125 16.1605 8.27913 16.1605 8.23647 16.1605H7.2827C6.87797 16.1605 6.63348 16.1598 6.45139 16.1451C6.3243 16.1349 6.2767 16.1207 6.26659 16.1171C6.19397 16.0805 6.13794 16.0243 6.10332 15.9593C6.0995 15.9476 6.08735 15.9024 6.07821 15.7925C6.06355 15.6164 6.06275 15.3789 6.06275 14.9806L6.06281 5.67992C6.06281 5.2816 6.06362 5.04409 6.07827 4.86798C6.08742 4.75806 6.09956 4.71289 6.10339 4.7012C6.13801 4.63617 6.19384 4.58011 6.26646 4.54347ZM13.0001 16.6511C13.2464 16.5122 13.5082 16.401 13.7805 16.3198C14.3178 16.1596 14.8921 16.16 15.6374 16.1604C15.679 16.1605 15.7211 16.1605 15.7637 16.1605H16.7175C17.1222 16.1605 17.3667 16.1598 17.5488 16.1451C17.6759 16.1349 17.7235 16.1207 17.7336 16.1171C17.8062 16.0805 17.8623 16.0243 17.8969 15.9593C17.9007 15.9476 17.9129 15.9024 17.922 15.7925C17.9367 15.6164 17.9375 15.3789 17.9375 14.9806L17.9375 5.67991C17.9375 5.28159 17.9367 5.04409 17.9221 4.86798C17.9129 4.75807 17.9008 4.7129 17.8969 4.7012C17.8623 4.63617 17.8063 4.58004 17.7337 4.5434C17.7236 4.53976 17.676 4.52556 17.5489 4.51536C17.3668 4.50075 17.1223 4.5 16.7176 4.5H16.4401C15.6468 4.5 15.1109 4.50075 14.6975 4.53392C14.2952 4.5662 14.0967 4.62429 13.9628 4.69131C13.6273 4.85916 13.3579 5.12525 13.1902 5.44839C13.1252 5.57377 13.0671 5.76196 13.0345 6.15304C13.001 6.55634 13.0002 7.07998 13.0002 7.85983L13.0001 16.6511ZM17.7358 16.1162C17.7358 16.1162 17.735 16.1166 17.7336 16.1171L17.7358 16.1162Z" fill="#212529"/>

View File

@@ -0,0 +1,8 @@
export * from './errors';
export * from './guards';
export * from './models';
export * from './operators';
export * from './questions';
export * from './schemas';
export * from './services';
export * from './stores';

View File

@@ -116,7 +116,7 @@ export class ReturnDetailsService {
* Validates that the email parameter is a properly formatted email address.
*/
static FetchReceiptsEmailParamsSchema = z.object({
email: z.string().email(),
email: z.string(),
});
/**

View File

@@ -22,7 +22,7 @@
></oms-feature-return-details-static>
@if (customerReceiptsResource.isLoading()) {
<ui-progress-bar class="w-full" mode="indeterminate"></ui-progress-bar>
} @else {
} @else if (!customerReceiptsResource.error()) {
@for (receipt of customerReceiptsResource.value(); track receipt.id) {
@if (r.id !== receipt.id) {
<oms-feature-return-details-lazy

View File

@@ -1,151 +1,157 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
resource,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { z } from 'zod';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectActivatedTabId } from '@isa/core/tabs';
import { Location } from '@angular/common';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
import {
ReturnDetailsService,
ReturnProcessStore,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ReturnDetailsStaticComponent } from './return-details-static/return-details-static.component';
import { ReturnDetailsLazyComponent } from './return-details-lazy/return-details-lazy.component';
import { logger } from '@isa/core/logging';
import { groupBy } from 'lodash';
@Component({
selector: 'oms-feature-return-details',
templateUrl: './return-details.component.html',
styleUrls: ['./return-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReturnDetailsStaticComponent,
ReturnDetailsLazyComponent,
NgIconComponent,
ButtonComponent,
ExpandableDirectives,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionChevronLeft }), ReturnDetailsStore],
})
export class ReturnDetailsComponent {
#logger = logger(() => ({
component: 'ReturnDetailsComponent',
itemId: this.receiptId(),
processId: this.processId(),
params: this.params(),
}));
#store = inject(ReturnDetailsStore);
#returnDetailsService = inject(ReturnDetailsService);
#returnProcessStore = inject(ReturnProcessStore);
private processId = injectActivatedTabId();
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
location = inject(Location);
params = toSignal(this._activatedRoute.params);
receiptId = computed<number>(() => {
const params = this.params();
if (params) {
return z.coerce.number().parse(params['receiptId']);
}
throw new Error('No receiptId found in route params');
});
receiptResource = this.#store.receiptResource(this.receiptId);
customerReceiptsResource = resource({
params: this.receiptResource.value,
loader: async ({ params, abortSignal }) => {
const email = params.buyer?.communicationDetails?.email;
if (!email) {
return [];
}
return await this.#returnDetailsService.fetchReceiptsByEmail(
{ email },
abortSignal,
);
},
});
canStartProcess = computed(() => {
return (
this.#store.selectedItemIds().length > 0 && this.processId() !== undefined
);
});
startProcess() {
if (!this.canStartProcess()) {
this.#logger.warn(
'Cannot start process: No items selected or no process ID',
);
return;
}
const processId = this.processId();
const selectedItems = this.#store.selectedItems();
const selectedQuantites = this.#store.selectedQuantityMap();
const selectedProductCategories = this.#store.itemCategoryMap();
this.#logger.info('Starting return process', () => ({
processId: processId,
selectedItems: selectedItems.map((item) => item.id),
}));
if (!selectedItems.length || !processId) {
return;
}
const itemsGrouptByReceiptId = groupBy(
selectedItems,
(item) => item.receipt?.id,
);
const receipts = this.#store.receiptsEntityMap();
const returns = Object.entries(itemsGrouptByReceiptId).map(
([receiptId, items]) => ({
receipt: receipts[Number(receiptId)],
items: items.map((item) => {
const receiptItem = item;
return {
receiptItem,
quantity: selectedQuantites[receiptItem.id],
category: selectedProductCategories[receiptItem.id],
};
}),
}),
);
this.#logger.info('Starting return process with returns', () => ({
processId,
returns,
}));
this.#returnProcessStore.startProcess({
processId,
returns,
});
this._router.navigate(['../../', 'process'], {
relativeTo: this._activatedRoute,
});
}
}
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
resource,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { z } from 'zod';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { isaActionChevronLeft } from '@isa/icons';
import { ButtonComponent } from '@isa/ui/buttons';
import { injectTabId } from '@isa/core/tabs';
import { Location } from '@angular/common';
import { ExpandableDirectives } from '@isa/ui/expandable';
import { ProgressBarComponent } from '@isa/ui/progress-bar';
import {
ReturnDetailsService,
ReturnProcessStore,
ReturnDetailsStore,
} from '@isa/oms/data-access';
import { ReturnDetailsStaticComponent } from './return-details-static/return-details-static.component';
import { ReturnDetailsLazyComponent } from './return-details-lazy/return-details-lazy.component';
import { logger } from '@isa/core/logging';
import { groupBy } from 'lodash';
@Component({
selector: 'oms-feature-return-details',
templateUrl: './return-details.component.html',
styleUrls: ['./return-details.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
ReturnDetailsStaticComponent,
ReturnDetailsLazyComponent,
NgIconComponent,
ButtonComponent,
ExpandableDirectives,
ProgressBarComponent,
],
providers: [provideIcons({ isaActionChevronLeft }), ReturnDetailsStore],
})
export class ReturnDetailsComponent {
#logger = logger(() => ({
component: 'ReturnDetailsComponent',
itemId: this.receiptId(),
processId: this.processId(),
params: this.params(),
}));
#store = inject(ReturnDetailsStore);
#returnDetailsService = inject(ReturnDetailsService);
#returnProcessStore = inject(ReturnProcessStore);
private processId = injectTabId();
private _router = inject(Router);
private _activatedRoute = inject(ActivatedRoute);
location = inject(Location);
params = toSignal(this._activatedRoute.params);
receiptId = computed<number>(() => {
const params = this.params();
if (params) {
return z.coerce.number().parse(params['receiptId']);
}
throw new Error('No receiptId found in route params');
});
receiptResource = this.#store.receiptResource(this.receiptId);
customerReceiptsResource = resource({
params: this.receiptResource.value,
loader: async ({ params, abortSignal }) => {
const email = params.buyer?.communicationDetails?.email;
if (!email) {
return [];
}
try {
return await this.#returnDetailsService.fetchReceiptsByEmail(
{ email },
abortSignal,
);
} catch (error) {
this.#logger.error('Failed to fetch customer receipts', error);
return [];
}
},
});
canStartProcess = computed(() => {
return (
this.#store.selectedItemIds().length > 0 && this.processId() !== undefined
);
});
startProcess() {
if (!this.canStartProcess()) {
this.#logger.warn(
'Cannot start process: No items selected or no process ID',
);
return;
}
const processId = this.processId();
const selectedItems = this.#store.selectedItems();
const selectedQuantites = this.#store.selectedQuantityMap();
const selectedProductCategories = this.#store.itemCategoryMap();
this.#logger.info('Starting return process', () => ({
processId: processId,
selectedItems: selectedItems.map((item) => item.id),
}));
if (!selectedItems.length || !processId) {
return;
}
const itemsGrouptByReceiptId = groupBy(
selectedItems,
(item) => item.receipt?.id,
);
const receipts = this.#store.receiptsEntityMap();
const returns = Object.entries(itemsGrouptByReceiptId).map(
([receiptId, items]) => ({
receipt: receipts[Number(receiptId)],
items: items.map((item) => {
const receiptItem = item;
return {
receiptItem,
quantity: selectedQuantites[receiptItem.id],
category: selectedProductCategories[receiptItem.id],
};
}),
}),
);
this.#logger.info('Starting return process with returns', () => ({
processId,
returns,
}));
this.#returnProcessStore.startProcess({
processId,
returns,
});
this._router.navigate(['../../', 'process'], {
relativeTo: this._activatedRoute,
});
}
}

View File

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

View File

@@ -21,7 +21,7 @@ import { ReturnProcessComponent } from './return-process.component';
const mockActivatedProcessIdSignal = signal<number | null>(123);
jest.mock('@isa/core/tabs', () => ({
injectActivatedTabId: jest.fn(() => mockActivatedProcessIdSignal),
injectTabId: jest.fn(() => mockActivatedProcessIdSignal),
}));
jest.mock('scandit-web-datacapture-core', () => ({}));

View File

@@ -18,7 +18,7 @@ import {
ReturnProcessService,
ReturnProcessStore,
} from '@isa/oms/data-access';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { ReturnProcessItemComponent } from './return-process-item/return-process-item.component';
import { Location } from '@angular/common';
import { RouterLink } from '@angular/router';
@@ -58,7 +58,7 @@ export class ReturnProcessComponent {
#logger = logger();
/** Signal emitting the numeric ID of the currently active return process, derived from the route parameters. Null if no ID is present. */
processId = injectActivatedTabId();
processId = injectTabId();
#returnCanReturnService = inject(ReturnCanReturnService);

View File

@@ -1,7 +1,7 @@
import { computed, inject, Injectable } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { firstValueFrom } from 'rxjs';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { ReturnTaskListStore } from '@isa/oms/data-access';
import { ReturnReviewComponent } from '../return-review.component';
import { ConfirmationDialogComponent, injectDialog } from '@isa/ui/dialog';
@@ -15,7 +15,7 @@ export class UncompletedTasksGuard
title: 'Aufgaben erledigen',
});
processId = injectActivatedTabId();
processId = injectTabId();
uncompletedTaskListItems = computed(() => {
const processId = this.processId();

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { PrintReceiptsService, ReturnProcessStore } from '@isa/oms/data-access';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { ReturnTaskListComponent } from '@isa/oms/shared/task-list';
import { ReturnReviewHeaderComponent } from './return-review-header/return-review-header.component';
@@ -15,7 +15,7 @@ import { ReturnReviewHeaderComponent } from './return-review-header/return-revie
export class ReturnReviewComponent {
#printReceiptsService = inject(PrintReceiptsService);
#returnProcessStore = inject(ReturnProcessStore);
processId = injectActivatedTabId();
processId = injectTabId();
async printReceipt() {
const processId = this.processId();

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { CallbackResult, ListResponseArgs } from '@isa/common/data-access';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import {
ReceiptListItem,
ReturnSearchStatus,
@@ -38,7 +38,7 @@ export class ReturnSearchMainComponent {
#route = inject(ActivatedRoute);
#router = inject(Router);
private _processId = injectActivatedTabId();
private _processId = injectTabId();
private _filterService = inject(FilterService);
private _returnSearchStore = inject(ReturnSearchStore);

View File

@@ -6,7 +6,7 @@ import {
inject,
linkedSignal,
} from '@angular/core';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
@@ -72,7 +72,7 @@ export class ReturnSearchResultComponent implements AfterViewInit {
restoreScrollPosition = injectRestoreScrollPosition();
/** Current process ID from the activated route */
processId = injectActivatedTabId();
processId = injectTabId();
/** Store for managing return search data and operations */
returnSearchStore = inject(ReturnSearchStore);

View File

@@ -10,7 +10,7 @@ import { ReturnSummaryItemComponent } from './return-summary-item/return-summary
import { ActivatedRoute, Router } from '@angular/router';
jest.mock('@isa/core/tabs', () => ({
injectActivatedTabId: () => jest.fn(() => 1),
injectTabId: () => jest.fn(() => 1),
}));
const MOCK_RETURN_PROCESSES: ReturnProcess[] = [

View File

@@ -6,7 +6,7 @@ import {
signal,
} from '@angular/core';
import { ReturnSummaryItemComponent } from './return-summary-item/return-summary-item.component';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import {
ReturnProcess,
ReturnProcessService,
@@ -55,7 +55,7 @@ export class ReturnSummaryComponent {
location = inject(Location);
/** The active process ID from the current route */
processId = injectActivatedTabId();
processId = injectTabId();
/** Filtered list of return processes for the current process ID */
returnProcesses = computed<ReturnProcess[]>(() => {
@@ -118,9 +118,13 @@ export class ReturnSummaryComponent {
relativeTo: this.#activatedRoute,
});
} catch (error) {
this.#logger.error('Error completing return process', error as Error, () => ({
function: 'returnItemsAndPrintRecipt',
}));
this.#logger.error(
'Error completing return process',
error as Error,
() => ({
function: 'returnItemsAndPrintRecipt',
}),
);
this.returnItemsAndPrintReciptStatus.set('error');
}
}

View File

@@ -20,7 +20,7 @@ import {
} from '@isa/oms/data-access';
import { IconButtonComponent } from '@isa/ui/buttons';
import { firstValueFrom } from 'rxjs';
import { injectActivatedTabId } from '@isa/core/tabs';
import { injectTabId } from '@isa/core/tabs';
import { logger, provideLoggerContext } from '@isa/core/logging';
// TODO: Komponente und logik benötigt review
@@ -45,7 +45,7 @@ export class ReturnTaskListComponent {
#returnTaskListStore = inject(ReturnTaskListStore);
#logger = logger();
processId = injectActivatedTabId();
processId = injectTabId();
appearanceClass = computed(
() => `oms-shared-return-task-list__${this.appearance()}`,

View File

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

View File

@@ -0,0 +1,80 @@
import { calculateAvailableStock } from './calc-available-stock.helper';
describe('calculateAvailableStock', () => {
it('should return stock when removedFromStock is undefined', () => {
// Arrange
const input = { stock: 10 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(10);
});
it('should return 0 when stock is undefined and removedFromStock is undefined', () => {
// Arrange
const input = { stock: undefined };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
it('should subtract removedFromStock from stock', () => {
// Arrange
const input = { stock: 20, removedFromStock: 5 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(15);
});
it('should return 0 if result is negative', () => {
// Arrange
const input = { stock: 3, removedFromStock: 5 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
it('should treat undefined stock as 0', () => {
// Arrange
const input = { stock: undefined, removedFromStock: 2 };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
it('should treat undefined removedFromStock as 0', () => {
// Arrange
const input = { stock: 7, removedFromStock: undefined };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(7);
});
it('should return 0 if both stock and removedFromStock are undefined', () => {
// Arrange
const input = { stock: undefined, removedFromStock: undefined };
// Act
const result = calculateAvailableStock(input);
// Assert
expect(result).toBe(0);
});
});

View File

@@ -0,0 +1,18 @@
/**
* Current available stock.
* Calculation: stock - removedFromStock
* Returns 0 if result is negative.
*
* @remarks
* Used as the base for further stock calculations.
*/
export const calculateAvailableStock = ({
stock,
removedFromStock,
}: {
stock: number | undefined;
removedFromStock?: number;
}): number => {
const availableStock = (stock ?? 0) - (removedFromStock ?? 0);
return availableStock < 0 ? 0 : availableStock;
};

View File

@@ -0,0 +1,207 @@
import {
calculateCapacity,
calculateMaxCapacity,
} from './calc-capacity.helper';
describe('calculateCapacity', () => {
it('should return capacityValue2 when it is smaller than capacityValue3', () => {
// Arrange
const input = { capacityValue2: 5, capacityValue3: 10 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(5);
});
it('should return capacityValue3 when it is smaller than capacityValue2', () => {
// Arrange
const input = { capacityValue2: 15, capacityValue3: 8 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return capacityValue3 when both values are equal', () => {
// Arrange
const input = { capacityValue2: 10, capacityValue3: 10 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle zero values correctly', () => {
// Arrange
const input = { capacityValue2: 0, capacityValue3: 5 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(0);
});
it('should handle negative values correctly', () => {
// Arrange
const input = { capacityValue2: -3, capacityValue3: 2 };
// Act
const result = calculateCapacity(input);
// Assert
expect(result).toBe(-3);
});
});
describe('calculateMaxCapacity', () => {
it('should return capacityValue2 when capacityValue4 is greater than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 20,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should return capacityValue4 when it is positive and less than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 8,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return capacityValue2 when capacityValue4 is zero and capacityValue3 is greater than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: 0,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should return capacityValue3 when capacityValue4 is zero and capacityValue3 is positive and less than capacityValue2', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 8,
capacityValue4: 0,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
it('should return comparer when it is greater than calculated max capacity', () => {
// Arrange
const input = {
capacityValue2: 5,
capacityValue3: 3,
capacityValue4: 2,
comparer: 10,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle undefined capacityValue4 with default value 0', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 15,
capacityValue4: undefined,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(10);
});
it('should handle all zero capacity values', () => {
// Arrange
const input = {
capacityValue2: 0,
capacityValue3: 0,
capacityValue4: 0,
comparer: 3,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(3);
});
it('should handle negative capacity values', () => {
// Arrange
const input = {
capacityValue2: -5,
capacityValue3: -3,
capacityValue4: -2,
comparer: 1,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(1);
});
it('should use default values for optional parameters when not provided', () => {
// Arrange
const input = {
capacityValue2: 10,
capacityValue3: 8,
capacityValue4: undefined,
comparer: 5,
};
// Act
const result = calculateMaxCapacity(input);
// Assert
expect(result).toBe(8);
});
});

View File

@@ -0,0 +1,56 @@
/**
* Calculates the capacity based on the provided capacity values.
* It returns the minimum of the two capacity values.
* @param {Object} params - The parameters for the calculation
* @param {number} params.capacityValue2 - The second capacity value
* @param {number} params.capacityValue3 - The third capacity value
* @return {number} The calculated capacity
*/
export const calculateCapacity = ({
capacityValue2,
capacityValue3,
}: {
capacityValue2: number;
capacityValue3: number;
}): number => {
return capacityValue3 > capacityValue2 ? capacityValue2 : capacityValue3;
};
/**
* Calculates the maximum capacity based on the provided capacity values.
* It compares the values and returns the maximum capacity that is greater than or equal to the comparer
* or the maximum of the capacity values.
* @param {Object} params - The parameters for the calculation
* @param {number} params.capacityValue2 - The second capacity value
* @param {number} params.capacityValue3 - The third capacity value
* @param {number} params.capacityValue4 - The fourth capacity value (optional)
* @param {number} params.comparer - The value to compare against
* @return {number} The maximum capacity calculated
*/
export const calculateMaxCapacity = ({
capacityValue2 = 0,
capacityValue3 = 0,
capacityValue4 = 0,
comparer,
}: {
capacityValue2: number;
capacityValue3: number;
capacityValue4: number | undefined;
comparer: number;
}): number => {
let maxCapacity = 0;
if (capacityValue4 < capacityValue2) {
if (capacityValue4 > 0) {
maxCapacity = capacityValue4;
} else if (capacityValue3 > capacityValue2) {
maxCapacity = capacityValue2;
} else if (capacityValue3 > 0) {
maxCapacity = capacityValue3;
}
} else {
maxCapacity = capacityValue2;
}
return Math.max(comparer, maxCapacity);
};

View File

@@ -0,0 +1,62 @@
import {
calculateStockToRemit,
getStockToRemit,
} from './calc-stock-to-remit.helper';
import { RemissionListType } from '@isa/remission/data-access';
describe('calculateStockToRemit', () => {
it('should return predefinedReturnQuantity when provided', () => {
const result = calculateStockToRemit({
availableStock: 10,
predefinedReturnQuantity: 5,
remainingQuantityInStock: 2,
});
expect(result).toBe(5);
});
it('should calculate availableStock minus remainingQuantityInStock when no predefinedReturnQuantity', () => {
const result = calculateStockToRemit({
availableStock: 10,
remainingQuantityInStock: 3,
});
expect(result).toBe(7);
});
});
describe('getStockToRemit', () => {
it('should handle Pflicht remission list type with predefined return quantity', () => {
const remissionItem = {
remainingQuantityInStock: 2,
predefinedReturnQuantity: 5,
} as any;
const result = getStockToRemit({
remissionItem,
remissionListType: RemissionListType.Pflicht,
availableStock: 10,
});
expect(result).toBe(5);
});
it('should handle Abteilung remission list type with return suggestion', () => {
const remissionItem = {
remainingQuantityInStock: 1,
returnItem: {
data: {
predefinedReturnQuantity: 8,
},
},
} as any;
const result = getStockToRemit({
remissionItem,
remissionListType: RemissionListType.Abteilung,
availableStock: 10,
});
expect(result).toBe(8);
});
});

View File

@@ -0,0 +1,71 @@
import {
RemissionItem,
RemissionListType,
ReturnItem,
ReturnSuggestion,
} from '@isa/remission/data-access';
/**
* Calculates the stock to remit based on the remission item and available stock.
* Uses the getStockToRemit helper function.
* @param {Object} params - The parameters for the calculation
* @param {RemissionItem} params.remissionItem - The remission item to calculate stock for
* @param {RemissionListType} params.remissionListType - The type of the remission list
* @param {number} params.availableStock - The available stock for the item
* @return {number} The calculated stock to remit
*/
export const getStockToRemit = ({
remissionItem,
remissionListType,
availableStock,
}: {
remissionItem: RemissionItem;
remissionListType: RemissionListType;
availableStock: number;
}): number => {
const remainingQuantityInStock = remissionItem?.remainingQuantityInStock;
let predefinedReturnQuantity: number | undefined = 0;
if (remissionListType === RemissionListType.Pflicht) {
predefinedReturnQuantity =
(remissionItem as ReturnItem)?.predefinedReturnQuantity ?? 0;
}
if (remissionListType === RemissionListType.Abteilung) {
predefinedReturnQuantity = (remissionItem as ReturnSuggestion)?.returnItem
?.data?.predefinedReturnQuantity;
}
return calculateStockToRemit({
availableStock,
remainingQuantityInStock,
predefinedReturnQuantity,
});
};
/**
* Calculates the stock to remit based on available stock, predefined return quantity,
* and remaining quantity in stock.
*
* @param {Object} params - The parameters for the calculation.
* @param {number} params.availableStock - The total available stock.
* @param {number} [params.predefinedReturnQuantity] - The predefined return quantity, if any.
* @param {number} [params.remainingQuantityInStock] - The remaining quantity in stock, if any.
* @returns {number} - The calculated stock to remit.
*/
export const calculateStockToRemit = ({
availableStock,
predefinedReturnQuantity,
remainingQuantityInStock,
}: {
availableStock: number;
predefinedReturnQuantity?: number;
remainingQuantityInStock?: number;
}): number => {
if (predefinedReturnQuantity === undefined) {
const stockToRemit = availableStock - (remainingQuantityInStock ?? 0);
return stockToRemit < 0 ? 0 : stockToRemit;
}
return predefinedReturnQuantity;
};

View File

@@ -0,0 +1,59 @@
import { calculateTargetStock } from './calc-target-stock.helper';
describe('calculateTargetStock', () => {
it('should return remainingQuantityInStock if set', () => {
// Arrange
const input = {
availableStock: 10,
stockToRemit: 3,
remainingQuantityInStock: 5,
};
// Act
const result = calculateTargetStock(input);
// Assert
expect(result).toBe(5);
});
it('should calculate as availableStock - stockToRemit if remainingQuantityInStock is not set', () => {
// Arrange
const input = {
availableStock: 10,
stockToRemit: 4,
};
// Act
const result = calculateTargetStock(input);
// Assert
expect(result).toBe(6);
});
it('should return 0 if result is negative', () => {
// Arrange
const input = {
availableStock: 2,
stockToRemit: 5,
};
// Act
const result = calculateTargetStock(input);
// Assert
expect(result).toBe(0);
});
it('should treat undefined stockToRemit as 0', () => {
// Arrange
const input = {
availableStock: 7,
};
// Act
const result = calculateTargetStock(input);
// Assert
expect(result).toBe(7);
});
});

View File

@@ -0,0 +1,27 @@
/**
* Target stock after remission.
*
* - If `remainingQuantityInStock` is set (non-zero), returns that value.
* - Otherwise, calculates as `availableStock - stockToRemit`.
* - Returns 0 if the result is negative.
*
* @remarks
* Depends on `stockToRemit` for calculation.
* Represents the expected stock after the remission process.
*/
export const calculateTargetStock = ({
availableStock,
stockToRemit,
remainingQuantityInStock,
}: {
availableStock: number;
stockToRemit?: number;
remainingQuantityInStock?: number;
}): number => {
if (!remainingQuantityInStock) {
const targetStock = availableStock - (stockToRemit ?? 0);
return targetStock < 0 ? 0 : targetStock;
}
return remainingQuantityInStock;
};

View File

@@ -0,0 +1,4 @@
export * from './calc-available-stock.helper';
export * from './calc-stock-to-remit.helper';
export * from './calc-target-stock.helper';
export * from './calc-capacity.helper';

View File

@@ -12,3 +12,6 @@ export * from './return';
export * from './stock-info';
export * from './stock';
export * from './supplier';
export * from './receipt-return-tuple';
export * from './receipt-return-suggestion-tuple';
export * from './value-tuple-sting-and-integer';

View File

@@ -0,0 +1,9 @@
import { ValueTupleOfReceiptItemDTOAndReturnSuggestionDTO } from '@generated/swagger/inventory-api';
import { Receipt } from './receipt';
import { ReturnSuggestion } from './return-suggestion';
export interface ReceiptReturnSuggestionTuple
extends ValueTupleOfReceiptItemDTOAndReturnSuggestionDTO {
item1: Receipt;
item2: ReturnSuggestion;
}

View File

@@ -0,0 +1,9 @@
import { ValueTupleOfReceiptItemDTOAndReturnItemDTO } from '@generated/swagger/inventory-api';
import { Receipt } from './receipt';
import { Return } from './return';
export interface ReceiptReturnTuple
extends ValueTupleOfReceiptItemDTOAndReturnItemDTO {
item1: Receipt;
item2: Return;
}

View File

@@ -5,4 +5,5 @@ import { Price } from './price';
export interface ReturnItem extends ReturnItemDTO {
product: Product;
retailPrice: Price;
quantity: number;
}

View File

@@ -5,4 +5,5 @@ import { Price } from './price';
export interface ReturnSuggestion extends ReturnSuggestionDTO {
product: Product;
retailPrice: Price;
quantity: number;
}

View File

@@ -0,0 +1,10 @@
import { ValueTupleOfStringAndIntegerAndIntegerAndNullableIntegerAndString } from '@generated/swagger/inventory-api';
export interface ValueTupleOfStringAndInteger
extends ValueTupleOfStringAndIntegerAndIntegerAndNullableIntegerAndString {
item1?: string;
item2: number;
item3: number;
item4?: number;
item5?: string;
}

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const AddReturnItemSchema = z.object({
returnId: z.number(),
receiptId: z.number(),
returnItemId: z.number(),
quantity: z.number().optional(),
inStock: z.number(),
});
export type AddReturnItem = z.infer<typeof AddReturnItemSchema>;

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
export const AddReturnSuggestionItemSchema = z.object({
returnId: z.number(),
receiptId: z.number(),
returnSuggestionId: z.number(),
quantity: z.number().optional(),
inStock: z.number(),
});
export type AddReturnSuggestionItem = z.infer<
typeof AddReturnSuggestionItemSchema
>;

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const AssignPackageSchema = z.object({
returnId: z.number(),
receiptId: z.number(),
packageNumber: z.string(),
});
export type AssignPackage = z.infer<typeof AssignPackageSchema>;

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const CreateReceiptSchema = z.object({
returnId: z.number(),
receiptNumber: z.string().optional(), // InputDialogValue or generated
});
export type CreateReceipt = z.infer<typeof CreateReceiptSchema>;

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const CreateReturnSchema = z
.object({
returnGroup: z.string().or(z.undefined()), // Wird gesetzt, wenn die Remission fortgesetzt wird nachdem man eine Wanne abgeschlossen hat
})
.transform((o) => ({ returnGroup: o.returnGroup })); // Um keine Optionalität zuzulassen
export type CreateReturn = z.infer<typeof CreateReturnSchema>;

View File

@@ -0,0 +1,14 @@
import z from 'zod';
export const FetchRemissionReturnReceiptsSchema = z.object({
returncompleted: z.boolean(),
start: z.coerce.date().optional(),
});
export type FetchRemissionReturnReceipts = z.infer<
typeof FetchRemissionReturnReceiptsSchema
>;
export type FetchRemissionReturnReceiptsParams = z.input<
typeof FetchRemissionReturnReceiptsSchema
>;

View File

@@ -0,0 +1,9 @@
import { z } from 'zod';
export const FetchRequiredCapacitySchema = z.object({
departments: z.array(z.string()),
supplierId: z.number(),
stockId: z.number(),
});
export type FetchRequiredCapacity = z.infer<typeof FetchRequiredCapacitySchema>;

View File

@@ -1,9 +0,0 @@
import { z } from 'zod';
export const FetchReturnReasonSchema = z.object({
stockId: z.number(),
});
export type FetchReturnReason = z.infer<typeof FetchReturnReasonSchema>;
export type FetchReturnReasonParams = z.input<typeof FetchReturnReasonSchema>;

View File

@@ -1,5 +1,11 @@
export * from './add-return-item.schema';
export * from './add-return-suggestion.schema';
export * from './assign-package.schema';
export * from './create-receipt.schema';
export * from './create-return.schema';
export * from './fetch-query-settings.schema';
export * from './fetch-remission-return-receipt.schema';
export * from './fetch-return-reason.schema';
export * from './fetch-remission-return-receipts.schema';
export * from './fetch-stock-in-stock.schema';
export * from './query-token.schema';
export * from './fetch-required-capacity.schema';

View File

@@ -4,13 +4,8 @@ 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';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { InFlight, Cache, CacheTimeToLive } from '@isa/common/decorators';
/**
* Service responsible for managing remission product groups.
@@ -57,7 +52,8 @@ export class RemissionProductGroupService {
* console.error('Failed to fetch product groups:', error);
* }
*/
@InFlightWithCache()
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
async fetchProductGroups(
abortSignal?: AbortSignal,
): Promise<KeyValueStringAndString[]> {

View File

@@ -0,0 +1,176 @@
import { TestBed } from '@angular/core/testing';
import { of } from 'rxjs';
import { RemissionReasonService } from './remission-reason.service';
import { ReturnService } from '@generated/swagger/inventory-api';
import { RemissionStockService } from './remission-stock.service';
import { ResponseArgsError } from '@isa/common/data-access';
import { KeyValueStringAndString } from '../models';
describe('RemissionReasonService', () => {
let service: RemissionReasonService;
let returnServiceSpy: jest.Mocked<ReturnService>;
let stockServiceSpy: jest.Mocked<RemissionStockService>;
beforeEach(() => {
const returnServiceMock = {
ReturnGetReturnReasons: jest.fn(),
};
const stockServiceMock = {
fetchAssignedStock: jest.fn(),
};
TestBed.configureTestingModule({
providers: [
RemissionReasonService,
{ provide: ReturnService, useValue: returnServiceMock },
{ provide: RemissionStockService, useValue: stockServiceMock },
],
});
service = TestBed.inject(RemissionReasonService);
returnServiceSpy = TestBed.inject(ReturnService) as jest.Mocked<ReturnService>;
stockServiceSpy = TestBed.inject(RemissionStockService) as jest.Mocked<RemissionStockService>;
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('fetchReturnReasons', () => {
it('should return reasons when successful', async () => {
// Arrange
const mockStock = { id: 123, name: 'Test Stock' };
const mockReasons: KeyValueStringAndString[] = [
{ key: 'reason1', value: 'Defective' },
{ key: 'reason2', value: 'Wrong size' },
];
const mockResponse = {
error: false,
result: mockReasons,
};
stockServiceSpy.fetchAssignedStock.mockResolvedValue(mockStock);
returnServiceSpy.ReturnGetReturnReasons.mockReturnValue(of(mockResponse));
// Act
const result = await service.fetchReturnReasons();
// Assert
expect(result).toEqual(mockReasons);
expect(stockServiceSpy.fetchAssignedStock).toHaveBeenCalled();
expect(returnServiceSpy.ReturnGetReturnReasons).toHaveBeenCalledWith({
stockId: 123,
});
});
it('should return empty array when no assigned stock', async () => {
// Arrange
stockServiceSpy.fetchAssignedStock.mockResolvedValue(null as any);
// Act
const result = await service.fetchReturnReasons();
// Assert
expect(result).toEqual([]);
expect(stockServiceSpy.fetchAssignedStock).toHaveBeenCalled();
expect(returnServiceSpy.ReturnGetReturnReasons).not.toHaveBeenCalled();
});
it('should throw ResponseArgsError when API request fails', async () => {
// Arrange
const mockStock = { id: 123, name: 'Test Stock' };
const mockErrorResponse = {
error: true,
message: 'API Error',
};
stockServiceSpy.fetchAssignedStock.mockResolvedValue(mockStock);
returnServiceSpy.ReturnGetReturnReasons.mockReturnValue(of(mockErrorResponse));
// Act & Assert
await expect(service.fetchReturnReasons()).rejects.toThrow(ResponseArgsError);
expect(stockServiceSpy.fetchAssignedStock).toHaveBeenCalled();
expect(returnServiceSpy.ReturnGetReturnReasons).toHaveBeenCalledWith({
stockId: 123,
});
});
it('should handle abort signal', async () => {
// Arrange
const mockStock = { id: 123, name: 'Test Stock' };
const mockReasons: KeyValueStringAndString[] = [
{ key: 'reason1', value: 'Defective' },
];
const mockResponse = {
error: false,
result: mockReasons,
};
stockServiceSpy.fetchAssignedStock.mockResolvedValue(mockStock);
returnServiceSpy.ReturnGetReturnReasons.mockReturnValue(of(mockResponse));
const abortController = new AbortController();
// Act
const result = await service.fetchReturnReasons(abortController.signal);
// Assert
expect(result).toEqual(mockReasons);
expect(stockServiceSpy.fetchAssignedStock).toHaveBeenCalledWith(abortController.signal);
expect(returnServiceSpy.ReturnGetReturnReasons).toHaveBeenCalledWith({
stockId: 123,
});
});
it('should handle undefined assigned stock', async () => {
// Arrange
stockServiceSpy.fetchAssignedStock.mockResolvedValue(undefined as any);
// Act
const result = await service.fetchReturnReasons();
// Assert
expect(result).toEqual([]);
expect(stockServiceSpy.fetchAssignedStock).toHaveBeenCalled();
expect(returnServiceSpy.ReturnGetReturnReasons).not.toHaveBeenCalled();
});
it('should handle empty reasons response', async () => {
// Arrange
const mockStock = { id: 123, name: 'Test Stock' };
const mockResponse = {
error: false,
result: [],
};
stockServiceSpy.fetchAssignedStock.mockResolvedValue(mockStock);
returnServiceSpy.ReturnGetReturnReasons.mockReturnValue(of(mockResponse));
// Act
const result = await service.fetchReturnReasons();
// Assert
expect(result).toEqual([]);
expect(stockServiceSpy.fetchAssignedStock).toHaveBeenCalled();
expect(returnServiceSpy.ReturnGetReturnReasons).toHaveBeenCalledWith({
stockId: 123,
});
});
it('should handle API error without message', async () => {
// Arrange
const mockStock = { id: 123, name: 'Test Stock' };
const mockErrorResponse = {
error: true,
message: undefined,
};
stockServiceSpy.fetchAssignedStock.mockResolvedValue(mockStock);
returnServiceSpy.ReturnGetReturnReasons.mockReturnValue(of(mockErrorResponse));
// Act & Assert
await expect(service.fetchReturnReasons()).rejects.toThrow(ResponseArgsError);
});
});
});

View File

@@ -1,85 +1,102 @@
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
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[];
}
}
import { inject, Injectable } from '@angular/core';
import { ReturnService } from '@generated/swagger/inventory-api';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { firstValueFrom } from 'rxjs';
import { KeyValueStringAndString } from '../models';
import { logger } from '@isa/core/logging';
import { InFlight, Cache, CacheTimeToLive } from '@isa/common/decorators';
import { RemissionStockService } from './remission-stock.service';
/**
* 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 {
#stockService = inject(RemissionStockService);
#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);
* }
*/
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
async fetchReturnReasons(
abortSignal?: AbortSignal,
): Promise<KeyValueStringAndString[]> {
this.#logger.debug('Fetching return reasons');
const assignedStock =
await this.#stockService.fetchAssignedStock(abortSignal);
if (!assignedStock) {
this.#logger.warn('No assigned stock found, returning empty reasons');
return [];
}
this.#logger.info('Fetching return reasons from API', () => ({
stockId: assignedStock?.id,
}));
let req$ = this.#returnService.ReturnGetReturnReasons({
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) {
this.#logger.error(
'Failed to fetch return reasons',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.debug('Successfully fetched return reasons', () => ({
reasonCount: res.result?.length || 0,
}));
return res.result as KeyValueStringAndString[];
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -6,23 +6,39 @@ import { firstValueFrom } from 'rxjs';
import { RemissionStockService } from './remission-stock.service';
import { Return } from '../models/return';
import {
AddReturnItem,
AddReturnItemSchema,
AddReturnSuggestionItem,
AddReturnSuggestionItemSchema,
AssignPackage,
CreateReceipt,
CreateReturn,
CreateReturnSchema,
FetchRemissionReturnParams,
FetchRemissionReturnReceiptSchema,
FetchRemissionReturnReceiptsParams,
FetchRemissionReturnReceiptsSchema,
} from '../schemas';
import { Receipt } from '../models';
import {
Receipt,
ReceiptReturnSuggestionTuple,
ReceiptReturnTuple,
RemissionListType,
} from '../models';
import { logger } from '@isa/core/logging';
import { RemissionSupplierService } from './remission-supplier.service';
/**
* 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();
@@ -33,41 +49,50 @@ export class RemissionReturnReceiptService {
#returnService = inject(ReturnService);
/** Private instance of the remission stock service */
#remissionStockService = inject(RemissionStockService);
/** Private instance of the remission supplier service */
#remissionSupplierService = inject(RemissionSupplierService);
/** 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(
async fetchRemissionReturnReceipts(
params: FetchRemissionReturnReceiptsParams,
abortSignal?: AbortSignal,
): Promise<Return[]> {
this.#logger.debug('Fetching completed remission return receipts');
const { start, returncompleted } =
FetchRemissionReturnReceiptsSchema.parse(params);
// Default to 7 days ago if no start date is provided
const startDate = start ?? subDays(new Date(), 7);
const assignedStock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
this.#logger.info('Fetching completed returns from API', () => ({
stockId: assignedStock.id,
startDate: subDays(new Date(), 7).toISOString()
startDate: startDate.toISOString(),
}));
let req$ = this.#returnService.ReturnQueryReturns({
stockId: assignedStock.id,
queryToken: {
input: { returncompleted: 'true' },
start: subDays(new Date(), 7).toISOString(),
filter: { returncompleted: returncompleted ? 'true' : 'false' },
start: startDate.toISOString(),
eagerLoading: 3,
},
});
@@ -80,77 +105,25 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch completed returns', new Error(res.message || 'Unknown 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
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
@@ -159,7 +132,7 @@ export class RemissionReturnReceiptService {
* @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',
@@ -171,15 +144,15 @@ export class RemissionReturnReceiptService {
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
returnId,
}));
let req$ = this.#returnService.ReturnGetReturnReceipt({
receiptId,
returnId,
@@ -194,15 +167,583 @@ export class RemissionReturnReceiptService {
const res = await firstValueFrom(req$);
if (res?.error) {
this.#logger.error('Failed to fetch return receipt', new Error(res.message || 'Unknown 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
found: !!receipt,
}));
return receipt;
}
/**
* Creates a new remission return with an optional receipt number.
* Uses CreateReturnSchema to validate parameters before making the request.
*
* @async
* @param {CreateReturn} params - The parameters for creating the return
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Return | undefined>} The created return object if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const newReturn = await service.createReturn({ returnGroup: 'group1' });
*/
async createReturn(
params: CreateReturn,
abortSignal?: AbortSignal,
): Promise<Return | undefined> {
this.#logger.debug('Create remission return', () => ({ params }));
const suppliers =
await this.#remissionSupplierService.fetchSuppliers(abortSignal);
const firstSupplier = suppliers[0];
this.#logger.debug('Create remission return', () => ({
params,
}));
let { returnGroup } = CreateReturnSchema.parse(params);
// Wird gesetzt, wenn die Remission fortgesetzt wird nachdem man eine Wanne abgeschlossen hat
// Ansonsten Default auf aktuelles Datum
if (!returnGroup) {
returnGroup = String(Date.now());
}
this.#logger.info('Create remission return from API', () => ({
supplierId: firstSupplier.id,
returnGroup,
}));
let req$ = this.#returnService.ReturnCreateReturn({
data: {
supplier: { id: firstSupplier.id },
returnGroup,
},
});
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 create return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const createdReturn = res?.result as Return | undefined;
this.#logger.debug('Successfully created return', () => ({
found: !!createdReturn,
}));
return createdReturn;
}
/**
* Creates a new remission return receipt with the specified parameters.
* Validates parameters using CreateReceiptSchema before making the request.
*
* @async
* @param {CreateReceipt} params - The parameters for creating the receipt
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Receipt | undefined>} The created receipt object if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const receipt = await service.createReceipt({
* returnId: 123,
* receiptNumber: 'ABC-123',
* });
*/
async createReceipt(
params: CreateReceipt,
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
this.#logger.debug('Create remission return receipt', () => ({ params }));
const stock =
await this.#remissionStockService.fetchAssignedStock(abortSignal);
const suppliers =
await this.#remissionSupplierService.fetchSuppliers(abortSignal);
const firstSupplier = suppliers[0];
const { returnId, receiptNumber } = params;
this.#logger.info('Create remission return receipt from API', () => ({
returnId,
receiptNumber,
}));
let req$ = this.#returnService.ReturnCreateReceipt({
returnId,
data: {
receiptNumber,
stock: {
id: stock.id,
},
supplier: {
id: firstSupplier.id,
},
receiptType: 1, // Default to ShippingNote = 1
},
});
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 create return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
this.#logger.debug('Successfully created return receipt', () => ({
found: !!receipt,
}));
return receipt;
}
/**
* Assigns a package number to an existing return receipt.
* Validates parameters using AssignPackageSchema before making the request.
*
* @async
* @param {AssignPackage} params - The parameters for assigning the package number
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<Receipt | undefined>} The updated receipt object if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const updatedReceipt = await service.assignPackage({
* returnId: 123,
* receiptId: 456,
* packageNumber: 'PKG-789',
* });
*/
async assignPackage(
params: AssignPackage,
abortSignal?: AbortSignal,
): Promise<Receipt | undefined> {
this.#logger.debug('Assign package to return receipt', () => ({ params }));
const { returnId, receiptId, packageNumber } = params;
this.#logger.info('Assign package from API', () => ({
returnId,
receiptId,
packageNumber,
}));
let req$ = this.#returnService.ReturnCreateAndAssignPackage({
returnId,
receiptId,
data: {
packageNumber,
},
});
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 assign package',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const receipt = res?.result as Receipt | undefined;
this.#logger.debug('Successfully assigned package', () => ({
found: !!receipt,
}));
return receipt;
}
async removeReturnItemFromReturnReceipt(params: {
returnId: number;
receiptId: number;
receiptItemId: number;
}) {
const res = await firstValueFrom(
this.#returnService.ReturnRemoveReturnItem(params),
);
if (res?.error) {
this.#logger.error(
'Failed to remove item from return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
}
async completeReturnReceipt({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}): Promise<Receipt> {
this.#logger.debug('Completing return receipt', () => ({ returnId }));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReceipt({
returnId,
receiptId,
data: {},
}),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return receipt',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
return res?.result as Receipt;
}
async completeReturn(params: { returnId: number }): Promise<Return> {
this.#logger.debug('Completing return', () => ({
returnId: params.returnId,
}));
const res = await firstValueFrom(
this.#returnService.ReturnFinalizeReturn(params),
);
if (res?.error) {
this.#logger.error(
'Failed to complete return',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
this.#logger.info('Successfully completed return', () => ({
returnId: params.returnId,
}));
return res?.result as Return;
}
async completeReturnReceiptAndReturn(params: {
returnId: number;
receiptId: number;
}): Promise<Return> {
this.#logger.debug('Completing return receipt and return', () => ({
returnId: params.returnId,
receiptId: params.receiptId,
}));
await this.completeReturnReceipt({
returnId: params.returnId,
receiptId: params.receiptId,
});
const completedReturn = await this.completeReturn({
returnId: params.returnId,
});
this.#logger.info('Successfully completed return and receipt', () => ({
returnId: params.returnId,
receiptId: params.receiptId,
}));
return completedReturn;
}
/**
* Adds a return item to the specified return receipt.
* Validates parameters using AddReturnItemSchema before making the request.
*
* @async
* @param {AddReturnItem} params - The parameters for adding the return item
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<ReceiptReturnTuple | undefined>} The updated receipt and return tuple if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const updatedTuple = await service.addReturnItem({
* returnId: 123,
* receiptId: 456,
* returnItemId: 789,
* quantity: 10,
* inStock: 5,
* });
*/
async addReturnItem(
params: AddReturnItem,
abortSignal?: AbortSignal,
): Promise<ReceiptReturnTuple | undefined> {
this.#logger.debug('Adding return item', () => ({ params }));
const { returnId, receiptId, returnItemId, quantity, inStock } =
AddReturnItemSchema.parse(params);
this.#logger.info('Add return item from API', () => ({
returnId,
receiptId,
returnItemId,
quantity,
inStock,
}));
let req$ = this.#returnService.ReturnAddReturnItem({
returnId,
receiptId,
data: {
returnItemId,
quantity,
inStock,
},
});
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 add return item',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const updatedReturn = res?.result as ReceiptReturnTuple | undefined;
this.#logger.debug('Successfully added return item', () => ({
found: !!updatedReturn,
}));
return updatedReturn;
}
/**
* Adds a return suggestion item to the specified return receipt.
* Validates parameters using AddReturnSuggestionItemSchema before making the request.
*
* @async
* @param {AddReturnSuggestionItem} params - The parameters for adding the return suggestion item
* @param {AbortSignal} [abortSignal] - Optional signal to abort the request
* @returns {Promise<ReceiptReturnSuggestionTuple | undefined>} The updated receipt and return suggestion tuple if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const updatedTuple = await service.addReturnSuggestionItem({
* returnId: 123,
* receiptId: 456,
* returnSuggestionId: 789,
* quantity: 10,
* inStock: 5,
* });
*/
async addReturnSuggestionItem(
params: AddReturnSuggestionItem,
abortSignal?: AbortSignal,
): Promise<ReceiptReturnSuggestionTuple | undefined> {
this.#logger.debug('Adding return suggestion item', () => ({ params }));
const { returnId, receiptId, returnSuggestionId, quantity, inStock } =
AddReturnSuggestionItemSchema.parse(params);
this.#logger.info('Add return suggestion item from API', () => ({
returnId,
receiptId,
returnSuggestionId,
quantity,
inStock,
}));
let req$ = this.#returnService.ReturnAddReturnSuggestion({
returnId,
receiptId,
data: {
returnSuggestionId,
quantity,
inStock,
},
});
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 add return suggestion item',
new Error(res.message || 'Unknown error'),
);
throw new ResponseArgsError(res);
}
const updatedReturnSuggestion = res?.result as
| ReceiptReturnSuggestionTuple
| undefined;
this.#logger.debug('Successfully added return suggestion item', () => ({
found: !!updatedReturnSuggestion,
}));
return updatedReturnSuggestion;
}
/**
* Starts a new remission process by creating a return and receipt.
* Validates parameters using FetchRemissionReturnReceiptSchema before making the request.
*
* @async
* @param {Object} params - The parameters for starting the remission
* @param {string | undefined} params.returnGroup - Optional group identifier for the return
* @param {string | undefined} params.receiptNumber - Optional receipt number
* @param {string} params.packageNumber - The package number to assign
* @returns {Promise<FetchRemissionReturnParams | undefined>} The created return and receipt identifiers if successful, undefined otherwise
* @throws {ResponseArgsError} When the API request fails
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const remission = await service.startRemission({
* returnGroup: 'group1',
* receiptNumber: 'ABC-123',
* packageNumber: 'PKG-789',
* });
*/
async startRemission({
returnGroup,
receiptNumber,
packageNumber,
}: {
returnGroup: string | undefined;
receiptNumber: string | undefined;
packageNumber: string;
}): Promise<FetchRemissionReturnParams | undefined> {
this.#logger.debug('Starting remission', () => ({
returnGroup,
receiptNumber,
packageNumber,
}));
// Warenbegleitschein eröffnen
const createdReturn: Return | undefined = await this.createReturn({
returnGroup,
});
if (!createdReturn) {
this.#logger.error('Failed to create return for remission');
return;
}
// Warenbegleitschein eröffnen
const createdReceipt: Receipt | undefined = await this.createReceipt({
returnId: createdReturn.id,
receiptNumber,
});
if (!createdReceipt) {
this.#logger.error('Failed to create return receipt');
return;
}
// Wannennummer zuweisen
await this.assignPackage({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
packageNumber,
});
this.#logger.info('Successfully started remission', () => ({
returnId: createdReturn.id,
receiptId: createdReceipt.id,
}));
return {
returnId: createdReturn.id,
receiptId: createdReceipt.id,
};
}
/**
* Remits an item to the return receipt based on its type.
* Determines whether to add a return item or return suggestion item based on the remission list type.
*
* @async
* @param {Object} params - The parameters for remitting the item
* @param {number} params.itemId - The ID of the item to remit
* @param {AddReturnItem | AddReturnSuggestionItem} params.addItem - The item data to add
* @param {RemissionListType} params.type - The type of remission list (Abteilung or Pflicht)
* @returns {Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined>} The updated receipt and return tuple if successful, undefined otherwise
*/
async remitItem({
itemId,
addItem,
type,
}: {
itemId: number;
addItem:
| Omit<AddReturnItem, 'returnItemId'>
| Omit<AddReturnSuggestionItem, 'returnSuggestionId'>;
type: RemissionListType;
}): Promise<ReceiptReturnSuggestionTuple | ReceiptReturnTuple | undefined> {
// ReturnSuggestion
if (type === RemissionListType.Abteilung) {
return await this.addReturnSuggestionItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,
returnSuggestionId: itemId,
quantity: addItem.quantity,
inStock: addItem.inStock,
});
}
// ReturnItem
if (type === RemissionListType.Pflicht) {
return await this.addReturnItem({
returnId: addItem.returnId,
receiptId: addItem.receiptId,
returnItemId: itemId,
quantity: addItem.quantity,
inStock: addItem.inStock,
});
}
return;
}
}

View File

@@ -1,305 +1,481 @@
import { inject, Injectable } from '@angular/core';
import {
QuerySettings,
RemissionListType,
RemissionListTypeKey,
ReturnItem,
ReturnSuggestion,
} from '../models';
import { RemiService } from '@generated/swagger/inventory-api';
import {
FetchQuerySettings,
FetchQuerySettingsSchema,
RemissionQueryTokenInput,
RemissionQueryTokenSchema,
} 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' }));
/**
* 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,
value: RemissionListType[key],
}),
);
}
/**
* 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,
});
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
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,
});
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
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,
supplierId: parsed.supplierId,
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: parsed.take,
skip: parsed.skip,
},
});
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
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,
supplierId: parsed.supplierId,
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
take: parsed.take,
skip: parsed.skip,
},
});
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
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>;
}
}
import { inject, Injectable } from '@angular/core';
import {
QuerySettings,
RemissionListType,
RemissionListTypeKey,
ReturnItem,
ReturnSuggestion,
ValueTupleOfStringAndInteger,
} from '../models';
import { RemiService } from '@generated/swagger/inventory-api';
import {
FetchQuerySettings,
FetchQuerySettingsSchema,
FetchRequiredCapacity,
FetchRequiredCapacitySchema,
RemissionQueryTokenInput,
RemissionQueryTokenSchema,
} from '../schemas';
import { firstValueFrom } from 'rxjs';
import {
BatchResponseArgs,
ListResponseArgs,
ResponseArgsError,
takeUntilAborted,
} from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { Item } from '@isa/catalogue/data-access';
import { RemissionStockService } from './remission-stock.service';
/**
* 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);
#remiStockService = inject(RemissionStockService);
#logger = logger(() => ({ service: 'RemissionSearchService' }));
/**
* 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,
value: RemissionListType[key],
}),
);
}
/**
* Fetches the required capacity for remission based on departments and supplier ID.
* Validates input parameters using FetchRequiredCapacitySchema.
*
* @async
* @param {FetchRequiredCapacity} params - Parameters for fetching required capacity
* @param {string[]} params.departments - List of department names
* @param {number} params.supplierId - ID of the supplier
* @param {number} params.stockId - ID of the stock
* @returns {Promise<ValueTupleOfStringAndInteger[]>} Required capacity data as an array of key-value pairs
* @throws {Error} When the API request fails or returns an error
* @throws {z.ZodError} When parameter validation fails
*
* @example
* const capacity = await service.fetchRequiredCapacity({
* departments: ['Department1', 'Department2'],
* supplierId: 123,
* stockId: 456
* });
*/
async fetchRequiredCapacity(
params: FetchRequiredCapacity,
): Promise<ValueTupleOfStringAndInteger[]> {
this.#logger.debug('Fetching required capacity', () => ({ params }));
const parsed = FetchRequiredCapacitySchema.parse(params);
this.#logger.info('Fetching required capacity from API', () => ({
stockId: parsed.stockId,
departments: parsed.departments,
supplierId: parsed.supplierId,
}));
const req$ = this.#remiService.RemiGetRequiredCapacities({
stockId: parsed.stockId,
payload: {
departments: parsed.departments,
supplierId: parsed.supplierId,
},
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to fetch required capacity', error);
throw error;
}
this.#logger.debug('Successfully fetched required capacity');
return (res?.result ?? []) as ValueTupleOfStringAndInteger[];
}
/**
* 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,
});
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
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,
});
const res = await firstValueFrom(req$);
if (res.error || !res.result) {
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',
* 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,
abortSignal?: AbortSignal,
): 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,
}));
let req$ = this.#remiService.RemiPflichtremissionsartikel({
queryToken: {
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
},
});
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) {
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',
* });
*
* @todo After fetching, StockInStock should be called in the old DomainRemissionService
*/
// TODO: Im alten DomainRemissionService wird danach StockInStock abgerufen
async fetchDepartmentList(
params: RemissionQueryTokenInput,
abortSignal?: AbortSignal,
): 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,
}));
let req$ = this.#remiService.RemiUeberlauf({
queryToken: {
stockId: parsed.assignedStockId,
supplierId: parsed.supplierId,
filter: parsed.filter,
input: parsed.input,
orderBy: parsed.orderBy,
},
});
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) {
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>;
}
async canAddItemToRemiList(
items: { item: Item; quantity: number; reason: string }[],
abortSignal?: AbortSignal,
): Promise<BatchResponseArgs<ReturnItem>> {
const stock = await this.#remiStockService.fetchAssignedStock(abortSignal);
if (!stock) {
this.#logger.error('No assigned stock found for remission items');
throw new Error('No assigned stock found');
}
let req = this.#remiService.RemiCanAddReturnItem({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
})),
});
if (abortSignal) {
req = req.pipe(takeUntilAborted(abortSignal));
}
const res = await firstValueFrom(req);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to check item addition', error);
throw error;
}
return res as BatchResponseArgs<ReturnItem>;
}
async addToList(
items: { item: Item; quantity: number; reason: string }[],
abortSignal?: AbortSignal,
): Promise<ReturnItem[]> {
const stock = await this.#remiStockService.fetchAssignedStock(abortSignal);
if (!stock) {
this.#logger.error('No assigned stock found for remission items');
throw new Error('No assigned stock found');
}
const req$ = this.#remiService.RemiCreateReturnItem({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
})),
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to add item to remission list', error);
throw error;
}
return res.successful?.map((r) => r.value) as ReturnItem[];
}
async addToDepartmentList(
items: { item: Item; quantity: number; reason: string }[],
abortSignal?: AbortSignal,
): Promise<ReturnSuggestion[]> {
const stock = await this.#remiStockService.fetchAssignedStock(abortSignal);
if (!stock) {
this.#logger.error('No assigned stock found for remission items');
throw new Error('No assigned stock found');
}
const req$ = this.#remiService.RemiCreateReturnSuggestions({
data: items.map((i) => ({
product: i.item.product,
assortment: 'Basissortiment|B',
predefinedReturnQuantity: i.quantity,
retailPrice: i.item.catalogAvailability.price,
source: 'manually-added',
returnReason: i.reason,
stock: { id: stock.id },
})),
});
const res = await firstValueFrom(req$);
if (res.error) {
const error = new ResponseArgsError(res);
this.#logger.error('Failed to add to department list', error);
throw error;
}
return res.successful?.map((r) => r.value) as ReturnSuggestion[];
}
}

View File

@@ -5,7 +5,7 @@ import { Stock, StockInfo } from '../models';
import { FetchStockInStock, FetchStockInStockSchema } from '../schemas';
import { ResponseArgsError, takeUntilAborted } from '@isa/common/data-access';
import { logger } from '@isa/core/logging';
import { InFlightWithCache } from '@isa/common/decorators';
import { InFlight, Cache, CacheTimeToLive } from '@isa/common/decorators';
/**
* Service responsible for managing remission stock operations.
@@ -52,7 +52,8 @@ export class RemissionStockService {
*
* @todo Remove caching from data-access services
*/
@InFlightWithCache()
@Cache({ ttl: CacheTimeToLive.fiveMinutes })
@InFlight()
async fetchAssignedStock(abortSignal?: AbortSignal): Promise<Stock> {
this.#logger.info('Fetching assigned stock from API');
let req$ = this.#stockService.StockCurrentStock();

View File

@@ -0,0 +1 @@
export * from './remission.store';

View File

@@ -0,0 +1,30 @@
import { RemissionStore } from './remission.store';
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { RemissionReturnReceiptService } from '../services';
describe('RemissionStore', () => {
let store: InstanceType<typeof RemissionStore>;
beforeEach(() => {
const mockRemissionReturnReceiptService = {
fetchRemissionReturnReceipt: jest.fn(),
};
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [
RemissionStore,
{
provide: RemissionReturnReceiptService,
useValue: mockRemissionReturnReceiptService,
},
],
});
store = TestBed.inject(RemissionStore);
});
it('should create an instance of RemissionStore', () => {
expect(store).toBeTruthy();
});
});

View File

@@ -0,0 +1,289 @@
import {
patchState,
signalStore,
withComputed,
withMethods,
withProps,
withState,
} from '@ngrx/signals';
import { ReturnItem, ReturnSuggestion } from '../models';
import { computed, inject, resource } from '@angular/core';
import { UserStorageProvider, withStorage } from '@isa/core/storage';
import { RemissionReturnReceiptService } from '../services';
/**
* Union type representing items that can be selected for remission.
* Can be either a ReturnItem or a ReturnSuggestion.
*/
export type RemissionItem = ReturnItem | ReturnSuggestion;
/**
* Interface defining the state structure for the remission selection store.
*/
interface RemissionState {
/** The unique identifier for the return process. Can only be set once. */
returnId: number | undefined;
/** The unique identifier for the receipt. Can only be set once. */
receiptId: number | undefined;
/** Map of selected remission items indexed by their ID */
selectedItems: Record<number, RemissionItem>;
/** Map of selected quantities for each remission item indexed by their ID */
selectedQuantity: Record<number, number>;
}
/**
* Initial state for the remission selection store.
* All values are undefined or empty objects.
*/
const initialState: RemissionState = {
returnId: undefined,
receiptId: undefined,
selectedItems: {},
selectedQuantity: {},
};
/**
* NgRx Signal Store for managing remission selection state.
* Provides methods to start remission processes, select items, update quantities,
* and manage the overall selection state.
*
* @example
* ```typescript
* // Inject the store in a component
* readonly remissionStore = inject(RemissionStore);
*
* // Start a remission process
* this.remissionStore.startRemission(123, 456);
*
* // Select an item
* this.remissionStore.selectRemissionItem(1, returnItem);
*
* // Update quantity
* this.remissionStore.updateRemissionQuantity(1, returnItem, 5);
* ```
*/
export const RemissionStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withStorage('remission-data-access.remission-store', UserStorageProvider),
withProps(
(
store,
remissionReturnReceiptService = inject(RemissionReturnReceiptService),
) => ({
/**
* Private resource for fetching the current remission receipt.
*
* This resource automatically tracks changes to returnId and receiptId from the store
* and refetches the receipt data when either value changes. The resource returns
* undefined when either ID is not set, preventing unnecessary HTTP requests.
*
* The resource uses the injected RemissionReturnReceiptService to fetch receipt data
* and supports request cancellation via AbortSignal for proper cleanup.
*
* @private
* @returns A resource instance that manages the receipt data fetching lifecycle
*
* @example
* ```typescript
* // Access the resource through computed signals
* const receipt = computed(() => store._receiptResource.value());
* const status = computed(() => store._receiptResource.status());
* const error = computed(() => store._receiptResource.error());
*
* // Manually reload the resource
* store._receiptResource.reload();
* ```
*
* @see {@link https://angular.dev/guide/signals/resource} Angular Resource API documentation
*/
_receiptResource: resource({
params: () => ({
returnId: store.returnId(),
receiptId: store.receiptId(),
}),
loader: async ({ params, abortSignal }) => {
const { receiptId, returnId } = params;
if (!receiptId || !returnId) {
return undefined;
}
const receipt =
await remissionReturnReceiptService.fetchRemissionReturnReceipt(
{
returnId,
receiptId,
},
abortSignal,
);
return receipt;
},
}),
}),
),
withComputed((store) => ({
remissionStarted: computed(
() => store.returnId() !== undefined && store.receiptId() !== undefined,
),
receipt: computed(() => store._receiptResource.value()),
})),
withMethods((store) => ({
/**
* Initializes a remission process with the given return and receipt IDs.
* Can only be called once - subsequent calls will throw an error.
*
* @param returnId - The unique identifier for the return process
* @param receiptId - The unique identifier for the receipt
* @throws {Error} When remission has already been started (returnId or receiptId already set)
*
* @example
* ```typescript
* remissionStore.startRemission(123, 456);
* ```
*/
startRemission({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}) {
if (store.returnId() !== undefined || store.receiptId() !== undefined) {
throw new Error(
'Remission has already been started. returnId and receiptId can only be set once.',
);
}
patchState(store, {
returnId,
receiptId,
});
store._receiptResource.reload();
store.storeState();
},
/**
* Reloads the receipt resource.
* This method should be called when the receipt data needs to be refreshed.
*/
reloadReceipt() {
store._receiptResource.reload();
},
/**
* Selects a remission item and adds it to the selected items collection.
* If the item is already selected, it will be replaced with the new item.
*
* @param remissionItemId - The unique identifier for the remission item
* @param item - The remission item to select (ReturnItem or ReturnSuggestion)
*
* @example
* ```typescript
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
* remissionStore.selectRemissionItem(1, returnItem);
* ```
*/
selectRemissionItem(remissionItemId: number, item: RemissionItem) {
patchState(store, {
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
});
},
/**
* Updates the quantity for a selected remission item.
* Also ensures the item is in the selected items collection.
*
* @param remissionItemId - The unique identifier for the remission item
* @param item - The remission item to update (ReturnItem or ReturnSuggestion)
* @param quantity - The new quantity value
*
* @example
* ```typescript
* const returnItem: ReturnItem = { id: 1, name: 'Product A' };
* remissionStore.updateRemissionQuantity(1, returnItem, 5);
* ```
*/
updateRemissionQuantity(
remissionItemId: number,
item: RemissionItem,
quantity: number,
) {
patchState(store, {
selectedItems: { ...store.selectedItems(), [remissionItemId]: item },
selectedQuantity: {
...store.selectedQuantity(),
[remissionItemId]: quantity,
},
});
},
/**
* Removes a remission item from the selected items collection.
* Does not affect the selected quantities.
*
* @param remissionItemId - The unique identifier for the remission item to remove
*
* @example
* ```typescript
* remissionStore.removeItem(1);
* ```
*/
removeItem(remissionItemId: number) {
const items = { ...store.selectedItems() };
delete items[remissionItemId];
patchState(store, {
selectedItems: items,
});
},
/**
* Removes a remission item and its associated quantity from the store.
* Updates both selected items and selected quantities collections.
*
* @param remissionItemId - The unique identifier for the remission item to remove
*
* @example
* ```typescript
* remissionStore.removeItemAndQuantity(1);
* ```
*/
removeItemAndQuantity(remissionItemId: number) {
const items = { ...store.selectedItems() };
const quantities = { ...store.selectedQuantity() };
delete items[remissionItemId];
delete quantities[remissionItemId];
patchState(store, {
selectedItems: items,
selectedQuantity: quantities,
});
},
/**
* Clears all selected remission items.
* Resets the remission state to its initial values.
*
* @example
* ```typescript
* remissionStore.clearSelectedItems();
* ```
*/
clearSelectedItems() {
patchState(store, {
selectedItems: {},
});
},
/**
* Resets the remission store to its initial state.
* Clears all selected items, quantities, and resets return/receipt IDs.
*
* @example
* ```typescript
* remissionStore.resetRemission();
* ```
*/
finishRemission() {
patchState(store, initialState);
store.storeState();
},
})),
);

View File

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

View File

@@ -1,22 +1,22 @@
export default {
displayName: 'remission-feature-remission-list',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory:
'../../../../coverage/libs/remission/feature/remission-list',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};
export default {
displayName: 'remi-remission-list',
preset: '../../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory:
'../../../../coverage/libs/remission/feature/remission-list',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -1,5 +1,5 @@
{
"name": "remission-feature-remission-list",
"name": "remi-remission-list",
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/remission/feature/remission-list/src",
"prefix": "remi",

View File

@@ -0,0 +1,32 @@
<filter-input-menu-button
[filterInput]="filterDepartmentInput()"
[label]="selectedDepartments()"
[commitOnClose]="true"
>
</filter-input-menu-button>
@if (displayCapacityValues()) {
<ui-toolbar class="ui-toolbar-rounded">
<span class="isa-text-body-2-regular"
><span class="isa-text-body-2-bold"
>{{ leistung() }}/{{ maxLeistung() }}</span
>
Leistung</span
>
<span class="isa-text-body-2-regular"
><span class="isa-text-body-2-bold"
>{{ stapel() }}/{{ maxStapel() }}</span
>
Stapel</span
>
<button
class="w-6 h-6 flex items-center justify-center text-isa-accent-blue"
uiTooltip
[title]="'Stapel/Leistungsplätze'"
[content]="''"
[triggerOn]="['click', 'hover']"
>
<ng-icon size="1.5rem" name="isaOtherInfo"></ng-icon>
</button>
</ui-toolbar>
}

View File

@@ -0,0 +1,7 @@
:host {
@apply flex flex-row gap-4 items-center max-h-12;
}
.ui-toolbar-rounded {
@apply rounded-2xl;
}

View File

@@ -0,0 +1,138 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { isaOtherInfo } from '@isa/icons';
import {
calculateCapacity,
calculateMaxCapacity,
} from '@isa/remission/data-access';
import {
FilterInputMenuButtonComponent,
FilterService,
InputType,
} from '@isa/shared/filter';
import { ToolbarComponent } from '@isa/ui/toolbar';
import { TooltipDirective } from '@isa/ui/tooltip';
import { NgIconComponent, provideIcons } from '@ng-icons/core';
import { createRemissionCapacityResource } from '../resources';
@Component({
selector: 'remi-feature-remission-list-department-elements',
templateUrl: './remission-list-department-elements.component.html',
styleUrl: './remission-list-department-elements.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [provideIcons({ isaOtherInfo })],
imports: [
FilterInputMenuButtonComponent,
ToolbarComponent,
TooltipDirective,
NgIconComponent,
],
})
export class RemissionListDepartmentElementsComponent {
/**
* FilterService instance for managing filter state and queries.
* @private
*/
#filterService = inject(FilterService);
/**
* Filter input for departments, used to filter remission items by department.
*/
filterDepartmentInput = computed(() => {
const inputs = this.#filterService
.inputs()
.filter((input) => input.group === 'filter');
return inputs?.find((input) => input.key === 'abteilungen');
});
/**
* Computed signal for the selected departments from the filter input.
* If the input type is Checkbox and has selected values, it returns a comma-separated string.
* Otherwise, it returns undefined.
*/
selectedDepartments = computed(() => {
const input = this.filterDepartmentInput();
if (input?.type === InputType.Checkbox && input?.selected?.length > 0) {
return input?.selected?.filter((selected) => !!selected).join(', ');
}
return;
});
/**
* Resource signal for fetching remission capacity based on selected departments.
* Updates when the selected departments change.
* @returns Remission capacity resource state.
*/
capacityResource = createRemissionCapacityResource(() => {
return {
departments: this.selectedDepartments()
?.split(',')
.map((d) => d.trim()),
};
});
capacityResourceValue = computed(() => this.capacityResource.value());
displayCapacityValues = computed(() => {
const value = this.capacityResourceValue();
return !!value && value?.length > 0;
});
leistungValues = computed(() => {
const value = this.capacityResourceValue();
return value?.find((cap) => cap.item1 === 'Leistung');
});
stapelValues = computed(() => {
const value = this.capacityResourceValue();
return value?.find((cap) => cap.item1 === 'Stapel');
});
leistung = computed(() => {
const values = this.leistungValues();
return values
? calculateCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
})
: 0;
});
maxLeistung = computed(() => {
const values = this.leistungValues();
return values
? calculateMaxCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
capacityValue4: values.item4,
comparer: this.leistung(),
})
: 0;
});
stapel = computed(() => {
const values = this.stapelValues();
return values
? calculateCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
})
: 0;
});
maxStapel = computed(() => {
const values = this.stapelValues();
return values
? calculateMaxCapacity({
capacityValue2: values.item2,
capacityValue3: values.item3,
capacityValue4: values.item4,
comparer: this.stapel(),
})
: 0;
});
}

View File

@@ -0,0 +1,10 @@
<ui-checkbox appearance="bullet">
<input
type="checkbox"
[ngModel]="itemSelected()"
(ngModelChange)="setSelected($event)"
(click)="$event.stopPropagation()"
data-what="remission-item-selection-checkbox"
[attr.data-which]="item()?.product?.ean"
/>
</ui-checkbox>

View File

@@ -0,0 +1,57 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RemissionItem, RemissionStore } from '@isa/remission/data-access';
import { TextButtonComponent } from '@isa/ui/buttons';
import { CheckboxComponent } from '@isa/ui/input-controls';
@Component({
selector: 'remi-feature-remission-list-item-select',
templateUrl: './remission-list-item-select.component.html',
styleUrl: './remission-list-item-select.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [FormsModule, TextButtonComponent, CheckboxComponent],
})
export class RemissionListItemSelectComponent {
/**
* Store for managing selected remission quantities.
* @private
*/
#store = inject(RemissionStore);
/**
* The item to display in the list.
* Can be either a ReturnItem or a ReturnSuggestion.
*/
item = input.required<RemissionItem>();
/**
* Computes whether the current item is selected in the remission store.
* Checks if the item's ID exists in the selected items collection.
*/
itemSelected = computed(() => {
const itemId = this.item()?.id;
return !!itemId && !!this.#store.selectedItems()?.[itemId];
});
/**
* Selects the current item in the remission store.
* Updates the selected items and quantities based on the item's ID.
*
* @param selected - Whether the item should be selected or not
*/
setSelected(selected: boolean) {
const itemId = this.item()?.id;
if (itemId && selected) {
this.#store.selectRemissionItem(itemId, this.item());
}
if (itemId && !selected) {
this.#store.removeItem(itemId);
}
}
}

View File

@@ -1,20 +1,61 @@
@let i = item();
@let s = stock();
<ui-client-row data-what="remission-list-item" [attr.data-which]="i.id">
<ui-client-row-content>
<ui-client-row-content class="flex flex-row gap-6 justify-between">
<remi-product-info
[item]="i"
[orientation]="'vertical'"
[orientation]="remiProductInfoOrientation()"
></remi-product-info>
@if (displayActions() && !desktopBreakpoint()) {
<remi-feature-remission-list-item-select
class="self-start mt-4"
[item]="i"
></remi-feature-remission-list-item-select>
}
</ui-client-row-content>
<ui-item-row-data> Shelfinfos... (TODO) </ui-item-row-data>
<ui-item-row-data>
<remi-product-shelf-meta-info
[department]="i?.department"
[shelfLabel]="i?.shelfLabel"
[productGroupKey]="i?.product?.productGroup"
[productGroupValue]="productGroupValue()"
[assortment]="i?.assortment"
[returnReason]="i?.returnReason"
></remi-product-shelf-meta-info>
</ui-item-row-data>
<ui-item-row-data>
<remi-product-stock-info
[predefinedReturnQuantity]="predefinedReturnQuantity()"
[remainingQuantityInStock]="i?.remainingQuantityInStock ?? 0"
[stock]="s?.inStock ?? 0"
[removedFromStock]="s?.removedFromStock ?? 0"
[zob]="s?.minStockCategoryManagement ?? 0"
[availableStock]="availableStock()"
[stockToRemit]="selectedStockToRemit() ?? stockToRemit()"
[targetStock]="targetStock()"
[zob]="stock()?.minStockCategoryManagement ?? 0"
></remi-product-stock-info>
</ui-item-row-data>
@if (displayActions()) {
<ui-item-row-data class="justify-end desktop:justify-between col-end-last">
@if (desktopBreakpoint()) {
<remi-feature-remission-list-item-select
class="self-end mt-4"
[item]="i"
>
</remi-feature-remission-list-item-select>
}
<button
class="self-end"
type="button"
uiTextButton
color="strong"
(click)="
openRemissionQuantityDialog();
$event.stopPropagation();
$event.preventDefault()
"
data-what="button"
data-which="change-remission-quantity"
>
Remi Menge ändern
</button>
</ui-item-row-data>
}
</ui-client-row>

View File

@@ -0,0 +1,15 @@
:host {
@apply w-full;
}
.ui-client-row {
@apply isa-desktop:grid-cols-2 desktop-large:grid-cols-4;
}
.ui-client-row-content {
@apply isa-desktop:col-span-2 desktop-large:col-span-1;
}
.col-end-last {
grid-column-end: -1;
}

View File

@@ -1,35 +1,90 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/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 {
ReturnItem,
ReturnSuggestion,
StockInfo,
RemissionListType,
RemissionStore,
} from '@isa/remission/data-access';
import {
ProductInfoComponent,
ProductStockInfoComponent,
} from '@isa/remission/shared/product';
import { MockComponent } from 'ng-mocks';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
import { signal } from '@angular/core';
import { of } from 'rxjs';
// --- Setup dynamic mocking for injectRemissionListType ---
let remissionListTypeValue: RemissionListType = RemissionListType.Pflicht;
jest.mock('../injects/inject-remission-list-type', () => ({
injectRemissionListType: () => () => remissionListTypeValue,
}));
// Mock the calculation functions to have predictable behavior
jest.mock('@isa/remission/data-access', () => ({
...jest.requireActual('@isa/remission/data-access'),
getStockToRemit: jest.fn(),
calculateAvailableStock: jest.fn(),
calculateTargetStock: jest.fn(),
}));
// Mock the RemissionStore
const mockRemissionStore = {
remissionStarted: signal(true),
selectedQuantity: signal({}),
updateRemissionQuantity: jest.fn(),
};
// Mock the dialog services
const mockNumberInputDialog = jest.fn();
const mockFeedbackDialog = jest.fn();
jest.mock('@isa/ui/dialog', () => ({
injectNumberInputDialog: () => mockNumberInputDialog,
injectFeedbackDialog: () => mockFeedbackDialog,
}));
describe('RemissionListItemComponent', () => {
let component: RemissionListItemComponent;
let fixture: ComponentFixture<RemissionListItemComponent>;
const setRemissionListType = (type: RemissionListType) => {
remissionListTypeValue = type;
};
const createMockReturnItem = (overrides: Partial<ReturnItem> = {}): ReturnItem => ({
id: 1,
predefinedReturnQuantity: 5,
...overrides,
} as ReturnItem);
const createMockReturnItem = (
overrides: Partial<ReturnItem> = {},
): ReturnItem =>
({
id: 1,
remainingQuantityInStock: 10,
...overrides,
}) as ReturnItem;
const createMockReturnSuggestion = (overrides: Partial<ReturnSuggestion> = {}): ReturnSuggestion => ({
id: 1,
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: 10,
const createMockReturnSuggestion = (
overrides: Partial<ReturnSuggestion> = {},
): ReturnSuggestion =>
({
id: 1,
remainingQuantityInStock: 10,
returnItem: {
data: {
id: 1,
},
},
},
...overrides,
} as ReturnSuggestion);
...overrides,
}) as ReturnSuggestion;
const createMockStockInfo = (overrides: Partial<StockInfo> = {}): StockInfo => ({
id: 1,
quantity: 100,
...overrides,
} as StockInfo);
const createMockStockInfo = (overrides: Partial<StockInfo> = {}): StockInfo =>
({
id: 1,
inStock: 100,
removedFromStock: 0,
...overrides,
}) as StockInfo;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -37,6 +92,12 @@ describe('RemissionListItemComponent', () => {
RemissionListItemComponent,
MockComponent(ProductInfoComponent),
MockComponent(ProductStockInfoComponent),
MockComponent(RemissionListItemSelectComponent),
],
providers: [
provideHttpClient(),
provideHttpClientTesting(),
{ provide: RemissionStore, useValue: mockRemissionStore },
],
}).compileComponents();
@@ -44,6 +105,23 @@ describe('RemissionListItemComponent', () => {
component = fixture.componentInstance;
});
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
mockRemissionStore.selectedQuantity.set({});
mockRemissionStore.remissionStarted.set(true);
// Reset the mocked functions to return default values
const {
getStockToRemit,
calculateAvailableStock,
calculateTargetStock,
} = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(0);
calculateAvailableStock.mockReturnValue(100);
calculateTargetStock.mockReturnValue(100);
});
describe('Component Setup', () => {
it('should create', () => {
fixture.componentRef.setInput('item', createMockReturnItem());
@@ -66,242 +144,245 @@ describe('RemissionListItemComponent', () => {
});
});
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());
describe('computed properties', () => {
describe('availableStock', () => {
it('should calculate available stock correctly', () => {
const {
calculateAvailableStock,
} = require('@isa/remission/data-access');
calculateAvailableStock.mockReturnValue(90);
const mockStock = createMockStockInfo({
inStock: 100,
removedFromStock: 10,
});
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', mockStock);
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);
expect(component.availableStock()).toBe(90);
expect(calculateAvailableStock).toHaveBeenCalledWith({
stock: 100,
removedFromStock: 10,
});
});
});
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();
describe('stockToRemit', () => {
it('should calculate stock to remit correctly', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(25);
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();
const mockStock = createMockStockInfo();
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', mockStock);
fixture.detectChanges();
expect(component.stockToRemit()).toBe(25);
expect(getStockToRemit).toHaveBeenCalledWith({
remissionItem: mockItem,
remissionListType: remissionListTypeValue,
availableStock: 100, // default mock value
});
});
});
describe('targetStock', () => {
it('should calculate target stock correctly', () => {
const { calculateTargetStock } = require('@isa/remission/data-access');
calculateTargetStock.mockReturnValue(75);
const mockItem = createMockReturnItem({ remainingQuantityInStock: 15 });
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);
expect(component.targetStock()).toBe(75);
expect(calculateTargetStock).toHaveBeenCalledWith({
availableStock: 100, // default mock value
stockToRemit: 0, // default mock value
remainingQuantityInStock: 15,
});
});
});
describe('remainingQuantityInStock', () => {
it('should return remainingQuantityInStock from item', () => {
const mockItem = createMockReturnItem({ remainingQuantityInStock: 42 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remainingQuantityInStock()).toBe(42);
});
it('should handle undefined remainingQuantityInStock', () => {
const mockItem = createMockReturnItem({
remainingQuantityInStock: undefined,
});
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.remainingQuantityInStock()).toBeUndefined();
});
});
describe('selectedStockToRemit', () => {
it('should return selected quantity from store', () => {
mockRemissionStore.selectedQuantity.set({ 1: 15 });
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedStockToRemit()).toBe(15);
});
it('should return undefined when no selected quantity exists', () => {
mockRemissionStore.selectedQuantity.set({});
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.selectedStockToRemit()).toBeUndefined();
});
});
describe('displayActions', () => {
it('should return true when stockToRemit > 0 and remission started', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
mockRemissionStore.remissionStarted.set(true);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayActions()).toBe(true);
});
it('should return false when stockToRemit is 0', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(0);
mockRemissionStore.remissionStarted.set(true);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayActions()).toBe(false);
});
it('should return false when remission has not started', () => {
const { getStockToRemit } = require('@isa/remission/data-access');
getStockToRemit.mockReturnValue(5);
mockRemissionStore.remissionStarted.set(false);
fixture.componentRef.setInput('item', createMockReturnItem());
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.displayActions()).toBe(false);
});
});
});
describe('Component reactivity', () => {
it('should update predefinedReturnQuantity when input changes from ReturnItem to ReturnSuggestion', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 5 });
describe('Component behavior with different remission list types', () => {
it('should call getStockToRemit with correct parameters for Pflicht type', () => {
setRemissionListType(RemissionListType.Pflicht);
const { getStockToRemit } = require('@isa/remission/data-access');
const mockItem = createMockReturnItem();
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(5);
// Access the computed property to trigger the calculation
component.stockToRemit();
const mockSuggestion = createMockReturnSuggestion({
returnItem: {
data: {
id: 1,
predefinedReturnQuantity: 20,
},
},
expect(getStockToRemit).toHaveBeenCalledWith({
remissionItem: mockItem,
remissionListType: RemissionListType.Pflicht,
availableStock: 100,
});
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,
},
},
});
it('should call getStockToRemit with correct parameters for Abteilung type', () => {
setRemissionListType(RemissionListType.Abteilung);
const { getStockToRemit } = require('@isa/remission/data-access');
const mockSuggestion = createMockReturnSuggestion();
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(30);
// Access the computed property to trigger the calculation
component.stockToRemit();
const mockItem = createMockReturnItem({ predefinedReturnQuantity: 8 });
fixture.componentRef.setInput('item', mockItem);
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(8);
expect(getStockToRemit).toHaveBeenCalledWith({
remissionItem: mockSuggestion,
remissionListType: RemissionListType.Abteilung,
availableStock: 100,
});
});
});
describe('Edge cases', () => {
it('should handle negative predefinedReturnQuantity values', () => {
const mockItem = createMockReturnItem({ predefinedReturnQuantity: -5 });
describe('Dialog interactions', () => {
it('should open remission quantity dialog and update store on valid input', async () => {
const mockDialogRef = {
closed: of({ inputValue: 10 }), // Return Observable instead of object with toPromise
};
mockNumberInputDialog.mockReturnValue(mockDialogRef);
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(-5);
});
await component.openRemissionQuantityDialog();
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,
expect(mockNumberInputDialog).toHaveBeenCalledWith({
title: 'Remi-Menge ändern',
data: {
message: 'Wie viele Exemplare können remittiert werden?',
inputLabel: 'Remi-Menge',
inputValidation: expect.arrayContaining([
expect.objectContaining({ errorKey: 'required' }),
expect.objectContaining({ errorKey: 'pattern' }),
]),
},
} as ReturnSuggestion;
fixture.componentRef.setInput('item', mockSuggestion);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
});
expect(component.predefinedReturnQuantity()).toBe(0);
expect(mockRemissionStore.updateRemissionQuantity).toHaveBeenCalledWith(
1,
mockItem,
10,
);
expect(mockFeedbackDialog).toHaveBeenCalledWith({
data: { message: 'Remi-Menge wurde geändert' },
});
});
it('should handle item with unexpected structure', () => {
const unexpectedItem = {
id: 1,
// Missing both returnItem and predefinedReturnQuantity
} as any;
fixture.componentRef.setInput('item', unexpectedItem);
it('should not update store when dialog is cancelled', async () => {
const mockDialogRef = {
closed: of(null), // Return Observable with null result
};
mockNumberInputDialog.mockReturnValue(mockDialogRef);
const mockItem = createMockReturnItem({ id: 1 });
fixture.componentRef.setInput('item', mockItem);
fixture.componentRef.setInput('stock', createMockStockInfo());
fixture.detectChanges();
expect(component.predefinedReturnQuantity()).toBe(0);
await component.openRemissionQuantityDialog();
expect(mockRemissionStore.updateRemissionQuantity).not.toHaveBeenCalled();
expect(mockFeedbackDialog).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -2,48 +2,211 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { FormsModule, Validators } from '@angular/forms';
import {
calculateAvailableStock,
calculateTargetStock,
getStockToRemit,
RemissionStore,
ReturnItem,
ReturnSuggestion,
StockInfo,
} from '@isa/remission/data-access';
import {
ProductInfoComponent,
ProductShelfMetaInfoComponent,
ProductStockInfoComponent,
} from '@isa/remission/shared/product';
import { TextButtonComponent } from '@isa/ui/buttons';
import { injectFeedbackDialog, injectNumberInputDialog } from '@isa/ui/dialog';
import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows';
import { firstValueFrom } from 'rxjs';
import { Breakpoint, breakpoint } from '@isa/ui/layout';
import { injectRemissionListType } from '../injects/inject-remission-list-type';
import { RemissionListItemSelectComponent } from './remission-list-item-select.component';
/**
* Component representing a single item in the remission list.
*
* Displays product information, stock details, and allows the user to change
* the remission quantity via a dialog. Handles both `ReturnItem` and
* `ReturnSuggestion` types, adapting logic based on the current remission list type.
*
* @remarks
* - Uses OnPush change detection for performance.
* - Relies on signals for local state and computed values.
* - Follows workspace guidelines for type safety, clean code, and documentation.
*
* @see https://context7.com/angular/angular/20.0.0/llms.txt?topic=component
*/
@Component({
selector: 'remi-feature-remission-list-item',
templateUrl: './remission-list-item.component.html',
styleUrl: './remission-list-item.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
FormsModule,
ProductInfoComponent,
ProductStockInfoComponent,
ProductShelfMetaInfoComponent,
TextButtonComponent,
ClientRowImports,
ItemRowDataImports,
RemissionListItemSelectComponent,
],
})
export class RemissionListItemComponent {
/**
* Dialog service for prompting the user to enter a remission quantity.
* @private
*/
#dialog = injectNumberInputDialog();
/**
* Dialog service for providing feedback to the user.
* @private
*/
#feedbackDialog = injectFeedbackDialog();
/**
* Store for managing selected remission quantities.
* @private
*/
#store = inject(RemissionStore);
/**
* Signal providing the current breakpoint state.
* Used to determine layout orientation and visibility of action buttons.
*/
desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]);
/**
* Signal providing the current remission list type (Abteilung or Pflicht).
*/
remissionListType = injectRemissionListType();
/**
* The item to display in the list.
* Can be either a ReturnItem or a ReturnSuggestion.
*/
item = input.required<ReturnItem | ReturnSuggestion>();
/**
* Stock information for the item.
*/
stock = input.required<StockInfo>();
predefinedReturnQuantity = computed(() => {
const item = this.item();
/**
* Optional product group value for display or filtering.
*/
productGroupValue = input<string>('');
// ReturnSuggestion
if ('returnItem' in item && item?.returnItem?.data) {
return item.returnItem.data.predefinedReturnQuantity ?? 0;
}
// ReturnItem
if ('predefinedReturnQuantity' in item) {
return item.predefinedReturnQuantity ?? 0;
}
return 0;
/**
* Computes the orientation of the product info based on the mobile breakpoint.
* If on mobile, uses vertical layout; otherwise, horizontal.
*/
remiProductInfoOrientation = computed(() => {
return this.desktopBreakpoint() ? 'horizontal' : 'vertical';
});
/**
* Computes the remaining quantity in stock for the current item.
*/
remainingQuantityInStock = computed(
() => this.item()?.remainingQuantityInStock,
);
/**
* Computes whether to display action buttons based on stock to remit and remission status.
* @returns true if stock to remit is greater than 0 and remission has started
*/
displayActions = computed<boolean>(() => {
return this.stockToRemit() > 0 && this.#store.remissionStarted();
});
/**
* Computes the available stock for the item using stock and removedFromStock.
* @returns The calculated available stock.
*/
availableStock = computed(() =>
calculateAvailableStock({
stock: this.stock()?.inStock,
removedFromStock: this.stock()?.removedFromStock,
}),
);
/**
* Computes the selected stock quantity to remit for the current item.
* Uses the store's selected quantity for the item's ID.
*/
selectedStockToRemit = computed(
() => this.#store.selectedQuantity()?.[this.item().id!],
);
/**
* Computes the stock to remit based on the remission item and available stock.
* Uses the getStockToRemit helper function.
*/
stockToRemit = computed(() =>
getStockToRemit({
remissionItem: this.item(),
remissionListType: this.remissionListType(),
availableStock: this.availableStock(),
}),
);
/**
* Computes the target stock after remission.
* @returns The calculated target stock.
*/
targetStock = computed(() =>
calculateTargetStock({
availableStock: this.availableStock(),
stockToRemit: this.stockToRemit(),
remainingQuantityInStock: this.remainingQuantityInStock(),
}),
);
/**
* Opens a dialog to change the remission quantity for the current item.
* Prompts the user for a new quantity and updates the store if valid.
* Displays feedback dialog upon successful update.
*
* @returns A promise that resolves when the dialog is closed.
*/
async openRemissionQuantityDialog(): Promise<void> {
const dialogRef = this.#dialog({
title: 'Remi-Menge ändern',
data: {
message: 'Wie viele Exemplare können remittiert werden?',
inputLabel: 'Remi-Menge',
inputValidation: [
{
errorKey: 'required',
inputValidator: Validators.required,
errorText: 'Bitte geben Sie eine Menge an.',
},
{
errorKey: 'pattern',
inputValidator: Validators.pattern(/^[1-9][0-9]*$/),
errorText: 'Die Menge muss mindestens 1 sein.',
},
],
},
});
const result = await firstValueFrom(dialogRef.closed);
const itemId = this.item()?.id;
const quantity = result?.inputValue;
if (itemId && quantity !== undefined && quantity > 0) {
this.#store.updateRemissionQuantity(itemId, this.item(), quantity);
this.#feedbackDialog({
data: { message: 'Remi-Menge wurde geändert' },
});
}
}
}

View File

@@ -1,6 +1,7 @@
<ui-dropdown
class="remi-feature-remission-list-select__dropdown"
[value]="selectedRemissionListType()"
[label]="selectedRemissionListTypeLabel()"
[appearance]="DropdownAppearance.Grey"
(valueChange)="changeRemissionType($event)"
data-which="remission-list-select-dropdown"

View File

@@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import {
RemissionListType,
@@ -30,8 +35,6 @@ export class RemissionListSelectComponent {
selectedRemissionListType = injectRemissionListType();
async changeRemissionType(remissionTypeValue: RemissionListType | undefined) {
console.log(remissionTypeValue, remissionListTypeRouteMapping);
if (
!remissionTypeValue ||
remissionTypeValue === RemissionListType.Koerperlos
@@ -45,4 +48,18 @@ export class RemissionListSelectComponent {
},
);
}
selectedRemissionListTypeLabel = computed(() => {
const type = this.selectedRemissionListType();
if (type === RemissionListType.Pflicht) {
return 'Pflicht';
}
if (type === RemissionListType.Abteilung) {
return 'Abteilungen';
}
return;
});
}

View File

@@ -1,6 +1,6 @@
import { RemissionListType } from '@isa/remission/data-access';
export const remissionListTypeRouteMapping = {
[RemissionListType.Pflicht]: '..',
[RemissionListType.Abteilung]: 'department',
[RemissionListType.Pflicht]: '../mandatory',
[RemissionListType.Abteilung]: '../department',
} as const;

View File

@@ -1,8 +1,19 @@
<remission-feature-remission-start-card></remission-feature-remission-start-card>
<remi-remission-processed-hint></remi-remission-processed-hint>
<remi-feature-remission-list-select></remi-feature-remission-list-select>
@if (!remissionStarted()) {
<remi-feature-remission-start-card></remi-feature-remission-start-card>
} @else {
<remi-feature-remission-return-card></remi-feature-remission-return-card>
}
<filter-controls-panel (triggerSearch)="search()"></filter-controls-panel>
<div class="flex flex-row gap-4 items-center max-h-12">
<remi-feature-remission-list-select></remi-feature-remission-list-select>
@if (isDepartment()) {
<remi-feature-remission-list-department-elements></remi-feature-remission-list-department-elements>
}
</div>
<filter-controls-panel (triggerSearch)="search($event)"></filter-controls-panel>
<span
class="text-isa-neutral-900 isa-text-body-2-regular self-start"
@@ -11,16 +22,15 @@
{{ hits() }} Einträge
</span>
<div class="flex flex-col gap-4 w-full items-center justify-center">
<div class="flex flex-col gap-4 w-full items-center justify-center mb-24">
@for (item of items(); track item.id) {
@defer (on viewport) {
<a [routerLink]="['../', 'return', item.id]" class="w-full">
<remi-feature-remission-list-item
#listElement
[item]="item"
[stock]="getStockForItem(item)"
></remi-feature-remission-list-item>
</a>
<remi-feature-remission-list-item
#listElement
[item]="item"
[stock]="getStockForItem(item)"
[productGroupValue]="getProductGroupValueForItem(item)"
></remi-feature-remission-list-item>
} @placeholder {
<div class="h-[7.75rem] w-full flex items-center justify-center">
<ui-icon-button
@@ -33,3 +43,24 @@
}
}
</div>
@if (remissionStarted()) {
<ui-stateful-button
class="fixed right-6 bottom-6"
(clicked)="remitItems()"
(action)="remitItems()"
[(state)]="remitItemsState"
defaultContent="Remittieren"
defaultWidth="13rem"
[errorContent]="remitItemsError()"
errorWidth="32rem"
errorAction="Erneut versuchen"
successContent="Hinzugefügt"
successWidth="20rem"
size="large"
color="brand"
[pending]="remitItemsInProgress()"
[disabled]="!hasSelectedItems()"
>
</ui-stateful-button>
}

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