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 0000000..26e201f Binary files /dev/null and b/src/main/resources/templates/report/Cross Team Charge Report.xlsx differ