| @@ -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 { | companion object { | ||||
| private const val HARVEST_CALC_SCALE = 10 | 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 val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong") | ||||
| private fun formatBomShopHeaderCode(itemCode: String, version: Int): String = | 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") | @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). | * 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 { | open fun contentFingerprint(request: M18BomForShopSaveRequest): String { | ||||
| val json = objectMapper.writeValueAsString(normalizedForFingerprint(request)) | val json = objectMapper.writeValueAsString(normalizedForFingerprint(request)) | ||||
| @@ -100,7 +104,7 @@ open class M18BomForShopService( | |||||
| * Builds M18 save body from a persisted BOM (materials loaded). | * 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, | * [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only, | ||||
| * reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible. | * 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? { | open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? { | ||||
| val bomId = bom.id ?: return null | val bomId = bom.id ?: return null | ||||
| @@ -222,7 +226,8 @@ open class M18BomForShopService( | |||||
| val codeForUpdate = | val codeForUpdate = | ||||
| latest?.m18HeaderCode?.takeIf { it.isNotBlank() } | latest?.m18HeaderCode?.takeIf { it.isNotBlank() } | ||||
| ?: formatBomShopHeaderCode(itemCode, 0) | ?: formatBomShopHeaderCode(itemCode, 0) | ||||
| val forcedRev = parseTrailingVersion(codeForUpdate) ?: "000" | |||||
| val forcedRev = parseTrailingVersion(codeForUpdate) | |||||
| ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) | |||||
| return Triple(codeForUpdate, forcedRev, forcedId) | return Triple(codeForUpdate, forcedRev, forcedId) | ||||
| } | } | ||||
| @@ -232,14 +237,15 @@ open class M18BomForShopService( | |||||
| val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty() | val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty() | ||||
| if (samePayload) { | if (samePayload) { | ||||
| val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000" | |||||
| val revReuse = parseTrailingVersion(prevCodeTrimmed) | |||||
| ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) | |||||
| return Triple(prevCodeTrimmed, revReuse, bomM18Id) | return Triple(prevCodeTrimmed, revReuse, bomM18Id) | ||||
| } | } | ||||
| val maxV = maxVersionFromLogs(bomId, itemCode) | val maxV = maxVersionFromLogs(bomId, itemCode) | ||||
| val nextV = maxV + 1 | val nextV = maxV + 1 | ||||
| val newCode = formatBomShopHeaderCode(itemCode, nextV) | 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) | return Triple(newCode, rev, null) | ||||
| } | } | ||||
| @@ -255,7 +261,7 @@ open class M18BomForShopService( | |||||
| private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { | private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { | ||||
| val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$") | 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) | return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId) | ||||
| .mapNotNull { row -> | .mapNotNull { row -> | ||||
| val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty { | val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty { | ||||
| @@ -276,7 +282,7 @@ open class M18BomForShopService( | |||||
| } | } | ||||
| private fun parseTrailingVersion(headerCode: String): String? = | 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] | * 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_DO1_SAT = "SCHEDULE.m18.do1.sat"; | ||||
| public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2"; | 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"; | 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) */ | /** 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.m18.model.SyncResult | ||||
| import com.ffii.fpsms.modules.common.SettingNames | import com.ffii.fpsms.modules.common.SettingNames | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService | 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.master.service.ProductionScheduleService | ||||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | ||||
| @@ -59,11 +60,14 @@ open class SchedulerService( | |||||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | val m18GrnCodeSyncService: M18GrnCodeSyncService, | ||||
| val inventoryLotLineService: InventoryLotLineService, | val inventoryLotLineService: InventoryLotLineService, | ||||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | ||||
| private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | |||||
| ) { | ) { | ||||
| companion object { | companion object { | ||||
| /** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */ | /** 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_MODIFIED_TO_HOUR: Int = 13 | ||||
| const val DO2_DEFAULT_CRON: String = "0 0 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. */ | /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | ||||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | ||||
| } | } | ||||
| @@ -81,6 +85,8 @@ open class SchedulerService( | |||||
| var scheduledM18Do1Sat: ScheduledFuture<*>? = null | var scheduledM18Do1Sat: ScheduledFuture<*>? = null | ||||
| var scheduledM18Do2: ScheduledFuture<*>? = null | var scheduledM18Do2: ScheduledFuture<*>? = null | ||||
| var scheduledM18BomShop: ScheduledFuture<*>? = null | |||||
| @Volatile | @Volatile | ||||
| var scheduledM18Master: ScheduledFuture<*>? = null | var scheduledM18Master: ScheduledFuture<*>? = null | ||||
| @@ -178,6 +184,7 @@ open class SchedulerService( | |||||
| scheduleM18Po(); | scheduleM18Po(); | ||||
| scheduleM18Do1(); | scheduleM18Do1(); | ||||
| scheduleM18Do2(); | scheduleM18Do2(); | ||||
| scheduleM18BomShop(); | |||||
| scheduleM18MasterData(); | scheduleM18MasterData(); | ||||
| schedulePostCompletedDnGrn(); | schedulePostCompletedDnGrn(); | ||||
| scheduleGrnCodeSync(); | scheduleGrnCodeSync(); | ||||
| @@ -223,6 +230,18 @@ open class SchedulerService( | |||||
| scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2) | 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() { | fun scheduleM18MasterData() { | ||||
| if (!m18SyncEnabled) { | if (!m18SyncEnabled) { | ||||
| scheduledM18Master?.cancel(false) | 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( | open fun getPostCompletedDnAndProcessGrn( | ||||
| receiptDate: java.time.LocalDate? = null, | receiptDate: java.time.LocalDate? = null, | ||||
| skipFirst: Int = 0, | skipFirst: Int = 0, | ||||
| @@ -49,6 +49,19 @@ class SchedulerController( | |||||
| return "M18 DO2 Sync Triggered Successfully" | 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") | @GetMapping("/trigger/master-data") | ||||
| fun triggerMasterData(): String { | fun triggerMasterData(): String { | ||||
| schedulerService.getM18MasterData() | schedulerService.getM18MasterData() | ||||
| @@ -18,6 +18,10 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||||
| fun findBomComboByDeletedIsFalse(): List<BomCombo> | fun findBomComboByDeletedIsFalse(): List<BomCombo> | ||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | 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? | fun findByCodeAndDeletedIsFalse(code: String): Bom? | ||||
| @Query(""" | @Query(""" | ||||
| select b.item.id | 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. | * 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]) | * 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. | * 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 { | open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult { | ||||
| if (!isM18BomShopSyncEnabled()) { | if (!isM18BomShopSyncEnabled()) { | ||||
| return M18BomShopSyncTriggerResult( | return M18BomShopSyncTriggerResult( | ||||
| @@ -510,7 +511,7 @@ open class BomService( | |||||
| return result | return result | ||||
| } | } | ||||
| private fun isM18BomShopSyncEnabled(): Boolean = | |||||
| internal fun isM18BomShopSyncEnabled(): Boolean = | |||||
| settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED) | ||||
| .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | .map { Settings.VALUE_BOOLEAN_TRUE == it.value } | ||||
| .orElse(false) | .orElse(false) | ||||
| @@ -11,6 +11,9 @@ import java.io.Serializable | |||||
| @Repository | @Repository | ||||
| interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> { | 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( | @Query( | ||||
| "SELECT pol FROM PurchaseOrderLine pol " + | "SELECT pol FROM PurchaseOrderLine pol " + | ||||
| "LEFT JOIN FETCH pol.purchaseOrder po " + | "LEFT JOIN FETCH pol.purchaseOrder po " + | ||||
| @@ -18,6 +21,7 @@ interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Lo | |||||
| "JOIN FETCH pol.uom " + | "JOIN FETCH pol.uom " + | ||||
| "LEFT JOIN FETCH pol.uomM18 " + | "LEFT JOIN FETCH pol.uomM18 " + | ||||
| "WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " + | "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", | "ORDER BY pol.created DESC", | ||||
| ) | ) | ||||
| fun findLatestLinesForBomM18ByItemId( | fun findLatestLinesForBomM18ByItemId( | ||||
| @@ -26,8 +30,8 @@ interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Lo | |||||
| ): List<PurchaseOrderLine> | ): 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( | @Query( | ||||
| value = | value = | ||||
| @@ -36,7 +40,8 @@ interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Lo | |||||
| "LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " + | "LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " + | ||||
| "LEFT JOIN shop sh ON po.supplierId = sh.id " + | "LEFT JOIN shop sh ON po.supplierId = sh.id " + | ||||
| "LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.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", | "ORDER BY po.created DESC LIMIT 1", | ||||
| nativeQuery = true, | nativeQuery = true, | ||||
| ) | ) | ||||
| @@ -12,7 +12,7 @@ server: | |||||
| # PostCompletedDn GRN: runs daily at 00:01, processes all POs with receipt date = yesterday. | # 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. | # 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. | # 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: | scheduler: | ||||
| m18Sync: | m18Sync: | ||||
| enabled: false | 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' | |||||
| ); | |||||