| @@ -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 <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. | |||
| * | |||
| @@ -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" | |||
| ) | |||
| @@ -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<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, | |||
| 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}") | |||
| @@ -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) | |||
| @@ -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" | |||
| @@ -57,6 +57,17 @@ fun searchStockInLines( | |||
| fun findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<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(""" | |||
| SELECT sil FROM StockInLine sil | |||
| WHERE sil.receiptDate IS NOT NULL | |||
| @@ -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 } | |||
| @@ -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}") | |||
| @@ -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: | |||