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