diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 0ec68f5..56f6539 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -151,9 +151,13 @@ open class M18DeliveryOrderService( return deliveryOrder } - open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { + open fun saveDeliveryOrders(request: M18CommonRequest, skipExistingDo: Boolean = false): SyncResult { val deliveryOrdersWithType = getDeliveryOrdersWithType(request) - return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncisExtra = false) + return saveDeliveryOrdersWithPreparedList( + deliveryOrdersWithType, + syncisExtra = false, + skipExistingDo = skipExistingDo, + ) } /** @@ -209,16 +213,21 @@ open class M18DeliveryOrderService( query = conds ) - return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync) + return saveDeliveryOrdersWithPreparedList(prepared, syncisExtra = isExtraSync, skipExistingDo = newOnly) } private fun saveDeliveryOrdersWithPreparedList( deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, syncisExtra: Boolean = false, + skipExistingDo: Boolean = false, ): SyncResult { logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") + if (skipExistingDo) { + logger.info("skipExistingDo=true — local delivery orders will not be updated") + } val successList = mutableListOf() + val skippedList = mutableListOf() val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() @@ -241,6 +250,22 @@ open class M18DeliveryOrderService( if (deliveryOrdersValues != null) { deliveryOrdersValues.forEach { deliveryOrder -> + if (skipExistingDo) { + val latestDeliveryOrderLog = + m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) + val existingByM18 = latestDeliveryOrderLog?.id?.let { + deliveryOrderService.findByM18DataLogId(it) + } + if (existingByM18 != null && existingByM18.deleted != true) { + logger.info( + "${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} " + + "code=${existingByM18.code} localId=${existingByM18.id} status=${existingByM18.status}" + ) + skippedList.add(deliveryOrder.id) + return@forEach + } + } + val deliveryOrderDetail = getDeliveryOrder(deliveryOrder.id) var deliveryOrderId: Long? = null //FP-MTMS @@ -254,6 +279,14 @@ open class M18DeliveryOrderService( // delivery_order + m18_data_log table if (mainpo != null) { + if (skipExistingDo && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(mainpo.code)) { + logger.info( + "${doRefType}: skipExistingDo — skipping M18 id=${deliveryOrder.id} code=${mainpo.code} (local DO exists by code)" + ) + skippedList.add(deliveryOrder.id) + return@forEach + } + // Find the latest m18 data log by m18 id & type // logger.info("${doRefType}: Finding For Latest M18 Data Log...") val latestDeliveryOrderLog = @@ -573,6 +606,9 @@ open class M18DeliveryOrderService( // End of save. Check result logger.info("Total Success (${doRefType}) (${successList.size})") logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") + if (skippedList.isNotEmpty()) { + logger.info("Total Skipped (${doRefType}) (${skippedList.size}): $skippedList") + } logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") @@ -585,11 +621,12 @@ open class M18DeliveryOrderService( logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") + val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" return SyncResult( - totalProcessed = successList.size + failList.size, + totalProcessed = successList.size + failList.size + skippedList.size, totalSuccess = successList.size, totalFail = failList.size, - query = deliveryOrdersWithType?.query ?: "" + query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, ) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index 9eaa1e2..3adac3b 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -28,6 +28,8 @@ public abstract class SettingNames { public static final String SCHEDULE_M18_DO1 = "SCHEDULE.m18.do1"; /** Saturday-only DO1 time (default 03:10). Mon–Fri & Sun use [SCHEDULE_M18_DO1] time via a second trigger. */ public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; + /** Comma-separated dDates (yyyy-MM-dd) of completed one-time DO1 catch-ups ([scheduler.do1CatchUp]). */ + public static final String SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE = "SCHEDULE.m18.do1.catchup.doneDDate"; public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2"; /** Daily push FPSMS BOMs → M18 udfBomForShop (default 23:00; requires [M18_BOM_SHOP_SYNC_ENABLED] and scheduler.m18Sync.enabled). */ 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 43cc3c5..bf4b589 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 @@ -26,6 +26,7 @@ import org.springframework.stereotype.Service import java.time.DayOfWeek import java.time.LocalDate import java.time.LocalDateTime +import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.HashMap import java.util.concurrent.ScheduledFuture @@ -44,6 +45,14 @@ open class SchedulerService( /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, + @Value("\${scheduler.do1CatchUp.enabled:false}") val do1CatchUpEnabled: Boolean, + @Value("\${scheduler.do1CatchUp.dDate:}") val do1CatchUpDDate: String, + @Value("\${scheduler.do1CatchUp.runAt:}") val do1CatchUpRunAt: String, + @Value("\${scheduler.do1CatchUp.skipExistingDo:true}") val do1CatchUpSkipExistingDo: Boolean, + @Value("\${scheduler.do1CatchUp2.enabled:false}") val do1CatchUp2Enabled: Boolean, + @Value("\${scheduler.do1CatchUp2.dDate:}") val do1CatchUp2DDate: String, + @Value("\${scheduler.do1CatchUp2.runAt:}") val do1CatchUp2RunAt: String, + @Value("\${scheduler.do1CatchUp2.skipExistingDo:true}") val do1CatchUp2SkipExistingDo: Boolean, val settingsService: SettingsService, /** * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, @@ -100,6 +109,9 @@ open class SchedulerService( var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null + var scheduledDo1CatchUp: ScheduledFuture<*>? = null + var scheduledDo1CatchUp2: ScheduledFuture<*>? = null + //@Volatile //var scheduledRoughProd: ScheduledFuture<*>? = null @@ -191,10 +203,144 @@ open class SchedulerService( scheduleGrnCodeSync(); scheduleInventoryLotExpiry(); scheduleJobOrderPlanStartAuto(); + scheduleDo1CatchUpOnce(); //scheduleRoughProd(); //scheduleDetailedProd(); } + /** + * 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. + * Completed dDates are stored comma-separated in [SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE]. + */ + fun scheduleDo1CatchUpOnce() { + scheduledDo1CatchUp?.cancel(false) + scheduledDo1CatchUp = null + scheduledDo1CatchUp2?.cancel(false) + scheduledDo1CatchUp2 = null + + if (!m18SyncEnabled) { + logger.info("DO1 catch-up schedulers disabled (scheduler.m18Sync.enabled=false; production only)") + return + } + + scheduledDo1CatchUp = scheduleOneDo1CatchUp( + scheduledDo1CatchUp, + do1CatchUpEnabled, + do1CatchUpDDate, + do1CatchUpRunAt, + do1CatchUpSkipExistingDo, + "do1CatchUp", + ) + scheduledDo1CatchUp2 = scheduleOneDo1CatchUp( + scheduledDo1CatchUp2, + do1CatchUp2Enabled, + do1CatchUp2DDate, + do1CatchUp2RunAt, + do1CatchUp2SkipExistingDo, + "do1CatchUp2", + ) + } + + private fun scheduleOneDo1CatchUp( + existing: ScheduledFuture<*>?, + enabled: Boolean, + dDateRaw: String, + runAtRaw: String, + skipExistingDo: Boolean, + configKey: String, + ): ScheduledFuture<*>? { + existing?.cancel(false) + if (!enabled) { + return null + } + val dDateStr = dDateRaw.trim() + val runAtStr = runAtRaw.trim() + if (dDateStr.isEmpty() || runAtStr.isEmpty()) { + logger.warn("{} enabled but dDate or runAt is blank — skipped", configKey) + return null + } + + val dDate = try { + LocalDate.parse(dDateStr) + } catch (e: Exception) { + logger.error("Invalid scheduler.{}.dDate={}", configKey, dDateStr) + return null + } + val runAt = try { + LocalDateTime.parse(runAtStr) + } catch (e: Exception) { + logger.error("Invalid scheduler.{}.runAt={}", configKey, runAtStr) + return null + } + + if (isDo1CatchUpAlreadyDone(dDate)) { + logger.info("DO1 catch-up ({}) already completed for dDate={}", configKey, dDate) + return null + } + + val now = LocalDateTime.now() + if (!runAt.isAfter(now)) { + logger.warn( + "DO1 catch-up ({}) runAt={} is not in the future (now={}); use GET /scheduler/trigger/do1-catchup?dDate={}", + configKey, + runAt, + now, + dDate, + ) + return null + } + + val scheduled = taskScheduler.schedule( + { runDo1CatchUp(dDate, skipExistingDo) }, + runAt.atZone(ZoneId.systemDefault()).toInstant(), + ) + logger.info( + "Scheduled one-time DO1 catch-up ({}) for dDate={} at {} skipExistingDo={}", + configKey, + dDate, + runAt, + skipExistingDo, + ) + return scheduled + } + + private fun getDo1CatchUpDoneDDateSet(): Set { + val done = settingsService.findByName(SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE).getOrNull()?.value + ?: return emptySet() + return done.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toSet() + } + + private fun isDo1CatchUpAlreadyDone(dDate: LocalDate): Boolean { + return dDate.toString() in getDo1CatchUpDoneDDateSet() + } + + private fun markDo1CatchUpDone(dDate: LocalDate) { + val updated = (getDo1CatchUpDoneDDateSet() + dDate.toString()).sorted() + settingsService.update(SettingNames.SCHEDULE_M18_DO1_CATCHUP_DONE_DDATE, updated.joinToString(",")) + } + + open fun runDo1CatchUp(dDate: LocalDate, skipExistingDo: Boolean = true) { + if (!m18SyncEnabled) { + logger.warn( + "DO1 catch-up refused for dDate={}: production only (scheduler.m18Sync.enabled=false)", + dDate, + ) + return + } + if (isDo1CatchUpAlreadyDone(dDate)) { + logger.info("DO1 catch-up already completed for dDate={}", dDate) + return + } + try { + getM18Dos1ForDDate(dDate, syncType = "DO1_CATCHUP", skipExistingDo = skipExistingDo) + markDo1CatchUpDone(dDate) + logger.info("DO1 catch-up completed for dDate={}", dDate) + } catch (e: Exception) { + logger.error("DO1 catch-up failed for dDate={}: {}", dDate, e.message, e) + } + } + // Scheduler // --------------------------- FP-MTMS --------------------------- // //fun scheduleRoughProd() { @@ -480,24 +626,42 @@ open class SchedulerService( open fun getM18Dos1() { logger.info("DO Scheduler 1 - DO") - val currentTime = LocalDateTime.now() - val today = currentTime.toLocalDate().atStartOfDay() - val twoDaysLater = today.plusDays(2L) - - var requestDO = M18CommonRequest( - dDateTo = twoDaysLater.format(dateTimeStringFormat), - dDateFrom = twoDaysLater.format(dateTimeStringFormat) - ) - - val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO); + val today = LocalDateTime.now().toLocalDate().atStartOfDay() + val dDate = today.plusDays(2L).toLocalDate() + getM18Dos1ForDDate(dDate, syncType = "DO1") + } - saveSyncLog( - type = "DO1", - status = "SUCCESS", - result = result, - start = currentTime + /** DO1 sync for an explicit delivery date (normal DO1 uses run-day + 2 days). */ + open fun getM18Dos1ForDDate( + dDate: LocalDate, + syncType: String = "DO1", + skipExistingDo: Boolean = syncType == "DO1_CATCHUP", + ) { + logger.info("{} sync for dDate={} skipExistingDo={}", syncType, dDate, skipExistingDo) + val currentTime = LocalDateTime.now() + val dDateStart = dDate.atStartOfDay() + val requestDO = M18CommonRequest( + dDateTo = dDateStart.format(dateTimeStringFormat), + dDateFrom = dDateStart.format(dateTimeStringFormat), ) - + try { + val result = m18DeliveryOrderService.saveDeliveryOrders(requestDO, skipExistingDo = skipExistingDo) + saveSyncLog( + type = syncType, + status = "SUCCESS", + result = result?.copy(query = "dDate=$dDate ${result.query}".trim()), + start = currentTime, + ) + } catch (e: Exception) { + logger.error("{} sync failed for dDate={}: {}", syncType, dDate, e.message, e) + saveSyncLog( + type = syncType, + status = "FAILED", + error = e.message, + start = currentTime, + ) + throw e + } } private fun saveSyncLog(type: String, status: String, result: SyncResult? = null, error: String? = null, start: LocalDateTime) { 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 d3eeb57..81a0c96 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 @@ -43,6 +43,16 @@ class SchedulerController( return "M18 DO1 Sync Triggered Successfully" } + /** Manual DO1 catch-up for a fixed dDate (production only). Skips existing local DOs by default. */ + @GetMapping("/trigger/do1-catchup") + fun triggerDo1CatchUp( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) dDate: LocalDate, + @RequestParam(required = false, defaultValue = "true") skipExistingDo: Boolean = true, + ): String { + schedulerService.runDo1CatchUp(dDate, skipExistingDo = skipExistingDo) + return "M18 DO1 catch-up triggered for dDate=$dDate skipExistingDo=$skipExistingDo" + } + @GetMapping("/trigger/do2") fun triggerDo2(): String { schedulerService.getM18Dos2() diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 9cd76a6..bf54567 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -40,6 +40,18 @@ scheduler: syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code inventoryLotExpiry: enabled: true + # One-time DO1 catch-ups (only registered when scheduler.m18Sync.enabled=true — production profile). + # skipExistingDo defaults true for catch-up: already-synced/picked DOs are not overwritten. + do1CatchUp: + enabled: true + skipExistingDo: true + dDate: "2026-06-17" + runAt: "2026-06-17T11:30:00" + do1CatchUp2: + enabled: true + skipExistingDo: true + dDate: "2026-06-18" + runAt: "2026-06-16T15:00:00" # 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 bc992be..ceedd96 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -12,7 +12,7 @@ server: # PostCompletedDn GRN: runs daily at 00:01, processes all POs with receipt date = yesterday. # Set enabled: false to disable. Optional receiptDate: "yyyy-MM-dd" overrides for testing only. # m18Grn.createEnabled: M18 GRN PUT/create — false outside production so UAT/dev never posts GRNs. -# m18Sync: M18 cron jobs for PO, DO1, DO2, BOM→M18 udfBomForShop ([SCHEDULE.m18.bom.shop], default 23:00), master data — false outside production (manual /trigger/* still works). +# m18Sync: M18 cron jobs for PO, DO1, DO2, DO1 catch-up, BOM→M18 udfBomForShop, master data — false outside production (manual /trigger/* still works except do1-catchup). truck: lane: schedule: @@ -37,6 +37,11 @@ scheduler: jo: planStart: enabled: true + # One-time DO1 catch-up jobs (production only — requires scheduler.m18Sync.enabled=true in application-prod.yml). + do1CatchUp: + enabled: false + do1CatchUp2: + enabled: false # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). fpsms: