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 e0fa020..e74855c 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -57,6 +57,11 @@ public abstract class SettingNames { public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; + + /** + * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. + */ + public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; /* * Mail settings */ 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 beaabe3..55aab0d 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 @@ -10,6 +10,7 @@ import com.ffii.fpsms.m18.entity.SchedulerSyncLog 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.ProductionScheduleService import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService import com.ffii.fpsms.modules.stock.service.InventoryLotLineService @@ -42,6 +43,7 @@ open class SchedulerService( @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, + @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, val settingsService: SettingsService, /** * Lookback window for GRN code sync: rows with `created` from **start of (today − N days)** through **now**, @@ -56,11 +58,14 @@ open class SchedulerService( val searchCompletedDnService: SearchCompletedDnService, val m18GrnCodeSyncService: M18GrnCodeSyncService, val inventoryLotLineService: InventoryLotLineService, + val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, ) { 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 * * *" + /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ + const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" } var logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) @@ -86,6 +91,8 @@ open class SchedulerService( var scheduledGrnCodeSync: ScheduledFuture<*>? = null var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null + var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null + //@Volatile //var scheduledRoughProd: ScheduledFuture<*>? = null @@ -175,6 +182,7 @@ open class SchedulerService( schedulePostCompletedDnGrn(); scheduleGrnCodeSync(); scheduleInventoryLotExpiry(); + scheduleJobOrderPlanStartAuto(); //scheduleRoughProd(); //scheduleDetailedProd(); } @@ -292,6 +300,42 @@ open class SchedulerService( ) } + /** + * Job order plan-start batch at 00:00:15 daily (yesterday plan day). + * Set scheduler.jo.planStart.enabled=false to disable. + */ + fun scheduleJobOrderPlanStartAuto() { + if (!jobOrderPlanStartAutoEnabled) { + scheduledJobOrderPlanStart?.cancel(false) + scheduledJobOrderPlanStart = null + logger.info("Job order plan-start auto scheduler disabled (scheduler.jo.planStart.enabled=false)") + return + } + scheduledJobOrderPlanStart = commonSchedule( + scheduledJobOrderPlanStart, + SettingNames.SCHEDULE_JO_PLAN_START, + JO_PLAN_START_DEFAULT_CRON, + ::runJobOrderPlanStartAuto, + ) + logger.info("Scheduled job order plan-start auto (default cron={})", JO_PLAN_START_DEFAULT_CRON) + } + + open fun runJobOrderPlanStartAuto() { + try { + val report = jobOrderPlanStartAutoService.runAutoProcess(LocalDateTime.now()) + logger.info( + "Scheduler - Job order plan-start auto: candidates={}, hidden={}, rescheduled={}, skipped={}, errors={}", + report.candidates, + report.hidden, + report.rescheduled, + report.skipped, + report.errors, + ) + } catch (e: Exception) { + logger.error("Scheduler - Job order plan-start auto failed: ${e.message}", e) + } + } + /** Mark expired inventory lot lines as unavailable daily. Set scheduler.inventoryLotExpiry.enabled=false to disable. */ fun scheduleInventoryLotExpiry() { if (!inventoryLotExpiryEnabled) { 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 5c5d49a..3b01c91 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 @@ -88,4 +88,9 @@ class SchedulerController( schedulerService.init() return "Cron Schedules Refreshed from Database" } + @GetMapping("/trigger/jo-plan-start") + fun triggerJoPlanStart(): String { + schedulerService.runJobOrderPlanStartAuto() + return "Job order plan-start auto triggered" + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt new file mode 100644 index 0000000..b1872c1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt @@ -0,0 +1,243 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus +import com.ffii.fpsms.modules.pickOrder.entity.PickOrder +import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.productProcess.entity.ProductProcess +import com.ffii.fpsms.modules.productProcess.entity.ProductProcessRepository +import com.ffii.fpsms.modules.productProcess.enums.ProductProcessStatus +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.modules.stock.service.StockInLineService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import org.springframework.transaction.support.TransactionTemplate +import java.time.LocalDateTime +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Daily batch after plan day ends: at run time (default 00:00:15), process job orders whose + * [JobOrder.planStart] fell on the previous calendar day. + * + * - Branch A: no pick submitted, product process pending → hide job order. + * - Branch B: pick submitted, product process still pending → reschedule to today 00:00:00 and renumber. + */ +@Service +open class JobOrderPlanStartAutoService( + private val jobOrderRepository: JobOrderRepository, + private val pickOrderRepository: PickOrderRepository, + private val productProcessRepository: ProductProcessRepository, + private val stockInLineRepository: StockInLineRepository, + private val jobOrderService: JobOrderService, + private val stockInLineService: StockInLineService, + private val transactionTemplate: TransactionTemplate, +) { + private val logger = LoggerFactory.getLogger(javaClass) + private val inFlight = AtomicBoolean(false) + + data class JobOrderPlanStartAutoReport( + val runAt: LocalDateTime, + val targetPlanDayFrom: LocalDateTime, + val targetPlanDayToExclusive: LocalDateTime, + val candidates: Int = 0, + val hidden: Int = 0, + val rescheduled: Int = 0, + val skipped: Int = 0, + val errors: Int = 0, + ) + + open fun runAutoProcess(runAt: LocalDateTime = LocalDateTime.now()): JobOrderPlanStartAutoReport { + if (!inFlight.compareAndSet(false, true)) { + logger.warn("Job order plan-start auto process skipped: previous run still in flight") + val targetDay = runAt.toLocalDate().minusDays(1) + return JobOrderPlanStartAutoReport( + runAt = runAt, + targetPlanDayFrom = targetDay.atStartOfDay(), + targetPlanDayToExclusive = targetDay.plusDays(1).atStartOfDay(), + ) + } + try { + return runAutoProcessInternal(runAt) + } finally { + inFlight.set(false) + } + } + + private fun runAutoProcessInternal(runAt: LocalDateTime): JobOrderPlanStartAutoReport { + val targetDay = runAt.toLocalDate().minusDays(1) + val from = targetDay.atStartOfDay() + val toExclusive = targetDay.plusDays(1).atStartOfDay() + val newPlanStart = runAt.toLocalDate().atStartOfDay() + + var hidden = 0 + var rescheduled = 0 + var skipped = 0 + var errors = 0 + + val jobOrders = jobOrderRepository + .findByDeletedFalseAndPlanStartFromBeforeExclusiveOrderByIdAsc(from, toExclusive) + .filter { isEligibleCandidate(it) } + + val joIds = jobOrders.mapNotNull { it.id } + val pickOrdersByJoId = loadPickOrdersByJobOrderId(joIds) + val productProcessesByJoId = loadProductProcessesByJobOrderId(joIds) + + logger.info( + "Job order plan-start auto: runAt={}, targetPlanDay=[{}, {}), candidates={}", + runAt, + from, + toExclusive, + jobOrders.size, + ) + + for (jo in jobOrders) { + val joId = jo.id ?: continue + try { + when ( + classify( + jo, + pickOrdersByJoId[joId].orEmpty(), + productProcessesByJoId[joId], + ) + ) { + Branch.HIDE -> { + transactionTemplate.executeWithoutResult { + applyHide(jo, runAt) + } + hidden++ + } + Branch.RESCHEDULE -> { + transactionTemplate.executeWithoutResult { + applyReschedule(jo, productProcessesByJoId[joId], newPlanStart, runAt) + } + rescheduled++ + } + Branch.SKIP -> skipped++ + } + } catch (e: Exception) { + errors++ + logger.error("Job order plan-start auto failed for joId={} code={}: {}", joId, jo.code, e.message, e) + } + } + + val report = JobOrderPlanStartAutoReport( + runAt = runAt, + targetPlanDayFrom = from, + targetPlanDayToExclusive = toExclusive, + candidates = jobOrders.size, + hidden = hidden, + rescheduled = rescheduled, + skipped = skipped, + errors = errors, + ) + logger.info("Job order plan-start auto finished: {}", report) + return report + } + + private fun isEligibleCandidate(jo: JobOrder): Boolean { + if (jo.isHidden == true) return false + if (jo.status == JobOrderStatus.COMPLETED) return false + return true + } + + private enum class Branch { + HIDE, + RESCHEDULE, + SKIP, + } + + private fun classify( + jo: JobOrder, + pickOrders: List, + productProcess: ProductProcess?, + ): Branch { + if (!isProductProcessPendingNotStarted(productProcess)) { + return Branch.SKIP + } + val maxSubmittedLines = pickOrders.maxOfOrNull { it.submittedLines ?: 0 } ?: 0 + return when { + maxSubmittedLines == 0 -> Branch.HIDE + maxSubmittedLines > 0 -> Branch.RESCHEDULE + else -> Branch.SKIP + } + } + + private fun isProductProcessPendingNotStarted(productProcess: ProductProcess?): Boolean { + if (productProcess == null) return false + if (productProcess.deleted) return false + if (productProcess.status != ProductProcessStatus.PENDING) return false + if (productProcess.startTime != null) return false + return true + } + + private fun loadPickOrdersByJobOrderId(jobOrderIds: List): Map> { + if (jobOrderIds.isEmpty()) return emptyMap() + return pickOrderRepository + .findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds) + .groupBy { it.jobOrder?.id ?: -1L } + .filterKeys { it > 0L } + } + + private fun loadProductProcessesByJobOrderId(jobOrderIds: List): Map { + if (jobOrderIds.isEmpty()) return emptyMap() + return productProcessRepository + .findByJobOrder_IdInAndDeletedIsFalse(jobOrderIds) + .mapNotNull { pp -> pp.jobOrder?.id?.let { it to pp } } + .groupBy { it.first } + .mapValues { (_, entries) -> + entries.map { it.second }.firstOrNull { isProductProcessPendingNotStarted(it) } + ?: entries.map { it.second }.first() + } + .filterValues { it != null } + .mapValues { it.value!! } + } + + private fun applyHide(jo: JobOrder, runAt: LocalDateTime) { + jo.isHidden = true + appendRemarks(jo, "[auto ${runAt.toLocalDate()}] hidden: overdue plan day, no pick submitted, process pending") + jobOrderRepository.save(jo) + logger.info("Job order plan-start auto hid joId={} code={}", jo.id, jo.code) + } + + private fun applyReschedule( + jo: JobOrder, + productProcess: ProductProcess?, + newPlanStart: LocalDateTime, + runAt: LocalDateTime, + ) { + val pp = productProcess?.takeIf { isProductProcessPendingNotStarted(it) } + ?: throw IllegalStateException("Product process not pending for reschedule, joId=${jo.id}") + + val newCode = jobOrderService.assignJobNo(newPlanStart) + jo.planStart = newPlanStart + jo.code = newCode + appendRemarks( + jo, + "[auto ${runAt.toLocalDate()}] rescheduled from overdue plan day; pick started, process pending", + ) + jobOrderRepository.save(jo) + + pp.date = newPlanStart.toLocalDate() + productProcessRepository.save(pp) + + val sil = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + if (sil != null) { + sil.lotNo = stockInLineService.assignLotNoForJo(newPlanStart.toLocalDate()) + sil.productLotNo = newCode + stockInLineRepository.save(sil) + } + + logger.info( + "Job order plan-start auto rescheduled joId={} newCode={} newPlanStart={}", + jo.id, + newCode, + newPlanStart, + ) + } + + private fun appendRemarks(jo: JobOrder, snippet: String) { + val existing = jo.remarks?.trim().orEmpty() + jo.remarks = if (existing.isEmpty()) snippet else "$existing | $snippet" + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9d4cb97..e8cd673 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,6 +27,10 @@ scheduler: syncOffsetDays: 0 inventoryLotExpiry: enabled: true + # Job order: at 00:00:15 daily, process JOs whose planStart was yesterday (hide or reschedule). + jo: + planStart: + enabled: true # Nav: PO stock_in_line pending/receiving within last N days (see ProductProcessService for 工單 QC/上架:今日+昨日). fpsms: