| @@ -21,6 +21,11 @@ open class M18GoodsReceiptNoteLog : BaseEntity<Long>() { | |||||
| @Column(name = "m18_record_id", nullable = false) | @Column(name = "m18_record_id", nullable = false) | ||||
| open var m18RecordId: Long? = null | open var m18RecordId: Long? = null | ||||
| /** M18 AN document code (from GET /root/api/read/an, data.mainan.code). */ | |||||
| @Size(max = 60) | |||||
| @Column(name = "grn_code", length = 60) | |||||
| open var grnCode: String? = null | |||||
| /** Stock-in line this GRN line was created from. */ | /** Stock-in line this GRN line was created from. */ | ||||
| @NotNull | @NotNull | ||||
| @Column(name = "stock_in_line_id", nullable = false) | @Column(name = "stock_in_line_id", nullable = false) | ||||
| @@ -1,9 +1,29 @@ | |||||
| package com.ffii.fpsms.m18.entity | package com.ffii.fpsms.m18.entity | ||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| import java.time.LocalDateTime | |||||
| interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | ||||
| /** Returns true if a successful GRN was already created for this PO (avoids core_201 duplicate). */ | /** Returns true if a successful GRN was already created for this PO (avoids core_201 duplicate). */ | ||||
| fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean | fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean | ||||
| /** | |||||
| * GRN log rows that need M18 AN code backfill: have record id, no grn_code yet, created in [start, end). | |||||
| */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT l FROM M18GoodsReceiptNoteLog l | |||||
| WHERE l.deleted = false | |||||
| AND l.m18RecordId IS NOT NULL | |||||
| AND (l.grnCode IS NULL OR l.grnCode = '') | |||||
| AND l.created >= :start AND l.created < :end | |||||
| """ | |||||
| ) | |||||
| fun findNeedingGrnCode( | |||||
| @Param("start") start: LocalDateTime, | |||||
| @Param("end") end: LocalDateTime, | |||||
| ): List<M18GoodsReceiptNoteLog> | |||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.m18.service | package com.ffii.fpsms.m18.service | ||||
| import com.fasterxml.jackson.core.JsonGenerator | import com.fasterxml.jackson.core.JsonGenerator | ||||
| import com.fasterxml.jackson.databind.JsonNode | |||||
| import com.fasterxml.jackson.databind.ObjectMapper | import com.fasterxml.jackson.databind.ObjectMapper | ||||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | ||||
| import com.ffii.fpsms.api.service.ApiCallerService | import com.ffii.fpsms.api.service.ApiCallerService | ||||
| @@ -27,6 +28,7 @@ open class M18GoodsReceiptNoteService( | |||||
| private val logger: Logger = LoggerFactory.getLogger(M18GoodsReceiptNoteService::class.java) | private val logger: Logger = LoggerFactory.getLogger(M18GoodsReceiptNoteService::class.java) | ||||
| private val M18_SAVE_GOODS_RECEIPT_NOTE_API = "/root/api/save/an" | private val M18_SAVE_GOODS_RECEIPT_NOTE_API = "/root/api/save/an" | ||||
| private val M18_READ_GOODS_RECEIPT_NOTE_API = "/root/api/read/an" | |||||
| private val MENU_CODE_AN = "an" | private val MENU_CODE_AN = "an" | ||||
| // Serialize with UTF-8 characters (no \\uXXXX) so M18 persists bDesc/bDesc_en like Postman | // Serialize with UTF-8 characters (no \\uXXXX) so M18 persists bDesc/bDesc_en like Postman | ||||
| @@ -84,4 +86,38 @@ open class M18GoodsReceiptNoteService( | |||||
| logger.error("[M18 GRN API] call failed: $fullUrl error=${e.message}", e) | logger.error("[M18 GRN API] call failed: $fullUrl error=${e.message}", e) | ||||
| } | } | ||||
| } | } | ||||
| /** | |||||
| * Reads AN (GRN) from M18 by record id. GET /root/api/read/an?id={recordId} | |||||
| * Document code is typically at data.mainan.values[0].code or data.mainan.code. | |||||
| */ | |||||
| open fun readAnCodeByRecordId(recordId: Long): String? { | |||||
| return try { | |||||
| val node: JsonNode = apiCallerService.get<JsonNode>( | |||||
| M18_READ_GOODS_RECEIPT_NOTE_API, | |||||
| mapOf("id" to recordId), | |||||
| ).block() ?: return null | |||||
| extractAnCodeFromReadResponse(node) | |||||
| } catch (e: Exception) { | |||||
| logger.warn("[M18 read AN] id=$recordId failed: ${e.message}", e) | |||||
| null | |||||
| } | |||||
| } | |||||
| private fun extractAnCodeFromReadResponse(node: JsonNode): String? { | |||||
| val paths = listOf( | |||||
| "/data/mainan/values/0/code", | |||||
| "/data/mainan/code", | |||||
| "/mainan/values/0/code", | |||||
| "/mainan/code", | |||||
| ) | |||||
| for (p in paths) { | |||||
| val v = node.at(p) | |||||
| if (!v.isMissingNode && !v.isNull) { | |||||
| val text = v.asText() | |||||
| if (!text.isNullOrBlank()) return text | |||||
| } | |||||
| } | |||||
| return null | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,54 @@ | |||||
| package com.ffii.fpsms.m18.service | |||||
| import com.ffii.fpsms.m18.entity.M18GoodsReceiptNoteLogRepository | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.time.LocalDate | |||||
| import java.time.ZoneId | |||||
| /** | |||||
| * Fills [com.ffii.fpsms.m18.entity.M18GoodsReceiptNoteLog.grnCode] by calling M18 GET /root/api/read/an?id=... | |||||
| */ | |||||
| @Service | |||||
| open class M18GrnCodeSyncService( | |||||
| private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | |||||
| private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(M18GrnCodeSyncService::class.java) | |||||
| /** | |||||
| * For today's [created] window, backfill grn_code for rows with m18_record_id but empty grn_code. | |||||
| * One M18 read per distinct [m18_record_id]; all matching log rows get the same code. | |||||
| */ | |||||
| @Transactional | |||||
| open fun syncGrnCodesForDate(day: LocalDate, zoneId: ZoneId = ZoneId.systemDefault()): Int { | |||||
| val start = day.atStartOfDay(zoneId).toLocalDateTime() | |||||
| val end = day.plusDays(1).atStartOfDay(zoneId).toLocalDateTime() | |||||
| val rows = m18GoodsReceiptNoteLogRepository.findNeedingGrnCode(start, end) | |||||
| if (rows.isEmpty()) { | |||||
| logger.info("[M18GrnCodeSync] No GRN log rows need grn_code for day=$day") | |||||
| return 0 | |||||
| } | |||||
| val byRecord = rows.groupBy { it.m18RecordId } | |||||
| var updated = 0 | |||||
| for ((recordId, list) in byRecord) { | |||||
| val rid = recordId ?: continue | |||||
| val code = m18GoodsReceiptNoteService.readAnCodeByRecordId(rid) | |||||
| if (code.isNullOrBlank()) { | |||||
| logger.warn("[M18GrnCodeSync] No code from M18 for m18_record_id=$rid (day=$day)") | |||||
| continue | |||||
| } | |||||
| for (log in list) { | |||||
| log.grnCode = code | |||||
| m18GoodsReceiptNoteLogRepository.save(log) | |||||
| updated++ | |||||
| } | |||||
| logger.info("[M18GrnCodeSync] Set grn_code=$code for m18_record_id=$rid (${list.size} row(s))") | |||||
| } | |||||
| return updated | |||||
| } | |||||
| /** Convenience: sync for calendar "today" in the default timezone. */ | |||||
| open fun syncGrnCodesForToday(): Int = syncGrnCodesForDate(LocalDate.now()) | |||||
| } | |||||
| @@ -33,6 +33,9 @@ public abstract class SettingNames { | |||||
| /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | ||||
| public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | ||||
| /** Backfill M18 AN document code (grn_code) via GET /root/api/read/an (default 1:20 AM daily) */ | |||||
| public static final String SCHEDULE_GRN_CODE_SYNC = "SCHEDULE.grn.grnCode.m18"; | |||||
| public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | ||||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.common.scheduler.service | |||||
| import com.ffii.core.utils.JwtTokenUtil | import com.ffii.core.utils.JwtTokenUtil | ||||
| import com.ffii.fpsms.m18.service.M18DeliveryOrderService | import com.ffii.fpsms.m18.service.M18DeliveryOrderService | ||||
| import com.ffii.fpsms.m18.service.M18GrnCodeSyncService | |||||
| import com.ffii.fpsms.m18.service.M18MasterDataService | import com.ffii.fpsms.m18.service.M18MasterDataService | ||||
| import com.ffii.fpsms.m18.service.M18PurchaseOrderService | import com.ffii.fpsms.m18.service.M18PurchaseOrderService | ||||
| import com.ffii.fpsms.m18.web.models.M18CommonRequest | import com.ffii.fpsms.m18.web.models.M18CommonRequest | ||||
| @@ -30,6 +31,9 @@ import kotlin.jvm.optionals.getOrNull | |||||
| open class SchedulerService( | open class SchedulerService( | ||||
| @Value("\${scheduler.postCompletedDnGrn.enabled:true}") val postCompletedDnGrnEnabled: Boolean, | @Value("\${scheduler.postCompletedDnGrn.enabled:true}") val postCompletedDnGrnEnabled: Boolean, | ||||
| @Value("\${scheduler.postCompletedDnGrn.receiptDate:}") val postCompletedDnGrnReceiptDate: String, | @Value("\${scheduler.postCompletedDnGrn.receiptDate:}") val postCompletedDnGrnReceiptDate: String, | ||||
| @Value("\${scheduler.grnCodeSync.enabled:true}") val grnCodeSyncEnabled: Boolean, | |||||
| /** 0 = calendar day of run; 1 = previous day (typical for 1:20 AM job to backfill yesterday's GRNs). */ | |||||
| @Value("\${scheduler.grnCodeSync.syncOffsetDays:0}") val grnCodeSyncSyncOffsetDays: Int, | |||||
| val settingsService: SettingsService, | val settingsService: SettingsService, | ||||
| val taskScheduler: TaskScheduler, | val taskScheduler: TaskScheduler, | ||||
| val productionScheduleService: ProductionScheduleService, | val productionScheduleService: ProductionScheduleService, | ||||
| @@ -38,6 +42,7 @@ open class SchedulerService( | |||||
| val m18MasterDataService: M18MasterDataService, | val m18MasterDataService: M18MasterDataService, | ||||
| val schedulerSyncLogRepository: SchedulerSyncLogRepository, | val schedulerSyncLogRepository: SchedulerSyncLogRepository, | ||||
| val searchCompletedDnService: SearchCompletedDnService, | val searchCompletedDnService: SearchCompletedDnService, | ||||
| val m18GrnCodeSyncService: M18GrnCodeSyncService, | |||||
| ) { | ) { | ||||
| var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| @@ -55,6 +60,8 @@ open class SchedulerService( | |||||
| var scheduledPostCompletedDnGrn: ScheduledFuture<*>? = null | var scheduledPostCompletedDnGrn: ScheduledFuture<*>? = null | ||||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | |||||
| //@Volatile | //@Volatile | ||||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | //var scheduledRoughProd: ScheduledFuture<*>? = null | ||||
| @@ -99,6 +106,7 @@ open class SchedulerService( | |||||
| scheduleM18Do2(); | scheduleM18Do2(); | ||||
| scheduleM18MasterData(); | scheduleM18MasterData(); | ||||
| schedulePostCompletedDnGrn(); | schedulePostCompletedDnGrn(); | ||||
| scheduleGrnCodeSync(); | |||||
| //scheduleRoughProd(); | //scheduleRoughProd(); | ||||
| //scheduleDetailedProd(); | //scheduleDetailedProd(); | ||||
| } | } | ||||
| @@ -141,6 +149,32 @@ open class SchedulerService( | |||||
| else try { LocalDate.parse(s) } catch (e: Exception) { LocalDate.now().minusDays(1) } | else try { LocalDate.parse(s) } catch (e: Exception) { LocalDate.now().minusDays(1) } | ||||
| } | } | ||||
| /** Backfill grn_code from M18 read/an for today's logs missing code. Default 1:20 AM daily. Set scheduler.grnCodeSync.enabled=false to disable. */ | |||||
| fun scheduleGrnCodeSync() { | |||||
| if (!grnCodeSyncEnabled) { | |||||
| scheduledGrnCodeSync?.cancel(false) | |||||
| scheduledGrnCodeSync = null | |||||
| logger.info("GRN code sync scheduler disabled (scheduler.grnCodeSync.enabled=false)") | |||||
| return | |||||
| } | |||||
| commonSchedule( | |||||
| scheduledGrnCodeSync, | |||||
| SettingNames.SCHEDULE_GRN_CODE_SYNC, | |||||
| "0 20 1 * * *", | |||||
| { syncGrnCodesFromM18() }, | |||||
| ) | |||||
| } | |||||
| open fun syncGrnCodesFromM18() { | |||||
| try { | |||||
| val day = java.time.LocalDate.now().minusDays(grnCodeSyncSyncOffsetDays.toLong().coerceAtLeast(0)) | |||||
| val updated = m18GrnCodeSyncService.syncGrnCodesForDate(day) | |||||
| logger.info("Scheduler - M18 GRN code sync done for date=$day, rows updated=$updated") | |||||
| } catch (e: Exception) { | |||||
| logger.error("Scheduler - M18 GRN code sync failed: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| /** Post completed DN and create M18 GRN at 00:01 daily; processes all POs with receipt date = yesterday. Set scheduler.postCompletedDnGrn.enabled=false to disable. */ | /** Post completed DN and create M18 GRN at 00:01 daily; processes all POs with receipt date = yesterday. Set scheduler.postCompletedDnGrn.enabled=false to disable. */ | ||||
| fun schedulePostCompletedDnGrn() { | fun schedulePostCompletedDnGrn() { | ||||
| if (!postCompletedDnGrnEnabled) { | if (!postCompletedDnGrnEnabled) { | ||||
| @@ -55,6 +55,12 @@ class SchedulerController( | |||||
| return "M18 Master Data Sync Triggered Successfully" | return "M18 Master Data Sync Triggered Successfully" | ||||
| } | } | ||||
| @GetMapping("/trigger/grn-code-sync") | |||||
| fun triggerGrnCodeSync(): String { | |||||
| schedulerService.syncGrnCodesFromM18() | |||||
| return "M18 GRN code sync (read/an) triggered" | |||||
| } | |||||
| @GetMapping("/trigger/post-completed-dn-grn") | @GetMapping("/trigger/post-completed-dn-grn") | ||||
| fun triggerPostCompletedDnGrn( | fun triggerPostCompletedDnGrn( | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | ||||
| @@ -22,6 +22,9 @@ spring: | |||||
| scheduler: | scheduler: | ||||
| postCompletedDnGrn: | postCompletedDnGrn: | ||||
| enabled: true | enabled: true | ||||
| grnCodeSync: | |||||
| enabled: true | |||||
| syncOffsetDays: 0 # 0 = m18_goods_receipt_note_log rows created today; use 1 for "yesterday" if you prefer night backfill | |||||
| m18: | m18: | ||||
| config: | config: | ||||
| @@ -15,6 +15,9 @@ scheduler: | |||||
| postCompletedDnGrn: | postCompletedDnGrn: | ||||
| enabled: false | enabled: false | ||||
| # receiptDate: # leave unset for production (uses yesterday) | # receiptDate: # leave unset for production (uses yesterday) | ||||
| grnCodeSync: | |||||
| enabled: false # set true in prod; backfills grn_code from M18 GET /root/api/read/an | |||||
| syncOffsetDays: 0 # 0=today, 1=yesterday (use 1 for 1:20 AM job to backfill prior day) | |||||
| spring: | spring: | ||||
| servlet: | servlet: | ||||
| @@ -0,0 +1,5 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fai:add_grn_code_to_m18_goods_receipt_note_log | |||||
| ALTER TABLE `m18_goods_receipt_note_log` | |||||
| ADD COLUMN `grn_code` VARCHAR(60) NULL DEFAULT NULL COMMENT 'M18 AN document code (from read/an mainan.code)' AFTER `m18_record_id`; | |||||