| @@ -1,10 +1,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 SchedulerSyncLogRepository : AbstractRepository<SchedulerSyncLog, Long> { | |||
| fun findTop20ByOrderByEndTimeDesc(): List<SchedulerSyncLog> | |||
| fun findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc( | |||
| syncType: String, | |||
| startTime: LocalDateTime, | |||
| endTime: LocalDateTime, | |||
| ): SchedulerSyncLog? | |||
| } | |||
| @@ -82,9 +82,9 @@ data class M18UdfProductSaveValue( | |||
| /** M18 vendor id ([StSearchType.VENDOR]) for the BOM business entity: PP → [M18Config.BEID_PP], PF → [M18Config.BEID_PF]. */ | |||
| val udfSupplier: Long? = null, | |||
| /** | |||
| * M18 UOM id for price/purchase unit on the **M18-linked** PO line (`m18DataLog` present): | |||
| * [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uomM18] (M18 `unitId`) then | |||
| * [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine.uom] → [com.ffii.fpsms.modules.master.entity.UomConversion.m18Id]. | |||
| * M18 UOM id for price/purchase unit: from an M18-linked PO line on the **same BE** as the BOM. | |||
| * When PP/PF supplier is resolved via code from another BE's PO, unit is taken from a target-BE PO line | |||
| * (same item + supplier code), not from the foreign BE line. | |||
| */ | |||
| @JsonProperty("udfpurchaseUnit") | |||
| val udfpurchaseUnit: Long? = null, | |||
| @@ -343,20 +343,12 @@ open class M18BomForShopService( | |||
| return null | |||
| } | |||
| val itemId = mat.item?.id | |||
| val latestPoLine = itemId?.let { id -> | |||
| pickPreferredPoLine( | |||
| purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 10)), | |||
| targetBeId, | |||
| ) | |||
| } | |||
| val supplierM18Id = resolveSupplierM18Id(latestPoLine, flowTypeId, supplierCache) | |||
| /** | |||
| * M18 line price unit id ([M18PurchaseOrderPot.unitId]): prefer [PurchaseOrderLine.uomM18] from M18 PO sync, | |||
| * else [PurchaseOrderLine.uom] when uomM18 is missing. | |||
| */ | |||
| val purchaseUnitM18Id = | |||
| latestPoLine?.uomM18?.m18Id?.takeIf { it > 0L } | |||
| ?: latestPoLine?.uom?.m18Id?.takeIf { it > 0L } | |||
| val poLines = itemId?.let { id -> | |||
| purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 20)) | |||
| } ?: emptyList() | |||
| val supplierAndUnit = resolvePoSupplierAndPurchaseUnit(poLines, flowTypeId, targetBeId, supplierCache) | |||
| val supplierM18Id = supplierAndUnit.supplierM18Id | |||
| val purchaseUnitM18Id = supplierAndUnit.purchaseUnitM18Id | |||
| val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble() | |||
| return M18UdfProductSaveValue( | |||
| id = mat.m18Id?.takeIf { it > 0 }, | |||
| @@ -380,6 +372,80 @@ open class M18BomForShopService( | |||
| return lines.firstOrNull { it.purchaseOrder?.m18BeId == preferredBeId } ?: lines.first() | |||
| } | |||
| private fun pickPoLineMatchingBeOnly(lines: List<PurchaseOrderLine>, beId: Long?): PurchaseOrderLine? { | |||
| if (lines.isEmpty() || beId == null) return null | |||
| return lines.firstOrNull { it.purchaseOrder?.m18BeId == beId } | |||
| } | |||
| /** M18 line price unit ([M18PurchaseOrderPot.unitId]): [PurchaseOrderLine.uomM18] then [PurchaseOrderLine.uom]. */ | |||
| private fun purchaseUnitM18IdFromPoLine(poLine: PurchaseOrderLine?): Long? = | |||
| poLine?.uomM18?.m18Id?.takeIf { it > 0L } | |||
| ?: poLine?.uom?.m18Id?.takeIf { it > 0L } | |||
| private data class PoSupplierUnitContext( | |||
| val supplierM18Id: Long?, | |||
| val purchaseUnitM18Id: Long?, | |||
| ) | |||
| /** | |||
| * Supplier + purchase unit for BOM material lines. | |||
| * PF/PP BOMs: [udfpurchaseUnit] must come from a PO line on the **same business entity** (PP unit with PP supplier). | |||
| * When PP supplier is resolved via supplier code from a PF/other PO, look for a PP PO line for the same item + supplier code for the unit. | |||
| */ | |||
| private fun resolvePoSupplierAndPurchaseUnit( | |||
| poLines: List<PurchaseOrderLine>, | |||
| flowTypeId: Int, | |||
| targetBeId: Long?, | |||
| supplierCache: MutableMap<String, Long?>, | |||
| ): PoSupplierUnitContext { | |||
| if (poLines.isEmpty()) { | |||
| return PoSupplierUnitContext(null, null) | |||
| } | |||
| val beMatchedLine = pickPoLineMatchingBeOnly(poLines, targetBeId) | |||
| if (beMatchedLine != null) { | |||
| return PoSupplierUnitContext( | |||
| supplierM18Id = resolveSupplierM18Id(beMatchedLine, flowTypeId, supplierCache), | |||
| purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(beMatchedLine), | |||
| ) | |||
| } | |||
| if (flowTypeId != 2 && flowTypeId != 3) { | |||
| val line = pickPreferredPoLine(poLines, targetBeId) | |||
| return PoSupplierUnitContext( | |||
| supplierM18Id = resolveSupplierM18Id(line, flowTypeId, supplierCache), | |||
| purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(line), | |||
| ) | |||
| } | |||
| val supplierSourceLine = pickPreferredPoLine(poLines, targetBeId) | |||
| val supplierCode = supplierSourceLine?.purchaseOrder?.supplier?.code?.trim()?.takeIf { it.isNotEmpty() } | |||
| val supplierM18Id = resolveSupplierM18Id(supplierSourceLine, flowTypeId, supplierCache) | |||
| val unitLine = | |||
| if (targetBeId != null && !supplierCode.isNullOrEmpty()) { | |||
| poLines.firstOrNull { pol -> | |||
| pol.purchaseOrder?.m18BeId == targetBeId && | |||
| pol.purchaseOrder?.supplier?.code?.trim().equals(supplierCode, ignoreCase = true) | |||
| } | |||
| } else { | |||
| null | |||
| } | |||
| val purchaseUnitM18Id = purchaseUnitM18IdFromPoLine(unitLine) | |||
| if (supplierM18Id != null && purchaseUnitM18Id == null && supplierSourceLine != null) { | |||
| val beLabel = if (flowTypeId == 2) "PF" else "PP" | |||
| logger.warn( | |||
| "[M18 BOM] $beLabel supplier resolved from PO code={} supplierCode={} but no $beLabel PO line " + | |||
| "for same item+supplier — omitting udfpurchaseUnit (PF/other BE unit is invalid with $beLabel supplier)", | |||
| supplierSourceLine.purchaseOrder?.code, | |||
| supplierCode, | |||
| ) | |||
| } | |||
| return PoSupplierUnitContext(supplierM18Id, purchaseUnitM18Id) | |||
| } | |||
| private fun resolveTargetBeId(flowTypeId: Int): Long? = when (flowTypeId) { | |||
| 2 -> m18Config.BEID_PF.toLongOrNull() | |||
| 3 -> m18Config.BEID_PP.toLongOrNull() | |||
| @@ -23,12 +23,11 @@ open class MailSMTP(settingsService: SettingsService) { | |||
| if (other == null || other !is MailSMTP) return false | |||
| val o = other as MailSMTP | |||
| if (StringUtils.equals( | |||
| this.host, | |||
| o.host | |||
| ) && this.port == o.port && | |||
| if (StringUtils.equals(this.host, o.host) && | |||
| this.port == o.port && | |||
| StringUtils.equals(this.username, o.username) && | |||
| StringUtils.equals(this.password, o.password) | |||
| StringUtils.equals(this.password, o.password) && | |||
| this.auth == o.auth | |||
| ) { | |||
| return true | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| package com.ffii.fpsms.modules.common.alert | |||
| import org.slf4j.LoggerFactory | |||
| /** Dev / disabled mode: log SMS body instead of sending. */ | |||
| class LoggingSmsSender : SmsSender { | |||
| private val logger = LoggerFactory.getLogger(LoggingSmsSender::class.java) | |||
| override fun send( | |||
| toNumbers: List<String>, | |||
| body: String, | |||
| job: String, | |||
| code: String, | |||
| detail: String, | |||
| ) { | |||
| logger.warn("[SMS would send to {}] {}", toNumbers.joinToString(), body) | |||
| } | |||
| } | |||
| @@ -0,0 +1,67 @@ | |||
| package com.ffii.fpsms.modules.common.alert | |||
| import org.springframework.boot.context.properties.ConfigurationProperties | |||
| @ConfigurationProperties(prefix = "scheduler.sync-alert") | |||
| data class SchedulerSyncAlertProperties( | |||
| /** When false, checks still run but SMS is not sent (logged only). */ | |||
| val enabled: Boolean = false, | |||
| /** Cron for the watchdog that evaluates sync health (default: every 15 minutes). */ | |||
| val checkCron: String = "0 */15 * * * *", | |||
| val sms: SmsProperties = SmsProperties(), | |||
| val email: EmailAlertProperties = EmailAlertProperties(), | |||
| val do1: Do1AlertProperties = Do1AlertProperties(), | |||
| val presence: PresenceAlertProperties = PresenceAlertProperties(), | |||
| ) | |||
| data class SmsProperties( | |||
| /** When false, no SMS/WhatsApp alerts (email-only). */ | |||
| val enabled: Boolean = false, | |||
| /** twilio | log (dev: log message only, no HTTP) */ | |||
| val provider: String = "log", | |||
| /** sms or whatsapp (Twilio WhatsApp uses whatsapp:+E164 on To/From). */ | |||
| val channel: String = "sms", | |||
| val accountSid: String = "", | |||
| val authToken: String = "", | |||
| val fromNumber: String = "", | |||
| /** Comma-separated E.164 numbers, e.g. +85291234567 (whatsapp prefix added when channel=whatsapp). */ | |||
| val toNumbers: String = "", | |||
| /** | |||
| * Twilio Content Template SID (WhatsApp business-initiated messages). | |||
| * When set, sends ContentSid + ContentVariables instead of Body. | |||
| * Template should use {{1}}=job (DO1/PO/…) and {{2}}=alert detail. | |||
| */ | |||
| val contentSid: String = "", | |||
| val contentDetailMaxLength: Int = 200, | |||
| /** | |||
| * Maps to Twilio ContentVariables `"1"` (template {{1}}). | |||
| * job | date (d/M, e.g. 17/6) | time (3pm) | detail | summary (job + code + detail) | |||
| */ | |||
| val contentVar1: String = "job", | |||
| /** Maps to ContentVariables `"2"`. Same options as [contentVar1]. */ | |||
| val contentVar2: String = "detail", | |||
| ) | |||
| data class EmailAlertProperties( | |||
| /** Send sync alerts by email (uses DB MAIL.smtp.* — Office 365). */ | |||
| val enabled: Boolean = false, | |||
| /** Comma-separated recipients, e.g. [email protected],[email protected] */ | |||
| val toAddresses: String = "", | |||
| /** Subject prefix; full subject is prefix + job + code in brackets. */ | |||
| val subjectPrefix: String = "FPSMS M18 sync alert", | |||
| ) | |||
| data class Do1AlertProperties( | |||
| /** Warn when recordsProcessed is below this (typical full day ~800+). */ | |||
| val minRecordsProcessed: Int = 400, | |||
| /** Minutes after today's DO1 schedule time before evaluating DO1 rules. */ | |||
| val graceMinutesAfterSchedule: Int = 30, | |||
| val alertOnFailed: Boolean = true, | |||
| val alertOnZeroRecords: Boolean = true, | |||
| val alertOnLineFailures: Boolean = true, | |||
| ) | |||
| /** PO / DO2 / master-data: alert if no SUCCESS log by schedule time + grace. */ | |||
| data class PresenceAlertProperties( | |||
| val graceMinutesAfterSchedule: Int = 60, | |||
| ) | |||
| @@ -0,0 +1,449 @@ | |||
| package com.ffii.fpsms.modules.common.alert | |||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLog | |||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService | |||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.beans.factory.annotation.Value | |||
| import org.springframework.scheduling.support.CronExpression | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.web.reactive.function.client.WebClient | |||
| import java.time.DayOfWeek | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| import java.util.Locale | |||
| import kotlin.jvm.optionals.getOrNull | |||
| /** | |||
| * Watches [scheduler_sync_log] after M18 cron jobs and sends SMS when rules fail. | |||
| * | |||
| * - **DO1**: low volume (< min records), FAILED status, zero records, line failures. | |||
| * - **PO / DO2 / master-data**: no SUCCESS log by schedule + grace window. | |||
| */ | |||
| @Service | |||
| open class SchedulerSyncAlertService( | |||
| private val properties: SchedulerSyncAlertProperties, | |||
| private val smsSender: SmsSender, | |||
| private val syncAlertEmailSender: SyncAlertEmailSender, | |||
| private val schedulerSyncLogRepository: SchedulerSyncLogRepository, | |||
| 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) | |||
| /** 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<String> { | |||
| if (!m18SyncEnabled) { | |||
| logger.debug("Sync alert skipped (scheduler.m18Sync.enabled=false)") | |||
| return emptyList() | |||
| } | |||
| val alerts = mutableListOf<Triple<String, String, String>>() | |||
| 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) | |||
| alerts += checkMasterDataPresence(now) | |||
| for (message in alerts) { | |||
| sendAlert(message.first, message.second, message.third) | |||
| } | |||
| return alerts.map { it.third } | |||
| } | |||
| private fun checkDo1(now: LocalDateTime): List<Triple<String, String, String>> { | |||
| val runDate = now.toLocalDate() | |||
| val scheduledTime = resolveDo1ScheduledTime(runDate) ?: return emptyList() | |||
| val checkAfter = scheduledTime.plusMinutes(properties.do1.graceMinutesAfterSchedule.toLong()) | |||
| if (now.isBefore(checkAfter)) { | |||
| return emptyList() | |||
| } | |||
| val log = latestLogOnDay("DO1", runDate) ?: run { | |||
| return listOf(Triple("DO1", "MISSING", alertMessage("DO1", "MISSING", "No DO1 sync log today (expected after ${scheduledTime.toLocalTime()})."))) | |||
| } | |||
| val issues = mutableListOf<String>() | |||
| val cfg = properties.do1 | |||
| if (cfg.alertOnFailed && log.status.equals("FAILED", ignoreCase = true)) { | |||
| issues += "status=FAILED ${log.errorMessage.orEmpty()}".trim() | |||
| } | |||
| if (cfg.alertOnZeroRecords && log.recordsProcessed == 0) { | |||
| issues += "0 orders processed (possible M18 list timeout)" | |||
| } | |||
| if (log.recordsProcessed < cfg.minRecordsProcessed) { | |||
| issues += "only ${log.recordsProcessed} processed (min ${cfg.minRecordsProcessed})" | |||
| } | |||
| if (cfg.alertOnLineFailures && log.recordsFailed > 0) { | |||
| issues += "${log.recordsFailed} line(s) failed" | |||
| } | |||
| if (issues.isEmpty()) { | |||
| return emptyList() | |||
| } | |||
| return listOf(Triple("DO1", "PROBLEM", alertMessage("DO1", "PROBLEM", issues.joinToString("; ")))) | |||
| } | |||
| private fun checkMasterDataPresence(now: LocalDateTime): List<Triple<String, String, String>> { | |||
| val runDate = now.toLocalDate() | |||
| val cron = | |||
| settingsService.findByName(SettingNames.SCHEDULE_M18_MASTER).getOrNull()?.value | |||
| ?: "0 0 1 * * *" | |||
| val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList() | |||
| val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong()) | |||
| if (now.isBefore(checkAfter)) { | |||
| return emptyList() | |||
| } | |||
| val missing = masterDataSyncTypes.filter { type -> | |||
| val log = latestLogOnDay(type, runDate) | |||
| log == null || !log.status.equals("SUCCESS", ignoreCase = true) | |||
| } | |||
| if (missing.isEmpty()) { | |||
| return emptyList() | |||
| } | |||
| return listOf( | |||
| Triple( | |||
| "MASTER", | |||
| "MISSING", | |||
| alertMessage("MASTER", "MISSING", "No SUCCESS today for: ${missing.joinToString()}"), | |||
| ), | |||
| ) | |||
| } | |||
| private fun checkPresenceSync( | |||
| label: String, | |||
| settingName: String, | |||
| defaultCron: String, | |||
| now: LocalDateTime, | |||
| ): List<Triple<String, String, String>> { | |||
| val runDate = now.toLocalDate() | |||
| val cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCron | |||
| val scheduledTime = scheduledTimeToday(cron, runDate) ?: return emptyList() | |||
| val checkAfter = scheduledTime.plusMinutes(properties.presence.graceMinutesAfterSchedule.toLong()) | |||
| if (now.isBefore(checkAfter)) { | |||
| return emptyList() | |||
| } | |||
| val log = latestLogOnDay(label, runDate) | |||
| when { | |||
| log == null -> | |||
| return listOf( | |||
| Triple( | |||
| label, | |||
| "MISSING", | |||
| alertMessage(label, "MISSING", "No $label sync log today (expected after ${scheduledTime.toLocalTime()})."), | |||
| ), | |||
| ) | |||
| !log.status.equals("SUCCESS", ignoreCase = true) -> | |||
| return listOf( | |||
| Triple( | |||
| label, | |||
| log.status.uppercase(), | |||
| alertMessage( | |||
| label, | |||
| log.status.uppercase(), | |||
| "processed=${log.recordsProcessed} failed=${log.recordsFailed} ${log.errorMessage.orEmpty()}".trim(), | |||
| ), | |||
| ), | |||
| ) | |||
| else -> return emptyList() | |||
| } | |||
| } | |||
| private fun latestLogOnDay(syncType: String, date: LocalDate): SchedulerSyncLog? { | |||
| val dayStart = date.atStartOfDay() | |||
| val dayEnd = dayStart.plusDays(1) | |||
| return schedulerSyncLogRepository.findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc( | |||
| syncType, | |||
| dayStart, | |||
| dayEnd, | |||
| ) | |||
| } | |||
| private fun resolveDo1ScheduledTime(date: LocalDate): LocalDateTime? { | |||
| val cron = | |||
| if (date.dayOfWeek == DayOfWeek.SATURDAY) { | |||
| settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_SAT).getOrNull()?.value | |||
| ?: "0 10 3 ? * SAT" | |||
| } else { | |||
| settingsService.findByName(SettingNames.SCHEDULE_M18_DO1).getOrNull()?.value | |||
| ?: "0 10 19 * * *" | |||
| } | |||
| return scheduledTimeToday(cron, date) | |||
| } | |||
| /** Next fire time on [date] from a 6-field Spring cron, if that day matches the DOW field. */ | |||
| internal fun scheduledTimeToday(cronExpression: String, date: LocalDate): LocalDateTime? { | |||
| if (!cronMatchesDate(cronExpression, date)) { | |||
| return null | |||
| } | |||
| val parts = cronExpression.trim().split(Regex("\\s+")) | |||
| if (parts.size != 6) { | |||
| return null | |||
| } | |||
| val second = parts[0].toIntOrNull() ?: 0 | |||
| val minute = parts[1].toIntOrNull() ?: return null | |||
| val hour = parts[2].toIntOrNull() ?: return null | |||
| return date.atTime(LocalTime.of(hour, minute, second)) | |||
| } | |||
| internal fun cronMatchesDate(cronExpression: String, date: LocalDate): Boolean { | |||
| val parts = cronExpression.trim().split(Regex("\\s+")) | |||
| if (parts.size != 6) { | |||
| return false | |||
| } | |||
| val dowField = parts[5].uppercase() | |||
| if (dowField == "*" || dowField == "?") { | |||
| return true | |||
| } | |||
| val day = date.dayOfWeek | |||
| val tokens = dowField.split(",") | |||
| for (token in tokens) { | |||
| val trimmed = token.trim() | |||
| when { | |||
| trimmed.contains("-") -> { | |||
| val range = trimmed.split("-") | |||
| if (range.size == 2) { | |||
| val from = parseDayOfWeek(range[0]) ?: continue | |||
| val to = parseDayOfWeek(range[1]) ?: continue | |||
| if (day.value in from.value..to.value) { | |||
| return true | |||
| } | |||
| } | |||
| } | |||
| else -> { | |||
| parseDayOfWeek(trimmed)?.let { if (day == it) return true } | |||
| } | |||
| } | |||
| } | |||
| return false | |||
| } | |||
| private fun parseDayOfWeek(token: String): DayOfWeek? = | |||
| when (token.uppercase()) { | |||
| "SUN", "0", "7" -> DayOfWeek.SUNDAY | |||
| "MON", "1" -> DayOfWeek.MONDAY | |||
| "TUE", "2" -> DayOfWeek.TUESDAY | |||
| "WED", "3" -> DayOfWeek.WEDNESDAY | |||
| "THU", "4" -> DayOfWeek.THURSDAY | |||
| "FRI", "5" -> DayOfWeek.FRIDAY | |||
| "SAT", "6" -> DayOfWeek.SATURDAY | |||
| else -> null | |||
| } | |||
| private fun alertMessage(job: String, code: String, detail: String): String = | |||
| "FPSMS $job $code: $detail" | |||
| private fun detailFromMessage(message: String): String { | |||
| val prefix = message.indexOf(": ") | |||
| return if (prefix >= 0) message.substring(prefix + 2) else message | |||
| } | |||
| private fun sendAlert(job: String, code: String, message: String) { | |||
| val dedupeKey = "SCHEDULE.syncAlert.sent.$job.$code.${LocalDate.now()}" | |||
| if (alreadySent(dedupeKey)) { | |||
| logger.debug("Sync alert already sent today: {}", message) | |||
| return | |||
| } | |||
| if (!properties.enabled) { | |||
| logger.warn("[sync-alert disabled] {}", message) | |||
| return | |||
| } | |||
| var anySent = false | |||
| if (properties.email.enabled) { | |||
| val emailTo = properties.email.toAddresses.split(",") | |||
| .map { it.trim() } | |||
| .filter { it.isNotBlank() } | |||
| if (emailTo.isEmpty()) { | |||
| logger.error("Sync alert email skipped (no scheduler.sync-alert.email.to-addresses): {}", message) | |||
| } else if (!syncAlertEmailSender.isSmtpConfigured()) { | |||
| logger.error( | |||
| "Sync alert email skipped (configure MAIL.smtp.username/password/host in settings): {}", | |||
| message, | |||
| ) | |||
| } else { | |||
| try { | |||
| val subject = "${properties.email.subjectPrefix} [$job] $code" | |||
| syncAlertEmailSender.send(emailTo, subject, message) | |||
| anySent = true | |||
| logger.info("Sync alert email sent: {}", message) | |||
| } catch (e: Exception) { | |||
| logger.error("Sync alert email failed: {}", message, e) | |||
| } | |||
| } | |||
| } | |||
| val smsTo = properties.sms.toNumbers.split(",") | |||
| .map { it.trim() } | |||
| .filter { it.isNotBlank() } | |||
| val smsActive = | |||
| properties.sms.enabled && | |||
| !properties.sms.provider.equals("log", ignoreCase = true) && | |||
| smsTo.isNotEmpty() | |||
| if (smsActive) { | |||
| try { | |||
| smsSender.send(smsTo, message, job, code, detailFromMessage(message)) | |||
| anySent = true | |||
| logger.info("Sync alert SMS sent: {}", message) | |||
| } catch (e: Exception) { | |||
| logger.error("Sync alert SMS failed: {}", message, e) | |||
| } | |||
| } | |||
| if (anySent) { | |||
| markSent(dedupeKey, message) | |||
| } else { | |||
| logger.error("Sync alert not delivered on any channel: {}", message) | |||
| } | |||
| } | |||
| private fun alreadySent(key: String): Boolean = | |||
| settingsService.findByName(key).map { it.value == Settings.VALUE_BOOLEAN_TRUE }.orElse(false) | |||
| private fun markSent(key: String, message: String) { | |||
| val existing = settingsService.findByName(key).orElse(null) | |||
| if (existing != null) { | |||
| settingsService.update(key, Settings.VALUE_BOOLEAN_TRUE) | |||
| } else { | |||
| val setting = Settings() | |||
| setting.name = key | |||
| setting.value = Settings.VALUE_BOOLEAN_TRUE | |||
| setting.category = "SCHEDULE" | |||
| setting.type = Settings.TYPE_BOOLEAN | |||
| settingsService.save(setting) | |||
| } | |||
| } | |||
| /** Validates cron at startup / schedule registration. */ | |||
| fun isValidCronExpression(cronExpression: String): Boolean = | |||
| try { | |||
| CronExpression.parse(cronExpression) | |||
| true | |||
| } catch (_: IllegalArgumentException) { | |||
| false | |||
| } | |||
| /** | |||
| * Send one WhatsApp using your Twilio Content template (same shape as Twilio console sample). | |||
| * Defaults: var1=today d/M, var2=now h:mma (e.g. 17/6, 7:30pm). Pass var1=12/1&var2=3pm to match sandbox demo. | |||
| */ | |||
| open fun sendTestWhatsApp(var1: String?, var2: String?): String { | |||
| if (!properties.sms.enabled) { | |||
| return "WhatsApp/SMS sync alerts are disabled (scheduler.sync-alert.sms.enabled=false)." | |||
| } | |||
| twilioConfigIssue()?.let { return it } | |||
| val twilio = resolveTwilioSender()!! | |||
| val recipients = properties.sms.toNumbers.split(",") | |||
| .map { it.trim() } | |||
| .filter { it.isNotBlank() } | |||
| val now = LocalDateTime.now() | |||
| val v1 = var1?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: now.format(DateTimeFormatter.ofPattern("d/M")) | |||
| val v2 = var2?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: now.format(DateTimeFormatter.ofPattern("h:mma", Locale.ENGLISH)) | |||
| twilio.sendWithTemplateVars(recipients, v1, v2) | |||
| return "WhatsApp test sent ContentVariables={\"1\":\"$v1\",\"2\":\"$v2\"} to ${recipients.joinToString()}" | |||
| } | |||
| /** Send a test email via DB SMTP (Office 365). Does not require sync-alert.enabled. */ | |||
| open fun sendTestEmail(message: String? = null, subject: String? = null): String { | |||
| val emailTo = properties.email.toAddresses.split(",") | |||
| .map { it.trim() } | |||
| .filter { it.isNotBlank() } | |||
| if (emailTo.isEmpty()) { | |||
| return "Set scheduler.sync-alert.email.to-addresses (e.g. [email protected])" | |||
| } | |||
| if (!syncAlertEmailSender.isSmtpConfigured()) { | |||
| return syncAlertEmailSender.smtpConfigIssue() | |||
| ?: "Configure MAIL.smtp.username, MAIL.smtp.password, MAIL.smtp.host in settings table" | |||
| } | |||
| val from = syncAlertEmailSender.resolveFromAddress() ?: "unknown" | |||
| val body = | |||
| message?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: ( | |||
| "FPSMS sync alert email test.\n\n" + | |||
| "If you receive this, Office 365 SMTP is working.\n" + | |||
| "From (MAIL.smtp.username): $from\n" + | |||
| "Time: ${LocalDateTime.now()}" | |||
| ) | |||
| val emailSubject = | |||
| subject?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: "${properties.email.subjectPrefix} [TEST]" | |||
| return try { | |||
| syncAlertEmailSender.send(emailTo, emailSubject, body) | |||
| "Test email sent from $from to ${emailTo.joinToString()}\nSubject: $emailSubject" | |||
| } catch (e: Exception) { | |||
| val hint = | |||
| when { | |||
| e.message?.contains("530", ignoreCase = true) == true || | |||
| e.message?.contains("not authenticated", ignoreCase = true) == true -> | |||
| "\n\nOffice 365 fix checklist:\n" + | |||
| "1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\n" + | |||
| "2. MAIL.smtp.username must match the mailbox ([email protected])\n" + | |||
| "3. Use an App password if MFA is on (not your normal login password)\n" + | |||
| "4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" + | |||
| "5. Restart backend after changing settings (SMTP client is cached)" | |||
| else -> "" | |||
| } | |||
| "Email send failed: ${e.message}$hint" | |||
| } | |||
| } | |||
| /** Uses injected [TwilioSmsSender] bean, or builds one when Twilio credentials are in config. */ | |||
| private fun resolveTwilioSender(): TwilioSmsSender? { | |||
| if (twilioConfigIssue() != null) { | |||
| return null | |||
| } | |||
| if (smsSender is TwilioSmsSender) { | |||
| return smsSender | |||
| } | |||
| return TwilioSmsSender(properties, webClientBuilder) | |||
| } | |||
| private fun twilioConfigIssue(): String? { | |||
| val sms = properties.sms | |||
| val missing = mutableListOf<String>() | |||
| if (sms.accountSid.isBlank()) { | |||
| missing += "TWILIO_ACCOUNT_SID (or scheduler.sync-alert.sms.account-sid)" | |||
| } | |||
| if (sms.authToken.isBlank()) { | |||
| missing += "TWILIO_AUTH_TOKEN (or scheduler.sync-alert.sms.auth-token)" | |||
| } | |||
| if (sms.fromNumber.isBlank()) { | |||
| missing += "TWILIO_FROM_NUMBER" | |||
| } | |||
| if (sms.toNumbers.isBlank()) { | |||
| missing += "SYNC_ALERT_SMS_TO (or scheduler.sync-alert.sms.to-numbers)" | |||
| } | |||
| if (sms.contentSid.isBlank()) { | |||
| missing += "TWILIO_WHATSAPP_CONTENT_SID" | |||
| } | |||
| if (missing.isEmpty()) { | |||
| return null | |||
| } | |||
| return ( | |||
| "Twilio not configured. Missing: ${missing.joinToString(", ")}.\n" + | |||
| "Set env vars and restart backend. Example (PowerShell before bootRun):\n" + | |||
| "\$env:TWILIO_ACCOUNT_SID='AC…'; \$env:TWILIO_AUTH_TOKEN='…'; " + | |||
| "\$env:SYNC_ALERT_SMS_PROVIDER='twilio'" | |||
| ) | |||
| } | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| package com.ffii.fpsms.modules.common.alert | |||
| import org.springframework.boot.context.properties.EnableConfigurationProperties | |||
| import org.springframework.context.annotation.Bean | |||
| import org.springframework.context.annotation.Configuration | |||
| import org.springframework.web.reactive.function.client.WebClient | |||
| @Configuration | |||
| @EnableConfigurationProperties(SchedulerSyncAlertProperties::class) | |||
| open class SmsAlertConfig( | |||
| private val properties: SchedulerSyncAlertProperties, | |||
| private val webClientBuilder: WebClient.Builder, | |||
| ) { | |||
| @Bean | |||
| open fun smsSender(): SmsSender { | |||
| val sms = properties.sms | |||
| val useTwilio = | |||
| sms.enabled && | |||
| (sms.provider.equals("twilio", ignoreCase = true) || | |||
| (sms.accountSid.isNotBlank() && sms.authToken.isNotBlank())) | |||
| return if (useTwilio) { | |||
| TwilioSmsSender(properties, webClientBuilder) | |||
| } else { | |||
| LoggingSmsSender() | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,11 @@ | |||
| package com.ffii.fpsms.modules.common.alert | |||
| interface SmsSender { | |||
| fun send( | |||
| toNumbers: List<String>, | |||
| body: String, | |||
| job: String = "", | |||
| code: String = "", | |||
| detail: String = "", | |||
| ) | |||
| } | |||
| @@ -0,0 +1,68 @@ | |||
| package com.ffii.fpsms.modules.common.alert | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.common.mail.service.MailSenderService | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import jakarta.mail.internet.InternetAddress | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.mail.javamail.MimeMessageHelper | |||
| import org.springframework.stereotype.Service | |||
| /** | |||
| * Plain-text alert emails via existing Office 365 SMTP settings in DB | |||
| * ([SettingNames.MAIL_SMTP_HOST] = smtp.office365.com, port 587, etc.). | |||
| */ | |||
| @Service | |||
| open class SyncAlertEmailSender( | |||
| private val mailSenderService: MailSenderService, | |||
| private val settingsService: SettingsService, | |||
| ) { | |||
| private val logger = LoggerFactory.getLogger(SyncAlertEmailSender::class.java) | |||
| open fun isSmtpConfigured(): Boolean { | |||
| return smtpConfigIssue() == null | |||
| } | |||
| /** Null if OK; otherwise a short message for UI/logs. */ | |||
| open fun smtpConfigIssue(): String? { | |||
| return try { | |||
| val username = settingsService.findByName(SettingNames.MAIL_SMTP_USERNAME).orElse(null)?.value | |||
| val password = settingsService.findByName(SettingNames.MAIL_SMTP_PASSWORD).orElse(null)?.value | |||
| val host = settingsService.findByName(SettingNames.MAIL_SMTP_HOST).orElse(null)?.value | |||
| when { | |||
| host.isNullOrBlank() -> "MAIL.smtp.host is empty" | |||
| username.isNullOrBlank() -> "MAIL.smtp.username is empty" | |||
| password.isNullOrBlank() -> "MAIL.smtp.password is empty" | |||
| else -> null | |||
| } | |||
| } catch (_: Exception) { | |||
| "MAIL.smtp settings missing in settings table" | |||
| } | |||
| } | |||
| open fun resolveFromAddress(): String? = | |||
| settingsService.findByName(SettingNames.MAIL_SMTP_USERNAME).orElse(null)?.value?.trim()?.takeIf { it.isNotEmpty() } | |||
| open fun send(toAddresses: List<String>, subject: String, body: String) { | |||
| require(toAddresses.isNotEmpty()) { "No email recipients" } | |||
| smtpConfigIssue()?.let { throw IllegalStateException(it) } | |||
| val from = resolveFromAddress() | |||
| ?: throw IllegalStateException("MAIL.smtp.username is not set in settings") | |||
| try { | |||
| val sender = mailSenderService.get() | |||
| val mimeMessage = sender.createMimeMessage() | |||
| val helper = MimeMessageHelper(mimeMessage, false, Charsets.UTF_8.name()) | |||
| helper.setFrom(InternetAddress(from)) | |||
| helper.setTo(toAddresses.toTypedArray()) | |||
| helper.setSubject(subject) | |||
| helper.setText(body, false) | |||
| sender.send(mimeMessage) | |||
| logger.info("Sync alert email sent from {} to {}", from, toAddresses.joinToString()) | |||
| } catch (e: Exception) { | |||
| logger.error("Sync alert email failed from {}: {}", from, e.message, e) | |||
| throw e | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,138 @@ | |||
| package com.ffii.fpsms.modules.common.alert | |||
| import com.google.gson.Gson | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.util.LinkedMultiValueMap | |||
| import org.springframework.web.reactive.function.BodyInserters | |||
| import org.springframework.web.reactive.function.client.WebClient | |||
| import java.time.LocalDateTime | |||
| import java.time.format.DateTimeFormatter | |||
| import java.util.Locale | |||
| /** | |||
| * Twilio Programmable Messaging (SMS or WhatsApp) — same API as: | |||
| * | |||
| * ``` | |||
| * curl 'https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages.json' -X POST \ | |||
| * --data-urlencode 'To=whatsapp:+85292710394' \ | |||
| * --data-urlencode 'From=whatsapp:+14155238886' \ | |||
| * --data-urlencode 'ContentSid=HXb5b62575e6e4ff6129ad7c8efe1f983e' \ | |||
| * --data-urlencode 'ContentVariables={"1":"12/1","2":"3pm"}' \ | |||
| * -u {AccountSid}:{AuthToken} | |||
| * ``` | |||
| */ | |||
| class TwilioSmsSender( | |||
| private val properties: SchedulerSyncAlertProperties, | |||
| webClientBuilder: WebClient.Builder, | |||
| ) : SmsSender { | |||
| private val logger = LoggerFactory.getLogger(TwilioSmsSender::class.java) | |||
| private val webClient = webClientBuilder.build() | |||
| private val gson = Gson() | |||
| override fun send( | |||
| toNumbers: List<String>, | |||
| body: String, | |||
| job: String, | |||
| code: String, | |||
| detail: String, | |||
| ) { | |||
| sendRaw(toNumbers, body, buildContentVariables(job, code, detail, body)) | |||
| } | |||
| /** Send with explicit template variables (for connectivity tests matching a Twilio sample). */ | |||
| fun sendWithTemplateVars(toNumbers: List<String>, var1: String, var2: String) { | |||
| val json = gson.toJson(mapOf("1" to var1, "2" to var2)) | |||
| sendRaw(toNumbers, "FPSMS test", json) | |||
| } | |||
| private fun sendRaw(toNumbers: List<String>, body: String, contentVariablesJson: String?) { | |||
| val sms = properties.sms | |||
| require(sms.accountSid.isNotBlank()) { "scheduler.sync-alert.sms.accountSid is required for Twilio" } | |||
| require(sms.authToken.isNotBlank()) { "scheduler.sync-alert.sms.authToken is required for Twilio" } | |||
| require(sms.fromNumber.isNotBlank()) { "scheduler.sync-alert.sms.fromNumber is required for Twilio" } | |||
| val url = "https://api.twilio.com/2010-04-01/Accounts/${sms.accountSid}/Messages.json" | |||
| val from = formatAddress(sms.fromNumber, sms.channel) | |||
| val useTemplate = sms.contentSid.isNotBlank() | |||
| for (to in toNumbers) { | |||
| val form = LinkedMultiValueMap<String, String>() | |||
| form.add("To", formatAddress(to, sms.channel)) | |||
| form.add("From", from) | |||
| if (useTemplate) { | |||
| require(!contentVariablesJson.isNullOrBlank()) { "ContentVariables required when contentSid is set" } | |||
| form.add("ContentSid", sms.contentSid) | |||
| form.add("ContentVariables", contentVariablesJson) | |||
| logger.info("Twilio WhatsApp ContentVariables={}", contentVariablesJson) | |||
| } else { | |||
| form.add("Body", body.take(1600)) | |||
| } | |||
| try { | |||
| webClient.post() | |||
| .uri(url) | |||
| .headers { it.setBasicAuth(sms.accountSid, sms.authToken) } | |||
| .contentType(MediaType.APPLICATION_FORM_URLENCODED) | |||
| .body(BodyInserters.fromFormData(form)) | |||
| .retrieve() | |||
| .bodyToMono(String::class.java) | |||
| .block() | |||
| logger.info("Twilio {} sent to {}", sms.channel, to) | |||
| } catch (e: Exception) { | |||
| logger.error("Twilio {} failed for {}: {}", sms.channel, to, e.message, e) | |||
| throw e | |||
| } | |||
| } | |||
| } | |||
| private fun formatAddress(number: String, channel: String): String { | |||
| val trimmed = number.trim() | |||
| if (!channel.equals("whatsapp", ignoreCase = true)) { | |||
| return trimmed | |||
| } | |||
| return if (trimmed.startsWith("whatsapp:", ignoreCase = true)) trimmed else "whatsapp:$trimmed" | |||
| } | |||
| private fun buildContentVariables(job: String, code: String, detail: String, body: String): String { | |||
| val now = LocalDateTime.now() | |||
| val summary = buildSummary(job, code, detail, body) | |||
| val var1 = resolveContentVar(properties.sms.contentVar1, job, code, detail, body, summary, now) | |||
| val var2 = resolveContentVar(properties.sms.contentVar2, job, code, detail, body, summary, now) | |||
| return gson.toJson(mapOf("1" to var1, "2" to var2)) | |||
| } | |||
| private fun buildSummary(job: String, code: String, detail: String, body: String): String { | |||
| val j = job.ifBlank { "FPSMS" } | |||
| return when { | |||
| code.isNotBlank() && detail.isNotBlank() -> "$j $code — $detail" | |||
| detail.isNotBlank() -> "$j — $detail" | |||
| body.isNotBlank() -> body.removePrefix("FPSMS ").take(properties.sms.contentDetailMaxLength) | |||
| else -> j | |||
| }.take(properties.sms.contentDetailMaxLength) | |||
| } | |||
| private fun resolveContentVar( | |||
| kind: String, | |||
| job: String, | |||
| code: String, | |||
| detail: String, | |||
| body: String, | |||
| summary: String, | |||
| now: LocalDateTime, | |||
| ): String = | |||
| when (kind.lowercase()) { | |||
| "date" -> DATE_FORMAT.format(now) | |||
| "time" -> TIME_FORMAT.format(now) | |||
| "job" -> job.ifBlank { "FPSMS" } | |||
| "code" -> code.ifBlank { "ALERT" } | |||
| "detail" -> detail.take(properties.sms.contentDetailMaxLength).ifBlank { summary } | |||
| "summary" -> summary | |||
| else -> summary | |||
| } | |||
| companion object { | |||
| private val DATE_FORMAT = DateTimeFormatter.ofPattern("d/M", Locale.ENGLISH) | |||
| private val TIME_FORMAT = DateTimeFormatter.ofPattern("h:mma", Locale.ENGLISH) | |||
| } | |||
| } | |||
| @@ -22,19 +22,22 @@ open class MailSenderService(private val settingsService: SettingsService) { | |||
| val sender = JavaMailSenderImpl() | |||
| val props = Properties() | |||
| val auth = config.auth ?: false | |||
| val hasCredentials = | |||
| !config.username.isNullOrBlank() && !config.password.isNullOrBlank() | |||
| // Office 365 requires AUTH; treat as enabled when username + password are set. | |||
| val auth = (config.auth == true) || hasCredentials | |||
| if (auth) { | |||
| props["mail.smtp.timeout"] = "20000" | |||
| props["mail.smtp.connectiontimeout"] = "10000" | |||
| } | |||
| props["mail.smtp.auth"] = auth | |||
| // The below setting needs to be included when the SMTP has TLS Version | |||
| props["mail.smtp.auth"] = auth.toString() | |||
| props["mail.smtp.starttls.enable"] = "true" | |||
| props["mail.smtp.starttls.required"] = "true" | |||
| props["mail.smtp.ssl.protocols"] = "TLSv1.2" | |||
| sender.host = config.host | |||
| sender.port = config.port!! | |||
| if (auth) { | |||
| if (auth && hasCredentials) { | |||
| sender.username = config.username | |||
| sender.password = config.password | |||
| } | |||
| @@ -9,6 +9,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog | |||
| import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository | |||
| import com.ffii.fpsms.m18.model.SyncResult | |||
| import com.ffii.fpsms.modules.common.SettingNames | |||
| import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService | |||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService | |||
| import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | |||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | |||
| @@ -69,6 +70,9 @@ open class SchedulerService( | |||
| val inventoryLotLineService: InventoryLotLineService, | |||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | |||
| private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | |||
| private val schedulerSyncAlertService: SchedulerSyncAlertService, | |||
| @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, | |||
| @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, | |||
| ) { | |||
| companion object { | |||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | |||
| @@ -112,6 +116,8 @@ open class SchedulerService( | |||
| var scheduledDo1CatchUp: ScheduledFuture<*>? = null | |||
| var scheduledDo1CatchUp2: ScheduledFuture<*>? = null | |||
| var scheduledSyncAlert: ScheduledFuture<*>? = null | |||
| //@Volatile | |||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | |||
| @@ -204,10 +210,42 @@ open class SchedulerService( | |||
| scheduleInventoryLotExpiry(); | |||
| scheduleJobOrderPlanStartAuto(); | |||
| scheduleDo1CatchUpOnce(); | |||
| scheduleSyncAlertWatchdog(); | |||
| //scheduleRoughProd(); | |||
| //scheduleDetailedProd(); | |||
| } | |||
| /** Periodic check of scheduler_sync_log; sends SMS when M18 sync rules fail (production). */ | |||
| fun scheduleSyncAlertWatchdog() { | |||
| scheduledSyncAlert?.cancel(false) | |||
| scheduledSyncAlert = null | |||
| if (!m18SyncEnabled || !syncAlertEnabled) { | |||
| logger.info( | |||
| "M18 sync alert watchdog disabled (m18Sync={}, sync-alert.enabled={})", | |||
| m18SyncEnabled, | |||
| syncAlertEnabled, | |||
| ) | |||
| return | |||
| } | |||
| var cron = syncAlertCheckCron | |||
| if (!isValidCronExpression(cron)) { | |||
| cron = "0 */15 * * * *" | |||
| } | |||
| scheduledSyncAlert = taskScheduler.schedule( | |||
| { schedulerSyncAlertService.runChecks() }, | |||
| CronTrigger(cron), | |||
| ) | |||
| logger.info("Scheduled M18 sync alert watchdog: {}", cron) | |||
| } | |||
| open fun runSyncAlertCheckNow(): List<String> = schedulerSyncAlertService.runChecks() | |||
| open fun sendSyncAlertTestWhatsApp(var1: String?, var2: String?): String = | |||
| schedulerSyncAlertService.sendTestWhatsApp(var1, var2) | |||
| open fun sendSyncAlertTestEmail(message: String?, subject: String?): String = | |||
| schedulerSyncAlertService.sendTestEmail(message, subject) | |||
| /** | |||
| * One-time DO1 catch-up jobs for fixed dDates (e.g. missed 15/6 → dDate 17/6, 16/6 → dDate 18/6). | |||
| * Requires [m18SyncEnabled] (production only). Config: scheduler.do1CatchUp / do1CatchUp2 in application-prod.yml. | |||
| @@ -116,4 +116,31 @@ class SchedulerController( | |||
| schedulerService.runJobOrderPlanStartAuto() | |||
| return "Job order plan-start auto triggered" | |||
| } | |||
| /** Run M18 sync health checks now (same as periodic SMS watchdog). Returns alert messages (empty = OK). */ | |||
| @GetMapping("/trigger/sync-alert-check") | |||
| fun triggerSyncAlertCheck(): List<String> { | |||
| return schedulerService.runSyncAlertCheckNow() | |||
| } | |||
| /** | |||
| * Send a Twilio WhatsApp template test (same API as Twilio console curl). | |||
| * Example: ?var1=12/1&var2=3pm — omit params to use today's date and current time. | |||
| */ | |||
| @GetMapping("/trigger/sync-alert-test-whatsapp") | |||
| fun triggerSyncAlertTestWhatsApp( | |||
| @RequestParam(required = false) var1: String?, | |||
| @RequestParam(required = false) var2: String?, | |||
| ): String { | |||
| return schedulerService.sendSyncAlertTestWhatsApp(var1, var2) | |||
| } | |||
| /** Test Office 365 / SMTP email (uses MAIL.smtp.* from settings). Optional message and subject. */ | |||
| @GetMapping("/trigger/sync-alert-test-email") | |||
| fun triggerSyncAlertTestEmail( | |||
| @RequestParam(required = false) message: String?, | |||
| @RequestParam(required = false) subject: String?, | |||
| ): String { | |||
| return schedulerService.sendSyncAlertTestEmail(message, subject) | |||
| } | |||
| } | |||
| @@ -52,6 +52,17 @@ scheduler: | |||
| skipExistingDo: true | |||
| dDate: "2026-06-18" | |||
| runAt: "2026-06-17T12:26:00" | |||
| sync-alert: | |||
| enabled: true | |||
| sms: | |||
| enabled: false | |||
| provider: log | |||
| channel: whatsapp | |||
| to-numbers: "" | |||
| email: | |||
| enabled: true | |||
| to-addresses: "[email protected],[email protected]" | |||
| # From = MAIL.smtp.username in DB (e.g. [email protected] + Gmail app password) | |||
| # Laser Bag2 (/laserPrint) TCP auto-send; uses LASER_PRINT host/port/itemCodes from DB and sends first matching job only. | |||
| laser: | |||
| @@ -42,6 +42,27 @@ scheduler: | |||
| enabled: false | |||
| do1CatchUp2: | |||
| enabled: false | |||
| # SMS alerts when M18 PO/DO1/DO2/master-data sync looks wrong (see SchedulerSyncAlertService). | |||
| sync-alert: | |||
| enabled: ${SYNC_ALERT_ENABLED:false} | |||
| check-cron: "0 */15 * * * *" | |||
| sms: | |||
| enabled: false | |||
| provider: log # WhatsApp/Twilio off; use email below | |||
| channel: whatsapp | |||
| account-sid: ${TWILIO_ACCOUNT_SID:} | |||
| auth-token: ${TWILIO_AUTH_TOKEN:} | |||
| from-number: ${TWILIO_FROM_NUMBER:} | |||
| to-numbers: "" | |||
| content-sid: ${TWILIO_WHATSAPP_CONTENT_SID:} | |||
| email: | |||
| enabled: ${SYNC_ALERT_EMAIL_ENABLED:true} | |||
| to-addresses: "[email protected],[email protected]" | |||
| do1: | |||
| min-records-processed: 400 | |||
| grace-minutes-after-schedule: 30 | |||
| presence: | |||
| grace-minutes-after-schedule: 60 | |||
| # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). | |||
| fpsms: | |||