From b02f176e088dec63ea5117de1978c52b09da6e54 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 10 Jun 2026 12:34:34 +0800 Subject: [PATCH] job order bom status and do merge --- .../entity/DeliveryOrderPickOrder.kt | 4 + .../service/DoWorkbenchReleaseService.kt | 887 ++++++++++++++++-- .../service/WorkbenchReleaseTypeSupport.kt | 4 + .../web/DoWorkbenchController.kt | 32 +- .../models/WorkbenchBatchReleaseRequest.kt | 11 + .../web/models/WorkbenchMergeTicketModels.kt | 35 + .../ffii/fpsms/modules/master/entity/Bom.kt | 6 + .../modules/master/entity/BomRepository.kt | 5 +- .../master/entity/projections/BomCombo.kt | 2 + .../fpsms/modules/master/enums/BomStatus.kt | 12 + .../master/enums/BomStatusConverter.kt | 12 + .../modules/master/service/BomService.kt | 15 + .../service/ProductionScheduleService.kt | 2 +- .../fpsms/modules/master/web/BomController.kt | 11 +- .../master/web/models/EditBomRequest.kt | 1 + .../master/web/models/ItemUomRequest.kt | 1 + .../changes/20260609_Enson/02_setting.sql | 7 + .../changes/20260609_Enson/03_bom_status.sql | 7 + 18 files changed, 961 insertions(+), 93 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchReleaseRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchMergeTicketModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/enums/BomStatus.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/enums/BomStatusConverter.kt create mode 100644 src/main/resources/db/changelog/changes/20260609_Enson/02_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20260609_Enson/03_bom_status.sql diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt index 93e5171..57472ce 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderPickOrder.kt @@ -61,6 +61,10 @@ class DeliveryOrderPickOrder { @Column(name = "cartonQty") var cartonQty: Int? = null + /** Merge lineage: equals own [id] until soft-deleted into a successor [TI-M] header. */ + @Column(name = "relationshipId") + var relationshipId: Long? = null + @CreationTimestamp @Column(name = "created") var created: LocalDateTime? = null diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt index e5d3bda..c45e8a8 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchReleaseService.kt @@ -4,6 +4,8 @@ import com.ffii.core.support.JdbcDao import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoRequest import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleaseDoResult +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandidate +import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchMergeTicketCandidatesResponse 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 @@ -13,6 +15,7 @@ import java.time.Instant import java.sql.SQLException import java.time.LocalDate import java.time.LocalDateTime +import java.time.LocalTime import java.time.format.DateTimeFormatter import java.util.UUID import java.util.concurrent.ConcurrentHashMap @@ -31,6 +34,9 @@ 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" +/** Merged batch/single-family workbench ticket prefix. */ +private const val WORKBENCH_MERGE_TICKET_PREFIX = "TI-M-" + private fun isWorkbenchOptimisticLockFailure(t: Throwable?): Boolean { var c: Throwable? = t while (c != null) { @@ -125,8 +131,8 @@ open class DoWorkbenchReleaseService( /** * V2: deferred suggested pick / stock out / stock out lines until [DoWorkbenchDopoAssignmentService] assigns the ticket. */ - open fun startBatchReleaseAsyncV2(ids: List, userId: Long): MessageResponse = - startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "batch") + open fun startBatchReleaseAsyncV2(ids: List, userId: Long, mergeExtraIntoLaneTicket: Boolean = true): MessageResponse = + startBatchReleaseAsyncInternal(ids, userId, useV2 = true, dopReleaseType = "batch", mergeExtraIntoLaneTicket = mergeExtraIntoLaneTicket) /** * V2 async for one (or few) DOs: [delivery_order_pick_order.releaseType] = `single`, ticket prefix `TI-S-` (aligned with legacy single DO pick tickets). @@ -139,6 +145,7 @@ open class DoWorkbenchReleaseService( userId: Long, useV2: Boolean, dopReleaseType: String, + mergeExtraIntoLaneTicket: Boolean = true, ): MessageResponse { if (ids.isEmpty()) { return MessageResponse( @@ -195,7 +202,7 @@ open class DoWorkbenchReleaseService( } try { - createAndLinkDeliveryOrderPickOrders(successResults, dopReleaseType) + 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}") @@ -281,10 +288,15 @@ open class DoWorkbenchReleaseService( * and skips suggested pick + stock out line creation (done on ticket assign). */ @Transactional(rollbackFor = [Exception::class]) - open fun releaseBatchV2(ids: List, userId: Long): MessageResponse = - releaseBatchInternal(ids, userId, useV2 = true) + open fun releaseBatchV2(ids: List, userId: Long, mergeExtraIntoLaneTicket: Boolean = true): MessageResponse = + releaseBatchInternal(ids, userId, useV2 = true, mergeExtraIntoLaneTicket = mergeExtraIntoLaneTicket) - private fun releaseBatchInternal(ids: List, userId: Long, useV2: Boolean): MessageResponse { + private fun releaseBatchInternal( + ids: List, + userId: Long, + useV2: Boolean, + mergeExtraIntoLaneTicket: Boolean = true, + ): MessageResponse { if (ids.isEmpty()) { return MessageResponse( id = null, @@ -329,7 +341,7 @@ open class DoWorkbenchReleaseService( } } - val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch") + val createdHeaders = createAndLinkDeliveryOrderPickOrders(successResults, "batch", mergeExtraIntoLaneTicket) if (!useV2) { successResults.forEach { result -> try { @@ -367,17 +379,19 @@ open class DoWorkbenchReleaseService( storeDisplay: String, ticketLetter: String, ): String { - require(ticketLetter == "B" || ticketLetter == "S") { - "ticketLetter must be B or S" + require(ticketLetter == "B" || ticketLetter == "S" || ticketLetter == "E" || ticketLetter == "M") { + "ticketLetter must be B, S, E, or M" } val ymd = requiredDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")) val floor = storeDisplay.replace("/", "").trim() val prefix = "TI-$ticketLetter-$ymd-$floor-" val sql = """ - SELECT ticket_no AS t FROM fpsmsdb.do_pick_order WHERE deleted = 0 AND ticket_no LIKE CONCAT(:prefix, '%') - UNION ALL - SELECT ticketNo AS t FROM fpsmsdb.delivery_order_pick_order WHERE deleted = 0 AND ticketNo LIKE CONCAT(:prefix, '%') - """.trimIndent() + SELECT ticket_no AS t FROM fpsmsdb.do_pick_order + WHERE ticket_no LIKE CONCAT(:prefix, '%') + UNION ALL + SELECT ticketNo AS t FROM fpsmsdb.delivery_order_pick_order + WHERE ticketNo LIKE CONCAT(:prefix, '%') +""".trimIndent() val rows = try { jdbcDao.queryForList(sql, mapOf("prefix" to prefix)) } catch (_: Exception) { @@ -400,6 +414,15 @@ open class DoWorkbenchReleaseService( private fun nextDeliveryOrderPickOrderSingleTicketNo(requiredDate: LocalDate, storeDisplay: String): String = nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "S") + private fun nextDeliveryOrderPickOrderEtraTicketNo(requiredDate: LocalDate, storeDisplay: String): String = + nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "E") + + private fun nextDeliveryOrderPickOrderMergeTicketNo(requiredDate: LocalDate, storeDisplay: String): String = + nextDeliveryOrderPickOrderTicketNo(requiredDate, storeDisplay, "M") + + private fun isWorkbenchMergeTicketNo(ticketNo: String?): Boolean = + ticketNo?.trim()?.uppercase()?.startsWith(WORKBENCH_MERGE_TICKET_PREFIX) == true + private fun asyncJobType(useV2: Boolean, dopReleaseType: String): String { val single = dopReleaseType.equals("single", ignoreCase = true) return when { @@ -467,10 +490,61 @@ open class DoWorkbenchReleaseService( (storeId ?: "2/F").replace("/", "").trim() } - private data class MergeableWorkbenchTicket(val id: Long, val releaseType: String?) + private data class MergeableWorkbenchTicket( + val id: Long, + val releaseType: String?, + val ticketNo: String?, + ) + + private fun appendLaneMatchClauses( + sql: StringBuilder, + first: ReleaseDoResult, + storeId: String?, + params: MutableMap, + ) { + if (storeId.isNullOrBlank()) { + sql.append(" AND (storeId IS NULL OR TRIM(COALESCE(storeId, '')) = '') ") + } else { + sql.append(" AND storeId = :storeId ") + params["storeId"] = storeId + } + if (first.truckId != null) { + sql.append(" AND truckId = :truckId ") + params["truckId"] = first.truckId!! + } else { + sql.append(" AND truckId IS NULL ") + } + if (first.truckDepartureTime != null) { + sql.append(" AND truckDepartureTime = :truckDepartureTime ") + params["truckDepartureTime"] = first.truckDepartureTime!! + } else { + sql.append(" AND truckDepartureTime IS NULL ") + } + if (!first.truckLanceCode.isNullOrBlank()) { + sql.append(" AND truckLanceCode = :truckLanceCode ") + params["truckLanceCode"] = first.truckLanceCode!!.trim() + } else { + sql.append(" AND (truckLanceCode IS NULL OR TRIM(truckLanceCode) = '') ") + } + if (first.loadingSequence != null) { + sql.append(" AND loadingSequence = :loadingSequence ") + params["loadingSequence"] = first.loadingSequence!! + } + } + + private fun mapRowToMergeableTicket(row: Map): MergeableWorkbenchTicket? { + fun cell(name: String): String? { + val key = row.keys.find { it.equals(name, true) } ?: return null + return row[key]?.toString()?.trim()?.takeIf { it.isNotEmpty() } + } + val idKey = row.keys.find { it.equals("id", true) } ?: return null + val id = (row[idKey] as? Number)?.toLong() ?: return null + return MergeableWorkbenchTicket(id = id, releaseType = cell("releaseType"), ticketNo = cell("ticketNo")) + } /** - * Active batch/single-family ticket for the same lane. Excludes completed tickets and legacy `isExtra`. + * Active batch/single-family ticket for the same lane. + * Strategy 1: prefer [TI-M] over legacy [TI-B] / in-place [isExtrabatch]. */ private fun findMergeableWorkbenchTicket( first: ReleaseDoResult, @@ -488,7 +562,7 @@ open class DoWorkbenchReleaseService( val sql = StringBuilder( """ - SELECT id, releaseType + SELECT id, releaseType, ticketNo FROM fpsmsdb.delivery_order_pick_order WHERE deleted = 0 AND shopId = :shopId @@ -501,34 +575,164 @@ open class DoWorkbenchReleaseService( "shopId" to first.shopId!!, "requiredDate" to first.estimatedArrivalDate!!, ) - if (storeId.isNullOrBlank()) { - sql.append(" AND (storeId IS NULL OR TRIM(COALESCE(storeId, '')) = '') ") - } else { - sql.append(" AND storeId = :storeId ") - params["storeId"] = storeId - } - if (first.truckId != null) { - sql.append(" AND truckId = :truckId ") - params["truckId"] = first.truckId!! - } else { - sql.append(" AND truckId IS NULL ") + appendLaneMatchClauses(sql, first, storeId, params) + sql.append( + """ + ORDER BY + CASE + WHEN ticketNo LIKE 'TI-M-%' THEN 0 + WHEN LOWER(COALESCE(releaseType, '')) IN ('isextrabatch', 'isextrasingle') THEN 1 + ELSE 2 + END, + id ASC + """.trimIndent() + ) + + val rows = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (_: Exception) { + emptyList() } - if (first.truckDepartureTime != null) { - sql.append(" AND truckDepartureTime = :truckDepartureTime ") - params["truckDepartureTime"] = first.truckDepartureTime!! - } else { - sql.append(" AND truckDepartureTime IS NULL ") + return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) } + } + + /** Pending [TI-M] merge ticket on the same lane (D1 follow-up / extra merge target). */ + private fun findActiveMergeTicket( + first: ReleaseDoResult, + storeId: String?, + isSingleRelease: Boolean, + ): MergeableWorkbenchTicket? { + if (first.shopId == null || first.estimatedArrivalDate == null) return null + val mergeType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease).lowercase() + + val sql = StringBuilder( + """ + SELECT id, releaseType, ticketNo + FROM fpsmsdb.delivery_order_pick_order + WHERE deleted = 0 + AND shopId = :shopId + AND requiredDeliveryDate = :requiredDate + AND ticketStatus IN ('pending', 'released') + AND ticketNo LIKE 'TI-M-%' + AND LOWER(COALESCE(releaseType, '')) = :mergeType + """.trimIndent() + ) + val params = mutableMapOf( + "shopId" to first.shopId!!, + "requiredDate" to first.estimatedArrivalDate!!, + "mergeType" to mergeType, + ) + appendLaneMatchClauses(sql, first, storeId, params) + sql.append(" ORDER BY id ASC ") + + val rows = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (_: Exception) { + emptyList() } - if (!first.truckLanceCode.isNullOrBlank()) { - sql.append(" AND truckLanceCode = :truckLanceCode ") - params["truckLanceCode"] = first.truckLanceCode!!.trim() - } else { - sql.append(" AND (truckLanceCode IS NULL OR TRIM(truckLanceCode) = '') ") + return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) } + } + + /** + * Plain `batch` / `single` ticket (not [isExtrabatch]) on the same lane — candidate to retire into [TI-M]. + */ + private fun findPlainFamilyTicketForMergeRetire( + first: ReleaseDoResult, + storeId: String?, + isSingleRelease: Boolean, + ): MergeableWorkbenchTicket? { + if (first.shopId == null || first.estimatedArrivalDate == null) return null + val plainType = if (isSingleRelease) WorkbenchReleaseTypeSupport.SINGLE else WorkbenchReleaseTypeSupport.BATCH + + val sql = StringBuilder( + """ + SELECT id, releaseType, ticketNo + FROM fpsmsdb.delivery_order_pick_order + WHERE deleted = 0 + AND shopId = :shopId + AND requiredDeliveryDate = :requiredDate + AND ticketStatus IN ('pending', 'released') + AND handledBy IS NULL + AND LOWER(COALESCE(releaseType, '')) = :plainType + """.trimIndent() + ) + val params = mutableMapOf( + "shopId" to first.shopId!!, + "requiredDate" to first.estimatedArrivalDate!!, + "plainType" to plainType.lowercase(), + ) + appendLaneMatchClauses(sql, first, storeId, params) + sql.append(" ORDER BY id ASC ") + + val rows = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (_: Exception) { + emptyList() } - if (first.loadingSequence != null) { - sql.append(" AND loadingSequence = :loadingSequence ") - params["loadingSequence"] = first.loadingSequence!! + return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) } + } + + /** Legacy in-place upgraded header (`isExtrabatch` on `TI-B-`) to retire when creating [TI-M]. */ + private fun findLegacyUpgradedFamilyTicket( + first: ReleaseDoResult, + storeId: String?, + isSingleRelease: Boolean, + ): MergeableWorkbenchTicket? { + if (first.shopId == null || first.estimatedArrivalDate == null) return null + val upgradedType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease).lowercase() + + val sql = StringBuilder( + """ + SELECT id, releaseType, ticketNo + FROM fpsmsdb.delivery_order_pick_order + WHERE deleted = 0 + AND shopId = :shopId + AND requiredDeliveryDate = :requiredDate + AND ticketStatus IN ('pending', 'released') + AND handledBy IS NULL + AND LOWER(COALESCE(releaseType, '')) = :upgradedType + AND (ticketNo IS NULL OR ticketNo NOT LIKE 'TI-M-%') + """.trimIndent() + ) + val params = mutableMapOf( + "shopId" to first.shopId!!, + "requiredDate" to first.estimatedArrivalDate!!, + "upgradedType" to upgradedType, + ) + appendLaneMatchClauses(sql, first, storeId, params) + sql.append(" ORDER BY id ASC ") + + val rows = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (_: Exception) { + emptyList() } + return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) } + } + + /** Pending standalone `isExtra` ticket on the same lane (Case 2 grouping; excludes batch family). */ + private fun findMergeableStandaloneIsExtraTicket( + first: ReleaseDoResult, + storeId: String?, + ): MergeableWorkbenchTicket? { + if (first.shopId == null || first.estimatedArrivalDate == null) return null + + val sql = StringBuilder( + """ + SELECT id, releaseType, ticketNo + FROM fpsmsdb.delivery_order_pick_order + WHERE deleted = 0 + AND shopId = :shopId + AND requiredDeliveryDate = :requiredDate + AND ticketStatus IN ('pending', 'released') + AND LOWER(COALESCE(releaseType, '')) = 'isextra' + """.trimIndent() + ) + val params = mutableMapOf( + "shopId" to first.shopId!!, + "requiredDate" to first.estimatedArrivalDate!!, + ) + appendLaneMatchClauses(sql, first, storeId, params) sql.append(" ORDER BY id ASC ") val rows = try { @@ -536,20 +740,108 @@ open class DoWorkbenchReleaseService( } catch (_: Exception) { emptyList() } - if (rows.isEmpty()) return null + return rows.firstOrNull()?.let { mapRowToMergeableTicket(it) } + } - fun rowId(row: Map): Long? { - val key = row.keys.find { it.equals("id", true) } ?: return null - return (row[key] as? Number)?.toLong() + private fun assertTicketUnassignedForMerge(ticketId: Long) { + val rows = jdbcDao.queryForList( + """ + SELECT handledBy + FROM fpsmsdb.delivery_order_pick_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to ticketId), + ) + val handledBy = rows.firstOrNull()?.let { row -> + val key = row.keys.find { it.equals("handledBy", true) } ?: return@let null + row[key] + } + if (handledBy != null) { + throw IllegalStateException("Ticket $ticketId is already assigned; merge not allowed") } - fun rowReleaseType(row: Map): String? { - val key = row.keys.find { it.equals("releaseType", true) } ?: return null - return row[key]?.toString() + } + + private fun setRelationshipIdSelf(headerId: Long) { + jdbcDao.executeUpdate( + """ + UPDATE fpsmsdb.delivery_order_pick_order + SET relationshipId = :headerId, + modified = :modified, + modifiedBy = :modifiedBy + WHERE id = :headerId + """.trimIndent(), + mapOf( + "headerId" to headerId, + "modified" to LocalDateTime.now(), + "modifiedBy" to "system", + ), + ) + } + + private fun softDeleteMergedSourceTicket(sourceId: Long, successorId: Long) { + jdbcDao.executeUpdate( + """ + UPDATE fpsmsdb.delivery_order_pick_order + SET deleted = 1, + relationshipId = :successorId, + modified = :modified, + modifiedBy = :modifiedBy + WHERE id = :sourceId AND deleted = 0 + """.trimIndent(), + mapOf( + "sourceId" to sourceId, + "successorId" to successorId, + "modified" to LocalDateTime.now(), + "modifiedBy" to "system", + ), + ) + } + + private fun reparentPickOrdersFromHeader(sourceHeaderId: Long, targetHeaderId: Long) { + jdbcDao.executeUpdate( + """ + UPDATE fpsmsdb.pick_order + SET deliveryOrderPickOrderId = :targetHeaderId + WHERE deliveryOrderPickOrderId = :sourceHeaderId AND deleted = 0 + """.trimIndent(), + mapOf( + "sourceHeaderId" to sourceHeaderId, + "targetHeaderId" to targetHeaderId, + ), + ) + } + + private fun retireSourceTicketIntoMergeHeader(sourceId: Long, mergeHeaderId: Long) { + assertTicketUnassignedForMerge(sourceId) + reparentPickOrdersFromHeader(sourceId, mergeHeaderId) + softDeleteMergedSourceTicket(sourceId, mergeHeaderId) + } + + /** Strategy 1: fold unassigned plain `batch` tickets into an active [TI-M] header. */ + private fun consolidateUnassignedPlainBatchTicketsIntoMergeHeader( + first: ReleaseDoResult, + storeId: String?, + isSingleRelease: Boolean, + mergeHeaderId: Long, + ) { + if (isSingleRelease) return + while (true) { + val stray = findPlainFamilyTicketForMergeRetire(first, storeId, isSingleRelease) ?: break + if (stray.id == mergeHeaderId) break + retireSourceTicketIntoMergeHeader(stray.id, mergeHeaderId) } + } - val matched = rows.first() - val id = rowId(matched) ?: return null - return MergeableWorkbenchTicket(id = id, releaseType = rowReleaseType(matched)) + private fun createNewMergeHeader( + first: ReleaseDoResult, + storeId: String?, + isSingleRelease: Boolean, + requiredDate: LocalDate, + ticketFloorSegment: String, + ): Long? { + val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) + val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) + return insertNewDeliveryOrderPickOrderHeader(first, storeId, releaseType, ticketNo) } private fun linkPickOrdersToHeader(headerId: Long, group: List) { @@ -568,34 +860,6 @@ open class DoWorkbenchReleaseService( } } - private fun maybeUpgradeReleaseType( - headerId: Long, - currentReleaseType: String?, - isExtraRelease: Boolean, - isSingleRelease: Boolean, - ) { - val upgraded = WorkbenchReleaseTypeSupport.upgradedReleaseTypeIfNeeded( - currentReleaseType, - isExtraRelease, - isSingleRelease, - ) ?: return - jdbcDao.executeUpdate( - """ - UPDATE fpsmsdb.delivery_order_pick_order - SET releaseType = :releaseType, - modified = :modified, - modifiedBy = :modifiedBy - WHERE id = :id AND deleted = 0 - """.trimIndent(), - mapOf( - "releaseType" to upgraded, - "modified" to LocalDateTime.now(), - "modifiedBy" to "system", - "id" to headerId, - ) - ) - } - private fun insertNewDeliveryOrderPickOrderHeader( first: ReleaseDoResult, storeId: String?, @@ -644,17 +908,55 @@ open class DoWorkbenchReleaseService( LIMIT 1 """.trimIndent(), mapOf("ticketNo" to ticketNo) - ).firstOrNull()?.get("id")?.let { (it as Number).toLong() } + ).firstOrNull()?.get("id")?.let { (it as Number).toLong() }?.also { setRelationshipIdSelf(it) } + } + + /** + * isExtra + merge: link to existing [TI-M], or create [TI-M] and retire plain/legacy batch-family sources. + */ + private fun processExtraMergeIntoLaneTicket( + group: List, + first: ReleaseDoResult, + storeId: String?, + isSingleRelease: Boolean, + requiredDate: LocalDate, + ticketFloorSegment: String, + ): Int { + val activeMerge = findActiveMergeTicket(first, storeId, isSingleRelease) + if (activeMerge != null) { + linkPickOrdersToHeader(activeMerge.id, group) + consolidateUnassignedPlainBatchTicketsIntoMergeHeader(first, storeId, isSingleRelease, activeMerge.id) + return 0 + } + + val plainBatch = findPlainFamilyTicketForMergeRetire(first, storeId, isSingleRelease) + val legacyUpgraded = findLegacyUpgradedFamilyTicket(first, storeId, isSingleRelease) + val sourceToRetire = plainBatch ?: legacyUpgraded + + if (sourceToRetire != null) { + val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) + ?: return 0 + retireSourceTicketIntoMergeHeader(sourceToRetire.id, mergeHeaderId) + linkPickOrdersToHeader(mergeHeaderId, group) + return 1 + } + + val mergeHeaderId = createNewMergeHeader(first, storeId, isSingleRelease, requiredDate, ticketFloorSegment) + ?: return 0 + linkPickOrdersToHeader(mergeHeaderId, group) + return 1 } /** * Link released pick orders to [delivery_order_pick_order] headers. - * Normal DOs merge into batch/single-family tickets; isExtra DOs upgrade `batch`→`isExtrabatch` or `single`→`isExtrasingle`. - * Legacy `isExtra` tickets are never merge targets. Completed tickets are skipped (new header created instead). + * Normal DOs merge into batch/single-family tickets ([TI-M] preferred). + * isExtra + merge: new [TI-M] with [relationshipId] lineage (no in-place releaseType upgrade). + * isExtra + no merge: standalone `isExtra` + `TI-E-` (Case 2). */ private fun createAndLinkDeliveryOrderPickOrders( results: List, dopReleaseType: String = "batch", + mergeExtraIntoLaneTicket: Boolean = true, ): Int { if (results.isEmpty()) return 0 @@ -664,7 +966,11 @@ open class DoWorkbenchReleaseService( var createdHeaders = 0 - fun processGroup(group: List, isExtraRelease: Boolean): Int { + fun processGroup( + group: List, + isExtraRelease: Boolean, + mergeExtra: Boolean, + ): Int { if (group.isEmpty()) return 0 val first = group.first() val storeId = resolveStoreId(first) @@ -674,10 +980,45 @@ open class DoWorkbenchReleaseService( val ticketFloorSegment = resolveTicketFloorSegment(storeId, isDefaultTruckLane) val requiredDate = first.estimatedArrivalDate ?: LocalDate.now() + if (isExtraRelease && mergeExtra) { + return processExtraMergeIntoLaneTicket( + group, + first, + storeId, + isSingleRelease, + requiredDate, + ticketFloorSegment, + ) + } + + if (isExtraRelease && !mergeExtra) { + val existingStandalone = findMergeableStandaloneIsExtraTicket(first, storeId) + if (existingStandalone != null) { + linkPickOrdersToHeader(existingStandalone.id, group) + return 0 + } + val ticketNo = nextDeliveryOrderPickOrderEtraTicketNo(requiredDate, ticketFloorSegment) + val headerId = insertNewDeliveryOrderPickOrderHeader( + first, + storeId, + WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA, + ticketNo, + ) ?: return 0 + linkPickOrdersToHeader(headerId, group) + return 1 + } + val existing = findMergeableWorkbenchTicket(first, storeId, isSingleRelease) if (existing != null) { linkPickOrdersToHeader(existing.id, group) - maybeUpgradeReleaseType(existing.id, existing.releaseType, isExtraRelease, isSingleRelease) + if (isWorkbenchMergeTicketNo(existing.ticketNo)) { + consolidateUnassignedPlainBatchTicketsIntoMergeHeader( + first, + storeId, + isSingleRelease, + existing.id, + ) + } return 0 } @@ -694,12 +1035,388 @@ open class DoWorkbenchReleaseService( } normalGroups.values.forEach { group -> - createdHeaders += processGroup(group, isExtraRelease = false) + createdHeaders += processGroup(group, isExtraRelease = false, mergeExtra = true) } extraGroups.values.forEach { group -> - createdHeaders += processGroup(group, isExtraRelease = true) + createdHeaders += processGroup(group, isExtraRelease = true, mergeExtra = mergeExtraIntoLaneTicket) } return createdHeaders } + + // --- Case 3: post-hoc merge plain batch/single + standalone isExtra → new TI-M --- + + open fun getMergeTicketCandidates( + requiredDate: LocalDate, + shopSearch: String?, + ): WorkbenchMergeTicketCandidatesResponse { + val batchFamily = queryMergeTicketCandidates( + requiredDate = requiredDate, + shopSearch = shopSearch, + side = "batchFamily", + ) + val isExtra = queryMergeTicketCandidates( + requiredDate = requiredDate, + shopSearch = shopSearch, + side = "isExtra", + ) + return WorkbenchMergeTicketCandidatesResponse( + batchFamilyTickets = batchFamily, + isExtraTickets = isExtra, + ) + } + + @Transactional(rollbackFor = [Exception::class]) + open fun mergeTicketsCase3(batchOrSingleDopoId: Long, isExtraDopoId: Long): MessageResponse { + if (batchOrSingleDopoId == isExtraDopoId) { + return mergeTicketsError("SAME_TICKET", "Cannot merge a ticket with itself") + } + val batchRow = loadDopoRowForMerge(batchOrSingleDopoId) + ?: return mergeTicketsError("NOT_FOUND", "Batch/single ticket not found") + val extraRow = loadDopoRowForMerge(isExtraDopoId) + ?: return mergeTicketsError("NOT_FOUND", "isExtra ticket not found") + + val batchRt = batchRow.releaseType?.trim()?.lowercase().orEmpty() + if (batchRt != WorkbenchReleaseTypeSupport.BATCH.lowercase() && + batchRt != WorkbenchReleaseTypeSupport.SINGLE.lowercase() + ) { + return mergeTicketsError("INVALID_BATCH", "Left ticket must be plain batch or single") + } + if (isWorkbenchMergeTicketNo(batchRow.ticketNo)) { + return mergeTicketsError("INVALID_BATCH", "Left ticket must not be a TI-M merge ticket") + } + if (extraRow.releaseType?.trim()?.lowercase() != WorkbenchReleaseTypeSupport.LEGACY_IS_EXTRA.lowercase()) { + return mergeTicketsError("INVALID_EXTRA", "Right ticket must be releaseType isExtra") + } + + try { + assertTicketUnassignedForMerge(batchOrSingleDopoId) + assertTicketUnassignedForMerge(isExtraDopoId) + } catch (e: IllegalStateException) { + return mergeTicketsError("ASSIGNED", e.message ?: "Ticket already assigned") + } + + if (!sameLaneForMerge(batchRow, extraRow)) { + return mergeTicketsError( + "LANE_MISMATCH", + "Tickets are not on the same shop, floor, truck lane, and departure time", + ) + } + + val isSingleRelease = batchRt == WorkbenchReleaseTypeSupport.SINGLE.lowercase() + val requiredDate = batchRow.requiredDeliveryDate ?: LocalDate.now() + val storeId = batchRow.storeId + val isDefaultTruckLane = storeId.isNullOrBlank() + val ticketFloorSegment = resolveTicketFloorSegment(storeId, isDefaultTruckLane) + + val mergeHeaderId = insertMergeHeaderFromDopoRow( + template = batchRow, + isSingleRelease = isSingleRelease, + requiredDate = requiredDate, + ticketFloorSegment = ticketFloorSegment, + ) ?: return mergeTicketsError("CREATE_FAILED", "Failed to create TI-M merge ticket") + + retireSourceTicketIntoMergeHeader(batchOrSingleDopoId, mergeHeaderId) + retireSourceTicketIntoMergeHeader(isExtraDopoId, mergeHeaderId) + + val newTicketNo = jdbcDao.queryForList( + "SELECT ticketNo AS t FROM fpsmsdb.delivery_order_pick_order WHERE id = :id", + mapOf("id" to mergeHeaderId), + ).firstOrNull()?.let { row -> + row.entries.find { it.key.equals("t", true) }?.value?.toString() + } + + return MessageResponse( + id = mergeHeaderId, + code = "SUCCESS", + name = "workbench_merge_tickets", + type = "workbench_merge_tickets", + message = "Merged into $newTicketNo", + errorPosition = null, + entity = mapOf( + "mergeDeliveryOrderPickOrderId" to mergeHeaderId, + "ticketNo" to newTicketNo, + "batchOrSingleDopoId" to batchOrSingleDopoId, + "isExtraDopoId" to isExtraDopoId, + ), + ) + } + + private fun mergeTicketsError(code: String, message: String): MessageResponse = + MessageResponse( + id = null, + code = code, + name = "workbench_merge_tickets", + type = "workbench_merge_tickets", + message = message, + errorPosition = null, + entity = null, + ) + + private data class DopoMergeRow( + val id: Long, + val ticketNo: String?, + val releaseType: String?, + val shopId: Long?, + val shopCode: String?, + val shopName: String?, + val storeId: String?, + val truckId: Long?, + val requiredDeliveryDate: LocalDate?, + val truckLanceCode: String?, + val truckDepartureTime: LocalTime?, + val loadingSequence: Int?, + ) + + private fun queryMergeTicketCandidates( + requiredDate: LocalDate, + shopSearch: String?, + side: String, + ): List { + val params = mutableMapOf("requiredDate" to requiredDate) + val sql = StringBuilder( + """ + SELECT + dop.id AS id, + dop.ticketNo AS ticketNo, + dop.releaseType AS releaseType, + dop.shopId AS shopId, + dop.shopCode AS shopCode, + dop.shopName AS shopName, + dop.storeId AS storeId, + dop.truckId AS truckId, + dop.requiredDeliveryDate AS requiredDeliveryDate, + dop.truckLanceCode AS truckLanceCode, + dop.truckDepartureTime AS truckDepartureTime, + dop.loadingSequence AS loadingSequence, + ( + SELECT GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',') + FROM fpsmsdb.pick_order po + INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0 + WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 + ) AS doCodesStr + FROM fpsmsdb.delivery_order_pick_order dop + WHERE dop.deleted = 0 + AND dop.requiredDeliveryDate = :requiredDate + AND dop.ticketStatus IN ('pending', 'released') + AND dop.handledBy IS NULL + """.trimIndent() + ) + when (side) { + "batchFamily" -> sql.append( + """ + AND LOWER(COALESCE(dop.releaseType, '')) IN ('batch', 'single') + AND (dop.ticketNo IS NULL OR dop.ticketNo NOT LIKE 'TI-M-%') + """.trimIndent().let { " $it" }, + ) + "isExtra" -> sql.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isextra' ") + else -> return emptyList() + } + if (!shopSearch.isNullOrBlank()) { + sql.append( + """ + AND ( + LOWER(COALESCE(dop.shopName, '')) LIKE :shopPatLower + OR LOWER(COALESCE(dop.shopCode, '')) LIKE :shopPatLower + ) + """.trimIndent().let { " $it" }, + ) + params["shopPatLower"] = "%${shopSearch.trim().lowercase()}%" + } + sql.append(" ORDER BY dop.shopName, dop.truckDepartureTime, dop.truckLanceCode, dop.id ") + + val rows = try { + jdbcDao.queryForList(sql.toString(), params) + } catch (e: Exception) { + println("❌ queryMergeTicketCandidates($side): ${e.message}") + emptyList() + } + return rows.mapNotNull { mapRowToMergeTicketCandidate(it) } + } + + private fun mapRowToMergeTicketCandidate(row: Map): WorkbenchMergeTicketCandidate? { + fun str(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it]?.toString()?.trim() }?.takeIf { it.isNotEmpty() } + fun long(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it] }?.let { + when (it) { + is Number -> it.toLong() + is String -> it.trim().toLongOrNull() + else -> it.toString().trim().toLongOrNull() + } + } + fun int(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it] }?.let { + when (it) { + is Number -> it.toInt() + is String -> it.trim().toIntOrNull() + else -> it.toString().trim().toIntOrNull() + } + } + val id = long("id") ?: return null + val reqDate = row.keys.find { it.equals("requiredDeliveryDate", true) }?.let { row[it] }.let { v -> + when (v) { + null -> null + is java.sql.Date -> v.toLocalDate() + is LocalDate -> v + else -> runCatching { LocalDate.parse(v.toString().take(10)) }.getOrNull() + } + } + val tt = row.keys.find { it.equals("truckDepartureTime", true) }?.let { row[it] } + val truckDepartureTime = when (tt) { + null -> null + is java.sql.Time -> tt.toLocalTime() + is LocalTime -> tt + is java.sql.Timestamp -> tt.toLocalDateTime().toLocalTime() + else -> runCatching { LocalTime.parse(tt.toString().take(8)) }.getOrNull() + ?: runCatching { LocalTime.parse(tt.toString()) }.getOrNull() + } + val doCodesStr = str("doCodesStr") + val codes = doCodesStr?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList() + val shopId = long("shopId") + val storeId = str("storeId") + val truckId = long("truckId") + val truckLanceCode = str("truckLanceCode") + val loadingSequence = int("loadingSequence") + val laneKey = buildLaneKey( + shopId = shopId, + requiredDeliveryDate = reqDate, + storeId = storeId, + truckId = truckId, + truckLanceCode = truckLanceCode, + truckDepartureTime = truckDepartureTime, + loadingSequence = loadingSequence, + ) + return WorkbenchMergeTicketCandidate( + id = id, + ticketNo = str("ticketNo"), + releaseType = str("releaseType"), + shopId = shopId, + shopCode = str("shopCode"), + shopName = str("shopName"), + storeId = storeId, + truckId = truckId, + requiredDeliveryDate = reqDate, + truckLanceCode = truckLanceCode, + truckDepartureTime = truckDepartureTime, + loadingSequence = loadingSequence, + deliveryOrderCodes = codes, + laneKey = laneKey, + ) + } + + private fun buildLaneKey( + shopId: Long?, + requiredDeliveryDate: LocalDate?, + storeId: String?, + truckId: Long?, + truckLanceCode: String?, + truckDepartureTime: LocalTime?, + loadingSequence: Int?, + ): String { + val storeNorm = normalizeStoreIdForLaneKey(storeId) + val is4F = storeNorm.replace("/", "").equals("4F", ignoreCase = true) + val seqPart = if (is4F) loadingSequence?.toString() ?: "" else "" + return listOf( + shopId?.toString() ?: "", + requiredDeliveryDate?.toString() ?: "", + storeNorm, + truckId?.toString() ?: "", + truckDepartureTime?.toString() ?: "", + truckLanceCode?.trim() ?: "", + seqPart, + ).joinToString("|") + } + + private fun normalizeStoreIdForLaneKey(storeId: String?): String { + val s = storeId?.trim().orEmpty() + return if (s.isEmpty()) "" else s + } + + private fun sameLaneForMerge(a: DopoMergeRow, b: DopoMergeRow): Boolean = + buildLaneKey( + a.shopId, a.requiredDeliveryDate, a.storeId, a.truckId, + a.truckLanceCode, a.truckDepartureTime, a.loadingSequence, + ) == buildLaneKey( + b.shopId, b.requiredDeliveryDate, b.storeId, b.truckId, + b.truckLanceCode, b.truckDepartureTime, b.loadingSequence, + ) + + private fun loadDopoRowForMerge(id: Long): DopoMergeRow? { + val rows = jdbcDao.queryForList( + """ + SELECT + id, ticketNo, releaseType, shopId, shopCode, shopName, storeId, truckId, + requiredDeliveryDate, truckLanceCode, truckDepartureTime, loadingSequence + FROM fpsmsdb.delivery_order_pick_order + WHERE id = :id AND deleted = 0 + """.trimIndent(), + mapOf("id" to id), + ) + val row = rows.firstOrNull() ?: return null + val candidate = mapRowToMergeTicketCandidate( + row + mapOf("doCodesStr" to null), + ) ?: return null + return DopoMergeRow( + id = candidate.id, + ticketNo = candidate.ticketNo, + releaseType = candidate.releaseType, + shopId = candidate.shopId, + shopCode = candidate.shopCode, + shopName = candidate.shopName, + storeId = candidate.storeId, + truckId = candidate.truckId, + requiredDeliveryDate = candidate.requiredDeliveryDate, + truckLanceCode = candidate.truckLanceCode, + truckDepartureTime = candidate.truckDepartureTime, + loadingSequence = candidate.loadingSequence, + ) + } + + private fun insertMergeHeaderFromDopoRow( + template: DopoMergeRow, + isSingleRelease: Boolean, + requiredDate: LocalDate, + ticketFloorSegment: String, + ): Long? { + val ticketNo = nextDeliveryOrderPickOrderMergeTicketNo(requiredDate, ticketFloorSegment) + val releaseType = WorkbenchReleaseTypeSupport.mergeTicketReleaseType(isSingleRelease) + val now = LocalDateTime.now() + jdbcDao.executeUpdate( + """ + INSERT INTO fpsmsdb.delivery_order_pick_order ( + truckId, shopId, storeId, requiredDeliveryDate, truckDepartureTime, + truckLanceCode, shopCode, shopName, loadingSequence, ticketNo, + ticketReleaseTime, ticketStatus, releaseType, handledBy, handlerName, + created, createdBy, version, modified, modifiedBy, deleted + ) VALUES ( + :truckId, :shopId, :storeId, :requiredDeliveryDate, :truckDepartureTime, + :truckLanceCode, :shopCode, :shopName, :loadingSequence, :ticketNo, + NULL, 'pending', :releaseType, NULL, NULL, + :created, :createdBy, 0, :modified, :modifiedBy, 0 + ) + """.trimIndent(), + mapOf( + "truckId" to template.truckId, + "shopId" to template.shopId, + "storeId" to template.storeId, + "requiredDeliveryDate" to (template.requiredDeliveryDate ?: requiredDate), + "truckDepartureTime" to template.truckDepartureTime, + "truckLanceCode" to template.truckLanceCode, + "shopCode" to template.shopCode, + "shopName" to template.shopName, + "loadingSequence" to template.loadingSequence, + "ticketNo" to ticketNo, + "releaseType" to releaseType, + "created" to now, + "createdBy" to "system", + "modified" to now, + "modifiedBy" to "system", + ), + ) + return jdbcDao.queryForList( + """ + SELECT id FROM fpsmsdb.delivery_order_pick_order + 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) } + } } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt index c775577..ac433ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/WorkbenchReleaseTypeSupport.kt @@ -49,6 +49,10 @@ object WorkbenchReleaseTypeSupport { else -> BATCH } + /** [TI-M] merged workbench ticket release type (batch-family merge). */ + fun mergeTicketReleaseType(isSingleRelease: Boolean): String = + if (isSingleRelease) IS_EXTRA_SINGLE else IS_EXTRA_BATCH + fun upgradedReleaseTypeIfNeeded(currentType: String?, isExtraRelease: Boolean, isSingleRelease: Boolean): String? { if (!isExtraRelease) return null val cur = currentType?.trim()?.lowercase().orEmpty() diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt index dc6b487..1761002 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DoWorkbenchController.kt @@ -178,10 +178,14 @@ class DoWorkbenchController( */ @PostMapping("/batch-release/async-v2") fun startWorkbenchBatchReleaseAsyncV2( - @RequestBody ids: List, + @RequestBody request: WorkbenchBatchReleaseRequest, @RequestParam(defaultValue = "1") userId: Long ): MessageResponse { - return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) + return doWorkbenchReleaseService.startBatchReleaseAsyncV2( + request.ids, + userId, + request.mergeExtraIntoLaneTicket, + ) } /** @@ -200,10 +204,14 @@ class DoWorkbenchController( /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ @PostMapping("/batch-release/sync-v2") fun workbenchBatchReleaseSyncV2( - @RequestBody ids: List, + @RequestBody request: WorkbenchBatchReleaseRequest, @RequestParam(defaultValue = "1") userId: Long ): MessageResponse { - return doWorkbenchReleaseService.releaseBatchV2(ids, userId) + return doWorkbenchReleaseService.releaseBatchV2( + request.ids, + userId, + request.mergeExtraIntoLaneTicket, + ) } @GetMapping("/batch-release/progress/{jobId}") @@ -211,6 +219,22 @@ class DoWorkbenchController( return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) } + /** Case 3: unassigned plain batch/single + isExtra tickets on the same lane (for merge UI). */ + @GetMapping("/merge-ticket-candidates") + fun getWorkbenchMergeTicketCandidates( + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate, + @RequestParam(required = false) shopSearch: String?, + ): WorkbenchMergeTicketCandidatesResponse = + doWorkbenchReleaseService.getMergeTicketCandidates(requiredDate, shopSearch) + + /** Case 3: merge selected batch/single + isExtra into a new [TI-M] ticket. */ + @PostMapping("/merge-tickets") + fun mergeWorkbenchTickets(@RequestBody request: WorkbenchMergeTicketsRequest): MessageResponse = + doWorkbenchReleaseService.mergeTicketsCase3( + request.batchOrSingleDopoId, + request.isExtraDopoId, + ) + @GetMapping("/ticket-release-table/{startDate}&{endDate}") fun getWorkbenchTicketReleaseTable( @PathVariable startDate: LocalDate, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchReleaseRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchReleaseRequest.kt new file mode 100644 index 0000000..f46fb83 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchBatchReleaseRequest.kt @@ -0,0 +1,11 @@ +package com.ffii.fpsms.modules.deliveryOrder.web.models + +/** + * Workbench batch release body (async-v2 / sync-v2). + * [mergeExtraIntoLaneTicket]: when true, isExtra DOs join batch/single family (isExtrabatch / isExtrasingle); + * when false, standalone `releaseType=isExtra` tickets with `TI-E-` prefix. + */ +data class WorkbenchBatchReleaseRequest( + val ids: List = emptyList(), + val mergeExtraIntoLaneTicket: Boolean = true, +) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchMergeTicketModels.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchMergeTicketModels.kt new file mode 100644 index 0000000..ade5947 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchMergeTicketModels.kt @@ -0,0 +1,35 @@ +package com.ffii.fpsms.modules.deliveryOrder.web.models + +import com.fasterxml.jackson.annotation.JsonFormat +import java.time.LocalDate +import java.time.LocalTime + +data class WorkbenchMergeTicketCandidate( + val id: Long, + val ticketNo: String?, + val releaseType: String?, + val shopId: Long?, + val shopCode: String?, + val shopName: String?, + val storeId: String?, + val truckId: Long?, + @JsonFormat(pattern = "yyyy-MM-dd") + val requiredDeliveryDate: LocalDate?, + val truckLanceCode: String?, + @JsonFormat(pattern = "HH:mm") + val truckDepartureTime: LocalTime?, + val loadingSequence: Int?, + val deliveryOrderCodes: List, + /** Stable lane identity for same-truck merge matching (2/F, 4/F, truck-X). */ + val laneKey: String, +) + +data class WorkbenchMergeTicketCandidatesResponse( + val batchFamilyTickets: List, + val isExtraTickets: List, +) + +data class WorkbenchMergeTicketsRequest( + val batchOrSingleDopoId: Long, + val isExtraDopoId: Long, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt index fd094e8..3307651 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt @@ -3,6 +3,8 @@ package com.ffii.fpsms.modules.master.entity import com.fasterxml.jackson.annotation.JsonBackReference import com.fasterxml.jackson.annotation.JsonManagedReference import com.ffii.core.entity.BaseEntity +import com.ffii.fpsms.modules.master.enums.BomStatus +import com.ffii.fpsms.modules.master.enums.BomStatusConverter import jakarta.persistence.* import jakarta.validation.constraints.NotNull import jakarta.validation.constraints.Size @@ -87,4 +89,8 @@ open class Bom : BaseEntity() { @Column(name = "baseScore", precision = 14, scale = 2) open var baseScore: BigDecimal? = null + + @Column(name = "status", nullable = false, length = 20) + @Convert(converter = BomStatusConverter::class) + open var status: BomStatus = BomStatus.ACTIVE } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt index e85f85e..8d5654e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.entity import com.ffii.core.support.AbstractRepository import com.ffii.fpsms.modules.master.entity.projections.BomCombo +import com.ffii.fpsms.modules.master.enums.BomStatus import org.springframework.stereotype.Repository import java.io.Serializable import org.springframework.data.jpa.repository.Query @@ -27,8 +28,10 @@ interface BomRepository : AbstractRepository { fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom? fun findBomComboByDeletedIsFalse(): List - fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List + fun findBomComboByDeletedIsFalseAndStatus(status: BomStatus): List + fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List + fun findAllByItemIdAndStatusAndDeletedIsFalse(itemId: Long, status: BomStatus): List @Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id") fun findAllIdsByDeletedIsFalse(): List diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt index 7fd7917..8f4bb24 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt @@ -13,4 +13,6 @@ interface BomCombo { val outputQtyUom: String?; @get:Value("#{target.description}") val description: String?; + @get:Value("#{target.status?.value}") + val status: String?; } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/enums/BomStatus.kt b/src/main/java/com/ffii/fpsms/modules/master/enums/BomStatus.kt new file mode 100644 index 0000000..594c9bb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/enums/BomStatus.kt @@ -0,0 +1,12 @@ +package com.ffii.fpsms.modules.master.enums + +enum class BomStatus(val value: String) { + ACTIVE("active"), + INACTIVE("inactive"); + + companion object { + fun fromValue(value: String): BomStatus = + entries.find { it.value == value } + ?: throw IllegalArgumentException("Unknown BOM status: $value") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/enums/BomStatusConverter.kt b/src/main/java/com/ffii/fpsms/modules/master/enums/BomStatusConverter.kt new file mode 100644 index 0000000..3307371 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/enums/BomStatusConverter.kt @@ -0,0 +1,12 @@ +package com.ffii.fpsms.modules.master.enums + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter(autoApply = true) +class BomStatusConverter : AttributeConverter { + override fun convertToDatabaseColumn(attribute: BomStatus?): String? = attribute?.value + + override fun convertToEntityAttribute(dbData: String?): BomStatus? = + dbData?.let { BomStatus.fromValue(it) } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 8559a3d..fba477d 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -43,6 +43,8 @@ import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository import com.ffii.fpsms.modules.common.SettingNames import com.ffii.fpsms.modules.settings.entity.Settings import com.ffii.fpsms.modules.settings.service.SettingsService +import com.ffii.fpsms.modules.master.enums.BomStatus +import com.ffii.core.exception.BadRequestException @Service open class BomService( @@ -141,6 +143,11 @@ open class BomService( .minByOrNull { if (it.description == "FG") 0 else 1 } ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() } + open fun findByItemIdAndStatus(itemId: Long, status: BomStatus): Bom? { + return bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status) + .minByOrNull { if (it.description == "FG") 0 else 1 } + ?: bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status).firstOrNull() + } /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { @@ -240,6 +247,13 @@ open class BomService( else -> "Other" } } + request.status?.let { raw -> + bom.status = try { + BomStatus.fromValue(raw.trim().lowercase()) + } catch (_: IllegalArgumentException) { + throw BadRequestException("Invalid BOM status: $raw") + } + } val replaceMaterials = request.materials != null val replaceProcesses = request.processes != null @@ -2962,6 +2976,7 @@ println("=====================================") description = bom.description, outputQty = bom.outputQty, outputQtyUom = bom.outputQtyUom, + status = bom.status.value, materials = materials, processes = processes ) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt index 2817f27..648b174 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt @@ -726,7 +726,7 @@ open class ProductionScheduleService( LEFT JOIN items ON bom.itemId = items.id LEFT JOIN inventory ON items.id = inventory.itemId left join item_fake_onhand on items.code = item_fake_onhand.itemCode - WHERE bom.deleted = 0 and bom.description = 'FG' + WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active' -- and bom.itemId != 16771 ) AS i WHERE 1 diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index ad0b4da..6721017 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.web import com.ffii.fpsms.modules.master.entity.Bom import com.ffii.fpsms.modules.master.entity.BomRepository +import com.ffii.fpsms.modules.master.enums.BomStatus import com.ffii.fpsms.modules.master.entity.projections.BomCombo import com.ffii.fpsms.modules.master.service.BomService import org.springframework.core.io.ByteArrayResource @@ -108,8 +109,14 @@ fun downloadBomFormatIssueLog( } @GetMapping("/combo") - fun getCombo(): List { - return bomRepository.findBomComboByDeletedIsFalse(); + fun getCombo( + @RequestParam(defaultValue = "false") includeInactive: Boolean, + ): List { + return if (includeInactive) { + bomRepository.findBomComboByDeletedIsFalse() + } else { + bomRepository.findBomComboByDeletedIsFalseAndStatus(BomStatus.ACTIVE) + } } @GetMapping("/combo/issues") diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt index f9b46e6..68567eb 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt @@ -30,6 +30,7 @@ data class EditBomRequest( val complexity: Int? = null, val isDrink: Boolean? = null, val isPowderMixture: Boolean? = null, + val status: String? = null, // children val materials: List? = null, diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt index 6f8d739..b2130c6 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt @@ -122,6 +122,7 @@ data class BomDetailResponse( val description: String?, val outputQty: BigDecimal?, val outputQtyUom: String?, + val status: String?, val materials: List, val processes: List ) \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260609_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260609_Enson/02_setting.sql new file mode 100644 index 0000000..658afd2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260609_Enson/02_setting.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql + +-- Add column relationshipId to delivery_order_pick_order +--changeset Enson:20260609-01 +ALTER TABLE delivery_order_pick_order +ADD COLUMN relationshipId INT NULL; +UPDATE delivery_order_pick_order SET relationshipId = id WHERE relationshipId IS NULL OR relationshipId = 0; diff --git a/src/main/resources/db/changelog/changes/20260609_Enson/03_bom_status.sql b/src/main/resources/db/changelog/changes/20260609_Enson/03_bom_status.sql new file mode 100644 index 0000000..0aa82af --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260609_Enson/03_bom_status.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql + +--changeset Enson:20260609-bom-status +ALTER TABLE bom + ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active' AFTER type; + +UPDATE bom SET status = 'active' WHERE status IS NULL OR status = '';