Ver a proveniência

creating the GRN daily enabled

reset-do-picking-order
[email protected] há 1 semana
ascendente
cometimento
291d039dc6
9 ficheiros alterados com 143 adições e 25 eliminações
  1. +87
    -0
      src/main/java/com/ffii/fpsms/api/service/ApiCallerService.kt
  2. +5
    -1
      src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt
  3. +14
    -6
      src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt
  4. +8
    -4
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  5. +1
    -1
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  6. +11
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt
  7. +6
    -5
      src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt
  8. +7
    -4
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  9. +4
    -4
      src/main/resources/application.yml

+ 87
- 0
src/main/java/com/ffii/fpsms/api/service/ApiCallerService.kt Ver ficheiro

@@ -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&param=$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.
*


+ 5
- 1
src/main/java/com/ffii/fpsms/m18/model/GoodsReceiptNoteRequest.kt Ver ficheiro

@@ -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"
)

+ 14
- 6
src/main/java/com/ffii/fpsms/m18/service/M18GoodsReceiptNoteService.kt Ver ficheiro

@@ -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}")


+ 8
- 4
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Ver ficheiro

@@ -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)


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt Ver ficheiro

@@ -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"


+ 11
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt Ver ficheiro

@@ -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


+ 6
- 5
src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt Ver ficheiro

@@ -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 }


+ 7
- 4
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Ver ficheiro

@@ -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}")


+ 4
- 4
src/main/resources/application.yml Ver ficheiro

@@ -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:


Carregando…
Cancelar
Guardar