Explorar el Código

job order auto cancel and delay

production
CANCERYS\kw093 hace 1 mes
padre
commit
e78971d7b2
Se han modificado 5 ficheros con 301 adiciones y 0 borrados
  1. +5
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  2. +44
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  3. +5
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  4. +243
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt
  5. +4
    -0
      src/main/resources/application.yml

+ 5
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Ver fichero

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


+ 44
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Ver fichero

@@ -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) {


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt Ver fichero

@@ -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"
}
}

+ 243
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderPlanStartAutoService.kt Ver fichero

@@ -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<PickOrder>,
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<Long>): Map<Long, List<PickOrder>> {
if (jobOrderIds.isEmpty()) return emptyMap()
return pickOrderRepository
.findAllByJobOrder_IdInAndDeletedFalseOrderByJobOrder_IdAscCreatedDesc(jobOrderIds)
.groupBy { it.jobOrder?.id ?: -1L }
.filterKeys { it > 0L }
}

private fun loadProductProcessesByJobOrderId(jobOrderIds: List<Long>): Map<Long, ProductProcess> {
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"
}
}

+ 4
- 0
src/main/resources/application.yml Ver fichero

@@ -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:


Cargando…
Cancelar
Guardar