diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18DataLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18DataLogRepository.kt index a8cab66..891fa02 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18DataLogRepository.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18DataLogRepository.kt @@ -3,9 +3,16 @@ package com.ffii.fpsms.m18.entity import com.ffii.core.support.AbstractRepository import com.ffii.fpsms.m18.enums.M18DataLogStatus import org.springframework.stereotype.Repository +import java.time.LocalDateTime @Repository interface M18DataLogRepository : AbstractRepository { // find latest m18 data log by m18 id & ref type & status is true & deleted is false (order by id asc limit 1) fun findTopByM18IdAndRefTypeAndDeletedIsFalseAndStatusOrderByIdDesc(m18Id: Long, refType: String, status: M18DataLogStatus): M18DataLog? + + fun findAllByRefTypeAndStatusAndDeletedIsFalseAndCreatedGreaterThanEqualOrderByIdAsc( + refType: String, + status: M18DataLogStatus, + created: LocalDateTime, + ): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index daa2977..0cba470 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -445,12 +445,12 @@ open class M18PurchaseOrderService( if (itemId == null) { failDetailList.add(line.id) logger.error( - "${poLineRefType}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}" + "${poLineRefType}: PO ${mainpo.code}: Cannot resolve local item for M18 proId=${line.proId}, skipping line ${line.id}" ) val errorSaveM18PurchaseOrderLineLogRequest = SaveM18DataLogRequest( id = saveM18PurchaseOrderLineLog.id, dataLog = mutableMapOf( - "Exception Message" to "Cannot resolve local item for M18 proId=${line.proId}" + "Exception Message" to "PO ${mainpo.code} Cannot resolve local item for M18 proId=${line.proId}" ), statusEnum = M18DataLogStatus.FAIL ) diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/PoLineFailureAlertSupport.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/PoLineFailureAlertSupport.kt new file mode 100644 index 0000000..da3eba3 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/PoLineFailureAlertSupport.kt @@ -0,0 +1,27 @@ +package com.ffii.fpsms.modules.common.alert + +/** + * Parses PO line sync failure rows written by [com.ffii.fpsms.m18.service.M18PurchaseOrderService]. + * Expected format: "PO {code} Cannot resolve local item for M18 proId={id}". + */ +internal object PoLineFailureAlertSupport { + private val WITH_PO_CODE = + Regex("^PO (\\S+) Cannot resolve local item for M18 proId=(\\d+)$") + + /** @return (poCode, bullet line for email) or null when the message cannot be parsed. */ + fun parseExceptionMessage(raw: String?): Pair? { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) { + return null + } + val match = WITH_PO_CODE.matchEntire(trimmed) ?: return null + val poCode = match.groupValues[1] + val proId = match.groupValues[2] + return poCode to "Cannot resolve local item for M18 proId=$proId" + } + + fun buildEmailBody(poCode: String, bulletLines: Collection): String { + val detail = bulletLines.joinToString("\n") { "- $it" } + return "FPSMS PO_LINE FAIL: $poCode\n\n$detail" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt index bd82875..fbfe8b3 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt @@ -12,6 +12,7 @@ data class SchedulerSyncAlertProperties( val email: EmailAlertProperties = EmailAlertProperties(), val do1: Do1AlertProperties = Do1AlertProperties(), val presence: PresenceAlertProperties = PresenceAlertProperties(), + val poLine: PoLineAlertProperties = PoLineAlertProperties(), ) data class SmsProperties( @@ -65,3 +66,8 @@ data class Do1AlertProperties( data class PresenceAlertProperties( val graceMinutesAfterSchedule: Int = 60, ) + +/** Purchase order line sync failures from [m18_data_log] (one email per PO code per day). */ +data class PoLineAlertProperties( + val enabled: Boolean = true, +) diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt index 739ff78..dca5b13 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt @@ -1,7 +1,9 @@ package com.ffii.fpsms.modules.common.alert +import com.ffii.fpsms.m18.entity.M18DataLogRepository import com.ffii.fpsms.m18.entity.SchedulerSyncLog import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository +import com.ffii.fpsms.m18.enums.M18DataLogStatus import com.ffii.fpsms.modules.common.SettingNames import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService import com.ffii.fpsms.modules.settings.entity.Settings @@ -24,6 +26,7 @@ import kotlin.jvm.optionals.getOrNull * * - **DO1**: low volume (< min records), FAILED status, zero records, line failures. * - **PO / DO2 / master-data**: no SUCCESS log by schedule + grace window. + * - **PO_LINE**: [m18_data_log] purchase order line FAIL rows (one email per PO code per day). */ @Service open class SchedulerSyncAlertService( @@ -31,23 +34,36 @@ open class SchedulerSyncAlertService( private val smsSender: SmsSender, private val syncAlertEmailSender: SyncAlertEmailSender, private val schedulerSyncLogRepository: SchedulerSyncLogRepository, + private val m18DataLogRepository: M18DataLogRepository, private val settingsService: SettingsService, private val webClientBuilder: WebClient.Builder, @Value("\${scheduler.m18Sync.enabled:false}") private val m18SyncEnabled: Boolean, ) { private val logger = LoggerFactory.getLogger(SchedulerSyncAlertService::class.java) + private companion object { + const val PO_LINE_REF_TYPE = "Purchase Order Line" + const val PO_LINE_ALERT_JOB = "PO_LINE" + } + /** Master-data sub-jobs written by [SchedulerService.getM18MasterData]. */ private val masterDataSyncTypes = listOf("Units", "Products", "Vendors", "BusinessUnits", "Currencies") open fun runChecks(now: LocalDateTime = LocalDateTime.now()): List { + val alerts = mutableListOf>() + alerts += checkPoLineFailures(now) + if (!m18SyncEnabled) { - logger.debug("Sync alert skipped (scheduler.m18Sync.enabled=false)") - return emptyList() + for (message in alerts) { + sendAlert(message.first, message.second, message.third) + } + if (alerts.isEmpty()) { + logger.debug("Sync alert skipped (scheduler.m18Sync.enabled=false)") + } + return alerts.map { it.third } } - val alerts = mutableListOf>() alerts += checkDo1(now) alerts += checkPresenceSync("PO", SettingNames.SCHEDULE_M18_PO, "0 0 2 * * *", now) alerts += checkPresenceSync("DO2", SettingNames.SCHEDULE_M18_DO2, SchedulerService.DO2_DEFAULT_CRON, now) @@ -59,6 +75,45 @@ open class SchedulerSyncAlertService( return alerts.map { it.third } } + /** + * Email when M18 PO line sync wrote FAIL rows to [m18_data_log] today. + * Dedupes via [sendAlert] using job [PO_LINE_ALERT_JOB] and PO code (one email per PO per day). + */ + private fun checkPoLineFailures(now: LocalDateTime): List> { + if (!properties.poLine.enabled) { + return emptyList() + } + + val dayStart = now.toLocalDate().atStartOfDay() + val logs = + m18DataLogRepository.findAllByRefTypeAndStatusAndDeletedIsFalseAndCreatedGreaterThanEqualOrderByIdAsc( + PO_LINE_REF_TYPE, + M18DataLogStatus.FAIL, + dayStart, + ) + if (logs.isEmpty()) { + return emptyList() + } + + val bulletsByPo = linkedMapOf>() + for (log in logs) { + val raw = log.dataLog?.get("Exception Message")?.toString() + val parsed = PoLineFailureAlertSupport.parseExceptionMessage(raw) ?: continue + bulletsByPo.getOrPut(parsed.first) { LinkedHashSet() }.add(parsed.second) + } + if (bulletsByPo.isEmpty()) { + return emptyList() + } + + return bulletsByPo.map { (poCode, bullets) -> + Triple( + PO_LINE_ALERT_JOB, + poCode, + PoLineFailureAlertSupport.buildEmailBody(poCode, bullets), + ) + } + } + private fun checkDo1(now: LocalDateTime): List> { val runDate = now.toLocalDate() val scheduledTime = resolveDo1ScheduledTime(runDate) ?: return emptyList() diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 5ec3aa8..dcd2d6e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -62,6 +62,8 @@ scheduler: email: enabled: true to-addresses: "vluk@2fi-solutions.com.hk,kelvin.yau@2fi-solutions.com.hk" + po-line: + enabled: true # From = MAIL.smtp.username in DB (e.g. vinluk95@gmail.com + Gmail app password) # Laser Bag2 (/laserPrint) TCP auto-send; uses LASER_PRINT host/port/itemCodes from DB and sends first matching job only. diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 84408a8..81f0b94 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -63,6 +63,8 @@ scheduler: grace-minutes-after-schedule: 30 presence: grace-minutes-after-schedule: 60 + po-line: + enabled: ${SYNC_ALERT_PO_LINE_ENABLED:true} # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). fpsms: