| @@ -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" | |||
| } | |||
| @@ -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] | |||
| @@ -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) */ | |||
| @@ -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, | |||
| @@ -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() | |||
| @@ -18,6 +18,10 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||
| fun findBomComboByDeletedIsFalse(): List<BomCombo> | |||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | |||
| @Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id") | |||
| fun findAllIdsByDeletedIsFalse(): List<Long> | |||
| fun findByCodeAndDeletedIsFalse(code: String): Bom? | |||
| @Query(""" | |||
| select b.item.id | |||
| @@ -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, | |||
| ) | |||
| } | |||
| } | |||
| @@ -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) | |||
| @@ -11,6 +11,9 @@ import java.io.Serializable | |||
| @Repository | |||
| interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> { | |||
| /** | |||
| * 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<PurchaseOrderLine, Lo | |||
| "JOIN FETCH pol.uom " + | |||
| "LEFT JOIN FETCH pol.uomM18 " + | |||
| "WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " + | |||
| "AND po.deleted = false AND po.code IS NOT NULL AND po.code LIKE 'PP%' " + | |||
| "ORDER BY pol.created DESC", | |||
| ) | |||
| fun findLatestLinesForBomM18ByItemId( | |||
| @@ -26,8 +30,8 @@ interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Lo | |||
| ): List<PurchaseOrderLine> | |||
| /** | |||
| * 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<PurchaseOrderLine, Lo | |||
| "LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " + | |||
| "LEFT JOIN shop sh ON po.supplierId = sh.id " + | |||
| "LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.id " + | |||
| "WHERE pol.deleted = false AND it.deleted = false AND it.code = :itemCode " + | |||
| "WHERE pol.deleted = false AND po.deleted = false AND it.deleted = false AND it.code = :itemCode " + | |||
| "AND po.code LIKE 'PP%' " + | |||
| "ORDER BY po.created DESC LIMIT 1", | |||
| nativeQuery = true, | |||
| ) | |||
| @@ -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, master data — false outside production (manual /trigger/* still works). | |||
| # 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). | |||
| scheduler: | |||
| m18Sync: | |||
| enabled: false | |||
| @@ -0,0 +1,12 @@ | |||
| --liquibase formatted sql | |||
| --changeset fpsms:20260521_clear_m18_bom_shop_sync_log stripComments:false | |||
| DELETE FROM `m18_bom_shop_sync_log`; | |||
| --changeset fpsms:20260521_insert_schedule_m18_bom_shop stripComments:false | |||
| INSERT INTO `settings` (`name`, `value`, `category`, `type`) | |||
| SELECT 'SCHEDULE.m18.bom.shop', '0 0 23 * * *', 'SCHEDULE', 'string' | |||
| FROM DUAL | |||
| WHERE NOT EXISTS ( | |||
| SELECT 1 FROM `settings` WHERE `name` = 'SCHEDULE.m18.bom.shop' | |||
| ); | |||