diff --git a/src/main/java/com/ffii/fpsms/api/service/ApiCallerService.kt b/src/main/java/com/ffii/fpsms/api/service/ApiCallerService.kt index 73565aa..cf275ca 100644 --- a/src/main/java/com/ffii/fpsms/api/service/ApiCallerService.kt +++ b/src/main/java/com/ffii/fpsms/api/service/ApiCallerService.kt @@ -21,12 +21,17 @@ import org.springframework.context.annotation.Lazy import org.springframework.http.HttpMethod import org.springframework.http.HttpRequest 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 java.net.URLEncoder +import java.nio.charset.StandardCharsets import org.springframework.web.reactive.function.client.ClientRequest import org.springframework.web.reactive.function.client.ExchangeFilterFunction import org.springframework.web.reactive.function.client.WebClientResponseException import org.springframework.web.service.invoker.HttpRequestValues import reactor.util.retry.Retry +import java.net.URI import java.time.Duration import java.util.concurrent.atomic.AtomicReference @@ -328,6 +333,88 @@ open class ApiCallerService( } // ------------------------------------ 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 putWithJsonString( + urlPath: String, + queryParams: MultiValueMap, + bodyJson: String, + customHeaders: Map? = null + ): Mono { + 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 putWithParamJson( + urlPath: String, + menuCode: String, + bodyJson: String, + customHeaders: Map? = null + ): Mono { + 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. * diff --git a/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt b/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt index 686ecc1..d2cdf61 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.m18.model import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonProperty /** * Request body for M18 Goods Receipt Note (AN) save API. @@ -55,5 +56,8 @@ data class GoodsReceiptNoteAntValue( val amt: Number, val beId: 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" ) diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt index 4cf2a15..83f7132 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt @@ -1,10 +1,12 @@ 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.m18.M18Config import com.ffii.fpsms.m18.model.GoodsReceiptNoteRequest import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse -import com.google.gson.Gson import org.slf4j.Logger import org.slf4j.LoggerFactory 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 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. * @@ -58,14 +66,14 @@ open class M18GoodsReceiptNoteService( } 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 requestJson = Gson().toJson(request) - logger.info("[M18 GRN API] call=PUT url=$fullUrl queryParams=$queryParams body=$requestJson") - return apiCallerService.put( + val requestJson = grnRequestMapper.writeValueAsString(request) + logger.info("[M18 GRN API] call=PUT url=$fullUrl body=$requestJson") + return apiCallerService.putWithJsonString( urlPath = M18_SAVE_GOODS_RECEIPT_NOTE_API, queryParams = queryParams, - body = request, + bodyJson = requestJson, ).doOnSuccess { response -> - val responseJson = Gson().toJson(response) + val responseJson = grnRequestMapper.writeValueAsString(response) logger.info("[M18 GRN API] response (all): $responseJson") if (response.status) { logger.info("Goods receipt note created in M18. recordId=${response.recordId}") diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index ed23967..9587754 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -141,7 +141,7 @@ open class SchedulerService( 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() { if (!postCompletedDnGrnEnabled) { scheduledPostCompletedDnGrn?.cancel(false) @@ -149,8 +149,12 @@ open class SchedulerService( logger.info("PostCompletedDn GRN scheduler disabled (scheduler.postCompletedDnGrn.enabled=false)") 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 @@ -323,7 +327,7 @@ open class SchedulerService( open fun getPostCompletedDnAndProcessGrn( receiptDate: java.time.LocalDate? = null, skipFirst: Int = 0, - limitToFirst: Int? = 1, + limitToFirst: Int? = null, ) { logger.info("Scheduler - Post completed DN and process GRN") val date = receiptDate ?: java.time.LocalDate.now().minusDays(1) diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt index f756b05..edc5061 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt @@ -59,7 +59,7 @@ class SchedulerController( fun triggerPostCompletedDnGrn( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, @RequestParam(required = false, defaultValue = "0") skipFirst: Int = 0, - @RequestParam(required = false) limitToFirst: Int? = 1, + @RequestParam(required = false) limitToFirst: Int? = null, ): String { schedulerService.getPostCompletedDnAndProcessGrn(receiptDate = receiptDate, skipFirst = skipFirst, limitToFirst = limitToFirst) return "Post completed DN and process GRN triggered" 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 162f72f..f595a2c 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 @@ -57,6 +57,17 @@ fun searchStockInLines( fun findAllByItemIdInAndDeletedFalse(itemIds: List): List 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 + @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 502643a..ece3e97 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 @@ -47,7 +47,8 @@ open class SearchCompletedDnService( sil.demandQty as demandQty, sil.acceptedQty as acceptedQty, 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 LEFT JOIN items it ON sil.itemId = it.id 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. * Triggered by scheduler. One GRN per Purchase Order, with the PO lines it received (acceptedQty as ant qty). * @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 open fun postCompletedDnAndProcessGrn( - receiptDate: LocalDate = LocalDate.now().minusDays(2), + receiptDate: LocalDate = LocalDate.now().minusDays(1), skipFirst: Int = 0, - limitToFirst: Int? = 1, + limitToFirst: Int? = null, ): Int { val lines = searchCompletedDn(receiptDate) val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } 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 23121b2..c633849 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 @@ -426,7 +426,8 @@ open class StockInLineService( else -> 1 } 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")) ?: LocalDate.now().format(DateTimeFormatter.ofPattern("MM/dd/yyyy")) // 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 totalQty = silList.sumOf { it.acceptedQty ?: BigDecimal.ZERO } 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( sourceType = "po", sourceId = sourceId, sourceLot = pol.m18Lot ?: "", - proId = (sil.item?.m18Id ?: 0L).toInt(), + proId = (sil.item?.m18Id ?: pol.item?.m18Id ?: 0L).toInt(), locId = 155, unitId = unitIdFromDataLog ?: (pol.uom?.m18Id ?: 0L).toInt(), qty = totalQty.toDouble(), @@ -476,7 +478,8 @@ open class StockInLineService( ), beId = beId, flowTypeId = flowTypeId, - bDesc = sil.item?.name, + itemDesc = itemName, + itemDescEn = itemName, ) } val ant = GoodsReceiptNoteAnt(values = antValues) @@ -536,7 +539,7 @@ open class StockInLineService( 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 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 } if (linesForGrn.isEmpty()) { logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index cdb1c71..b9d423a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,12 +9,12 @@ server: error: 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: postCompletedDnGrn: - enabled: false - receiptDate: 2026-03-07 + enabled: true + # receiptDate: # leave unset for production (uses yesterday) spring: servlet: