| @@ -21,12 +21,17 @@ import org.springframework.context.annotation.Lazy | |||||
| import org.springframework.http.HttpMethod | import org.springframework.http.HttpMethod | ||||
| import org.springframework.http.HttpRequest | import org.springframework.http.HttpRequest | ||||
| import org.springframework.http.HttpStatusCode | import org.springframework.http.HttpStatusCode | ||||
| import org.springframework.core.io.buffer.DataBuffer | |||||
| import org.springframework.core.io.buffer.DefaultDataBufferFactory | |||||
| import org.springframework.web.reactive.function.BodyInserters | import org.springframework.web.reactive.function.BodyInserters | ||||
| import java.net.URLEncoder | |||||
| import java.nio.charset.StandardCharsets | |||||
| import org.springframework.web.reactive.function.client.ClientRequest | import org.springframework.web.reactive.function.client.ClientRequest | ||||
| import org.springframework.web.reactive.function.client.ExchangeFilterFunction | import org.springframework.web.reactive.function.client.ExchangeFilterFunction | ||||
| import org.springframework.web.reactive.function.client.WebClientResponseException | import org.springframework.web.reactive.function.client.WebClientResponseException | ||||
| import org.springframework.web.service.invoker.HttpRequestValues | import org.springframework.web.service.invoker.HttpRequestValues | ||||
| import reactor.util.retry.Retry | import reactor.util.retry.Retry | ||||
| import java.net.URI | |||||
| import java.time.Duration | import java.time.Duration | ||||
| import java.util.concurrent.atomic.AtomicReference | import java.util.concurrent.atomic.AtomicReference | ||||
| @@ -328,6 +333,88 @@ open class ApiCallerService( | |||||
| } | } | ||||
| // ------------------------------------ PUT ------------------------------------ // | // ------------------------------------ PUT ------------------------------------ // | ||||
| /** | |||||
| * Performs a PUT HTTP request with a pre-serialized JSON string body. | |||||
| * Sends the body as explicit UTF-8 bytes. Use Content-Type application/json only (no charset) | |||||
| * so M18 persists bDesc/bDesc_en like when sent from Postman. | |||||
| */ | |||||
| inline fun <reified T : Any> putWithJsonString( | |||||
| urlPath: String, | |||||
| queryParams: MultiValueMap<String, String>, | |||||
| bodyJson: String, | |||||
| customHeaders: Map<String, String>? = null | |||||
| ): Mono<T> { | |||||
| val contentType = MediaType.APPLICATION_JSON | |||||
| val utf8Bytes = bodyJson.toByteArray(StandardCharsets.UTF_8) | |||||
| val bodyBuffer: DataBuffer = DefaultDataBufferFactory.sharedInstance.wrap(utf8Bytes) | |||||
| return webClient.put() | |||||
| .uri { uriBuilder -> uriBuilder.path(urlPath).queryParams(queryParams).build() } | |||||
| .contentType(contentType) | |||||
| .headers { headers -> customHeaders?.forEach { (k, v) -> headers.set(k, v) } } | |||||
| .body(BodyInserters.fromDataBuffers(Mono.just(bodyBuffer))) | |||||
| .retrieve() | |||||
| .bodyToMono(T::class.java) | |||||
| .doOnError { error -> logger.error("PUT error: ${error.message}") } | |||||
| .onErrorResume(WebClientResponseException::class.java) { error -> | |||||
| logger.error("WebClientResponseException: ${error.statusCode} - ${error.statusText}, Body: ${error.responseBodyAsString}") | |||||
| if (error.statusCode == HttpStatusCode.valueOf(400) || error.statusCode == HttpStatusCode.valueOf(401)) { | |||||
| updateToken().flatMap { newToken -> | |||||
| val retryBuffer = DefaultDataBufferFactory.sharedInstance.wrap(utf8Bytes) | |||||
| webClient.put() | |||||
| .uri { uriBuilder -> uriBuilder.path(urlPath).queryParams(queryParams).build() } | |||||
| .header(HttpHeaders.AUTHORIZATION, "Bearer $newToken") | |||||
| .contentType(contentType) | |||||
| .headers { headers -> customHeaders?.forEach { (k, v) -> headers.set(k, v) } } | |||||
| .body(BodyInserters.fromDataBuffers(Mono.just(retryBuffer))) | |||||
| .retrieve() | |||||
| .bodyToMono(T::class.java) | |||||
| } | |||||
| } else { | |||||
| Mono.error(error) | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | |||||
| * Performs a PUT with the JSON payload in the "param" query parameter (URL-encoded) and empty body. | |||||
| * Builds the URI manually so JSON braces are not treated as URI template variables. | |||||
| */ | |||||
| inline fun <reified T : Any> putWithParamJson( | |||||
| urlPath: String, | |||||
| menuCode: String, | |||||
| bodyJson: String, | |||||
| customHeaders: Map<String, String>? = null | |||||
| ): Mono<T> { | |||||
| val encodedParam = URLEncoder.encode(bodyJson, StandardCharsets.UTF_8).replace("+", "%20") | |||||
| val base = m18Config.BASE_URL.removeSuffix("/") | |||||
| val path = urlPath.removePrefix("/") | |||||
| val fullUri = "$base/$path?menuCode=$menuCode¶m=$encodedParam" | |||||
| val uri = URI.create(fullUri) | |||||
| return webClient.put() | |||||
| .uri(uri) | |||||
| .headers { headers -> customHeaders?.forEach { (k, v) -> headers.set(k, v) } } | |||||
| .bodyValue("") | |||||
| .retrieve() | |||||
| .bodyToMono(T::class.java) | |||||
| .doOnError { error -> logger.error("PUT (param) error: ${error.message}") } | |||||
| .onErrorResume(WebClientResponseException::class.java) { error -> | |||||
| logger.error("WebClientResponseException: ${error.statusCode} - ${error.statusText}, Body: ${error.responseBodyAsString}") | |||||
| if (error.statusCode == HttpStatusCode.valueOf(400) || error.statusCode == HttpStatusCode.valueOf(401)) { | |||||
| updateToken().flatMap { newToken -> | |||||
| webClient.put() | |||||
| .uri(uri) | |||||
| .header(HttpHeaders.AUTHORIZATION, "Bearer $newToken") | |||||
| .headers { headers -> customHeaders?.forEach { (k, v) -> headers.set(k, v) } } | |||||
| .bodyValue("") | |||||
| .retrieve() | |||||
| .bodyToMono(T::class.java) | |||||
| } | |||||
| } else { | |||||
| Mono.error(error) | |||||
| } | |||||
| } | |||||
| } | |||||
| /** | /** | ||||
| * Performs a PUT HTTP request to the specified URL path with query parameters and JSON body. | * Performs a PUT HTTP request to the specified URL path with query parameters and JSON body. | ||||
| * | * | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.m18.model | package com.ffii.fpsms.m18.model | ||||
| import com.fasterxml.jackson.annotation.JsonInclude | import com.fasterxml.jackson.annotation.JsonInclude | ||||
| import com.fasterxml.jackson.annotation.JsonProperty | |||||
| /** | /** | ||||
| * Request body for M18 Goods Receipt Note (AN) save API. | * Request body for M18 Goods Receipt Note (AN) save API. | ||||
| @@ -55,5 +56,8 @@ 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 | |||||
| @JsonProperty("bDesc") | |||||
| val itemDesc: String? = null, // itemName (Chinese); Kotlin property renamed so Jackson outputs "bDesc" not "bdesc" | |||||
| @JsonProperty("bDesc_en") | |||||
| val itemDescEn: String? = null, // itemName (English); M18 expects exact key "bDesc_en" | |||||
| ) | ) | ||||
| @@ -1,10 +1,12 @@ | |||||
| package com.ffii.fpsms.m18.service | package com.ffii.fpsms.m18.service | ||||
| import com.fasterxml.jackson.core.JsonGenerator | |||||
| import com.fasterxml.jackson.databind.ObjectMapper | |||||
| import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper | |||||
| import com.ffii.fpsms.api.service.ApiCallerService | import com.ffii.fpsms.api.service.ApiCallerService | ||||
| import com.ffii.fpsms.m18.M18Config | import com.ffii.fpsms.m18.M18Config | ||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteRequest | import com.ffii.fpsms.m18.model.GoodsReceiptNoteRequest | ||||
| import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse | ||||
| import com.google.gson.Gson | |||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| @@ -27,6 +29,12 @@ open class M18GoodsReceiptNoteService( | |||||
| private val M18_SAVE_GOODS_RECEIPT_NOTE_API = "/root/api/save/an" | private val M18_SAVE_GOODS_RECEIPT_NOTE_API = "/root/api/save/an" | ||||
| private val MENU_CODE_AN = "an" | private val MENU_CODE_AN = "an" | ||||
| // Serialize with UTF-8 characters (no \\uXXXX) so M18 persists bDesc/bDesc_en like Postman | |||||
| @Suppress("DEPRECATION") | |||||
| private val grnRequestMapper: ObjectMapper = jacksonObjectMapper().apply { | |||||
| disable(JsonGenerator.Feature.ESCAPE_NON_ASCII) | |||||
| } | |||||
| /** | /** | ||||
| * Creates a goods receipt note in M18. | * Creates a goods receipt note in M18. | ||||
| * | * | ||||
| @@ -58,14 +66,14 @@ open class M18GoodsReceiptNoteService( | |||||
| } | } | ||||
| val queryString = queryParams.entries.flatMap { (k, v) -> v.map { value -> "$k=$value" } }.joinToString("&") | val queryString = queryParams.entries.flatMap { (k, v) -> v.map { value -> "$k=$value" } }.joinToString("&") | ||||
| val fullUrl = "${m18Config.BASE_URL}$M18_SAVE_GOODS_RECEIPT_NOTE_API?$queryString" | val fullUrl = "${m18Config.BASE_URL}$M18_SAVE_GOODS_RECEIPT_NOTE_API?$queryString" | ||||
| val requestJson = Gson().toJson(request) | |||||
| logger.info("[M18 GRN API] call=PUT url=$fullUrl queryParams=$queryParams body=$requestJson") | |||||
| return apiCallerService.put<GoodsReceiptNoteResponse>( | |||||
| val requestJson = grnRequestMapper.writeValueAsString(request) | |||||
| logger.info("[M18 GRN API] call=PUT url=$fullUrl body=$requestJson") | |||||
| return apiCallerService.putWithJsonString<GoodsReceiptNoteResponse>( | |||||
| urlPath = M18_SAVE_GOODS_RECEIPT_NOTE_API, | urlPath = M18_SAVE_GOODS_RECEIPT_NOTE_API, | ||||
| queryParams = queryParams, | queryParams = queryParams, | ||||
| body = request, | |||||
| bodyJson = requestJson, | |||||
| ).doOnSuccess { response -> | ).doOnSuccess { response -> | ||||
| val responseJson = Gson().toJson(response) | |||||
| val responseJson = grnRequestMapper.writeValueAsString(response) | |||||
| logger.info("[M18 GRN API] response (all): $responseJson") | logger.info("[M18 GRN API] response (all): $responseJson") | ||||
| if (response.status) { | if (response.status) { | ||||
| logger.info("Goods receipt note created in M18. recordId=${response.recordId}") | logger.info("Goods receipt note created in M18. recordId=${response.recordId}") | ||||
| @@ -141,7 +141,7 @@ open class SchedulerService( | |||||
| else try { LocalDate.parse(s) } catch (e: Exception) { 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. */ | |||||
| /** Post completed DN and create M18 GRN at 00:01 daily; processes all POs with receipt date = yesterday. Set scheduler.postCompletedDnGrn.enabled=false to disable. */ | |||||
| fun schedulePostCompletedDnGrn() { | fun schedulePostCompletedDnGrn() { | ||||
| if (!postCompletedDnGrnEnabled) { | if (!postCompletedDnGrnEnabled) { | ||||
| scheduledPostCompletedDnGrn?.cancel(false) | scheduledPostCompletedDnGrn?.cancel(false) | ||||
| @@ -149,8 +149,12 @@ open class SchedulerService( | |||||
| logger.info("PostCompletedDn GRN scheduler disabled (scheduler.postCompletedDnGrn.enabled=false)") | logger.info("PostCompletedDn GRN scheduler disabled (scheduler.postCompletedDnGrn.enabled=false)") | ||||
| return | 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) }) | |||||
| commonSchedule( | |||||
| scheduledPostCompletedDnGrn, | |||||
| SettingNames.SCHEDULE_POST_COMPLETED_DN_GRN, | |||||
| "0 1 0 * * *", | |||||
| { getPostCompletedDnAndProcessGrn(receiptDate = getPostCompletedDnGrnReceiptDate()) } | |||||
| ) | |||||
| } | } | ||||
| // Function for schedule | // Function for schedule | ||||
| @@ -323,7 +327,7 @@ open class SchedulerService( | |||||
| open fun getPostCompletedDnAndProcessGrn( | open fun getPostCompletedDnAndProcessGrn( | ||||
| receiptDate: java.time.LocalDate? = null, | receiptDate: java.time.LocalDate? = null, | ||||
| skipFirst: Int = 0, | skipFirst: Int = 0, | ||||
| limitToFirst: Int? = 1, | |||||
| limitToFirst: Int? = null, | |||||
| ) { | ) { | ||||
| 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) | ||||
| @@ -59,7 +59,7 @@ class SchedulerController( | |||||
| fun triggerPostCompletedDnGrn( | fun triggerPostCompletedDnGrn( | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | ||||
| @RequestParam(required = false, defaultValue = "0") skipFirst: Int = 0, | @RequestParam(required = false, defaultValue = "0") skipFirst: Int = 0, | ||||
| @RequestParam(required = false) limitToFirst: Int? = 1, | |||||
| @RequestParam(required = false) limitToFirst: Int? = null, | |||||
| ): String { | ): String { | ||||
| schedulerService.getPostCompletedDnAndProcessGrn(receiptDate = receiptDate, skipFirst = skipFirst, limitToFirst = limitToFirst) | schedulerService.getPostCompletedDnAndProcessGrn(receiptDate = receiptDate, skipFirst = skipFirst, limitToFirst = limitToFirst) | ||||
| return "Post completed DN and process GRN triggered" | return "Post completed DN and process GRN triggered" | ||||
| @@ -57,6 +57,17 @@ fun searchStockInLines( | |||||
| 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? | ||||
| /** Loads stock-in lines with item and purchaseOrderLine.item so bDesc/bDesc_en (item name) are available for M18 GRN. */ | |||||
| @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 | |||||
| ORDER BY sil.id | |||||
| """) | |||||
| fun findAllByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): 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 | ||||
| @@ -47,7 +47,8 @@ open class SearchCompletedDnService( | |||||
| sil.demandQty as demandQty, | sil.demandQty as demandQty, | ||||
| sil.acceptedQty as acceptedQty, | sil.acceptedQty as acceptedQty, | ||||
| sil.receiptDate as receiptDate, | sil.receiptDate as receiptDate, | ||||
| CASE WHEN sil.dnNo = 'DN00000' THEN '' ELSE sil.dnNo END as dnNo | |||||
| -- CASE WHEN sil.dnNo = 'DN00000' THEN '' ELSE sil.dnNo END as dnNo | |||||
| sil.dnNo | |||||
| FROM stock_in_line sil | FROM stock_in_line sil | ||||
| LEFT JOIN items it ON sil.itemId = it.id | LEFT JOIN items it ON sil.itemId = it.id | ||||
| WHERE sil.receiptDate IS NOT NULL | WHERE sil.receiptDate IS NOT NULL | ||||
| @@ -81,14 +82,14 @@ 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 Purchase Order, with the PO lines it received (acceptedQty as ant qty). | ||||
| * @param receiptDate Default: yesterday | * @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. | |||||
| * @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. | |||||
| */ | */ | ||||
| @Transactional | @Transactional | ||||
| open fun postCompletedDnAndProcessGrn( | open fun postCompletedDnAndProcessGrn( | ||||
| receiptDate: LocalDate = LocalDate.now().minusDays(2), | |||||
| receiptDate: LocalDate = LocalDate.now().minusDays(1), | |||||
| skipFirst: Int = 0, | skipFirst: Int = 0, | ||||
| limitToFirst: Int? = 1, | |||||
| limitToFirst: Int? = null, | |||||
| ): Int { | ): Int { | ||||
| val lines = searchCompletedDn(receiptDate) | val lines = searchCompletedDn(receiptDate) | ||||
| val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } | ||||
| @@ -426,7 +426,8 @@ open class StockInLineService( | |||||
| else -> 1 | else -> 1 | ||||
| } | } | ||||
| val firstLine = stockInLines.firstOrNull() | val firstLine = stockInLines.firstOrNull() | ||||
| val doNo = (firstLine?.dnNo?.takeIf { it != "DN00000" } ?: "").trim() | |||||
| // Use DN number as-is (including \"DN00000\"); only trim whitespace | |||||
| val doNo = (firstLine?.dnNo ?: "").trim() | |||||
| val tDate = firstLine?.receiptDate?.toLocalDate()?.format(DateTimeFormatter.ofPattern("MM/dd/yyyy")) | val tDate = firstLine?.receiptDate?.toLocalDate()?.format(DateTimeFormatter.ofPattern("MM/dd/yyyy")) | ||||
| ?: LocalDate.now().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 | // Group by POL first for udfpartiallyreceived: true = any POL short (accepted < ordered), false = all fully received | ||||
| @@ -460,11 +461,12 @@ open class StockInLineService( | |||||
| val pol = sil.purchaseOrderLine!! | val pol = sil.purchaseOrderLine!! | ||||
| val totalQty = silList.sumOf { it.acceptedQty ?: BigDecimal.ZERO } | 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() | ||||
| val itemName = (sil.item?.name ?: pol.item?.name).orEmpty() // always non-null for M18 bDesc/bDesc_en | |||||
| GoodsReceiptNoteAntValue( | GoodsReceiptNoteAntValue( | ||||
| sourceType = "po", | sourceType = "po", | ||||
| sourceId = sourceId, | sourceId = sourceId, | ||||
| sourceLot = pol.m18Lot ?: "", | sourceLot = pol.m18Lot ?: "", | ||||
| proId = (sil.item?.m18Id ?: 0L).toInt(), | |||||
| proId = (sil.item?.m18Id ?: pol.item?.m18Id ?: 0L).toInt(), | |||||
| locId = 155, | locId = 155, | ||||
| unitId = unitIdFromDataLog ?: (pol.uom?.m18Id ?: 0L).toInt(), | unitId = unitIdFromDataLog ?: (pol.uom?.m18Id ?: 0L).toInt(), | ||||
| qty = totalQty.toDouble(), | qty = totalQty.toDouble(), | ||||
| @@ -476,7 +478,8 @@ open class StockInLineService( | |||||
| ), | ), | ||||
| beId = beId, | beId = beId, | ||||
| flowTypeId = flowTypeId, | flowTypeId = flowTypeId, | ||||
| bDesc = sil.item?.name, | |||||
| itemDesc = itemName, | |||||
| itemDescEn = itemName, | |||||
| ) | ) | ||||
| } | } | ||||
| val ant = GoodsReceiptNoteAnt(values = antValues) | val ant = GoodsReceiptNoteAnt(values = antValues) | ||||
| @@ -536,7 +539,7 @@ open class StockInLineService( | |||||
| logger.info("[updatePurchaseOrderStatus] savedPo id=${savedPo.id}, status=${savedPo.status}") | logger.info("[updatePurchaseOrderStatus] savedPo id=${savedPo.id}, status=${savedPo.status}") | ||||
| // TODO: For test only - normally check savedPo.status == PurchaseOrderStatus.COMPLETED and use only COMPLETE lines | // TODO: For test only - normally check savedPo.status == PurchaseOrderStatus.COMPLETED and use only COMPLETE lines | ||||
| try { | try { | ||||
| val allLines = stockInLineRepository.findAllByPurchaseOrderIdAndDeletedFalse(savedPo.id!!).orElse(emptyList()) | |||||
| val allLines = stockInLineRepository.findAllByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!) | |||||
| val linesForGrn = allLines // TODO test: use all lines; normally .filter { it.status == StockInLineStatus.COMPLETE.status } | val linesForGrn = allLines // TODO test: use all lines; normally .filter { it.status == StockInLineStatus.COMPLETE.status } | ||||
| 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}") | ||||
| @@ -9,12 +9,12 @@ 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. | |||||
| # PostCompletedDn GRN: runs daily at 00:01, processes all POs with receipt date = yesterday. | |||||
| # Set enabled: false to disable. Optional receiptDate: "yyyy-MM-dd" overrides for testing only. | |||||
| scheduler: | scheduler: | ||||
| postCompletedDnGrn: | postCompletedDnGrn: | ||||
| enabled: false | |||||
| receiptDate: 2026-03-07 | |||||
| enabled: true | |||||
| # receiptDate: # leave unset for production (uses yesterday) | |||||
| spring: | spring: | ||||
| servlet: | servlet: | ||||