| @@ -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<Long>() { | |||
| @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 | |||
| } | |||
| @@ -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 | |||
| ) | |||
| @@ -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"; | |||
| @@ -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() | |||
| @@ -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() | |||
| @@ -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<Long>): List<StockInLine> | |||
| 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<StockInLine> | |||
| } | |||
| @@ -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<StockInLine> { | |||
| 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<SearchCompletedDnResult> { | |||
| 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 | |||
| } | |||
| } | |||
| @@ -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) | |||
| } | |||
| @@ -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?, | |||
| ) | |||
| @@ -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: | |||
| @@ -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`; | |||