| @@ -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<Long>() { | |||
| /** 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 | |||
| } | |||
| @@ -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<M18Cunit, Long> { | |||
| @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 | |||
| } | |||
| @@ -105,15 +105,20 @@ data class M18UnitListValue ( | |||
| ) | |||
| /** Unit Response */ | |||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||
| data class M18UnitResponse ( | |||
| val data: M18UnitData?, | |||
| val messages: List<M18ErrorMessages>? | |||
| ) | |||
| @JsonIgnoreProperties(ignoreUnknown = true) | |||
| data class M18UnitData ( | |||
| val unit: List<M18UnitUnit> | |||
| val unit: List<M18UnitUnit>, | |||
| /** Conversion-unit lines; ratios are swapped when persisted to [com.ffii.fpsms.m18.entity.M18Cunit]. */ | |||
| val cunit: List<M18UnitCunit>? = 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<M18CurrencyListValue>?, | |||
| @@ -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) } | |||
| } | |||
| } | |||
| @@ -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 <reified T : Any> 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<Long>(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<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| 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<M18VendorListResponse>( | |||
| @@ -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 --------------------------------------------- /// | |||
| @@ -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) | |||
| @@ -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"; | |||
| @@ -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.", | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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() | |||
| @@ -63,4 +63,12 @@ open class ItemUom : BaseEntity<Long>() { | |||
| @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 | |||
| } | |||
| @@ -29,4 +29,28 @@ interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | |||
| 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<Long> | |||
| } | |||
| @@ -53,6 +53,10 @@ open class ItemUomService( | |||
| return itemUomRespository.findByItemIdAndUomIdAndStockUnitAndDeletedIsFalse(itemId, uomId, stockUnit) | |||
| } | |||
| open fun findDistinctProblemItemM18IdsForUomResync(): List<Long> { | |||
| 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) | |||
| } | |||
| @@ -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( | |||
| @@ -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<String, Any> { | |||
| val args = mutableMapOf<String, Any>() | |||
| 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 = """ | |||
| @@ -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<Map<String, Any?>>(), | |||
| @@ -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; | |||
| @@ -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' | |||
| ); | |||
| @@ -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`; | |||