| @@ -9,8 +9,8 @@ import jakarta.validation.constraints.Size | |||||
| /** | /** | ||||
| * Logs the result of creating a Goods Receipt Note (AN) in M18. | * 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 | @Entity | ||||
| @Table(name = "m18_goods_receipt_note_log") | @Table(name = "m18_goods_receipt_note_log") | ||||
| @@ -50,4 +50,8 @@ open class M18GoodsReceiptNoteLog : BaseEntity<Long>() { | |||||
| @Size(max = 1000) | @Size(max = 1000) | ||||
| @Column(name = "message", length = 1000) | @Column(name = "message", length = 1000) | ||||
| open var message: String? = null | 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 rate: Number, | ||||
| val status: String? = "Y", | val status: String? = "Y", | ||||
| val docDate: String? = null, | 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 locId: Int? = null, | ||||
| val flowTypeId: Int, | val flowTypeId: Int, | ||||
| val staffId: Int, // required by M18 (core_101905) | |||||
| val staffId: Int = 329, | |||||
| val cnDeptId: Int? = null, | val cnDeptId: Int? = null, | ||||
| val virDeptId: 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) | @JsonInclude(JsonInclude.Include.NON_NULL) | ||||
| @@ -53,4 +55,5 @@ data class GoodsReceiptNoteAntValue( | |||||
| val amt: Number, | val amt: Number, | ||||
| val beId: Int? = null, | val beId: Int? = null, | ||||
| val flowTypeId: 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"; | 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_ROUGH = "SCHEDULE.prod.rough"; | ||||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | 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.m18.model.SyncResult | ||||
| import com.ffii.fpsms.modules.common.SettingNames | import com.ffii.fpsms.modules.common.SettingNames | ||||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | 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 com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import jakarta.annotation.PostConstruct | import jakarta.annotation.PostConstruct | ||||
| import org.springframework.beans.factory.annotation.Value | |||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.scheduling.TaskScheduler | import org.springframework.scheduling.TaskScheduler | ||||
| import org.springframework.scheduling.support.CronTrigger | import org.springframework.scheduling.support.CronTrigger | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import java.util.HashMap | import java.util.HashMap | ||||
| @@ -25,6 +28,8 @@ import kotlin.jvm.optionals.getOrNull | |||||
| @Service | @Service | ||||
| open class SchedulerService( | open class SchedulerService( | ||||
| @Value("\${scheduler.postCompletedDnGrn.enabled:true}") val postCompletedDnGrnEnabled: Boolean, | |||||
| @Value("\${scheduler.postCompletedDnGrn.receiptDate:}") val postCompletedDnGrnReceiptDate: String, | |||||
| val settingsService: SettingsService, | val settingsService: SettingsService, | ||||
| val taskScheduler: TaskScheduler, | val taskScheduler: TaskScheduler, | ||||
| val productionScheduleService: ProductionScheduleService, | val productionScheduleService: ProductionScheduleService, | ||||
| @@ -32,6 +37,7 @@ open class SchedulerService( | |||||
| val m18DeliveryOrderService: M18DeliveryOrderService, | val m18DeliveryOrderService: M18DeliveryOrderService, | ||||
| val m18MasterDataService: M18MasterDataService, | val m18MasterDataService: M18MasterDataService, | ||||
| val schedulerSyncLogRepository: SchedulerSyncLogRepository, | val schedulerSyncLogRepository: SchedulerSyncLogRepository, | ||||
| val searchCompletedDnService: SearchCompletedDnService, | |||||
| ) { | ) { | ||||
| 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") | ||||
| @@ -47,6 +53,8 @@ open class SchedulerService( | |||||
| @Volatile | @Volatile | ||||
| var scheduledM18Master: ScheduledFuture<*>? = null | var scheduledM18Master: ScheduledFuture<*>? = null | ||||
| var scheduledPostCompletedDnGrn: ScheduledFuture<*>? = null | |||||
| //@Volatile | //@Volatile | ||||
| //var scheduledRoughProd: ScheduledFuture<*>? = null | //var scheduledRoughProd: ScheduledFuture<*>? = null | ||||
| @@ -64,15 +72,18 @@ open class SchedulerService( | |||||
| } | } | ||||
| fun commonSchedule(scheduled: ScheduledFuture<*>?, settingName: String, scheduleFunc: () -> Unit): ScheduledFuture<*>? { | 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) | scheduled?.cancel(false) | ||||
| var cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCronExpression | |||||
| var cron = settingsService.findByName(settingName).getOrNull()?.value ?: defaultCron | |||||
| if (!isValidCronExpression(cron)) { | if (!isValidCronExpression(cron)) { | ||||
| cron = defaultCronExpression | |||||
| cron = defaultCron | |||||
| } | } | ||||
| // Now Kotlin is happy because both the method and the function allow nulls | |||||
| return taskScheduler.schedule( | return taskScheduler.schedule( | ||||
| { scheduleFunc() }, | { scheduleFunc() }, | ||||
| CronTrigger(cron) | CronTrigger(cron) | ||||
| @@ -87,6 +98,7 @@ open class SchedulerService( | |||||
| scheduleM18Do1(); | scheduleM18Do1(); | ||||
| scheduleM18Do2(); | scheduleM18Do2(); | ||||
| scheduleM18MasterData(); | scheduleM18MasterData(); | ||||
| schedulePostCompletedDnGrn(); | |||||
| //scheduleRoughProd(); | //scheduleRoughProd(); | ||||
| //scheduleDetailedProd(); | //scheduleDetailedProd(); | ||||
| } | } | ||||
| @@ -123,6 +135,24 @@ open class SchedulerService( | |||||
| commonSchedule(scheduledM18Master, SettingNames.SCHEDULE_M18_MASTER, ::getM18MasterData) | 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 | // Function for schedule | ||||
| // --------------------------- FP-MTMS --------------------------- // | // --------------------------- 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() { | open fun getM18MasterData() { | ||||
| logger.info("Daily Scheduler - Master Data") | logger.info("Daily Scheduler - Master Data") | ||||
| var currentTime = LocalDateTime.now() | 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.common.scheduler.service.SchedulerService | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | import com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import jakarta.validation.Valid | import jakarta.validation.Valid | ||||
| import org.springframework.format.annotation.DateTimeFormat | |||||
| import org.springframework.web.bind.annotation.GetMapping | import org.springframework.web.bind.annotation.GetMapping | ||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| import java.time.LocalDate | |||||
| @RestController | @RestController | ||||
| @@ -53,6 +55,16 @@ class SchedulerController( | |||||
| return "M18 Master Data Sync Triggered Successfully" | 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") | @GetMapping("/refresh-cron") | ||||
| fun refreshCron(): String { | fun refreshCron(): String { | ||||
| schedulerService.init() | schedulerService.init() | ||||
| @@ -56,4 +56,15 @@ fun searchStockInLines( | |||||
| @Query("SELECT sil FROM StockInLine sil WHERE sil.item.id IN :itemIds AND sil.deleted = false") | @Query("SELECT sil FROM StockInLine sil WHERE sil.item.id IN :itemIds AND sil.deleted = false") | ||||
| fun findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<StockInLine> | fun findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<StockInLine> | ||||
| fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): 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 | poCode.startsWith("PP") -> 3 | ||||
| else -> 1 | 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( | val mainan = GoodsReceiptNoteMainan( | ||||
| values = listOf( | values = listOf( | ||||
| GoodsReceiptNoteMainanValue( | GoodsReceiptNoteMainanValue( | ||||
| id = null, // omit; "0" and "" didn't fix core_201 | |||||
| id = null, | |||||
| beId = beId, | beId = beId, | ||||
| code = null, // omit; empty string may cause 400 | |||||
| code = null, | |||||
| venId = (po.supplier?.m18Id ?: 0L).toInt(), | venId = (po.supplier?.m18Id ?: 0L).toInt(), | ||||
| curId = (po.currency?.m18Id ?: 0L).toInt(), | curId = (po.currency?.m18Id ?: 0L).toInt(), | ||||
| rate = 1, | rate = 1, | ||||
| flowTypeId = flowTypeId, | flowTypeId = flowTypeId, | ||||
| staffId = 194, // Steve | |||||
| staffId = 329, | |||||
| virDeptId = po.shop?.m18Id?.toInt(), | 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 sourceId = po.m18DataLog?.m18Id ?: 0L | ||||
| val antValues = stockInLines.map { sil -> | |||||
| val antValues = byPol.map { (_, silList) -> | |||||
| val sil = silList.first() | |||||
| val pol = sil.purchaseOrderLine!! | val pol = sil.purchaseOrderLine!! | ||||
| val totalQty = silList.sumOf { it.acceptedQty ?: BigDecimal.ZERO } | |||||
| val unitIdFromDataLog = (pol.m18DataLog?.dataLog?.get("unitId") as? Number)?.toLong()?.toInt() | val unitIdFromDataLog = (pol.m18DataLog?.dataLog?.get("unitId") as? Number)?.toLong()?.toInt() | ||||
| GoodsReceiptNoteAntValue( | GoodsReceiptNoteAntValue( | ||||
| sourceType = "po", | sourceType = "po", | ||||
| @@ -454,15 +467,16 @@ open class StockInLineService( | |||||
| proId = (sil.item?.m18Id ?: 0L).toInt(), | proId = (sil.item?.m18Id ?: 0L).toInt(), | ||||
| locId = 155, | locId = 155, | ||||
| unitId = unitIdFromDataLog ?: (pol.uom?.m18Id ?: 0L).toInt(), | unitId = unitIdFromDataLog ?: (pol.uom?.m18Id ?: 0L).toInt(), | ||||
| qty = sil.acceptedQty?.toDouble() ?: 0.0, | |||||
| qty = totalQty.toDouble(), | |||||
| up = pol.up?.toDouble() ?: 0.0, | up = pol.up?.toDouble() ?: 0.0, | ||||
| amt = CommonUtils.getAmt( | amt = CommonUtils.getAmt( | ||||
| up = pol.up ?: BigDecimal.ZERO, | up = pol.up ?: BigDecimal.ZERO, | ||||
| discount = pol.m18Discount ?: 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) | val ant = GoodsReceiptNoteAnt(values = antValues) | ||||
| @@ -501,6 +515,14 @@ open class StockInLineService( | |||||
| polRepository.saveAndFlush(pol) | 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, | * 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 | * 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}") | 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 | 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 grnRequest = buildGoodsReceiptNoteRequest(savedPo, linesForGrn) | ||||
| val grnRequestJson = Gson().toJson(grnRequest) | val grnRequestJson = Gson().toJson(grnRequest) | ||||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] M18 GRN API request (for discussion with M18) PO id=${savedPo.id} code=${savedPo.code}: $grnRequestJson") | 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.") | 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 | return | ||||
| } | } | ||||
| val byPol = linesForGrn.groupBy { it.purchaseOrderLine!!.id!! } | |||||
| if (grnResponse.status == true) { | if (grnResponse.status == true) { | ||||
| logger.info("M18 Goods Receipt Note created for PO ${savedPo.code}, goodsReceiptNote id (recordId)=${grnResponse.recordId}") | 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 { | val logEntry = M18GoodsReceiptNoteLog().apply { | ||||
| m18RecordId = grnResponse.recordId | m18RecordId = grnResponse.recordId | ||||
| stockInLineId = sil.id | stockInLineId = sil.id | ||||
| purchaseOrderLineId = linePol.id | |||||
| purchaseOrderLineId = pol.id | |||||
| purchaseOrderId = savedPo.id | purchaseOrderId = savedPo.id | ||||
| poCode = savedPo.code | poCode = savedPo.code | ||||
| status = true | status = true | ||||
| message = null | message = null | ||||
| requestJson = grnRequestJson | |||||
| } | } | ||||
| m18GoodsReceiptNoteLogRepository.save(logEntry) | m18GoodsReceiptNoteLogRepository.save(logEntry) | ||||
| } | } | ||||
| } else { | } 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") | 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 ?: "" } ?: "" | 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 { | val logEntry = M18GoodsReceiptNoteLog().apply { | ||||
| m18RecordId = grnResponse?.recordId ?: 0L | m18RecordId = grnResponse?.recordId ?: 0L | ||||
| stockInLineId = sil.id | stockInLineId = sil.id | ||||
| purchaseOrderLineId = linePol.id | |||||
| purchaseOrderLineId = pol.id | |||||
| purchaseOrderId = savedPo.id | purchaseOrderId = savedPo.id | ||||
| poCode = savedPo.code | poCode = savedPo.code | ||||
| status = false | status = false | ||||
| message = msg.take(1000) | message = msg.take(1000) | ||||
| requestJson = grnRequestJson | |||||
| } | } | ||||
| m18GoodsReceiptNoteLogRepository.save(logEntry) | 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: | error: | ||||
| include-message: always | 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: | spring: | ||||
| servlet: | servlet: | ||||
| multipart: | 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`; | |||||