| @@ -7,9 +7,12 @@ import java.time.LocalDateTime | |||
| interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { | |||
| /** Returns true if a successful GRN was already created for this PO (avoids core_201 duplicate). */ | |||
| /** Returns true if a successful GRN was already created for this PO (legacy / broad check). */ | |||
| 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, | |||
| * 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> | |||
| /** 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(""" | |||
| SELECT sil FROM StockInLine sil | |||
| 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. | |||
| * 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 | |||
| open class SearchCompletedDnService( | |||
| @@ -82,7 +83,7 @@ open class SearchCompletedDnService( | |||
| /** | |||
| * 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 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. | |||
| @@ -101,7 +102,7 @@ open class SearchCompletedDnService( | |||
| toProcess.forEach { (poId, silList) -> | |||
| silList.firstOrNull()?.let { first -> | |||
| try { | |||
| stockInLineService.processPurchaseOrderForGrn(first) | |||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||
| } catch (e: Exception) { | |||
| 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 | |||
| open fun postCompletedDnAndProcessGrnWithMissingRetry( | |||
| @@ -172,9 +169,12 @@ open class SearchCompletedDnService( | |||
| val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | |||
| 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 | |||
| @@ -190,7 +190,7 @@ open class SearchCompletedDnService( | |||
| toProcess.forEach { (poId, silList) -> | |||
| silList.firstOrNull()?.let { first -> | |||
| try { | |||
| stockInLineService.processPurchaseOrderForGrn(first) | |||
| stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate) | |||
| } catch (e: Exception) { | |||
| 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. | |||
| * 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 | |||
| * RECEIVED and PENDING/ESCALATED status flows. | |||
| */ | |||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedStockInLine: StockInLine) { | |||
| private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted( | |||
| savedStockInLine: StockInLine, | |||
| grnReceiptDate: LocalDate? = null, | |||
| ) { | |||
| if (savedStockInLine.purchaseOrderLine == null) return | |||
| val pol = savedStockInLine.purchaseOrderLine ?: return | |||
| 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). | |||
| 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()) { | |||
| logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") | |||
| 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) { | |||
| 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 | |||