From 5010ef29ab358421f3434ca6fbe3762c99781135 Mon Sep 17 00:00:00 2001 From: Christopher Garratt Date: Thu, 26 Mar 2026 15:18:56 +0000 Subject: [PATCH 1/3] Implementing exact offence code match --- ...fines-mac-offence-code-hint.component.html | 6 +- ...es-mac-offence-code-hint.component.spec.ts | 74 ++++++++++ .../fines-mac-offence-code-hint.component.ts | 10 +- ...-fixed-penalty-details-form.component.html | 1 + ...details-add-an-offence-form.component.html | 1 + ...ew-offence-heading-title.component.spec.ts | 61 +++++++++ ...-review-offence-heading-title.component.ts | 8 +- .../fines-mac-offence-details.service.spec.ts | 127 +++++++++++++++++- .../fines-mac-offence-details.service.ts | 43 +++++- ...-penalty-offence-details.component.spec.ts | 59 ++++++++ ...fixed-penalty-offence-details.component.ts | 6 +- 11 files changed, 382 insertions(+), 14 deletions(-) diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html index 2253459973..b2c3386217 100644 --- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html +++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html @@ -1,8 +1,6 @@ @if (selectedOffenceConfirmation) { - @if (offenceCode.count === 1) { - + @if (matchedOffenceTitle; as offenceTitle) { + } @else { } diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts index 5e6bc74881..2c749e8623 100644 --- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts +++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts @@ -53,6 +53,7 @@ describe('FinesMacOffenceCodeHintComponent', () => { it('should render component with both inputs provided', () => { fixture.componentRef.setInput('offenceCode', mockOffenceCode); + fixture.componentRef.setInput('searchedOffenceCode', 'AK123456'); fixture.componentRef.setInput('selectedOffenceConfirmation', true); fixture.detectChanges(); @@ -70,6 +71,7 @@ describe('FinesMacOffenceCodeHintComponent', () => { it('should maintain component state through input changes', () => { // Initial state fixture.componentRef.setInput('offenceCode', mockOffenceCode); + fixture.componentRef.setInput('searchedOffenceCode', 'AK123456'); fixture.componentRef.setInput('selectedOffenceConfirmation', false); fixture.detectChanges(); @@ -82,4 +84,76 @@ describe('FinesMacOffenceCodeHintComponent', () => { expect(component.selectedOffenceConfirmation).toBe(true); expect(component.offenceCode).toEqual(mockOffenceCode); }); + + it('should render Offence found when the searched code matches one result exactly', () => { + const multipleMatchResponse: IOpalFinesOffencesRefData = { + count: 4, + refData: [ + { + offence_id: 41799, + get_cjs_code: 'CD71039', + business_unit_id: 52, + offence_title: 'Criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1997-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30733, + get_cjs_code: 'CD71039A', + business_unit_id: 52, + offence_title: 'Attempt criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', + offence_oas_cy: null, + }, + { + offence_id: 30734, + get_cjs_code: 'CD71039B', + business_unit_id: 52, + offence_title: 'Aid, abet, counsel and procure damage under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30735, + get_cjs_code: 'CD71039C', + business_unit_id: 52, + offence_title: 'Conspiracy to destroy or damage property under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: '2004-12-25T00:00:00Z', + offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', + offence_oas_cy: null, + }, + ], + }; + + fixture.componentRef.setInput('offenceCode', multipleMatchResponse); + fixture.componentRef.setInput('searchedOffenceCode', 'CD71039'); + fixture.componentRef.setInput('selectedOffenceConfirmation', true); + fixture.detectChanges(); + + const textContent = fixture.nativeElement.textContent; + expect(textContent).toContain('Offence found'); + expect(textContent).toContain('Criminal damage to property valued under £5000'); + }); + + it('should render Offence not found when there is no exact code match', () => { + fixture.componentRef.setInput('offenceCode', mockOffenceCode); + fixture.componentRef.setInput('searchedOffenceCode', 'AK12345'); + fixture.componentRef.setInput('selectedOffenceConfirmation', true); + fixture.detectChanges(); + + const textContent = fixture.nativeElement.textContent; + expect(textContent).toContain('Offence not found'); + expect(textContent).toContain('Enter a valid offence code'); + }); }); diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts index 71b6ee902d..ad7cd02472 100644 --- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts +++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts @@ -1,7 +1,8 @@ import { NgTemplateOutlet } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core'; import { MojTicketPanelComponent } from '@hmcts/opal-frontend-common/components/moj/moj-ticket-panel'; import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; +import { FinesMacOffenceDetailsService } from '../../fines-mac-offence-details/services/fines-mac-offence-details.service'; @Component({ selector: 'app-fines-mac-offence-code-hint', @@ -10,6 +11,13 @@ import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/in changeDetection: ChangeDetectionStrategy.OnPush, }) export class FinesMacOffenceCodeHintComponent { + private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService); + @Input() public offenceCode!: IOpalFinesOffencesRefData; + @Input() public searchedOffenceCode: string | null = null; @Input() public selectedOffenceConfirmation!: boolean; + + public get matchedOffenceTitle(): string | null { + return this.offenceDetailsService.findExactOffenceMatch(this.offenceCode, this.searchedOffenceCode)?.offence_title ?? null; + } } diff --git a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html index 575b3f9cf9..30b542379c 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html +++ b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html @@ -266,6 +266,7 @@

Offence Details

@if (offenceCode$ | async; as offenceCode) { } diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html index 10a03ccf0b..3ee53afb19 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html @@ -61,6 +61,7 @@

@if (offenceCode$ | async; as offenceCode) { } diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts index 8d220e125d..7c9ac5d736 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent } from './fines-mac-offence-details-review-offence-heading-title.component'; +import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; import { OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-singular.mock'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -16,6 +17,7 @@ describe('FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent', () => { fixture = TestBed.createComponent(FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent); component = fixture.componentInstance; + component.offenceCode = OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK.refData[0].get_cjs_code; component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK; fixture.detectChanges(); @@ -40,4 +42,63 @@ describe('FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent', () => { expect(component.offenceTitle).toEqual(component.offenceRefData.refData[0].offence_title); }); + + it('should use the exact code match when multiple offences are returned', () => { + const multiResultResponse: IOpalFinesOffencesRefData = { + count: 4, + refData: [ + { + offence_id: 41799, + get_cjs_code: 'CD71039', + business_unit_id: 52, + offence_title: 'Criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1997-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30733, + get_cjs_code: 'CD71039A', + business_unit_id: 52, + offence_title: 'Attempt criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', + offence_oas_cy: null, + }, + { + offence_id: 30734, + get_cjs_code: 'CD71039B', + business_unit_id: 52, + offence_title: 'Aid, abet, counsel and procure damage under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30735, + get_cjs_code: 'CD71039C', + business_unit_id: 52, + offence_title: 'Conspiracy to destroy or damage property under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: '2004-12-25T00:00:00Z', + offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', + offence_oas_cy: null, + }, + ], + }; + + component.offenceCode = 'CD71039'; + component.offenceRefData = multiResultResponse; + + component.getOffenceTitle(); + + expect(component.offenceTitle).toEqual('Criminal damage to property valued under £5000'); + }); }); diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts index 9885656ab9..d08fd596d9 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts @@ -1,11 +1,12 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core'; import { GovukHeadingWithCaptionComponent } from '@hmcts/opal-frontend-common/components/govuk/govuk-heading-with-caption'; import { GovukSummaryListRowActionItemComponent, GovukSummaryListRowActionsComponent, } from '@hmcts/opal-frontend-common/components/govuk/govuk-summary-list'; import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; +import { FinesMacOffenceDetailsService } from '../../../services/fines-mac-offence-details.service'; @Component({ selector: 'app-fines-mac-offence-details-review-offence-heading-title', @@ -20,6 +21,8 @@ import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/in changeDetection: ChangeDetectionStrategy.OnPush, }) export class FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent implements OnInit { + private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService); + @Input({ required: true }) public offenceCode!: string; @Input({ required: true }) public offenceRefData!: IOpalFinesOffencesRefData; @Input({ required: false }) public showActions!: boolean; @@ -41,7 +44,8 @@ export class FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent implements * Retrieves the offence title from the offence reference data and assigns it to the `offenceTitle` property. */ public getOffenceTitle(): void { - this.offenceTitle = this.offenceRefData.refData[0].offence_title; + const exactMatch = this.offenceDetailsService.findExactOffenceMatch(this.offenceRefData, this.offenceCode); + this.offenceTitle = exactMatch?.offence_title ?? this.offenceRefData.refData[0]?.offence_title ?? ''; } public ngOnInit(): void { diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts index ce71eb0d95..d1b191ced4 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.spec.ts @@ -132,7 +132,7 @@ describe('FinesMacOffenceDetailsService', () => { it('should call populateHint immediately if initial code is present', () => { vi.useFakeTimers(); - form.get('code')?.setValue('ab12345'); + form.get('code')?.setValue('ak123456'); service.initOffenceCodeListener( form, @@ -166,13 +166,13 @@ describe('FinesMacOffenceDetailsService', () => { onConfirmChangeSpy, ); - form.get('code')?.setValue('xy98765'); + form.get('code')?.setValue('ak123456'); form.get('code')?.updateValueAndValidity(); vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); - expect(uppercaseAllLettersSpy).toHaveBeenCalledWith('xy98765'); - expect(form.get('code')?.value).toBe('XY98765'); + expect(uppercaseAllLettersSpy).toHaveBeenCalledWith('ak123456'); + expect(form.get('code')?.value).toBe('AK123456'); expect(form.get('id')?.value).toBe(314441); expect(onResultSpy).toHaveBeenCalled(); expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); @@ -201,6 +201,125 @@ describe('FinesMacOffenceDetailsService', () => { expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); }); + it('should populate the offence id when an exact match exists in a multi-result response', () => { + vi.useFakeTimers(); + const multiResultResponse: IOpalFinesOffencesRefData = { + count: 4, + refData: [ + { + offence_id: 41799, + get_cjs_code: 'CD71039', + business_unit_id: 52, + offence_title: 'Criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1997-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30733, + get_cjs_code: 'CD71039A', + business_unit_id: 52, + offence_title: 'Attempt criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', + offence_oas_cy: null, + }, + { + offence_id: 30734, + get_cjs_code: 'CD71039B', + business_unit_id: 52, + offence_title: 'Aid, abet, counsel and procure damage under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30735, + get_cjs_code: 'CD71039C', + business_unit_id: 52, + offence_title: 'Conspiracy to destroy or damage property under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: '2004-12-25T00:00:00Z', + offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', + offence_oas_cy: null, + }, + ], + }; + getOffenceByCjsCode = () => of(multiResultResponse); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('cd71039'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + expect(form.get('code')?.errors).toBeNull(); + expect(form.get('id')?.value).toBe(41799); + expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); + }); + + it('should mark code as invalid when results are returned but none match exactly', () => { + vi.useFakeTimers(); + const nonExactResponse: IOpalFinesOffencesRefData = { + count: 2, + refData: [ + { + offence_id: 1, + get_cjs_code: 'TEST123A', + business_unit_id: 52, + offence_title: 'Test A', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Test A', + offence_oas_cy: null, + }, + { + offence_id: 2, + get_cjs_code: 'TEST123B', + business_unit_id: 52, + offence_title: 'Test B', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Test B', + offence_oas_cy: null, + }, + ], + }; + getOffenceByCjsCode = () => of(nonExactResponse); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('TEST123'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + expect(form.get('code')?.errors).toEqual({ invalidOffenceCode: true }); + expect(form.get('id')?.value).toBeNull(); + }); + it('should not call populateHint for short code', () => { vi.useFakeTimers(); service.initOffenceCodeListener( diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts index 3519764552..584800b2c6 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts @@ -4,6 +4,7 @@ import { FormGroup } from '@angular/forms'; import { debounceTime, distinctUntilChanged, Observable, Subject, takeUntil, tap } from 'rxjs'; import { FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES } from '../constants/fines-mac-offence-details-default-values.constant'; import { UtilsService } from '@hmcts/opal-frontend-common/services/utils-service'; +import { IOpalFinesOffences } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences.interface'; import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; @Injectable({ @@ -55,6 +56,16 @@ export class FinesMacOffenceDetailsService { } } + /** + * Extracts the offence code from a ref-data item. + * + * @param offence - The offence ref-data item. + * @returns The offence code when present. + */ + private getOffenceCode(offence: IOpalFinesOffences & { cjs_code?: string }): string | undefined { + return offence.get_cjs_code ?? offence.cjs_code; + } + /** * Removes an imposition from the specified offence in the data array. * @@ -115,6 +126,32 @@ export class FinesMacOffenceDetailsService { }); } + /** + * Finds the exact offence match for a supplied offence code from the returned offence reference data. + * + * Supports both `get_cjs_code` and `cjs_code` shaped response objects so the caller does not need + * to care about the source format. + * + * @param response - The offence lookup response. + * @param offenceCode - The offence code entered by the user. + * @returns The matching offence entry, if one exists. + */ + public findExactOffenceMatch( + response: IOpalFinesOffencesRefData | null | undefined, + offenceCode: string | null | undefined, + ): IOpalFinesOffences | undefined { + if (!response?.refData?.length || !offenceCode) { + return undefined; + } + + const normalisedOffenceCode = offenceCode.trim().toUpperCase(); + + return response.refData.find((offence) => { + const returnedCode = this.getOffenceCode(offence as IOpalFinesOffences & { cjs_code?: string }); + return returnedCode?.trim().toUpperCase() === normalisedOffenceCode; + }); + } + /** * Initializes the offence code listener for a form control. * @param form - The FormGroup containing the controls. @@ -144,8 +181,10 @@ export class FinesMacOffenceDetailsService { if (code?.length >= 7 && code?.length <= 8) { const result$ = getOffenceByCjsCode(code).pipe( tap((response) => { - codeControl.setErrors(response.count === 0 ? { invalidOffenceCode: true } : null, { emitEvent: false }); - idControl.setValue(response.count === 1 ? response.refData[0].offence_id : null, { emitEvent: false }); + const exactMatch = this.findExactOffenceMatch(response, code); + + codeControl.setErrors(exactMatch ? null : { invalidOffenceCode: true }, { emitEvent: false }); + idControl.setValue(exactMatch?.offence_id ?? null, { emitEvent: false }); if (typeof onResult === 'function') { onResult(response); diff --git a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts index 44a9ed904b..ea3c523472 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts @@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent } from './fines-mac-review-account-fixed-penalty-offence-details.component'; import { OpalFines } from '@services/fines/opal-fines-service/opal-fines.service'; +import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; import { OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-singular.mock'; import { of } from 'rxjs'; import { FINES_MAC_FIXED_PENALTY_DETAILS_STORE_STATE_MOCK } from '../../fines-mac-fixed-penalty-details/mocks/fines-mac-fixed-penalty-details-store-state.mock'; @@ -48,4 +49,62 @@ describe('FinesMacReviewAccountFixedPenaltyDetailsComponent', () => { expect(mockOpalFinesService.getOffenceByCjsCode).toHaveBeenCalledWith(offenceCode); expect(component.offence).toBe('ak test (12345)'); }); + + it('should use the exact offence match when multiple offences are returned', () => { + const multiResultResponse: IOpalFinesOffencesRefData = { + count: 4, + refData: [ + { + offence_id: 41799, + get_cjs_code: 'CD71039', + business_unit_id: 52, + offence_title: 'Criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1997-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30733, + get_cjs_code: 'CD71039A', + business_unit_id: 52, + offence_title: 'Attempt criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', + offence_oas_cy: null, + }, + { + offence_id: 30734, + get_cjs_code: 'CD71039B', + business_unit_id: 52, + offence_title: 'Aid, abet, counsel and procure damage under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30735, + get_cjs_code: 'CD71039C', + business_unit_id: 52, + offence_title: 'Conspiracy to destroy or damage property under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: '2004-12-25T00:00:00Z', + offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', + offence_oas_cy: null, + }, + ], + }; + + mockOpalFinesService.getOffenceByCjsCode = vi.fn().mockReturnValue(of(multiResultResponse)); + + component.getOffence('CD71039'); + + expect(component.offence).toBe('Criminal damage to property valued under £5000 (CD71039)'); + }); }); diff --git a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts index 0d76828312..f9d1a5e194 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts @@ -13,6 +13,7 @@ import { FINES_DEFAULT_VALUES } from '../../../constants/fines-default-values.co import { FinesNotProvidedComponent } from '../../../components/fines-not-provided/fines-not-provided.component'; import { DateFormatPipe } from '@hmcts/opal-frontend-common/pipes/date-format'; import { MonetaryPipe } from '@hmcts/opal-frontend-common/pipes/monetary'; +import { FinesMacOffenceDetailsService } from '../../fines-mac-offence-details/services/fines-mac-offence-details.service'; @Component({ selector: 'app-fines-mac-review-account-fixed-penalty-offence-details', @@ -30,6 +31,7 @@ import { MonetaryPipe } from '@hmcts/opal-frontend-common/pipes/monetary'; }) export class FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent implements OnInit { private readonly opalFinesService = inject(OpalFines); + private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService); @Input({ required: true }) public offenceDetails!: IFinesMacFixedPenaltyDetailsStoreState; @Input({ required: false }) public isReadOnly = false; @Output() public emitChangeOffenceDetails = new EventEmitter(); @@ -56,7 +58,9 @@ export class FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent implements */ public getOffence(offenceCode: string): void { this.opalFinesService.getOffenceByCjsCode(offenceCode).subscribe((offence: IOpalFinesOffencesRefData) => { - this.offence = `${offence.refData[0].offence_title} (${offenceCode})`; + const exactMatch = this.offenceDetailsService.findExactOffenceMatch(offence, offenceCode); + const offenceTitle = exactMatch?.offence_title ?? offence.refData[0]?.offence_title ?? offenceCode; + this.offence = `${offenceTitle} (${offenceCode})`; }); } From a2ef302bae40c1b56b272bff25f552671d435526 Mon Sep 17 00:00:00 2001 From: Christopher Garratt Date: Thu, 2 Apr 2026 16:15:40 +0100 Subject: [PATCH 2/3] Addressing PR comments. Implementing P1 fix by using stored offence_id to disambiguate duplicate matches. --- ...fines-mac-offence-code-hint.component.html | 4 +- ...es-mac-offence-code-hint.component.spec.ts | 68 +++------ .../fines-mac-offence-code-hint.component.ts | 10 +- ...-fixed-penalty-details-form.component.html | 1 + ...details-add-an-offence-form.component.html | 3 +- ...ew-offence-heading-title.component.spec.ts | 77 ++++------ ...-review-offence-heading-title.component.ts | 7 +- ...ails-review-offence-heading.component.html | 1 + ...etails-review-offence-heading.component.ts | 1 + ...ence-details-review-offence.component.html | 1 + .../fines-mac-offence-details.service.spec.ts | 141 +++++++++++------- .../fines-mac-offence-details.service.ts | 23 ++- ...-penalty-offence-details.component.spec.ts | 78 +++------- ...fixed-penalty-offence-details.component.ts | 6 +- ...nes-offences-ref-data-multi-result.mock.ts | 79 ++++++++++ 15 files changed, 280 insertions(+), 220 deletions(-) create mode 100644 src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock.ts diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html index b2c3386217..a933fe1a41 100644 --- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html +++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.html @@ -1,6 +1,8 @@ @if (selectedOffenceConfirmation) { @if (matchedOffenceTitle; as offenceTitle) { - + } @else { } diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts index 2c749e8623..6ba16798ec 100644 --- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts +++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.spec.ts @@ -3,6 +3,10 @@ import { NgTemplateOutlet } from '@angular/common'; import { MojTicketPanelComponent } from '@hmcts/opal-frontend-common/components/moj/moj-ticket-panel'; import { FinesMacOffenceCodeHintComponent } from './fines-mac-offence-code-hint.component'; import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; +import { + OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK, + OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK, +} from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock'; import { OPAL_FINES_OFFENCES_REF_DATA_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data.mock'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -86,57 +90,7 @@ describe('FinesMacOffenceCodeHintComponent', () => { }); it('should render Offence found when the searched code matches one result exactly', () => { - const multipleMatchResponse: IOpalFinesOffencesRefData = { - count: 4, - refData: [ - { - offence_id: 41799, - get_cjs_code: 'CD71039', - business_unit_id: 52, - offence_title: 'Criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1997-11-16T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30733, - get_cjs_code: 'CD71039A', - business_unit_id: 52, - offence_title: 'Attempt criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', - offence_oas_cy: null, - }, - { - offence_id: 30734, - get_cjs_code: 'CD71039B', - business_unit_id: 52, - offence_title: 'Aid, abet, counsel and procure damage under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30735, - get_cjs_code: 'CD71039C', - business_unit_id: 52, - offence_title: 'Conspiracy to destroy or damage property under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: '2004-12-25T00:00:00Z', - offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', - offence_oas_cy: null, - }, - ], - }; - - fixture.componentRef.setInput('offenceCode', multipleMatchResponse); + fixture.componentRef.setInput('offenceCode', OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK); fixture.componentRef.setInput('searchedOffenceCode', 'CD71039'); fixture.componentRef.setInput('selectedOffenceConfirmation', true); fixture.detectChanges(); @@ -146,6 +100,18 @@ describe('FinesMacOffenceCodeHintComponent', () => { expect(textContent).toContain('Criminal damage to property valued under £5000'); }); + it('should resolve a duplicate code match using the saved offence id', () => { + fixture.componentRef.setInput('offenceCode', OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK); + fixture.componentRef.setInput('offenceId', 41800); + fixture.componentRef.setInput('searchedOffenceCode', 'GMMET001'); + fixture.componentRef.setInput('selectedOffenceConfirmation', true); + fixture.detectChanges(); + + const textContent = fixture.nativeElement.textContent; + expect(textContent).toContain('Offence found'); + expect(textContent).toContain('Duplicate offence title B'); + }); + it('should render Offence not found when there is no exact code match', () => { fixture.componentRef.setInput('offenceCode', mockOffenceCode); fixture.componentRef.setInput('searchedOffenceCode', 'AK12345'); diff --git a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts index ad7cd02472..9cb550817a 100644 --- a/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts +++ b/src/app/flows/fines/fines-mac/components/fines-mac-offence-code-hint/fines-mac-offence-code-hint.component.ts @@ -14,10 +14,18 @@ export class FinesMacOffenceCodeHintComponent { private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService); @Input() public offenceCode!: IOpalFinesOffencesRefData; + @Input() public offenceId: number | null = null; @Input() public searchedOffenceCode: string | null = null; @Input() public selectedOffenceConfirmation!: boolean; + /** + * Returns the title for a single exact offence-code match from the lookup response. + * @returns The matched offence title, or `null` when the code is missing, ambiguous, or not found. + */ public get matchedOffenceTitle(): string | null { - return this.offenceDetailsService.findExactOffenceMatch(this.offenceCode, this.searchedOffenceCode)?.offence_title ?? null; + return ( + this.offenceDetailsService.findExactOffenceMatch(this.offenceCode, this.searchedOffenceCode, this.offenceId) + ?.offence_title ?? null + ); } } diff --git a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html index 30b542379c..500d620492 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html +++ b/src/app/flows/fines/fines-mac/fines-mac-fixed-penalty-details/fines-mac-fixed-penalty-details-form/fines-mac-fixed-penalty-details-form.component.html @@ -266,6 +266,7 @@

Offence Details

@if (offenceCode$ | async; as offenceCode) { diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html index 3ee53afb19..9fd2529d05 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-add-an-offence/fines-mac-offence-details-add-an-offence-form/fines-mac-offence-details-add-an-offence-form.component.html @@ -61,7 +61,8 @@

@if (offenceCode$ | async; as offenceCode) { } diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts index 7c9ac5d736..8148310f0e 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.spec.ts @@ -1,7 +1,10 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent } from './fines-mac-offence-details-review-offence-heading-title.component'; -import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; +import { + OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK, + OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK, +} from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock'; import { OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-singular.mock'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -44,61 +47,31 @@ describe('FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent', () => { }); it('should use the exact code match when multiple offences are returned', () => { - const multiResultResponse: IOpalFinesOffencesRefData = { - count: 4, - refData: [ - { - offence_id: 41799, - get_cjs_code: 'CD71039', - business_unit_id: 52, - offence_title: 'Criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1997-11-16T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30733, - get_cjs_code: 'CD71039A', - business_unit_id: 52, - offence_title: 'Attempt criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', - offence_oas_cy: null, - }, - { - offence_id: 30734, - get_cjs_code: 'CD71039B', - business_unit_id: 52, - offence_title: 'Aid, abet, counsel and procure damage under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30735, - get_cjs_code: 'CD71039C', - business_unit_id: 52, - offence_title: 'Conspiracy to destroy or damage property under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: '2004-12-25T00:00:00Z', - offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', - offence_oas_cy: null, - }, - ], - }; - component.offenceCode = 'CD71039'; - component.offenceRefData = multiResultResponse; + component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK; component.getOffenceTitle(); expect(component.offenceTitle).toEqual('Criminal damage to property valued under £5000'); }); + + it('should use the saved offence id when duplicate code matches are returned', () => { + component.offenceCode = 'GMMET001'; + component.offenceId = 41800; + component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK; + + component.getOffenceTitle(); + + expect(component.offenceTitle).toEqual('Duplicate offence title B'); + }); + + it('should fall back to the first offence title when duplicate code matches are returned without a saved offence id', () => { + component.offenceCode = 'GMMET001'; + component.offenceId = null; + component.offenceRefData = OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK; + + component.getOffenceTitle(); + + expect(component.offenceTitle).toEqual('Duplicate offence title A'); + }); }); diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts index d08fd596d9..214d7e8802 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading-title/fines-mac-offence-details-review-offence-heading-title.component.ts @@ -24,6 +24,7 @@ export class FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent implements private readonly offenceDetailsService = inject(FinesMacOffenceDetailsService); @Input({ required: true }) public offenceCode!: string; + @Input({ required: false }) public offenceId: number | null = null; @Input({ required: true }) public offenceRefData!: IOpalFinesOffencesRefData; @Input({ required: false }) public showActions!: boolean; @Input({ required: false }) public showDetails: boolean = true; @@ -44,7 +45,11 @@ export class FinesMacOffenceDetailsReviewOffenceHeadingTitleComponent implements * Retrieves the offence title from the offence reference data and assigns it to the `offenceTitle` property. */ public getOffenceTitle(): void { - const exactMatch = this.offenceDetailsService.findExactOffenceMatch(this.offenceRefData, this.offenceCode); + const exactMatch = this.offenceDetailsService.findExactOffenceMatch( + this.offenceRefData, + this.offenceCode, + this.offenceId, + ); this.offenceTitle = exactMatch?.offence_title ?? this.offenceRefData.refData[0]?.offence_title ?? ''; } diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html index edc3d9cf6a..b371ad8e4b 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/fines-mac-offence-details-review-offence/fines-mac-offence-details-review-offence-heading/fines-mac-offence-details-review-offence-heading.component.html @@ -1,6 +1,7 @@ @if (offenceRefData$ | async; as offenceRefData) { { expect(result[0].formData.fm_offence_details_impositions[0]).toEqual(expected); }); + it('findExactOffenceMatch - should return undefined when duplicate code matches are ambiguous', () => { + const result = service.findExactOffenceMatch(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK, 'GMMET001'); + + expect(result).toBeUndefined(); + }); + + it('findExactOffenceMatch - should resolve duplicate code matches using the saved offence id', () => { + const result = service.findExactOffenceMatch(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK, 'GMMET001', 41800); + + expect(result).toEqual(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK.refData[1]); + }); + describe('initOffenceListener', () => { let form: FormGroup; let destroy$: Subject; @@ -116,7 +132,7 @@ describe('FinesMacOffenceDetailsService', () => { form = new FormGroup({ code: new FormControl(''), - id: new FormControl(''), + id: new FormControl(null), }); destroy$ = new Subject(); @@ -203,56 +219,7 @@ describe('FinesMacOffenceDetailsService', () => { it('should populate the offence id when an exact match exists in a multi-result response', () => { vi.useFakeTimers(); - const multiResultResponse: IOpalFinesOffencesRefData = { - count: 4, - refData: [ - { - offence_id: 41799, - get_cjs_code: 'CD71039', - business_unit_id: 52, - offence_title: 'Criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1997-11-16T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30733, - get_cjs_code: 'CD71039A', - business_unit_id: 52, - offence_title: 'Attempt criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', - offence_oas_cy: null, - }, - { - offence_id: 30734, - get_cjs_code: 'CD71039B', - business_unit_id: 52, - offence_title: 'Aid, abet, counsel and procure damage under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30735, - get_cjs_code: 'CD71039C', - business_unit_id: 52, - offence_title: 'Conspiracy to destroy or damage property under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: '2004-12-25T00:00:00Z', - offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', - offence_oas_cy: null, - }, - ], - }; - getOffenceByCjsCode = () => of(multiResultResponse); + getOffenceByCjsCode = () => of(OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK); service.initOffenceCodeListener( form, @@ -272,6 +239,78 @@ describe('FinesMacOffenceDetailsService', () => { expect(onConfirmChangeSpy).toHaveBeenCalledWith(true); }); + it('should mark duplicate exact code matches as invalid when there is no saved offence id', () => { + vi.useFakeTimers(); + getOffenceByCjsCode = () => of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK); + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + form.get('code')?.setValue('gmmet001'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + expect(form.get('code')?.errors).toEqual({ invalidOffenceCode: true }); + expect(form.get('id')?.value).toBeNull(); + }); + + it('should preserve the saved offence id when the original duplicate code is re-entered', () => { + vi.useFakeTimers(); + form.get('code')?.setValue('GMMET001'); + form.get('id')?.setValue(41800); + getOffenceByCjsCode = (code: string) => { + if (code === 'UNIQUE01') { + return of({ + count: 1, + refData: [ + { + offence_id: 99999, + get_cjs_code: 'UNIQUE01', + business_unit_id: 52, + offence_title: 'Unique offence title', + offence_title_cy: null, + date_used_from: '1997-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Unique offence', + offence_oas_cy: null, + }, + ], + }); + } + + return of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK); + }; + + service.initOffenceCodeListener( + form, + 'code', + 'id', + destroy$, + getOffenceByCjsCode, + onResultSpy, + onConfirmChangeSpy, + ); + + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + expect(form.get('id')?.value).toBe(41800); + + form.get('code')?.setValue('UNIQUE01'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + expect(form.get('id')?.value).toBe(99999); + + form.get('code')?.setValue('GMMET001'); + vi.advanceTimersByTime(FINES_MAC_OFFENCE_DETAILS_DEFAULT_VALUES.defaultDebounceTime); + + expect(form.get('code')?.errors).toBeNull(); + expect(form.get('id')?.value).toBe(41800); + }); + it('should mark code as invalid when results are returned but none match exactly', () => { vi.useFakeTimers(); const nonExactResponse: IOpalFinesOffencesRefData = { diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts index 584800b2c6..af874ecfbd 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts @@ -139,17 +139,27 @@ export class FinesMacOffenceDetailsService { public findExactOffenceMatch( response: IOpalFinesOffencesRefData | null | undefined, offenceCode: string | null | undefined, + offenceId?: number | null, ): IOpalFinesOffences | undefined { if (!response?.refData?.length || !offenceCode) { return undefined; } const normalisedOffenceCode = offenceCode.trim().toUpperCase(); - - return response.refData.find((offence) => { + const exactCodeMatches = response.refData.filter((offence) => { const returnedCode = this.getOffenceCode(offence as IOpalFinesOffences & { cjs_code?: string }); return returnedCode?.trim().toUpperCase() === normalisedOffenceCode; }); + + if (exactCodeMatches.length === 1) { + return exactCodeMatches[0]; + } + + if (offenceId == null) { + return undefined; + } + + return exactCodeMatches.find((offence) => offence.offence_id === offenceId); } /** @@ -174,14 +184,18 @@ export class FinesMacOffenceDetailsService { ): void { const codeControl = form.controls[codeControlName]; const idControl = form.controls[idControlName]; + const savedOffenceId = idControl.value; + const savedOffenceCode = typeof codeControl.value === 'string' ? codeControl.value.trim().toUpperCase() : null; const populateHint = (code: string) => { - idControl.setValue(null); + const normalisedCode = code?.trim().toUpperCase(); + const previousOffenceId = + normalisedCode && savedOffenceCode === normalisedCode ? savedOffenceId : idControl.value; if (code?.length >= 7 && code?.length <= 8) { const result$ = getOffenceByCjsCode(code).pipe( tap((response) => { - const exactMatch = this.findExactOffenceMatch(response, code); + const exactMatch = this.findExactOffenceMatch(response, code, previousOffenceId); codeControl.setErrors(exactMatch ? null : { invalidOffenceCode: true }, { emitEvent: false }); idControl.setValue(exactMatch?.offence_id ?? null, { emitEvent: false }); @@ -196,6 +210,7 @@ export class FinesMacOffenceDetailsService { result$.subscribe(); if (onConfirmChange) onConfirmChange(true); } else if (onConfirmChange) { + idControl.setValue(null, { emitEvent: false }); onConfirmChange(false); } }; diff --git a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts index ea3c523472..c2c6433196 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.spec.ts @@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent } from './fines-mac-review-account-fixed-penalty-offence-details.component'; import { OpalFines } from '@services/fines/opal-fines-service/opal-fines.service'; -import { IOpalFinesOffencesRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-offences-ref-data.interface'; +import { OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock'; import { OPAL_FINES_OFFENCES_REF_DATA_SINGULAR_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-offences-ref-data-singular.mock'; import { of } from 'rxjs'; import { FINES_MAC_FIXED_PENALTY_DETAILS_STORE_STATE_MOCK } from '../../fines-mac-fixed-penalty-details/mocks/fines-mac-fixed-penalty-details-store-state.mock'; @@ -25,7 +25,7 @@ describe('FinesMacReviewAccountFixedPenaltyDetailsComponent', () => { fixture = TestBed.createComponent(FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent); component = fixture.componentInstance; - component.offenceDetails = FINES_MAC_FIXED_PENALTY_DETAILS_STORE_STATE_MOCK; + component.offenceDetails = structuredClone(FINES_MAC_FIXED_PENALTY_DETAILS_STORE_STATE_MOCK); fixture.detectChanges(); }); @@ -44,67 +44,31 @@ describe('FinesMacReviewAccountFixedPenaltyDetailsComponent', () => { }); it('should get offence details by code', () => { - const offenceCode = '12345'; + const offenceCode = 'AK123456'; component.getOffence(offenceCode); expect(mockOpalFinesService.getOffenceByCjsCode).toHaveBeenCalledWith(offenceCode); - expect(component.offence).toBe('ak test (12345)'); + expect(component.offence).toBe('ak test (AK123456)'); }); - it('should use the exact offence match when multiple offences are returned', () => { - const multiResultResponse: IOpalFinesOffencesRefData = { - count: 4, - refData: [ - { - offence_id: 41799, - get_cjs_code: 'CD71039', - business_unit_id: 52, - offence_title: 'Criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1997-11-16T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30733, - get_cjs_code: 'CD71039A', - business_unit_id: 52, - offence_title: 'Attempt criminal damage to property valued under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', - offence_oas_cy: null, - }, - { - offence_id: 30734, - get_cjs_code: 'CD71039B', - business_unit_id: 52, - offence_title: 'Aid, abet, counsel and procure damage under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: null, - offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', - offence_oas_cy: null, - }, - { - offence_id: 30735, - get_cjs_code: 'CD71039C', - business_unit_id: 52, - offence_title: 'Conspiracy to destroy or damage property under £5000', - offence_title_cy: null, - date_used_from: '1971-01-01T00:00:00Z', - date_used_to: '2004-12-25T00:00:00Z', - offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', - offence_oas_cy: null, - }, - ], - }; + it('should use the saved offence id when duplicate offences are returned', () => { + component.offenceDetails.fm_offence_details_offence_id = 41800; + mockOpalFinesService.getOffenceByCjsCode = vi + .fn() + .mockReturnValue(of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK)); + + component.getOffence('GMMET001'); + + expect(component.offence).toBe('Duplicate offence title B (GMMET001)'); + }); - mockOpalFinesService.getOffenceByCjsCode = vi.fn().mockReturnValue(of(multiResultResponse)); + it('should fall back to the first offence title when duplicate offences are returned without a saved offence id', () => { + component.offenceDetails.fm_offence_details_offence_id = null; + mockOpalFinesService.getOffenceByCjsCode = vi + .fn() + .mockReturnValue(of(OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK)); - component.getOffence('CD71039'); + component.getOffence('GMMET001'); - expect(component.offence).toBe('Criminal damage to property valued under £5000 (CD71039)'); + expect(component.offence).toBe('Duplicate offence title A (GMMET001)'); }); }); diff --git a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts index f9d1a5e194..2741859055 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-review-account/fines-mac-review-account-fixed-penalty-offence-details/fines-mac-review-account-fixed-penalty-offence-details.component.ts @@ -58,7 +58,11 @@ export class FinesMacReviewAccountFixedPenaltyOffenceDetailsComponent implements */ public getOffence(offenceCode: string): void { this.opalFinesService.getOffenceByCjsCode(offenceCode).subscribe((offence: IOpalFinesOffencesRefData) => { - const exactMatch = this.offenceDetailsService.findExactOffenceMatch(offence, offenceCode); + const exactMatch = this.offenceDetailsService.findExactOffenceMatch( + offence, + offenceCode, + this.offenceDetails.fm_offence_details_offence_id, + ); const offenceTitle = exactMatch?.offence_title ?? offence.refData[0]?.offence_title ?? offenceCode; this.offence = `${offenceTitle} (${offenceCode})`; }); diff --git a/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock.ts b/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock.ts new file mode 100644 index 0000000000..bfb8c36fab --- /dev/null +++ b/src/app/flows/fines/services/opal-fines-service/mocks/opal-fines-offences-ref-data-multi-result.mock.ts @@ -0,0 +1,79 @@ +import { IOpalFinesOffencesRefData } from '../interfaces/opal-fines-offences-ref-data.interface'; + +export const OPAL_FINES_OFFENCES_REF_DATA_EXACT_MATCH_MULTI_RESULT_MOCK: IOpalFinesOffencesRefData = { + count: 4, + refData: [ + { + offence_id: 41799, + get_cjs_code: 'CD71039', + business_unit_id: 52, + offence_title: 'Criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1997-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30733, + get_cjs_code: 'CD71039A', + business_unit_id: 52, + offence_title: 'Attempt criminal damage to property valued under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to section 1(1) of the Criminal Attempts Act 1981.', + offence_oas_cy: null, + }, + { + offence_id: 30734, + get_cjs_code: 'CD71039B', + business_unit_id: 52, + offence_title: 'Aid, abet, counsel and procure damage under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: null, + offence_oas: 'Contrary to sections 1(1) and 4 of the Criminal Damage Act 1971.', + offence_oas_cy: null, + }, + { + offence_id: 30735, + get_cjs_code: 'CD71039C', + business_unit_id: 52, + offence_title: 'Conspiracy to destroy or damage property under £5000', + offence_title_cy: null, + date_used_from: '1971-01-01T00:00:00Z', + date_used_to: '2004-12-25T00:00:00Z', + offence_oas: 'Contrary to section 1 of the Criminal Law Act 1977.', + offence_oas_cy: null, + }, + ], +}; + +export const OPAL_FINES_OFFENCES_REF_DATA_DUPLICATE_CODE_MOCK: IOpalFinesOffencesRefData = { + count: 2, + refData: [ + { + offence_id: 41799, + get_cjs_code: 'GMMET001', + business_unit_id: 52, + offence_title: 'Duplicate offence title A', + offence_title_cy: null, + date_used_from: '1997-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Offence A', + offence_oas_cy: null, + }, + { + offence_id: 41800, + get_cjs_code: 'GMMET001', + business_unit_id: 73, + offence_title: 'Duplicate offence title B', + offence_title_cy: null, + date_used_from: '1998-11-16T00:00:00Z', + date_used_to: null, + offence_oas: 'Offence B', + offence_oas_cy: null, + }, + ], +}; From 8273fca8e067a0e2be8d719bbc64f6f2b5e9a0ae Mon Sep 17 00:00:00 2001 From: Christopher Garratt Date: Thu, 2 Apr 2026 16:19:10 +0100 Subject: [PATCH 3/3] Updating method doc --- .../services/fines-mac-offence-details.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts index af874ecfbd..3347230759 100644 --- a/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts +++ b/src/app/flows/fines/fines-mac/fines-mac-offence-details/services/fines-mac-offence-details.service.ts @@ -134,7 +134,8 @@ export class FinesMacOffenceDetailsService { * * @param response - The offence lookup response. * @param offenceCode - The offence code entered by the user. - * @returns The matching offence entry, if one exists. + * @param offenceId - Optional saved offence ID used to disambiguate duplicate exact-code matches. + * @returns The unique exact-code match, the saved offence when duplicates can be disambiguated, or `undefined`. */ public findExactOffenceMatch( response: IOpalFinesOffencesRefData | null | undefined, @@ -168,7 +169,6 @@ export class FinesMacOffenceDetailsService { * @param codeControlName - The name of the control for the offence code. * @param idControlName - The name of the control for the offence ID. * @param destroy$ - Subject to signal when to unsubscribe from observables. - * @param changeDetector - ChangeDetectorRef to trigger change detection. * @param onResult - Optional callback function to handle the result of the code lookup. * @param onConfirmChange - Optional callback function to confirm if the code change was successful. */