| @@ -1,5 +1,5 @@ | |||||
| { | { | ||||
| "api_ip": "10.10.0.81", | |||||
| "api_ip": "127.0.0.1", | |||||
| "api_port": "8090", | "api_port": "8090", | ||||
| "dabag_ip": "192.168.18.27", | "dabag_ip": "192.168.18.27", | ||||
| "dabag_port": "3008", | "dabag_port": "3008", | ||||
| @@ -33,6 +33,7 @@ import kotlin.jvm.optionals.getOrNull | |||||
| open class SchedulerService( | open class SchedulerService( | ||||
| @Value("\${scheduler.postCompletedDnGrn.enabled:true}") val postCompletedDnGrnEnabled: Boolean, | @Value("\${scheduler.postCompletedDnGrn.enabled:true}") val postCompletedDnGrnEnabled: Boolean, | ||||
| @Value("\${scheduler.postCompletedDnGrn.receiptDate:}") val postCompletedDnGrnReceiptDate: String, | @Value("\${scheduler.postCompletedDnGrn.receiptDate:}") val postCompletedDnGrnReceiptDate: String, | ||||
| @Value("\${scheduler.postCompletedDnGrn.retryMissingGrnLookbackDays:7}") val postCompletedDnGrnRetryMissingGrnLookbackDays: Int, | |||||
| @Value("\${scheduler.grnCodeSync.enabled:true}") val grnCodeSyncEnabled: Boolean, | @Value("\${scheduler.grnCodeSync.enabled:true}") val grnCodeSyncEnabled: Boolean, | ||||
| @Value("\${scheduler.grnCodeSync.syncOffsetDays:0}") val grnCodeSyncSyncOffsetDays: Int, | @Value("\${scheduler.grnCodeSync.syncOffsetDays:0}") val grnCodeSyncSyncOffsetDays: Int, | ||||
| @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, | @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, | ||||
| @@ -422,7 +423,12 @@ open class SchedulerService( | |||||
| ) { | ) { | ||||
| logger.info("Scheduler - Post completed DN and process GRN") | logger.info("Scheduler - Post completed DN and process GRN") | ||||
| val date = receiptDate ?: java.time.LocalDate.now().minusDays(1) | val date = receiptDate ?: java.time.LocalDate.now().minusDays(1) | ||||
| searchCompletedDnService.postCompletedDnAndProcessGrn(receiptDate = date, skipFirst = skipFirst, limitToFirst = limitToFirst) | |||||
| searchCompletedDnService.postCompletedDnAndProcessGrnWithMissingRetry( | |||||
| receiptDate = date, | |||||
| skipFirst = skipFirst, | |||||
| limitToFirst = limitToFirst, | |||||
| retryLookbackDays = postCompletedDnGrnRetryMissingGrnLookbackDays, | |||||
| ) | |||||
| } | } | ||||
| open fun getM18MasterData() { | open fun getM18MasterData() { | ||||
| @@ -14,7 +14,6 @@ import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import com.ffii.fpsms.py.PyJobOrderListItem | import com.ffii.fpsms.py.PyJobOrderListItem | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.awt.Color | import java.awt.Color | ||||
| import java.awt.Font | import java.awt.Font | ||||
| import java.awt.Graphics2D | import java.awt.Graphics2D | ||||
| @@ -46,7 +45,7 @@ import java.time.LocalDate | |||||
| data class BitmapResult(val bytes: ByteArray, val width: Int) | data class BitmapResult(val bytes: ByteArray, val width: Int) | ||||
| @Service | @Service | ||||
| open class PlasticBagPrinterService( | |||||
| class PlasticBagPrinterService( | |||||
| val jobOrderRepository: JobOrderRepository, | val jobOrderRepository: JobOrderRepository, | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| @@ -82,9 +81,11 @@ open class PlasticBagPrinterService( | |||||
| /** | /** | ||||
| * Same criteria as [com.ffii.fpsms.py.PyController.listJobOrders], filtered by [SettingNames.LASER_PRINT_ITEM_CODES] | * Same criteria as [com.ffii.fpsms.py.PyController.listJobOrders], filtered by [SettingNames.LASER_PRINT_ITEM_CODES] | ||||
| * (comma-separated item codes). Blank / whitespace-only setting value means no filter (all packaging job orders). | * (comma-separated item codes). Blank / whitespace-only setting value means no filter (all packaging job orders). | ||||
| * | |||||
| * No Transactional annotation on this class: CGLIB subclassing plus Kotlin breaks constructor-injected deps on the proxy. | |||||
| * Rely on default OSIV for lazy BOM access within the HTTP request. | |||||
| */ | */ | ||||
| @Transactional(readOnly = true) | |||||
| open fun listLaserPrintJobOrders(planStart: LocalDate): List<PyJobOrderListItem> { | |||||
| fun listLaserPrintJobOrders(planStart: LocalDate): List<PyJobOrderListItem> { | |||||
| val dayStart = planStart.atStartOfDay() | val dayStart = planStart.atStartOfDay() | ||||
| val dayEndExclusive = planStart.plusDays(1).atStartOfDay() | val dayEndExclusive = planStart.plusDays(1).atStartOfDay() | ||||
| val orders = jobOrderRepository.findByDeletedFalseAndPlanStartBetweenAndBomProcessNameOrderByIdAsc( | val orders = jobOrderRepository.findByDeletedFalseAndPlanStartBetweenAndBomProcessNameOrderByIdAsc( | ||||
| @@ -18,12 +18,14 @@ import org.springframework.http.HttpStatus | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| import org.slf4j.LoggerFactory | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/plastic") | @RequestMapping("/plastic") | ||||
| class PlasticBagPrinterController( | class PlasticBagPrinterController( | ||||
| private val plasticBagPrinterService: PlasticBagPrinterService, | private val plasticBagPrinterService: PlasticBagPrinterService, | ||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(javaClass) | |||||
| /** System defaults + current values from [LASER_PRINT.host] / [LASER_PRINT.port] / [LASER_PRINT.itemCodes] (see Liquibase). */ | /** System defaults + current values from [LASER_PRINT.host] / [LASER_PRINT.port] / [LASER_PRINT.itemCodes] (see Liquibase). */ | ||||
| @GetMapping("/laser-bag2-settings") | @GetMapping("/laser-bag2-settings") | ||||
| @@ -97,6 +99,19 @@ class PlasticBagPrinterController( | |||||
| response.contentType = "text/plain;charset=UTF-8" | response.contentType = "text/plain;charset=UTF-8" | ||||
| response.writer.write(e.message ?: "Invalid request") | response.writer.write(e.message ?: "Invalid request") | ||||
| response.writer.flush() | response.writer.flush() | ||||
| } catch (e: Exception) { | |||||
| logger.error("POST /plastic/download-onpack-qr failed", e) | |||||
| try { | |||||
| if (!response.isCommitted) { | |||||
| response.reset() | |||||
| } | |||||
| } catch (_: Exception) { | |||||
| /* ignore */ | |||||
| } | |||||
| response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR | |||||
| response.contentType = "text/plain;charset=UTF-8" | |||||
| response.writer.write(e.message ?: "Download failed") | |||||
| response.writer.flush() | |||||
| } | } | ||||
| } | } | ||||
| @@ -121,6 +136,19 @@ class PlasticBagPrinterController( | |||||
| response.contentType = "text/plain;charset=UTF-8" | response.contentType = "text/plain;charset=UTF-8" | ||||
| response.writer.write(e.message ?: "Invalid request") | response.writer.write(e.message ?: "Invalid request") | ||||
| response.writer.flush() | response.writer.flush() | ||||
| } catch (e: Exception) { | |||||
| logger.error("POST /plastic/download-onpack-qr-text failed", e) | |||||
| try { | |||||
| if (!response.isCommitted) { | |||||
| response.reset() | |||||
| } | |||||
| } catch (_: Exception) { | |||||
| /* ignore */ | |||||
| } | |||||
| response.status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR | |||||
| response.contentType = "text/plain;charset=UTF-8" | |||||
| response.writer.write(e.message ?: "Download failed") | |||||
| response.writer.flush() | |||||
| } | } | ||||
| } | } | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.stock.service | package com.ffii.fpsms.modules.stock.service | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.fpsms.m18.entity.M18GoodsReceiptNoteLogRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.StockInLine | import com.ffii.fpsms.modules.stock.entity.StockInLine | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import com.ffii.fpsms.modules.stock.web.model.SearchCompletedDnResult | import com.ffii.fpsms.modules.stock.web.model.SearchCompletedDnResult | ||||
| @@ -20,6 +21,7 @@ open class SearchCompletedDnService( | |||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val stockInLineService: StockInLineService, | private val stockInLineService: StockInLineService, | ||||
| private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(SearchCompletedDnService::class.java) | private val logger = LoggerFactory.getLogger(SearchCompletedDnService::class.java) | ||||
| @@ -107,4 +109,94 @@ open class SearchCompletedDnService( | |||||
| } | } | ||||
| return toProcess.size | return toProcess.size | ||||
| } | } | ||||
| /** | |||||
| * 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 | |||||
| */ | |||||
| @Transactional | |||||
| open fun postCompletedDnAndProcessGrnWithMissingRetry( | |||||
| receiptDate: LocalDate = LocalDate.now().minusDays(1), | |||||
| skipFirst: Int = 0, | |||||
| limitToFirst: Int? = null, | |||||
| retryLookbackDays: Int = 0, | |||||
| ): Int { | |||||
| val lookback = retryLookbackDays.coerceAtLeast(0) | |||||
| var totalProcessed = 0 | |||||
| // Requirement: never process today's completed stock-in lines in retry mode. | |||||
| val today = LocalDate.now() | |||||
| var processedDayCount = 0 | |||||
| // For each offset, day = receiptDate minus offset. Example: job runs 27/3 00:01, receiptDate=yesterday=26/3, | |||||
| // lookback=6 => days 26,25,24,23,22,21,20 (skip any day equal to today). | |||||
| for (offset in 0..lookback) { | |||||
| val day = receiptDate.minusDays(offset.toLong()) | |||||
| if (day.isEqual(today)) { | |||||
| logger.info( | |||||
| "[postCompletedDnAndProcessGrnWithMissingRetry] Skipping receiptDate=today=$today " + | |||||
| "(offset=$offset) to avoid creating GRN from today's completed stock-in lines." | |||||
| ) | |||||
| continue | |||||
| } | |||||
| val daySkipFirst = if (processedDayCount == 0) skipFirst else 0 | |||||
| val dayLimitToFirst = if (processedDayCount == 0) limitToFirst else null | |||||
| val processedForDay = postCompletedDnAndProcessMissingGrnForReceiptDate( | |||||
| receiptDate = day, | |||||
| skipFirst = daySkipFirst, | |||||
| limitToFirst = dayLimitToFirst, | |||||
| ) | |||||
| totalProcessed += processedForDay | |||||
| processedDayCount++ | |||||
| logger.info( | |||||
| "[postCompletedDnAndProcessGrnWithMissingRetry] offset=$offset day=$day processedForDay=$processedForDay totalProcessed=$totalProcessed" | |||||
| ) | |||||
| } | |||||
| return totalProcessed | |||||
| } | |||||
| private fun postCompletedDnAndProcessMissingGrnForReceiptDate( | |||||
| receiptDate: LocalDate, | |||||
| skipFirst: Int, | |||||
| limitToFirst: Int?, | |||||
| ): Int { | |||||
| val lines = searchCompletedDn(receiptDate) | |||||
| 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) | |||||
| } | |||||
| val toProcess = missingEntries | |||||
| .drop(skipFirst) | |||||
| .let { if (limitToFirst != null) it.take(limitToFirst) else it } | |||||
| logger.info( | |||||
| "[postCompletedDnAndProcessMissingGrnForReceiptDate] receiptDate=$receiptDate foundLines=${lines.size} " + | |||||
| "poTotal=${byPo.size} poMissingSuccess=${missingEntries.size} processing=${toProcess.size} " + | |||||
| "(skipFirst=$skipFirst, limitToFirst=$limitToFirst)" | |||||
| ) | |||||
| toProcess.forEach { (poId, silList) -> | |||||
| silList.firstOrNull()?.let { first -> | |||||
| try { | |||||
| stockInLineService.processPurchaseOrderForGrn(first) | |||||
| } catch (e: Exception) { | |||||
| logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e) | |||||
| } | |||||
| } | |||||
| } | |||||
| return toProcess.size | |||||
| } | |||||
| } | } | ||||
| @@ -24,6 +24,8 @@ spring: | |||||
| scheduler: | scheduler: | ||||
| postCompletedDnGrn: | postCompletedDnGrn: | ||||
| enabled: true | enabled: true | ||||
| # Missing-success GRN retry window: receiptDate (default yesterday) back N days inclusive. E.g. run 27/3 with receiptDate=26/3 and N=6 => 20/3–26/3. | |||||
| retryMissingGrnLookbackDays: 6 | |||||
| grnCodeSync: | grnCodeSync: | ||||
| enabled: true | enabled: true | ||||
| syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code | syncOffsetDays: 10 # from (today − 10) 00:00 to now, rows missing grn_code | ||||