Procházet zdrojové kódy

no message

production
[email protected] před 11 hodinami
rodič
revize
2e437a831b
16 změnil soubory, kde provedl 976 přidání a 27 odebrání
  1. +7
    -1
      src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt
  2. +3
    -3
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt
  3. +80
    -14
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  4. +4
    -5
      src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt
  5. +18
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt
  6. +67
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt
  7. +449
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt
  8. +27
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt
  9. +11
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt
  10. +68
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt
  11. +138
    -0
      src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt
  12. +7
    -4
      src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt
  13. +38
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  14. +27
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  15. +11
    -0
      src/main/resources/application-prod.yml
  16. +21
    -0
      src/main/resources/application.yml

+ 7
- 1
src/main/java/com/ffii/fpsms/m18/entity/SchedulerSyncLogRepository.kt Zobrazit soubor

@@ -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?
}

+ 3
- 3
src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveRequest.kt Zobrazit soubor

@@ -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,


+ 80
- 14
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt Zobrazit soubor

@@ -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()


+ 4
- 5
src/main/java/com/ffii/fpsms/modules/common/MailSMTP.kt Zobrazit soubor

@@ -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
}


+ 18
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/LoggingSmsSender.kt Zobrazit soubor

@@ -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)
}
}

+ 67
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt Zobrazit soubor

@@ -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,
)

+ 449
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt Zobrazit soubor

@@ -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 (&lt; 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'"
)
}
}

+ 27
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SmsAlertConfig.kt Zobrazit soubor

@@ -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()
}
}
}

+ 11
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SmsSender.kt Zobrazit soubor

@@ -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 = "",
)
}

+ 68
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/SyncAlertEmailSender.kt Zobrazit soubor

@@ -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
}
}
}

+ 138
- 0
src/main/java/com/ffii/fpsms/modules/common/alert/TwilioSmsSender.kt Zobrazit soubor

@@ -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)
}
}

+ 7
- 4
src/main/java/com/ffii/fpsms/modules/common/mail/service/MailSenderService.kt Zobrazit soubor

@@ -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
}


+ 38
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Zobrazit soubor

@@ -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.


+ 27
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt Zobrazit soubor

@@ -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)
}
}

+ 11
- 0
src/main/resources/application-prod.yml Zobrazit soubor

@@ -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:


+ 21
- 0
src/main/resources/application.yml Zobrazit soubor

@@ -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:


Načítá se…
Zrušit
Uložit