diff --git a/src/main/kotlin/org/octopus/internal/common/Extensions.kt b/src/main/kotlin/org/octopus/internal/common/Extensions.kt new file mode 100644 index 0000000..3d87543 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/common/Extensions.kt @@ -0,0 +1,50 @@ +package org.octopus.internal.common + +import org.octopus.internal.common.models.Entry +import java.time.YearMonth +import java.time.ZoneId +import java.util.Date + +fun YearMonth.toFirstDayOfMonthDate(zoneId: ZoneId): Date { + return Date.from(this.atDay(1).atStartOfDay(zoneId).toInstant()) +} + +fun Date?.isInTargetMonth(yearMonth: YearMonth, zoneId: ZoneId): Boolean { + if (this == null) return false + return this.toYearMonth(zoneId).isSame(yearMonth) +} + +fun Date.toYearMonth(zoneId: ZoneId = ZoneId.systemDefault()): YearMonth { + return YearMonth.from(this.toInstant().atZone(zoneId)) +} + +fun YearMonth.isSame(other: YearMonth): Boolean { + return this.year == other.year && this.month == other.month +} + +fun Date.isSameMonth(other: Date): Boolean { + val instant1 = this.toInstant() + val instant2 = other.toInstant() + + val zoneId = ZoneId.systemDefault() + + val yearMonth1 = YearMonth.from(instant1.atZone(zoneId)) + val yearMonth2 = YearMonth.from(instant2.atZone(zoneId)) + + return yearMonth1.isSame(yearMonth2) +} + +fun Entry.isRecurrentActive(yearMonth: YearMonth, zoneId: ZoneId): Boolean { + if (this.recurrentMonths?.contains(yearMonth.month) != true) { + return false + } + + val startYearMonth = this.startDate?.toYearMonth(zoneId) + val endYearMonth = this.endDate?.toYearMonth(zoneId) + + val afterStart = startYearMonth == null || !yearMonth.isBefore(startYearMonth) + + val beforeEnd = endYearMonth == null || !yearMonth.isAfter(endYearMonth) + + return afterStart && beforeEnd +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/common/enums/EBusinessException.kt b/src/main/kotlin/org/octopus/internal/common/enums/EBusinessException.kt index 6351988..e94fa7b 100755 --- a/src/main/kotlin/org/octopus/internal/common/enums/EBusinessException.kt +++ b/src/main/kotlin/org/octopus/internal/common/enums/EBusinessException.kt @@ -2,5 +2,9 @@ package org.octopus.internal.common.enums enum class EBusinessException(val msg: String) { ENTITY_WITH_ID_NOT_FOUND("%s with id %s not found"), - INVALID_REQUEST("Invalid request for %s with reason: %s") + INVALID_REQUEST("Invalid request for %s with reason: %s"), + STARTING_REPORT_ALREADY_EXISTS("Starting report already exists: %s"), + STARTING_REPORT_DOESNT_EXIST("Starting report doesn't exist."), + REPORT_END_DATE_LE_START_REPORT("End date is before or the same as the starting month"), + REPORT_IS_START_NOT_CLEAR("Report start and attribute start are mismatching.") } \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/common/mappers/ReportMapper.kt b/src/main/kotlin/org/octopus/internal/common/mappers/ReportMapper.kt new file mode 100755 index 0000000..5c221f1 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/common/mappers/ReportMapper.kt @@ -0,0 +1,11 @@ +package org.octopus.internal.common.mappers + +import org.mapstruct.Mapper +import org.mapstruct.Mapping +import org.octopus.internal.common.models.Entry +import org.octopus.internal.common.models.Report +import org.octopus.internal.db.entities.EntryEntity +import org.octopus.internal.db.entities.ReportEntity + +@Mapper(componentModel = "spring") +interface ReportMapper : GenericMapper diff --git a/src/main/kotlin/org/octopus/internal/common/models/DetailedReport.kt b/src/main/kotlin/org/octopus/internal/common/models/DetailedReport.kt new file mode 100644 index 0000000..3639ed0 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/common/models/DetailedReport.kt @@ -0,0 +1,9 @@ +package org.octopus.internal.common.models + +import java.time.YearMonth + +data class DetailedReport ( + val yearMonth: YearMonth, + val report: Report, + val entries: List +) \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/common/models/Report.kt b/src/main/kotlin/org/octopus/internal/common/models/Report.kt new file mode 100644 index 0000000..6128c39 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/common/models/Report.kt @@ -0,0 +1,15 @@ +package org.octopus.internal.common.models + +import java.util.Date + +data class Report( + var id: Long, + val start: Boolean, + val reportMonth: Date, + val totalIncomes: Double, + val totalOutcomes: Double, + val expectedDelta: Double, + val expectedCount: Double, + val corrective: Double, + val realCount: Double +) \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/db/EntryJpa.kt b/src/main/kotlin/org/octopus/internal/db/EntryJpa.kt index a239584..a92efe2 100755 --- a/src/main/kotlin/org/octopus/internal/db/EntryJpa.kt +++ b/src/main/kotlin/org/octopus/internal/db/EntryJpa.kt @@ -8,7 +8,7 @@ import java.util.Date interface EntryJpa : JpaRepository { fun findAllByType(type: EEntryType): MutableList - fun findAllByFixedDateIsNullAndRecurrentIsTrueAndEndDateIsNotNullAndEndDateBefore(endDate: Date): MutableList + fun findAllByFixedDateIsNullAndRecurrentIsTrueAndEndDateIsNotNullAndEndDateLessThanEqual(endDate: Date): MutableList fun findAllByFixedDateIsNullAndRecurrentIsTrueAndRecurrentMonthsIn(months: List): MutableList - fun findAllByFixedDateIsNotNullAndFixedDateBefore(fixedDate: Date): MutableList + fun findAllByFixedDateIsNotNullAndFixedDateLessThanEqual(fixedDate: Date): MutableList } \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/db/ReportJpa.kt b/src/main/kotlin/org/octopus/internal/db/ReportJpa.kt new file mode 100755 index 0000000..c0a8ff2 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/db/ReportJpa.kt @@ -0,0 +1,14 @@ +package org.octopus.internal.db + +import org.octopus.internal.db.entities.ReportEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.Date + +interface ReportJpa : JpaRepository { + fun findFirstByStartIsTrue(): ReportEntity? + fun findByStartIsFalse(): MutableList + fun findByStartIsFalseAndReportMonthLessThanEqual(date: Date): MutableList + fun deleteByReportMonthLessThanEqual(date: Date): Long + fun deleteByReportMonthGreaterThan(date: Date): Long + fun deleteByReportMonthGreaterThanEqual(date: Date): Long +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/db/entities/ReportEntity.kt b/src/main/kotlin/org/octopus/internal/db/entities/ReportEntity.kt new file mode 100644 index 0000000..288fa41 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/db/entities/ReportEntity.kt @@ -0,0 +1,23 @@ +package org.octopus.internal.db.entities + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import java.util.Date + +@Entity(name = "reports") +data class ReportEntity( + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + val start: Boolean, + @Column(unique = true) + val reportMonth: Date, + val totalIncomes: Double, + val totalOutcomes: Double, + val expectedDelta: Double, + val expectedCount: Double, + val corrective: Double, + val realCount: Double +) \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/repositories/ReportRepository.kt b/src/main/kotlin/org/octopus/internal/repositories/ReportRepository.kt new file mode 100755 index 0000000..7f05980 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/repositories/ReportRepository.kt @@ -0,0 +1,18 @@ +package org.octopus.internal.repositories + +import org.octopus.internal.common.enums.EEntryType +import org.octopus.internal.common.models.Entry +import org.octopus.internal.common.models.Report +import java.time.Month +import java.util.Date + +interface ReportRepository { + fun getById(id: Long): Report + fun createReport(report: Report): Report + fun createReports(reports: MutableList): MutableList + fun getStartingReport(): Report? + fun getNonStartingReports(endDate: Date?): MutableList + fun deleteAllReportsBefore(endDate: Date): Long + fun deleteAllReportsAfter(endDate: Date): Long + fun deleteAllReportsAfterOrAtDate(endDate: Date): Long +} diff --git a/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt b/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt index dc0aed4..d82d429 100755 --- a/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt +++ b/src/main/kotlin/org/octopus/internal/repositories/impl/EntryRepositoryImpl.kt @@ -1,6 +1,5 @@ package org.octopus.internal.repositories.impl -import org.hibernate.type.descriptor.DateTimeUtils import org.octopus.internal.common.enums.EBusinessException import org.octopus.internal.common.enums.EEntryType import org.octopus.internal.common.exceptions.OctopusPlanningException @@ -14,8 +13,8 @@ import java.util.* @Component class EntryRepositoryImpl( - protected val jpa: EntryJpa, - protected val mapper: EntryMapper + private val jpa: EntryJpa, + private val mapper: EntryMapper ) : EntryRepository { override fun getById(id: Long): Entry { @@ -50,7 +49,7 @@ class EntryRepositoryImpl( override fun getAllRecurrentMonthlyToEndDate(endDate: Date): MutableList { return mapper.toModels( - jpa.findAllByFixedDateIsNullAndRecurrentIsTrueAndEndDateIsNotNullAndEndDateBefore(endDate) + jpa.findAllByFixedDateIsNullAndRecurrentIsTrueAndEndDateIsNotNullAndEndDateLessThanEqual(endDate) ) } @@ -68,7 +67,7 @@ class EntryRepositoryImpl( override fun getAllFixedUpToEndDate(endDate: Date): MutableList { return mapper.toModels( - jpa.findAllByFixedDateIsNotNullAndFixedDateBefore(endDate) + jpa.findAllByFixedDateIsNotNullAndFixedDateLessThanEqual(endDate) ) } diff --git a/src/main/kotlin/org/octopus/internal/repositories/impl/ReportRepositoryImpl.kt b/src/main/kotlin/org/octopus/internal/repositories/impl/ReportRepositoryImpl.kt new file mode 100755 index 0000000..fe36a99 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/repositories/impl/ReportRepositoryImpl.kt @@ -0,0 +1,64 @@ +package org.octopus.internal.repositories.impl + +import org.octopus.internal.common.enums.EBusinessException +import org.octopus.internal.common.exceptions.OctopusPlanningException +import org.octopus.internal.common.mappers.ReportMapper +import org.octopus.internal.common.models.Report +import org.octopus.internal.db.ReportJpa +import org.octopus.internal.repositories.ReportRepository +import org.springframework.stereotype.Component +import java.util.* + +@Component +class ReportRepositoryImpl( + private val jpa: ReportJpa, + private val mapper: ReportMapper +) : ReportRepository { + + override fun getById(id: Long): Report { + return mapper.toModel( + jpa.findById(id).orElseThrow { + OctopusPlanningException.create( + EBusinessException.ENTITY_WITH_ID_NOT_FOUND, + Report::class.java.simpleName, + id + ) + }) + } + + override fun createReport(report: Report): Report { + return mapper.toModel(jpa.save(mapper.toEntity(report))) + } + + override fun createReports(reports: MutableList): MutableList { + return mapper.toModels(jpa.saveAll(mapper.toEntities(reports))) + } + + override fun getStartingReport(): Report? { + val entity = jpa.findFirstByStartIsTrue() ?: return null + return mapper.toModel(entity) + } + + override fun getNonStartingReports(endDate: Date?): MutableList { + if (endDate != null) { + return mapper.toModels(jpa.findByStartIsFalseAndReportMonthLessThanEqual(endDate)) + } + + return mapper.toModels(jpa.findByStartIsFalse()) + } + + override fun deleteAllReportsAfter(endDate: Date): Long { + return jpa.deleteByReportMonthGreaterThan(endDate) + } + + override fun deleteAllReportsAfterOrAtDate(endDate: Date): Long { + return jpa.deleteByReportMonthGreaterThanEqual(endDate) + } + + + override fun deleteAllReportsBefore(endDate: Date): Long { + return jpa.deleteByReportMonthLessThanEqual(endDate) + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/services/EntryService.kt b/src/main/kotlin/org/octopus/internal/services/EntryService.kt index 4042900..4c9f562 100755 --- a/src/main/kotlin/org/octopus/internal/services/EntryService.kt +++ b/src/main/kotlin/org/octopus/internal/services/EntryService.kt @@ -1,7 +1,7 @@ package org.octopus.internal.services import org.octopus.internal.common.models.Entry -import org.octopus.internal.web.utils.dtos.EntryDto +import org.octopus.internal.web.dtos.EntryDto interface EntryService { fun createEntry(entry: EntryDto): Entry diff --git a/src/main/kotlin/org/octopus/internal/services/ReportService.kt b/src/main/kotlin/org/octopus/internal/services/ReportService.kt new file mode 100755 index 0000000..d9536e9 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/services/ReportService.kt @@ -0,0 +1,16 @@ +package org.octopus.internal.services + +import org.octopus.internal.common.models.DetailedReport +import org.octopus.internal.common.models.Report +import org.octopus.internal.web.dtos.ReportDto +import java.util.Date + +interface ReportService { + fun createReport(reportDto: ReportDto): Report + fun generateProjections(endDate: Date): Boolean + fun getAllReports(endDate: Date?): List + fun getAllReportsWithAvgCorrection(endDate: Date?): List + fun getDetailedReport(id: Long): DetailedReport + fun getAllReportsWithDetails(endDate: Date?): List + fun updateReport(id: Long, reportDto: ReportDto): Report +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt b/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt index 1535903..bac8d18 100755 --- a/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt +++ b/src/main/kotlin/org/octopus/internal/services/impl/EntryServiceImpl.kt @@ -6,7 +6,7 @@ import org.octopus.internal.common.exceptions.OctopusPlanningException import org.octopus.internal.common.models.Entry import org.octopus.internal.repositories.EntryRepository import org.octopus.internal.services.EntryService -import org.octopus.internal.web.utils.dtos.EntryDto +import org.octopus.internal.web.dtos.EntryDto import org.springframework.stereotype.Service import java.time.Month diff --git a/src/main/kotlin/org/octopus/internal/services/impl/ReportServiceImpl.kt b/src/main/kotlin/org/octopus/internal/services/impl/ReportServiceImpl.kt new file mode 100755 index 0000000..df5e6c8 --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/services/impl/ReportServiceImpl.kt @@ -0,0 +1,333 @@ +package org.octopus.internal.services.impl + +import jakarta.transaction.Transactional +import org.octopus.internal.common.enums.EBusinessException +import org.octopus.internal.common.enums.EEntryType +import org.octopus.internal.common.exceptions.OctopusPlanningException +import org.octopus.internal.common.isInTargetMonth +import org.octopus.internal.common.isRecurrentActive +import org.octopus.internal.common.models.DetailedReport +import org.octopus.internal.common.models.Report +import org.octopus.internal.common.toFirstDayOfMonthDate +import org.octopus.internal.common.toYearMonth +import org.octopus.internal.repositories.EntryRepository +import org.octopus.internal.repositories.ReportRepository +import org.octopus.internal.services.ReportService +import org.octopus.internal.web.dtos.ReportDto +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import java.time.YearMonth +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import java.util.Date + +@Service +class ReportServiceImpl( + val repo: ReportRepository, + val entryRepo: EntryRepository, + private val zoneId: ZoneId = ZoneId.of("UTC") ) : ReportService { + + override fun createReport(reportDto: ReportDto): Report { + val startingRepo = repo.getStartingReport() + if (startingRepo != null) { + throw OctopusPlanningException.create(EBusinessException.STARTING_REPORT_ALREADY_EXISTS, startingRepo) + } + + return repo.createReport(Report( + id = 0L, + start = reportDto.start, + reportMonth = reportDto.reportMonth, + totalIncomes = 0.0, + totalOutcomes = 0.0, + expectedDelta = 0.0, + expectedCount = reportDto.realCount, + corrective = 0.0, + realCount = reportDto.realCount + )) + } + + @Transactional + override fun generateProjections(endDate: Date): Boolean { + val allFixedInValidTime = entryRepo.getAllFixedUpToEndDate(endDate) + val allRecurrent = entryRepo.getAllRecurrentMonthlyToEndDate(endDate) + + val lastRealCountReport = repo.getNonStartingReports(endDate) + .filter { it.realCount > 0.0 } + .maxByOrNull { it.reportMonth } + + + val loopStartDate: YearMonth + if (lastRealCountReport != null) { + loopStartDate = lastRealCountReport.reportMonth.toYearMonth(zoneId).plusMonths(1) + } else { + loopStartDate = repo.getStartingReport()!!.reportMonth.toYearMonth(zoneId).plusMonths(1) + } + + val loopEndDate = endDate.toYearMonth(zoneId) + if (loopStartDate > loopEndDate) { + println("Info: All reports are up to date until the end date. No new projections generated.") + return true + } + + deleteReportsAfterOrAtWithNewTransaction(loopStartDate.toFirstDayOfMonthDate(zoneId)) + + val startingReport = repo.getStartingReport()!! + + var runningProjectedBalance = (lastRealCountReport ?: startingReport).realCount + + var currentMonth = loopStartDate + while (currentMonth <= loopEndDate) { + var totalIncomes = 0.0 + var totalOutcomes = 0.0 + + allFixedInValidTime.filter { + it.fixedDate.isInTargetMonth(currentMonth, zoneId) + }.forEach { entry -> + when (entry.type) { + EEntryType.INCOME -> totalIncomes += entry.amount + EEntryType.OUTCOME -> totalOutcomes += entry.amount + EEntryType.INVESTMENT -> {} // Ignore investments for direct flow calculation + } + } + + allRecurrent.filter { entry -> + entry.isRecurrentActive(currentMonth, zoneId) + }.forEach { entry -> + when (entry.type) { + EEntryType.INCOME -> totalIncomes += entry.amount + EEntryType.OUTCOME -> totalOutcomes += entry.amount + EEntryType.INVESTMENT -> {} // Ignore investments for direct flow calculation + } + } + + val expectedDelta = totalIncomes - totalOutcomes + val expectedCount = runningProjectedBalance + expectedDelta + + val reportDate = currentMonth.toFirstDayOfMonthDate(zoneId) + + val projectionReport = Report( + id = 0L, + start = false, + reportMonth = reportDate, + totalIncomes = totalIncomes, + totalOutcomes = totalOutcomes, + expectedDelta = expectedDelta, + expectedCount = expectedCount, + corrective = 0.0, + realCount = 0.0 + ) + + repo.createReport(projectionReport) + println("Generated and saved projection for $currentMonth. Expected Balance: $expectedCount") + + runningProjectedBalance = expectedCount + currentMonth = currentMonth.plus(1, ChronoUnit.MONTHS) + } + + return true + } + + @Transactional(Transactional.TxType.REQUIRES_NEW) + private fun deleteReportsAfterOrAtWithNewTransaction(cutoffDate: Date) { + repo.deleteAllReportsAfterOrAtDate(cutoffDate) + } + + @Transactional(Transactional.TxType.REQUIRES_NEW) + private fun deleteReportsAfterNewTransaction(cutoffDate: Date) { + repo.deleteAllReportsAfter(cutoffDate) + } + + + override fun getAllReports(endDate: Date?): List { + val startRepo = repo.getStartingReport() + ?: throw OctopusPlanningException.create(EBusinessException.STARTING_REPORT_DOESNT_EXIST) + if (endDate != null && startRepo.reportMonth <= endDate) { + throw OctopusPlanningException.create(EBusinessException.REPORT_END_DATE_LE_START_REPORT) + } + val otherReports = repo.getNonStartingReports(endDate) + otherReports.addFirst(startRepo) + return otherReports + } + + override fun getAllReportsWithAvgCorrection(endDate: Date?): List { + val startRepo = repo.getStartingReport() + ?: throw OctopusPlanningException.create(EBusinessException.STARTING_REPORT_DOESNT_EXIST) + if (endDate != null && startRepo.reportMonth <= endDate) { + throw OctopusPlanningException.create(EBusinessException.REPORT_END_DATE_LE_START_REPORT) + } + val otherReports = repo.getNonStartingReports(endDate) + otherReports.addFirst(startRepo) + + val correctedReports = otherReports.filter { it.realCount > 0.0 && !it.start } + + val totalCorrection = correctedReports.sumOf { it.corrective } + val correctedReportCount = correctedReports.size.toDouble() + + val averageCorrection = if (correctedReportCount > 0) { + "%.2f".format(totalCorrection / correctedReportCount).toDouble() + } else { + 0.0 + } + + val finalReports = mutableListOf() + var runningProjectedBalance = 0.0 + + val lastActualReport = otherReports + .lastOrNull { it.start || it.realCount > 0.0 } + ?: startRepo + + runningProjectedBalance = if (lastActualReport.realCount > 0.0) { + lastActualReport.realCount + } else { + lastActualReport.expectedCount + } + + for (report in otherReports) { + if (report.start || report.realCount > 0.0) { + finalReports.add(report) + runningProjectedBalance = if (report.realCount > 0.0) report.realCount else report.expectedCount + } else { + val newExpectedCount = runningProjectedBalance + report.expectedDelta + averageCorrection + val correctedReport = report.copy( + expectedCount = newExpectedCount + ) + finalReports.add(correctedReport) + runningProjectedBalance = newExpectedCount + } + } + + return finalReports + } + + override fun getDetailedReport(id: Long): DetailedReport { + val report = repo.getById(id) + val reportYearMonth = report.reportMonth.toYearMonth(zoneId) + val reportDate = report.reportMonth + + val allFixed = entryRepo.getAllFixedUpToEndDate(reportDate) + val allRecurrent = entryRepo.getAllRecurrentMonthlyToEndDate(reportDate) + + val fixedEntries = allFixed.filter { entry -> + entry.fixedDate.isInTargetMonth(reportYearMonth, zoneId) + } + + val recurrentEntries = allRecurrent.filter { entry -> + entry.isRecurrentActive(reportYearMonth, zoneId) + } + + val allEntriesForMonth = fixedEntries + recurrentEntries + + return DetailedReport( + yearMonth = reportYearMonth, + report = report, + entries = allEntriesForMonth + ) + } + + override fun getAllReportsWithDetails(endDate: Date?): List { + val effectiveEndDate = determineEffectiveEndDate(endDate) + + val allReports = repo.getNonStartingReports(effectiveEndDate) + val allFixed = entryRepo.getAllFixedUpToEndDate(effectiveEndDate) + val allRecurrent = entryRepo.getAllRecurrentMonthlyToEndDate(effectiveEndDate) + + return allReports.map { report -> + val reportYearMonth = report.reportMonth.toYearMonth(zoneId) + + val fixedEntries = allFixed.filter { entry -> + entry.fixedDate.isInTargetMonth(reportYearMonth, zoneId) + } + + val recurrentEntries = allRecurrent.filter { entry -> + entry.isRecurrentActive(reportYearMonth, zoneId) + } + + val allEntriesForMonth = fixedEntries + recurrentEntries + + DetailedReport( + yearMonth = reportYearMonth, + report = report, + entries = allEntriesForMonth + ) + } + } + + @Transactional + override fun updateReport( + id: Long, + reportDto: ReportDto + ): Report { + val reportToUpdate = repo.getById(id) + if (reportDto.start != reportToUpdate.start) { + throw OctopusPlanningException.create(EBusinessException.REPORT_IS_START_NOT_CLEAR) + } + + if (reportDto.start) { + val startingReport = repo.createReport(Report( + id = reportToUpdate.id, + start = true, + reportMonth = reportDto.reportMonth, + totalIncomes = 0.0, + totalOutcomes = 0.0, + expectedDelta = 0.0, + expectedCount = reportDto.realCount, + corrective = 0.0, + realCount = reportDto.realCount + )) + + repo.deleteAllReportsBefore(reportDto.reportMonth) + generateProjections(determineEffectiveEndDate(null)) + return startingReport + } + + val correctionAmount = reportDto.realCount - reportToUpdate.expectedCount + + val updatedReport = repo.createReport(Report( + id = reportToUpdate.id, + start = false, + reportMonth = reportToUpdate.reportMonth, + totalIncomes = reportToUpdate.totalIncomes, + totalOutcomes = reportToUpdate.totalOutcomes, + expectedDelta = reportToUpdate.expectedDelta, + expectedCount = reportToUpdate.expectedCount, // Retain old expectedCount here + corrective = correctionAmount, // Record the correction + realCount = reportDto.realCount // Set the new verified amount + )) + + val cutoffDate = updatedReport.reportMonth + val subsequentReports = repo.getNonStartingReports(null) + .filter { it.reportMonth.after(cutoffDate) } + .sortedBy { it.reportMonth } + .toMutableList() + + val reportsToUpdateBatch = mutableListOf() + for (subReport in subsequentReports) { + if (subReport.realCount > 0.0) { + val correctedExpectedCount = subReport.expectedCount + correctionAmount + + reportsToUpdateBatch.add(subReport.copy( + expectedCount = correctedExpectedCount, + corrective = subReport.realCount - correctedExpectedCount + )) + } + } + + repo.createReports(reportsToUpdateBatch) + deleteReportsAfterNewTransaction(cutoffDate) + generateProjections(determineEffectiveEndDate(null)) + + return updatedReport + } + + private fun determineEffectiveEndDate(inputEndDate: Date?): Date { + if (inputEndDate != null) return inputEndDate + + val allIncome = entryRepo.getAllByType(EEntryType.INCOME) + val allOutcome = entryRepo.getAllByType(EEntryType.OUTCOME) + + val allDates = (allIncome + allOutcome).mapNotNull { entry -> entry.fixedDate ?: entry.endDate } + + return allDates.maxByOrNull { it } ?: Date() + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt b/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt index 99fab93..22ac906 100755 --- a/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt +++ b/src/main/kotlin/org/octopus/internal/web/controllers/EntryController.kt @@ -7,9 +7,8 @@ import jakarta.validation.Valid import lombok.AllArgsConstructor import org.octopus.internal.common.models.Entry import org.octopus.internal.services.EntryService -import org.octopus.internal.web.utils.dtos.EntryDto +import org.octopus.internal.web.dtos.EntryDto import org.octopus.internal.web.utils.responses.WebResponse -import org.springframework.boot.actuate.autoconfigure.metrics.MetricsProperties.Web import org.springframework.web.bind.annotation.* @RestController diff --git a/src/main/kotlin/org/octopus/internal/web/controllers/ReportController.kt b/src/main/kotlin/org/octopus/internal/web/controllers/ReportController.kt new file mode 100755 index 0000000..afbcd2a --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/web/controllers/ReportController.kt @@ -0,0 +1,58 @@ +package org.octopus.internal.web.controllers + +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import lombok.AllArgsConstructor +import org.octopus.internal.common.models.DetailedReport +import org.octopus.internal.common.models.Report +import org.octopus.internal.services.ReportService +import org.octopus.internal.web.dtos.ReportDto +import org.octopus.internal.web.utils.responses.WebResponse +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.* +import java.util.Date + +@RestController +@RequestMapping("/reports") +@AllArgsConstructor +@Tag(name = "Reports Management", description = "Operations related to reports.") +class ReportController( + val reportService: ReportService +) { + + @PostMapping + fun createReport(@Valid @RequestBody reportDto: ReportDto): WebResponse { + return WebResponse.ok(reportService.createReport(reportDto)) + } + + @PostMapping("/projections") + fun generateProjections(@RequestParam("endDate", required = true) @DateTimeFormat(pattern = "yyyy-MM-dd") endDate: Date): WebResponse { + return WebResponse.ok(reportService.generateProjections(endDate)) + } + + @GetMapping("/resume") + fun getAllReports( + @RequestParam("endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") endDate: Date?): WebResponse> { + return WebResponse.ok(reportService.getAllReports(endDate)) + } + + @GetMapping + fun getAllReportsWithDetails( + @RequestParam("endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") endDate: Date?): WebResponse> { + return WebResponse.ok(reportService.getAllReportsWithDetails(endDate)) + + } + + @PutMapping("/{id}") + fun updateReport(@PathVariable("id") id: Long, + @Valid @RequestBody reportDto: ReportDto): WebResponse { + return WebResponse.ok(reportService.updateReport(id, reportDto)) + } + + @GetMapping("/correction") + fun getCorrectionValue( + @RequestParam("endDate", required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") endDate: Date?): WebResponse> { + return WebResponse.ok(reportService.getAllReportsWithAvgCorrection(endDate)) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/web/utils/dtos/EntryDto.kt b/src/main/kotlin/org/octopus/internal/web/dtos/EntryDto.kt similarity index 84% rename from src/main/kotlin/org/octopus/internal/web/utils/dtos/EntryDto.kt rename to src/main/kotlin/org/octopus/internal/web/dtos/EntryDto.kt index 9e356fa..270dcf6 100755 --- a/src/main/kotlin/org/octopus/internal/web/utils/dtos/EntryDto.kt +++ b/src/main/kotlin/org/octopus/internal/web/dtos/EntryDto.kt @@ -1,11 +1,11 @@ -package org.octopus.internal.web.utils.dtos +package org.octopus.internal.web.dtos import io.swagger.v3.oas.annotations.media.Schema import jakarta.validation.constraints.DecimalMin import org.hibernate.validator.constraints.Length -import org.octopus.internal.web.utils.dtos.validators.EntryHexColorValidator -import org.octopus.internal.web.utils.dtos.validators.EntryMonthValidator -import org.octopus.internal.web.utils.dtos.validators.EntryTypeValidator +import org.octopus.internal.web.dtos.validators.EntryHexColorValidator +import org.octopus.internal.web.dtos.validators.EntryMonthValidator +import org.octopus.internal.web.dtos.validators.EntryTypeValidator import java.util.Date data class EntryDto( diff --git a/src/main/kotlin/org/octopus/internal/web/dtos/ReportDto.kt b/src/main/kotlin/org/octopus/internal/web/dtos/ReportDto.kt new file mode 100755 index 0000000..3c4b05a --- /dev/null +++ b/src/main/kotlin/org/octopus/internal/web/dtos/ReportDto.kt @@ -0,0 +1,15 @@ +package org.octopus.internal.web.dtos + +import io.swagger.v3.oas.annotations.media.Schema +import java.util.Date + +data class ReportDto( + @field:Schema(description = "Indicates if this is the **initial month** for financial projections.", defaultValue = "false") + val start: Boolean, + + @field:Schema(description = "The calendar month this report provides information **for**.") + val reportMonth: Date, + + @field:Schema(description = "The **actual amount of money observed** (e.g., in a bank account) for the month.") + val realCount: Double +) \ No newline at end of file diff --git a/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryHexColorValidator.kt b/src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryHexColorValidator.kt similarity index 96% rename from src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryHexColorValidator.kt rename to src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryHexColorValidator.kt index ecca425..d65425a 100755 --- a/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryHexColorValidator.kt +++ b/src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryHexColorValidator.kt @@ -1,4 +1,4 @@ -package org.octopus.internal.web.utils.dtos.validators +package org.octopus.internal.web.dtos.validators import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator diff --git a/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryMonthValidator.kt b/src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryMonthValidator.kt similarity index 95% rename from src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryMonthValidator.kt rename to src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryMonthValidator.kt index 9ad8b13..30d4097 100755 --- a/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryMonthValidator.kt +++ b/src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryMonthValidator.kt @@ -1,4 +1,4 @@ -package org.octopus.internal.web.utils.dtos.validators +package org.octopus.internal.web.dtos.validators import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator diff --git a/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryTypeValidator.kt b/src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryTypeValidator.kt similarity index 95% rename from src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryTypeValidator.kt rename to src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryTypeValidator.kt index dccc986..b7bfdd0 100755 --- a/src/main/kotlin/org/octopus/internal/web/utils/dtos/validators/EntryTypeValidator.kt +++ b/src/main/kotlin/org/octopus/internal/web/dtos/validators/EntryTypeValidator.kt @@ -1,4 +1,4 @@ -package org.octopus.internal.web.utils.dtos.validators +package org.octopus.internal.web.dtos.validators import jakarta.validation.Constraint import jakarta.validation.ConstraintValidator diff --git a/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt b/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt index 74a73ab..01c3d7b 100755 --- a/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt +++ b/src/main/kotlin/org/octopus/internal/web/utils/BaseAdvice.kt @@ -31,7 +31,11 @@ class BaseAdvice { private fun deductStatus(ex: EBusinessException): HttpStatus { return when (ex) { EBusinessException.ENTITY_WITH_ID_NOT_FOUND -> HttpStatus.NOT_FOUND + EBusinessException.STARTING_REPORT_ALREADY_EXISTS -> HttpStatus.CONFLICT + EBusinessException.REPORT_END_DATE_LE_START_REPORT, + EBusinessException.REPORT_IS_START_NOT_CLEAR, EBusinessException.INVALID_REQUEST -> HttpStatus.BAD_REQUEST + EBusinessException.STARTING_REPORT_DOESNT_EXIST -> HttpStatus.NOT_ACCEPTABLE } } @@ -122,4 +126,4 @@ class BaseAdvice { ex.message ) } -} \ No newline at end of file +}