diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt index cd6311e..060c075 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt @@ -21,6 +21,11 @@ open class M18GoodsReceiptNoteLog : BaseEntity() { @Column(name = "m18_record_id", nullable = false) 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. */ @NotNull @Column(name = "stock_in_line_id", nullable = false) diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt index 079e8c6..9f8a741 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt @@ -1,9 +1,29 @@ package com.ffii.fpsms.m18.entity 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 { /** Returns true if a successful GRN was already created for this PO (avoids core_201 duplicate). */ 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 } diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt index 83f7132..f497246 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.m18.service import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper 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 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" // 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) } } + + /** + * 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( + 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 + } } diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18GrnCodeSyncService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18GrnCodeSyncService.kt new file mode 100644 index 0000000..0e4c972 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/service/M18GrnCodeSyncService.kt @@ -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()) +} 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 bda81d4..fa550d7 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -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) */ 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_DETAILED = "SCHEDULE.prod.detailed"; 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 4ce2b78..c0612c9 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 @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.common.scheduler.service import com.ffii.core.utils.JwtTokenUtil 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.M18PurchaseOrderService import com.ffii.fpsms.m18.web.models.M18CommonRequest @@ -30,6 +31,9 @@ import kotlin.jvm.optionals.getOrNull open class SchedulerService( @Value("\${scheduler.postCompletedDnGrn.enabled:true}") val postCompletedDnGrnEnabled: Boolean, @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 taskScheduler: TaskScheduler, val productionScheduleService: ProductionScheduleService, @@ -38,6 +42,7 @@ open class SchedulerService( val m18MasterDataService: M18MasterDataService, val schedulerSyncLogRepository: SchedulerSyncLogRepository, val searchCompletedDnService: SearchCompletedDnService, + val m18GrnCodeSyncService: M18GrnCodeSyncService, ) { var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") @@ -55,6 +60,8 @@ open class SchedulerService( var scheduledPostCompletedDnGrn: ScheduledFuture<*>? = null + var scheduledGrnCodeSync: ScheduledFuture<*>? = null + //@Volatile //var scheduledRoughProd: ScheduledFuture<*>? = null @@ -99,6 +106,7 @@ open class SchedulerService( scheduleM18Do2(); scheduleM18MasterData(); schedulePostCompletedDnGrn(); + scheduleGrnCodeSync(); //scheduleRoughProd(); //scheduleDetailedProd(); } @@ -141,6 +149,32 @@ open class SchedulerService( 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. */ fun schedulePostCompletedDnGrn() { if (!postCompletedDnGrnEnabled) { 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 edc5061..9bddc6e 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 @@ -55,6 +55,12 @@ class SchedulerController( 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") fun triggerPostCompletedDnGrn( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 0db8be1..9f299f1 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -22,6 +22,9 @@ spring: scheduler: postCompletedDnGrn: 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: config: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c9ca48e..6b1177c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,6 +15,9 @@ scheduler: postCompletedDnGrn: enabled: false # 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: servlet: diff --git a/src/main/resources/db/changelog/changes/20260325_01_fai/01_add_grn_code_to_m18_goods_receipt_note_log.sql b/src/main/resources/db/changelog/changes/20260325_01_fai/01_add_grn_code_to_m18_goods_receipt_note_log.sql new file mode 100644 index 0000000..e338fdf --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260325_01_fai/01_add_grn_code_to_m18_goods_receipt_note_log.sql @@ -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`;