| @@ -21,6 +21,11 @@ open class M18GoodsReceiptNoteLog : BaseEntity<Long>() { | |||
| @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) | |||
| @@ -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<M18GoodsReceiptNoteLog, Long> { | |||
| /** 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<M18GoodsReceiptNoteLog> | |||
| } | |||
| @@ -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<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) */ | |||
| 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"; | |||
| @@ -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) { | |||
| @@ -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, | |||
| @@ -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: | |||
| @@ -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: | |||
| @@ -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`; | |||