From 1ed2cc68b22010f164a3a7df6f9606df3c50de08 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Mon, 4 Aug 2025 16:55:11 +0800 Subject: [PATCH] Update Mail Service Update customer max code Update Last record Report --- .../mail/service/MailReminderService.kt | 122 ++++++++++++++++- .../modules/common/mail/web/MailController.kt | 4 +- .../common/mail/web/models/WorkHourRecords.kt | 7 + .../modules/data/service/CustomerService.kt | 18 +++ .../modules/data/web/CustomerController.kt | 6 + .../modules/project/service/InvoiceService.kt | 2 + .../modules/report/service/ReportService.kt | 126 +++++++++++++++++- .../modules/report/web/ReportController.kt | 5 +- .../modules/report/web/model/ReportRequest.kt | 3 +- .../report/AR10_Staff Last Record Report.xlsx | Bin 14640 -> 14551 bytes 10 files changed, 278 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/ffii/tsms/modules/common/mail/service/MailReminderService.kt b/src/main/java/com/ffii/tsms/modules/common/mail/service/MailReminderService.kt index ff38e1a..d425551 100644 --- a/src/main/java/com/ffii/tsms/modules/common/mail/service/MailReminderService.kt +++ b/src/main/java/com/ffii/tsms/modules/common/mail/service/MailReminderService.kt @@ -4,6 +4,7 @@ import com.ffii.tsms.modules.common.SettingNames import com.ffii.tsms.modules.common.holiday.service.HolidayService import com.ffii.tsms.modules.common.mail.pojo.MailRequest import com.ffii.tsms.modules.common.mail.web.models.StaffRecords +import com.ffii.tsms.modules.common.mail.web.models.StaffWithMissingDates import com.ffii.tsms.modules.common.mail.web.models.WorkHourRecords import com.ffii.tsms.modules.common.mail.web.models.WorkHourRecordsWithJoinDate import com.ffii.tsms.modules.data.entity.Staff @@ -18,6 +19,7 @@ import com.ffii.tsms.modules.user.service.UserService import jakarta.mail.internet.InternetAddress import org.apache.commons.logging.Log import org.apache.commons.logging.LogFactory +import org.springframework.cglib.core.Local import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import java.time.DayOfWeek @@ -58,7 +60,7 @@ open class MailReminderService( return false } } - private fun createHTMLTable(naughtyList: MutableList): String { + private fun createHTMLTable(naughtyList: MutableList, dateForRed: LocalDate? = LocalDate.of(2024,4,1)): String { val tableStarter = StringBuilder(" ") val header = StringBuilder( " " @@ -69,11 +71,21 @@ open class MailReminderService( ) tableStarter.append(header) for (per in naughtyList) { + val missDatesHtml = per.missingDates.joinToString(", "){ + localDate -> + val color = if(localDate.isBefore(LocalDate.now().minusMonths(1))){ + "red" + }else{ + "black" + } + "$localDate" + } + tableStarter.append( " " + " " + " " - + " " + + " " + " " ) } @@ -103,7 +115,7 @@ open class MailReminderService( mailService.send(mailRequestList) } - @Scheduled(cron = "0 0 6 7 * ?") // (SS/MM/HH/DD/MM/YY) +// @Scheduled(cron = "0 0 6 7 * ?") // (SS/MM/HH/DD/MM/YY) open fun sendTimesheetToTeamLead7TH() { logger.info("-----------------------") logger.info("Scheduled Start: 7th email reminder") @@ -153,6 +165,7 @@ open class MailReminderService( key.third // joinDate ) } +// println("**** timesheetByIdAndRecord: ${timesheetByIdAndRecord.filter { it.staffId == 1L }}") val goodStaffsList = filteredLastMonthDays.map { date -> val matchedStaffIds = timesheetByIdAndRecord .filter { @@ -196,6 +209,107 @@ open class MailReminderService( } } + // Function to generate a sequence of workdays, excluding weekends and public holidays + private fun generateWorkdays(startDate: LocalDate, endDate: LocalDate, publicHolidays: Set): Sequence { + return generateSequence(startDate) { date -> + date.plusDays(1).takeIf { it <= endDate } + }.filter { date -> + date.dayOfWeek != DayOfWeek.SATURDAY && + date.dayOfWeek != DayOfWeek.SUNDAY && + !publicHolidays.contains(date) + } + } + + // Function to find missing workdays for staff + private fun findStaffWithMissingDates(staffRecords: List, workdays: Sequence, staffList: List): List { + val staffMap = staffList.associateBy { it.id } + return staffRecords + .groupBy { it.staffId } + .map { (staffId, records) -> + val joinDate = records.firstOrNull()?.joinDate ?: LocalDate.of(1970, 1, 1) + val recordedDates = records.map { it.recordDate }.toSet() + val staffWorkdays = workdays.filter { it >= joinDate }.toList() + + val missingDates = staffWorkdays.filterNot { recordedDates.contains(it) } + + val staffInfo = staffMap[staffId] + + StaffWithMissingDates( + staffId = staffId, + staffIdinCode = staffInfo?.staffId ?: "", + staffName = staffInfo?.name ?: "", + missingDates = missingDates + ) + } + .filter { it.missingDates.isNotEmpty() } + } + + @Scheduled(cron = "0 0 6 7 * ?") // (SS/MM/HH/DD/MM/YY) + open fun sendMissingTimesheetDateToTeamLead7th(){ + logger.info("-----------------------") + logger.info("Scheduled Start: 7th email reminder V2") + + val firstDate = LocalDate.of(2024,10,1) + val today = LocalDate.now() + + val publicHolidays = holidayService.commonHolidayList().map { it.date } + val companyHolidays = companyHolidayService.allCompanyHolidays().map { it.date } + val allHolidays: Set = (publicHolidays + companyHolidays).toSet() + + val workdays = generateWorkdays(firstDate, today, allHolidays) + println("workdays: ${workdays.joinToString { ", " }}") + //Get all missing timesheet date and group by staff + val args = mutableMapOf( + "from" to firstDate, + "to" to today, + ) + + val ts = timesheetsService.workHourRecordsWithinRange(args) + val timesheet: List = ts.map { + WorkHourRecordsWithJoinDate( + staffId = it["staffId"].toString().toLong(), + recordDate = LocalDate.parse(it["recordDate"].toString()), + hours = it["hours"].toString().toDouble(), + joinDate = it["joinDate"]?.toString()?.let { date -> LocalDate.parse(date) } ?: LocalDate.of(1970, 1, 1) + ) + }.groupBy { Triple(it.staffId, it.recordDate, it.joinDate) } + .mapNotNull { (key, records) -> + StaffRecords( + key.second, // recordDate + key.first, // staffId + records.sumOf { it.hours }, + key.third // joinDate + ) + } + + val staffs = staffRepository.findAllByEmployTypeAndDeletedFalseAndDepartDateIsNull(FULLTIME).filter { it.staffId != "A003" && it.staffId != "A004" && it.staffId != "B011" }.filter{ it.team?.code != "HO"} + val teams = teamRepository.findAll().filter { team -> team.deleted == false } + + for (team in teams) { + val teamLead = team.staff + val teamMembers: List = staffs.filter { it.team != null && it.team.id == team.id } + if (teamMembers.isEmpty()) continue + val teamMembersIds: List = teamMembers.map { it.id!! }.sorted() + val filteredTimesheet = timesheet.filter { teamMembersIds.contains(it.staffId) } + val staffWithMissingDates = findStaffWithMissingDates(filteredTimesheet, workdays, teamMembers) +// println("staffWithMissingDates----: $staffWithMissingDates") + val intro = StringBuilder("${teamLead.name}, Staffs Missing Timesheet in the Table Below: \n") + val tableData = mutableListOf() + staffWithMissingDates.forEach{ + val rowData = TableRow(it.staffIdinCode, it.staffName, (it.missingDates).toMutableList()) + tableData.add(rowData) + } + val table = createHTMLTable(tableData) + val emailContent = intro + .append(table) + + val receiver = listOf(teamLead.email) + + createEmailRequest(emailContent.toString(), receiver) + } + + } + // Testing function to print the good staff list open fun test7thStaffList(){ val today = LocalDate.now() @@ -259,7 +373,7 @@ open class MailReminderService( Pair(date, matchedStaffIds) }.sortedBy { it.first } - println(goodStaffsList) +// println(goodStaffsList) } } diff --git a/src/main/java/com/ffii/tsms/modules/common/mail/web/MailController.kt b/src/main/java/com/ffii/tsms/modules/common/mail/web/MailController.kt index 406b493..385b3eb 100644 --- a/src/main/java/com/ffii/tsms/modules/common/mail/web/MailController.kt +++ b/src/main/java/com/ffii/tsms/modules/common/mail/web/MailController.kt @@ -37,7 +37,7 @@ class MailController( } @GetMapping("/test7th") fun test7th() { - mailReminderService.sendTimesheetToTeamLead7TH() + mailReminderService.sendMissingTimesheetDateToTeamLead7th() } @GetMapping("/test15th") fun test15th() { @@ -46,7 +46,7 @@ class MailController( @GetMapping("/test7th-staff-list") fun test7thStaffList(){ - mailReminderService.test7thStaffList() + mailReminderService.sendMissingTimesheetDateToTeamLead7th() } @GetMapping("/test15th-staff-list") diff --git a/src/main/java/com/ffii/tsms/modules/common/mail/web/models/WorkHourRecords.kt b/src/main/java/com/ffii/tsms/modules/common/mail/web/models/WorkHourRecords.kt index 10691ac..b8ab393 100644 --- a/src/main/java/com/ffii/tsms/modules/common/mail/web/models/WorkHourRecords.kt +++ b/src/main/java/com/ffii/tsms/modules/common/mail/web/models/WorkHourRecords.kt @@ -21,3 +21,10 @@ data class StaffRecords( val sumOfHours: Double, val joinDate: LocalDate? = LocalDate.of(1970,1,1) ) + +data class StaffWithMissingDates( + val staffId: Long, + val staffIdinCode: String, + val staffName: String, + val missingDates: List +) diff --git a/src/main/java/com/ffii/tsms/modules/data/service/CustomerService.kt b/src/main/java/com/ffii/tsms/modules/data/service/CustomerService.kt index a0df3dc..39da26f 100644 --- a/src/main/java/com/ffii/tsms/modules/data/service/CustomerService.kt +++ b/src/main/java/com/ffii/tsms/modules/data/service/CustomerService.kt @@ -103,4 +103,22 @@ open class CustomerService( return SaveCustomerResponse(customer = customer, message = "Success"); } + + //Auto generate the latest client code + open fun findMaxCustomerCode(): String{ + val sql = StringBuilder("select MAX(code) as maxCode from customer where code regexp 'CT-'") + val maxCode = jdbcDao.queryForString(sql.toString()) + val regex = "(\\D+)-(\\d+)".toRegex() + val match = regex.matchEntire(maxCode) + + val next = if (match != null) { + val (prefix, numberStr) = match.destructured + val nextNumber = numberStr.toInt() + 1 + val padded = nextNumber.toString().padStart(numberStr.length, '0') + "$prefix-$padded" + } else { + maxCode + } + return next + } } diff --git a/src/main/java/com/ffii/tsms/modules/data/web/CustomerController.kt b/src/main/java/com/ffii/tsms/modules/data/web/CustomerController.kt index 19c0001..df1cd86 100644 --- a/src/main/java/com/ffii/tsms/modules/data/web/CustomerController.kt +++ b/src/main/java/com/ffii/tsms/modules/data/web/CustomerController.kt @@ -8,6 +8,7 @@ import com.ffii.tsms.modules.data.service.CustomerSubsidiaryService import com.ffii.tsms.modules.data.web.models.CustomerResponse import com.ffii.tsms.modules.data.web.models.SaveCustomerResponse import com.ffii.tsms.modules.project.web.models.SaveCustomerRequest +import jakarta.servlet.http.HttpServletResponse import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -56,4 +57,9 @@ class CustomerController( fun saveCustomer(@Valid @RequestBody saveCustomer: SaveCustomerRequest): SaveCustomerResponse { return customerService.saveCustomer(saveCustomer) } + + @GetMapping("/getMaxCode") + fun getMaxCustomerCode(): String{ + return customerService.findMaxCustomerCode(); + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/project/service/InvoiceService.kt b/src/main/java/com/ffii/tsms/modules/project/service/InvoiceService.kt index ea4754a..d1bffcf 100644 --- a/src/main/java/com/ffii/tsms/modules/project/service/InvoiceService.kt +++ b/src/main/java/com/ffii/tsms/modules/project/service/InvoiceService.kt @@ -609,6 +609,8 @@ open class InvoiceService( if (workbook == null) { return InvoiceResponse(false, "No Excel import", ArrayList(), ArrayList(), ArrayList(), ArrayList(), ArrayList()) // if workbook is null } + + // find the imported Invoice Records val invoiceRecords = repository.findImportInvoiceInfoByAndDeletedFalse() val importInvoices: MutableList> = mutableListOf(); diff --git a/src/main/java/com/ffii/tsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/tsms/modules/report/service/ReportService.kt index 3206391..1ea7ad9 100644 --- a/src/main/java/com/ffii/tsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/tsms/modules/report/service/ReportService.kt @@ -1,7 +1,12 @@ package com.ffii.tsms.modules.report.service import com.ffii.core.support.JdbcDao +import com.ffii.tsms.modules.common.holiday.service.HolidayService +import com.ffii.tsms.modules.common.mail.web.models.StaffRecords +import com.ffii.tsms.modules.common.mail.web.models.StaffWithMissingDates +import com.ffii.tsms.modules.common.mail.web.models.WorkHourRecordsWithJoinDate import com.ffii.tsms.modules.data.entity.* +import com.ffii.tsms.modules.data.service.CompanyHolidayService import com.ffii.tsms.modules.data.service.SalaryEffectiveService import com.ffii.tsms.modules.data.service.SalaryEffectiveService.MonthlyStaffSalaryData import com.ffii.tsms.modules.data.service.StaffsService @@ -10,6 +15,7 @@ import com.ffii.tsms.modules.report.web.model.ExportCurrentStaffInfoRequest import com.ffii.tsms.modules.report.web.model.costAndExpenseRequest import com.ffii.tsms.modules.timesheet.entity.Timesheet import com.ffii.tsms.modules.timesheet.entity.TimesheetRepository +import com.ffii.tsms.modules.timesheet.service.TimesheetsService import org.apache.commons.logging.Log import org.apache.commons.logging.LogFactory import org.apache.poi.ss.usermodel.* @@ -58,6 +64,10 @@ open class ReportService( private val teamLogRepository: TeamLogRepository, private val teamRepository: TeamRepository, private val staffService: StaffsService, + private val holidayService: HolidayService, + private val companyHolidayService: CompanyHolidayService, + private val timesheetsService: TimesheetsService, + private val staffRepository: StaffRepository ) { private val logger: Log = LogFactory.getLog(javaClass) private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd") @@ -3478,6 +3488,7 @@ open class ReportService( if (teamId != null) { sql.append( " and p.teamLead = :teamLeadId " + + " and p.deleted = false " ) } @@ -5729,6 +5740,16 @@ open class ReportService( val team: String, val lastRecordDate: String, ) + + data class StaffLastRecordDataV2( + val staffId: Long, + val staffIdinCode: String, + val staffName: String, + val email: String, + val team: String, + val missingDates: List, + val lastRecordDate: LocalDate + ) private fun getStaffLastRecordDateByDate(date: LocalDate): List{ val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val sql = StringBuilder( @@ -5760,7 +5781,7 @@ open class ReportService( val currentDate = Date.from(LocalDate.now().atStartOfDay(ZoneId.systemDefault()).toInstant()) departDate.after(currentDate) } else { - false // Exclude entries with null departDate + true // Include entries with null departDate, null = not departed } } .map { @@ -5777,16 +5798,100 @@ open class ReportService( return results } + // Function to generate a sequence of workdays, excluding weekends and public holidays + private fun generateWorkdays(startDate: LocalDate, endDate: LocalDate, publicHolidays: Set): Sequence { + return generateSequence(startDate) { date -> + date.plusDays(1).takeIf { it <= endDate } + }.filter { date -> + date.dayOfWeek != DayOfWeek.SATURDAY && + date.dayOfWeek != DayOfWeek.SUNDAY && + !publicHolidays.contains(date) + } + } + + // Function to find missing workdays for staff + private fun findStaffWithMissingDates(staffRecords: List, workdays: Sequence, staffList: List): List { + val staffMap = staffList.associateBy { it.id } + return staffRecords + .groupBy { it.staffId } + .map { (staffId, records) -> + val joinDate = records.firstOrNull()?.joinDate ?: LocalDate.of(1970, 1, 1) + val recordedDates = records.map { it.recordDate }.toSet() + val staffWorkdays = workdays.filter { it >= joinDate }.toList() + + val missingDates = staffWorkdays.filterNot { recordedDates.contains(it) } + val workDates = staffWorkdays.filter { recordedDates.contains(it) } + + val staffInfo = staffMap[staffId] + + StaffLastRecordDataV2( + staffId = staffId, + staffIdinCode = staffInfo?.staffId ?: "", + staffName = staffInfo?.name ?: "", + email = staffInfo?.email ?: "", + team = staffInfo?.team?.code ?: "", + missingDates = missingDates, + lastRecordDate = workDates.last() + ) + } + .filter { it.missingDates.isNotEmpty() } + } + private fun getStaffLastRecordDateByDateWithDayRange(date: LocalDate, dayRange: Long): List{ + logger.info("-----------------------") + logger.info("Report Start: Staff Last Record Date By Date With DayRange") + + val today = date + val firstDate = today.minusDays(dayRange) + + val publicHolidays = holidayService.commonHolidayList().map { it.date } + val companyHolidays = companyHolidayService.allCompanyHolidays().map { it.date } + val allHolidays: Set = (publicHolidays + companyHolidays).toSet() + + val workdays = generateWorkdays(firstDate, today, allHolidays) + //Get all missing timesheet date and group by staff + val args = mutableMapOf( + "from" to firstDate, + "to" to today, + ) + logger.info(args) + val ts = timesheetsService.workHourRecordsWithinRange(args) + val timesheet: List = ts.map { + WorkHourRecordsWithJoinDate( + staffId = it["staffId"].toString().toLong(), + recordDate = LocalDate.parse(it["recordDate"].toString()), + hours = it["hours"].toString().toDouble(), + joinDate = it["joinDate"]?.toString()?.let { date -> LocalDate.parse(date) } ?: LocalDate.of(1970, 1, 1) + ) + }.groupBy { Triple(it.staffId, it.recordDate, it.joinDate) } + .mapNotNull { (key, records) -> + StaffRecords( + key.second, // recordDate + key.first, // staffId + records.sumOf { it.hours }, + key.third // joinDate + ) + } + + val staffs = staffRepository.findAllByEmployTypeAndDeletedFalseAndDepartDateIsNull("FT").filter { it.staffId != "A003" && it.staffId != "A004" && it.staffId != "B011" }.filter{ it.team?.code != "HO"} +// val teams = teamRepository.findAll().filter { team -> team.deleted == false } + val filteredTimesheet = timesheet.filter { staffs.map { it.id!! }.sorted().contains(it.staffId) } + val staffWithMissingDates = findStaffWithMissingDates(filteredTimesheet, workdays, staffs) +// logger.info(staffWithMissingDates.sortedBy { it.team }) + return staffWithMissingDates.sortedWith(compareBy({ it.team }, { it.staffId })) + } + @Throws(IOException::class) private fun createLastRecordReportWorkbook( date: LocalDate, + dayRange: Long, templatePath: String, ): Workbook{ val resource = ClassPathResource(templatePath) val templateInputStream = resource.inputStream val workbook: Workbook = XSSFWorkbook(templateInputStream) - val result = getStaffLastRecordDateByDate(date) +// val result = getStaffLastRecordDateByDate(date) + val result = getStaffLastRecordDateByDateWithDayRange(date, dayRange) println(result) var sheet: Sheet = workbook.getSheetAt(0) @@ -5812,7 +5917,9 @@ open class ReportService( rowIndex = 2 sheet.getRow(rowIndex).getCell(columnIndex+1).apply { - setCellValue(date.format(DATE_FORMATTER)) + val startDate = date.minusDays(dayRange).format(DATE_FORMATTER) + val endDate = date.format(DATE_FORMATTER) + setCellValue("$startDate - $endDate ($dayRange)") } rowIndex = 3 @@ -5830,9 +5937,10 @@ open class ReportService( val emailCell = row.createCell(2) val teamCell = row.createCell(3) val lastRecordDateCell = row.createCell(4) + val numOfMissingDateCell = row.createCell(5) staffIdCell.apply { - setCellValue(it.staffId) + setCellValue(it.staffIdinCode) cellStyle =blackFontStyle } @@ -5852,7 +5960,12 @@ open class ReportService( } lastRecordDateCell.apply { - setCellValue(it.lastRecordDate) + setCellValue(it.lastRecordDate.toString()) + cellStyle =blackFontStyle + } + + numOfMissingDateCell.apply { + setCellValue(it.missingDates.size.toDouble()) // count total number of missing date in list cellStyle =blackFontStyle } @@ -5864,8 +5977,9 @@ open class ReportService( fun genLastRecordReport( date: LocalDate, + dayRange: Long ): ByteArray{ - val workbook = createLastRecordReportWorkbook(date, LAST_RECORD_REPORT) + val workbook = createLastRecordReportWorkbook(date, dayRange, LAST_RECORD_REPORT) val outputStream: ByteArrayOutputStream = ByteArrayOutputStream() workbook.write(outputStream) diff --git a/src/main/java/com/ffii/tsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/tsms/modules/report/web/ReportController.kt index 2ea4633..3d4477a 100644 --- a/src/main/java/com/ffii/tsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/tsms/modules/report/web/ReportController.kt @@ -387,16 +387,17 @@ class ReportController( val reportResult: ByteArray = excelReportService.genProjectMonthlyReport(request.yearMonth, request.projectId, request.holidays) return ResponseEntity.ok() - .header("filename", "Project Monthly Report - " + LocalDate.now() + ".xlsx") + .header("filename", "Project Monthly daily Summary Report - " + LocalDate.now() + ".xlsx") .body(ByteArrayResource(reportResult)) } @PostMapping("/gen-staff-last-record-report") fun genLastRecordReport(@RequestBody @Valid request: LastRecordReportRequest): ResponseEntity{ println("================= ${request.dateString}") + println("================= ${request.dayRange}") val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val date = LocalDate.parse(request.dateString, formatter) - val reportResult: ByteArray = excelReportService.genLastRecordReport(date) + val reportResult: ByteArray = excelReportService.genLastRecordReport(date, request.dayRange) return ResponseEntity.ok() .header("filename", "Staff Last Record Report - " + LocalDate.now() + ".xlsx") diff --git a/src/main/java/com/ffii/tsms/modules/report/web/model/ReportRequest.kt b/src/main/java/com/ffii/tsms/modules/report/web/model/ReportRequest.kt index f7a687e..29bd339 100644 --- a/src/main/java/com/ffii/tsms/modules/report/web/model/ReportRequest.kt +++ b/src/main/java/com/ffii/tsms/modules/report/web/model/ReportRequest.kt @@ -79,7 +79,8 @@ data class ProjectMonthlyRequest ( ) data class LastRecordReportRequest ( - val dateString: String + val dateString: String, + val dayRange: Long ) data class ExportCurrentStaffInfoRequest ( diff --git a/src/main/resources/templates/report/AR10_Staff Last Record Report.xlsx b/src/main/resources/templates/report/AR10_Staff Last Record Report.xlsx index 37601e1df6253afce1af2fdea99b99aca5d9201d..f2b81411b328344e88377c1ce6885020c3251fa1 100644 GIT binary patch delta 5844 zcmb7IcQjmIyB=foj9x;NQO5|u1f%ydMhlW?A)<>GqDvel(FsP2mSD7K(Fsw4Xwjp$ zXu(g5-XgB#`@Z}8?p^n;yYBhpIcq)Z-OqmB^X{|vyY_pxao6`#gb<~t4!zhpLKeWu zB}bg{m;dkuQM(r@k39JC2U7MmKG7oHzUo>%T#vB!E}f8V8qetC%kvm^!9E+09uEp` zv6|4%fiKdQ4}qzqm%EskR*g)lYdykHW2(&ZzkKX|)uT%S?a?obt}!%bEA2;P_DA}5 znoV8myp3FcS%BoZLv2d$vP{-+NeBStjf%oqYk8Cx|m%nEL^&!M(B4 zCW8ifPrc2#gD5IU$qienu1PdUg~%J1+~**kBO3aNj~Z{RH>(VibsrXeVhViD?Z5ko zi6H55ys%1T)?_IA{NNJAn>$s~*$}U28|MCeq)DR7hsPXL&qfy!L3&g2dm^}StKjlo zvGJyBze4-x^j{-F3R#}=ir>hAsuX2!Zd=0@RnCYkiI}7h{Z`Q=rnk29kH<){493

NN-q;1-W;T){!XtKX~*iy4>pmAaPm0lqxAx>9k~!$3XVIkFig?)D8<#gb`%%pgZ?EpqnWB|jr&q0szLFURZGA1Dk?b{8f#xP2oP9u*&> zOZqA|jYjoI3bUm^`gLj!C@|iA^9pro}_J);#&prK@TU0rgfHOCPdRQ2;^+>p2xKDz0$m4T*-%VCI@Z)> zN{BXO2fihz+yoEbhl1Y~)0UuUjHkDz#0iUeTy4_8X-D&3j_Bb+x*$wIv|Q#s>zm^! zm!o^@jY5TW1#sd9SvNy1mDY_zh>T1>5u;lIkB0(Aw~|Cs=%sVs8@XsNMcE>##~p|? zk`E<@C9f#EG1&^uQ$JLzt0K&Tio%}FiQ)6TBc#+Yb9?^&&hlM%_vX@WVYh;bO z*6Y|Ej#>`%D`Wd3Hq^=k2!8PG7U?~6Q3?Lc&dh7 z!N18kdQJJ!@=y9~-?um`U_Q!H!*D-~A*wf7Q95no_{kvZ)21j*_?^Co69P9EPWS^= zPpvK6s<1Yux*4<+ z$y+dU2wC7RgycCYa*4M)Dv91L36F{&|8r8@2iLzsCzgGvI3(H4rP+}v(nXO5X1uIo z((5{5u#aLi1r^pU6G$Yg6o0(Et97 z4}_;gy~0b=O$<5%q_%4sV|<;SM^U{BKag!*A2l}4|2D@Lcxe{hpTb&e)~cRznBIhv z8c5ZybvmX1fi5pWzab%Q+Id=)Rt4zzLG*Q~uf9c?5_`MK9@~F$WK&1Dmq70tTLV1Q zd#bs0J5snKpS~z<)6PCyB27jFo0xJ;6fKM!4GM?RbxCv)pi7nu{C0x_t!=*T8qDQO84Ro>%5|HaQZkzP zaAb@T0eV4@SYo}v^xy{pUt!5G0~i!d<_6tkT84I+#CpMY6l4RQK~5oijLT$Q2C)WZ zwDo-zsntej?lneZlQxVTZ8qx4D6+rjgSwHp5ex@Z!Ny@_3UdLC1U^K2tjqXaYOy-7 zHwsMw&+aXHe)b*fUmKb1_fFaZqS%p}*Eox2JBk0F8aBT*W``sjJ8ele=MFdm51kp7 zVau@ZZ!zY#XO^S`m+&4ezt#S>9$TmRZ4y-KHn{v&>zfY{uJ10bq5Ee0*AMqkq3eKU z+{HpU`uOq&hXxyhfB3>O%<&nHIh^?%eLYXPfh0MAvj&KqHGp0ZnWzC!UYg zAFTivH5Ac@tRK5^1v<^rGfsn*%(Nu@o{<6yV6>)H?LZNvT@P%5Ao92Zyz+!#9wH}j z)q!JDuIv{7%3HL&Kp-SG1qd_5$n$f!9#mn1d$5( zF$q^dyYe5g-#U_45MbD&fDIPIGd6a=_c$caX~qYPh$=v2%hN)``8h3m!`8rs(P2WI z7T0ii_R&%3-rfymdo8*^I+; z=7}OXN_NDiPT;s)KHRXI9zV6~Z~$0;C>EnJURb4k*I4^;!z+viV`38abE%#^;x2L6Q|3zCDf0&6ePOMChe|V-yN&x=SI$h+(vR4`B??gp3kCSxm zQ+5P$hJ_YPRC6DFuKf}wlCgIVut>U6nt$1uIeAnfw1{&Zw&i2ev%h}yJ8=7V;J4Z+ zOk0=BYbtxmgB^CL{w3J5nF<~qEcg3!v~9XDCWfrfBl)2F&DM9Bm;$cM+7nGEF=6B{ z>t;uTK80aFv$ot7!42Ob;LjPpL--qregiSTlKn;UO`vUELcr;US2PGGlsy~<))nCz zj_O*sSH=Z?RaH)J>)>`Me3ioPm|%%O*U8RySugzZ^h@J{fA|6`4urV+Pa!~| z{A14P@fBcP*k#J>&=utr{Lk$(k-GW2Pun(W+buNSKQ>9ku0tlup;X8r=xBi>2lYP>#ifKdj8$9dW3sG#*cXbJoEJ|FfT88M2YF$RBs47;x|N zUaRR*C!v<-K_o4d>v@^5CXWWf2!Q8Z7YJ7xZH zL?luJ%~mpaS0w(E=um-wk{bp`#K*8d!`1!^z59INcdIeoZ{ zBs+aYJYE?S?&NzQRywE|?dNblhF3IMrz-4`=H%-rdIPnrV>8 z(~SN=_w>Yw#@uHaqVy|jV999)TYg0;30_e*QycqsJa<*ST#sd?&1h=Yhfp&zIw_6O zP^o9Sm7crby&6uu$8OE#%|=R2T}0M0g=fx*|7v5Pyizc1id=Ey&VOgy8Ad< zKfUGcQ%hRPn~Uh=3<14FlOC*{r-k5{0yvB}joB9i+V)2OHYH9C9Z@wX_upQO42 zRu_g??t~ZX*Wn{EKSy7eiC80Y6p-ZEA_^=%;mA|m`L5bl)GbbTNu@9hjH6p}FKXCF z<6h;HkVTl3_9|~63Y*Z5_mchfyUFyo=IQ0H7SW<2eY#5^ctz}BM4P1IVt};SmqelH zRlE*P=0_ivE36RYCdc{em>^xqou{(^sUl*W#R4sX5L>8p*`!&D#TiLvb;1m^;55wH zsD%&jiU=PeNVs<3>b3X!>TrJAM+sJi5L5H-vh~y2^yO?LnxrEtime$N2=&H#x$Ukb zgvKXCu=_O|$>#k+_k+oyIL~7HRj-}$g&e7lc242lZd7E#oPPU2H_5}J(^J6IXsc#) zG;{WdORJ~E^gVSYu;Ci-pLVjD*wS|2V|tQiog>U#)fV|l1QV{z?hK-MqaJiUoURP; z**#_xG`l<-X>PvwGNv7LT}@-R-<(Ez?tY}@wYk-Tb}<97r45&ABXKe`A-iBpT0aqC zi?#R0KL~HoXgU7WM>n4$1 zbj&IaNTGmh6Uw#FMs+InXV=k1tCcIvdV+Wn43ll+PDV1^t$L`<9wNT^_tUJoR@c-p z3QCT)!lO=Vc8cuM#F_mQf?BITyb|jpTb+cz40bI!RIK0dFhP)%lIqfCAXl0)i??ioBTjLO zvroh;Uzqdvv$m=isXgKp22DGibxoY;)Xe4jb%uc| zjY?_1tPaHw$%XQ(1^^Arhd#v#OJ;#N>Aq;a2*n;3UZ$tJJQoM}>$OJPj}G*UhF}&s z)bxbC`Lj!kJ#Z8J_Bp$j(Q)(e2(`%zi}AXl5@gs7kJ@uLs#lb|r}yG#DV@o(kPPQ1r=thq^08r zvXl4wQdMx8?nE}a1&k7!F`MrYTO@*NES^Pfe$)VMx>4}L=F-!NBuZVLn%47pIM*qR z+l2PE9*o_t%m#!T1kE0KXF0^&4#<1b=ER%i^cXX+aRbwAFX3A$ zC*ow_*K@9_BI-x-(kj6si=>`@x*=MkeVi2~gH$J3%h)alE>sr~zA2S>B*?_JbC=gyyo+Q#kf*C&gx3Ty( zWrz8X<-7KAD00xZvJ(hdq zTUPs+157)%nPSE&J+yYUv_kKYq+&*dojPhgEZ2~R&j*fLbBiwF%O5`4>YM6&m{zSa zdN-)JMscLmHt|W{%^-lc{vhQ3`ibW8X&H7K_;YJOTIwQ;W5)x5H1V+l+&2JhpLUzj zway)Mqf&yD1|CUP;THLl64pHRvo~m@&H?xNR(RyZG`5r`Xu~&RabSMlb+4n=Wt1Ug zErs;COmCIqy9voDhO_6#?jB|LXklamH1uZyx`I8uky9Y4*fGncI$kN6mQwJwc0~25fhCkR zfBDrl!C2MGj)-8bPLN#gclBR{!Io0*Wc6n`y~ni`CfQFArvTCI#sw~BdU`u}wB_tM z#kB~B!@n#v&;4hL_ER*^peq!hN?jgpQpgg5YPB*)NE8o99~<<1TM;ZySaN#%NS;P|2x%_;8xm&HG!)&aT;kt=^VvaZmFQ}us;46<>*$Prq*PKN^Xw+CC zhTME8yRNazTd7{`hod|G=X>V(I(kWaJZdJZOcCWPCb3^so3ftOjj1A6lWl@;e3qLe ze4^MTn)EHj&OBy;{JIz?6-!*ut1cVczgS`7AxvDnwyw+_C;1yK==0S;+1Dc0E9B9@ zh^|);{WIduqq+N*UUXLC%kLQO{!zRF%t-rX3b&on>y;+ebGn~a^!5U7vHNpgy} z1yg)cJT75oSUiJLCm{P+_L8at^=F+`a^5z<|HqYakXRt;-&a&vDC#~QKh_Gx1OM+G z#8ngV&*um0zx5MXVKG|fzn3itMEkF>Kkp(g>;g(2{EHjQDXs$s!Lc521U5sQ@=xKg a<>K^s;@B>6S#To~ODw@bm?rvr)PDfHIK5o} delta 5954 zcmaJ_byQUA`W{+hDCrz>2$7PZr5mKATWN-n5+pWV(u05q3^^bm2uR8S=@yh2I;2HF zX++=$$8+vI>#lo$`;X__``zz)zBiurt#7^PCvQF9&fsFlOrppT3%HDcjeDUO=|%A8 z3%stUaatjy-@rT*Gw@dPM^mB_23K(W)Ik-AztkHq9+>8Q;7uglq^^dA`_ zkNN394r4S#F|3jk9Z=2lZuetm~Csn-R7r(Rr1Ev?-?iZN01$(h8?kw_WkQR9mTZ@udcUP80+>r;$CuD(Shzk2(+PT8^F0|zwbQMRwLTi1D_ z@eJoKAiO6bYfS9|Kke=jeRi(s?!=zCUNo}1;qrQxWNOfgR7jolk<|->=hFQ& zF~(-2KvuSXDPuBeBY?%H7Fa{&#VY(}x!V|d%4^_Iz=?0cx94!ulf};ePTeRB%4;Ca z3$g}lml~^?PU_%)?{OM#H2*`NOPoLYDo;1Hl=LNOdg8N_%f~Svuy#)*vNbB(&t1Lo zni`>1$+UeyGyj~~Eomkx1N@U8W9SsoLsU9Ft>rf&Fnwew8DPom&qwm>AY9xf<&wBSWCPju^~F!ty`DLxpar= zfX8mO+Rd6U`@-lJ(%ht$J_Zp*g!ParH%t&32rl7aR*jDLdZ9n;jkhtUU+m$nh zD0_ayE6hC*A|H#QHjY3TlK?1m;3*>nfdXU^y+q7Fnw9&E07dsAbVpJ#7+QV6jQw!h z^xdva*`#j(ED@tzEqfaE0Tpy&K~K)(Qzp;xfyFoY#2mSzS#|8QChb)m3Ac|qrSWgE zsi7Et7RAI&Rmk8!Gm0vD0Y4>TY*aQnNMc4wzRo?2@>;>j|0MiQ5S4=+YEN&ezcZLF zDo_igmZ5Mj%Y5?8x}CO|%E}>KVqj+;I&XH(andOBH`puaR9BaUOg6dxy_mMn>19tHgvE6vdf^-w<>g05k#z6t^^a@5bui*M7Oe6%zZr5cxu7VX z9vYp33O?w>_R(Kl+a;vlPQajzMYcx_P8Ug;dksTQ^SYvsMi~*5hd6DPeBbb+mOlSM z|5QZ`)FH(ZyW-;Y)``>l)~qwzHUblXwnc}W5M!?3liSE>r-}`~NhjXt#NrXdjb~&# zKoF^P9gE*0Ly`*Qk~T3%tEH2u$a0rp_S|M2y1Tg^9upOT*r->a%*caXi z%>0==O&uu-y#j$iSHB_c)vVi`GL;h0J&G4UCpwEyf#qARt~aQ)SmhqoE1N46-q*(t z$k?Je1C00vD3IiL-1SO~GWf=5^CUb10cElwFCD#UOH~Gx@z3H z+(RF&)Z(>Wo$kQrf%Z0|*{a3hixZjQlUeu0tIefO;LTG9ZT{9HYU%B&ua$nD$9d<7 zS>mfBZ?7kp=&|Pq?r~DgY#Xc?MHsOV{8jQ*f>k=(z15b0ABCYlc1s&oE!t13T7*Sf z_oF9xnb}r&sn6En5U?t?8YBZ^1VKXrlIfGFA$ZADs~mj*Z!#L(pTvFay^M-hkhL+@c3?o(YtyI~&a+R``a>o!+2)`^bgs)E{ z*$7f4w-C~T@g<#bSolie8=XVP zl!E*77i(J)Nz#4>7i+pj=l+jvEtJDp=YgKS%J_T0?GzO|_6Leg87XOOB`4cLSO{cj ze^vYazt((QSKPG4VWI9@rw16N+D|tQmbXH}uu~?sV!{-Y(_;MxK1kVF=Q_U~`Mr9S z%Duvn_8Hs7)DCJs#3ol9x_w>vmzgC*L;ASuuW`@+TBGUS(T9*t zM0;I7%9o>tRTaYJ&X&J^h1OvbV0pT#=jhn-;5XF==C}-Y)lJw|oj2BJKXDZi-z?Kd zUHeb&-D}+%0UQT;b@IVn6%pq2|I42Toi2QP=4U7nTHnGTKk0K*($ zB`x^>eLJ$uIzoQE`>(s-#Gwf|n$>e0TCu*qKgw~V+W(r*O=h=e(p_(IRtzm=iBnW7 zJ=gI$b=_YJdEnkY5%QodsD*QeFz4Nz7+i3Qke8|>8zxE&9!fa4kHqURj?f0p{1^}_RgsTI?h z#e9jR%%2^&qjOeaJWkl~)zk8}j&&JJ91f3*>G($dSL;8&fjxfk~9d-n6S))=q07VBJNp@JCii2l2HEu9@~*g{%-ia3msIJF`tdRj9sj~ zOFfDQNU#)GXW)Q9xpW{9`1%sz2j}LbxPrY=CeO|Mj8?7GdiPI&y<&>* zo#*L%qD`vVJIKAo*6A4)e+D&&7+WzdiJD-bw4YqP!#f_~q;mPS%P6ZR?GuC+Pd6(s zu|@G^;P}O9p|5fVnM5v&A_r-TEu+8kR!ec2iI~EZWJZ)%n(QDu#}nCnRg^Of%0=S< z*I{@ag%WpWSW=`SH+^EPqRNn5^1O*0^+PJpFM>@o!#w>?x6StQJvB9fIFNItnH(3e z)~rCp4ee%cInMEa#E^PV1A0ihTDD~yrf_)I+{kAFa)%>yb)J2zzKnLmd!#=x_0Eet z;h{O`)7gI3_`?i(`5PV z$6PfBIcyOL1Q|+Kot@wbmY<&%d6(*;-}zv9+m+wZF&cS!vEwlLl)OvKG!7PS@ZHbM zCsEK3Sx7!+o*94A_Q`hn4um$M1>`QlOasbTe&b+&5nC~%5 z8bRf|>pe59BP{cDjFPN;J3(0HX!4HXbh*kUE>lD(hV2KQX4fUHIMpu-a;8DZI~&Q* z@O4`Hm=@eM_PFuWn{AA6O9MOH0@6|i?{MRQt6wS^G%)7lM}CL+Nj$!~m(#h&RZQX7 zFw3-Joy95FwL6fES6?EkKq~G zXb9xbtu?<%!#0ZKic5UyX{9~sTPxQcsF&|%oNXd6K;?j3Dw-&Ho5B^c0|N`eh`-je z5SFOLlP|#(xrk{cqjeO+v=91msvpskS;Gcjgq=y53jN$jR>JqlD83IQy{MVv^++>F zQd8GeJtJ0ne0QM;*$TaY4bCS8JB!dM!XI{Yy!DMEi}KesJ`#P!UQ|@YtQrIpye9jtY+1$F zGFX_UqyxkhwIV|9jtu|_*4okQCST=6-#CNtV3mz|yJYIvI&I+(y3abZf(3D`c;d{AuY##OS!^V zbK--U_o!#a|H(pp0~3N$6<*?f#sqY_!L^nxO}b7dXpU>p?}PL$ z$nA|726jF1I!GRPJS@vD8OcGRH+W*_OT1UnK=9Sb^F_iaJdFe@u>vs@=Cr%akWfkV zbNL6RghbH&y}fFg}?!?=JbR-47jHSpEbLOa5dy}r>|o?8rgrb z>|sr;rOkYrILMK)Vg7wejNkqKDL1mlB`_FTbIjuB7|+=buoghKdlJoqfzTd5vhehB z%cLJB>P<1%7MVvG+7fMpYEw;bLu&$JW<64)hKqlHr~Kx_bo&%e{dsQ8xn7VH6o%R=32? zWtksg>L<^~oZfteaWj_ytXINMNijg6Q%n$u;;+-&(Z zp3Nkps!V}!6J54XYbzib`;Gp~)B@_K-a)AMP@-1m@ET5{$A>_;c*`;umF~BZ_|A2t z8G!ZYFo%d!f&d$S_`#-DT|fi_n5xL%k+s8n0x{NYiCmpEgr}z3`ktSlb_^vcSTfdw z4i5WOYS!c?+o@^HoL&@)v2uaB>q^IADphMdn!47E`e75Ma*2u7pK2Og2mBP8VBg{1 zSUm>TSlW8&`XGYQm53<{t_&?7)pCVXRLAV zzo%dWLS|UMN7LM}(0`7@-oXqpk`RQVoDBbYow(NIUynXBLQ9mH;Xf7o_iqr0k^>PV zDvN2vi5L*o#x#K<7@*t;MKMzPn^tLEi`?>BwaR2}S

${per.staffId}${per.name}${per.missingDates.joinToString(", ")}${missDatesHtml}