From bfe70fbc8c23bbad526e05dc4b8ebb766ee4b8e5 Mon Sep 17 00:00:00 2001 From: "MSI\\2Fi" Date: Wed, 22 May 2024 16:26:30 +0800 Subject: [PATCH] PANDL Report --- .../modules/report/service/ReportService.kt | 462 +++++++++++++++++- .../modules/report/web/ReportController.kt | 27 +- .../modules/report/web/model/ReportRequest.kt | 7 + .../report/AR07_Project P&L Report v02.xlsx | Bin 14778 -> 12779 bytes 4 files changed, 483 insertions(+), 13 deletions(-) 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 666e109..53f6d3d 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,18 +1,13 @@ 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.Salary import com.ffii.tsms.modules.data.entity.Staff import com.ffii.tsms.modules.data.entity.Team -import com.ffii.tsms.modules.data.entity.Customer import com.ffii.tsms.modules.project.entity.Invoice import com.ffii.tsms.modules.project.entity.Project -import com.ffii.tsms.modules.timesheet.entity.Leave import com.ffii.tsms.modules.timesheet.entity.Timesheet -import com.ffii.tsms.modules.timesheet.entity.projections.MonthlyLeave -import com.ffii.tsms.modules.timesheet.entity.projections.ProjectMonthlyHoursWithDate -import com.ffii.tsms.modules.timesheet.entity.projections.TimesheetHours -import com.ffii.tsms.modules.timesheet.web.models.LeaveEntry import org.apache.commons.logging.Log import org.apache.commons.logging.LogFactory import org.apache.poi.ss.usermodel.* @@ -25,8 +20,8 @@ import org.springframework.stereotype.Service import java.io.ByteArrayOutputStream import java.io.IOException import java.math.BigDecimal -import java.sql.Time import java.time.LocalDate +import java.time.YearMonth import java.time.format.DateTimeFormatter import java.util.* @@ -40,6 +35,7 @@ open class ReportService( private val DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy/MM/dd") private val FORMATTED_TODAY = LocalDate.now().format(DATE_FORMATTER) + private val PandL_REPORT = "templates/report/AR07_Project P&L Report v02.xlsx" private val FINANCIAL_STATUS_REPORT = "templates/report/EX01_Financial Status Report.xlsx" private val PROJECT_CASH_FLOW_REPORT = "templates/report/EX02_Project Cash Flow Report.xlsx" private val MONTHLY_WORK_HOURS_ANALYSIS_REPORT = "templates/report/AR08_Monthly Work Hours Analysis Report.xlsx" @@ -1177,4 +1173,456 @@ open class ReportService( return jdbcDao.queryForList(sql.toString(), args) } + open fun getManhoursSpent(projectId: Long, startMonth: String, endMonth: 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," + + " t.recordDate" + + " from timesheet t" + + " left join project_task pt on pt.id = t.projectTaskId" + + " left join project p ON p.id = pt.project_id" + + " left join staff s on s.id = t.staffId" + + " left join salary s2 on s.salaryId = s2.salaryPoint" + + " left join team t2 on t2.id = s.teamId" + + " )," + + " cte_invoice as (" + + " select p.code, sum(i.issueAmount) as sumIssuedAmount , sum(i.paidAmount) as sumPaidAmount" + + " from invoice i" + + " left join project p on p.code = i.projectCode" + + " group by p.code" + + " )" + + " select p.code, p.description, c.name as client, concat(t.code, \" - \", t.name) as teamLead," + + " IFNULL(cte_ts.normalConsumed, 0) as normalConsumed, IFNULL(cte_ts.otConsumed, 0) as otConsumed, DATE_FORMAT(cte_ts.recordDate, '%Y-%m') as recordDate, " + + " IFNULL(cte_ts.salaryPoint, 0) as salaryPoint, " + + " IFNULL(cte_ts.hourlyRate, 0) as hourlyRate, IFNULL(cte_i.sumIssuedAmount, 0) as sumIssuedAmount, IFNULL(cte_i.sumPaidAmount, 0) as sumPaidAmount," + + " s.name, s.staffId, g.code as gradeCode, g.name as gradeName, t2.code as teamCode, t2.name as teamName" + + " from project p" + + " left join cte_timesheet cte_ts on p.code = cte_ts.code" + + " left join customer c on c.id = p.customerId" + + " left join tsmsdb.team t on t.teamLead = p.teamLead" + + " left join cte_invoice cte_i on cte_i.code = p.code" + + " left join staff s on s.id = cte_ts.staffId" + + " left join grade g on g.id = s.gradeId" + + " left join team t2 on t2.id = s.teamId" + + " where p.deleted = false" + + " and (DATE_FORMAT(cte_ts.recordDate, \'%Y-%m\') >= :startMonth and DATE_FORMAT(cte_ts.recordDate, \'%Y-%m\') <= :endMonth)" + + " and p.id = :projectId" + + " order by g.code, s.name, cte_ts.recordDate" + ) + + val args = mapOf( + "projectId" to projectId, + "startMonth" to startMonth, + "endMonth" to endMonth, + ) + + val manHoursSpent = jdbcDao.queryForList(sql.toString(), args) + + val projectCodeSql = StringBuilder( + "select p.code, p.description from project p where p.deleted = false and p.id = :projectId" + ) + + val args2 = mapOf( + "projectId" to projectId, + ) + + val projectsCode = jdbcDao.queryForMap(projectCodeSql.toString(), args).get() + + val otFactor = BigDecimal(1).toDouble() + + var tempList = mutableListOf>() + + val info: MutableMap = mutableMapOf( + "reportGenerationDate" to LocalDate.now().toString(), + "startMonth" to startMonth, + "endMonth" to endMonth, + "code" to projectsCode["code"], + "description" to projectsCode["description"], + ) + + val staffInfoList = mutableListOf>() + println("manHoursSpent------- ${manHoursSpent}") + for(item in manHoursSpent){ + if(info["teamLead"] != item.getValue("teamLead")){ + info["teamLead"] = item.getValue("teamLead") + } + if(info["client"] != item.getValue("client")){ + info["client"] = item.getValue("client") + } + if(info["code"] != item.getValue("code")){ + info["code"] = item.getValue("code") + } + if(info["code"] == item["code"] && "paidAmount" !in info){ + info["paidAmount"] = item.getValue("sumPaidAmount") + } + if(info["description"] != item.getValue("description")){ + info["description"] = item.getValue("description") + } + val hourlyRate = (item.getValue("hourlyRate") as BigDecimal).toDouble() + if(!staffInfoList.any { it["staffId"] == item["staffId"] && it["name"] == item["name"]}){ + staffInfoList.add( + mapOf( + "staffId" to item.getValue("staffId"), + "name" to item.getValue("name"), + "team" to "${item.getValue("teamCode")} - ${item.getValue("teamName")}", + "grade" to item.getValue("gradeCode"), + "salaryPoint" to item.getValue("salaryPoint"), + "hourlyRate" to hourlyRate, + "hourlySpent" to mutableListOf( + mapOf( + "recordDate" to item.getValue("recordDate"), + "normalConsumed" to item.getValue("normalConsumed"), + "otConsumed" to item.getValue("otConsumed"), + "totalManHours" to item.getValue("normalConsumed") as Double + item.getValue("otConsumed") as Double, + "manhourExpenditure" to (hourlyRate * item.getValue("normalConsumed") as Double ) + + (hourlyRate * item.getValue("otConsumed") as Double * otFactor) + ) + ) + ) + ) + }else{ + val existingMap = staffInfoList.find { it.containsValue(item.getValue("staffId")) }!! + val updatedMap = existingMap.toMutableMap() + val hourlySpentList = updatedMap["hourlySpent"] as MutableList> + val existingRecord = hourlySpentList.find { it["recordDate"] == item.getValue("recordDate") } + if (existingRecord != null) { + val existingIndex = hourlySpentList.indexOf(existingRecord) + val updatedRecord = existingRecord.toMutableMap() + updatedRecord["normalConsumed"] = (updatedRecord["normalConsumed"] as Double) + item.getValue("normalConsumed") as Double + updatedRecord["otConsumed"] = (updatedRecord["otConsumed"] as Double) + item.getValue("otConsumed") as Double + updatedRecord["totalManHours"] = (updatedRecord["totalManHours"] as Double) + item.getValue("normalConsumed") as Double + item.getValue("otConsumed") as Double + updatedRecord["manhourExpenditure"] = (updatedRecord["manhourExpenditure"] as Double) + (hourlyRate * item.getValue("normalConsumed") as Double) + (hourlyRate * item.getValue("otConsumed") as Double * otFactor) + hourlySpentList[existingIndex] = updatedRecord + } else { + hourlySpentList.add( + mapOf( + "recordDate" to item.getValue("recordDate"), + "normalConsumed" to item.getValue("normalConsumed"), + "otConsumed" to item.getValue("otConsumed"), + "totalManHours" to item.getValue("normalConsumed") as Double + item.getValue("otConsumed") as Double, + "manhourExpenditure" to (hourlyRate * item.getValue("normalConsumed") as Double) + + (hourlyRate * item.getValue("otConsumed") as Double * otFactor) + ) + ) + } + updatedMap["hourlySpent"] = hourlySpentList + val updatedIndex = staffInfoList.indexOf(existingMap) + staffInfoList[updatedIndex] = updatedMap + } + } + println("staffInfoList----------------- $staffInfoList") + println("info----------------- $info") + + tempList.add(mapOf("info" to info)) + tempList.add(mapOf("staffInfoList" to staffInfoList)) + + println("Only Staff Info List --------------------- ${ tempList.first() { it.containsKey("staffInfoList") }["staffInfoList"]}") + println("tempList----------------- $tempList") + + return tempList + } + + fun genPandLReport(projectId: Long, startMonth: String, endMonth: String): ByteArray { + val manhoursSpentList = getManhoursSpent(projectId, startMonth, endMonth) + + val workbook: Workbook = createPandLReportWorkbook(PandL_REPORT, manhoursSpentList, startMonth, endMonth) + + val outputStream: ByteArrayOutputStream = ByteArrayOutputStream() + workbook.write(outputStream) + workbook.close() + + return outputStream.toByteArray() + } + + fun createPandLReportWorkbook( + templatePath: String, + manhoursSpent: List>, + startMonth: String, + endMonth: String + ): Workbook{ + val resource = ClassPathResource(templatePath) + val templateInputStream = resource.inputStream + val workbook: Workbook = XSSFWorkbook(templateInputStream) + + val accountingStyle = workbook.createDataFormat().getFormat("_(* #,##0.00_);_(* (#,##0.00);_(* \"-\"??_);_(@_)") + + val startDate = YearMonth.parse(startMonth, DateTimeFormatter.ofPattern("yyyy-MM")) + val convertStartMonth = YearMonth.of(startDate.year, startDate.month) + val endDate = YearMonth.parse(endMonth, DateTimeFormatter.ofPattern("yyyy-MM")) + val convertEndMonth = YearMonth.of(endDate.year, endDate.month) + + val monthRange: MutableList> = ArrayList() + var currentDate = startDate + while (!currentDate.isAfter(endDate)) { + monthRange.add( + mapOf( + "display" to currentDate.month.name.substring(0, 3), + "date" to currentDate + ) + ) + currentDate = currentDate.plusMonths(1) + } + + val monthFormat = DateTimeFormatter.ofPattern("MMM yyyy", Locale.ENGLISH) + + val info:Map = manhoursSpent.first() { it.containsKey("info") }["info"] as Map + val staffInfoList: List> = manhoursSpent.first() { it.containsKey("staffInfoList") }["staffInfoList"] as List> + + if (staffInfoList.isEmpty()){ + val sheet = workbook.getSheetAt(0) + var rowNum = 0 + rowNum = 1 + val row1: Row = sheet.getRow(rowNum) + val row1Cell = row1.getCell(1) + row1Cell.setCellValue(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")).toString()) + + rowNum = 2 + val row2: Row = sheet.getRow(rowNum) + val row2Cell = row2.getCell(1) + row2Cell.setCellValue("${convertStartMonth.format(monthFormat)} - ${convertEndMonth.format(monthFormat)}") + + rowNum = 3 + val row3: Row = sheet.getRow(rowNum) + val row3Cell = row3.getCell(1) + row3Cell.setCellValue(info.getValue("code").toString()) + + rowNum = 4 + val row4: Row = sheet.getRow(rowNum) + val row4Cell = row4.getCell(1) + row4Cell.setCellValue(info.getValue("description").toString()) + + rowNum = 5 + val row5: Row = sheet.getRow(rowNum) + val row5Cell = row5.getCell(1) + row5Cell.setCellValue("-") + + rowNum = 6 + val row6: Row = sheet.getRow(rowNum) + val row6Cell = row6.getCell(1) + row6Cell.setCellValue("-") + + return workbook + } + + val sheet = workbook.getSheetAt(0) + var rowNum = 0 + rowNum = 1 + val row1: Row = sheet.getRow(rowNum) + val row1Cell = row1.getCell(1) + row1Cell.setCellValue(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")).toString()) + + rowNum = 2 + val row2: Row = sheet.getRow(rowNum) + val row2Cell = row2.getCell(1) + row2Cell.setCellValue("${convertStartMonth.format(monthFormat)} - ${convertEndMonth.format(monthFormat)}") + + rowNum = 3 + val row3: Row = sheet.getRow(rowNum) + val row3Cell = row3.getCell(1) + row3Cell.setCellValue(info.getValue("code").toString()) + + rowNum = 4 + val row4: Row = sheet.getRow(rowNum) + val row4Cell = row4.getCell(1) + row4Cell.setCellValue(info.getValue("description").toString()) + + rowNum = 5 + val row5: Row = sheet.getRow(rowNum) + val row5Cell = row5.getCell(1) + row5Cell.setCellValue(info.getValue("teamLead").toString()) + + rowNum = 6 + val row6: Row = sheet.getRow(rowNum) + val row6Cell = row6.getCell(1) + row6Cell.setCellValue(info.getValue("client").toString()) + + rowNum = 9 + val row9: Row = sheet.getRow(rowNum) + val row9Cell = row9.getCell(3) + row9Cell.setCellValue("${convertStartMonth.format(monthFormat)} - ${convertEndMonth.format(monthFormat)}") + + rowNum = 10 + for(staff in staffInfoList){ +// val row: Row = sheet.getRow(rowNum++) ?: sheet.createRow(rowNum++) + val row: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val staffCell = row.getCell(0) ?: row.createCell(0) + staffCell.apply { + setCellValue("${staff.getValue("staffId")} - ${staff.getValue("name")}") + } + + val gradeCell = row.getCell(1) ?: row.createCell(1) + gradeCell.apply { + setCellValue("G${staff.getValue("grade")}") + } + CellUtil.setAlignment(gradeCell, HorizontalAlignment.CENTER); + + val salaryPointCell = row.getCell(2) ?: row.createCell(2) + salaryPointCell.apply { + setCellValue((staff.getValue("salaryPoint") as Long).toDouble()) + } + CellUtil.setAlignment(salaryPointCell, HorizontalAlignment.CENTER); + + sheet.addMergedRegion(CellRangeAddress(rowNum, rowNum, 3, 4)) + val hourlyRateCell = row.getCell(3) ?: row.createCell(3) + hourlyRateCell.apply { + setCellValue((staff.getValue("hourlyRate") as Double)) + } + CellUtil.setAlignment(hourlyRateCell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(hourlyRateCell, VerticalAlignment.CENTER); + + sheet.setRowBreak(rowNum++); + } + rowNum += 2 + val titleRow = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + sheet.addMergedRegion(CellRangeAddress(rowNum, rowNum, 0, monthRange.size+3)) + val titleCell = titleRow.getCell(0) ?: titleRow.createCell(0) + titleCell.apply { + setCellValue("Manhours Spent per Month") + } + CellUtil.setAlignment(titleCell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(titleCell, VerticalAlignment.CENTER); + + + rowNum += 1 + val manhoursSpentRow: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val staffCell = manhoursSpentRow.getCell(0) ?: manhoursSpentRow.createCell(0) + staffCell.apply { + setCellValue("Staff No. and Name") + } + CellUtil.setAlignment(staffCell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(staffCell, VerticalAlignment.CENTER); + CellUtil.setCellStyleProperty(staffCell,"borderBottom", BorderStyle.THIN) + + val teamCell = manhoursSpentRow.getCell(1) ?: manhoursSpentRow.createCell(1) + teamCell.apply { + setCellValue("Team") + } + CellUtil.setAlignment(teamCell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(teamCell, VerticalAlignment.CENTER); + CellUtil.setCellStyleProperty(teamCell,"borderBottom", BorderStyle.THIN) + + for ((column, month) in monthRange.withIndex()){ + val row: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val monthCell = row.getCell(2+column) ?: row.createCell(2+column) + monthCell.apply { + setCellValue(month.getValue("display").toString()) + } + CellUtil.setAlignment(monthCell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(monthCell, VerticalAlignment.CENTER); + CellUtil.setCellStyleProperty(monthCell,"borderBottom", BorderStyle.THIN) + } + val monthColumnEnd = monthRange.size + val row: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val manhourCell = row.getCell(2+monthColumnEnd) ?: row.createCell(2+monthColumnEnd) + manhourCell.apply { + setCellValue("Manhour Sub-total") + } + CellUtil.setAlignment(manhourCell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(manhourCell, VerticalAlignment.CENTER); + CellUtil.setCellStyleProperty(manhourCell, CellUtil.WRAP_TEXT, true) + CellUtil.setCellStyleProperty(manhourCell,"borderBottom", BorderStyle.THIN) + + val manhourECell = row.getCell(2+monthColumnEnd+1) ?: row.createCell(2+monthColumnEnd+1) + manhourECell.apply { + setCellValue("Manhour Expenditure (HKD)") + } + CellUtil.setAlignment(manhourECell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(manhourECell, VerticalAlignment.CENTER); + CellUtil.setCellStyleProperty(manhourECell, CellUtil.WRAP_TEXT, true) + CellUtil.setCellStyleProperty(manhourECell,"borderBottom", BorderStyle.THIN) + sheet.setColumnWidth(2+monthColumnEnd+1, 20*256) + + + // Manhour Spent LOOP + println("monthRange--------------- ${monthRange}\n") + rowNum += 1 + for(staff in staffInfoList){ + val row: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val staffCell = row.getCell(0) ?: row.createCell(0) + var manhourExpenditure = 0.0 + staffCell.apply { + setCellValue("${staff.getValue("staffId")} - ${staff.getValue("name")}") + } + + val teamCell = row.getCell(1) ?: row.createCell(1) + teamCell.apply { + setCellValue(staff.getValue("team").toString()) + } + CellUtil.setCellStyleProperty(teamCell, CellUtil.WRAP_TEXT, true) + + for(i in 0 until monthRange.size){ + val cell = row.getCell(i+2) ?: row.createCell(i+2) + cell.setCellValue(0.0) + for(hourlySpent in staff.getValue("hourlySpent") as List>){ + cell.apply { + if (hourlySpent.getValue("recordDate") == monthRange[i].getValue("date").toString()) { + setCellValue(hourlySpent.getValue("totalManHours") as Double) + manhourExpenditure += hourlySpent.getValue("manhourExpenditure") as Double + } + cellStyle.dataFormat = accountingStyle + } + } + CellUtil.setAlignment(cell, HorizontalAlignment.CENTER); + CellUtil.setVerticalAlignment(cell, VerticalAlignment.CENTER); + } + + val manhourCell = row.getCell(2+monthRange.size) ?: row.createCell(2+monthRange.size) + manhourCell.apply { + cellFormula="SUM(C${rowNum+1}:${getColumnAlphabet(1+monthRange.size)}${rowNum+1})" + cellStyle.dataFormat = accountingStyle + } + + val manhourECell = row.getCell(3+monthRange.size) ?: row.createCell(3+monthRange.size) + manhourECell.apply { + setCellValue(manhourExpenditure) + cellStyle.dataFormat = accountingStyle + } + sheet.setRowBreak(rowNum++); + } + + rowNum += 3 + val totalRevenueRow: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val totalRevenueTitleCell = totalRevenueRow.getCell(0) ?: totalRevenueRow.createCell(0) + totalRevenueTitleCell.apply { + setCellValue("Total Revenue (HKD)") + } + val totalRevenueCell = totalRevenueRow.getCell(1) ?: totalRevenueRow.createCell(1) + totalRevenueCell.apply { + setCellValue((info.getValue("paidAmount") as BigDecimal).toDouble()) + cellStyle.dataFormat = accountingStyle + } + + rowNum += 1 + val totalManhourERow: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val startRow = 15+staffInfoList.size + val lastColumnIndex = getColumnAlphabet(3+monthRange.size) + val totalManhourETitleCell = totalManhourERow.getCell(0) ?: totalManhourERow.createCell(0) + totalManhourETitleCell.apply { + setCellValue("Total Manhour Expenditure (HKD)") + } + val totalManhourECell = totalManhourERow.getCell(1) ?: totalManhourERow.createCell(1) + totalManhourECell.apply { + cellFormula = "SUM(${lastColumnIndex}${startRow}:${lastColumnIndex}${startRow+staffInfoList.size})" + cellStyle.dataFormat = accountingStyle + } + CellUtil.setCellStyleProperty(totalManhourETitleCell,"borderBottom", BorderStyle.THIN) + CellUtil.setCellStyleProperty(totalManhourECell,"borderBottom", BorderStyle.THIN) + + rowNum += 1 + val pandlRow: Row = sheet.getRow(rowNum) ?: sheet.createRow(rowNum) + val panlCellTitle = pandlRow.getCell(0) ?: pandlRow.createCell(0) + panlCellTitle.apply { + setCellValue("Profit / (Loss)") + } + val panlCell = pandlRow.getCell(1) ?: pandlRow.createCell(1) + panlCell.apply { + cellFormula = "B${rowNum-1}-B${rowNum}" + cellStyle.dataFormat = accountingStyle + } + CellUtil.setCellStyleProperty(panlCellTitle,"borderBottom", BorderStyle.DOUBLE) + CellUtil.setCellStyleProperty(panlCell,"borderBottom", BorderStyle.DOUBLE) + + return workbook + } + } 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 11f16de..df0dc8e 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 @@ -6,10 +6,6 @@ import com.ffii.tsms.modules.data.entity.projections.StaffSearchInfo import com.ffii.tsms.modules.project.entity.* import com.ffii.tsms.modules.report.service.ReportService import com.ffii.tsms.modules.project.service.InvoiceService -import com.ffii.tsms.modules.report.web.model.FinancialStatusReportRequest -import com.ffii.tsms.modules.report.web.model.ProjectCashFlowReportRequest -import com.ffii.tsms.modules.report.web.model.StaffMonthlyWorkHourAnalysisReportRequest -import com.ffii.tsms.modules.report.web.model.LateStartReportRequest import com.ffii.tsms.modules.project.entity.ProjectRepository import com.ffii.tsms.modules.timesheet.entity.LeaveRepository import com.ffii.tsms.modules.timesheet.entity.TimesheetRepository @@ -38,6 +34,7 @@ import com.ffii.tsms.modules.data.entity.CustomerRepository import org.springframework.data.jpa.repository.JpaRepository import com.ffii.tsms.modules.data.entity.Customer import com.ffii.tsms.modules.project.entity.Project +import com.ffii.tsms.modules.report.web.model.* @RestController @RequestMapping("/reports") @@ -165,11 +162,29 @@ fun downloadLateStartReport(@RequestBody @Valid request: LateStartReportRequest) .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) .body(ByteArrayResource(reportResult)) } - + + // For API TESTING @GetMapping("/financialReport/{id}") fun getFinancialReport(@PathVariable id: Long): List> { -// println(excelReportService.genFinancialStatusReport(id)) return excelReportService.getFinancialStatus(id) } + // For API TESTING + @GetMapping("/manHoursSpent/{id}") + fun getManhoursSpent(@PathVariable id: Long): List> { + return excelReportService.getManhoursSpent(id, "2024-05", "2025-05") + } + + // P&L Report + @PostMapping("/projectpandlreport") + @Throws(ServletRequestBindingException::class, IOException::class) + fun getProjectPandLReport(@RequestBody @Valid request: PandLReportRequest): ResponseEntity { + println(request) + val reportResult: ByteArray = excelReportService.genPandLReport(request.projectId, request.startMonth, request.endMonth) + + return ResponseEntity.ok() + .header("filename", "Project P&L Report - " + LocalDate.now() + ".xlsx") + .body(ByteArrayResource(reportResult)) + } + } \ 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 8a85bc5..0264e98 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 @@ -6,6 +6,13 @@ import java.time.YearMonth data class FinancialStatusReportRequest ( val teamLeadId: Long ) + +data class PandLReportRequest ( + val projectId: Long, + val startMonth: String, + val endMonth: String, +) + data class ProjectCashFlowReportRequest ( val projectId: Long, val dateType: String, // "Date", "Month" diff --git a/src/main/resources/templates/report/AR07_Project P&L Report v02.xlsx b/src/main/resources/templates/report/AR07_Project P&L Report v02.xlsx index d0e818220f197bf5b426f756bb782a33b35fd421..7febce934dfae57a46ec459c555f0af86810111e 100644 GIT binary patch delta 5129 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