@@ -25,6 +25,12 @@ import org.springframework.orm.ObjectOptimisticLockingFailureException
private const val WORKBENCH_RELEASE_RETRY_MAX = 3
private const val WORKBENCH_RELEASE_RETRY_MAX = 3
/** 與 workbench 車線摘要一致:`車線-X`(預設車)不帶樓層 `storeId`。 */
private const val WORKBENCH_DEFAULT_TRUCK_LANCE_CODE = "車線-X"
/** 票號 `TI-B-yyyyMMdd-{floor}-nnn` 中段;無樓層時用 `X` 與 2F/4F 區隔。 */
private const val WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK = "X"
private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean {
private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean {
var c: Throwable? = t
var c: Throwable? = t
while (c != null) {
while (c != null) {
@@ -114,21 +120,32 @@ open class DoWorkbenchReleaseService(
}
}
open fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse =
open fun startBatchReleaseAsync(ids: List<Long>, userId: Long): MessageResponse =
startBatchReleaseAsyncInternal(ids, userId, useV2 = false)
startBatchReleaseAsyncInternal(ids, userId, useV2 = false, dopReleaseType = "batch" )
/**
/**
* V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket.
* V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket.
*/
*/
open fun startBatchReleaseAsyncV2(ids: List<Long>, userId: Long): MessageResponse =
open fun startBatchReleaseAsyncV2(ids: List<Long>, userId: Long): MessageResponse =
startBatchReleaseAsyncInternal(ids, userId, useV2 = true)
startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "batch" )
private fun startBatchReleaseAsyncInternal(ids: List<Long>, userId: Long, useV2: Boolean): MessageResponse {
/**
* V2 async for one (or few) DOs: [delivery_order_pick_order.releaseType] = `single`, ticket prefix `TI-S-` (aligned with legacy single DO pick tickets).
*/
open fun startBatchReleaseAsyncSingleV2(ids: List<Long>, userId: Long): MessageResponse =
startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "single")
private fun startBatchReleaseAsyncInternal(
ids: List<Long>,
userId: Long,
useV2: Boolean,
dopReleaseType: String,
): MessageResponse {
if (ids.isEmpty()) {
if (ids.isEmpty()) {
return MessageResponse(
return MessageResponse(
id = null,
id = null,
code = "NO_IDS",
code = "NO_IDS",
name = null,
name = null,
type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async",
type = asyncJobType(useV2, dopReleaseType) ,
message = "No delivery order ids provided",
message = "No delivery order ids provided",
errorPosition = null,
errorPosition = null,
entity = null
entity = null
@@ -178,7 +195,7 @@ open class DoWorkbenchReleaseService(
}
}
try {
try {
createAndLinkDeliveryOrderPickOrders(successResults)
createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType )
} catch (e: Exception) {
} catch (e: Exception) {
// header-link failure shouldn't crash job; status.failed already includes per-DO failures
// header-link failure shouldn't crash job; status.failed already includes per-DO failures
println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}")
println("❌ workbench createAndLinkDeliveryOrderPickOrders failed: ${e.message}")
@@ -207,8 +224,8 @@ open class DoWorkbenchReleaseService(
id = null,
id = null,
code = "STARTED",
code = "STARTED",
name = null,
name = null,
type = if (useV2) "workbench_batch_release_async_v2" else "workbench_batch_release_async" ,
message = if (useV2) "Workbench batch release V2 started" else "Workbench batch release started" ,
type = asyncJobType(useV2, dopReleaseType) ,
message = asyncJobMessage(useV2, dopReleaseType) ,
errorPosition = null,
errorPosition = null,
entity = mapOf("jobId" to jobId, "total" to ids.size)
entity = mapOf("jobId" to jobId, "total" to ids.size)
)
)
@@ -312,7 +329,7 @@ open class DoWorkbenchReleaseService(
}
}
}
}
val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults)
val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch" )
if (!useV2) {
if (!useV2) {
successResults.forEach { result ->
successResults.forEach { result ->
try {
try {
@@ -342,14 +359,17 @@ open class DoWorkbenchReleaseService(
}
}
/**
/**
* Same visual format as batch DO pick tickets (`DoReleaseCoordinatorService`): `TI-B-yyyyMMdd-2F-001`.
* Allocates the next 3-digit suffix by scanning existing `do_pick_order.ticket_no` and
* `delivery_order_pick_order.ticketNo` with the same prefix (avoids `uk_dopo_ticket_no` clashes).
* `TI-B-yyyyMMdd-2F-001` (batch) or `TI-S-yyyyMMdd-2F-001` (single), same suffix rules as [DoReleaseCoordinatorService] / legacy `do_pick_order`.
*/
*/
private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String {
private fun nextDeliveryOrderPickOrderTicketNo(
requiredDate: LocalDate,
storeDisplay: String,
ticketLetter: String,
): String {
require(ticketLetter == "B" || ticketLetter == "S") { "ticketLetter must be B or S" }
val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd"))
val floor = storeDisplay.replace("/", "").trim()
val floor = storeDisplay.replace("/", "").trim()
val prefix = "TI-B-$ymd-$floor-"
val prefix = "TI-$ticketLetter -$ymd-$floor-"
val sql = """
val sql = """
SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%')
SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%')
UNION ALL
UNION ALL
@@ -371,6 +391,32 @@ open class DoWorkbenchReleaseService(
return "$prefix${next.toString().padStart(3, '0')}"
return "$prefix${next.toString().padStart(3, '0')}"
}
}
private fun nextDeliveryOrderPickOrderBatchTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "B")
private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String =
nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S")
private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String {
val single = dopReleaseType.equals("single", ignoreCase = true)
return when {
useV2 && single -> "workbench_single_release_async_v2"
useV2 -> "workbench_batch_release_async_v2"
single -> "workbench_single_release_async"
else -> "workbench_batch_release_async"
}
}
private fun asyncJobMessage(useV2: Boolean, dopReleaseType: String): String {
val single = dopReleaseType.equals("single", ignoreCase = true)
return when {
useV2 && single -> "Workbench single release V2 started"
useV2 -> "Workbench batch release V2 started"
single -> "Workbench single release started"
else -> "Workbench batch release started"
}
}
private fun getOrderedDeliveryOrderIds(ids: List<Long>): List<Long> {
private fun getOrderedDeliveryOrderIds(ids: List<Long>): List<Long> {
if (ids.isEmpty()) return emptyList()
if (ids.isEmpty()) return emptyList()
return try {
return try {
@@ -388,9 +434,17 @@ open class DoWorkbenchReleaseService(
}
}
}
}
private fun createAndLinkDeliveryOrderPickOrders(results: List<ReleaseDoResult>): Int {
private fun createAndLinkDeliveryOrderPickOrders(
results: List<ReleaseDoResult>,
dopReleaseType: String = "batch",
): Int {
if (results.isEmpty()) return 0
if (results.isEmpty()) return 0
val releaseTypeCol = when (dopReleaseType.lowercase()) {
"single" -> "single"
else -> "batch"
}
val grouped = results.groupBy {
val grouped = results.groupBy {
listOf(
listOf(
it.shopId?.toString() ?: "",
it.shopId?.toString() ?: "",
@@ -405,13 +459,29 @@ open class DoWorkbenchReleaseService(
var createdHeaders = 0
var createdHeaders = 0
grouped.values.forEach { group ->
grouped.values.forEach { group ->
val first = group.first()
val first = group.first()
val storeId = when (first.preferredFloor) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> "2/F"
val isDefaultTruckLane =
first.usedDefaultTruck == true ||
first.truckLanceCode?.trim() == WORKBENCH_DEFAULT_TRUCK_LANCE_CODE
val storeId: String? = if (isDefaultTruckLane) {
null
} else {
when (first.preferredFloor) {
"2F" -> "2/F"
"4F" -> "4/F"
else -> "2/F"
}
}
val ticketFloorSegment = if (isDefaultTruckLane) {
WORKBENCH_TICKET_FLOOR_SEGMENT_DEFAULT_TRUCK
} else {
(storeId ?: "2/F").replace("/", "").trim()
}
}
val requiredDate = first.estimatedArrivalDate ?: LocalDate.now()
val requiredDate = first.estimatedArrivalDate ?: LocalDate.now()
val tempTicket = nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, storeId)
val tempTicket = if (releaseTypeCol == "single") {
nextDeliveryOrderPickOrderSingleTicketNo(requiredDate, ticketFloorSegment)
} else {
nextDeliveryOrderPickOrderBatchTicketNo(requiredDate, ticketFloorSegment)
}
val now = LocalDateTime.now()
val now = LocalDateTime.now()
// Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case.
// Column names must match Liquibase `01_alter_stock_take.sql` (camelCase), not snake_case.
@@ -425,7 +495,7 @@ open class DoWorkbenchReleaseService(
) VALUES (
) VALUES (
:truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime,
:truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime,
:truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo,
:truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo,
NULL, 'pending', 'batch' , NULL, NULL,
NULL, 'pending', :releaseType , NULL, NULL,
:created, :createdBy, 0, :modified, :modifiedBy, 0
:created, :createdBy, 0, :modified, :modifiedBy, 0
)
)
""".trimIndent(),
""".trimIndent(),
@@ -440,6 +510,7 @@ open class DoWorkbenchReleaseService(
"shopName" to first.shopName,
"shopName" to first.shopName,
"loadingSequence" to first.loadingSequence,
"loadingSequence" to first.loadingSequence,
"ticketNo" to tempTicket,
"ticketNo" to tempTicket,
"releaseType" to releaseTypeCol,
"created" to now,
"created" to now,
"createdBy" to "system",
"createdBy" to "system",
"modified" to now,
"modified" to now,