소스 검색

added for bom sync to m18

production
[email protected] 1 개월 전
부모
커밋
9dbd06e338
11개의 변경된 파일166개의 추가작업 그리고 14개의 파일을 삭제
  1. +18
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt
  2. +14
    -8
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  3. +3
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  4. +49
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  5. +13
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  6. +4
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt
  7. +41
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/BomM18ShopBulkPushService.kt
  8. +3
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  9. +8
    -3
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt
  10. +1
    -1
      src/main/resources/application.yml
  11. +12
    -0
      src/main/resources/db/changelog/changes/20260521_fpsms_bom_shop/01_clear_sync_log_schedule_bom_shop.sql

+ 18
- 0
src/main/java/com/ffii/fpsms/m18/model/M18BomShopBatchSyncSummary.kt 파일 보기

@@ -0,0 +1,18 @@
package com.ffii.fpsms.m18.model

/**
* Result of scheduling job [com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed].
*/
data class M18BomShopBatchSyncSummary(
/** BOM rows with deleted=false scanned. */
val totalProcessed: Int,
val synced: Int,
/** Pushed attempted but [M18BomShopSyncTriggerResult.synced] is false (includes build/API failures). */
val notSynced: Int,
/** [SettingNames.M18_BOM_SHOP_SYNC_ENABLED] is off — no BOMs attempted. */
val skippedBecauseFeatureDisabled: Boolean = false,
) {
/** One-line summary for logs / scheduler_sync_log.query */
fun toLogQuery(): String =
"BOMShop batch: processed=$totalProcessed synced=$synced notSynced=$notSynced skippedFeatureDisabled=$skippedBecauseFeatureDisabled"
}

+ 14
- 8
src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt 파일 보기

@@ -57,10 +57,14 @@ open class M18BomForShopService(

companion object {
private const val HARVEST_CALC_SCALE = 10
internal const val BOM_SHOP_HEADER_VERSION_DIGITS = 4
private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")

private fun formatBomShopHeaderCode(itemCode: String, version: Int): String =
"BOM${itemCode}V${version.toString().padStart(3, '0')}"
"BOM${itemCode}V${version.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')}"

internal fun normalizedHeaderRevision(versionDigits: String): String =
versionDigits.padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
}

@Suppress("DEPRECATION")
@@ -70,7 +74,7 @@ open class M18BomForShopService(

/**
* Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality).
* Used with [M18BomShopSyncLog] to decide V000 vs V001+.
* Used with [M18BomShopSyncLog] to decide V0000 vs V0001+.
*/
open fun contentFingerprint(request: M18BomForShopSaveRequest): String {
val json = objectMapper.writeValueAsString(normalizedForFingerprint(request))
@@ -100,7 +104,7 @@ open class M18BomForShopService(
* Builds M18 save body from a persisted BOM (materials loaded).
* [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only,
* reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible.
* Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnn`.
* Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnnn`.
*/
open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? {
val bomId = bom.id ?: return null
@@ -222,7 +226,8 @@ open class M18BomForShopService(
val codeForUpdate =
latest?.m18HeaderCode?.takeIf { it.isNotBlank() }
?: formatBomShopHeaderCode(itemCode, 0)
val forcedRev = parseTrailingVersion(codeForUpdate) ?: "000"
val forcedRev = parseTrailingVersion(codeForUpdate)
?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS)
return Triple(codeForUpdate, forcedRev, forcedId)
}

@@ -232,14 +237,15 @@ open class M18BomForShopService(
val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty()

if (samePayload) {
val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000"
val revReuse = parseTrailingVersion(prevCodeTrimmed)
?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS)
return Triple(prevCodeTrimmed, revReuse, bomM18Id)
}

val maxV = maxVersionFromLogs(bomId, itemCode)
val nextV = maxV + 1
val newCode = formatBomShopHeaderCode(itemCode, nextV)
val rev = nextV.toString().padStart(3, '0')
val rev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0')
return Triple(newCode, rev, null)
}

@@ -255,7 +261,7 @@ open class M18BomForShopService(

private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int {
val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$")
// Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnn.
// Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnnn.
return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId)
.mapNotNull { row ->
val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty {
@@ -276,7 +282,7 @@ open class M18BomForShopService(
}

private fun parseTrailingVersion(headerCode: String): String? =
Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.padStart(3, '0')
Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.let { normalizedHeaderRevision(it) }

/**
* From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code]


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java 파일 보기

@@ -30,6 +30,9 @@ public abstract class SettingNames {
public static final String SCHEDULE_M18_DO1_SAT = "SCHEDULE.m18.do1.sat";
public static final String SCHEDULE_M18_DO2 = "SCHEDULE.m18.do2";

/** Daily push FPSMS BOMs → M18 udfBomForShop (default 23:00; requires [M18_BOM_SHOP_SYNC_ENABLED] and scheduler.m18Sync.enabled). */
public static final String SCHEDULE_M18_BOM_SHOP = "SCHEDULE.m18.bom.shop";

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) */


+ 49
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt 파일 보기

@@ -11,6 +11,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLogRepository
import com.ffii.fpsms.m18.model.SyncResult
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService
import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService
import com.ffii.fpsms.modules.master.service.ProductionScheduleService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
@@ -59,11 +60,14 @@ open class SchedulerService(
val m18GrnCodeSyncService: M18GrnCodeSyncService,
val inventoryLotLineService: InventoryLotLineService,
val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService,
private val bomM18ShopBulkPushService: BomM18ShopBulkPushService,
) {
companion object {
/** DO2: Spring 6-field cron default and M18 `lastModifyDate` upper bound hour (1pm local). */
const val DO2_MODIFIED_TO_HOUR: Int = 13
const val DO2_DEFAULT_CRON: String = "0 0 13 * * *"
/** Default 23:00 daily — BOM → M18 udfBomForShop for all BOMs ([SettingNames.SCHEDULE_M18_BOM_SHOP]). */
const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *"
/** Daily 00:00:15 — process job orders whose planStart was yesterday. */
const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *"
}
@@ -81,6 +85,8 @@ open class SchedulerService(
var scheduledM18Do1Sat: ScheduledFuture<*>? = null
var scheduledM18Do2: ScheduledFuture<*>? = null

var scheduledM18BomShop: ScheduledFuture<*>? = null

@Volatile
var scheduledM18Master: ScheduledFuture<*>? = null

@@ -178,6 +184,7 @@ open class SchedulerService(
scheduleM18Po();
scheduleM18Do1();
scheduleM18Do2();
scheduleM18BomShop();
scheduleM18MasterData();
schedulePostCompletedDnGrn();
scheduleGrnCodeSync();
@@ -223,6 +230,18 @@ open class SchedulerService(
scheduledM18Do2 = commonSchedule(scheduledM18Do2, SettingNames.SCHEDULE_M18_DO2, DO2_DEFAULT_CRON, ::getM18Dos2)
}

/** Daily push FPSMS BOMs → M18; cron from settings [SettingNames.SCHEDULE_M18_BOM_SHOP] ([M18_BOM_SHOP_DEFAULT_CRON]); requires scheduler.m18Sync.enabled. */
fun scheduleM18BomShop() {
if (!m18SyncEnabled) {
scheduledM18BomShop?.cancel(false)
scheduledM18BomShop = null
logger.info("M18 BOM Shop scheduler disabled (scheduler.m18Sync.enabled=false)")
return
}
scheduledM18BomShop =
commonSchedule(scheduledM18BomShop, SettingNames.SCHEDULE_M18_BOM_SHOP, M18_BOM_SHOP_DEFAULT_CRON, ::getM18BomShopPushAllBoms)
}

fun scheduleM18MasterData() {
if (!m18SyncEnabled) {
scheduledM18Master?.cancel(false)
@@ -543,6 +562,36 @@ open class SchedulerService(
)
}

open fun getM18BomShopPushAllBoms() {
logger.info("M18 BOM Shop - push all BOMs to udfBomForShop")
val currentTime = LocalDateTime.now()
try {
val summary = bomM18ShopBulkPushService.pushAllBomsToM18ShopIfAllowed()
val status = if (summary.skippedBecauseFeatureDisabled) "SKIPPED" else "SUCCESS"
saveSyncLog(
type = "M18_BOM_SHOP",
status = status,
result =
SyncResult(
totalProcessed = summary.totalProcessed,
totalSuccess = summary.synced,
totalFail = summary.notSynced,
query = summary.toLogQuery(),
),
start = currentTime,
)
logger.info("M18 BOM Shop batch done: ${summary.toLogQuery()}")
} catch (e: Exception) {
logger.error("M18 BOM Shop batch failed: ${e.message}", e)
saveSyncLog(
type = "M18_BOM_SHOP",
status = "FAILED",
error = e.message,
start = currentTime,
)
}
}

open fun getPostCompletedDnAndProcessGrn(
receiptDate: java.time.LocalDate? = null,
skipFirst: Int = 0,


+ 13
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt 파일 보기

@@ -49,6 +49,19 @@ class SchedulerController(
return "M18 DO2 Sync Triggered Successfully"
}

/** Manual test: push all FPSMS BOMs to M18 udfBomForShop ([SettingNames.M18_BOM_SHOP_SYNC_ENABLED] must still be true). */
@GetMapping("/trigger/bom-shop-sync-all")
fun triggerBomShopSyncAll(): String {
schedulerService.getM18BomShopPushAllBoms()
return "M18 BOM Shop (all BOMs) sync triggered (see scheduler_sync_log type M18_BOM_SHOP)"
}

@GetMapping("/updateSetting/bomShopCron")
fun scheduleBomShop(@RequestParam @Valid newCron: String) {
settingsService.update(SettingNames.SCHEDULE_M18_BOM_SHOP, newCron)
schedulerService.scheduleM18BomShop()
}

@GetMapping("/trigger/master-data")
fun triggerMasterData(): String {
schedulerService.getM18MasterData()


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt 파일 보기

@@ -18,6 +18,10 @@ interface BomRepository : AbstractRepository<Bom, Long> {

fun findBomComboByDeletedIsFalse(): List<BomCombo>
fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom>

@Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id")
fun findAllIdsByDeletedIsFalse(): List<Long>

fun findByCodeAndDeletedIsFalse(code: String): Bom?
@Query("""
select b.item.id


+ 41
- 0
src/main/java/com/ffii/fpsms/modules/master/service/BomM18ShopBulkPushService.kt 파일 보기

@@ -0,0 +1,41 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.m18.model.M18BomShopBatchSyncSummary
import com.ffii.fpsms.modules.master.entity.BomRepository
import org.springframework.stereotype.Service

/**
* Calls [BomService.pushBomToM18ShopIfAllowed] for each BOM id via the injected proxied bean
* so `@Transactional` applies per BOM (avoids same-class self-invocation).
*/
@Service
open class BomM18ShopBulkPushService(
private val bomRepository: BomRepository,
private val bomService: BomService,
) {

/** Pushes all non-deleted BOMs to M18 when {@link BomService}'s BOM shop sync setting is enabled. */
open fun pushAllBomsToM18ShopIfAllowed(): M18BomShopBatchSyncSummary {
if (!bomService.isM18BomShopSyncEnabled()) {
return M18BomShopBatchSyncSummary(
totalProcessed = 0,
synced = 0,
notSynced = 0,
skippedBecauseFeatureDisabled = true,
)
}
val ids = bomRepository.findAllIdsByDeletedIsFalse()
var synced = 0
var notSynced = 0
for (id in ids) {
val result = bomService.pushBomToM18ShopIfAllowed(id)
if (result.synced) synced++ else notSynced++
}
return M18BomShopBatchSyncSummary(
totalProcessed = ids.size,
synced = synced,
notSynced = notSynced,
skippedBecauseFeatureDisabled = false,
)
}
}

+ 3
- 2
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt 파일 보기

@@ -409,10 +409,11 @@ open class BomService(

/**
* When {@link SettingNames#M18_BOM_SHOP_SYNC_ENABLED} is true, push BOM to M18 udfBomForShop.
* Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or future UI) to trigger explicitly.
* Use {@code POST /m18/test/bom-shop-sync/{bomId}} (or bulk job /scheduler/trigger/bom-shop-sync-all) to trigger explicitly.
* Optional [m18HeaderId]: M18 udfBomForShop **header** record id (e.g. [BomIdByItemCodeResponse.bomM18Id])
* to force **update** when [Bom.m18Id] is missing or stale. When null, uses [Bom.m18Id] if set.
*/
@Transactional
open fun pushBomToM18ShopIfAllowed(bomId: Long, m18HeaderId: Long? = null): M18BomShopSyncTriggerResult {
if (!isM18BomShopSyncEnabled()) {
return M18BomShopSyncTriggerResult(
@@ -510,7 +511,7 @@ open class BomService(
return result
}

private fun isM18BomShopSyncEnabled(): Boolean =
internal fun isM18BomShopSyncEnabled(): Boolean =
settingsService.findByName(SettingNames.M18_BOM_SHOP_SYNC_ENABLED)
.map { Settings.VALUE_BOOLEAN_TRUE == it.value }
.orElse(false)


+ 8
- 3
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderLineRepository.kt 파일 보기

@@ -11,6 +11,9 @@ import java.io.Serializable

@Repository
interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Long> {
/**
* Latest POL for BOM/M18 pushes: PO code must start with **`PP`**, row has M18 log, newest `pol.created` first.
*/
@Query(
"SELECT pol FROM PurchaseOrderLine pol " +
"LEFT JOIN FETCH pol.purchaseOrder po " +
@@ -18,6 +21,7 @@ interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Lo
"JOIN FETCH pol.uom " +
"LEFT JOIN FETCH pol.uomM18 " +
"WHERE pol.deleted = false AND pol.item.id = :itemId AND pol.m18DataLog IS NOT NULL " +
"AND po.deleted = false AND po.code IS NOT NULL AND po.code LIKE 'PP%' " +
"ORDER BY pol.created DESC",
)
fun findLatestLinesForBomM18ByItemId(
@@ -26,8 +30,8 @@ interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Lo
): List<PurchaseOrderLine>

/**
* Latest PO (by header `purchase_order.created`) for a material item code: supplier `shop.m18Id` from `purchase_order.supplierId`.
* Mirrors manual SQL: pol → items (code), po, shop on supplier, uom_conversion; order by po.created desc limit 1.
* Latest **PP** PO (code starts with `PP`, by header `purchase_order.created`) for a material item code:
* supplier `shop.m18Id` from `purchase_order.supplierId`.
*/
@Query(
value =
@@ -36,7 +40,8 @@ interface PurchaseOrderLineRepository : AbstractRepository<PurchaseOrderLine, Lo
"LEFT JOIN purchase_order po ON pol.purchaseOrderId = po.id " +
"LEFT JOIN shop sh ON po.supplierId = sh.id " +
"LEFT JOIN uom_conversion um ON pol.uomIdM18 = um.id " +
"WHERE pol.deleted = false AND it.deleted = false AND it.code = :itemCode " +
"WHERE pol.deleted = false AND po.deleted = false AND it.deleted = false AND it.code = :itemCode " +
"AND po.code LIKE 'PP%' " +
"ORDER BY po.created DESC LIMIT 1",
nativeQuery = true,
)


+ 1
- 1
src/main/resources/application.yml 파일 보기

@@ -12,7 +12,7 @@ server:
# PostCompletedDn GRN: runs daily at 00:01, processes all POs with receipt date = yesterday.
# Set enabled: false to disable. Optional receiptDate: "yyyy-MM-dd" overrides for testing only.
# m18Grn.createEnabled: M18 GRN PUT/create — false outside production so UAT/dev never posts GRNs.
# m18Sync: M18 cron jobs for PO, DO1, DO2, master data — false outside production (manual /trigger/* still works).
# m18Sync: M18 cron jobs for PO, DO1, DO2, BOM→M18 udfBomForShop ([SCHEDULE.m18.bom.shop], default 23:00), master data — false outside production (manual /trigger/* still works).
scheduler:
m18Sync:
enabled: false


+ 12
- 0
src/main/resources/db/changelog/changes/20260521_fpsms_bom_shop/01_clear_sync_log_schedule_bom_shop.sql 파일 보기

@@ -0,0 +1,12 @@
--liquibase formatted sql

--changeset fpsms:20260521_clear_m18_bom_shop_sync_log stripComments:false
DELETE FROM `m18_bom_shop_sync_log`;

--changeset fpsms:20260521_insert_schedule_m18_bom_shop stripComments:false
INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT 'SCHEDULE.m18.bom.shop', '0 0 23 * * *', 'SCHEDULE', 'string'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `settings` WHERE `name` = 'SCHEDULE.m18.bom.shop'
);

불러오는 중...
취소
저장