From 8864110e440659ab25e8fcff587ff2757d32cb6c Mon Sep 17 00:00:00 2001 From: Vincenzo Pierro Date: Tue, 25 Feb 2025 20:42:28 +0100 Subject: [PATCH] Added endpoints to get a downloadable excel report --- build.gradle.kts | 2 + libs.versions.toml | 11 +- .../homunculus/configuration/KoinConfig.kt | 4 +- .../controller/MaterialController.kt | 31 ++++ .../org/cdb/homunculus/dao/MaterialDao.kt | 1 - .../homunculus/dao/impl/MaterialDaoImpl.kt | 12 +- .../homunculus/logic/AuthenticationLogic.kt | 22 +++ .../org/cdb/homunculus/logic/MaterialLogic.kt | 14 ++ .../logic/impl/AuthenticationLogicImpl.kt | 22 +++ .../cdb/homunculus/logic/impl/BoxLogicImpl.kt | 12 +- .../logic/impl/MaterialLogicImpl.kt | 140 +++++++++++++++++- 11 files changed, 253 insertions(+), 18 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 0f4db13..1d28836 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,6 +40,8 @@ dependencies { implementation(libs.krontab) implementation(libs.caffeine) implementation(libs.tika) + implementation(libs.apachePoi) + implementation(libs.apachePoiOoxml) } tasks.withType { diff --git a/libs.versions.toml b/libs.versions.toml index 1a3d5f9..506b07e 100644 --- a/libs.versions.toml +++ b/libs.versions.toml @@ -1,8 +1,9 @@ [versions] -ktor="2.3.7" -kotlin="1.9.10" -logback="1.4.14" -koin="3.5.0" +ktor = "2.3.7" +kotlin = "1.9.10" +logback = "1.4.14" +koin = "3.5.0" +apachePoi = "5.4.0" [libraries] ktorCore = { group = "io.ktor", name ="ktor-server-core-jvm", version.ref="ktor" } @@ -28,6 +29,8 @@ kotlinxDatetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", krontab = { group = "dev.inmo", name = "krontab", version = "2.3.0" } caffeine = { group = "com.github.ben-manes.caffeine", name = "caffeine", version = "3.1.8" } tika = { group = "org.apache.tika", name = "tika-core", version = "2.9.1" } +apachePoi = { group = "org.apache.poi", name = "poi", version.ref = "apachePoi" } +apachePoiOoxml = { group = "org.apache.poi", name = "poi-ooxml", version.ref = "apachePoi" } [bundles] koin = ["koinKtor", "koinLogger"] diff --git a/src/main/kotlin/org/cdb/homunculus/configuration/KoinConfig.kt b/src/main/kotlin/org/cdb/homunculus/configuration/KoinConfig.kt index 3856dfc..a4731ef 100644 --- a/src/main/kotlin/org/cdb/homunculus/configuration/KoinConfig.kt +++ b/src/main/kotlin/org/cdb/homunculus/configuration/KoinConfig.kt @@ -93,9 +93,9 @@ fun applicationModules( single { AlertLogicImpl(get(), get(), logger) } single { ProfilePictureLogicImpl(get()) } single { AuthenticationLogicImpl(get(), get(), get(), get()) } - single { BoxLogicImpl(get(), get()) } + single { MaterialLogicImpl(get(), get(), get()) } + single { BoxLogicImpl(get(), get(), get()) } single { BoxDefinitionLogicImpl(get()) } - single { MaterialLogicImpl(get()) } single { ProcessLogicImpl(get(), get(), get(), get()) } single { ReportLogicImpl(get(), get(), get(), logger) } single { RoleLogicImpl(get()) } diff --git a/src/main/kotlin/org/cdb/homunculus/controller/MaterialController.kt b/src/main/kotlin/org/cdb/homunculus/controller/MaterialController.kt index 91b938f..59ec4cf 100644 --- a/src/main/kotlin/org/cdb/homunculus/controller/MaterialController.kt +++ b/src/main/kotlin/org/cdb/homunculus/controller/MaterialController.kt @@ -1,10 +1,14 @@ package org.cdb.homunculus.controller +import io.ktor.http.ContentDisposition +import io.ktor.http.HttpHeaders import io.ktor.server.application.* import io.ktor.server.request.* import io.ktor.server.response.* import io.ktor.server.routing.* import kotlinx.coroutines.flow.toList +import org.cdb.homunculus.exceptions.UnauthorizedException +import org.cdb.homunculus.logic.AuthenticationLogic import org.cdb.homunculus.logic.MaterialLogic import org.cdb.homunculus.models.Material import org.cdb.homunculus.models.filters.Filter @@ -15,10 +19,13 @@ import org.cdb.homunculus.requests.authenticatedGet import org.cdb.homunculus.requests.authenticatedPost import org.cdb.homunculus.requests.authenticatedPut import org.koin.ktor.ext.inject +import java.time.LocalDate +import java.time.format.DateTimeFormatter fun Routing.materialController() = route("/material") { val materialLogic by inject() + val authLogic by inject() authenticatedGet("") { call.respond(materialLogic.getAll().toList()) @@ -82,4 +89,28 @@ fun Routing.materialController() = val filter = call.receive() call.respond(materialLogic.filter(filter)) } + + authenticatedPost("/report") { + materialLogic.createMaterialsReport() + call.respond(authLogic.generateOTT("GET", "/material/report")) + } + + get("/report") { + requireNotNull(call.request.queryParameters["token"]) { "Token must not be null." }.also { + if (!authLogic.consumeOTT(it, "GET", "/material/report")) { + throw UnauthorizedException("Invalid OTT") + } + } + val report = materialLogic.createMaterialsReport() + val currentDate = LocalDate.now() + val formatter = DateTimeFormatter.ofPattern("yyyyMMdd") + val formattedDate = currentDate.format(formatter) + val fileName = "report_$formattedDate.xlsx" + call.response.header( + HttpHeaders.ContentDisposition, + ContentDisposition.Attachment.withParameter(ContentDisposition.Parameters.FileName, fileName).toString(), + ) + call.response.header(HttpHeaders.ContentType, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + call.respondBytes(report) + } } diff --git a/src/main/kotlin/org/cdb/homunculus/dao/MaterialDao.kt b/src/main/kotlin/org/cdb/homunculus/dao/MaterialDao.kt index d0facf7..22647c7 100644 --- a/src/main/kotlin/org/cdb/homunculus/dao/MaterialDao.kt +++ b/src/main/kotlin/org/cdb/homunculus/dao/MaterialDao.kt @@ -1,6 +1,5 @@ package org.cdb.homunculus.dao -import com.mongodb.kotlin.client.coroutine.FindFlow import com.mongodb.kotlin.client.coroutine.MongoCollection import kotlinx.coroutines.flow.Flow import org.bson.conversions.Bson diff --git a/src/main/kotlin/org/cdb/homunculus/dao/impl/MaterialDaoImpl.kt b/src/main/kotlin/org/cdb/homunculus/dao/impl/MaterialDaoImpl.kt index 851548b..5f7d5a2 100644 --- a/src/main/kotlin/org/cdb/homunculus/dao/impl/MaterialDaoImpl.kt +++ b/src/main/kotlin/org/cdb/homunculus/dao/impl/MaterialDaoImpl.kt @@ -32,10 +32,14 @@ class MaterialDaoImpl(client: DBClient) : MaterialDao(client) { ), ).skip(skip).limit(limit) - override fun getSorted(sort: Bson?) = collection.find().let { - if (sort == null) it - else it.sort(sort) - } + override fun getSorted(sort: Bson?) = + collection.find().let { + if (sort == null) { + it + } else { + it.sort(sort) + } + } override fun getLastCreated(limit: Int): Flow = collection.find( diff --git a/src/main/kotlin/org/cdb/homunculus/logic/AuthenticationLogic.kt b/src/main/kotlin/org/cdb/homunculus/logic/AuthenticationLogic.kt index 6b58a3b..0116623 100644 --- a/src/main/kotlin/org/cdb/homunculus/logic/AuthenticationLogic.kt +++ b/src/main/kotlin/org/cdb/homunculus/logic/AuthenticationLogic.kt @@ -30,4 +30,26 @@ interface AuthenticationLogic { * @throws UnauthorizedException if it is not possible to refresh the token for the user. */ suspend fun refresh(userId: EntityId): AuthResponse + + /** + * @param method the HTTP method the token is valid for. + * @param path the path the token is valid for. + * @return a single-use token valid for a single operation for the following 5 minutes. + */ + fun generateOTT( + method: String, + path: String, + ): String + + /** + * Checks the validity of [token]. If it is valid, it will be invalidated. + * @param method the HTTP method the token is valid for. + * @param path the path the token is valid for. + * @return true if the token is valid, false otherwise. + */ + fun consumeOTT( + token: String, + method: String, + path: String, + ): Boolean } diff --git a/src/main/kotlin/org/cdb/homunculus/logic/MaterialLogic.kt b/src/main/kotlin/org/cdb/homunculus/logic/MaterialLogic.kt index f813137..8284acc 100644 --- a/src/main/kotlin/org/cdb/homunculus/logic/MaterialLogic.kt +++ b/src/main/kotlin/org/cdb/homunculus/logic/MaterialLogic.kt @@ -1,6 +1,7 @@ package org.cdb.homunculus.logic import kotlinx.coroutines.flow.Flow +import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.cdb.homunculus.exceptions.NotFoundException import org.cdb.homunculus.models.Material import org.cdb.homunculus.models.filters.Filter @@ -128,4 +129,17 @@ interface MaterialLogic { * @return a [Flow] of [Material]s. */ fun filter(filter: Filter): Flow + + /** + * Creates an Excel report for each [Material]. Each row corresponds to a material and contains [Material.name], [Material.brand], + * [Material.referenceCode] and the total quantity. + * The report is cached and if more users call this method at the same time, they will wait for the same completable future. + * @return an [XSSFWorkbook] as [ByteArray] + */ + suspend fun createMaterialsReport(): ByteArray + + /** + * Invalidates the current cached material report. + */ + fun invalidateReport() } diff --git a/src/main/kotlin/org/cdb/homunculus/logic/impl/AuthenticationLogicImpl.kt b/src/main/kotlin/org/cdb/homunculus/logic/impl/AuthenticationLogicImpl.kt index 4d31121..db08186 100644 --- a/src/main/kotlin/org/cdb/homunculus/logic/impl/AuthenticationLogicImpl.kt +++ b/src/main/kotlin/org/cdb/homunculus/logic/impl/AuthenticationLogicImpl.kt @@ -1,5 +1,6 @@ package org.cdb.homunculus.logic.impl +import com.github.benmanes.caffeine.cache.Caffeine import org.cdb.homunculus.components.JWTManager import org.cdb.homunculus.components.PasswordEncoder import org.cdb.homunculus.dao.RoleDao @@ -14,6 +15,8 @@ import org.cdb.homunculus.models.security.JWTClaims import org.cdb.homunculus.models.security.JWTRefreshClaims import org.cdb.homunculus.utils.DynamicBitArray import java.util.Date +import java.util.UUID +import java.util.concurrent.TimeUnit class AuthenticationLogicImpl( private val userDao: UserDao, @@ -21,6 +24,8 @@ class AuthenticationLogicImpl( private val passwordEncoder: PasswordEncoder, private val jwtManager: JWTManager, ) : AuthenticationLogic { + private val tokenCache = Caffeine.newBuilder().expireAfterWrite(10, TimeUnit.MINUTES).build() + private suspend fun User.permissionsAsBitArray(): DynamicBitArray = role?.let { roleDao.getById(it) @@ -65,4 +70,21 @@ class AuthenticationLogicImpl( refreshJwt = null, ) } ?: throw UnauthorizedException("It is not possible to refresh the token for the user $userId") + + override fun generateOTT( + method: String, + path: String, + ): String = + UUID.randomUUID().toString().substring(0, 6).also { + tokenCache.put(it, "$method:$path") + } + + override fun consumeOTT( + token: String, + method: String, + path: String, + ): Boolean = + tokenCache.getIfPresent(token)?.also { + tokenCache.invalidate(token) + } == "$method:$path" } diff --git a/src/main/kotlin/org/cdb/homunculus/logic/impl/BoxLogicImpl.kt b/src/main/kotlin/org/cdb/homunculus/logic/impl/BoxLogicImpl.kt index d4d78bb..3f2b922 100644 --- a/src/main/kotlin/org/cdb/homunculus/logic/impl/BoxLogicImpl.kt +++ b/src/main/kotlin/org/cdb/homunculus/logic/impl/BoxLogicImpl.kt @@ -3,11 +3,13 @@ package org.cdb.homunculus.logic.impl import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.toList import org.cdb.homunculus.components.NotificationManager import org.cdb.homunculus.dao.BoxDao import org.cdb.homunculus.exceptions.NotFoundException import org.cdb.homunculus.logic.BoxLogic +import org.cdb.homunculus.logic.MaterialLogic import org.cdb.homunculus.models.Box import org.cdb.homunculus.models.embed.Operation import org.cdb.homunculus.models.embed.UsageLog @@ -20,6 +22,7 @@ import java.util.Date class BoxLogicImpl( private val boxDao: BoxDao, private val notificationManager: NotificationManager, + private val materialLogic: MaterialLogic, ) : BoxLogic { override suspend fun create( box: Box, @@ -41,6 +44,7 @@ class BoxLogicImpl( return checkNotNull(boxDao.save(boxWithLog)) { "Error during box creation" }.also { + materialLogic.invalidateReport() notificationManager.checkMaterial(box.material) } } @@ -63,6 +67,8 @@ class BoxLogicImpl( deletionDate = Date(), ), ) + }.onCompletion { + materialLogic.invalidateReport() } override fun getByPosition(shelfId: HierarchicalId): Flow = boxDao.getByPosition(shelfId, false) @@ -76,6 +82,7 @@ class BoxLogicImpl( deletionDate = Date(), ), )?.id?.also { + materialLogic.invalidateReport() notificationManager.checkMaterial(box.material) } ?: throw IllegalStateException("Cannot delete the box with id $id") } @@ -104,6 +111,7 @@ class BoxLogicImpl( }, ), )?.id?.also { + materialLogic.invalidateReport() notificationManager.checkMaterial(box.material) } ?: throw IllegalStateException("Cannot update the quantity for box $boxId") } @@ -120,6 +128,8 @@ class BoxLogicImpl( description = box.description, ), )?.id, - ) { "An error occurred while updating box ${box.id}" } + ) { "An error occurred while updating box ${box.id}" }.also { + materialLogic.invalidateReport() + } } } diff --git a/src/main/kotlin/org/cdb/homunculus/logic/impl/MaterialLogicImpl.kt b/src/main/kotlin/org/cdb/homunculus/logic/impl/MaterialLogicImpl.kt index 6b0e23b..f080231 100644 --- a/src/main/kotlin/org/cdb/homunculus/logic/impl/MaterialLogicImpl.kt +++ b/src/main/kotlin/org/cdb/homunculus/logic/impl/MaterialLogicImpl.kt @@ -1,28 +1,59 @@ package org.cdb.homunculus.logic.impl +import com.github.benmanes.caffeine.cache.Caffeine import com.mongodb.client.model.Sorts +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectIndexed import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.fold +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.future.await +import kotlinx.coroutines.future.future +import kotlinx.coroutines.launch +import org.apache.poi.xssf.usermodel.XSSFRow +import org.apache.poi.xssf.usermodel.XSSFSheet +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import org.cdb.homunculus.dao.BoxDao +import org.cdb.homunculus.dao.BoxDefinitionDao import org.cdb.homunculus.dao.MaterialDao import org.cdb.homunculus.exceptions.NotFoundException import org.cdb.homunculus.logic.MaterialLogic import org.cdb.homunculus.models.Material +import org.cdb.homunculus.models.embed.BoxDefinition +import org.cdb.homunculus.models.embed.BoxUnit import org.cdb.homunculus.models.filters.Filter import org.cdb.homunculus.models.identifiers.EntityId import org.cdb.homunculus.models.identifiers.Identifier import org.cdb.homunculus.utils.StringNormalizer import org.cdb.homunculus.utils.exist +import java.io.ByteArrayOutputStream import java.util.Date +import java.util.concurrent.TimeUnit class MaterialLogicImpl( private val materialDao: MaterialDao, + private val boxDefinitionDao: BoxDefinitionDao, + private val boxDao: BoxDao, ) : MaterialLogic { + private val materialLogicScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val reportKey = "MATERIAL_REPORT_KEY" + private val materialReportCache = + Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.DAYS) + .buildAsync() + override suspend fun create(material: Material): Identifier = - materialDao.save(material.copy(creationDate = Date(), normalizedName = StringNormalizer.normalize(material.name))) + materialDao.save(material.copy(creationDate = Date(), normalizedName = StringNormalizer.normalize(material.name))).also { + invalidateReport() + } override suspend fun get(materialId: EntityId): Material = materialDao.getById(materialId) ?: throw NotFoundException("Material $materialId not found") @@ -49,7 +80,9 @@ class MaterialLogicImpl( material.copy( deletionDate = Date(), ), - )?.id ?: throw IllegalStateException("Cannot delete the material with id $id") + )?.id?.also { + invalidateReport() + } ?: throw IllegalStateException("Cannot delete the material with id $id") } override suspend fun modify(material: Material) { @@ -64,7 +97,9 @@ class MaterialLogicImpl( deletionDate = material.deletionDate ?: currentMaterial.deletionDate, normalizedName = StringNormalizer.normalize(material.name), ), - ) + ).also { + invalidateReport() + } } private fun search( @@ -74,9 +109,11 @@ class MaterialLogicImpl( ): Flow = flow { if (query.isBlank()) { - emitAll(materialDao.getSorted(Sorts.ascending(Material::normalizedName.name)).filter { - it.deletionDate == null - }) + emitAll( + materialDao.getSorted(Sorts.ascending(Material::normalizedName.name)).filter { + it.deletionDate == null + }, + ) } else { emitAll(materialDao.getByFuzzyName(query, includeDeleted = false, limit = null, skip = null)) emitAll(materialDao.searchByReferenceCode(query, includeDeleted = false, limit = null, skip = null)) @@ -109,4 +146,95 @@ class MaterialLogicImpl( override fun getLastCreated(limit: Int): Flow = materialDao.getLastCreated(limit) override fun filter(filter: Filter): Flow = materialDao.find(filter.toBson()).filter { it.deletionDate == null } + + override fun invalidateReport() { + materialLogicScope.launch { + materialReportCache.getIfPresent(reportKey)?.await()?.also { + materialReportCache.synchronous().invalidate(reportKey) + } + } + } + + override suspend fun createMaterialsReport(): ByteArray = + materialLogicScope.async { + materialReportCache.get(reportKey) { _, _ -> + future { + val boxDefinitions = mutableMapOf() + XSSFWorkbook().also { workbook -> + val workSheet = workbook.createSheet() + workSheet.setHeader(0) + materialDao.get().filter { + it.deletionDate == null + }.collectIndexed { index, material -> + val rowIndex = index + 1 + workSheet.createRow(rowIndex).let { row -> + row.set(0, material.name.text) + row.set(1, material.brand) + row.set(2, material.referenceCode ?: "UNKNOWN") + val boxDefinition = + boxDefinitions[material.boxDefinition] + ?: boxDefinitionDao.getById(material.boxDefinition)?.also { + boxDefinitions[material.boxDefinition] = it + } + var unit = boxDefinition?.boxUnit + var total = + boxDao.getByMaterial(material.id, includeDeleted = false).filter { + it.deletionDate == null && it.quantity.quantity > 0 + }.map { it.quantity.quantity }.toList().sum() + row.set(6, "$total total pieces") + var firstBag = true + do { + when { + firstBag && unit != null && unit.metric == null -> { + firstBag = false + val totalUnit = unit.boxUnit.quantityRecursive() + val boxes = total / totalUnit + row.set(3, "$boxes full box of ${unit.boxUnit?.quantity} ${if (unit.boxUnit?.metric != null) "pieces" else "bags"}") + total -= (boxes * totalUnit) + } + !firstBag && unit != null && unit.metric == null -> { + val totalUnit = unit.boxUnit.quantityRecursive() + val boxes = total / totalUnit + row.set(4, "$boxes full bags of ${unit.boxUnit?.quantity} pieces") + total -= (boxes * totalUnit) + } + else -> { + row.set(5, "$total single pieces") + total = 0 + } + } + unit = unit?.boxUnit + } while (unit != null) + } + } + }.let { workbook -> + val byteArrayOutputStream = ByteArrayOutputStream() + workbook.write(byteArrayOutputStream) + byteArrayOutputStream.toByteArray().also { + byteArrayOutputStream.close() + workbook.close() + } + } + } + }.await() + }.await() + + private fun BoxUnit?.quantityRecursive(quantity: Int = 1): Int = + if (this == null) { + quantity + } else { + boxUnit.quantityRecursive(quantity * this.quantity) + } + + private fun XSSFSheet.setHeader(rowIndex: Int) = + createRow(rowIndex).let { row -> + listOf("Name", "Brand", "Reference Code", "Boxes", "Bags", "Units", "Total").forEachIndexed { index, s -> + row.createCell(index).setCellValue(s) + } + } + + private fun XSSFRow.set( + col: Int, + value: String, + ) = createCell(col).setCellValue(value) }