diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt index 664e3f3..b8dd922 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt @@ -1,6 +1,8 @@ package com.ffii.fpsms.m18.entity import com.ffii.core.support.AbstractRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param interface M18BomShopSyncLogRepository : AbstractRepository { fun findFirstByBomIdOrderByIdDesc(bomId: Long): M18BomShopSyncLog? @@ -19,4 +21,29 @@ interface M18BomShopSyncLogRepository : AbstractRepository= :syncDateStart) + AND (:syncDateEnd IS NULL OR l.created <= :syncDateEnd) + AND ( + :finishedItemCode IS NULL OR :finishedItemCode = '' + OR LOWER(l.finishedItemCode) LIKE LOWER(CONCAT('%', :finishedItemCode, '%')) + ) + AND ( + :syncStatus IS NULL OR :syncStatus = '' OR :syncStatus = 'all' + OR (:syncStatus = 'success' AND l.synced = true) + OR (:syncStatus = 'failed' AND l.synced = false) + ) + ORDER BY l.created DESC, l.id DESC + """, + ) + fun searchForReport( + @Param("syncDateStart") syncDateStart: java.time.LocalDateTime?, + @Param("syncDateEnd") syncDateEnd: java.time.LocalDateTime?, + @Param("finishedItemCode") finishedItemCode: String?, + @Param("syncStatus") syncStatus: String?, + ): List } diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt new file mode 100644 index 0000000..8a9985e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt @@ -0,0 +1,165 @@ +package com.ffii.fpsms.modules.report.service + +import com.fasterxml.jackson.databind.ObjectMapper +import com.ffii.fpsms.m18.entity.M18BomShopSyncLog +import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository +import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse +import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest +import com.ffii.fpsms.m18.model.M18UdfProductSaveValue +import com.ffii.fpsms.modules.master.entity.BomRepository +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Service +open class M18BomShopSyncReportService( + private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, + private val bomRepository: BomRepository, + private val itemsRepository: ItemsRepository, + private val objectMapper: ObjectMapper, +) { + private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val versionFromHeaderCode = Regex("V(\\d+)$", RegexOption.IGNORE_CASE) + + open fun searchBomShopSyncHistoryReport( + syncDateStart: String?, + syncDateEnd: String?, + finishedItemCode: String?, + syncStatus: String?, + ): Map { + val start = parseDateStart(syncDateStart) + val end = parseDateEnd(syncDateEnd) + val itemFilter = finishedItemCode?.trim()?.takeIf { it.isNotEmpty() } + val statusFilter = syncStatus?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "all" } + + val logs = m18BomShopSyncLogRepository.searchForReport( + syncDateStart = start, + syncDateEnd = end, + finishedItemCode = itemFilter, + syncStatus = statusFilter, + ) + + val syncRows = mutableListOf>() + val materialRows = mutableListOf>() + var successCount = 0 + var failedCount = 0 + var skippedUnchangedCount = 0 + + for (log in logs) { + val bomId = log.bomId ?: continue + val bom = bomRepository.findByIdAndDeletedIsFalse(bomId) + val finishedCode = log.finishedItemCode?.trim().orEmpty() + val itemName = + finishedCode.takeIf { it.isNotEmpty() } + ?.let { itemsRepository.findByCodeAndDeletedFalse(it)?.name?.trim() } + ?: bom?.name?.trim()?.takeIf { it.isNotEmpty() } + val headerCode = log.m18HeaderCode?.trim().orEmpty() + val version = extractVersion(headerCode) + val status = resolveSyncStatus(log) + when (status) { + "SUCCESS" -> successCount++ + "SKIPPED_UNCHANGED" -> skippedUnchangedCount++ + else -> failedCount++ + } + val syncAt = log.created?.format(dateTimeFormatter) + val failureReason = resolveFailureReason(log).takeIf { status == "FAILED" } + + syncRows.add( + linkedMapOf( + "syncLogId" to log.id, + "syncDateTime" to syncAt, + "bomId" to bomId, + "bomRoutingCode" to bom?.code, + "finishedItemCode" to finishedCode.ifEmpty { null }, + "finishedItemName" to itemName, + "m18HeaderCode" to headerCode.ifEmpty { null }, + "version" to version, + "m18RecordId" to log.m18RecordId?.takeIf { it > 0L }, + "syncStatus" to status, + "synced" to log.synced, + "m18ApiStatus" to log.m18ApiStatus, + "failureReason" to failureReason, + "message" to log.message, + ), + ) + + val materials = parseMaterialLines(log) + for (line in materials) { + materialRows.add( + linkedMapOf( + "syncLogId" to log.id, + "syncDateTime" to syncAt, + "bomId" to bomId, + "finishedItemCode" to finishedCode.ifEmpty { null }, + "m18HeaderCode" to headerCode.ifEmpty { null }, + "version" to version, + "syncStatus" to status, + "lineNo" to line.itemNo?.trim(), + "materialName" to line.udfIngredients?.trim(), + "udfProductM18Id" to line.udfProduct, + "udfBaseUnit" to line.udfBaseUnit, + "udfQty" to line.udfqty, + "udfSupplierM18Id" to line.udfSupplier, + "udfPurchaseUnitM18Id" to line.udfpurchaseUnit, + ), + ) + } + } + + return mapOf( + "summary" to mapOf( + "totalAttempts" to logs.size, + "success" to successCount, + "skippedUnchanged" to skippedUnchangedCount, + "failed" to failedCount, + "syncDateStart" to syncDateStart, + "syncDateEnd" to syncDateEnd, + ), + "syncRows" to syncRows, + "materialRows" to materialRows, + ) + } + + private fun parseDateStart(raw: String?): LocalDateTime? { + if (raw.isNullOrBlank()) return null + return LocalDate.parse(raw.trim().replace("/", "-")).atStartOfDay() + } + + private fun parseDateEnd(raw: String?): LocalDateTime? { + if (raw.isNullOrBlank()) return null + return LocalDate.parse(raw.trim().replace("/", "-")).atTime(23, 59, 59, 999_999_999) + } + + private fun extractVersion(headerCode: String?): String? { + if (headerCode.isNullOrBlank()) return null + return versionFromHeaderCode.find(headerCode.trim())?.groupValues?.get(1) + } + + private fun resolveSyncStatus(log: M18BomShopSyncLog): String = when { + !log.synced -> "FAILED" + log.message?.contains("skippedUnchanged", ignoreCase = true) == true -> "SKIPPED_UNCHANGED" + else -> "SUCCESS" + } + + private fun resolveFailureReason(log: M18BomShopSyncLog): String? { + log.message?.trim()?.takeIf { it.isNotEmpty() }?.let { return it } + val json = log.responseJson?.trim().orEmpty().ifEmpty { return null } + return runCatching { + val resp = objectMapper.readValue(json, GoodsReceiptNoteResponse::class.java) + resp.messages + .joinToString("; ") { m -> m.msgDetail?.trim().orEmpty().ifEmpty { m.msgCode.orEmpty() } } + .trim() + .ifEmpty { null } + }.getOrNull() + } + + private fun parseMaterialLines(log: M18BomShopSyncLog): List { + val json = log.requestJson?.trim().orEmpty().ifEmpty { return emptyList() } + return runCatching { + objectMapper.readValue(json, M18BomForShopSaveRequest::class.java) + .udfproduct.values + }.getOrElse { emptyList() } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index dd3b9af..9fbd9bc 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -22,12 +22,14 @@ import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService import com.ffii.fpsms.modules.report.service.ReportService @RestController @RequestMapping("/report") class ReportController( private val reportService: ReportService, + private val m18BomShopSyncReportService: M18BomShopSyncReportService, ) { private data class ExcelStyles( val title: XSSFCellStyle, @@ -979,6 +981,26 @@ class ReportController( ) } + /** + * M18 BOM Shop sync history (from [com.ffii.fpsms.m18.entity.M18BomShopSyncLog]). + * JSON for Excel export on the report page: sync attempts + material lines per version. + * + * Example: `/report/bom-shop-sync-history?syncDateStart=2026-06-01&syncDateEnd=2026-06-13` + */ + @GetMapping("/bom-shop-sync-history") + fun getBomShopSyncHistoryReport( + @RequestParam(required = false) syncDateStart: String?, + @RequestParam(required = false) syncDateEnd: String?, + @RequestParam(required = false) finishedItemCode: String?, + @RequestParam(required = false) syncStatus: String?, + ): Map = + m18BomShopSyncReportService.searchBomShopSyncHistoryReport( + syncDateStart = syncDateStart, + syncDateEnd = syncDateEnd, + finishedItemCode = finishedItemCode, + syncStatus = syncStatus, + ) + companion object { /** GRN report fields only users with ADMIN authority may see */ private val GRN_FINANCIAL_KEYS = setOf("unitPrice", "lineAmount", "currencyCode")