| @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { | |||||
| registry.addMapping("/**") | registry.addMapping("/**") | ||||
| .allowedHeaders("*") | .allowedHeaders("*") | ||||
| .allowedOrigins("*") | .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"); | .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); | ||||
| } | } | ||||
| @@ -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.service.PurchaseOrderService | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest | import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderRequest | 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 com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| @@ -45,12 +44,18 @@ open class M18PurchaseOrderService( | |||||
| val m18MasterDataService: M18MasterDataService, | val m18MasterDataService: M18MasterDataService, | ||||
| val settingsService: SettingsService, | 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 commonUtils = CommonUtils() | ||||
| val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| val lastModifyDateStart = "2025-05-14 14:00:00" | val lastModifyDateStart = "2025-05-14 14:00:00" | ||||
| val lastModifyDateEnd = "2025-05-14 14:30: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 = | // val lastModifyDateConds = | ||||
| // "lastModifyDate=largerOrEqual=${lastModifyDateStart}=and=lastModifyDate=lessOrEqual=${lastModifyDateEnd}" | // "lastModifyDate=largerOrEqual=${lastModifyDateStart}=and=lastModifyDate=lessOrEqual=${lastModifyDateEnd}" | ||||
| // val lastModifyDate = LocalDateTime.now().minusMinutes(30) | // 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 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( | 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( | 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( | private fun saveFromPoListSearch( | ||||
| query: String, | query: String, | ||||
| poListResponse: M18PurchaseOrderListResponse?, | poListResponse: M18PurchaseOrderListResponse?, | ||||
| @@ -318,25 +321,6 @@ open class M18PurchaseOrderService( | |||||
| return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request) | 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? { | private fun searchPurchaseOrdersByConds(conds: String): M18PurchaseOrderListResponse? { | ||||
| val searchRequest = M18PurchaseOrderListRequest( | val searchRequest = M18PurchaseOrderListRequest( | ||||
| stSearch = "po", | stSearch = "po", | ||||
| @@ -752,4 +736,15 @@ open class M18PurchaseOrderService( | |||||
| query = purchaseOrdersWithType?.query ?: "" | 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, | |||||
| ) | |||||
| @@ -53,9 +53,9 @@ public abstract class SettingNames { | |||||
| public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | 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. | * 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 = | public static final String M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED = | ||||
| "M18.po.stockIn.refreshIfNewer.enabled"; | "M18.po.stockIn.refreshIfNewer.enabled"; | ||||
| @@ -364,6 +364,20 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| return purchaseOrderRepository.findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code) | 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) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse { | open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse { | ||||
| @@ -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.PurchaseOrder | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderDataClass | import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderDataClass | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderInfo | 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.service.PurchaseOrderService | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.PagingRequest | import com.ffii.fpsms.modules.purchaseOrder.web.model.PagingRequest | ||||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | 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.ExportQrCodeRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest | import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest | ||||
| import jakarta.servlet.http.HttpServletRequest | import jakarta.servlet.http.HttpServletRequest | ||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import net.sf.jasperreports.engine.JasperExportManager | import net.sf.jasperreports.engine.JasperExportManager | ||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||
| @@ -23,15 +25,20 @@ import org.springframework.data.domain.PageRequest | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import java.io.File | import java.io.File | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.PurchaseOrderSummary | import com.ffii.fpsms.modules.purchaseOrder.web.model.PurchaseOrderSummary | ||||
| import org.slf4j.LoggerFactory | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/po") | @RequestMapping("/po") | ||||
| class PurchaseOrderController( | class PurchaseOrderController( | ||||
| private val purchaseOrderService: PurchaseOrderService, | 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") | @GetMapping("/list") | ||||
| fun getPoList( | fun getPoList( | ||||
| request: HttpServletRequest | |||||
| request: HttpServletRequest, | |||||
| response: HttpServletResponse, | |||||
| // @RequestParam(required = false) pageNum: Int, | // @RequestParam(required = false) pageNum: Int, | ||||
| // @RequestParam(required = false) pageSize: Int | // @RequestParam(required = false) pageSize: Int | ||||
| ): RecordsRes<PurchaseOrderDataClass> { | ): RecordsRes<PurchaseOrderDataClass> { | ||||
| @@ -50,6 +57,17 @@ class PurchaseOrderController( | |||||
| // println(criteriaArgs) | // println(criteriaArgs) | ||||
| val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10 | val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10 | ||||
| val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 | 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) | val total = purchaseOrderService.getPoListTotalCount(criteriaArgs) | ||||
| if (total == 0) { | if (total == 0) { | ||||
| @@ -30,6 +30,18 @@ public class SettingsService extends AbstractIdEntityService<Settings, Long, Set | |||||
| return this.repository.findByName(name); | 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) { | public List<Settings> findAllByCategory(String category) { | ||||
| return this.repository.findAllByCategory(category); | return this.repository.findAllByCategory(category); | ||||
| } | } | ||||
| @@ -67,12 +67,15 @@ interface StockInLineInfo { | |||||
| interface PutAwayLineForSil { | interface PutAwayLineForSil { | ||||
| val id: Long?; | val id: Long?; | ||||
| /** Stock qty (inventory lot line inQty) */ | |||||
| @get:Value("#{target.inQty}") | |||||
| /** | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 | |||||
| * Origin putaway qty from inventory_lot.stockInLine (lot line → lot → SIL). | |||||
| * Do not use inventory_lot_line.inQty: later ADJ/TKE is added on the same line. | |||||
| */ | |||||
| @get:Value("#{target.inventoryLot?.stockInLine?.acceptedQty}") | |||||
| val stockQty: BigDecimal?; | val stockQty: BigDecimal?; | ||||
| @get:Value("#{target.inQty " + | |||||
| "* ((target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD))" + | |||||
| "/ ((target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioD))}") | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */ | |||||
| @get:Value("#{target.inventoryLot?.stockInLine?.acceptedQtyM18 ?: target.inventoryLot?.stockInLine?.acceptedQty}") | |||||
| val qty: BigDecimal?; | val qty: BigDecimal?; | ||||
| @get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}") | @get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}") | ||||
| val warehouse: String?; | val warehouse: String?; | ||||