From 7d7a1f7d0ca13496e830345ce88f020b77dbb32c Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Wed, 3 Jul 2024 18:39:52 +0800 Subject: [PATCH] add cross team charge report --- .../modules/report/service/ReportService.kt | 528 +++++++++++++----- .../modules/report/web/ReportController.kt | 36 +- .../modules/report/web/model/ReportRequest.kt | 4 + .../timesheet/entity/TimesheetRepository.kt | 2 + .../report/Cross Team Charge Report.xlsx | Bin 0 -> 18610 bytes 5 files changed, 433 insertions(+), 137 deletions(-) create mode 100644 src/main/resources/templates/report/Cross Team Charge Report.xlsx 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 d5c38a1..de32af4 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 @@ -2,6 +2,7 @@ package com.ffii.tsms.modules.report.service import com.ffii.core.support.JdbcDao import com.ffii.tsms.modules.data.entity.Customer +import com.ffii.tsms.modules.data.entity.Grade import com.ffii.tsms.modules.data.entity.Salary import com.ffii.tsms.modules.data.entity.Staff import com.ffii.tsms.modules.data.entity.Team @@ -17,6 +18,8 @@ import org.apache.poi.ss.util.CellRangeAddress import org.apache.poi.ss.util.CellUtil import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.ss.usermodel.FormulaEvaluator +import org.apache.poi.ss.util.CellReference +import org.apache.poi.ss.util.RegionUtil import org.springframework.core.io.ClassPathResource import org.springframework.stereotype.Service import java.io.ByteArrayOutputStream @@ -54,6 +57,7 @@ open class ReportService( private val COMPLETE_PROJECT_OUTSTANDING_RECEIVABLE = "templates/report/AR06_Project Completion Report with Outstanding Accounts Receivable v02.xlsx" private val COMPLETION_PROJECT = "templates/report/AR05_Project Completion Report.xlsx" + private val CROSS_TEAM_CHARGE_REPORT = "templates/report/Cross Team Charge Report.xlsx" // ==============================|| GENERATE REPORT ||============================== // fun generalCreateReportIndexed( // just loop through query records one by one, return rowIndex @@ -299,6 +303,25 @@ open class ReportService( return outputStream.toByteArray() } + @Throws(IOException::class) + fun generateCrossTeamChargeReport( + month: String, + timesheets: List, + teams: List, + grades: List, + ): ByteArray { + // Generate the Excel report with query results + val workbook: Workbook = + createCrossTeamChargeReport(month, timesheets, teams, grades, CROSS_TEAM_CHARGE_REPORT) + + // Write the workbook to a ByteArrayOutputStream + val outputStream: ByteArrayOutputStream = ByteArrayOutputStream() + workbook.write(outputStream) + workbook.close() + + return outputStream.toByteArray() + } + // ==============================|| CREATE REPORT ||============================== // // EX01 Financial Report @@ -985,10 +1008,15 @@ open class ReportService( project.milestones.forEach { milestone: Milestone -> val manHoursSpent = groupedTimesheets[Pair(project.id, milestone.id)]?.sum() ?: 0.0 - val resourceUtilization = manHoursSpent / (milestone.stagePercentAllocation!! / 100 * project.totalManhour!!) + val resourceUtilization = + manHoursSpent / (milestone.stagePercentAllocation!! / 100 * project.totalManhour!!) // logger.info(project.name + " : " + milestone.taskGroup?.name + " : " + ChronoUnit.DAYS.between(LocalDate.now(), milestone.endDate)) // logger.info(daysUntilCurrentStageEnd) - if (ChronoUnit.DAYS.between(LocalDate.now(), milestone.endDate) <= daysUntilCurrentStageEnd.toLong() && resourceUtilization <= resourceUtilizationPercentage.toDouble() / 100.0) { + if (ChronoUnit.DAYS.between( + LocalDate.now(), + milestone.endDate + ) <= daysUntilCurrentStageEnd.toLong() && resourceUtilization <= resourceUtilizationPercentage.toDouble() / 100.0 + ) { milestoneCount++ val tempRow = sheet.getRow(rowIndex) ?: sheet.createRow(rowIndex) rowIndex++ @@ -1010,7 +1038,7 @@ open class ReportService( // // val resourceUtilization = // manHoursSpent / (milestone.stagePercentAllocation!! / 100 * project.totalManhour!!) - setCellValue(resourceUtilization) + setCellValue(resourceUtilization) // } else { // setCellValue(0.0) // } @@ -1480,7 +1508,7 @@ open class ReportService( setDataAndConditionalFormatting(workbook, sheet, lateStartData, evaluator) // Automatically adjust column widths to fit content - autoSizeColumns(sheet) + autoSizeColumns(sheet) return workbook } @@ -1626,7 +1654,8 @@ open class ReportService( // NEW Column F: Subsidiary Name val subsidiaryNameCell = row.createCell(5) // subsidiaryNameCell.setCellValue(data["subsidiary_name"] as String) - val subsidiaryName = data["subsidiary_name"] as? String ?: "N/A" // Checks if subsidiary_name is null and replaces it with "N/A" + val subsidiaryName = + data["subsidiary_name"] as? String ?: "N/A" // Checks if subsidiary_name is null and replaces it with "N/A" subsidiaryNameCell.setCellValue(subsidiaryName) // Column G: Project Plan Start Date @@ -1747,112 +1776,118 @@ open class ReportService( ) return jdbcDao.queryForList(sql.toString(), args) } + open fun getProjectCompletionReport(args: Map): List> { - val sql = StringBuilder("select" - + " result.code, " - + " result.name, " - + " result.teamCode, " - + " result.custCode, " - + " result.subCode, " ) + val sql = StringBuilder( + "select" + + " result.code, " + + " result.name, " + + " result.teamCode, " + + " result.custCode, " + + " result.subCode, " + ) if (args.get("outstanding") as Boolean) { sql.append(" result.projectFee - COALESCE(i.paidAmount, 0) as `Receivable Remained`, ") // sql.append(" result.projectFee - (result.totalBudget - COALESCE(i.issueAmount , 0) + COALESCE(i.issueAmount, 0) - COALESCE(i.paidAmount, 0)) as `Receivable Remained`, ") } sql.append( " DATE_FORMAT(result.actualEnd, '%d/%m/%Y') as actualEnd " - + " from ( " - + " SELECT " - + " pt.project_id, " - + " min(p.code) as code, " - + " min(p.name) as name, " - + " min(t.code) as teamCode, " - + " concat(min(c.code), ' - ', min(c.name)) as custCode, " - + " COALESCE(concat(min(ss.code),' - ',min(ss.name)), 'N/A') as subCode, " - + " min(p.actualEnd) as actualEnd, " - + " min(p.expectedTotalFee) as projectFee, " - + " sum(COALESCE(tns.totalConsumed*sal.hourlyRate, 0)) as totalBudget " - + " FROM ( " - + " SELECT " - + " t.staffId, " - + " sum(t.normalConsumed + COALESCE(t.otConsumed, 0)) as totalConsumed, " - + " t.projectTaskId AS taskId " - + " FROM timesheet t " - + " LEFT JOIN staff s ON t.staffId = s.id " - + " LEFT JOIN team te on s.teamId = te.id " - + " GROUP BY t.staffId, t.projectTaskId " - + " order by t.staffId " - + " ) AS tns " - + " right join project_task pt ON tns.taskId = pt.id " - + " left join project p on p.id = pt.project_id " - + " left JOIN staff s ON p.teamLead = s.id " - + " left join salary sal on s.salaryId = sal.salaryPoint " - + " left JOIN team t ON s.teamId = t.id " - + " left join customer c on c.id = p.customerId " - + " LEFT JOIN subsidiary ss on p.customerSubsidiaryId = ss.id " - + " where p.deleted = false ") - if (args.containsKey("teamId")) { - sql.append("t.id = :teamId") - } - sql.append( + + " from ( " + + " SELECT " + + " pt.project_id, " + + " min(p.code) as code, " + + " min(p.name) as name, " + + " min(t.code) as teamCode, " + + " concat(min(c.code), ' - ', min(c.name)) as custCode, " + + " COALESCE(concat(min(ss.code),' - ',min(ss.name)), 'N/A') as subCode, " + + " min(p.actualEnd) as actualEnd, " + + " min(p.expectedTotalFee) as projectFee, " + + " sum(COALESCE(tns.totalConsumed*sal.hourlyRate, 0)) as totalBudget " + + " FROM ( " + + " SELECT " + + " t.staffId, " + + " sum(t.normalConsumed + COALESCE(t.otConsumed, 0)) as totalConsumed, " + + " t.projectTaskId AS taskId " + + " FROM timesheet t " + + " LEFT JOIN staff s ON t.staffId = s.id " + + " LEFT JOIN team te on s.teamId = te.id " + + " GROUP BY t.staffId, t.projectTaskId " + + " order by t.staffId " + + " ) AS tns " + + " right join project_task pt ON tns.taskId = pt.id " + + " left join project p on p.id = pt.project_id " + + " left JOIN staff s ON p.teamLead = s.id " + + " left join salary sal on s.salaryId = sal.salaryPoint " + + " left JOIN team t ON s.teamId = t.id " + + " left join customer c on c.id = p.customerId " + + " LEFT JOIN subsidiary ss on p.customerSubsidiaryId = ss.id " + + " where p.deleted = false " + ) + if (args.containsKey("teamId")) { + sql.append("t.id = :teamId") + } + sql.append( " and p.status = 'Completed' " - + " and p.actualEnd BETWEEN :startDate and :endDate " - + " group by pt.project_id " - + " ) as result " - + " left join invoice i on result.code = i.projectCode " - + " order by result.actualEnd ") + + " and p.actualEnd BETWEEN :startDate and :endDate " + + " group by pt.project_id " + + " ) as result " + + " left join invoice i on result.code = i.projectCode " + + " order by result.actualEnd " + ) return jdbcDao.queryForList(sql.toString(), args) } open fun getProjectResourceOverconsumptionReport(args: Map): List> { - val sql = StringBuilder("WITH teamNormalConsumed AS (" - + " SELECT" - + " tns.projectId," - + " SUM(tns.totalConsumed) AS totalConsumed, " - + " sum(tns.totalBudget) as totalBudget " - + " FROM ( " - + " SELECT" - + " t.staffId," - + " t.projectId AS projectId," - + " sum(t.normalConsumed + COALESCE(t.otConsumed, 0)) as totalConsumed, " - + " sum(t.normalConsumed + COALESCE(t.otConsumed, 0)) * min(sal.hourlyRate) as totalBudget " - + " FROM timesheet t" - + " LEFT JOIN staff s ON t.staffId = s.id " - + " left join salary sal on sal.salaryPoint = s.salaryId " - + " GROUP BY t.staffId, t.projectId" - + " ) AS tns" - + " GROUP BY projectId" - + " ) " - + " SELECT " - + " p.code, " - + " p.name, " - + " t.code as team, " - + " concat(c.code, ' - ', c.name) as client, " - + " COALESCE(concat(ss.code, ' - ', ss.name), 'N/A') as subsidiary, " - + " p.expectedTotalFee * 0.8 as plannedBudget, " - + " COALESCE(tns.totalBudget, 0) as actualConsumedBudget, " - + " COALESCE(p.totalManhour, 0) as plannedManhour, " - + " COALESCE(tns.totalConsumed, 0) as actualConsumedManhour, " - + " (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) as budgetConsumptionRate, " - + " (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) as manhourConsumptionRate, " - + " CASE " - + " when (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= 1 " - + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= 1 " - + " then 'Overconsumption' " - + " when (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= :lowerLimit and (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) <= 1 " - + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= :lowerLimit and (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) <= 1 " - + " then 'Potential Overconsumption' " - + " else 'Within Budget' " - + " END as status " - + " FROM project p " - + " LEFT JOIN team t ON p.teamLead = t.teamLead " - + " LEFT JOIN staff s ON p.teamLead = s.id " - + " LEFT JOIN salary sa ON s.salaryId = sa.salaryPoint " - + " LEFT JOIN customer c ON p.customerId = c.id " - + " LEFT JOIN subsidiary ss on p.customerSubsidiaryId = ss.id " - + " left join teamNormalConsumed tns on tns.project_id = p.id " - + " WHERE p.deleted = false " - + " and p.status = 'On-going' " + val sql = StringBuilder( + "WITH teamNormalConsumed AS (" + + " SELECT" + + " tns.projectId," + + " SUM(tns.totalConsumed) AS totalConsumed, " + + " sum(tns.totalBudget) as totalBudget " + + " FROM ( " + + " SELECT" + + " t.staffId," + + " t.projectId AS projectId," + + " sum(t.normalConsumed + COALESCE(t.otConsumed, 0)) as totalConsumed, " + + " sum(t.normalConsumed + COALESCE(t.otConsumed, 0)) * min(sal.hourlyRate) as totalBudget " + + " FROM timesheet t" + + " LEFT JOIN staff s ON t.staffId = s.id " + + " left join salary sal on sal.salaryPoint = s.salaryId " + + " GROUP BY t.staffId, t.projectId" + + " ) AS tns" + + " GROUP BY projectId" + + " ) " + + " SELECT " + + " p.code, " + + " p.name, " + + " t.code as team, " + + " concat(c.code, ' - ', c.name) as client, " + + " COALESCE(concat(ss.code, ' - ', ss.name), 'N/A') as subsidiary, " + + " p.expectedTotalFee * 0.8 as plannedBudget, " + + " COALESCE(tns.totalBudget, 0) as actualConsumedBudget, " + + " COALESCE(p.totalManhour, 0) as plannedManhour, " + + " COALESCE(tns.totalConsumed, 0) as actualConsumedManhour, " + + " (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) as budgetConsumptionRate, " + + " (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) as manhourConsumptionRate, " + + " CASE " + + " when (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= 1 " + + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= 1 " + + " then 'Overconsumption' " + + " when (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= :lowerLimit and (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) <= 1 " + + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= :lowerLimit and (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) <= 1 " + + " then 'Potential Overconsumption' " + + " else 'Within Budget' " + + " END as status " + + " FROM project p " + + " LEFT JOIN team t ON p.teamLead = t.teamLead " + + " LEFT JOIN staff s ON p.teamLead = s.id " + + " LEFT JOIN salary sa ON s.salaryId = sa.salaryPoint " + + " LEFT JOIN customer c ON p.customerId = c.id " + + " LEFT JOIN subsidiary ss on p.customerSubsidiaryId = ss.id " + + " left join teamNormalConsumed tns on tns.project_id = p.id " + + " WHERE p.deleted = false " + + " and p.status = 'On-going' " ) if (args != null) { var statusFilter: String = "" @@ -1869,6 +1904,7 @@ open class ReportService( " and (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) <= 1 " + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= :lowerLimit " + " and (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) <= 1 " + "All" -> " and " + " (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= :lowerLimit " + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= :lowerLimit " @@ -2170,9 +2206,9 @@ open class ReportService( rowNum = 6 val row6: Row = sheet.getRow(rowNum) val row6Cell = row6.getCell(1) - val clientSubsidiary = if(info.getValue("subsidiary").toString() != "N/A"){ + val clientSubsidiary = if (info.getValue("subsidiary").toString() != "N/A") { info.getValue("subsidiary").toString() - }else{ + } else { info.getValue("client").toString() } row6Cell.setCellValue(clientSubsidiary) @@ -2367,10 +2403,10 @@ open class ReportService( return workbook } - fun getCostAndExpense(clientId: Long?, teamId: Long?, type: String): List>{ + fun getCostAndExpense(clientId: Long?, teamId: Long?, type: String): List> { val sql = StringBuilder( " with cte_timesheet as ( " - + " Select p.code, s.name as staff, IFNULL(t.normalConsumed, 0) as normalConsumed, IFNULL(t.otConsumed , 0) as otConsumed, s2.salaryPoint, s2.hourlyRate, t.staffId," + + " Select p.code, s.name as staff, IFNULL(t.normalConsumed, 0) as normalConsumed, IFNULL(t.otConsumed , 0) as otConsumed, s2.salaryPoint, s2.hourlyRate, t.staffId," + " t.recordDate" + " from timesheet t" + " left join project_task pt on pt.id = t.projectTaskId" @@ -2394,20 +2430,20 @@ open class ReportService( + " left join team t2 on t2.id = s.teamId" + " where ISNULL(p.code) = False" ) - if(clientId != null){ - if(type == "client"){ + if (clientId != null) { + if (type == "client") { sql.append( " and c.id = :clientId " ) } - if(type == "subsidiary"){ + if (type == "subsidiary") { sql.append( " and s2.id = :clientId " ) } } - if(teamId != null){ + if (teamId != null) { sql.append( " and p.teamLead = :teamId " ) @@ -2426,9 +2462,9 @@ open class ReportService( val queryList = jdbcDao.queryForList(sql.toString(), args) val costAndExpenseList = mutableListOf>() - for(item in queryList){ + for (item in queryList) { val hourlyRate = (item.getValue("hourlyRate") as BigDecimal).toDouble() - if(item["code"] !in costAndExpenseList.map { it["code"] }){ + if (item["code"] !in costAndExpenseList.map { it["code"] }) { costAndExpenseList.add( mapOf( "code" to item["code"], @@ -2437,22 +2473,27 @@ open class ReportService( "subsidiary" to item["subsidiary"], "teamLead" to item["teamLead"], "budget" to item["expectedTotalFee"], - "totalManhours" to item["normalConsumed"] as Double + item["otConsumed"] as Double, - "manhourExpenditure" to (hourlyRate * item["normalConsumed"] as Double ) - + (hourlyRate * item["otConsumed"] as Double * otFactor) + "totalManhours" to item["normalConsumed"] as Double + item["otConsumed"] as Double, + "manhourExpenditure" to (hourlyRate * item["normalConsumed"] as Double) + + (hourlyRate * item["otConsumed"] as Double * otFactor) ) ) - }else{ + } else { val existingMap = costAndExpenseList.find { it.containsValue(item["code"]) }!! costAndExpenseList[costAndExpenseList.indexOf(existingMap)] = existingMap.toMutableMap().apply { - put("totalManhours", get("totalManhours") as Double + (item["normalConsumed"] as Double + item["otConsumed"] as Double)) - put("manhourExpenditure", get("manhourExpenditure") as Double + ((hourlyRate * item["normalConsumed"] as Double ) - + (hourlyRate * item["otConsumed"]as Double * otFactor))) + put( + "totalManhours", + get("totalManhours") as Double + (item["normalConsumed"] as Double + item["otConsumed"] as Double) + ) + put( + "manhourExpenditure", + get("manhourExpenditure") as Double + ((hourlyRate * item["normalConsumed"] as Double) + + (hourlyRate * item["otConsumed"] as Double * otFactor)) + ) } } } - val result = costAndExpenseList.map { - item -> + val result = costAndExpenseList.map { item -> val budget = (item["budget"] as? Double)?.times(0.8) ?: 0.0 val budgetRemain = budget - (item["manhourExpenditure"] as? Double ?: 0.0) val remainingPercent = (budgetRemain / budget) @@ -2471,7 +2512,7 @@ open class ReportService( clientId: Long?, budgetPercentage: Double?, type: String - ): Workbook{ + ): Workbook { val resource = ClassPathResource(templatePath) val templateInputStream = resource.inputStream val workbook: Workbook = XSSFWorkbook(templateInputStream) @@ -2489,9 +2530,9 @@ open class ReportService( rowNum = 2 val row2: Row = sheet.getRow(rowNum) val row2Cell = row2.getCell(2) - if(teamId == null){ + if (teamId == null) { row2Cell.setCellValue("All") - }else{ + } else { val sql = StringBuilder( " select t.id, t.code, t.name, concat(t.code, \" - \" ,t.name) as teamLead from team t where t.id = :teamId " ) @@ -2502,16 +2543,16 @@ open class ReportService( rowNum = 3 val row3: Row = sheet.getRow(rowNum) val row3Cell = row3.getCell(2) - if(clientId == null){ + if (clientId == null) { row3Cell.setCellValue("All") - }else{ - val sql= StringBuilder() - if(type == "client"){ + } else { + val sql = StringBuilder() + if (type == "client") { sql.append( " select c.id, c.name from customer c where c.id = :clientId " ) } - if(type == "subsidiary"){ + if (type == "subsidiary") { sql.append( " select s.id, s.name from subsidiary s where s.id = :clientId " ) @@ -2522,15 +2563,15 @@ open class ReportService( val filterList: List> - if(budgetPercentage != null){ + if (budgetPercentage != null) { filterList = costAndExpenseList.filter { ((it["budgetPercentage"] as? Double) ?: 0.0) <= budgetPercentage } - }else{ + } else { filterList = costAndExpenseList } rowNum = 6 - for(item in filterList){ + for (item in filterList) { val index = filterList.indexOf(item) val row: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) val cell = row.getCell(0) ?: row.createCell(0) @@ -2580,13 +2621,13 @@ open class ReportService( val cell8 = row.getCell(8) ?: row.createCell(8) cell8.apply { - cellFormula = "G${rowNum+1}-H${rowNum+1}" + cellFormula = "G${rowNum + 1}-H${rowNum + 1}" } CellUtil.setCellStyleProperty(cell8, "dataFormat", accountingStyle) val cell9 = row.getCell(9) ?: row.createCell(9) cell9.apply { - cellFormula = "I${rowNum+1}/G${rowNum+1}" + cellFormula = "I${rowNum + 1}/G${rowNum + 1}" } CellUtil.setCellStyleProperty(cell9, "dataFormat", percentStyle) @@ -2596,11 +2637,18 @@ open class ReportService( return workbook } - fun genCostAndExpenseReport(request: costAndExpenseRequest): ByteArray{ + fun genCostAndExpenseReport(request: costAndExpenseRequest): ByteArray { val costAndExpenseList = getCostAndExpense(request.clientId, request.teamId, request.type) - val workbook: Workbook = createCostAndExpenseWorkbook(COSTANDEXPENSE_REPORT, costAndExpenseList, request.teamId, request.clientId, request.budgetPercentage, request.type) + val workbook: Workbook = createCostAndExpenseWorkbook( + COSTANDEXPENSE_REPORT, + costAndExpenseList, + request.teamId, + request.clientId, + request.budgetPercentage, + request.type + ) val outputStream: ByteArrayOutputStream = ByteArrayOutputStream() workbook.write(outputStream) @@ -2608,8 +2656,16 @@ open class ReportService( return outputStream.toByteArray() } - open fun getLateStartDetails(teamId: Long?, clientId: Long?, remainedDate: LocalDate, remainedDateTo: LocalDate, type: String?): List> { - val sql = StringBuilder(""" + + open fun getLateStartDetails( + teamId: Long?, + clientId: Long?, + remainedDate: LocalDate, + remainedDateTo: LocalDate, + type: String? + ): List> { + val sql = StringBuilder( + """ SELECT p.code AS project_code, p.name AS project_name, @@ -2630,7 +2686,8 @@ open class ReportService( p.status = 'Pending to Start' AND p.planStart < CURRENT_DATE AND m.endDate BETWEEN :remainedDate AND :remainedDateTo - """.trimIndent()) + """.trimIndent() + ) if (teamId != null && teamId > 0) { sql.append(" AND t.id = :teamId") @@ -2654,4 +2711,203 @@ open class ReportService( ) return jdbcDao.queryForList(sql.toString(), args) } -} + + @Throws(IOException::class) + private fun createCrossTeamChargeReport( + month: String, + timesheets: List, + teams: List, + grades: List, + templatePath: String, + ): Workbook { + // please create a new function for each report template + val resource = ClassPathResource(templatePath) + val templateInputStream = resource.inputStream + val workbook: Workbook = XSSFWorkbook(templateInputStream) + + val sheet: Sheet = workbook.getSheetAt(0) + + // accounting style + comma style + val accountingStyle = workbook.createDataFormat().getFormat("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)") + + // bold font with border style + val boldFont = workbook.createFont().apply { + bold = true + fontName = "Times New Roman" + } + + val boldFontWithBorderStyle = workbook.createCellStyle().apply { + setFont(boldFont) + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + // normal font + val normalFont = workbook.createFont().apply { + fontName = "Times New Roman" + } + + val normalFontWithBorderStyle = workbook.createCellStyle().apply { + setFont(normalFont) + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + var rowIndex = 1 // Assuming the location is in (1,2), which is the report date field + var columnIndex = 2 + val monthFormat = DateTimeFormatter.ofPattern("MMMM yyyy", Locale.ENGLISH) + val reportMonth = YearMonth.parse(month, DateTimeFormatter.ofPattern("yyyy-MM")) + val convertReportMonth = YearMonth.of(reportMonth.year, reportMonth.month).format(monthFormat) + sheet.getRow(rowIndex).createCell(columnIndex).apply { + setCellValue(convertReportMonth) + } + + if (timesheets.isNotEmpty()) { + val combinedTeamCodeColNumber = grades.size + val sortedGrades = grades.sortedBy { it.id } + val sortedTeams = teams.sortedBy { it.id } + + val groupedTimesheets = timesheets + .filter { it.project?.teamLead?.team?.id != it.staff?.team?.id } + .groupBy { timesheetEntry -> + Triple( + timesheetEntry.project?.teamLead?.team?.id, + timesheetEntry.staff?.team?.id, + timesheetEntry.staff?.grade?.id + ) + } + .mapValues { (_, timesheetEntries) -> + timesheetEntries.map { timesheet -> + if (timesheet.normalConsumed != null) { + timesheet.normalConsumed!!.plus(timesheet.otConsumed ?: 0.0) + } else if (timesheet.otConsumed != null) { + timesheet.otConsumed!!.plus(timesheet.normalConsumed ?: 0.0) + } else { + 0.0 + } + } + } + + if (sortedTeams.isNotEmpty() && sortedTeams.size > 1) { + rowIndex = 3 + sortedTeams.forEach { team: Team -> + + // Team + sheet.createRow(rowIndex++).apply { + createCell(0).apply { + setCellValue("Team to be charged:") + cellStyle = boldFontWithBorderStyle.apply { + alignment = HorizontalAlignment.LEFT + } + } + + val rangeAddress = CellRangeAddress(this.rowNum, this.rowNum, 1, 1 + combinedTeamCodeColNumber) + sheet.addMergedRegion(rangeAddress) + RegionUtil.setBorderTop(BorderStyle.THIN, rangeAddress, sheet) + RegionUtil.setBorderLeft(BorderStyle.THIN, rangeAddress, sheet) + RegionUtil.setBorderRight(BorderStyle.THIN, rangeAddress, sheet) + RegionUtil.setBorderBottom(BorderStyle.THIN, rangeAddress, sheet) + + createCell(1).apply { + setCellValue(team.code) + cellStyle = normalFontWithBorderStyle.apply { + alignment = HorizontalAlignment.CENTER + } + } + + } + + // Grades + sheet.createRow(rowIndex++).apply { + columnIndex = 0 + createCell(columnIndex++).apply { + setCellValue("") + cellStyle = boldFontWithBorderStyle + } + + + sortedGrades.forEach { grade: Grade -> + createCell(columnIndex++).apply { + setCellValue(grade.name) + cellStyle = boldFontWithBorderStyle.apply { + alignment = HorizontalAlignment.CENTER + } + } + } + + createCell(columnIndex).apply { + setCellValue("Total Manhour by Team") + cellStyle = boldFontWithBorderStyle + } + } + + // Team + Manhour + val startRow = rowIndex + var endRow = rowIndex + sortedTeams.forEach { chargedTeam: Team -> + if (team.id != chargedTeam.id) { + endRow++ + sheet.createRow(rowIndex++).apply { + columnIndex = 0 + createCell(columnIndex++).apply { + setCellValue(chargedTeam.code) + cellStyle = normalFontWithBorderStyle + } + + sortedGrades.forEach { grade: Grade -> + createCell(columnIndex++).apply { + setCellValue( + groupedTimesheets[Triple(team.id, chargedTeam.id, grade.id)]?.sum() ?: 0.0 + ) + cellStyle = normalFontWithBorderStyle.apply { + dataFormat = accountingStyle + } + } + } + + createCell(columnIndex).apply { + val lastCellLetter = CellReference.convertNumToColString(this.columnIndex - 1) + cellFormula = "sum(B${this.rowIndex + 1}:${lastCellLetter}${this.rowIndex + 1})" + cellStyle = boldFontWithBorderStyle.apply { + dataFormat = accountingStyle + } + } + } + } + } + + // Total Manhour by grade + sheet.createRow(rowIndex).apply { + columnIndex = 0 + createCell(columnIndex++).apply { + setCellValue("Total Manhour by Grade") + cellStyle = boldFontWithBorderStyle + } + + sortedGrades.forEach { grade: Grade -> + createCell(columnIndex++).apply { + val currentCellLetter = CellReference.convertNumToColString(this.columnIndex) + cellFormula = "sum(${currentCellLetter}${startRow}:${currentCellLetter}${endRow})" + cellStyle = normalFontWithBorderStyle.apply { + dataFormat = accountingStyle + } + } + } + + createCell(columnIndex).apply { + setCellValue("") + cellStyle = boldFontWithBorderStyle + } + } + rowIndex += 2 + } + } + } + + return workbook + } +} \ No newline at end of file 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 1c312f0..fd6b347 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 @@ -38,6 +38,8 @@ import org.apache.commons.logging.Log import org.apache.commons.logging.LogFactory import org.springframework.data.domain.Example import org.springframework.data.domain.ExampleMatcher +import java.time.YearMonth +import java.util.* @RestController @RequestMapping("/reports") @@ -57,7 +59,8 @@ class ReportController( private val customerService: CustomerService, private val subsidiaryService: SubsidiaryService, private val invoiceService: InvoiceService, private val gradeAllocationRepository: GradeAllocationRepository, - private val subsidiaryRepository: SubsidiaryRepository + private val subsidiaryRepository: SubsidiaryRepository, private val staffAllocationRepository: StaffAllocationRepository, + private val gradeRepository: GradeRepository ) { private val logger: Log = LogFactory.getLog(javaClass) @@ -304,4 +307,35 @@ class ReportController( .body(ByteArrayResource(reportResult)) } + @PostMapping("/CrossTeamChargeReport") + @Throws(ServletRequestBindingException::class, IOException::class) + fun getCrossTeamChargeReport(@RequestBody @Valid request: CrossTeamChargeReportRequest): ResponseEntity { + + val authorities = staffsService.currentAuthorities() ?: return ResponseEntity.noContent().build() + + if (authorities.stream().anyMatch { it.authority.equals("GENERATE_REPORTS") }) { + val crossProjects = staffAllocationRepository.findAll() + .filter { it.project?.teamLead?.team?.id != it.staff?.team?.id } + .map { it.project!! } + .distinct() + + val reportMonth = YearMonth.parse(request.month, DateTimeFormatter.ofPattern("yyyy-MM")) + val convertReportMonth = YearMonth.of(reportMonth.year, reportMonth.month) + val startDate = convertReportMonth.atDay(1) + val endDate = convertReportMonth.atEndOfMonth() + val timesheets = timesheetRepository.findAllByProjectIn(crossProjects) + .filter { it.recordDate != null && + (it.recordDate!!.isEqual(startDate) || it.recordDate!!.isEqual(endDate) || (it.recordDate!!.isAfter(startDate) && it.recordDate!!.isBefore(endDate)))} + val teams = teamRepository.findAll().filter { it.deleted == false } + val grades = gradeRepository.findAll().filter { it.deleted == false } + + val reportResult: ByteArray = excelReportService.generateCrossTeamChargeReport(request.month, timesheets, teams, grades) + return ResponseEntity.ok() + .header("filename", "Cross Team Charge Report - " + LocalDate.now() + ".xlsx") + .body(ByteArrayResource(reportResult)) + + } else { + return ResponseEntity.noContent().build() + } + } } \ No newline at end of file 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 1233753..1c8b509 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 @@ -57,4 +57,8 @@ data class ProjectCompletionReport ( val startDate: LocalDate, val endDate: LocalDate, val outstanding: Boolean +) + +data class CrossTeamChargeReportRequest ( + val month: String, ) \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/timesheet/entity/TimesheetRepository.kt b/src/main/java/com/ffii/tsms/modules/timesheet/entity/TimesheetRepository.kt index 3defcf1..2826b9f 100644 --- a/src/main/java/com/ffii/tsms/modules/timesheet/entity/TimesheetRepository.kt +++ b/src/main/java/com/ffii/tsms/modules/timesheet/entity/TimesheetRepository.kt @@ -14,6 +14,8 @@ interface TimesheetRepository : AbstractRepository { fun findAllByProjectTaskIn(projectTasks: List): List + fun findAllByProjectIn(project: List): List + fun deleteAllByStaffAndRecordDate(staff: Staff, recordDate: LocalDate) @Query("SELECT new com.ffii.tsms.modules.timesheet.entity.projections.TimesheetHours(IFNULL(SUM(normalConsumed), 0), IFNULL(SUM(otConsumed), 0)) FROM Timesheet t JOIN ProjectTask pt on t.projectTask = pt WHERE pt.project = ?1") diff --git a/src/main/resources/templates/report/Cross Team Charge Report.xlsx b/src/main/resources/templates/report/Cross Team Charge Report.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..26e201fe936564e94d206545a128e17f0ac0eab3 GIT binary patch literal 18610 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 a2!65>pzq%LGucG|kOg_)+(hW-yZ;00kHZN7 literal 0 HcmV?d00001