diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt new file mode 100644 index 0000000..65ecd05 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt @@ -0,0 +1,18 @@ +package com.ffii.fpsms.m18.model + +/** + * Result of scheduling job [com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed]. + */ +data class M18BomShopBatchSyncSummary( + /** BOM rows with deleted=false scanned. */ + val totalProcessed: Int, + val synced: Int, + /** Pushed attempted but [M18BomShopSyncTriggerResult.synced] is false (includes build/API failures). */ + val notSynced: Int, + /** [SettingNames.M18_BOM_SHOP_SYNC_ENABLED] is off — no BOMs attempted. */ + val skippedBecauseFeatureDisabled: Boolean = false, +) { + /** One-line summary for logs / scheduler_sync_log.query */ + fun toLogQuery(): String = + "BOMShop batch: processed=$totalProcessed synced=$synced notSynced=$notSynced skippedFeatureDisabled=$skippedBecauseFeatureDisabled" +} 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 f74dd70..4bd2af0 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -57,10 +57,14 @@ open class M18BomForShopService( companion object { private const val HARVEST_CALC_SCALE = 10 + internal const val BOM_SHOP_HEADER_VERSION_DIGITS = 4 private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong") private fun formatBomShopHeaderCode(itemCode: String, version: Int): String = - "BOM${itemCode}V${version.toString().padStart(3, '0')}" + "BOM${itemCode}V${version.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')}" + + internal fun normalizedHeaderRevision(versionDigits: String): String = + versionDigits.padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') } @Suppress("DEPRECATION") @@ -70,7 +74,7 @@ open class M18BomForShopService( /** * Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality). - * Used with [M18BomShopSyncLog] to decide V000 vs V001+. + * Used with [M18BomShopSyncLog] to decide V0000 vs V0001+. */ open fun contentFingerprint(request: M18BomForShopSaveRequest): String { val json = objectMapper.writeValueAsString(normalizedForFingerprint(request)) @@ -100,7 +104,7 @@ open class M18BomForShopService( * Builds M18 save body from a persisted BOM (materials loaded). * [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only, * reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible. - * Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnn`. + * Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnnn`. */ open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { val bomId = bom.id ?: return null @@ -222,7 +226,8 @@ open class M18BomForShopService( val codeForUpdate = latest?.m18HeaderCode?.takeIf { it.isNotBlank() } ?: formatBomShopHeaderCode(itemCode, 0) - val forcedRev = parseTrailingVersion(codeForUpdate) ?: "000" + val forcedRev = parseTrailingVersion(codeForUpdate) + ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) return Triple(codeForUpdate, forcedRev, forcedId) } @@ -232,14 +237,15 @@ open class M18BomForShopService( val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty() if (samePayload) { - val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000" + val revReuse = parseTrailingVersion(prevCodeTrimmed) + ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) return Triple(prevCodeTrimmed, revReuse, bomM18Id) } val maxV = maxVersionFromLogs(bomId, itemCode) val nextV = maxV + 1 val newCode = formatBomShopHeaderCode(itemCode, nextV) - val rev = nextV.toString().padStart(3, '0') + val rev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') return Triple(newCode, rev, null) } @@ -255,7 +261,7 @@ open class M18BomForShopService( private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$") - // Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnn. + // Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnnn. return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId) .mapNotNull { row -> val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty { @@ -276,7 +282,7 @@ open class M18BomForShopService( } private fun parseTrailingVersion(headerCode: String): String? = - Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.padStart(3, '0') + Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.let { normalizedHeaderRevision(it) } /** * From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code] 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 e74855c..9eaa1e2 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -30,6 +30,9 @@ public abstract class SettingNames { public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat"; 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). */ + public static final String SCHEDULE_M18_BOM_SHOP = "SCHEDULE.m18.bom.shop"; + public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master"; /** M18 unit master sync via GET /search/search?stSearch=unit (cron, e.g. "0 40 12 * * *" for 12:40 daily) */ 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 55aab0d..b10d272 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 @@ -11,6 +11,7 @@ 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.jobOrder.service.JobOrderPlanStartAutoService +import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService import com.ffii.fpsms.modules.master.service.ProductionScheduleService import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService import com.ffii.fpsms.modules.stock.service.InventoryLotLineService @@ -59,11 +60,14 @@ open class SchedulerService( val m18GrnCodeSyncService: M18GrnCodeSyncService, val inventoryLotLineService: InventoryLotLineService, val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, + private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, ) { companion object { /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ const val DO2_MODIFIED_TO_HOUR: Int = 13 const val DO2_DEFAULT_CRON: String = "0 0 13 * * *" + /** Default 23:00 daily — BOM → M18 udfBomForShop for all BOMs ([SettingNames.SCHEDULE_M18_BOM_SHOP]). */ + const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" } @@ -81,6 +85,8 @@ open class SchedulerService( var scheduledM18Do1Sat: ScheduledFuture<*>? = null var scheduledM18Do2: ScheduledFuture<*>? = null + var scheduledM18BomShop: ScheduledFuture<*>? = null + @Volatile var scheduledM18Master: ScheduledFuture<*>? = null @@ -178,6 +184,7 @@ open class SchedulerService( scheduleM18Po(); scheduleM18Do1(); scheduleM18Do2(); + scheduleM18BomShop(); scheduleM18MasterData(); schedulePostCompletedDnGrn(); scheduleGrnCodeSync(); @@ -223,6 +230,18 @@ open class SchedulerService( scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) } + /** Daily push FPSMS BOMs → M18; cron from settings [SettingNames.SCHEDULE_M18_BOM_SHOP] ([M18_BOM_SHOP_DEFAULT_CRON]); requires scheduler.m18Sync.enabled. */ + fun scheduleM18BomShop() { + if (!m18SyncEnabled) { + scheduledM18BomShop?.cancel(false) + scheduledM18BomShop = null + logger.info("M18 BOM Shop scheduler disabled (scheduler.m18Sync.enabled=false)") + return + } + scheduledM18BomShop = + commonSchedule(scheduledM18BomShop, SettingNames.SCHEDULE_M18_BOM_SHOP, M18_BOM_SHOP_DEFAULT_CRON, ::getM18BomShopPushAllBoms) + } + fun scheduleM18MasterData() { if (!m18SyncEnabled) { scheduledM18Master?.cancel(false) @@ -543,6 +562,36 @@ open class SchedulerService( ) } + open fun getM18BomShopPushAllBoms() { + logger.info("M18 BOM Shop - push all BOMs to udfBomForShop") + val currentTime = LocalDateTime.now() + try { + val summary = bomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed() + val status = if (summary.skippedBecauseFeatureDisabled) "SKIPPED" else "SUCCESS" + saveSyncLog( + type = "M18_BOM_SHOP", + status = status, + result = + SyncResult( + totalProcessed = summary.totalProcessed, + totalSuccess = summary.synced, + totalFail = summary.notSynced, + query = summary.toLogQuery(), + ), + start = currentTime, + ) + logger.info("M18 BOM Shop batch done: ${summary.toLogQuery()}") + } catch (e: Exception) { + logger.error("M18 BOM Shop batch failed: ${e.message}", e) + saveSyncLog( + type = "M18_BOM_SHOP", + status = "FAILED", + error = e.message, + start = currentTime, + ) + } + } + open fun getPostCompletedDnAndProcessGrn( receiptDate: java.time.LocalDate? = null, skipFirst: Int = 0, 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 3b01c91..d3eeb57 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 @@ -49,6 +49,19 @@ class SchedulerController( return "M18 DO2 Sync Triggered Successfully" } + /** Manual test: push all FPSMS BOMs to M18 udfBomForShop ([SettingNames.M18_BOM_SHOP_SYNC_ENABLED] must still be true). */ + @GetMapping("/trigger/bom-shop-sync-all") + fun triggerBomShopSyncAll(): String { + schedulerService.getM18BomShopPushAllBoms() + return "M18 BOM Shop (all BOMs) sync triggered (see scheduler_sync_log type M18_BOM_SHOP)" + } + + @GetMapping("/updateSetting/bomShopCron") + fun scheduleBomShop(@RequestParam @Valid newCron: String) { + settingsService.update(SettingNames.SCHEDULE_M18_BOM_SHOP, newCron) + schedulerService.scheduleM18BomShop() + } + @GetMapping("/trigger/master-data") fun triggerMasterData(): String { schedulerService.getM18MasterData() diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt index ed5d8d4..358ddb3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt @@ -18,6 +18,10 @@ interface BomRepository : AbstractRepository { fun findBomComboByDeletedIsFalse(): List fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List + + @Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id") + fun findAllIdsByDeletedIsFalse(): List + fun findByCodeAndDeletedIsFalse(code: String): Bom? @Query(""" select b.item.id diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomM18ShopBulkPushService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomM18ShopBulkPushService.kt new file mode 100644 index 0000000..eb9eb90 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomM18ShopBulkPushService.kt @@ -0,0 +1,41 @@ +package com.ffii.fpsms.modules.master.service + +import com.ffii.fpsms.m18.model.M18BomShopBatchSyncSummary +import com.ffii.fpsms.modules.master.entity.BomRepository +import org.springframework.stereotype.Service + +/** + * Calls [BomService.pushBomToM18ShopIfAllowed] for each BOM id via the injected proxied bean + * so `@Transactional` applies per BOM (avoids same-class self-invocation). + */ +@Service +open class BomM18ShopBulkPushService( + private val bomRepository: BomRepository, + private val bomService: BomService, +) { + + /** Pushes all non-deleted BOMs to M18 when {@link BomService}'s BOM shop sync setting is enabled. */ + open fun pushAllBomsToM18ShopIfAllowed(): M18BomShopBatchSyncSummary { + if (!bomService.isM18BomShopSyncEnabled()) { + return M18BomShopBatchSyncSummary( + totalProcessed = 0, + synced = 0, + notSynced = 0, + skippedBecauseFeatureDisabled = true, + ) + } + val ids = bomRepository.findAllIdsByDeletedIsFalse() + var synced = 0 + var notSynced = 0 + for (id in ids) { + val result = bomService.pushBomToM18ShopIfAllowed(id) + if (result.synced) synced++ else notSynced++ + } + return M18BomShopBatchSyncSummary( + totalProcessed = ids.size, + synced = synced, + notSynced = notSynced, + skippedBecauseFeatureDisabled = false, + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index b10e26f..a608deb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -409,10 +409,11 @@ open class BomService( /** * When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop. - * Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or future UI) to trigger explicitly. + * Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or bulk job /scheduler/trigger/bom-shop-sync-all) to trigger explicitly. * Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id]) * to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set. */ + @Transactional open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult { if (!isM18BomShopSyncEnabled()) { return M18BomShopSyncTriggerResult( @@ -510,7 +511,7 @@ open class BomService( return result } - private fun isM18BomShopSyncEnabled(): Boolean = + internal fun isM18BomShopSyncEnabled(): Boolean = settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) .map { Settings.VALUE_BOOLEAN_TRUE == it.value } .orElse(false) diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt index a5a1896..c1e8bef 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt @@ -11,6 +11,9 @@ import java.io.Serializable @Repository interface PurchaseOrderLineRepository : AbstractRepository { + /** + * Latest POL for BOM/M18 pushes: PO code must start with **`PP`**, row has M18 log, newest `pol.created` first. + */ @Query( "SELECT pol FROM PurchaseOrderLine pol " + "LEFT JOIN FETCH pol.purchaseOrder po " + @@ -18,6 +21,7 @@ interface PurchaseOrderLineRepository : AbstractRepository /** - * Latest PO (by header `purchase_order.created`) for a material item code: supplier `shop.m18Id` from `purchase_order.supplierId`. - * Mirrors manual SQL: pol → items (code), po, shop on supplier, uom_conversion; order by po.created desc limit 1. + * Latest **PP** PO (code starts with `PP`, by header `purchase_order.created`) for a material item code: + * supplier `shop.m18Id` from `purchase_order.supplierId`. */ @Query( value = @@ -36,7 +40,8 @@ interface PurchaseOrderLineRepository : AbstractRepository