diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18Cunit.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18Cunit.kt new file mode 100644 index 0000000..4166044 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18Cunit.kt @@ -0,0 +1,45 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.entity.BaseEntity +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import java.math.BigDecimal + +/** + * M18 `cunit` lines from GET /root/api/read/unit?id=… (data.cunit[]). + * [ratioN] / [ratioD] here are **swapped** vs M18 JSON: DB ratioN ← JSON ratioD, DB ratioD ← JSON ratioN. + */ +@Entity +@Table(name = "m18_cunit") +open class M18Cunit : BaseEntity() { + + /** M18 unit id (same as unit.id / list value id). */ + @NotNull + @Column(name = "m18Id", nullable = false) + open var m18Id: Long = 0L + + /** M18 primary key of the cunit row (JSON `id`). */ + @NotNull + @Column(name = "m18CunitLineId", nullable = false) + open var m18CunitLineId: Long = 0L + + @Column(name = "ratioN", precision = 20, scale = 10) + open var ratioN: BigDecimal? = null + + @Column(name = "ratioD", precision = 20, scale = 10) + open var ratioD: BigDecimal? = null + + @Column(name = "hId") + open var hId: Long? = null + + @Column(name = "unitId") + open var unitId: Long? = null + + @Column(name = "iRev") + open var iRev: Int? = null + + @Column(name = "itemNo", length = 32) + open var itemNo: String? = null +} diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18CunitRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18CunitRepository.kt new file mode 100644 index 0000000..885fa3c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18CunitRepository.kt @@ -0,0 +1,21 @@ +package com.ffii.fpsms.m18.entity + +import com.ffii.core.support.AbstractRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import org.springframework.stereotype.Repository + +@Repository +interface M18CunitRepository : AbstractRepository { + + @Modifying + @Query("DELETE FROM M18Cunit c WHERE c.m18Id = :m18Id") + fun deleteByM18Id(@Param("m18Id") m18Id: Long): Int + + fun findFirstByM18IdAndUnitIdAndDeletedFalse(m18Id: Long, unitId: Long): M18Cunit? + + fun findFirstByM18IdAndDeletedFalseOrderByIdAsc(m18Id: Long): M18Cunit? + + fun countByDeletedFalse(): Long +} diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18MasterDataResponse.kt b/src/main/java/com/ffii/fpsms/m18/model/M18MasterDataResponse.kt index 8edefde..7f21747 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/M18MasterDataResponse.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/M18MasterDataResponse.kt @@ -105,15 +105,20 @@ data class M18UnitListValue ( ) /** Unit Response */ +@JsonIgnoreProperties(ignoreUnknown = true) data class M18UnitResponse ( val data: M18UnitData?, val messages: List? ) +@JsonIgnoreProperties(ignoreUnknown = true) data class M18UnitData ( - val unit: List + val unit: List, + /** Conversion-unit lines; ratios are swapped when persisted to [com.ffii.fpsms.m18.entity.M18Cunit]. */ + val cunit: List? = null, ) +@JsonIgnoreProperties(ignoreUnknown = true) data class M18UnitUnit ( val id: Long, val expiredDate: Long, @@ -124,6 +129,17 @@ data class M18UnitUnit ( val status: String, ) +@JsonIgnoreProperties(ignoreUnknown = true) +data class M18UnitCunit ( + val id: Long, + val hId: Long? = null, + val ratioN: BigDecimal? = null, + val ratioD: BigDecimal? = null, + val unitId: Long? = null, + val iRev: Int? = null, + val itemNo: String? = null, +) + /** Currency List Response */ data class M18CurrencyListResponse ( val values: List?, diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18CunitService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18CunitService.kt new file mode 100644 index 0000000..0fe9587 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/service/M18CunitService.kt @@ -0,0 +1,57 @@ +package com.ffii.fpsms.m18.service + +import com.ffii.fpsms.m18.entity.M18Cunit +import com.ffii.fpsms.m18.entity.M18CunitRepository +import com.ffii.fpsms.m18.model.M18UnitData +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.math.BigDecimal + +/** + * Persists M18 `cunit` lines from GET /root/api/read/unit. + * DB [M18Cunit.ratioN] ← JSON `ratioD`, DB [M18Cunit.ratioD] ← JSON `ratioN`. + */ +@Service +open class M18CunitService( + private val m18CunitRepository: M18CunitRepository, +) { + data class UnitRatios( + val ratioN: BigDecimal?, + val ratioD: BigDecimal?, + ) + + @Transactional + open fun replaceForUnit(m18Id: Long, data: M18UnitData) { + m18CunitRepository.deleteByM18Id(m18Id) + data.cunit.orEmpty().forEach { c -> + val row = M18Cunit().apply { + this.m18Id = m18Id + m18CunitLineId = c.id + ratioN = c.ratioD + ratioD = c.ratioN + hId = c.hId + unitId = c.unitId + iRev = c.iRev + itemNo = c.itemNo?.trim()?.ifBlank { null } + } + m18CunitRepository.save(row) + } + } + + open fun countActiveRows(): Long { + return m18CunitRepository.countByDeletedFalse() + } + + /** + * Resolve unit conversion ratio by M18 unit id from m18_cunit. + * Prefer exact row m18Id+unitId; fallback to first row under m18Id. + */ + open fun resolveRatiosByM18UnitId(unitM18Id: Long): UnitRatios? { + val exact = m18CunitRepository.findFirstByM18IdAndUnitIdAndDeletedFalse(unitM18Id, unitM18Id) + if (exact != null) { + return UnitRatios(ratioN = exact.ratioN, ratioD = exact.ratioD) + } + val fallback = m18CunitRepository.findFirstByM18IdAndDeletedFalseOrderByIdAsc(unitM18Id) + return fallback?.let { UnitRatios(ratioN = it.ratioN, ratioD = it.ratioD) } + } +} diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt index c65d5bf..5609426 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt @@ -33,11 +33,15 @@ open class M18MasterDataService( val itemUomService: ItemUomService, val bomService: BomService, val bomMaterialService: BomMaterialService, + val m18CunitService: M18CunitService, ) { val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) val commonUtils = CommonUtils() val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") + private val one = BigDecimal.ONE + private val ratio2268 = BigDecimal("2268") + private val ratio5 = BigDecimal("5") // M18 Conditions // val lastModifyDate = LocalDate.now().minusDays(1) @@ -61,6 +65,49 @@ open class M18MasterDataService( val M18_LOAD_BOM_API = "${M18_COMMON_LOAD_LINE_API}/${StSearchType.BOM.value}" val M18_LOAD_BUSINESS_UNIT_API = "${M18_COMMON_LOAD_LINE_API}/${StSearchType.BUSINESS_UNIT.value}" // for shop po? + private fun isBothOne(ratioN: BigDecimal?, ratioD: BigDecimal?): Boolean { + return ratioN?.compareTo(one) == 0 && ratioD?.compareTo(one) == 0 + } + + private fun isPair2268And5(ratioN: BigDecimal?, ratioD: BigDecimal?): Boolean { + return ratioN?.compareTo(ratio2268) == 0 && ratioD?.compareTo(ratio5) == 0 + } + + /** + * Problematic ratio cases that should be corrected by m18_cunit: + * 1) uomId=4 and ratioN/ratioD are not both 1 + * 2) uomId=785 and ratioN/ratioD do not contain 453 + */ + private fun shouldSyncRatioFromCunit(unitId: Long, ratioN: BigDecimal?, ratioD: BigDecimal?): Boolean { + val caseUom4 = unitId == 4L && !isBothOne(ratioN, ratioD) + val caseUom785 = unitId == 785L && !isPair2268And5(ratioN, ratioD) + return caseUom4 || caseUom785 + } + + /** + * Same rule as [shouldSyncRatioFromCunit], but based on local uom_conversion.id. + */ + private fun shouldSyncRatioFromCunitByLocalUomId(localUomId: Long?, ratioN: BigDecimal?, ratioD: BigDecimal?): Boolean { + if (localUomId == null) return false + val caseUom4 = localUomId == 4L && !isBothOne(ratioN, ratioD) + val caseUom785 = localUomId == 785L && !isPair2268And5(ratioN, ratioD) + return caseUom4 || caseUom785 + } + + /** + * Safety net: when m18_cunit has no rows, force a full unit sync first so cunit ratios are available. + */ + open fun ensureCunitSeededForAllIfEmpty() { + val cunitRows = m18CunitService.countActiveRows() + if (cunitRows > 0L) return + logger.warn("m18_cunit has 0 rows; triggering full unit sync first to seed cunit data for all units.") + val result = saveUnits(M18CommonRequest()) + logger.warn( + "m18_cunit seed sync finished: processed=${result.totalProcessed}, " + + "success=${result.totalSuccess}, fail=${result.totalFail}" + ) + } + // --------------------------------------------- Common Function --------------------------------------------- /// private inline fun getList( stSearch: String?, @@ -143,6 +190,7 @@ open class M18MasterDataService( open fun saveProduct(id: Long): MessageResponse? { try { + ensureCunitSeededForAllIfEmpty() val itemDetail = getProduct(id) val pro = itemDetail?.data?.pro?.get(0) val price = itemDetail?.data?.price @@ -197,7 +245,23 @@ open class M18MasterDataService( // Update the item uom logger.info("Updating item uom...") + val hasBaseUom4 = price?.any { p -> + p.basicUnit && (uomConversionService.findByM18Id(p.unitId)?.id == 4L) + } == true + val forceCunitForAllRows = hasBaseUom4 && (price?.any { p -> + val localBaseId = uomConversionService.findByM18Id(p.unitId)?.id + shouldSyncRatioFromCunitByLocalUomId(localBaseId, p.ratioN, p.ratioD) + } == true) price?.forEach { + val localUomId = uomConversionService.findByM18Id(it.unitId)?.id + val keepOriginalItemRatio = forceCunitForAllRows + val useUnitRatios = forceCunitForAllRows + val unitRatios = if (useUnitRatios) m18CunitService.resolveRatiosByM18UnitId(it.unitId) else null + logger.info( + "ItemUom ratio apply (single): itemM18Id=$id m18UomId=${it.unitId} localUomId=$localUomId " + + "hasBaseUom4=$hasBaseUom4 forceCunitForAllRows=$forceCunitForAllRows useCunit=$useUnitRatios oldRatioN=${it.ratioN} oldRatioD=${it.ratioD} " + + "cunitRatioN=${unitRatios?.ratioN} cunitRatioD=${unitRatios?.ratioD}" + ) val itemUomRequest = ItemUomRequest( m18UomId = it.unitId, itemId = savedItem.id, @@ -210,8 +274,10 @@ open class M18MasterDataService( currencyId = null, m18Id = it.id, m18LastModifyDate = commonUtils.timestampToLocalDateTime(pro.lastModifyDate), - ratioD = it.ratioD, - ratioN = it.ratioN, + ratioD = if (useUnitRatios) (unitRatios?.ratioD ?: it.ratioD) else it.ratioD, + ratioN = if (useUnitRatios) (unitRatios?.ratioN ?: it.ratioN) else it.ratioN, + itemRatioD = if (keepOriginalItemRatio) it.ratioD else null, + itemRatioN = if (keepOriginalItemRatio) it.ratioN else null, deleted = it.expired ) @@ -236,6 +302,7 @@ open class M18MasterDataService( open fun saveProducts(request: M18CommonRequest): SyncResult { logger.info("--------------------------------------------Start - Saving M18 Products / Materials--------------------------------------------") + ensureCunitSeededForAllIfEmpty() val items = getProducts(request) val exampleProducts = listOf(10946L, 3825L) @@ -310,10 +377,26 @@ open class M18MasterDataService( // Update / create UOMs from M18 logger.info("Updating item uom...") + val hasBaseUom4 = price?.any { p -> + p.basicUnit && (uomConversionService.findByM18Id(p.unitId)?.id == 4L) + } == true + val forceCunitForAllRows = hasBaseUom4 && (price?.any { p -> + val localBaseId = uomConversionService.findByM18Id(p.unitId)?.id + shouldSyncRatioFromCunitByLocalUomId(localBaseId, p.ratioN, p.ratioD) + } == true) price?.forEach { val endMillis = it.endDate val endInstant = Instant.ofEpochMilli(endMillis) val now = Instant.now() + val localUomId = uomConversionService.findByM18Id(it.unitId)?.id + val keepOriginalItemRatio = forceCunitForAllRows + val useUnitRatios = forceCunitForAllRows + val unitRatios = if (useUnitRatios) m18CunitService.resolveRatiosByM18UnitId(it.unitId) else null + logger.info( + "ItemUom ratio apply (bulk): itemM18Id=${item.id} m18UomId=${it.unitId} localUomId=$localUomId " + + "hasBaseUom4=$hasBaseUom4 forceCunitForAllRows=$forceCunitForAllRows useCunit=$useUnitRatios oldRatioN=${it.ratioN} oldRatioD=${it.ratioD} " + + "cunitRatioN=${unitRatios?.ratioN} cunitRatioD=${unitRatios?.ratioD}" + ) val itemUomRequest = ItemUomRequest( m18UomId = it.unitId, @@ -327,8 +410,10 @@ open class M18MasterDataService( currencyId = null, m18Id = it.id, m18LastModifyDate = commonUtils.timestampToLocalDateTime(pro.lastModifyDate), - ratioD = it.ratioD, - ratioN = it.ratioN, + ratioD = if (useUnitRatios) (unitRatios?.ratioD ?: it.ratioD) else it.ratioD, + ratioN = if (useUnitRatios) (unitRatios?.ratioN ?: it.ratioN) else it.ratioN, + itemRatioD = if (keepOriginalItemRatio) it.ratioD else null, + itemRatioN = if (keepOriginalItemRatio) it.ratioN else null, deleted = endInstant.isBefore(now) ) @@ -368,6 +453,49 @@ open class M18MasterDataService( ) } + /** + * Targeted re-sync for locally identified problematic item_uom ratios: + * - uomId = 4 and ratioN != 1 + * - uomId = 785 and ratioN != 453 + * + * Uses local item_uom + item.m18Id to fetch only affected products from M18 and re-save their item_uom. + */ + open fun resyncProblemItemUomsFromM18(): SyncResult { + logger.info("--------------------------------------------Start - Re-sync Problem Item UOMs--------------------------------------------") + val m18Ids = itemUomService.findDistinctProblemItemM18IdsForUomResync().distinct().sorted() + val successList = mutableListOf() + val failList = mutableListOf() + logger.warn("Problem item_uom selector found ${m18Ids.size} item(s) for re-sync.") + if (m18Ids.isNotEmpty()) { + logger.warn("Problem item m18Id sample (up to 20): ${m18Ids.take(20)}") + } + + m18Ids.forEach { m18ItemId -> + try { + logger.warn("Re-sync item_uom: calling M18 product API for id=$m18ItemId") + val result = saveProduct(m18ItemId) + if (result != null) { + successList.add(m18ItemId) + logger.warn("Re-sync item_uom: M18 API sync success for id=$m18ItemId") + } else { + failList.add(m18ItemId) + logger.warn("Re-sync item_uom: M18 API sync returned null for id=$m18ItemId") + } + } catch (e: Exception) { + failList.add(m18ItemId) + logger.error("Re-sync problem item uom failed for m18ItemId=$m18ItemId: ${e.message}", e) + } + } + + logger.info("Problem item_uom re-sync done. total=${m18Ids.size}, success=${successList.size}, fail=${failList.size}") + logger.info("--------------------------------------------End - Re-sync Problem Item UOMs--------------------------------------------") + return SyncResult( + totalProcessed = m18Ids.size, + totalSuccess = successList.size, + totalFail = failList.size + ) + } + // --------------------------------------------- Vendor --------------------------------------------- /// open fun getVendors(request: M18CommonRequest): M18VendorListResponse? { return getList( @@ -477,7 +605,7 @@ open class M18MasterDataService( ) } - open fun saveUnits(request: M18CommonRequest) { + open fun saveUnits(request: M18CommonRequest): SyncResult { logger.info("--------------------------------------------Start - Saving M18 Units--------------------------------------------") val units = getUnits(request) @@ -497,6 +625,11 @@ open class M18MasterDataService( if (unitDetail != null && unitDetail.data?.unit != null) { val unit = unitDetail.data.unit[0] + try { + m18CunitService.replaceForUnit(unit.id, unitDetail.data!!) + } catch (e: Exception) { + logger.error("M18 cunit save failed for unit ${unit.id}: ${e.message}", e) + } val tempObject = UomConversionService.BomObject().apply { code = unit.code udfudesc = unit.udfudesc @@ -549,6 +682,14 @@ open class M18MasterDataService( logger.error("Total Save Fail (${failSaveList.size}): $failSaveList") } logger.info("--------------------------------------------End - Saving M18 Units--------------------------------------------") + val processed = values?.size ?: 0 + val failed = failTransformList.size + failSaveList.size + return SyncResult( + totalProcessed = processed, + totalSuccess = successSaveList.size, + totalFail = failed, + query = "stSearch=${StSearchType.UNIT.value}", + ) } // --------------------------------------------- Currency --------------------------------------------- /// diff --git a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt index 16a18ab..aa8cf68 100644 --- a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt +++ b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt @@ -83,6 +83,7 @@ class M18TestController ( // Master Data m18MasterDataService.saveUnits(mdRequest) m18MasterDataService.saveProducts(mdRequest) + m18MasterDataService.resyncProblemItemUomsFromM18() m18MasterDataService.saveVendors(mdRequest) m18MasterDataService.saveBusinessUnits(mdRequest) m18MasterDataService.saveCurrencies(mdRequest) @@ -96,8 +97,10 @@ class M18TestController ( @PostMapping("/master-data") fun m18MasterData(@Valid @RequestBody request: M18CommonRequest) { // Master Data + m18MasterDataService.ensureCunitSeededForAllIfEmpty() m18MasterDataService.saveUnits(request) m18MasterDataService.saveProducts(request) + m18MasterDataService.resyncProblemItemUomsFromM18() m18MasterDataService.saveVendors(request) m18MasterDataService.saveBusinessUnits(request) m18MasterDataService.saveCurrencies(request) diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index fa550d7..3c7acd9 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -30,6 +30,15 @@ public abstract class SettingNames { public static final String SCHEDULE_M18_MASTER = "SCHEDULE.m18.master"; + /** M18 unit master sync via GET /search/search?stSearch=unit (cron, e.g. "0 40 12 * * *" for 12:40 daily) */ + public static final String SCHEDULE_M18_UNITS = "SCHEDULE.m18.units"; + + /** + * After first successful full unit sync (no lastModifyDate filter), set to "true". + * While "false", unit sync fetches all units; once "true", sync uses rolling lastModifyDate window only. + */ + public static final String M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE = "M18.units.sync.initialFullSyncDone"; + /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; 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 3877855..16a8acc 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 @@ -12,6 +12,7 @@ import com.ffii.fpsms.m18.model.SyncResult import com.ffii.fpsms.modules.common.SettingNames import com.ffii.fpsms.modules.master.service.ProductionScheduleService import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService +import com.ffii.fpsms.modules.settings.entity.Settings import com.ffii.fpsms.modules.settings.service.SettingsService import jakarta.annotation.PostConstruct import org.springframework.beans.factory.annotation.Value @@ -37,6 +38,12 @@ open class SchedulerService( * missing `grn_code`. Example: 4 = from 4 days ago 00:00 to now. */ @Value("\${scheduler.grnCodeSync.syncOffsetDays:0}") val grnCodeSyncSyncOffsetDays: Int, + @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, + /** + * After [SettingNames.M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE] is true, unit search uses + * lastModifyDate from **start of (today − N days)** through **end of today** (inclusive). + */ + @Value("\${scheduler.m18Units.incrementalLookbackDays:3}") val m18UnitsIncrementalLookbackDays: Int, val settingsService: SettingsService, val taskScheduler: TaskScheduler, val productionScheduleService: ProductionScheduleService, @@ -61,6 +68,8 @@ open class SchedulerService( @Volatile var scheduledM18Master: ScheduledFuture<*>? = null + var scheduledM18Units: ScheduledFuture<*>? = null + var scheduledPostCompletedDnGrn: ScheduledFuture<*>? = null var scheduledGrnCodeSync: ScheduledFuture<*>? = null @@ -146,6 +155,26 @@ open class SchedulerService( commonSchedule(scheduledM18Master, SettingNames.SCHEDULE_M18_MASTER, ::getM18MasterData) } + /** + * Daily sync of M18 units via GET /search/search?stSearch=unit (list + full line per id). + * Cron from settings [SettingNames.SCHEDULE_M18_UNITS] (default 12:40 local server time). + * Set scheduler.m18Units.enabled=false to disable. + */ + fun scheduleM18Units() { + if (!m18UnitsSchedulerEnabled) { + scheduledM18Units?.cancel(false) + scheduledM18Units = null + logger.info("M18 units scheduler disabled (scheduler.m18Units.enabled=false)") + return + } + commonSchedule( + scheduledM18Units, + SettingNames.SCHEDULE_M18_UNITS, + "0 40 12 * * *", + ::getM18Units, + ) + } + private fun getPostCompletedDnGrnReceiptDate(): LocalDate { val s = postCompletedDnGrnReceiptDate.trim() return if (s.isEmpty()) LocalDate.now().minusDays(1) @@ -372,6 +401,7 @@ open class SchedulerService( open fun getM18MasterData() { logger.info("Daily Scheduler - Master Data") + m18MasterDataService.ensureCunitSeededForAllIfEmpty() var currentTime = LocalDateTime.now() val today = currentTime.toLocalDate().atStartOfDay() val yesterday = today.minusDays(1L) @@ -379,8 +409,17 @@ open class SchedulerService( modifiedDateTo = today.format(dataStringFormat), modifiedDateFrom = yesterday.format(dataStringFormat) ) - m18MasterDataService.saveUnits(request) + // Keep units as part of master-data sync and run it first with the same modified-date window. + val resultUnits = m18MasterDataService.saveUnits(request) + saveSyncLog( + type = "Units", + status = "SUCCESS", + result = resultUnits, + start = currentTime + ) + + currentTime = LocalDateTime.now() val resultProducts = m18MasterDataService.saveProducts(request) saveSyncLog( type = "Products", @@ -388,6 +427,14 @@ open class SchedulerService( result = resultProducts, start = currentTime ) + currentTime = LocalDateTime.now() + val resultProblemItemUomResync = m18MasterDataService.resyncProblemItemUomsFromM18() + saveSyncLog( + type = "ProductsUomResync", + status = "SUCCESS", + result = resultProblemItemUomResync, + start = currentTime + ) // m18MasterDataService.saveBoms(request) currentTime = LocalDateTime.now() val resultVendors = m18MasterDataService.saveVendors(request) @@ -418,4 +465,54 @@ open class SchedulerService( } + + /** + * M18 units: first run (until setting [SettingNames.M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE] is true) + * calls search with **no** lastModifyDate filter (full list). After a successful full run, that flag is set + * and subsequent runs use lastModifyDate from **(today − [m18UnitsIncrementalLookbackDays])** through **today**. + */ + open fun getM18Units() { + logger.info("Scheduler - M18 units (search/search?stSearch=unit)") + val currentTime = LocalDateTime.now() + val today = currentTime.toLocalDate().atStartOfDay() + val todayStr = today.format(dataStringFormat) + + val initialFullSyncDone = settingsService.findByName(SettingNames.M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE) + .map { Settings.VALUE_BOOLEAN_TRUE == it.value } + .orElse(false) + + val request = if (initialFullSyncDone) { + val fromStr = today.minusDays(m18UnitsIncrementalLookbackDays.toLong()).format(dataStringFormat) + logger.info( + "M18 units sync mode=INCREMENTAL lastModifyDate from=$fromStr to=$todayStr " + + "(lookbackDays=$m18UnitsIncrementalLookbackDays)", + ) + M18CommonRequest( + modifiedDateFrom = fromStr, + modifiedDateTo = todayStr, + ) + } else { + logger.info("M18 units sync mode=FULL (no lastModifyDate filter; first successful run will switch to incremental)") + M18CommonRequest( + modifiedDateFrom = null, + modifiedDateTo = null, + ) + } + + val result = m18MasterDataService.saveUnits(request) + saveSyncLog( + type = "Units", + status = "SUCCESS", + result = result, + start = currentTime, + ) + + if (!initialFullSyncDone && result.totalFail == 0) { + settingsService.update(SettingNames.M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE, Settings.VALUE_BOOLEAN_TRUE) + logger.info( + "M18 units: initial full sync finished with no failures; " + + "setting ${SettingNames.M18_UNITS_SYNC_INITIAL_FULL_SYNC_DONE}=true — next runs use incremental window.", + ) + } + } } \ No newline at end of file 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 9bddc6e..88b894b 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 @@ -55,6 +55,12 @@ class SchedulerController( return "M18 Master Data Sync Triggered Successfully" } + @GetMapping("/trigger/m18-units") + fun triggerM18Units(): String { + schedulerService.getM18Units() + return "M18 units sync (/search/search?stSearch=unit) triggered" + } + @GetMapping("/trigger/grn-code-sync") fun triggerGrnCodeSync(): String { schedulerService.syncGrnCodesFromM18() diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUom.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUom.kt index cc71705..d7f60b3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUom.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUom.kt @@ -63,4 +63,12 @@ open class ItemUom : BaseEntity() { @NotNull @Column(name = "ratioD", nullable = false, precision = 14, scale = 2) open var ratioD: BigDecimal? = null + + /** Original item-level ratio from M18 product price line. */ + @Column(name = "itemRatioN", precision = 14, scale = 2) + open var itemRatioN: BigDecimal? = null + + /** Original item-level ratio from M18 product price line. */ + @Column(name = "itemRatioD", precision = 14, scale = 2) + open var itemRatioD: BigDecimal? = null } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt index a60c41a..59cb8c3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt @@ -29,4 +29,28 @@ interface ItemUomRespository : AbstractRepository { fun findByItemIdAndUomIdAndStockUnitAndDeletedIsFalse(itemId: Long, uomId: Long, stockUnit: Boolean): ItemUom? fun findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc(itemId: Long): ItemUom? + + @Query( + """ + SELECT DISTINCT iu.item.m18Id + FROM ItemUom iu + WHERE iu.deleted = false + AND iu.item.deleted = false + AND iu.item.m18Id IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM ItemUom iub + WHERE iub.item.id = iu.item.id + AND iub.deleted = false + AND iub.baseUnit = true + AND iub.uom.id = 4 + ) + AND ( + (iu.uom.id = 4 AND NOT (iu.ratioN = 1 AND iu.ratioD = 1)) + OR + (iu.uom.id = 785 AND NOT (iu.ratioN = 2268 AND iu.ratioD = 5)) + ) + """ + ) + fun findDistinctProblemItemM18IdsForUomResync(): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 21c0858..a60f098 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -53,6 +53,10 @@ open class ItemUomService( return itemUomRespository.findByItemIdAndUomIdAndStockUnitAndDeletedIsFalse(itemId, uomId, stockUnit) } + open fun findDistinctProblemItemM18IdsForUomResync(): List { + return itemUomRespository.findDistinctProblemItemM18IdsForUomResync() + } + open fun findPurchaseUnitByM18ItemId(m18ItemId: Long): ItemUom? { return itemUomRespository.findByItemM18IdAndPurchaseUnitIsTrueAndDeletedIsFalse(m18ItemId) } @@ -227,6 +231,8 @@ open class ItemUomService( m18LastModifyDate = request.m18LastModifyDate ratioD = request.ratioD ratioN = request.ratioN + itemRatioD = request.itemRatioD + itemRatioN = request.itemRatioN deleted = request.deleted // ← EXPLICIT assignment (same as before) } diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt index c1f9b17..c7ccedf 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt @@ -26,6 +26,8 @@ data class ItemUomRequest( val m18LastModifyDate: LocalDateTime?, val ratioD: BigDecimal?, val ratioN: BigDecimal?, + val itemRatioD: BigDecimal?, + val itemRatioN: BigDecimal?, val deleted: Boolean?, ) data class BomFormatIssue( diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt index 3665737..dd6cf93 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt @@ -1,4 +1,4 @@ -package com.ffii.fpsms.modules.report.service +package com.ffii.fpsms.modules.report.service import org.springframework.stereotype.Service import net.sf.jasperreports.engine.* @@ -747,7 +747,7 @@ return result fun searchGrnReport( receiptDateStart: String?, receiptDateEnd: String?, - itemCode: String?, + itemCode: String?, supplier: String?, poCode: String?, grnCode: String?, @@ -763,10 +763,10 @@ return result args["receiptDateEnd"] = formatted "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" } else "" - val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "grnItem", args) - val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "grnSupp", args) - val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "grnPo", args) - val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "grnG", args) + val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "listedItem", args) + val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "listedSupp", args) + val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "listedPo", args) + val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "listedGrn", args) val sql = """ SELECT @@ -866,7 +866,10 @@ return result fun searchGrnListedPoAmounts( receiptDateStart: String?, receiptDateEnd: String?, - itemCode: String? + itemCode: String?, + supplier: String?, + poCode: String?, + grnCode: String?, ): Map { val args = mutableMapOf() val receiptDateStartSql = if (!receiptDateStart.isNullOrBlank()) { @@ -879,7 +882,10 @@ return result args["receiptDateEnd"] = formatted "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" } else "" - val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) + val itemSql = buildMultiValueCodeOrNameLikeClause(itemCode, "it.code", "it.name", "listedItem", args) + val supplierSql = buildMultiValueCodeOrNameLikeClause(supplier, "sp.code", "sp.name", "listedSupp", args) + val poCodeSql = buildMultiValueLikeClause(poCode, "po.code", "listedPo", args) + val grnExistsSql = buildMultiValueGrnExistsClause(grnCode, "listedGrn", args) val lineSubquery = """ SELECT @@ -889,6 +895,8 @@ return result ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2) AS line_amt FROM stock_in_line sil INNER JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id + INNER JOIN purchase_order po ON sil.purchaseOrderId = po.id + LEFT JOIN shop sp ON po.supplierId = sp.id LEFT JOIN items it ON sil.itemId = it.id WHERE sil.deleted = false AND sil.receiptDate IS NOT NULL @@ -896,7 +904,10 @@ return result AND pol.status = 'completed' $receiptDateStartSql $receiptDateEndSql - $itemCodeSql + $itemSql + $supplierSql + $poCodeSql + $grnExistsSql """.trimIndent() val currencySql = """ diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt index eabb5a2..57238d2 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt @@ -255,7 +255,14 @@ class ReportController( } val listedPoAmounts = if (isAdmin) { - reportService.searchGrnListedPoAmounts(receiptDateStart, receiptDateEnd, itemCode) + reportService.searchGrnListedPoAmounts( + receiptDateStart, + receiptDateEnd, + itemCode, + supplier, + poCode, + grnCode, + ) } else { mapOf( "currencyTotals" to emptyList>(), diff --git a/src/main/resources/db/changelog/changes/20260311_fai/01_m18_cunit.sql b/src/main/resources/db/changelog/changes/20260311_fai/01_m18_cunit.sql new file mode 100644 index 0000000..0c3c5fc --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260311_fai/01_m18_cunit.sql @@ -0,0 +1,23 @@ +--liquibase formatted sql +--changeset fai:20260311_m18_cunit + +CREATE TABLE `m18_cunit` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `version` int NOT NULL DEFAULT '0', + `created` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `createdBy` varchar(30) DEFAULT NULL, + `modified` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + `modifiedBy` varchar(30) DEFAULT NULL, + `deleted` tinyint(1) NOT NULL DEFAULT '0', + `m18Id` bigint NOT NULL COMMENT 'M18 unit id (parent unit from read/unit)', + `m18CunitLineId` bigint NOT NULL COMMENT 'M18 cunit row id', + `ratioN` decimal(20, 10) DEFAULT NULL COMMENT 'Stored from M18 ratioD (swapped)', + `ratioD` decimal(20, 10) DEFAULT NULL COMMENT 'Stored from M18 ratioN (swapped)', + `hId` bigint DEFAULT NULL, + `unitId` bigint DEFAULT NULL, + `iRev` int DEFAULT NULL, + `itemNo` varchar(32) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_m18_cunit_line` (`m18CunitLineId`), + KEY `idx_m18_cunit_m18Id` (`m18Id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; diff --git a/src/main/resources/db/changelog/changes/20260322_fai/01_m18_units_sync_setting.sql b/src/main/resources/db/changelog/changes/20260322_fai/01_m18_units_sync_setting.sql new file mode 100644 index 0000000..649b907 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260322_fai/01_m18_units_sync_setting.sql @@ -0,0 +1,11 @@ +--liquibase formatted sql +--changeset fai:20260322_m18_units_sync_setting + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'M18.units.sync.initialFullSyncDone' AS name, 'false' AS value, 'M18' AS category, 'boolean' AS type +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = 'M18.units.sync.initialFullSyncDone' +); diff --git a/src/main/resources/db/changelog/changes/20260323_fai/01_update_item_uom_add_item_ratios.sql b/src/main/resources/db/changelog/changes/20260323_fai/01_update_item_uom_add_item_ratios.sql new file mode 100644 index 0000000..4fe6f46 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260323_fai/01_update_item_uom_add_item_ratios.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql +--changeset fai:20260323_item_uom_item_ratios + +ALTER TABLE `item_uom` + ADD COLUMN `itemRatioN` DECIMAL(14,2) NULL AFTER `ratioD`, + ADD COLUMN `itemRatioD` DECIMAL(14,2) NULL AFTER `itemRatioN`;