Merged PR 1932: feat(remission): ensure package assignment before completing return receipts

feat(remission): ensure package assignment before completing return receipts

Add validation to check if a package is assigned to a return receipt before
allowing completion. When no package is assigned, automatically open the
package assignment dialog to let users scan/input a package number.

- Add hasAssignedPackage input to complete component and pass from parent
- Integrate RemissionStartService.assignPackage() in completion flow
- Add assignPackageOnly flag to conditionally hide step counter in dialog
- Update dialog data structure to support direct package assignment mode
- Enhance test coverage for new assignment scenarios

This ensures all completed return receipts have proper package tracking
and improves the user workflow by guiding them through required steps.

Ref: #5289
This commit is contained in:
Nino Righi
2025-09-03 13:15:32 +00:00
committed by Andreas Schickinger
parent 708ec01704
commit fa8e601660
10 changed files with 298 additions and 27 deletions

View File

@@ -68,6 +68,7 @@
<lib-remission-return-receipt-complete
[returnId]="returnId()"
[receiptId]="receiptId()"
[hasAssignedPackage]="hasAssignedPackage()"
[itemsLength]="items?.length"
(reloadData)="returnResource.reload()"
></lib-remission-return-receipt-complete>

View File

@@ -14,6 +14,7 @@ import { RemissionReturnReceiptDetailsItemComponent } from './remission-return-r
import { Location } from '@angular/common';
import { createReturnResource } from './resources/return.resource';
import {
getPackageNumbersFromReturn,
getReceiptItemsFromReturn,
getReceiptNumberFromReturn,
} from '@isa/remission/data-access';
@@ -105,4 +106,9 @@ export class RemissionReturnReceiptDetailsComponent {
const returnData = this.returnData();
return !!returnData && !returnData.completed;
});
hasAssignedPackage = computed(() => {
const returnData = this.returnData();
return getPackageNumbersFromReturn(returnData!) !== '';
});
}

View File

@@ -1,8 +1,10 @@
<span
class="w-full flex items-center justify-center text-isa-neutral-900 isa-text-body-2-bold"
>
2/2
</span>
@if (!assignPackageOnly()) {
<span
class="w-full flex items-center justify-center text-isa-neutral-900 isa-text-body-2-bold"
>
2/2
</span>
}
<div class="flex flex-col gap-4">
<h2 class="isa-text-subtitle-1-bold flex-shrink-0" data-what="title">
Wannennummer Scannen

View File

@@ -71,6 +71,9 @@ import { RequestStatus } from './remission-start-dialog.component';
],
})
export class AssignPackageNumberComponent {
/** Input flag indicating if the dialog is opened for package assignment only */
assignPackageOnly = input<boolean>(false);
/**
* Input signal containing the current request status for the assign package operation.
* Used to display loading states and handle server-side validation errors.

View File

@@ -1,10 +1,11 @@
@if (!assignPackageStepData()) {
@if (!assignPackageStepData() && !data?.assignPackage) {
<remi-create-return-receipt
(createReturnReceipt)="onCreateReturnReceipt($event)"
[createRemissionLoading]="createRemissionRequestStatus()"
></remi-create-return-receipt>
} @else {
<remi-assign-package-number
[assignPackageOnly]="!!data?.assignPackage"
(assignPackageNumber)="onAssignPackageNumber($event)"
[assignPackageLoading]="assignPackageRequestStatus()"
></remi-assign-package-number>

View File

@@ -59,6 +59,14 @@ export type RequestStatus = {
export type RemissionStartDialogData = {
/** The return group identifier for the remission process */
returnGroup: string | undefined;
/** #5289 - Flag indicating if the dialog is opened for package assignment only */
assignPackage?:
| {
returnId: number;
receiptId: number;
}
| undefined;
};
/**
@@ -220,17 +228,20 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
packageNumber: string | undefined,
): Promise<void> {
this.assignPackageRequestStatus.set({ loading: true });
const data = this.assignPackageStepData();
const data = this.assignPackageStepData() ?? this.data?.assignPackage;
if (!data || !packageNumber) {
return this.onDialogClose(undefined);
}
const returnId = data.returnId;
const receiptId = data.receiptId;
try {
const response = await this.#remissionReturnReceiptService.assignPackage({
packageNumber,
returnId: data.returnId,
receiptId: data.receiptId,
returnId,
receiptId,
});
if (!response) {
@@ -238,8 +249,8 @@ export class RemissionStartDialogComponent extends DialogContentDirective<
}
this.onDialogClose({
returnId: data.returnId,
receiptId: data.receiptId,
returnId,
receiptId,
});
this.assignPackageRequestStatus.set({ loading: false });
} catch (error: any) {

View File

@@ -38,23 +38,120 @@ describe('RemissionStartService', () => {
service = TestBed.inject(RemissionStartService);
});
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
describe('startRemission', () => {
it('should start remission successfully when dialog returns result', async () => {
// Arrange
const returnGroup = 'test-return-group';
// Act
await service.startRemission(returnGroup);
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
it('should handle undefined returnGroup', async () => {
// Arrange
const returnGroup = undefined;
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup: undefined },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).toHaveBeenCalledWith({
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
});
});
it('should not call startRemission when dialog returns falsy result', async () => {
// Arrange
const returnGroup = 'test-return-group';
mockDialogRef.closed = of(null);
// Act
await service.startRemission(returnGroup);
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: { returnGroup },
classList: ['gap-0'],
width: '30rem',
});
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
});
});
describe('assignPackage', () => {
it('should open dialog with correct assignPackage data and return result', async () => {
// Arrange
const returnId = 12345;
const receiptId = 67890;
const expectedResult = {
returnId: 'test-return-id',
receiptId: 'test-receipt-id',
};
mockDialogRef.closed = of(expectedResult);
// Act
const result = await service.assignPackage({ returnId, receiptId });
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
expect(result).toEqual(expectedResult);
expect(mockRemissionStore.startRemission).not.toHaveBeenCalled();
});
it('should handle null result from dialog', async () => {
// Arrange
const returnId = 12345;
const receiptId = 67890;
mockDialogRef.closed = of(null);
// Act
const result = await service.assignPackage({ returnId, receiptId });
// Assert
expect(mockDialog).toHaveBeenCalledWith({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
expect(result).toBeNull();
});
});
});

View File

@@ -26,4 +26,26 @@ export class RemissionStartService {
});
}
}
// #5289 - Bei WBS ohne Wannennummer, soll man nur die Wannennummer generieren können
async assignPackage({
returnId,
receiptId,
}: {
returnId: number;
receiptId: number;
}) {
const remissionStartDialogRef = this.#remissionStartDialog({
data: {
returnGroup: undefined,
assignPackage: {
returnId,
receiptId,
},
},
classList: ['gap-0'],
width: '30rem',
});
return await firstValueFrom(remissionStartDialogRef.closed);
}
}

View File

@@ -61,6 +61,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
mockRemissionStartService = {
startRemission: vi.fn(),
assignPackage: vi.fn(),
};
mockRouter = {
@@ -108,6 +109,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
fixture.componentRef.setInput('returnId', 123);
fixture.componentRef.setInput('receiptId', 456);
fixture.componentRef.setInput('itemsLength', 5);
fixture.componentRef.setInput('hasAssignedPackage', true);
fixture.detectChanges();
});
@@ -121,6 +123,7 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
expect(component.returnId()).toBe(123);
expect(component.receiptId()).toBe(456);
expect(component.itemsLength()).toBe(5);
expect(component.hasAssignedPackage()).toBe(true);
expect(component.completingRemission()).toBe(false);
});
});
@@ -150,10 +153,9 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
});
describe('completeRemission', () => {
it('should complete remission without return group', async () => {
it('should complete remission with package already assigned and no return group', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: null };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
@@ -166,10 +168,114 @@ describe('RemissionReturnReceiptCompleteComponent', () => {
// Assert
expect(component.completingRemission()).toBe(false);
expect(mockRemissionStartService.assignPackage).not.toHaveBeenCalled();
expect(mockInjectConfirmationDialog).not.toHaveBeenCalled();
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission without package assigned and assign package successfully', async () => {
// Arrange
fixture.componentRef.setInput('hasAssignedPackage', false);
fixture.detectChanges();
const mockReturn = { id: 123, returnGroup: null };
mockRemissionStartService.assignPackage.mockResolvedValue(true);
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockRemissionStartService.assignPackage).toHaveBeenCalledWith({
returnId: 123,
receiptId: 456,
});
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission with return group and user confirms completion', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: 'RG001' };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
mockRemissionReturnReceiptService.completeReturnGroup.mockResolvedValue(
undefined,
);
// Mock dialog result with confirmed=true
mockDialogRef.closed = of({ confirmed: true });
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockInjectConfirmationDialog).toHaveBeenCalledWith({
title: 'Wanne abgeschlossen',
width: '30rem',
data: {
message: expect.stringContaining('Legen Sie abschließend den'),
closeText: 'Neue Wanne',
confirmText: 'Beenden',
},
});
expect(
mockRemissionReturnReceiptService.completeReturnGroup,
).toHaveBeenCalledWith({
returnGroup: 'RG001',
});
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should complete remission with return group and user chooses new container', async () => {
// Arrange
const mockReturn = { id: 123, returnGroup: 'RG001' };
mockRemissionReturnReceiptService.completeReturnReceiptAndReturn.mockResolvedValue(
mockReturn,
);
mockRemissionStartService.startRemission.mockResolvedValue(undefined);
// Mock dialog result with confirmed=false (user chose "Neue Wanne")
mockDialogRef.closed = of({ confirmed: false });
const reloadDataSpy = vi.fn();
component.reloadData.subscribe(reloadDataSpy);
// Act
await component.completeRemission();
// Assert
expect(mockInjectConfirmationDialog).toHaveBeenCalledWith({
title: 'Wanne abgeschlossen',
width: '30rem',
data: {
message: expect.stringContaining('Legen Sie abschließend den'),
closeText: 'Neue Wanne',
confirmText: 'Beenden',
},
});
expect(mockRemissionStartService.startRemission).toHaveBeenCalledWith(
'RG001',
);
expect(mockRouter.navigate).toHaveBeenCalledWith([
'/',
'test-tab-id',
'remission',
]);
expect(component.completingRemission()).toBe(false);
expect(reloadDataSpy).toHaveBeenCalled();
});
it('should prevent multiple completion attempts', async () => {
// Arrange
component.completingRemission.set(true);

View File

@@ -89,6 +89,13 @@ export class RemissionReturnReceiptCompleteComponent {
*/
itemsLength = input.required<number>();
/**
* Required input indicating if there is at least one package assigned to the return.
* @input
* @required
*/
hasAssignedPackage = input.required<boolean>();
/**
* Output event that emits when the list needs to be reloaded.
* This is used to refresh the remission list after completing a return.
@@ -128,7 +135,22 @@ export class RemissionReturnReceiptCompleteComponent {
return;
}
this.completingRemission.set(true);
try {
// #5289 - Ensure a package is assigned before completing the remission
if (!this.hasAssignedPackage()) {
const res = await this.#remissionStartService.assignPackage({
returnId: this.returnId(),
receiptId: this.receiptId(),
});
if (!res) {
this.completingRemission.set(false);
return;
}
}
// Complete Remission Flow
const completedReturn = await this.completeSingleReturnReceipt();
const returnGroup = completedReturn?.returnGroup;
@@ -146,7 +168,7 @@ export class RemissionReturnReceiptCompleteComponent {
const dialogResult = await firstValueFrom(dialogRef.closed);
if (dialogResult?.confirmed) {
// Beenden - Remission abschließen Flow
// Beenden - Remission abschließen Flow - Return Group Abschließen
await this.#remissionReturnReceiptService.completeReturnGroup({
returnGroup,
});