From 45600598a55492676f238c1741890f1b503d8b90 Mon Sep 17 00:00:00 2001 From: Mikko Savolainen Date: Fri, 13 Dec 2024 12:30:54 +0200 Subject: [PATCH] Titania report enhancement - fix incoming Titania dataset validation - add possibility to remove individual rows from Titania error report --- .../components/reports/TitaniaErrors.tsx | 73 +++++++++++-------- .../components/reports/queries.ts | 6 ++ .../generated/api-clients/reports.ts | 17 +++++ .../lib-common/generated/api-types/reports.ts | 2 + .../lib-common/generated/api-types/shared.ts | 2 + .../evaka/reports/TitaniaErrorsReport.kt | 47 +++++++++++- .../main/kotlin/fi/espoo/evaka/shared/Id.kt | 4 + .../fi/espoo/evaka/titania/TitaniaService.kt | 26 ++----- .../db/migration/V477__titania_errors_id.sql | 5 ++ service/src/main/resources/migrations.txt | 1 + 10 files changed, 134 insertions(+), 49 deletions(-) create mode 100644 service/src/main/resources/db/migration/V477__titania_errors_id.sql diff --git a/frontend/src/employee-frontend/components/reports/TitaniaErrors.tsx b/frontend/src/employee-frontend/components/reports/TitaniaErrors.tsx index 1266e8d7688..9c75fe1a1b1 100644 --- a/frontend/src/employee-frontend/components/reports/TitaniaErrors.tsx +++ b/frontend/src/employee-frontend/components/reports/TitaniaErrors.tsx @@ -11,6 +11,7 @@ import { } from 'lib-common/generated/api-types/reports' import { useQueryResult } from 'lib-common/query' import Title from 'lib-components/atoms/Title' +import { MutateButton } from 'lib-components/atoms/buttons/MutateButton' import ReturnButton from 'lib-components/atoms/buttons/ReturnButton' import { Container, ContentArea } from 'lib-components/layout/Container' import { Table, Thead, Th, Tbody, Td, Tr } from 'lib-components/layout/Table' @@ -20,7 +21,7 @@ import { Gap } from 'lib-components/white-space' import { useTranslation } from '../../state/i18n' import { renderResult } from '../async-rendering' -import { titaniaErrorsReportQuery } from './queries' +import { clearTitaniaErrorMutation, titaniaErrorsReportQuery } from './queries' export default React.memo(function TitaniaErrors() { const { i18n } = useTranslation() @@ -36,18 +37,18 @@ export default React.memo(function TitaniaErrors() { {renderResult(titaniaErrorsResult, (rows) => ( <> {rows.map((row: TitaniaErrorReportRow) => ( - <> -

+
+

{i18n.reports.titaniaErrors.header + ' ' + row.requestTime.format()}

{row.units.map((unit: TitaniaErrorUnit) => ( - <> -

{unit.unitName}

+
+

{unit.unitName}

{unit.employees.map((employee: TitaniaErrorEmployee) => ( - <> -

+
+

{employee.employeeName + (employee.employeeNumber == '' ? '' @@ -55,35 +56,47 @@ export default React.memo(function TitaniaErrors() {

- - - + + + + + - {employee.conflictingShifts.map( - (conflict, index) => ( - - - - - - ) - )} + {employee.conflictingShifts.map((conflict) => ( + + + + + + + ))}
{i18n.reports.titaniaErrors.date}{i18n.reports.titaniaErrors.shift1}{i18n.reports.titaniaErrors.shift2}
{i18n.reports.titaniaErrors.date}{i18n.reports.titaniaErrors.shift1}{i18n.reports.titaniaErrors.shift2} +
{conflict.shiftDate.format()} - {conflict.shiftBegins.format() + - ' - ' + - conflict.shiftEnds.format()} - - {conflict.overlappingShiftBegins.format() + - ' - ' + - conflict.overlappingShiftEnds.format()} -
{conflict.shiftDate.format()} + {conflict.shiftBegins.format() + + ' - ' + + conflict.shiftEnds.format()} + + {conflict.overlappingShiftBegins.format() + + ' - ' + + conflict.overlappingShiftEnds.format()} + + ({ + conflictId: conflict.id + })} + data-qa={`delete-button-${conflict.id}`} + /> +
- +
))} - +

))} - +
))} ))} diff --git a/frontend/src/employee-frontend/components/reports/queries.ts b/frontend/src/employee-frontend/components/reports/queries.ts index 437a5a57882..de35ed308c6 100644 --- a/frontend/src/employee-frontend/components/reports/queries.ts +++ b/frontend/src/employee-frontend/components/reports/queries.ts @@ -7,6 +7,7 @@ import { Arg0, UUID } from 'lib-common/types' import { sendJamixOrders } from '../../generated/api-clients/jamix' import { + clearTitaniaErrors, getAssistanceNeedsAndActionsReport, getAssistanceNeedsAndActionsReportByChild, getAttendanceReservationReportByChild, @@ -253,6 +254,11 @@ export const titaniaErrorsReportQuery = query({ queryKey: queryKeys.titaniaErrorsReport }) +export const clearTitaniaErrorMutation = mutation({ + api: clearTitaniaErrors, + invalidateQueryKeys: () => [queryKeys.titaniaErrorsReport()] +}) + export const incompleteIncomeReportQuery = query({ api: getIncompleteIncomeReport, queryKey: queryKeys.incompleteIncomeReport diff --git a/frontend/src/employee-frontend/generated/api-clients/reports.ts b/frontend/src/employee-frontend/generated/api-clients/reports.ts index be5a065c25a..a05ed98f0a5 100644 --- a/frontend/src/employee-frontend/generated/api-clients/reports.ts +++ b/frontend/src/employee-frontend/generated/api-clients/reports.ts @@ -70,6 +70,7 @@ import { SextetReportRow } from 'lib-common/generated/api-types/reports' import { SourceUnitsReportRow } from 'lib-common/generated/api-types/reports' import { StartingPlacementsRow } from 'lib-common/generated/api-types/reports' import { TitaniaErrorReportRow } from 'lib-common/generated/api-types/reports' +import { TitaniaErrorsId } from 'lib-common/generated/api-types/shared' import { UnitsReportRow } from 'lib-common/generated/api-types/reports' import { VardaChildErrorReportRow } from 'lib-common/generated/api-types/reports' import { VardaUnitErrorReportRow } from 'lib-common/generated/api-types/reports' @@ -1021,6 +1022,22 @@ export async function getStartingPlacementsReport( } +/** +* Generated from fi.espoo.evaka.reports.TitaniaErrorReport.clearTitaniaErrors +*/ +export async function clearTitaniaErrors( + request: { + conflictId: TitaniaErrorsId + } +): Promise { + const { data: json } = await client.request>({ + url: uri`/employee/reports/titania-errors/${request.conflictId}`.toString(), + method: 'DELETE' + }) + return json +} + + /** * Generated from fi.espoo.evaka.reports.TitaniaErrorReport.getTitaniaErrorsReport */ diff --git a/frontend/src/lib-common/generated/api-types/reports.ts b/frontend/src/lib-common/generated/api-types/reports.ts index a7caedf8d20..1042727aeea 100644 --- a/frontend/src/lib-common/generated/api-types/reports.ts +++ b/frontend/src/lib-common/generated/api-types/reports.ts @@ -31,6 +31,7 @@ import { PreschoolAssistanceLevel } from './assistance' import { ProviderType } from './daycare' import { ServiceNeedId } from './shared' import { ServiceNeedOption } from './application' +import { TitaniaErrorsId } from './shared' import { UUID } from '../../types' import { VoucherValueDecisionId } from './shared' @@ -954,6 +955,7 @@ export interface StartingPlacementsRow { * Generated from fi.espoo.evaka.reports.TitaniaErrorConflict */ export interface TitaniaErrorConflict { + id: TitaniaErrorsId overlappingShiftBegins: LocalTime overlappingShiftEnds: LocalTime shiftBegins: LocalTime diff --git a/frontend/src/lib-common/generated/api-types/shared.ts b/frontend/src/lib-common/generated/api-types/shared.ts index c378d98fb55..6cdef7be566 100644 --- a/frontend/src/lib-common/generated/api-types/shared.ts +++ b/frontend/src/lib-common/generated/api-types/shared.ts @@ -253,6 +253,8 @@ export type StaffAttendanceExternalId = string export type StaffAttendanceRealtimeId = string +export type TitaniaErrorsId = Id<'TitaniaErrors'> + /** * Generated from fi.espoo.evaka.shared.domain.Translatable */ diff --git a/service/src/main/kotlin/fi/espoo/evaka/reports/TitaniaErrorsReport.kt b/service/src/main/kotlin/fi/espoo/evaka/reports/TitaniaErrorsReport.kt index 85f7ee61d21..1e7ccf3ff45 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/reports/TitaniaErrorsReport.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/reports/TitaniaErrorsReport.kt @@ -5,6 +5,7 @@ package fi.espoo.evaka.reports import fi.espoo.evaka.Audit +import fi.espoo.evaka.shared.TitaniaConflictId import fi.espoo.evaka.shared.auth.AuthenticatedUser import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.domain.EvakaClock @@ -13,7 +14,9 @@ import fi.espoo.evaka.shared.security.AccessControl import fi.espoo.evaka.shared.security.Action import java.time.LocalDate import java.time.LocalTime +import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.RestController @RestController @@ -38,6 +41,27 @@ class TitaniaErrorReport(private val accessControl: AccessControl) { } .also { Audit.TitaniaReportRead.log() } } + + @DeleteMapping("/employee/reports/titania-errors/{conflictId}") + fun clearTitaniaErrors( + db: Database, + user: AuthenticatedUser.Employee, + clock: EvakaClock, + @PathVariable conflictId: TitaniaConflictId, + ) { + return db.connect { dbc -> + dbc.transaction { tx -> + accessControl.requirePermissionFor( + tx, + user, + clock, + Action.Global.READ_TITANIA_ERRORS, + ) + tx.deleteTitaniaError(conflictId) + } + } + .also { Audit.TitaniaReportRead.log() } + } } fun Database.Read.getTitaniaErrors(): List { @@ -49,7 +73,8 @@ fun Database.Read.getTitaniaErrors(): List { te.request_time, emp.first_name, emp.last_name, - emp.employee_number, + emp.employee_number, + te.id, te.shift_date, te.shift_begins, te.shift_ends, @@ -100,6 +125,7 @@ fun Database.Read.getTitaniaErrors(): List { employeeEntry.value[0].employeeNumber ?: "", employeeEntry.value.map { shiftEntry -> TitaniaErrorConflict( + shiftEntry.id, shiftEntry.shiftDate, shiftEntry.shiftBegins, shiftEntry.shiftEnds, @@ -115,11 +141,29 @@ fun Database.Read.getTitaniaErrors(): List { } } +fun Database.Transaction.deleteTitaniaErrors() { + createUpdate { sql("DELETE FROM titania_errors") }.execute() +} + +fun Database.Transaction.deleteTitaniaError(id: TitaniaConflictId) { + createUpdate { + sql( + """ + DELETE FROM titania_errors + WHERE id = ${bind(id)} + """ + .trimIndent() + ) + } + .execute() +} + data class TitaniaDbRow( val requestTime: HelsinkiDateTime, val firstName: String, val lastName: String, val employeeNumber: String?, + val id: TitaniaConflictId, val shiftDate: LocalDate, val shiftBegins: LocalTime, val shiftEnds: LocalTime, @@ -129,6 +173,7 @@ data class TitaniaDbRow( ) data class TitaniaErrorConflict( + val id: TitaniaConflictId, val shiftDate: LocalDate, val shiftBegins: LocalTime, val shiftEnds: LocalTime, diff --git a/service/src/main/kotlin/fi/espoo/evaka/shared/Id.kt b/service/src/main/kotlin/fi/espoo/evaka/shared/Id.kt index 17321c56bd7..721e2b416bf 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/shared/Id.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/shared/Id.kt @@ -176,6 +176,8 @@ sealed interface DatabaseTable { sealed class StaffOccupancyCoefficient : DatabaseTable + sealed class TitaniaErrors : DatabaseTable + sealed class VoucherValueDecision : DatabaseTable } @@ -344,6 +346,8 @@ typealias StaffAttendanceRealtimeId = Id typealias StaffOccupancyCoefficientId = Id +typealias TitaniaConflictId = Id + typealias VoucherValueDecisionId = Id @JsonSerialize(converter = Id.ToJson::class) diff --git a/service/src/main/kotlin/fi/espoo/evaka/titania/TitaniaService.kt b/service/src/main/kotlin/fi/espoo/evaka/titania/TitaniaService.kt index abb4fefdbbb..64227f3303a 100644 --- a/service/src/main/kotlin/fi/espoo/evaka/titania/TitaniaService.kt +++ b/service/src/main/kotlin/fi/espoo/evaka/titania/TitaniaService.kt @@ -10,7 +10,6 @@ import fi.espoo.evaka.shared.EmployeeId import fi.espoo.evaka.shared.db.Database import fi.espoo.evaka.shared.domain.HelsinkiDateTime import fi.espoo.evaka.shared.domain.HelsinkiDateTimeRange -import fi.espoo.evaka.shared.domain.TimeRange import java.time.Duration import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -62,7 +61,7 @@ class TitaniaService(private val idConverter: TitaniaEmployeeIdConverter) { val employeeNumbers = persons.map { (employeeNumber, _) -> employeeNumber }.distinct() val employeeNumberToId = tx.getEmployeeIdsByNumbers(employeeNumbers) - var unmergedSameDayPlans = mutableListOf() + var unmergedPlans = mutableListOf() val overlappingShifts = mutableListOf() val newPlans = @@ -120,26 +119,17 @@ class TitaniaService(private val idConverter: TitaniaEmployeeIdConverter) { plans.add(next) } - if ( - unmergedSameDayPlans.lastOrNull()?.startTime?.toLocalDate() != - next.startTime.toLocalDate() - ) { - unmergedSameDayPlans = mutableListOf(next) + if (unmergedPlans.isEmpty()) { + unmergedPlans = mutableListOf(next) } else { // identical shifts are deduplicated later, ignore them here - if (next !in unmergedSameDayPlans) { - unmergedSameDayPlans + if (next !in unmergedPlans) { + unmergedPlans .filter { it.employeeId == next.employeeId } .filter { - TimeRange( - next.startTime.toLocalTime(), - next.endTime.toLocalTime(), - ) + HelsinkiDateTimeRange(next.startTime, next.endTime) .overlaps( - TimeRange( - it.startTime.toLocalTime(), - it.endTime.toLocalTime(), - ) + HelsinkiDateTimeRange(it.startTime, it.endTime) ) } .forEach { @@ -155,7 +145,7 @@ class TitaniaService(private val idConverter: TitaniaEmployeeIdConverter) { ) } - unmergedSameDayPlans.add(next) + unmergedPlans.add(next) } } diff --git a/service/src/main/resources/db/migration/V477__titania_errors_id.sql b/service/src/main/resources/db/migration/V477__titania_errors_id.sql new file mode 100644 index 00000000000..2e4f7a09741 --- /dev/null +++ b/service/src/main/resources/db/migration/V477__titania_errors_id.sql @@ -0,0 +1,5 @@ +ALTER TABLE titania_errors +ADD COLUMN id uuid DEFAULT ext.uuid_generate_v1mc(); + +ALTER TABLE titania_errors +ADD CONSTRAINT titania_errors_pkey PRIMARY KEY (id); diff --git a/service/src/main/resources/migrations.txt b/service/src/main/resources/migrations.txt index 08e47f4563b..9891bb0de7f 100644 --- a/service/src/main/resources/migrations.txt +++ b/service/src/main/resources/migrations.txt @@ -472,3 +472,4 @@ V473__person_municipality_of_residence.sql V474__holiday_questionnaire_open_ranges.sql V475__application_modified_metadata.sql V476__finance_metadata_process.sql +V477__titania_errors_id.sql