diff --git a/src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt index adb1861..387fcc7 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt @@ -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 { fun findTop20ByOrderByEndTimeDesc(): List + + fun findFirstBySyncTypeAndStartTimeBetweenOrderByEndTimeDesc( + syncType: String, + startTime: LocalDateTime, + endTime: LocalDateTime, + ): SchedulerSyncLog? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt index eceebd2..3c7e21f 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt @@ -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, diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt index dbd06d5..701760d 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -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, 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, + flowTypeId: Int, + targetBeId: Long?, + supplierCache: MutableMap, + ): 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() diff --git a/src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt b/src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt index 54b0f82..cb3bb3a 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt @@ -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 } diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt new file mode 100644 index 0000000..90fda33 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt @@ -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, + body: String, + job: String, + code: String, + detail: String, + ) { + logger.warn("[SMS would send to {}] {}", toNumbers.joinToString(), body) + } +} 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 new file mode 100644 index 0000000..bd82875 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt @@ -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. vluk@2fi-solutions.com.hk,ops@example.com */ + 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, +) 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 new file mode 100644 index 0000000..739ff78 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt @@ -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 { + if (!m18SyncEnabled) { + logger.debug("Sync alert skipped (scheduler.m18Sync.enabled=false)") + return emptyList() + } + + 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) + 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> { + 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() + 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> { + 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> { + 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. vluk@2fi-solutions.com.hk)" + } + 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 (vluk@2fi-solutions.com.hk)\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() + 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'" + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt new file mode 100644 index 0000000..29b541b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt @@ -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() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt new file mode 100644 index 0000000..168c5b6 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt @@ -0,0 +1,11 @@ +package com.ffii.fpsms.modules.common.alert + +interface SmsSender { + fun send( + toNumbers: List, + body: String, + job: String = "", + code: String = "", + detail: String = "", + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt new file mode 100644 index 0000000..43a009e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt @@ -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, 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 + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt b/src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt new file mode 100644 index 0000000..72dbf50 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt @@ -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, + 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, 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, 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() + 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) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt b/src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt index 3cc9a0b..f73b41f 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt @@ -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 } diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index 675f16b..875ce4b 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -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 = 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. diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt index 81a0c96..23e5568 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt @@ -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 { + 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) + } } \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index f5e6415..5ec3aa8 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -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: "vluk@2fi-solutions.com.hk,kelvin.yau@2fi-solutions.com.hk" + # 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. laser: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 733eb95..84408a8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: "vluk@2fi-solutions.com.hk,kelvin.yau@2fi-solutions.com.hk" + 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: