diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt index 9bb6c6d..e459e03 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt @@ -7,9 +7,12 @@ import java.time.LocalDateTime interface M18GoodsReceiptNoteLogRepository : AbstractRepository { - /** 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). 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 69b3b81..ede7185 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 @@ -80,6 +80,26 @@ fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? """) fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List + /** 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 + @Query(""" SELECT sil FROM StockInLine sil WHERE sil.receiptDate IS NOT NULL 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 index f705b4e..0f8f07d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt @@ -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) } 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 dbca9ba..d2c0168 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 @@ -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