|
|
|
@@ -9,6 +9,9 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandi |
|
|
|
import com.ffii.fpsms.modules.master.web.models.MessageResponse |
|
|
|
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService |
|
|
|
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService |
|
|
|
import com.ffii.fpsms.modules.user.entity.UserRepository |
|
|
|
import org.springframework.security.core.context.SecurityContext |
|
|
|
import org.springframework.security.core.context.SecurityContextHolder |
|
|
|
import org.springframework.stereotype.Service |
|
|
|
import org.springframework.transaction.annotation.Transactional |
|
|
|
import java.time.Instant |
|
|
|
@@ -95,6 +98,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, |
|
|
|
private val stockOutLineWorkbenchService: StockOutLineWorkbenchService, |
|
|
|
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, |
|
|
|
private val userRepository: UserRepository, |
|
|
|
) { |
|
|
|
private val poolSize = Runtime.getRuntime().availableProcessors() |
|
|
|
private val executor = Executors.newFixedThreadPool(kotlin.math.min(poolSize, 4)) |
|
|
|
@@ -142,6 +146,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
open fun startBatchReleaseAsyncSingleV2(ids: List<Long>, userId: Long): MessageResponse = |
|
|
|
startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single") |
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ |
|
|
|
private fun startBatchReleaseAsyncInternal( |
|
|
|
ids: List<Long>, |
|
|
|
userId: Long, |
|
|
|
@@ -164,75 +169,83 @@ open class DoWorkbenchReleaseService( |
|
|
|
val jobId = UUID.randomUUID().toString() |
|
|
|
val status = WorkbenchBatchReleaseJobStatus(jobId = jobId, total = ids.size) |
|
|
|
jobs[jobId] = status |
|
|
|
val actor = resolveReleaseActor(userId) |
|
|
|
// Capture auth before worker thread: BaseEntity @PrePersist needs SecurityContext for pick_order / lines. |
|
|
|
val workerSecurityContext = copySecurityContextForWorker() |
|
|
|
|
|
|
|
executor.submit { |
|
|
|
batchReleaseConcurrencyGate.acquireUninterruptibly() |
|
|
|
try { |
|
|
|
val orderedIds = getOrderedDeliveryOrderIds(ids) |
|
|
|
val successResults = mutableListOf<ReleaseDoResult>() |
|
|
|
SecurityContextHolder.setContext(workerSecurityContext) |
|
|
|
batchReleaseConcurrencyGate.acquireUninterruptibly() |
|
|
|
try { |
|
|
|
val orderedIds = getOrderedDeliveryOrderIds(ids) |
|
|
|
val successResults = mutableListOf<ReleaseDoResult>() |
|
|
|
|
|
|
|
orderedIds.forEach { deliveryOrderId -> |
|
|
|
try { |
|
|
|
val statusRows = jdbcDao.queryForList( |
|
|
|
""" |
|
|
|
SELECT status |
|
|
|
FROM fpsmsdb.delivery_order |
|
|
|
WHERE id = :id AND deleted = 0 |
|
|
|
""".trimIndent(), |
|
|
|
mapOf("id" to deliveryOrderId) |
|
|
|
) |
|
|
|
val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() |
|
|
|
if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { |
|
|
|
return@forEach |
|
|
|
} |
|
|
|
orderedIds.forEach { deliveryOrderId -> |
|
|
|
try { |
|
|
|
val statusRows = jdbcDao.queryForList( |
|
|
|
""" |
|
|
|
SELECT status |
|
|
|
FROM fpsmsdb.delivery_order |
|
|
|
WHERE id = :id AND deleted = 0 |
|
|
|
""".trimIndent(), |
|
|
|
mapOf("id" to deliveryOrderId) |
|
|
|
) |
|
|
|
val currentStatus = statusRows.firstOrNull()?.get("status")?.toString()?.lowercase() |
|
|
|
if (currentStatus == DeliveryOrderStatus.COMPLETED.value || currentStatus == DeliveryOrderStatus.RECEIVING.value) { |
|
|
|
return@forEach |
|
|
|
} |
|
|
|
|
|
|
|
val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) |
|
|
|
val released = releaseDeliveryOrderWorkbenchWithRetries(deliveryOrderId, userId, useV2) |
|
|
|
|
|
|
|
if (released != null) { |
|
|
|
successResults += released |
|
|
|
status.success.incrementAndGet() |
|
|
|
} else { |
|
|
|
if (released != null) { |
|
|
|
successResults += released |
|
|
|
status.success.incrementAndGet() |
|
|
|
} else { |
|
|
|
synchronized(status.failed) { |
|
|
|
status.failed.add(deliveryOrderId to "Release returned null") |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e: Exception) { |
|
|
|
synchronized(status.failed) { |
|
|
|
status.failed.add(deliveryOrderId to "Release returned null") |
|
|
|
status.failed.add(deliveryOrderId to (e.message ?: "Unknown error")) |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (e: Exception) { |
|
|
|
synchronized(status.failed) { |
|
|
|
status.failed.add(deliveryOrderId to (e.message ?: "Unknown error")) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
try { |
|
|
|
createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket) |
|
|
|
} catch (e: Exception) { |
|
|
|
// header-link failure shouldn't crash job; status.failed already includes per-DO failures |
|
|
|
println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") |
|
|
|
} |
|
|
|
|
|
|
|
val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) |
|
|
|
try { |
|
|
|
createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType, mergeExtraIntoLaneTicket, actor) |
|
|
|
} catch (e: Exception) { |
|
|
|
// header-link failure shouldn't crash job; status.failed already includes per-DO failures |
|
|
|
println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}") |
|
|
|
} |
|
|
|
|
|
|
|
if (!useV2) { |
|
|
|
val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() |
|
|
|
pickOrdersForDownstream.forEach { pickOrderId -> |
|
|
|
try { |
|
|
|
suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId) |
|
|
|
stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) |
|
|
|
} catch (e: Exception) { |
|
|
|
val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId |
|
|
|
?: 0L |
|
|
|
synchronized(status.failed) { |
|
|
|
status.failed.add( |
|
|
|
deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}") |
|
|
|
) |
|
|
|
val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) |
|
|
|
|
|
|
|
if (!useV2) { |
|
|
|
val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() |
|
|
|
pickOrdersForDownstream.forEach { pickOrderId -> |
|
|
|
try { |
|
|
|
suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrder(pickOrderId) |
|
|
|
stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderNoHold(pickOrderId, userId) |
|
|
|
} catch (e: Exception) { |
|
|
|
val deliveryOrderId = successResults.firstOrNull { it.pickOrderId == pickOrderId }?.deliveryOrderId |
|
|
|
?: 0L |
|
|
|
synchronized(status.failed) { |
|
|
|
status.failed.add( |
|
|
|
deliveryOrderId to ("Downstream workbench step failed for pick order $pickOrderId: ${e.message}") |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
} finally { |
|
|
|
status.running = false |
|
|
|
status.finishedAt = Instant.now().toEpochMilli() |
|
|
|
batchReleaseConcurrencyGate.release() |
|
|
|
} |
|
|
|
} finally { |
|
|
|
status.running = false |
|
|
|
status.finishedAt = Instant.now().toEpochMilli() |
|
|
|
batchReleaseConcurrencyGate.release() |
|
|
|
SecurityContextHolder.clearContext() |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
@@ -318,6 +331,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
val actor = resolveReleaseActor(userId) |
|
|
|
val orderedIds = getOrderedDeliveryOrderIds(ids) |
|
|
|
val successResults = mutableListOf<ReleaseDoResult>() |
|
|
|
val failed = mutableListOf<Pair<Long, String>>() |
|
|
|
@@ -350,7 +364,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket) |
|
|
|
val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket, actor) |
|
|
|
val replenishmentPickOrderIds = runWorkbenchReplenishmentRelease(successResults) |
|
|
|
if (!useV2) { |
|
|
|
val pickOrdersForDownstream = (successResults.map { it.pickOrderId } + replenishmentPickOrderIds).toSet() |
|
|
|
@@ -504,6 +518,37 @@ open class DoWorkbenchReleaseService( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 |
|
|
|
* Username for ticket createdBy/modifiedBy. Capture on the request thread, then copy onto the worker. |
|
|
|
*/ |
|
|
|
private fun resolveReleaseActor(userId: Long): String { |
|
|
|
val fromSecurity = SecurityContextHolder.getContext().authentication?.name |
|
|
|
?.trim() |
|
|
|
?.takeIf { it.isNotEmpty() && !it.equals("anonymousUser", ignoreCase = true) } |
|
|
|
?.take(30) |
|
|
|
if (fromSecurity != null) return fromSecurity |
|
|
|
if (userId > 0L) { |
|
|
|
val fromUser = userRepository.findById(userId).orElse(null)?.username |
|
|
|
?.trim() |
|
|
|
?.takeIf { it.isNotEmpty() } |
|
|
|
?.take(30) |
|
|
|
if (fromUser != null) return fromUser |
|
|
|
} |
|
|
|
return "system" |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 |
|
|
|
* Copy auth for the worker thread so BaseEntity @PrePersist matches JO / consumable pick-order creation. |
|
|
|
*/ |
|
|
|
private fun copySecurityContextForWorker(): SecurityContext { |
|
|
|
val parent = SecurityContextHolder.getContext() |
|
|
|
val copy = SecurityContextHolder.createEmptyContext() |
|
|
|
copy.authentication = parent.authentication |
|
|
|
return copy |
|
|
|
} |
|
|
|
|
|
|
|
private fun resolveTicketFloorSegment(storeId: String?, isDefaultTruckLane: Boolean): String = |
|
|
|
if (isDefaultTruckLane) { |
|
|
|
WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK |
|
|
|
@@ -837,7 +882,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
private fun setRelationshipIdSelf(headerId: Long) { |
|
|
|
private fun setRelationshipIdSelf(headerId: Long, actor: String) { |
|
|
|
jdbcDao.executeUpdate( |
|
|
|
""" |
|
|
|
UPDATE fpsmsdb.delivery_order_pick_order |
|
|
|
@@ -849,7 +894,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
mapOf( |
|
|
|
"headerId" to headerId, |
|
|
|
"modified" to LocalDateTime.now(), |
|
|
|
"modifiedBy" to "system", |
|
|
|
"modifiedBy" to actor, |
|
|
|
), |
|
|
|
) |
|
|
|
} |
|
|
|
@@ -914,10 +959,11 @@ open class DoWorkbenchReleaseService( |
|
|
|
isSingleRelease: Boolean, |
|
|
|
requiredDate: LocalDate, |
|
|
|
ticketFloorSegment: String, |
|
|
|
actor: String, |
|
|
|
): Long? { |
|
|
|
val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) |
|
|
|
val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) |
|
|
|
return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo) |
|
|
|
return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo, actor) |
|
|
|
} |
|
|
|
|
|
|
|
private fun linkPickOrdersToHeader(headerId: Long, group: List<ReleaseDoResult>) { |
|
|
|
@@ -941,6 +987,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
storeId: String?, |
|
|
|
releaseTypeCol: String, |
|
|
|
ticketNo: String, |
|
|
|
actor: String, |
|
|
|
): Long? { |
|
|
|
val now = LocalDateTime.now() |
|
|
|
jdbcDao.executeUpdate( |
|
|
|
@@ -970,9 +1017,9 @@ open class DoWorkbenchReleaseService( |
|
|
|
"ticketNo" to ticketNo, |
|
|
|
"releaseType" to releaseTypeCol, |
|
|
|
"created" to now, |
|
|
|
"createdBy" to "system", |
|
|
|
"createdBy" to actor, |
|
|
|
"modified" to now, |
|
|
|
"modifiedBy" to "system", |
|
|
|
"modifiedBy" to actor, |
|
|
|
) |
|
|
|
) |
|
|
|
return jdbcDao.queryForList( |
|
|
|
@@ -984,7 +1031,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
LIMIT 1 |
|
|
|
""".trimIndent(), |
|
|
|
mapOf("ticketNo" to ticketNo) |
|
|
|
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) } |
|
|
|
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) } |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
@@ -997,6 +1044,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
isSingleRelease: Boolean, |
|
|
|
requiredDate: LocalDate, |
|
|
|
ticketFloorSegment: String, |
|
|
|
actor: String, |
|
|
|
): Int { |
|
|
|
val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease) |
|
|
|
if (activeMerge != null) { |
|
|
|
@@ -1010,14 +1058,14 @@ open class DoWorkbenchReleaseService( |
|
|
|
val sourceToRetire = plainBatch ?: legacyUpgraded |
|
|
|
|
|
|
|
if (sourceToRetire != null) { |
|
|
|
val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) |
|
|
|
val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor) |
|
|
|
?: return 0 |
|
|
|
retireSourceTicketIntoMergeHeader(sourceToRetire.id, mergeHeaderId) |
|
|
|
linkPickOrdersToHeader(mergeHeaderId, group) |
|
|
|
return 1 |
|
|
|
} |
|
|
|
|
|
|
|
val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) |
|
|
|
val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment, actor) |
|
|
|
?: return 0 |
|
|
|
linkPickOrdersToHeader(mergeHeaderId, group) |
|
|
|
return 1 |
|
|
|
@@ -1033,6 +1081,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
results: List<ReleaseDoResult>, |
|
|
|
dopReleaseType: String = "batch", |
|
|
|
mergeExtraIntoLaneTicket: Boolean = true, |
|
|
|
actor: String, |
|
|
|
): Int { |
|
|
|
if (results.isEmpty()) return 0 |
|
|
|
|
|
|
|
@@ -1064,6 +1113,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
isSingleRelease, |
|
|
|
requiredDate, |
|
|
|
ticketFloorSegment, |
|
|
|
actor, |
|
|
|
) |
|
|
|
} |
|
|
|
|
|
|
|
@@ -1079,6 +1129,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
storeId, |
|
|
|
WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA, |
|
|
|
ticketNo, |
|
|
|
actor, |
|
|
|
) ?: return 0 |
|
|
|
linkPickOrdersToHeader(headerId, group) |
|
|
|
return 1 |
|
|
|
@@ -1104,7 +1155,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
} else { |
|
|
|
nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment) |
|
|
|
} |
|
|
|
val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo) |
|
|
|
val headerId = insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseTypeCol, ticketNo, actor) |
|
|
|
?: return 0 |
|
|
|
linkPickOrdersToHeader(headerId, group) |
|
|
|
return 1 |
|
|
|
@@ -1206,6 +1257,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
isSingleRelease = isSingleRelease, |
|
|
|
requiredDate = requiredDate, |
|
|
|
ticketFloorSegment = ticketFloorSegment, |
|
|
|
actor = resolveReleaseActor(0L), |
|
|
|
) ?: return mergeTicketsError("CREATE_FAILED", "Failed to create TI-M merge ticket") |
|
|
|
} |
|
|
|
|
|
|
|
@@ -1473,6 +1525,7 @@ open class DoWorkbenchReleaseService( |
|
|
|
isSingleRelease: Boolean, |
|
|
|
requiredDate: LocalDate, |
|
|
|
ticketFloorSegment: String, |
|
|
|
actor: String, |
|
|
|
): Long? { |
|
|
|
val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) |
|
|
|
val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) |
|
|
|
@@ -1504,9 +1557,9 @@ open class DoWorkbenchReleaseService( |
|
|
|
"ticketNo" to ticketNo, |
|
|
|
"releaseType" to releaseType, |
|
|
|
"created" to now, |
|
|
|
"createdBy" to "system", |
|
|
|
"createdBy" to actor, |
|
|
|
"modified" to now, |
|
|
|
"modifiedBy" to "system", |
|
|
|
"modifiedBy" to actor, |
|
|
|
), |
|
|
|
) |
|
|
|
return jdbcDao.queryForList( |
|
|
|
@@ -1515,6 +1568,6 @@ open class DoWorkbenchReleaseService( |
|
|
|
WHERE ticketNo = :ticketNo ORDER BY id DESC LIMIT 1 |
|
|
|
""".trimIndent(), |
|
|
|
mapOf("ticketNo" to ticketNo), |
|
|
|
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) } |
|
|
|
).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it, actor) } |
|
|
|
} |
|
|
|
} |