| @@ -7,9 +7,12 @@ import java.time.LocalDateTime | |||||
| interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | ||||
| /** Returns true if a successful GRN was already created for this PO (avoids core_201 duplicate). */ | |||||
| /** Returns true if a successful GRN was already created for this PO (legacy / broad check). */ | |||||
| fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean | fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean | ||||
| /** True if this stock-in line was already included in a successful M18 GRN (one delivery = one GRN batch). */ | |||||
| fun existsByStockInLineIdAndStatusTrue(stockInLineId: Long): Boolean | |||||
| /** | /** | ||||
| * GRN log rows that need M18 AN code backfill: have record id, no grn_code yet, | * GRN log rows that need M18 AN code backfill: have record id, no grn_code yet, | ||||
| * created in [start, end] inclusive (e.g. start = 4 days ago 00:00, end = now). | * created in [start, end] inclusive (e.g. start = 4 days ago 00:00, end = now). | ||||
| @@ -80,6 +80,26 @@ fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? | |||||
| """) | """) | ||||
| fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> | fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> | ||||
| /** Completed stock-in lines for this PO whose calendar receipt day matches (one M18 GRN per delivery date / DN batch). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT DISTINCT sil FROM StockInLine sil | |||||
| LEFT JOIN FETCH sil.item | |||||
| LEFT JOIN FETCH sil.purchaseOrderLine pol | |||||
| LEFT JOIN FETCH pol.item | |||||
| WHERE sil.purchaseOrder.id = :purchaseOrderId | |||||
| AND sil.deleted = false | |||||
| AND sil.status = 'completed' | |||||
| AND sil.receiptDate IS NOT NULL | |||||
| AND DATE(sil.receiptDate) = :receiptDate | |||||
| ORDER BY sil.id | |||||
| """ | |||||
| ) | |||||
| fun findCompletedByPurchaseOrderIdAndReceiptDateAndDeletedFalseWithItemNames( | |||||
| @Param("purchaseOrderId") purchaseOrderId: Long, | |||||
| @Param("receiptDate") receiptDate: LocalDate, | |||||
| ): List<StockInLine> | |||||
| @Query(""" | @Query(""" | ||||
| SELECT sil FROM StockInLine sil | SELECT sil FROM StockInLine sil | ||||
| WHERE sil.receiptDate IS NOT NULL | WHERE sil.receiptDate IS NOT NULL | ||||
| @@ -15,6 +15,7 @@ import java.time.LocalDateTime | |||||
| /** | /** | ||||
| * Search completed stock-in lines (DN) by receipt date and process M18 GRN creation. | * Search completed stock-in lines (DN) by receipt date and process M18 GRN creation. | ||||
| * Query: receiptDate = yesterday, status = completed, purchaseOrderId not null. | * Query: receiptDate = yesterday, status = completed, purchaseOrderId not null. | ||||
| * Same PO may appear on multiple dates; each receipt date batch can produce its own M18 GRN. | |||||
| */ | */ | ||||
| @Service | @Service | ||||
| open class SearchCompletedDnService( | open class SearchCompletedDnService( | ||||
| @@ -82,7 +83,7 @@ open class SearchCompletedDnService( | |||||
| /** | /** | ||||
| * Post completed DNs and process each related purchase order for M18 GRN creation. | * 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). | |||||
| * Triggered by scheduler. One GRN per PO **per receipt date** for lines completed on that date (same PO may receive multiple GRNs on different days). | |||||
| * @param receiptDate Default: yesterday | * @param receiptDate Default: yesterday | ||||
| * @param skipFirst For testing/manual trigger: skip the first N POs. 1 = skip 1st, process from 2nd. 0 = process from 1st. | * @param skipFirst For testing/manual trigger: skip the first N POs. 1 = skip 1st, process from 2nd. 0 = process from 1st. | ||||
| * @param limitToFirst For testing/manual trigger: process only the next N POs after skip. 1 = one PO. null = all remaining POs. | * @param limitToFirst For testing/manual trigger: process only the next N POs after skip. 1 = one PO. null = all remaining POs. | ||||
| @@ -101,7 +102,7 @@ open class SearchCompletedDnService( | |||||
| toProcess.forEach { (poId, silList) -> | toProcess.forEach { (poId, silList) -> | ||||
| silList.firstOrNull()?.let { first -> | silList.firstOrNull()?.let { first -> | ||||
| try { | try { | ||||
| stockInLineService.processPurchaseOrderForGrn(first) | |||||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("[postCompletedDnAndProcessGrn] Failed for PO id=$poId: ${e.message}", e) | logger.error("[postCompletedDnAndProcessGrn] Failed for PO id=$poId: ${e.message}", e) | ||||
| } | } | ||||
| @@ -111,13 +112,9 @@ open class SearchCompletedDnService( | |||||
| } | } | ||||
| /** | /** | ||||
| * Retry GRN creation for completed stock-in lines where the PO does not have a SUCCESS log in | |||||
| * `m18_goods_receipt_note_log`. | |||||
| * | |||||
| * Grouping behavior matches normal scheduler: | |||||
| * - iterate by `receiptDate` day window | |||||
| * - for each day, group by `PO (purchaseOrderId)` | |||||
| * - process one GRN per PO using the first line from that PO group | |||||
| * Retry GRN creation for completed stock-in lines where **some** line in that PO batch (same receipt date) | |||||
| * has no SUCCESS row in `m18_goods_receipt_note_log` (matched by `stock_in_line_id`). | |||||
| * Same PO can receive multiple GRNs on different delivery/receipt dates. | |||||
| */ | */ | ||||
| @Transactional | @Transactional | ||||
| open fun postCompletedDnAndProcessGrnWithMissingRetry( | open fun postCompletedDnAndProcessGrnWithMissingRetry( | ||||
| @@ -172,9 +169,12 @@ open class SearchCompletedDnService( | |||||
| val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | ||||
| val entries = byPo.entries.toList() | val entries = byPo.entries.toList() | ||||
| // Only retry POs that do NOT have any successful GRN log. | |||||
| val missingEntries = entries.filter { (poId, _) -> | |||||
| !m18GoodsReceiptNoteLogRepository.existsByPurchaseOrderIdAndStatusTrue(poId) | |||||
| // Per delivery date: retry if any completed line for this PO on this date lacks a successful GRN log. | |||||
| val missingEntries = entries.filter { (_, silList) -> | |||||
| silList.any { sil -> | |||||
| val id = sil.id ?: return@any true | |||||
| !m18GoodsReceiptNoteLogRepository.existsByStockInLineIdAndStatusTrue(id) | |||||
| } | |||||
| } | } | ||||
| val toProcess = missingEntries | val toProcess = missingEntries | ||||
| @@ -190,7 +190,7 @@ open class SearchCompletedDnService( | |||||
| toProcess.forEach { (poId, silList) -> | toProcess.forEach { (poId, silList) -> | ||||
| silList.firstOrNull()?.let { first -> | silList.firstOrNull()?.let { first -> | ||||
| try { | try { | ||||
| stockInLineService.processPurchaseOrderForGrn(first) | |||||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e) | logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e) | ||||
| } | } | ||||
| @@ -660,10 +660,10 @@ open class StockInLineService( | |||||
| /** | /** | ||||
| * Processes a purchase order for M18 GRN creation. Updates PO/line status and creates GRN if applicable. | * 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. | |||||
| * @param grnReceiptDate When set (scheduler paths), only completed lines on that calendar receipt day are included — separate deliveries get separate GRNs. | |||||
| */ | */ | ||||
| open fun processPurchaseOrderForGrn(stockInLine: StockInLine) { | |||||
| tryUpdatePurchaseOrderAndCreateGrnIfCompleted(stockInLine) | |||||
| open fun processPurchaseOrderForGrn(stockInLine: StockInLine, grnReceiptDate: LocalDate? = null) { | |||||
| tryUpdatePurchaseOrderAndCreateGrnIfCompleted(stockInLine, grnReceiptDate = grnReceiptDate) | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -671,7 +671,10 @@ open class StockInLineService( | |||||
| * 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 | ||||
| * RECEIVED and PENDING/ESCALATED status flows. | * RECEIVED and PENDING/ESCALATED status flows. | ||||
| */ | */ | ||||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedStockInLine: StockInLine) { | |||||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted( | |||||
| savedStockInLine: StockInLine, | |||||
| grnReceiptDate: LocalDate? = null, | |||||
| ) { | |||||
| if (savedStockInLine.purchaseOrderLine == null) return | if (savedStockInLine.purchaseOrderLine == null) return | ||||
| val pol = savedStockInLine.purchaseOrderLine ?: return | val pol = savedStockInLine.purchaseOrderLine ?: return | ||||
| updatePurchaseOrderLineStatus(pol) | updatePurchaseOrderLineStatus(pol) | ||||
| @@ -682,12 +685,29 @@ open class StockInLineService( | |||||
| // Align POL.m18Lot with M18 before GRN (sourceLot must match M18 PO line lot or AN save may fail). | // Align POL.m18Lot with M18 before GRN (sourceLot must match M18 PO line lot or AN save may fail). | ||||
| syncPurchaseOrderLineM18LotFromM18(savedPo) | syncPurchaseOrderLineM18LotFromM18(savedPo) | ||||
| // Defensive: load only completed stock-in lines for the PO, so GRN payload can't include pending/escalated. | |||||
| val linesForGrn = stockInLineRepository.findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!) | |||||
| // Completed lines for GRN: either this receipt date only (multi-delivery POs) or all completed on PO (legacy). | |||||
| val linesForGrn = if (grnReceiptDate != null) { | |||||
| stockInLineRepository.findCompletedByPurchaseOrderIdAndReceiptDateAndDeletedFalseWithItemNames( | |||||
| savedPo.id!!, | |||||
| grnReceiptDate, | |||||
| ) | |||||
| } else { | |||||
| stockInLineRepository.findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!) | |||||
| } | |||||
| if (linesForGrn.isEmpty()) { | if (linesForGrn.isEmpty()) { | ||||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | ||||
| return | return | ||||
| } | } | ||||
| if (grnReceiptDate != null && linesForGrn.all { sil -> | |||||
| val id = sil.id ?: return@all false | |||||
| m18GoodsReceiptNoteLogRepository.existsByStockInLineIdAndStatusTrue(id) | |||||
| }) { | |||||
| logger.info( | |||||
| "[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] Skipping M18 GRN — all ${linesForGrn.size} line(s) for PO id=${savedPo.id} " + | |||||
| "code=${savedPo.code} on receipt date $grnReceiptDate already have successful GRN logs" | |||||
| ) | |||||
| return | |||||
| } | |||||
| if (savedPo.m18BeId == null || savedPo.supplier?.m18Id == null || savedPo.currency?.m18Id == null) { | if (savedPo.m18BeId == null || savedPo.supplier?.m18Id == null || savedPo.currency?.m18Id == null) { | ||||
| 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 | ||||