Browse Source

added a sync for stock in PO, when entered poNum and status = pending, and also the database settings M18.po.stockIn.refreshIfNewer.enabled = true (liquidbase default is false), then ask m18 to sync with lastModified after MTMS PO modified datetime

production
Fai Luk 2 days ago
parent
commit
a81c02be3f
6 changed files with 114 additions and 75 deletions
  1. +1
    -1
      src/main/java/com/ffii/fpsms/config/WebConfig.java
  2. +65
    -70
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  3. +2
    -2
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  4. +14
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt
  5. +20
    -2
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt
  6. +12
    -0
      src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java

+ 1
- 1
src/main/java/com/ffii/fpsms/config/WebConfig.java View File

@@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer {
registry.addMapping("/**")
.allowedHeaders("*")
.allowedOrigins("*")
.exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry")
.exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry", "X-M18-Po-Refresh")
.allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS");

}


+ 65
- 70
src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt View File

@@ -20,7 +20,6 @@ import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderLineService
import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService
import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest
import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderRequest
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService
import org.slf4j.Logger
import org.slf4j.LoggerFactory
@@ -45,12 +44,18 @@ open class M18PurchaseOrderService(
val m18MasterDataService: M18MasterDataService,
val settingsService: SettingsService,
) {
companion object {
fun isStockInLookupPoCode(code: String?): Boolean {
val trimmed = code?.trim().orEmpty()
return trimmed.length > 14 && (trimmed.startsWith("PP") || trimmed.startsWith("PF"))
}
}

val commonUtils = CommonUtils()
val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)

val lastModifyDateStart = "2025-05-14 14:00:00"
val lastModifyDateEnd = "2025-05-14 14:30:00"
private val M18_LAST_MODIFY_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
// val lastModifyDateConds =
// "lastModifyDate=largerOrEqual=${lastModifyDateStart}=and=lastModifyDate=lessOrEqual=${lastModifyDateEnd}"
// val lastModifyDate = LocalDateTime.now().minusMinutes(30)
@@ -237,67 +242,65 @@ open class M18PurchaseOrderService(
}

/**
* Stock-in `/po` lookup: create if missing; for a local **pending** PO, pull from M18 only when
* M18 list `lastModifyDate` is after [PurchaseOrder.modified] (last MTMS header write, usually last sync).
* Receiving / completed POs are not refreshed.
* Stock-in `/po` lookup.
* - Missing local PO: always sync from M18 (same as [savePurchaseOrderByCode]).
* - Local pending + setting [SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED] true:
* sync from M18 (overwrite pending). Receiving / completed are not refreshed.
* - Local PO + setting not true: do not call M18.
*/
open fun savePurchaseOrderByCodeIfNewer(code: String): SyncResult {
open fun savePurchaseOrderByCodeIfNewer(code: String): SyncResult =
refreshForStockInLookup(code).syncResult

open fun refreshForStockInLookup(code: String): StockInPoRefreshOutcome {
val trimmed = code.trim()
val localPo = purchaseOrderService.findByCode(trimmed)
if (localPo != null && !isStockInRefreshIfNewerEnabled()) {
logger.info(
"Purchase Order: Skipping if-newer M18 sync — setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} " +
"is not true. code=$trimmed"
)
return SyncResult(
totalProcessed = 0,
totalSuccess = 0,
totalFail = 0,
query = "skipped (ifNewer): setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} is not true"
)
}
if (localPo != null && localPo.status != PurchaseOrderStatus.PENDING) {
val enabled = isStockInRefreshIfNewerEnabled()
val localStatus = purchaseOrderService.findActiveStatusByCode(trimmed)
logger.info(
"Purchase Order: stock-in ifNewer code={} settingEnabled={} localStatus={}",
trimmed,
enabled,
localStatus ?: "<none>",
)
if (localStatus != null && !enabled) {
logger.info(
"Purchase Order: Skipping if-newer M18 sync — local PO id=${localPo.id} code=${localPo.code} " +
"status=${localPo.status?.value} (only pending may be refreshed)."
"Purchase Order: Skipping M18 sync — setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} " +
"is not true. code=$trimmed status=$localStatus"
)
return SyncResult(
totalProcessed = 0,
totalSuccess = 0,
totalFail = 0,
query = "skipped (ifNewer): local PO not pending: $trimmed status=${localPo.status?.value}"
return StockInPoRefreshOutcome(
action = StockInPoRefreshAction.SKIPPED_DISABLED,
syncResult = SyncResult(
totalProcessed = 0,
totalSuccess = 0,
totalFail = 0,
query = "skipped (ifNewer): setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} is not true"
),
)
}
if (localPo == null) {
return savePurchaseOrderByCode(trimmed)
}

val conds = "(code=equal=$trimmed)"
val poListResponse = searchPurchaseOrdersByConds(conds)
val m18Row = poListResponse?.values?.firstOrNull()
val localModified = localPo.modified ?: localPo.created
val m18LastModify = parseM18LastModifyDate(m18Row?.lastModifyDate)
if (m18Row == null || m18LastModify == null || localModified == null || !m18LastModify.isAfter(localModified)) {
if (localStatus != null &&
!PurchaseOrderStatus.PENDING.value.equals(localStatus, ignoreCase = true)
) {
logger.info(
"Purchase Order: Skipping if-newer M18 sync — M18 lastModifyDate not after " +
"purchase_order.modified. code=$trimmed localModified=$localModified " +
"m18LastModifyRaw=${m18Row?.lastModifyDate} m18LastModify=$m18LastModify"
"Purchase Order: Skipping M18 sync — local PO code=$trimmed status=$localStatus (only pending may be refreshed)."
)
return SyncResult(
totalProcessed = 0,
totalSuccess = 0,
totalFail = 0,
query = "skipped (ifNewer): M18 lastModifyDate not after purchase_order.modified: " +
"code=$trimmed localModified=$localModified m18LastModify=${m18Row?.lastModifyDate}"
return StockInPoRefreshOutcome(
action = StockInPoRefreshAction.SKIPPED_NOT_PENDING,
syncResult = SyncResult(
totalProcessed = 0,
totalSuccess = 0,
totalFail = 0,
query = "skipped (ifNewer): local PO not pending: $trimmed status=$localStatus"
),
)
}
logger.info(
"Purchase Order: M18 lastModifyDate after purchase_order.modified — syncing. " +
"code=$trimmed localModified=$localModified m18LastModify=$m18LastModify"
return StockInPoRefreshOutcome(
action = StockInPoRefreshAction.SYNCED,
syncResult = savePurchaseOrderByCode(trimmed),
)
return saveFromPoListSearch(conds, poListResponse)
}

private fun isStockInRefreshIfNewerEnabled(): Boolean =
settingsService.isBooleanTrueFromDb(SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED)

private fun saveFromPoListSearch(
query: String,
poListResponse: M18PurchaseOrderListResponse?,
@@ -318,25 +321,6 @@ open class M18PurchaseOrderService(
return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request)
}

private fun isStockInRefreshIfNewerEnabled(): Boolean =
settingsService.findByName(SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED)
.map { Settings.VALUE_BOOLEAN_TRUE.equals(it.value?.trim(), ignoreCase = true) }
.orElse(false)

private fun parseM18LastModifyDate(raw: String?): LocalDateTime? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
return try {
LocalDateTime.parse(value, M18_LAST_MODIFY_FORMATTER)
} catch (_: Exception) {
try {
LocalDateTime.parse(value)
} catch (_: Exception) {
value.toLongOrNull()?.let { commonUtils.timestampToLocalDateTime(it) }
}
}
}

private fun searchPurchaseOrdersByConds(conds: String): M18PurchaseOrderListResponse? {
val searchRequest = M18PurchaseOrderListRequest(
stSearch = "po",
@@ -752,4 +736,15 @@ open class M18PurchaseOrderService(
query = purchaseOrdersWithType?.query ?: ""
)
}
}
}

enum class StockInPoRefreshAction(val headerValue: String) {
SKIPPED_DISABLED("skipped_disabled"),
SKIPPED_NOT_PENDING("skipped_not_pending"),
SYNCED("synced"),
}

data class StockInPoRefreshOutcome(
val action: StockInPoRefreshAction,
val syncResult: SyncResult,
)

+ 2
- 2
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java View File

@@ -53,9 +53,9 @@ public abstract class SettingNames {
public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled";

/**
* When "true", stock-in {@code /po} lookup for an existing pending PO asks M18 whether
* {@code lastModifyDate} is after {@code purchase_order.modified} and re-syncs if newer.
* When "true", stock-in {@code /po} lookup for an existing pending PO re-syncs from M18.
* Read from DB on each request (no server restart). Missing POs still auto-sync.
* Receiving / completed POs are never overwritten.
*/
public static final String M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED =
"M18.po.stockIn.refreshIfNewer.enabled";


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt View File

@@ -364,6 +364,20 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
return purchaseOrderRepository.findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code)
}

/** JDBC lookup so stock-in skip does not depend on JPA finding the entity. */
open fun findActiveStatusByCode(code: String): String? {
val status = jdbcDao.queryForString(
"""
SELECT status FROM purchase_order
WHERE deleted = 0 AND code = :code
ORDER BY modified DESC
LIMIT 1
""".trimIndent(),
mapOf("code" to code),
)
return status.takeIf { it.isNotBlank() }
}

@Throws(IOException::class)
@Transactional
open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse {


+ 20
- 2
src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt View File

@@ -10,12 +10,14 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder
import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderDataClass
import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderInfo
import com.ffii.fpsms.m18.service.M18PurchaseOrderService
import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService
import com.ffii.fpsms.modules.purchaseOrder.web.model.PagingRequest
import com.ffii.fpsms.modules.stock.service.StockInLineService
import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest
import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperPrint
import org.springframework.data.domain.Page
@@ -23,15 +25,20 @@ import org.springframework.data.domain.PageRequest
import org.springframework.web.bind.annotation.*
import java.io.File
import com.ffii.fpsms.modules.purchaseOrder.web.model.PurchaseOrderSummary
import org.slf4j.LoggerFactory

@RestController
@RequestMapping("/po")
class PurchaseOrderController(
private val purchaseOrderService: PurchaseOrderService,
private val stockInLineService: StockInLineService
private val stockInLineService: StockInLineService,
private val m18PurchaseOrderService: M18PurchaseOrderService,
) {
private val logger = LoggerFactory.getLogger(PurchaseOrderController::class.java)
@GetMapping("/list")
fun getPoList(
request: HttpServletRequest
request: HttpServletRequest,
response: HttpServletResponse,
// @RequestParam(required = false) pageNum: Int,
// @RequestParam(required = false) pageSize: Int
): RecordsRes<PurchaseOrderDataClass> {
@@ -50,6 +57,17 @@ class PurchaseOrderController(
// println(criteriaArgs)
val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10
val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1
val rawCode = request.getParameter("code")?.trim()
val refreshFromM18 = request.getParameter("refreshFromM18")?.equals("true", ignoreCase = true) == true
if (refreshFromM18 && M18PurchaseOrderService.isStockInLookupPoCode(rawCode)) {
try {
logger.info("Stock-in /po/list: M18 refresh for code={}", rawCode)
val outcome = m18PurchaseOrderService.refreshForStockInLookup(rawCode!!)
response.setHeader("X-M18-Po-Refresh", outcome.action.headerValue)
} catch (e: Exception) {
logger.error("Stock-in /po/list: M18 refresh failed for code={}", rawCode, e)
}
}

val total = purchaseOrderService.getPoListTotalCount(criteriaArgs)
if (total == 0) {


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java View File

@@ -30,6 +30,18 @@ public class SettingsService extends AbstractIdEntityService<Settings, Long, Set
return this.repository.findByName(name);
}

/**
* Reads {@code settings.value} with JDBC on every call (no JPA persistence-context reuse).
* True only when the trimmed value equals {@link Settings#VALUE_BOOLEAN_TRUE} (ignore case).
*/
public boolean isBooleanTrueFromDb(String name) {
String value = this.jdbcDao.queryForString(
"SELECT value FROM settings WHERE name = :name LIMIT 1",
java.util.Map.of("name", name));
return StringUtils.isNotBlank(value)
&& Settings.VALUE_BOOLEAN_TRUE.equalsIgnoreCase(value.trim());
}

public List<Settings> findAllByCategory(String category) {
return this.repository.findAllByCategory(category);
}


Loading…
Cancel
Save