diff --git a/src/main/java/com/ffii/tsms/modules/data/service/CustomerContactService.kt b/src/main/java/com/ffii/tsms/modules/data/service/CustomerContactService.kt index 350f798..6d80457 100644 --- a/src/main/java/com/ffii/tsms/modules/data/service/CustomerContactService.kt +++ b/src/main/java/com/ffii/tsms/modules/data/service/CustomerContactService.kt @@ -17,7 +17,7 @@ class CustomerContactService( } fun findByContactId(contactId: Long): CustomerContact { - return customerContactRepository.findById(contactId).orElseThrow() + return customerContactRepository.findById(contactId).orElse(null) } fun saveContactsByCustomer(saveCustomerId: Long, saveCustomerContact: List) { diff --git a/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt b/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt index b9532c9..26d2695 100644 --- a/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt +++ b/src/main/java/com/ffii/tsms/modules/data/service/StaffsService.kt @@ -178,7 +178,7 @@ open class StaffsService( val company = companyRepository.findById(req.companyId).orElseThrow() val grade = if (req.gradeId != null && req.gradeId > 0L) gradeRepository.findById(req.gradeId).orElseThrow() else null val team = if (req.teamId != null && req.teamId > 0L) teamRepository.findById(req.teamId).orElseThrow() else null - val salary = salaryRepository.findById(req.salaryId).orElseThrow() + val salary = salaryRepository.findBySalaryPoint(req.salaryId).orElseThrow() val department = departmentRepository.findById(req.departmentId).orElseThrow() staff.apply { diff --git a/src/main/java/com/ffii/tsms/modules/project/entity/Project.kt b/src/main/java/com/ffii/tsms/modules/project/entity/Project.kt index 9294413..533dcd3 100644 --- a/src/main/java/com/ffii/tsms/modules/project/entity/Project.kt +++ b/src/main/java/com/ffii/tsms/modules/project/entity/Project.kt @@ -123,4 +123,12 @@ open class Project : BaseEntity() { // @JsonBackReference @JoinColumn(name = "mainProjectId") open var mainProject: Project? = null + + @ManyToOne + @JoinColumn(name = "customerContactId") + open var customerContact: CustomerContact? = null + + @ManyToOne + @JoinColumn(name = "subsidiaryContactId") + open var subsidiaryContact: SubsidiaryContact? = null } \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/project/entity/ProjectRepository.kt b/src/main/java/com/ffii/tsms/modules/project/entity/ProjectRepository.kt index 0f4b06b..57d1720 100644 --- a/src/main/java/com/ffii/tsms/modules/project/entity/ProjectRepository.kt +++ b/src/main/java/com/ffii/tsms/modules/project/entity/ProjectRepository.kt @@ -21,12 +21,12 @@ interface ProjectRepository : AbstractRepository { fun findAllByPlanStartLessThanEqualAndPlanEndGreaterThanEqual(remainedDateFrom: LocalDate?, remainedDateTo: LocalDate?):List - //fun findAllByDateRange(start: LocalDate, end: LocalDate): List - @Query("SELECT max(cast(substring_index(substring_index(p.code, '-', 2), '-', -1) as long)) FROM Project p WHERE p.isClpProject = ?1 and p.id != ?2" + "") fun getLatestCodeNumberByMainProject(isClpProject: Boolean, id: Serializable?): Long? - @Query("SELECT max(cast(substring_index(p.code, '-', -1) as long)) FROM Project p WHERE p.code like ?1 and p.id != ?2") + @Query("SELECT max(case when length(p.code) - length(replace(p.code, '-', '')) > 1 then cast(substring_index(p.code, '-', -1) as long) end) FROM Project p WHERE p.code like ?1 and p.id != ?2") fun getLatestCodeNumberBySubProject(code: String, id: Serializable?): Long? + + fun findAllByStatusIsNotAndMainProjectIsNull(status: String): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/project/entity/projections/ProjectResourceReport.kt b/src/main/java/com/ffii/tsms/modules/project/entity/projections/ProjectResourceReport.kt new file mode 100644 index 0000000..d949faa --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/project/entity/projections/ProjectResourceReport.kt @@ -0,0 +1,15 @@ +package com.ffii.tsms.modules.project.entity.projections + +data class ProjectResourceReport ( + val code: String, + val name: String, + val team: String, + val client: String, + val plannedBudget: Double, + val actualConsumedBudget: Double, + val plannedManhour: Double, + val actualConsumedManhour: Double, + val budgetConsumptionRate: Double, + val manhourConsumptionRate: Double, + val status: String +) \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/project/entity/projections/ProjectSearchInfo.kt b/src/main/java/com/ffii/tsms/modules/project/entity/projections/ProjectSearchInfo.kt index 72dfde5..7843a95 100644 --- a/src/main/java/com/ffii/tsms/modules/project/entity/projections/ProjectSearchInfo.kt +++ b/src/main/java/com/ffii/tsms/modules/project/entity/projections/ProjectSearchInfo.kt @@ -11,6 +11,9 @@ interface ProjectSearchInfo { val code: String? val status: String? + @get:Value("#{target.mainProject?.code}") + val mainProject: String? + @get:Value("#{target.projectCategory.name}") val category: String? diff --git a/src/main/java/com/ffii/tsms/modules/project/service/ProjectsService.kt b/src/main/java/com/ffii/tsms/modules/project/service/ProjectsService.kt index ad4e206..16012b6 100644 --- a/src/main/java/com/ffii/tsms/modules/project/service/ProjectsService.kt +++ b/src/main/java/com/ffii/tsms/modules/project/service/ProjectsService.kt @@ -6,6 +6,7 @@ import com.ffii.tsms.modules.data.service.CustomerContactService import com.ffii.tsms.modules.project.entity.projections.ProjectSearchInfo import com.ffii.tsms.modules.data.service.CustomerService import com.ffii.tsms.modules.data.service.GradeService +import com.ffii.tsms.modules.data.service.SubsidiaryContactService import com.ffii.tsms.modules.project.entity.* import com.ffii.tsms.modules.project.entity.Milestone import com.ffii.tsms.modules.project.entity.projections.InvoiceInfoSearchInfo @@ -44,10 +45,12 @@ open class ProjectsService( private val taskGroupRepository: TaskGroupRepository, private val timesheetRepository: TimesheetRepository, private val taskTemplateRepository: TaskTemplateRepository, - private val subsidiaryRepository: SubsidiaryRepository, private val subsidiaryContactRepository: SubsidiaryContactRepository + private val subsidiaryContactService: SubsidiaryContactService, + private val subsidiaryContactRepository: SubsidiaryContactRepository ) { open fun allProjects(): List { - return projectRepository.findProjectSearchInfoByOrderByCreatedDesc().sortedByDescending { it.status?.lowercase() != "deleted" } + return projectRepository.findProjectSearchInfoByOrderByCreatedDesc() + .sortedByDescending { it.status?.lowercase() != "deleted" } } open fun allInvoices(): List { @@ -63,63 +66,57 @@ open class ProjectsService( } open fun markDeleted(id: Long) { - projectRepository.save(projectRepository.findById(id).orElseThrow() - .apply { - deleted = true - status = "Deleted" - }) + projectRepository.save(projectRepository.findById(id).orElseThrow().apply { + deleted = true + status = "Deleted" + }) } open fun allAssignedProjects(): List { return SecurityUtils.getUser().getOrNull()?.let { user -> staffRepository.findByUserId(user.id).getOrNull()?.let { staff -> - staffAllocationRepository.findAssignedProjectsByStaff(staff) - .mapNotNull { - it.project?.let { project -> - val timesheetHours = timesheetRepository.totalHoursConsumedByProject(project) - - AssignedProject( - id = project.id!!, - code = project.code!!, - name = project.name!!, - tasks = projectTaskRepository.findAllByProject(project).mapNotNull { pt -> pt.task }, - milestones = milestoneRepository.findAllByProject(project) - .filter { milestone -> milestone.taskGroup?.id != null } - .associateBy { milestone -> milestone.taskGroup!!.id!! } - .mapValues { (_, milestone) -> - MilestoneInfo( - startDate = milestone.startDate?.format(DateTimeFormatter.ISO_LOCAL_DATE), - endDate = milestone.endDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) - ) - }, - hoursAllocated = project.totalManhour ?: 0.0, - hoursAllocatedOther = 0.0, - hoursSpent = timesheetHours.normalConsumed, - hoursSpentOther = timesheetHours.otConsumed - ) - } + staffAllocationRepository.findAssignedProjectsByStaff(staff).mapNotNull { + it.project?.let { project -> + val timesheetHours = timesheetRepository.totalHoursConsumedByProject(project) + + AssignedProject(id = project.id!!, + code = project.code!!, + name = project.name!!, + tasks = projectTaskRepository.findAllByProject(project).mapNotNull { pt -> pt.task }, + milestones = milestoneRepository.findAllByProject(project) + .filter { milestone -> milestone.taskGroup?.id != null } + .associateBy { milestone -> milestone.taskGroup!!.id!! } + .mapValues { (_, milestone) -> + MilestoneInfo( + startDate = milestone.startDate?.format(DateTimeFormatter.ISO_LOCAL_DATE), + endDate = milestone.endDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) + ) + }, + hoursAllocated = project.totalManhour ?: 0.0, + hoursAllocatedOther = 0.0, + hoursSpent = timesheetHours.normalConsumed, + hoursSpentOther = timesheetHours.otConsumed + ) } + } } } ?: emptyList() } open fun allProjectWithTasks(): List { return projectRepository.findAll().map { project -> - ProjectWithTasks( - id = project.id!!, + ProjectWithTasks(id = project.id!!, code = project.code!!, name = project.name!!, tasks = projectTaskRepository.findAllByProject(project).mapNotNull { pt -> pt.task }, milestones = milestoneRepository.findAllByProject(project) .filter { milestone -> milestone.taskGroup?.id != null } - .associateBy { milestone -> milestone.taskGroup!!.id!! } - .mapValues { (_, milestone) -> + .associateBy { milestone -> milestone.taskGroup!!.id!! }.mapValues { (_, milestone) -> MilestoneInfo( startDate = milestone.startDate?.format(DateTimeFormatter.ISO_LOCAL_DATE), endDate = milestone.endDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ) - } - ) + }) } } @@ -135,15 +132,28 @@ open class ProjectsService( if (latestProjectCode != null) { val lastFix = latestProjectCode - return "$prefix-" + String.format("%04d", lastFix + 1L) + return "$prefix-" + String.format("%04d", lastFix + 1L) } else { return "$prefix-0001" } } + + open fun createSubProjectCode(mainProject: Project, project: Project): String { + val prefix = mainProject.code + + val latestProjectCode = projectRepository.getLatestCodeNumberBySubProject(prefix!!, project.id ?: -1) + + if (latestProjectCode != null) { + val lastFix = latestProjectCode + return "$prefix-" + String.format("%03d", lastFix + 1L) + } else { + return "$prefix-001" + } + } + @Transactional open fun saveProject(request: NewProjectRequest): NewProjectResponse { - val projectCategory = - projectCategoryRepository.findById(request.projectCategoryId).orElseThrow() + val projectCategory = projectCategoryRepository.findById(request.projectCategoryId).orElseThrow() val fundingType = fundingTypeRepository.findById(request.fundingTypeId).orElseThrow() val serviceType = serviceTypeRepository.findById(request.serviceTypeId).orElseThrow() val contractType = contractTypeRepository.findById(request.contractTypeId).orElseThrow() @@ -158,6 +168,7 @@ open class ProjectsService( val subsidiaryContact = subsidiaryContactRepository.findById(request.clientContactId).orElse(null) val clientContact = customerContactService.findByContactId(request.clientContactId) val customerSubsidiary = request.clientSubsidiaryId?.let { subsidiaryService.findSubsidiary(it) } + val mainProject = if (request.mainProjectId != null && request.mainProjectId > 0) projectRepository.findById(request.mainProjectId).orElse(null) else null val allTasksMap = tasksService.allTasks().associateBy { it.id } val taskGroupMap = tasksService.allTaskGroups().associateBy { it.id } @@ -169,7 +180,7 @@ open class ProjectsService( project.apply { name = request.projectName description = request.projectDescription - code = if (this.code.isNullOrEmpty()) createProjectCode(request.isClpProject, project) else this.code + code = if (this.code.isNullOrEmpty() && request.mainProjectId == null) createProjectCode(request.isClpProject, project) else if (this.code.isNullOrEmpty() && request.mainProjectId != null && mainProject != null) createSubProjectCode(mainProject, project) else this.code expectedTotalFee = request.expectedProjectFee totalManhour = request.totalManhour actualStart = request.projectActualStart @@ -179,6 +190,7 @@ open class ProjectsService( else if (this.actualStart != null) "On-going" else "Pending To Start" isClpProject = request.isClpProject + this.mainProject = mainProject this.projectCategory = projectCategory this.fundingType = fundingType @@ -191,10 +203,15 @@ open class ProjectsService( this.teamLead = teamLead this.customer = customer - custLeadName = if (request.isSubsidiaryContact == null || !request.isSubsidiaryContact) clientContact.name else subsidiaryContact.name - custLeadEmail = if (request.isSubsidiaryContact == null || !request.isSubsidiaryContact) clientContact.email else subsidiaryContact.email - custLeadPhone = if (request.isSubsidiaryContact == null || !request.isSubsidiaryContact) clientContact.phone else subsidiaryContact.phone + custLeadName = + if (request.isSubsidiaryContact == null || !request.isSubsidiaryContact) clientContact.name else subsidiaryContact.name + custLeadEmail = + if (request.isSubsidiaryContact == null || !request.isSubsidiaryContact) clientContact.email else subsidiaryContact.email + custLeadPhone = + if (request.isSubsidiaryContact == null || !request.isSubsidiaryContact) clientContact.phone else subsidiaryContact.phone this.customerSubsidiary = customerSubsidiary + this.customerContact = if (customerSubsidiary == null) clientContact else null + this.subsidiaryContact = if (customerSubsidiary != null) subsidiaryContact else null } @@ -203,8 +220,7 @@ open class ProjectsService( val milestones = request.taskGroups.entries.map { (taskStageId, taskGroupAllocation) -> val taskGroup = taskGroupRepository.findById(taskStageId).orElse(TaskGroup()) ?: TaskGroup() val milestone = if (project.id != null && project.id!! > 0L) milestoneRepository.findByProjectAndTaskGroup( - project, - taskGroup + project, taskGroup ) ?: Milestone() else Milestone() milestone.apply { val newMilestone = this @@ -233,8 +249,7 @@ open class ProjectsService( taskGroupAllocation.taskIds.map { taskId -> val projectTask = if (project.id != null && project.id!! > 0L) projectTaskRepository.findByProjectAndTask( - project, - allTasksMap[taskId]!! + project, allTasksMap[taskId]!! ) ?: ProjectTask() else ProjectTask() projectTask.apply { @@ -252,8 +267,7 @@ open class ProjectsService( val gradeAllocations = request.manhourPercentageByGrade.entries.map { val gradeAllocation = if (project.id != null && project.id!! > 0L) gradeAllocationRepository.findByProjectAndGrade( - project, - gradeMap[it.key]!! + project, gradeMap[it.key]!! ) ?: GradeAllocation() else GradeAllocation() gradeAllocation.apply { @@ -313,16 +327,16 @@ open class ProjectsService( val project = projectRepository.findById(projectId) return project.getOrNull()?.let { - val customerContact = - it.customer?.id?.let { customerId -> customerContactService.findAllByCustomerId(customerId) } - ?: emptyList() +// val subsidiaryContact = it.customerSubsidiary?.id?.let { subsidiaryId -> +// subsidiaryContactService.findAllBySubsidiaryId(subsidiaryId) +// } ?: emptyList() +// val customerContact = it.customer?.id?.let { customerId -> customerContactService.findAllByCustomerId(customerId) } +// ?: emptyList() - val milestoneMap = it.milestones - .filter { milestone -> milestone.taskGroup?.id != null } + val milestoneMap = it.milestones.filter { milestone -> milestone.taskGroup?.id != null } .associateBy { milestone -> milestone.taskGroup!!.id!! } - EditProjectDetails( - projectId = it.id, + EditProjectDetails(projectId = it.id, projectDeleted = it.deleted, projectCode = it.code, projectName = it.name, @@ -333,6 +347,7 @@ open class ProjectsService( projectActualEnd = it.actualEnd, projectStatus = it.status, isClpProject = it.isClpProject, + mainProjectId = it.mainProject?.id, serviceTypeId = it.serviceType?.id, fundingTypeId = it.fundingType?.id, contractTypeId = it.contractType?.id, @@ -341,7 +356,8 @@ open class ProjectsService( buildingTypeIds = it.buildingTypes.mapNotNull { buildingType -> buildingType.id }, workNatureIds = it.workNatures.mapNotNull { workNature -> workNature.id }, clientId = it.customer?.id, - clientContactId = customerContact.find { contact -> contact.name == it.custLeadName }?.id, + clientContactId = it.subsidiaryContact?.id ?: it.customerContact?.id, +// subsidiaryContact.find { contact -> contact.name == it.custLeadName }?.id ?: customerContact.find { contact -> contact.name == it.custLeadName }?.id, clientSubsidiaryId = it.customerSubsidiary?.id, totalManhour = it.totalManhour, manhourPercentageByGrade = gradeAllocationRepository.findByProject(it) @@ -349,8 +365,7 @@ open class ProjectsService( .associate { allocation -> Pair(allocation.grade!!.id!!, allocation.manhour ?: 0.0) }, taskGroups = projectTaskRepository.findAllByProject(it) .mapNotNull { projectTask -> if (projectTask.task?.taskGroup?.id != null) projectTask.task else null } - .groupBy { task -> task.taskGroup!!.id!! } - .mapValues { (taskGroupId, tasks) -> + .groupBy { task -> task.taskGroup!!.id!! }.mapValues { (taskGroupId, tasks) -> TaskGroupAllocation( taskIds = tasks.mapNotNull { task -> task.id }, percentAllocation = milestoneMap[taskGroupId]?.stagePercentAllocation ?: 0.0 @@ -359,8 +374,9 @@ open class ProjectsService( allocatedStaffIds = staffAllocationRepository.findByProject(it) .mapNotNull { allocation -> allocation.staff?.id }, milestones = milestoneMap.mapValues { (_, milestone) -> - com.ffii.tsms.modules.project.web.models.Milestone( - startDate = milestone.startDate?.format(DateTimeFormatter.ISO_LOCAL_DATE), + com.ffii.tsms.modules.project.web.models.Milestone(startDate = milestone.startDate?.format( + DateTimeFormatter.ISO_LOCAL_DATE + ), endDate = milestone.endDate?.format(DateTimeFormatter.ISO_LOCAL_DATE), payments = milestone.milestonePayments.map { payment -> PaymentInputs( @@ -369,8 +385,7 @@ open class ProjectsService( description = payment.description!!, date = payment.date!!.format(DateTimeFormatter.ISO_LOCAL_DATE) ) - } - ) + }) }, expectedProjectFee = it.expectedTotalFee ) @@ -400,4 +415,37 @@ open class ProjectsService( open fun allWorkNatures(): List { return workNatureRepository.findAll() } + + open fun allMainProjects(): List { + val mainProjects: List = projectRepository.findAllByStatusIsNotAndMainProjectIsNull("Deleted") + + return mainProjects.map { project: Project -> + val subsidiaryContact = project.customerSubsidiary?.id?.let { subsidiaryId -> + subsidiaryContactService.findAllBySubsidiaryId(subsidiaryId) + } ?: emptyList() + val customerContact = project.customer?.id?.let { customerId -> customerContactService.findAllByCustomerId(customerId) } + ?: emptyList() + + MainProjectDetails( + projectId = project.id, + projectCode = project.code, + projectName = project.name, + projectCategoryId = project.projectCategory?.id, + projectDescription = project.description, + projectLeadId = project.teamLead?.id, + projectStatus = project.status, + isClpProject = project.isClpProject, + serviceTypeId = project.serviceType?.id, + fundingTypeId = project.fundingType?.id, + contractTypeId = project.contractType?.id, + locationId = project.location?.id, + buildingTypeIds = project.buildingTypes.mapNotNull { buildingType -> buildingType.id }, + workNatureIds = project.workNatures.mapNotNull { workNature -> workNature.id }, + clientId = project.customer?.id, + clientContactId = subsidiaryContact.find { contact -> contact.name == project.custLeadName }?.id ?: customerContact.find { contact -> contact.name == project.custLeadName }?.id, + clientSubsidiaryId = project.customerSubsidiary?.id, + expectedProjectFee = project.expectedTotalFee + ) + } + } } diff --git a/src/main/java/com/ffii/tsms/modules/project/web/ProjectsController.kt b/src/main/java/com/ffii/tsms/modules/project/web/ProjectsController.kt index 0bf696a..447a50b 100644 --- a/src/main/java/com/ffii/tsms/modules/project/web/ProjectsController.kt +++ b/src/main/java/com/ffii/tsms/modules/project/web/ProjectsController.kt @@ -2,8 +2,10 @@ package com.ffii.tsms.modules.project.web import com.ffii.core.exception.NotFoundException import com.ffii.tsms.modules.data.entity.* +import com.ffii.tsms.modules.project.entity.Project import com.ffii.tsms.modules.project.entity.projections.ProjectSearchInfo import com.ffii.tsms.modules.project.entity.ProjectCategory +import com.ffii.tsms.modules.project.entity.ProjectRepository import com.ffii.tsms.modules.project.service.ProjectsService import com.ffii.tsms.modules.project.web.models.* import jakarta.validation.Valid @@ -12,12 +14,17 @@ import org.springframework.web.bind.annotation.* @RestController @RequestMapping("/projects") -class ProjectsController(private val projectsService: ProjectsService) { +class ProjectsController(private val projectsService: ProjectsService, private val projectRepository: ProjectRepository) { @GetMapping fun allProjects(): List { return projectsService.allProjects() } + @GetMapping("/main") + fun allMainProjects(): List { + return projectsService.allMainProjects() + } + @DeleteMapping("/{id}") @ResponseStatus(HttpStatus.NO_CONTENT) fun deleteProject(@PathVariable id: Long) { diff --git a/src/main/java/com/ffii/tsms/modules/project/web/models/EditProjectDetails.kt b/src/main/java/com/ffii/tsms/modules/project/web/models/EditProjectDetails.kt index c7ea88c..704f0fd 100644 --- a/src/main/java/com/ffii/tsms/modules/project/web/models/EditProjectDetails.kt +++ b/src/main/java/com/ffii/tsms/modules/project/web/models/EditProjectDetails.kt @@ -15,6 +15,7 @@ data class EditProjectDetails( val projectActualEnd: LocalDate?, val projectStatus: String?, val isClpProject: Boolean?, + val mainProjectId: Long?, val serviceTypeId: Long?, val fundingTypeId: Long?, diff --git a/src/main/java/com/ffii/tsms/modules/project/web/models/MainProjectDetails.kt b/src/main/java/com/ffii/tsms/modules/project/web/models/MainProjectDetails.kt new file mode 100644 index 0000000..3c2a748 --- /dev/null +++ b/src/main/java/com/ffii/tsms/modules/project/web/models/MainProjectDetails.kt @@ -0,0 +1,30 @@ +package com.ffii.tsms.modules.project.web.models + +data class MainProjectDetails ( + + // Project details + val projectId: Long?, + val projectCode: String?, + val projectName: String?, + + val projectCategoryId: Long?, + val projectDescription: String?, + + val projectLeadId: Long?, + val projectStatus: String?, + val isClpProject: Boolean?, + + val serviceTypeId: Long?, + val fundingTypeId: Long?, + val contractTypeId: Long?, + val locationId: Long?, + val buildingTypeIds: List, + val workNatureIds: List, + + // Client details + val clientId: Long?, + val clientContactId: Long?, + val clientSubsidiaryId: Long?, + + val expectedProjectFee: Double?, +) \ No newline at end of file diff --git a/src/main/java/com/ffii/tsms/modules/project/web/models/NewProjectRequest.kt b/src/main/java/com/ffii/tsms/modules/project/web/models/NewProjectRequest.kt index 18bcfab..1f59b33 100644 --- a/src/main/java/com/ffii/tsms/modules/project/web/models/NewProjectRequest.kt +++ b/src/main/java/com/ffii/tsms/modules/project/web/models/NewProjectRequest.kt @@ -17,6 +17,7 @@ data class NewProjectRequest( val projectActualStart: LocalDate?, val projectActualEnd: LocalDate?, val isClpProject: Boolean?, + val mainProjectId: Long?, val serviceTypeId: Long, val fundingTypeId: Long, 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 53f6d3d..93b913d 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 @@ -7,7 +7,12 @@ import com.ffii.tsms.modules.data.entity.Staff import com.ffii.tsms.modules.data.entity.Team 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.* @@ -20,6 +25,7 @@ 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 @@ -27,6 +33,8 @@ import java.util.* data class DayInfo(val date: String?, val weekday: String?) + + @Service open class ReportService( private val jdbcDao: JdbcDao, @@ -41,6 +49,7 @@ open class ReportService( private val MONTHLY_WORK_HOURS_ANALYSIS_REPORT = "templates/report/AR08_Monthly Work Hours Analysis Report.xlsx" private val SALART_LIST_TEMPLATE = "templates/report/Salary Template.xlsx" private val LATE_START_REPORT = "templates/report/AR01_Late Start Report v01.xlsx" + private val RESOURCE_OVERCONSUMPTION_REPORT = "templates/report/AR03_Resource Overconsumption.xlsx" // ==============================|| GENERATE REPORT ||============================== // @@ -143,6 +152,29 @@ open class ReportService( return outputStream.toByteArray() } + @Throws(IOException::class) + fun generateProjectResourceOverconsumptionReport( + team: String, + customer: String, + result: List>, + lowerLimit: Double + ): ByteArray { + // Generate the Excel report with query results + val workbook: Workbook = createProjectResourceOverconsumptionReport( + team, + customer, + result, + lowerLimit, + RESOURCE_OVERCONSUMPTION_REPORT + ) + // Write the workbook to a ByteArrayOutputStream + val outputStream: ByteArrayOutputStream = ByteArrayOutputStream() + workbook.write(outputStream) + workbook.close() + + return outputStream.toByteArray() + } + @Throws(IOException::class) fun exportSalaryList(salarys: List): ByteArray { // Generate the Excel report with query results @@ -733,10 +765,13 @@ open class ReportService( var projectList: List = listOf() println("----timesheets-----") println(timesheets) + + println("----leaves-----") + println(leaves) // result = timesheet record mapped var result: Map = mapOf() if (timesheets.isNotEmpty()) { - projectList = timesheets.map{ "${it["code"]}\n ${it["name"]}"}.toList() + projectList = timesheets.map{ "${it["code"]}\n ${it["name"]}"}.toList().distinct() result = timesheets.groupBy( { it["id"].toString() }, { mapOf( @@ -916,41 +951,40 @@ open class ReportService( columnSize++ } println(columnSize) - ///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////// main data ////////////////////////////////////////////////////////////// if (timesheets.isNotEmpty()) { - projectList.forEach { _ -> + + projectList.forEachIndexed { index, _ -> for (i in 0 until rowSize) { - tempCell = sheet.getRow(8 + i).createCell(columnIndex) + tempCell = sheet.getRow(8 + i).createCell(columnIndex + index) tempCell.setCellValue(0.0) tempCell.cellStyle.dataFormat = accountingStyle } - result.forEach{(id, list) -> - for (i in 0 until id.toInt()) { - val temp: List> = list as List> - temp.forEachIndexed { i, _ -> - dayInt = temp[i]["date"].toString().toInt() - tempCell = sheet.getRow(dayInt.plus(7)).createCell(columnIndex) - tempCell.setCellValue(temp[i]["normalConsumed"] as Double) - } + } + result.forEach{ (id, list) -> + val temp: List> = list as List> + temp.forEachIndexed { index, _ -> + dayInt = temp[index]["date"].toString().toInt() + tempCell = sheet.getRow(dayInt.plus(7)).createCell(columnIndex) + tempCell.setCellValue((temp[index]["normalConsumed"] as Double) + (temp[index]["otConsumed"] as Double)) } - } columnIndex++ } } - // leave hours data + /////////////////////////////////////////////////////////////// leave hours data ////////////////////////////////////////////////////////////// + for (i in 0 until rowSize) { + tempCell = sheet.getRow(8 + i).createCell(columnIndex) + tempCell.setCellValue(0.0) + tempCell.cellStyle.dataFormat = accountingStyle + } if (leaves.isNotEmpty()) { leaves.forEach { leave -> - for (i in 0 until rowSize) { - tempCell = sheet.getRow(8 + i).createCell(columnIndex) - tempCell.setCellValue(0.0) - tempCell.cellStyle.dataFormat = accountingStyle - } dayInt = leave["recordDate"].toString().toInt() tempCell = sheet.getRow(dayInt.plus(7)).createCell(columnIndex) tempCell.setCellValue(leave["leaveHours"] as Double) } } - ///////////////////////////////////////////////////////// Leave Hours //////////////////////////////////////////////////////////////////// + ///////////////////////////////////////////////////////// Leave Hours title //////////////////////////////////////////////////////////////////// tempCell = sheet.getRow(rowIndex).createCell(columnIndex) tempCell.setCellValue("Leave Hours") tempCell.cellStyle = boldStyle @@ -1027,6 +1061,73 @@ open class ReportService( return workbook } + + private fun createProjectResourceOverconsumptionReport( + team: String, + customer: String, + result: List>, + lowerLimit: Double, + templatePath: 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 sheet: Sheet = workbook.getSheetAt(0) + + val alignCenterStyle = workbook.createCellStyle() + alignCenterStyle.alignment = HorizontalAlignment.CENTER // Set the alignment to center + + var rowIndex = 1 // Assuming the location is in (1,2), which is the report date field + var columnIndex = 2 + var tempRow = sheet.getRow(rowIndex) + var tempCell = tempRow.getCell(columnIndex) + tempCell.setCellValue(FORMATTED_TODAY) + + rowIndex = 2 + tempCell = sheet.getRow(rowIndex).getCell(columnIndex) + tempCell.setCellValue(team) + + rowIndex = 3 + tempCell = sheet.getRow(rowIndex).getCell(columnIndex) + tempCell.setCellValue(customer) + + rowIndex = 6 + columnIndex = 0 + result.forEachIndexed { index, obj -> + tempCell = sheet.getRow(rowIndex).createCell(columnIndex) + tempCell.setCellValue((index + 1).toDouble()) + val keys = obj.keys.toList() + keys.forEachIndexed { keyIndex, key -> + tempCell = sheet.getRow(rowIndex).getCell(columnIndex + keyIndex + 1) ?: sheet.getRow(rowIndex).createCell(columnIndex + keyIndex + 1) + when (obj[key]) { + is Double -> tempCell.setCellValue(obj[key] as Double) + else -> tempCell.setCellValue(obj[key] as String ) + } + } + rowIndex++ + } + + val sheetCF = sheet.sheetConditionalFormatting + val rule1 = sheetCF.createConditionalFormattingRule("AND(J7 >= $lowerLimit, J7 <= 1)") + val rule2 = sheetCF.createConditionalFormattingRule("J7 > 1") + var fillOrange = rule1.createPatternFormatting() + fillOrange.setFillBackgroundColor(IndexedColors.LIGHT_ORANGE.index); + fillOrange.setFillPattern(PatternFormatting.SOLID_FOREGROUND) + + var fillRed = rule2.createPatternFormatting() + fillRed.setFillBackgroundColor(IndexedColors.ROSE.index); + fillRed.setFillPattern(PatternFormatting.SOLID_FOREGROUND) + + val cfRules = arrayOf(rule1, rule2) + val regions = arrayOf(CellRangeAddress.valueOf("\$J7:\$K${rowIndex+1}")) + sheetCF.addConditionalFormatting(regions, cfRules); + + return workbook + } + //createLateStartReport private fun createLateStartReport( team: Team, @@ -1173,6 +1274,81 @@ open class ReportService( return jdbcDao.queryForList(sql.toString(), args) } + open fun getProjectResourceOverconsumptionReport(args: Map): List> { + val sql = StringBuilder("WITH teamNormalConsumed AS (" + + " SELECT " + + " s.teamId, " + + " pt.project_id, " + + " SUM(tns.totalConsumed) AS totalConsumed " + + " 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 " + + " GROUP BY t.staffId, t.projectTaskId " + + " ) AS tns " + + " INNER JOIN project_task pt ON tns.taskId = pt.id " + + " JOIN staff s ON tns.staffId = s.id " + + " JOIN team t ON s.teamId = t.id " + + " GROUP BY teamId, project_id " + + " ) " + + " SELECT " + + " p.code, " + + " p.name, " + + " t.code as team, " + + " c.code as client, " + + " p.expectedTotalFee as plannedBudget, " + + " COALESCE((tns.totalConsumed * sa.hourlyRate), 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) >= :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' " + + " when (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= 1 " + + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= 1 " + + " then '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 teamNormalConsumed tns on tns.project_id = p.id " + + " WHERE p.deleted = false " + + " and p.status = 'On-going' " + ) + if (args != null) { + var statusFilter: String = "" + if (args.containsKey("teamId")) + sql.append("and t.id = :teamId") + if (args.containsKey("custId")) + sql.append("and c.id = :custId") + if (args.containsKey("status")) + statusFilter = when (args.get("status")) { + "Potential Overconsumption" -> "and " + + " ((COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= 0.9 " + + " and (COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) <= 1 " + + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= 0.9 " + + " and (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) <= 1)" + "Overconsumption" -> "and " + + " ((COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) >= 1 " + + " or (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) >= 1) " + "Within Budget" -> "and " + + " ((COALESCE((tns.totalConsumed * sa.hourlyRate), 0) / p.expectedTotalFee) < 0.9 " + + " and " + + " (COALESCE(tns.totalConsumed, 0) / COALESCE(p.totalManhour, 0)) <= 0.9 " + else -> "" + } + sql.append(statusFilter) + } + return jdbcDao.queryForList(sql.toString(), args) + } open fun getManhoursSpent(projectId: Long, startMonth: String, endMonth: String): List>{ val sql = StringBuilder( " with cte_timesheet as (" 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 df0dc8e..a336018 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 @@ -1,11 +1,23 @@ package com.ffii.tsms.modules.report.web +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.ffii.tsms.modules.data.entity.Customer import com.ffii.tsms.modules.data.entity.StaffRepository //import com.ffii.tsms.modules.data.entity.projections.FinancialStatusReportInfo import com.ffii.tsms.modules.data.entity.projections.StaffSearchInfo +import com.ffii.tsms.modules.data.entity.Team +import com.ffii.tsms.modules.data.service.CustomerService +import com.ffii.tsms.modules.data.service.TeamService import com.ffii.tsms.modules.project.entity.* -import com.ffii.tsms.modules.report.service.ReportService +import com.ffii.tsms.modules.project.entity.projections.ProjectResourceReport import com.ffii.tsms.modules.project.service.InvoiceService +import com.ffii.tsms.modules.report.service.ReportService +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.ProjectResourceOverconsumptionReport +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 @@ -13,17 +25,11 @@ import com.ffii.tsms.modules.timesheet.entity.projections.ProjectMonthlyHoursWit import jakarta.validation.Valid import org.springframework.core.io.ByteArrayResource import org.springframework.core.io.Resource -import org.springframework.http.ResponseEntity -import org.springframework.web.bind.ServletRequestBindingException -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController import org.springframework.http.HttpHeaders import org.springframework.http.MediaType +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.ServletRequestBindingException +import org.springframework.web.bind.annotation.* import java.io.IOException import java.time.LocalDate import java.net.URLEncoder @@ -32,7 +38,6 @@ import org.springframework.stereotype.Controller import com.ffii.tsms.modules.data.entity.TeamRepository 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.* @@ -49,6 +54,8 @@ class ReportController( private val customerRepository: CustomerRepository, private val staffRepository: StaffRepository, private val leaveRepository: LeaveRepository, + private val teamService: TeamService, + private val customerService: CustomerService, private val invoiceService: InvoiceService) { @PostMapping("/fetchProjectsFinancialStatusReport") @@ -87,13 +94,16 @@ class ReportController( val nextMonth = request.yearMonth.plusMonths(1).atDay(1) val staff = staffRepository.findById(request.id).orElseThrow() + println(thisMonth) + println(nextMonth) val args: Map = mutableMapOf( "staffId" to request.id, "startDate" to thisMonth, "endDate" to nextMonth, ) - val timesheets= excelReportService.getTimesheet(args) - val leaves= excelReportService.getLeaves(args) + val timesheets = excelReportService.getTimesheet(args) + val leaves = excelReportService.getLeaves(args) + println(leaves) val reportResult: ByteArray = excelReportService.generateStaffMonthlyWorkHourAnalysisReport(thisMonth, staff, timesheets, leaves) // val mediaType: MediaType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") @@ -102,6 +112,34 @@ class ReportController( .header("filename", "Monthly Work Hours Analysis Report - " + staff.name + " - " + LocalDate.now() + ".xlsx") .body(ByteArrayResource(reportResult)) } + private val mapper = ObjectMapper().registerModule(KotlinModule()) + + @PostMapping("/ProjectResourceOverconsumptionReport") + @Throws(ServletRequestBindingException::class, IOException::class) + fun ProjectResourceOverconsumptionReport(@RequestBody @Valid request: ProjectResourceOverconsumptionReport): ResponseEntity { + val lowerLimit = request.lowerLimit + var team: String = "All" + var customer: String = "All" + val args: MutableMap = mutableMapOf( + "status" to request.status, + "lowerLimit" to lowerLimit + ) + if (request.teamId != null) { + args["teamId"] = request.teamId + team = teamService.find(request.teamId).orElseThrow().name + } + if (request.custId != null) { + args["custId"] = request.custId + customer = customerService.find(request.custId).orElseThrow().name + } + val result: List> = excelReportService.getProjectResourceOverconsumptionReport(args); + val reportResult: ByteArray = excelReportService.generateProjectResourceOverconsumptionReport(team, customer, result, lowerLimit) +// val mediaType: MediaType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + return ResponseEntity.ok() +// .contentType(mediaType) + .header("filename", "Project Resource Overconsumption Report - " + " - " + LocalDate.now() + ".xlsx") + .body(ByteArrayResource(reportResult)) + } @GetMapping("/test/{id}") fun test(@PathVariable id: Long): List { 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 0264e98..b5797e3 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 @@ -30,4 +30,10 @@ data class LateStartReportRequest ( val remainedDateTo: LocalDate, val customer: String, val reportDate: LocalDate +) +data class ProjectResourceOverconsumptionReport ( + val teamId: Long?, + val custId: Long?, + val status: String, + val lowerLimit: Double ) \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20240522_01_cyril/01_update_project.sql b/src/main/resources/db/changelog/changes/20240522_01_cyril/01_update_project.sql new file mode 100644 index 0000000..96bc4d4 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20240522_01_cyril/01_update_project.sql @@ -0,0 +1,20 @@ +-- liquibase formatted sql +-- changeset cyril:project + +ALTER TABLE `project` + ADD COLUMN `customerContactId` INT NULL DEFAULT NULL AFTER `mainProjectId`, +ADD COLUMN `subsidiaryContactId` INT NULL DEFAULT NULL AFTER `customerContactId`, +ADD INDEX `FK_PROJECT_ON_CUSTOMERCONTACTID` (`customerContactId` ASC) VISIBLE, +ADD INDEX `FK_PROJECT_ON_SUBSIDIARYCONTACTID` (`subsidiaryContactId` ASC) VISIBLE; +; +ALTER TABLE `project` + ADD CONSTRAINT `FK_PROJECT_ON_CUSTOMERCONTACTID` + FOREIGN KEY (`customerContactId`) + REFERENCES `customer_contact` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION, +ADD CONSTRAINT `FK_PROJECT_ON_SUBSIDIARYCONTACTID` + FOREIGN KEY (`subsidiaryContactId`) + REFERENCES `subsidiary_contact` (`id`) + ON DELETE NO ACTION + ON UPDATE NO ACTION; diff --git a/src/main/resources/templates/report/AR03_Resource Overconsumption.xlsx b/src/main/resources/templates/report/AR03_Resource Overconsumption.xlsx index dc4f048..ff19668 100644 Binary files a/src/main/resources/templates/report/AR03_Resource Overconsumption.xlsx and b/src/main/resources/templates/report/AR03_Resource Overconsumption.xlsx differ