From 7b60ef3b94de78d27706ebf799c9d03085f79317 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Mon, 9 Mar 2026 01:13:39 +0800 Subject: [PATCH] adding GRN query for daily stock in and refining the m18 log and an/ant data --- .../m18/entity/M18GoodsReceiptNoteLog.kt | 8 +- .../m18/model/GoodsReceiptNoteRequest.kt | 7 +- .../fpsms/modules/common/SettingNames.java | 3 + .../scheduler/service/SchedulerService.kt | 46 +++++++- .../scheduler/web/SchedulerController.kt | 12 ++ .../stock/entity/StockInLineRepository.kt | 11 ++ .../stock/service/SearchCompletedDnService.kt | 109 ++++++++++++++++++ .../stock/service/StockInLineService.kt | 70 +++++++---- .../web/model/SearchCompletedDnResult.kt | 21 ++++ src/main/resources/application.yml | 7 ++ .../20260308_fai/01_add_request_json.sql | 5 + 11 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchCompletedDnResult.kt create mode 100644 src/main/resources/db/changelog/changes/20260308_fai/01_add_request_json.sql 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 ed85ad3..cd6311e 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLog.kt @@ -9,8 +9,8 @@ import jakarta.validation.constraints.Size /** * Logs the result of creating a Goods Receipt Note (AN) in M18. - * One row per stock-in line included in the GRN, so we can trace - * m18RecordId back to stockInLineId, purchaseOrderLineId, and poCode. + * One row per purchase order line (POL) in the GRN, so we can trace + * m18RecordId back by stockInLineId or purchaseOrderLineId. */ @Entity @Table(name = "m18_goods_receipt_note_log") @@ -50,4 +50,8 @@ open class M18GoodsReceiptNoteLog : BaseEntity() { @Size(max = 1000) @Column(name = "message", length = 1000) open var message: String? = null + + /** Request JSON sent to M18 GRN API (for debugging/audit). */ + @Column(name = "request_json", columnDefinition = "LONGTEXT") + open var requestJson: String? = null } diff --git a/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt index f329d26..45a5749 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt @@ -27,12 +27,14 @@ data class GoodsReceiptNoteMainanValue( val rate: Number, val status: String? = "Y", val docDate: String? = null, - val tDate: String? = null, // PO delivery date (estimatedArrivalDate) + val tDate: String? = null, // receiptDate from stock-in line (MM/dd/yyyy) val locId: Int? = null, val flowTypeId: Int, - val staffId: Int, // required by M18 (core_101905) + val staffId: Int = 329, val cnDeptId: Int? = null, val virDeptId: Int? = null, + val udfMTMSDNNO2: String? = null, // doNo (dnNo), same for same PO + val udfpartiallyreceived: Boolean? = null, // true if any line acc>=demand, else false ) @JsonInclude(JsonInclude.Include.NON_NULL) @@ -53,4 +55,5 @@ data class GoodsReceiptNoteAntValue( val amt: Number, val beId: Int? = null, val flowTypeId: Int? = null, + val bDesc: String? = null, // itemName ) 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 a8aa84e..bda81d4 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -30,6 +30,9 @@ public abstract class SettingNames { public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master"; + /** 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_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 5d7e58f..ed23967 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 @@ -10,13 +10,16 @@ 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.master.service.ProductionScheduleService +import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService import com.ffii.fpsms.modules.settings.service.SettingsService import jakarta.annotation.PostConstruct +import org.springframework.beans.factory.annotation.Value import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.scheduling.TaskScheduler import org.springframework.scheduling.support.CronTrigger import org.springframework.stereotype.Service +import java.time.LocalDate import java.time.LocalDateTime import java.time.format.DateTimeFormatter import java.util.HashMap @@ -25,6 +28,8 @@ import kotlin.jvm.optionals.getOrNull @Service open class SchedulerService( + @Value("\${scheduler.postCompletedDnGrn.enabled:true}") val postCompletedDnGrnEnabled: Boolean, + @Value("\${scheduler.postCompletedDnGrn.receiptDate:}") val postCompletedDnGrnReceiptDate: String, val settingsService: SettingsService, val taskScheduler: TaskScheduler, val productionScheduleService: ProductionScheduleService, @@ -32,6 +37,7 @@ open class SchedulerService( val m18DeliveryOrderService: M18DeliveryOrderService, val m18MasterDataService: M18MasterDataService, val schedulerSyncLogRepository: SchedulerSyncLogRepository, + val searchCompletedDnService: SearchCompletedDnService, ) { var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) val dataStringFormat = DateTimeFormatter.ofPattern("yyyy-MM-dd") @@ -47,6 +53,8 @@ open class SchedulerService( @Volatile var scheduledM18Master: ScheduledFuture<*>? = null + var scheduledPostCompletedDnGrn: ScheduledFuture<*>? = null + //@Volatile //var scheduledRoughProd: ScheduledFuture<*>? = null @@ -64,15 +72,18 @@ open class SchedulerService( } fun commonSchedule(scheduled: ScheduledFuture<*>?, settingName: String, scheduleFunc: () -> Unit): ScheduledFuture<*>? { + return commonSchedule(scheduled, settingName, defaultCronExpression, scheduleFunc) + } + + fun commonSchedule(scheduled: ScheduledFuture<*>?, settingName: String, defaultCron: String, scheduleFunc: () -> Unit): ScheduledFuture<*>? { scheduled?.cancel(false) - var cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCronExpression + var cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCron if (!isValidCronExpression(cron)) { - cron = defaultCronExpression + cron = defaultCron } - // Now Kotlin is happy because both the method and the function allow nulls return taskScheduler.schedule( { scheduleFunc() }, CronTrigger(cron) @@ -87,6 +98,7 @@ open class SchedulerService( scheduleM18Do1(); scheduleM18Do2(); scheduleM18MasterData(); + schedulePostCompletedDnGrn(); //scheduleRoughProd(); //scheduleDetailedProd(); } @@ -123,6 +135,24 @@ open class SchedulerService( commonSchedule(scheduledM18Master, SettingNames.SCHEDULE_M18_MASTER, ::getM18MasterData) } + private fun getPostCompletedDnGrnReceiptDate(): LocalDate { + val s = postCompletedDnGrnReceiptDate.trim() + return if (s.isEmpty()) LocalDate.now().minusDays(1) + else try { LocalDate.parse(s) } catch (e: Exception) { LocalDate.now().minusDays(1) } + } + + /** Post completed DN and create M18 GRN. Default 23:45 daily. Set scheduler.postCompletedDnGrn.enabled=false to disable. */ + fun schedulePostCompletedDnGrn() { + if (!postCompletedDnGrnEnabled) { + scheduledPostCompletedDnGrn?.cancel(false) + scheduledPostCompletedDnGrn = null + logger.info("PostCompletedDn GRN scheduler disabled (scheduler.postCompletedDnGrn.enabled=false)") + return + } + // TODO temp: skipFirst=1, limitToFirst=2 to test 2nd and 3rd POs. Revert to ::getPostCompletedDnAndProcessGrn for production. + commonSchedule(scheduledPostCompletedDnGrn, SettingNames.SCHEDULE_POST_COMPLETED_DN_GRN, "0 3 1 * * *", { getPostCompletedDnAndProcessGrn(receiptDate = getPostCompletedDnGrnReceiptDate(), skipFirst = 1, limitToFirst = 2) }) + } + // Function for schedule // --------------------------- FP-MTMS --------------------------- // @@ -290,6 +320,16 @@ open class SchedulerService( ) } + open fun getPostCompletedDnAndProcessGrn( + receiptDate: java.time.LocalDate? = null, + skipFirst: Int = 0, + limitToFirst: Int? = 1, + ) { + logger.info("Scheduler - Post completed DN and process GRN") + val date = receiptDate ?: java.time.LocalDate.now().minusDays(1) + searchCompletedDnService.postCompletedDnAndProcessGrn(receiptDate = date, skipFirst = skipFirst, limitToFirst = limitToFirst) + } + open fun getM18MasterData() { logger.info("Daily Scheduler - Master Data") var currentTime = LocalDateTime.now() 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 0c0ea4a..f756b05 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 @@ -4,10 +4,12 @@ import com.ffii.fpsms.modules.common.SettingNames import com.ffii.fpsms.modules.common.scheduler.service.SchedulerService import com.ffii.fpsms.modules.settings.service.SettingsService import jakarta.validation.Valid +import org.springframework.format.annotation.DateTimeFormat import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate @RestController @@ -53,6 +55,16 @@ class SchedulerController( return "M18 Master Data Sync Triggered Successfully" } + @GetMapping("/trigger/post-completed-dn-grn") + fun triggerPostCompletedDnGrn( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, + @RequestParam(required = false, defaultValue = "0") skipFirst: Int = 0, + @RequestParam(required = false) limitToFirst: Int? = 1, + ): String { + schedulerService.getPostCompletedDnAndProcessGrn(receiptDate = receiptDate, skipFirst = skipFirst, limitToFirst = limitToFirst) + return "Post completed DN and process GRN triggered" + } + @GetMapping("/refresh-cron") fun refreshCron(): String { schedulerService.init() diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt index dcccea1..162f72f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt @@ -56,4 +56,15 @@ fun searchStockInLines( @Query("SELECT sil FROM StockInLine sil WHERE sil.item.id IN :itemIds AND sil.deleted = false") fun findAllByItemIdInAndDeletedFalse(itemIds: List): List fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? + + @Query(""" + SELECT sil FROM StockInLine sil + WHERE sil.receiptDate IS NOT NULL + AND DATE(sil.receiptDate) = :receiptDate + AND sil.status = 'completed' + AND sil.purchaseOrder IS NOT NULL + AND sil.deleted = false + ORDER BY sil.purchaseOrder.id + """) + fun findCompletedDnByReceiptDate(@Param("receiptDate") receiptDate: LocalDate): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt new file mode 100644 index 0000000..502643a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt @@ -0,0 +1,109 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.stock.entity.StockInLine +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.modules.stock.web.model.SearchCompletedDnResult +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Search completed stock-in lines (DN) by receipt date and process M18 GRN creation. + * Query: receiptDate = yesterday, status = completed, purchaseOrderId not null. + */ +@Service +open class SearchCompletedDnService( + private val jdbcDao: JdbcDao, + private val stockInLineRepository: StockInLineRepository, + private val stockInLineService: StockInLineService, +) { + private val logger = LoggerFactory.getLogger(SearchCompletedDnService::class.java) + + /** + * Search completed DNs by receipt date (default: yesterday). + * Returns stock-in lines with receiptDate = date, status = completed, purchaseOrder not null. + */ + open fun searchCompletedDn(receiptDate: LocalDate = LocalDate.now().minusDays(1)): List { + return stockInLineRepository.findCompletedDnByReceiptDate(receiptDate) + } + + /** + * Search completed DNs by receipt date with exact query format. + * dnNo: empty string when original is 'DN00000'. + */ + open fun searchCompletedDnAsResult(receiptDate: LocalDate = LocalDate.now().minusDays(1)): List { + val sql = """ + SELECT + sil.purchaseOrderId as purchaseOrderId, + sil.purchaseOrderLineId as purchaseOrderLineId, + sil.itemId as itemId, + sil.itemNo as itemNo, + it.name as itemName, + sil.stockInId as stockInId, + sil.demandQty as demandQty, + sil.acceptedQty as acceptedQty, + sil.receiptDate as receiptDate, + CASE WHEN sil.dnNo = 'DN00000' THEN '' ELSE sil.dnNo END as dnNo + FROM stock_in_line sil + LEFT JOIN items it ON sil.itemId = it.id + WHERE sil.receiptDate IS NOT NULL + AND DATE(sil.receiptDate) = :receiptDate + AND sil.status = 'completed' + AND sil.purchaseOrderId IS NOT NULL + AND sil.deleted = false + ORDER BY sil.purchaseOrderId + """.trimIndent() + val rows = jdbcDao.queryForList(sql, mapOf("receiptDate" to receiptDate.toString())) + return rows.map { row -> + val getLong = { k: String -> ((row[k] ?: row[k.lowercase()]) as? Number)?.toLong() ?: 0L } + val getBd = { k: String -> (row[k] ?: row[k.lowercase()])?.let { when (it) { is BigDecimal -> it; is Number -> BigDecimal(it.toString()); else -> BigDecimal(it.toString()) } } } + val getTs = { k: String -> (row[k] ?: row[k.lowercase()])?.let { when (it) { is java.sql.Timestamp -> it.toLocalDateTime(); is java.sql.Date -> it.toLocalDate().atStartOfDay(); else -> null } } } + SearchCompletedDnResult( + purchaseOrderId = getLong("purchaseOrderId"), + purchaseOrderLineId = getLong("purchaseOrderLineId"), + itemId = getLong("itemId"), + itemNo = (row["itemNo"] ?: row["itemno"])?.toString(), + itemName = (row["itemName"] ?: row["itemname"])?.toString(), + stockInId = getLong("stockInId"), + demandQty = getBd("demandQty"), + acceptedQty = getBd("acceptedQty"), + receiptDate = getTs("receiptDate"), + dnNo = (row["dnNo"] ?: row["dnno"])?.toString()?.takeIf { it.isNotEmpty() }, + ) + } + } + + /** + * Post completed DNs and process each related purchase order for M18 GRN creation. + * Triggered by scheduler. One GRN per Purchase Order, with the PO lines it received (acceptedQty as ant qty). + * @param receiptDate Default: yesterday + * @param skipFirst For testing: skip the first N POs. 1 = skip 1st, process from 2nd. 0 = process from 1st. + * @param limitToFirst For testing: process only the next N POs after skip. 1 = one PO. null = all remaining POs. + */ + @Transactional + open fun postCompletedDnAndProcessGrn( + receiptDate: LocalDate = LocalDate.now().minusDays(2), + skipFirst: Int = 0, + limitToFirst: Int? = 1, + ): Int { + val lines = searchCompletedDn(receiptDate) + val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } + val entries = byPo.entries.toList() + val toProcess = entries.drop(skipFirst).let { if (limitToFirst != null) it.take(limitToFirst) else it } + logger.info("[postCompletedDnAndProcessGrn] receiptDate=$receiptDate, found ${lines.size} lines, ${byPo.size} POs, processing ${toProcess.size} (skipFirst=$skipFirst, limitToFirst=$limitToFirst)") + toProcess.forEach { (poId, silList) -> + silList.firstOrNull()?.let { first -> + try { + stockInLineService.processPurchaseOrderForGrn(first) + } catch (e: Exception) { + logger.error("[postCompletedDnAndProcessGrn] Failed for PO id=$poId: ${e.message}", e) + } + } + } + return toProcess.size + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 5a0c888..0ec64a7 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -425,27 +425,40 @@ open class StockInLineService( poCode.startsWith("PP") -> 3 else -> 1 } + val firstLine = stockInLines.firstOrNull() + val doNo = firstLine?.dnNo?.takeIf { it != "DN00000" } ?: "" + val tDate = firstLine?.receiptDate?.toLocalDate()?.format(DateTimeFormatter.ofPattern("MM/dd/yyyy")) + ?: LocalDate.now().format(DateTimeFormatter.ofPattern("MM/dd/yyyy")) + // Group by POL first for udfpartiallyreceived: true = any POL short (accepted < ordered), false = all fully received + val byPol = stockInLines.groupBy { it.purchaseOrderLine!!.id!! } + val udfpartiallyreceived = byPol.any { (_, silList) -> + val pol = silList.first().purchaseOrderLine!! + val totalAccepted = silList.sumOf { it.acceptedQty ?: BigDecimal.ZERO } + totalAccepted < (pol.qty ?: BigDecimal.ZERO) + } val mainan = GoodsReceiptNoteMainan( values = listOf( GoodsReceiptNoteMainanValue( - id = null, // omit; "0" and "" didn't fix core_201 + id = null, beId = beId, - code = null, // omit; empty string may cause 400 + code = null, venId = (po.supplier?.m18Id ?: 0L).toInt(), curId = (po.currency?.m18Id ?: 0L).toInt(), rate = 1, flowTypeId = flowTypeId, - staffId = 194, // Steve + staffId = 329, virDeptId = po.shop?.m18Id?.toInt(), - tDate = (po.estimatedArrivalDate?.toLocalDate() ?: LocalDate.now()).format(DateTimeFormatter.ofPattern("MM/dd/yyyy")), - status = null, // commented out - omit - // docDate = ..., locId = ..., cnDeptId = ... + tDate = tDate, + udfMTMSDNNO2 = doNo.ifEmpty { null }, + udfpartiallyreceived = udfpartiallyreceived, ) ) ) val sourceId = po.m18DataLog?.m18Id ?: 0L - val antValues = stockInLines.map { sil -> + val antValues = byPol.map { (_, silList) -> + val sil = silList.first() val pol = sil.purchaseOrderLine!! + val totalQty = silList.sumOf { it.acceptedQty ?: BigDecimal.ZERO } val unitIdFromDataLog = (pol.m18DataLog?.dataLog?.get("unitId") as? Number)?.toLong()?.toInt() GoodsReceiptNoteAntValue( sourceType = "po", @@ -454,15 +467,16 @@ open class StockInLineService( proId = (sil.item?.m18Id ?: 0L).toInt(), locId = 155, unitId = unitIdFromDataLog ?: (pol.uom?.m18Id ?: 0L).toInt(), - qty = sil.acceptedQty?.toDouble() ?: 0.0, + qty = totalQty.toDouble(), up = pol.up?.toDouble() ?: 0.0, amt = CommonUtils.getAmt( up = pol.up ?: BigDecimal.ZERO, discount = pol.m18Discount ?: BigDecimal.ZERO, - qty = sil.acceptedQty ?: BigDecimal.ZERO + qty = totalQty ), - beId = beId, // same as header - flowTypeId = flowTypeId // same as header: TOA->1, PF->2, PP->3 + beId = beId, + flowTypeId = flowTypeId, + bDesc = sil.item?.name, ) } val ant = GoodsReceiptNoteAnt(values = antValues) @@ -501,6 +515,14 @@ open class StockInLineService( polRepository.saveAndFlush(pol) } + /** + * Processes a purchase order for M18 GRN creation. Updates PO/line status and creates GRN if applicable. + * Called by SearchCompletedDnService (scheduler postCompletedDnAndProcessGrn) for batch processing of yesterday's completed DNs. + */ + open fun processPurchaseOrderForGrn(stockInLine: StockInLine) { + tryUpdatePurchaseOrderAndCreateGrnIfCompleted(stockInLine) + } + /** * Updates purchase order status from its lines and, when PO becomes COMPLETED, * creates M18 Goods Receipt Note. Called after saving stock-in line for both @@ -524,10 +546,11 @@ open class StockInLineService( logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - missing M18 ids for PO id=${savedPo.id} code=${savedPo.code}. m18BeId=${savedPo.m18BeId}, supplier.m18Id=${savedPo.supplier?.m18Id}, currency.m18Id=${savedPo.currency?.m18Id}") return } - if (m18GoodsReceiptNoteLogRepository.existsByPurchaseOrderIdAndStatusTrue(savedPo.id!!)) { - logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] Skipping M18 GRN - already created for PO id=${savedPo.id} code=${savedPo.code} (avoids core_201 duplicate)") - return - } + //temp comment this TODO will only check purchase order + dnNo duplicated + //if (m18GoodsReceiptNoteLogRepository.existsByPurchaseOrderIdAndStatusTrue(savedPo.id!!)) { + // logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] Skipping M18 GRN - already created for PO id=${savedPo.id} code=${savedPo.code} (avoids core_201 duplicate)") + // return + //} val grnRequest = buildGoodsReceiptNoteRequest(savedPo, linesForGrn) val grnRequestJson = Gson().toJson(grnRequest) logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] M18 GRN API request (for discussion with M18) PO id=${savedPo.id} code=${savedPo.code}: $grnRequestJson") @@ -536,34 +559,39 @@ open class StockInLineService( logger.warn("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: M18 API returned null for PO id=${savedPo.id} code=${savedPo.code}. API call may have failed or timed out.") return } + val byPol = linesForGrn.groupBy { it.purchaseOrderLine!!.id!! } if (grnResponse.status == true) { logger.info("M18 Goods Receipt Note created for PO ${savedPo.code}, goodsReceiptNote id (recordId)=${grnResponse.recordId}") - linesForGrn.forEach { sil -> - val linePol = sil.purchaseOrderLine!! + byPol.forEach { (_, silList) -> + val sil = silList.first() + val pol = sil.purchaseOrderLine!! val logEntry = M18GoodsReceiptNoteLog().apply { m18RecordId = grnResponse.recordId stockInLineId = sil.id - purchaseOrderLineId = linePol.id + purchaseOrderLineId = pol.id purchaseOrderId = savedPo.id poCode = savedPo.code status = true message = null + requestJson = grnRequestJson } m18GoodsReceiptNoteLogRepository.save(logEntry) } } else { logger.warn("M18 Goods Receipt Note save returned status=false for PO ${savedPo.code}, recordId=${grnResponse?.recordId}, messages=${grnResponse?.messages}. Request sent (for M18 discussion): $grnRequestJson") val msg = grnResponse?.messages?.joinToString { it.msgDetail ?: it.msgCode ?: "" } ?: "" - linesForGrn.forEach { sil -> - val linePol = sil.purchaseOrderLine!! + byPol.forEach { (_, silList) -> + val sil = silList.first() + val pol = sil.purchaseOrderLine!! val logEntry = M18GoodsReceiptNoteLog().apply { m18RecordId = grnResponse?.recordId ?: 0L stockInLineId = sil.id - purchaseOrderLineId = linePol.id + purchaseOrderLineId = pol.id purchaseOrderId = savedPo.id poCode = savedPo.code status = false message = msg.take(1000) + requestJson = grnRequestJson } m18GoodsReceiptNoteLogRepository.save(logEntry) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchCompletedDnResult.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchCompletedDnResult.kt new file mode 100644 index 0000000..5444c89 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchCompletedDnResult.kt @@ -0,0 +1,21 @@ +package com.ffii.fpsms.modules.stock.web.model + +import java.math.BigDecimal +import java.time.LocalDateTime + +/** + * Result of searchCompletedDn query. + * dnNo: empty string when original is 'DN00000'. + */ +data class SearchCompletedDnResult( + val purchaseOrderId: Long, + val purchaseOrderLineId: Long, + val itemId: Long, + val itemNo: String?, + val itemName: String?, + val stockInId: Long, + val demandQty: BigDecimal?, + val acceptedQty: BigDecimal?, + val receiptDate: LocalDateTime?, + val dnNo: String?, +) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2ec9b4c..cdb1c71 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,6 +9,13 @@ server: error: include-message: always +# Set to false to temporarily disable the PostCompletedDn GRN scheduler only +# receiptDate: override for testing (e.g. 2026-03-07). When unset, uses yesterday. +scheduler: + postCompletedDnGrn: + enabled: false + receiptDate: 2026-03-07 + spring: servlet: multipart: diff --git a/src/main/resources/db/changelog/changes/20260308_fai/01_add_request_json.sql b/src/main/resources/db/changelog/changes/20260308_fai/01_add_request_json.sql new file mode 100644 index 0000000..59c369d --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260308_fai/01_add_request_json.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql +--changeset fai:add_request_json_to_m18_goods_receipt_note_log + +ALTER TABLE `m18_goods_receipt_note_log` +ADD COLUMN `request_json` LONGTEXT NULL COMMENT 'Request JSON sent to M18 GRN API (for debugging/audit)' AFTER `message`;