| @@ -0,0 +1,99 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.Column | |||||
| import jakarta.persistence.Entity | |||||
| import jakarta.persistence.Table | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| @Entity | |||||
| @Table(name = "do_replenishment") | |||||
| open class DoReplenishment : BaseEntity<Long>() { | |||||
| @Size(max = 100) | |||||
| @NotNull | |||||
| @Column(name = "code", nullable = false, length = 100) | |||||
| open var code: String? = null | |||||
| @NotNull | |||||
| @Column(name = "deliveryDate", nullable = false) | |||||
| open var deliveryDate: LocalDate? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceDoId", nullable = false) | |||||
| open var sourceDoId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "sourceDoCode", length = 100) | |||||
| open var sourceDoCode: String? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceDoLineId", nullable = false) | |||||
| open var sourceDoLineId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceM18DataLogId", nullable = false) | |||||
| open var sourceM18DataLogId: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "sourceM18Id", nullable = false) | |||||
| open var sourceM18Id: Long? = null | |||||
| @NotNull | |||||
| @Column(name = "itemId", nullable = false) | |||||
| open var itemId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "itemNo", length = 100) | |||||
| open var itemNo: String? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "itemName", length = 255) | |||||
| open var itemName: String? = null | |||||
| @NotNull | |||||
| @Column(name = "replenishQty", nullable = false, precision = 14, scale = 2) | |||||
| open var replenishQty: BigDecimal? = null | |||||
| @Column(name = "uomId") | |||||
| open var uomId: Long? = null | |||||
| @Column(name = "shopId") | |||||
| open var shopId: Long? = null | |||||
| @Size(max = 50) | |||||
| @Column(name = "shopCode", length = 50) | |||||
| open var shopCode: String? = null | |||||
| @Size(max = 255) | |||||
| @Column(name = "shopName", length = 255) | |||||
| open var shopName: String? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "truckLaneCode", length = 100) | |||||
| open var truckLaneCode: String? = null | |||||
| @Column(name = "targetDoId") | |||||
| open var targetDoId: Long? = null | |||||
| @Size(max = 100) | |||||
| @Column(name = "targetDoCode", length = 100) | |||||
| open var targetDoCode: String? = null | |||||
| @Column(name = "pickOrderLineId") | |||||
| open var pickOrderLineId: Long? = null | |||||
| @NotNull | |||||
| @Size(max = 20) | |||||
| @Column(name = "status", nullable = false, length = 20) | |||||
| open var status: String = STATUS_PENDING | |||||
| companion object { | |||||
| const val STATUS_PENDING = "pending" | |||||
| const val STATUS_PROCESSING = "processing" | |||||
| const val STATUS_COMPLETED = "completed" | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,45 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | |||||
| import java.time.LocalDate | |||||
| @Repository | |||||
| interface DoReplenishmentRepository : AbstractRepository<DoReplenishment, Long> { | |||||
| fun findByCodeAndDeletedIsFalse(code: String): DoReplenishment? | |||||
| fun existsBySourceDoLineIdAndStatusAndDeletedIsFalse(sourceDoLineId: Long, status: String): Boolean | |||||
| fun findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse( | |||||
| sourceDoLineId: Long, | |||||
| status: String, | |||||
| ): DoReplenishment? | |||||
| fun findByTargetDoIdInAndDeletedIsFalse(targetDoIds: Collection<Long>): List<DoReplenishment> | |||||
| @Query( | |||||
| """ | |||||
| SELECT r FROM DoReplenishment r | |||||
| WHERE r.deleted = false | |||||
| AND (:deliveryDate IS NULL OR r.deliveryDate = :deliveryDate) | |||||
| AND (:status IS NULL OR r.status = :status) | |||||
| ORDER BY r.created DESC, r.id DESC | |||||
| """, | |||||
| ) | |||||
| fun search( | |||||
| @Param("deliveryDate") deliveryDate: LocalDate?, | |||||
| @Param("status") status: String?, | |||||
| ): List<DoReplenishment> | |||||
| @Query( | |||||
| """ | |||||
| SELECT r.code FROM DoReplenishment r | |||||
| WHERE r.deleted = false | |||||
| AND r.code LIKE CONCAT(:codePrefix, '%') | |||||
| """, | |||||
| ) | |||||
| fun findCodesByPrefix(@Param("codePrefix") codePrefix: String): List<String> | |||||
| } | |||||
| @@ -125,6 +125,7 @@ open class DeliveryOrderService( | |||||
| private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | ||||
| private val itemsRepository: ItemsRepository, | private val itemsRepository: ItemsRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | ||||
| private val doReplenishmentService: DoReplenishmentService, | |||||
| ) { | ) { | ||||
| /** | /** | ||||
| * 樓層篩選:2F/4F/ALL 由 [DoFloorSupplierSettingsService] 讀 `settings`。 | * 樓層篩選:2F/4F/ALL 由 [DoFloorSupplierSettingsService] 讀 `settings`。 | ||||
| @@ -525,6 +526,51 @@ open class DeliveryOrderService( | |||||
| return names.joinToString(",").ifBlank { null } | return names.joinToString(",").ifBlank { null } | ||||
| } | } | ||||
| private fun resolveTruckLaneCodeForDeliveryOrder(doId: Long): String? { | |||||
| doPickOrderRepository.findByDoOrderIdAndDeletedFalse(doId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(doId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| val sql = """ | |||||
| SELECT DISTINCT TRIM(truck_lance_code) AS truck_lance_code | |||||
| FROM ( | |||||
| SELECT NULLIF(TRIM(dop.truckLanceCode), '') AS truck_lance_code | |||||
| FROM fpsmsdb.delivery_order_pick_order dop | |||||
| INNER JOIN fpsmsdb.pick_order po | |||||
| ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||||
| WHERE dop.deleted = 0 | |||||
| AND po.doId = :doId | |||||
| UNION | |||||
| SELECT NULLIF(TRIM(dop.truckLanceCode), '') AS truck_lance_code | |||||
| FROM fpsmsdb.delivery_order_pick_order dop | |||||
| INNER JOIN fpsmsdb.pick_order po | |||||
| ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||||
| INNER JOIN fpsmsdb.do_pick_order_line dpol | |||||
| ON dpol.pick_order_id = po.id AND dpol.deleted = 0 | |||||
| WHERE dop.deleted = 0 | |||||
| AND dpol.do_order_id = :doId | |||||
| ) resolved | |||||
| WHERE truck_lance_code IS NOT NULL AND TRIM(truck_lance_code) <> '' | |||||
| ORDER BY truck_lance_code | |||||
| LIMIT 1 | |||||
| """.trimIndent() | |||||
| return jdbcDao.queryForList(sql, mapOf("doId" to doId)) | |||||
| .firstOrNull() | |||||
| ?.let { row -> | |||||
| val key = row.keys.find { it.equals("truck_lance_code", true) } ?: return@let null | |||||
| row[key]?.toString()?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } | |||||
| } | |||||
| open fun getDetailedDo(id: Long): DoDetailResponse? { | open fun getDetailedDo(id: Long): DoDetailResponse? { | ||||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(id) ?: return null | val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(id) ?: return null | ||||
| val handlerName = try { | val handlerName = try { | ||||
| @@ -533,6 +579,12 @@ open class DeliveryOrderService( | |||||
| log.warn("Failed to resolve handler name for delivery order {}: {}", id, ex.message) | log.warn("Failed to resolve handler name for delivery order {}: {}", id, ex.message) | ||||
| null | null | ||||
| } | } | ||||
| val truckLaneCode = try { | |||||
| resolveTruckLaneCodeForDeliveryOrder(id) | |||||
| } catch (ex: Exception) { | |||||
| log.warn("Failed to resolve truck lane for delivery order {}: {}", id, ex.message) | |||||
| null | |||||
| } | |||||
| val itemIds = deliveryOrder.deliveryOrderLines.mapNotNull { it.item?.id }.distinct() | val itemIds = deliveryOrder.deliveryOrderLines.mapNotNull { it.item?.id }.distinct() | ||||
| val stockQtyByItemId = itemIds.associateWith { itemId -> | val stockQtyByItemId = itemIds.associateWith { itemId -> | ||||
| @@ -552,6 +604,7 @@ open class DeliveryOrderService( | |||||
| status = deliveryOrder.status?.value, | status = deliveryOrder.status?.value, | ||||
| isExtra = deliveryOrder.isExtra, | isExtra = deliveryOrder.isExtra, | ||||
| handlerName = handlerName, | handlerName = handlerName, | ||||
| truckLaneCode = truckLaneCode, | |||||
| deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> | deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> | ||||
| val itemId = line.item?.id | val itemId = line.item?.id | ||||
| val stockQty = itemId?.let { stockQtyByItemId[it] } ?: BigDecimal.ZERO | val stockQty = itemId?.let { stockQtyByItemId[it] } ?: BigDecimal.ZERO | ||||
| @@ -1219,6 +1272,7 @@ open class DeliveryOrderService( | |||||
| val deliveryOrders = deliveryOrderRepository.findAllById(deliveryOrderIds) | val deliveryOrders = deliveryOrderRepository.findAllById(deliveryOrderIds) | ||||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | ||||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | ||||
| val replenishPdfIndex = buildReplenishPdfIndex(deliveryOrderIds) | |||||
| val exportLines = deliveryNoteExportLines(deliveryNoteInfo) | val exportLines = deliveryNoteExportLines(deliveryNoteInfo) | ||||
| val sortedLines = exportLines.sortedBy { row -> | val sortedLines = exportLines.sortedBy { row -> | ||||
| @@ -1237,14 +1291,16 @@ open class DeliveryOrderService( | |||||
| val line = row.line | val line = row.line | ||||
| val field = mutableMapOf<String, Any>() | val field = mutableMapOf<String, Any>() | ||||
| val sequenceNumber = fields.size + 1 | val sequenceNumber = fields.size + 1 | ||||
| val isExtra = isExtraDeliveryTicket( | |||||
| lineTicketNo = null, | |||||
| deliveryOrderIsExtra = isExtraByDoId[row.deliveryOrderId] == true, | |||||
| headerIsMerge = false, | |||||
| ) | |||||
| field["sequenceNumber"] = formatSequenceNumber( | field["sequenceNumber"] = formatSequenceNumber( | ||||
| sequenceNumber, | sequenceNumber, | ||||
| isExtraDeliveryTicket( | |||||
| lineTicketNo = null, | |||||
| deliveryOrderIsExtra = isExtraByDoId[row.deliveryOrderId] == true, | |||||
| headerIsMerge = false, | |||||
| ), | |||||
| isExtra, | |||||
| replenishPdfIndex.matches(row.deliveryOrderId, line.itemId, null), | |||||
| ) | ) | ||||
| field["deliveryOrderCode"] = formatDoCodeWithUnderlineLast4( | field["deliveryOrderCode"] = formatDoCodeWithUnderlineLast4( | ||||
| deliveryOrderCodeById[row.deliveryOrderId].orEmpty(), | deliveryOrderCodeById[row.deliveryOrderId].orEmpty(), | ||||
| @@ -1403,6 +1459,7 @@ open class DeliveryOrderService( | |||||
| val deliveryOrders = deliveryOrderRepository.findAllById(deliveryOrderIds) | val deliveryOrders = deliveryOrderRepository.findAllById(deliveryOrderIds) | ||||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | ||||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | ||||
| val replenishPdfIndex = buildReplenishPdfIndex(deliveryOrderIds) | |||||
| sortedLines.forEach { row -> | sortedLines.forEach { row -> | ||||
| fields.add( | fields.add( | ||||
| @@ -1419,6 +1476,7 @@ open class DeliveryOrderService( | |||||
| isExtraByDoId = isExtraByDoId, | isExtraByDoId = isExtraByDoId, | ||||
| headerTicketNo = null, | headerTicketNo = null, | ||||
| headerIsMerge = false, | headerIsMerge = false, | ||||
| replenishPdfIndex = replenishPdfIndex, | |||||
| ), | ), | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -1527,8 +1585,41 @@ open class DeliveryOrderService( | |||||
| codes.filter { it.isNotBlank() }.distinct().sorted() | codes.filter { it.isNotBlank() }.distinct().sorted() | ||||
| .joinToString(", ") { escapeXmlForJasperStyled(it) } | .joinToString(", ") { escapeXmlForJasperStyled(it) } | ||||
| fun formatSequenceNumber(sequenceNumber: Int, isExtra: Boolean): String = | |||||
| if (isExtra) "$sequenceNumber(加單)" else sequenceNumber.toString() | |||||
| fun formatSequenceNumber(sequenceNumber: Int, isExtra: Boolean, isReplenish: Boolean = false): String { | |||||
| var label = sequenceNumber.toString() | |||||
| if (isExtra) label += "(加單)" | |||||
| if (isReplenish) label += "(補貨)" | |||||
| return label | |||||
| } | |||||
| data class ReplenishPdfIndex( | |||||
| private val targetDoItemKeys: Set<Pair<Long, Long>>, | |||||
| private val pickOrderLineIds: Set<Long>, | |||||
| ) { | |||||
| fun matches(deliveryOrderId: Long, itemId: Long?, pickOrderLineId: Long?): Boolean { | |||||
| if (pickOrderLineId != null && pickOrderLineId in pickOrderLineIds) return true | |||||
| val resolvedItemId = itemId ?: return false | |||||
| return deliveryOrderId to resolvedItemId in targetDoItemKeys | |||||
| } | |||||
| companion object { | |||||
| val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) | |||||
| } | |||||
| } | |||||
| fun buildReplenishPdfIndex(deliveryOrderIds: Collection<Long>): ReplenishPdfIndex { | |||||
| if (deliveryOrderIds.isEmpty()) return ReplenishPdfIndex.EMPTY | |||||
| val records = doReplenishmentService.findReplenishmentsByTargetDoIds(deliveryOrderIds) | |||||
| if (records.isEmpty()) return ReplenishPdfIndex.EMPTY | |||||
| return ReplenishPdfIndex( | |||||
| targetDoItemKeys = records.mapNotNull { row -> | |||||
| val targetDoId = row.targetDoId | |||||
| val itemId = row.itemId | |||||
| if (targetDoId != null && itemId != null) targetDoId to itemId else null | |||||
| }.toSet(), | |||||
| pickOrderLineIds = records.mapNotNull { it.pickOrderLineId }.toSet(), | |||||
| ) | |||||
| } | |||||
| fun buildDeliveryNotePdfLineField( | fun buildDeliveryNotePdfLineField( | ||||
| deliveryOrderId: Long, | deliveryOrderId: Long, | ||||
| @@ -1543,6 +1634,7 @@ open class DeliveryOrderService( | |||||
| isExtraByDoId: Map<Long, Boolean> = emptyMap(), | isExtraByDoId: Map<Long, Boolean> = emptyMap(), | ||||
| headerTicketNo: String? = null, | headerTicketNo: String? = null, | ||||
| headerIsMerge: Boolean = false, | headerIsMerge: Boolean = false, | ||||
| replenishPdfIndex: ReplenishPdfIndex = ReplenishPdfIndex.EMPTY, | |||||
| ): MutableMap<String, Any> { | ): MutableMap<String, Any> { | ||||
| val field = mutableMapOf<String, Any>() | val field = mutableMapOf<String, Any>() | ||||
| val isExtra = isExtraDeliveryTicket( | val isExtra = isExtraDeliveryTicket( | ||||
| @@ -1550,14 +1642,6 @@ open class DeliveryOrderService( | |||||
| deliveryOrderIsExtra = isExtraByDoId[deliveryOrderId] == true, | deliveryOrderIsExtra = isExtraByDoId[deliveryOrderId] == true, | ||||
| headerIsMerge = headerIsMerge, | headerIsMerge = headerIsMerge, | ||||
| ) | ) | ||||
| field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra) | |||||
| field["deliveryOrderCode"] = formatDoCodeWithUnderlineLast4( | |||||
| deliveryOrderCodeById[deliveryOrderId].orEmpty(), | |||||
| ) | |||||
| field["itemNo"] = line.itemNo | |||||
| field["itemName"] = line.itemName ?: "" | |||||
| field["uom"] = line.uom ?: "" | |||||
| field["shortName"] = line.uomShortDesc ?: "" | |||||
| val polId = resolvePickOrderLineIdForDeliveryNoteLine( | val polId = resolvePickOrderLineIdForDeliveryNoteLine( | ||||
| deliveryOrderId = deliveryOrderId, | deliveryOrderId = deliveryOrderId, | ||||
| @@ -1565,6 +1649,16 @@ open class DeliveryOrderService( | |||||
| pickOrderIdByDoId = pickOrderIdByDoId, | pickOrderIdByDoId = pickOrderIdByDoId, | ||||
| pickOrderLines = pickOrderLines, | pickOrderLines = pickOrderLines, | ||||
| ) | ) | ||||
| val isReplenish = replenishPdfIndex.matches(deliveryOrderId, line.itemId, polId) | |||||
| field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish) | |||||
| field["deliveryOrderCode"] = formatDoCodeWithUnderlineLast4( | |||||
| deliveryOrderCodeById[deliveryOrderId].orEmpty(), | |||||
| ) | |||||
| field["itemNo"] = line.itemNo | |||||
| field["itemName"] = line.itemName ?: "" | |||||
| field["uom"] = line.uom ?: "" | |||||
| field["shortName"] = line.uomShortDesc ?: "" | |||||
| val polIdsForRow = listOfNotNull(polId) | val polIdsForRow = listOfNotNull(polId) | ||||
| field["qty"] = if (polId != null) { | field["qty"] = if (polId != null) { | ||||
| @@ -0,0 +1,214 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.service | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishment | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishmentRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderLineRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentLineRequest | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest | |||||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| import java.time.format.DateTimeFormatter | |||||
| @Service | |||||
| open class DoReplenishmentService( | |||||
| private val doReplenishmentRepository: DoReplenishmentRepository, | |||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | |||||
| private val deliveryOrderLineRepository: DeliveryOrderLineRepository, | |||||
| private val doPickOrderRepository: DoPickOrderRepository, | |||||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | |||||
| private val uomConversionRepository: UomConversionRepository, | |||||
| ) { | |||||
| @Transactional | |||||
| open fun submit(request: SubmitDoReplenishmentRequest): List<DoReplenishmentResponse> { | |||||
| if (request.lines.isEmpty()) { | |||||
| throw IllegalArgumentException("No replenishment lines to submit") | |||||
| } | |||||
| val nextSeqByDate = mutableMapOf<LocalDate, Int>() | |||||
| val created = mutableListOf<DoReplenishment>() | |||||
| val mergedLines = mergeSubmitLines(request.lines) | |||||
| for (lineReq in mergedLines) { | |||||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(lineReq.sourceDoId) | |||||
| ?: throw IllegalArgumentException("Source delivery order not found: ${lineReq.sourceDoId}") | |||||
| if (deliveryOrder.status != DeliveryOrderStatus.COMPLETED) { | |||||
| throw IllegalArgumentException("Source delivery order must be completed: ${deliveryOrder.code}") | |||||
| } | |||||
| val doLine = deliveryOrderLineRepository.findById(lineReq.sourceDoLineId).orElse(null) | |||||
| ?: throw IllegalArgumentException("Source delivery order line not found: ${lineReq.sourceDoLineId}") | |||||
| if (doLine.deleted == true || doLine.deliveryOrder?.id != lineReq.sourceDoId) { | |||||
| throw IllegalArgumentException("Source line does not belong to delivery order ${lineReq.sourceDoId}") | |||||
| } | |||||
| val existingPending = doReplenishmentRepository.findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse( | |||||
| lineReq.sourceDoLineId, | |||||
| DoReplenishment.STATUS_PENDING, | |||||
| ) | |||||
| if (existingPending != null) { | |||||
| existingPending.replenishQty = | |||||
| (existingPending.replenishQty ?: BigDecimal.ZERO).add(lineReq.replenishQty) | |||||
| if (existingPending.truckLaneCode.isNullOrBlank()) { | |||||
| existingPending.truckLaneCode = | |||||
| resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) | |||||
| } | |||||
| created += doReplenishmentRepository.save(existingPending) | |||||
| continue | |||||
| } | |||||
| val m18DataLog = doLine.m18DataLog | |||||
| ?: throw IllegalArgumentException("Source line missing M18 data log") | |||||
| val m18Id = m18DataLog.m18Id | |||||
| ?: throw IllegalArgumentException("Source line missing M18 id") | |||||
| val item = doLine.item | |||||
| ?: throw IllegalArgumentException("Source line missing item") | |||||
| val seq = nextSeqByDate.getOrPut(lineReq.deliveryDate) { | |||||
| nextCodeSequence(lineReq.deliveryDate) | |||||
| } | |||||
| nextSeqByDate[lineReq.deliveryDate] = seq + 1 | |||||
| val code = formatReplenishmentCode(lineReq.deliveryDate, seq) | |||||
| val shop = deliveryOrder.shop | |||||
| val entity = DoReplenishment().apply { | |||||
| this.code = code | |||||
| deliveryDate = lineReq.deliveryDate | |||||
| sourceDoId = lineReq.sourceDoId | |||||
| sourceDoCode = deliveryOrder.code | |||||
| sourceDoLineId = lineReq.sourceDoLineId | |||||
| sourceM18DataLogId = m18DataLog.id | |||||
| sourceM18Id = m18Id | |||||
| itemId = item.id | |||||
| itemNo = doLine.itemNo ?: item.code | |||||
| itemName = item.name | |||||
| replenishQty = lineReq.replenishQty | |||||
| uomId = doLine.uom?.id | |||||
| shopId = shop?.id | |||||
| shopCode = shop?.code | |||||
| shopName = shop?.name | |||||
| truckLaneCode = resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) | |||||
| status = DoReplenishment.STATUS_PENDING | |||||
| } | |||||
| created += doReplenishmentRepository.save(entity) | |||||
| } | |||||
| return toResponses(created) | |||||
| } | |||||
| open fun list(deliveryDate: LocalDate?, status: String?): List<DoReplenishmentResponse> { | |||||
| val normalizedStatus = status?.trim()?.takeIf { it.isNotEmpty() && it != "all" } | |||||
| val rows = doReplenishmentRepository.search(deliveryDate, normalizedStatus) | |||||
| return toResponses(rows) | |||||
| } | |||||
| open fun findReplenishmentsByTargetDoIds(targetDoIds: Collection<Long>): List<DoReplenishment> { | |||||
| if (targetDoIds.isEmpty()) return emptyList() | |||||
| return doReplenishmentRepository.findByTargetDoIdInAndDeletedIsFalse(targetDoIds) | |||||
| } | |||||
| private fun nextCodeSequence(deliveryDate: LocalDate): Int { | |||||
| val prefix = codePrefix(deliveryDate) | |||||
| val suffixPattern = Regex("^${Regex.escape(prefix)}(\\d+)$") | |||||
| var maxSeq = 0 | |||||
| for (code in doReplenishmentRepository.findCodesByPrefix(prefix)) { | |||||
| suffixPattern.find(code)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let { n -> | |||||
| if (n > maxSeq) maxSeq = n | |||||
| } | |||||
| } | |||||
| return maxSeq + 1 | |||||
| } | |||||
| private fun formatReplenishmentCode(deliveryDate: LocalDate, sequence: Int): String { | |||||
| return "${codePrefix(deliveryDate)}${sequence.toString().padStart(3, '0')}" | |||||
| } | |||||
| private fun codePrefix(deliveryDate: LocalDate): String { | |||||
| val ymd = deliveryDate.format(DateTimeFormatter.BASIC_ISO_DATE) | |||||
| return "RP-$ymd-" | |||||
| } | |||||
| /** 同一批次內相同來源行合併補貨數量。 */ | |||||
| private fun mergeSubmitLines( | |||||
| lines: List<SubmitDoReplenishmentLineRequest>, | |||||
| ): List<SubmitDoReplenishmentLineRequest> { | |||||
| if (lines.size <= 1) return lines | |||||
| val merged = linkedMapOf<String, SubmitDoReplenishmentLineRequest>() | |||||
| for (line in lines) { | |||||
| val key = "${line.sourceDoId}:${line.sourceDoLineId}" | |||||
| val existing = merged[key] | |||||
| if (existing == null) { | |||||
| merged[key] = line | |||||
| } else { | |||||
| merged[key] = existing.copy( | |||||
| replenishQty = existing.replenishQty.add(line.replenishQty), | |||||
| truckLaneCode = existing.truckLaneCode?.takeIf { it.isNotBlank() } ?: line.truckLaneCode, | |||||
| ) | |||||
| } | |||||
| } | |||||
| return merged.values.toList() | |||||
| } | |||||
| /** 來源 DO 車線:優先 do_pick_order / do_pick_order_record,其次請求帶入值。 */ | |||||
| private fun resolveSourceDoTruckLaneCode( | |||||
| deliveryOrder: DeliveryOrder, | |||||
| requestTruckLaneCode: String?, | |||||
| ): String? { | |||||
| val sourceDoId = deliveryOrder.id ?: return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| doPickOrderRepository.findByDoOrderIdAndDeletedFalse(sourceDoId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| doPickOrderRecordRepository.findByDoOrderIdAndDeletedFalse(sourceDoId) | |||||
| .mapNotNull { it.truckLanceCode?.trim()?.takeIf { code -> code.isNotEmpty() } } | |||||
| .firstOrNull() | |||||
| ?.let { return it } | |||||
| return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } | |||||
| private fun toResponses(entities: List<DoReplenishment>): List<DoReplenishmentResponse> { | |||||
| val uomIds = entities.mapNotNull { it.uomId }.distinct() | |||||
| val shortUomById = if (uomIds.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| uomConversionRepository.findAllById(uomIds).associate { uom -> | |||||
| uom.id!! to (uom.udfShortDesc?.takeIf { it.isNotBlank() } ?: uom.code) | |||||
| } | |||||
| } | |||||
| return entities.map { row -> | |||||
| DoReplenishmentResponse( | |||||
| id = row.id!!, | |||||
| code = row.code!!, | |||||
| deliveryDate = row.deliveryDate!!, | |||||
| sourceDoId = row.sourceDoId!!, | |||||
| sourceDoCode = row.sourceDoCode, | |||||
| sourceDoLineId = row.sourceDoLineId!!, | |||||
| itemId = row.itemId!!, | |||||
| itemNo = row.itemNo, | |||||
| itemName = row.itemName, | |||||
| replenishQty = row.replenishQty!!, | |||||
| shortUom = row.uomId?.let { shortUomById[it] }, | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| truckLaneCode = row.truckLaneCode, | |||||
| targetDoId = row.targetDoId, | |||||
| targetDoCode = row.targetDoCode, | |||||
| pickOrderLineId = row.pickOrderLineId, | |||||
| status = row.status, | |||||
| created = row.created, | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -1792,7 +1792,7 @@ return MessageResponse( | |||||
| if (doPickOrderInfo == null) { | if (doPickOrderInfo == null) { | ||||
| println("❌ No delivery_order_pick_order found for workbench user $userId") | println("❌ No delivery_order_pick_order found for workbench user $userId") | ||||
| val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 | val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 | ||||
| log.info( | |||||
| log. info( | |||||
| "workbench all-lots-hierarchical-workbench timing: userId={} totalMs={} detail={}", | "workbench all-lots-hierarchical-workbench timing: userId={} totalMs={} detail={}", | ||||
| userId, | userId, | ||||
| totalMs, | totalMs, | ||||
| @@ -2764,6 +2764,7 @@ return MessageResponse( | |||||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | ||||
| val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true | val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true | ||||
| val headerTicketNo = ctx.header.ticketNo | val headerTicketNo = ctx.header.ticketNo | ||||
| val replenishPdfIndex = deliveryOrderService.buildReplenishPdfIndex(ctx.deliveryOrderIds) | |||||
| sortedLines.forEach { row -> | sortedLines.forEach { row -> | ||||
| fields.add( | fields.add( | ||||
| @@ -2780,6 +2781,7 @@ return MessageResponse( | |||||
| isExtraByDoId = isExtraByDoId, | isExtraByDoId = isExtraByDoId, | ||||
| headerTicketNo = headerTicketNo, | headerTicketNo = headerTicketNo, | ||||
| headerIsMerge = headerIsMerge, | headerIsMerge = headerIsMerge, | ||||
| replenishPdfIndex = replenishPdfIndex, | |||||
| ), | ), | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -4,6 +4,7 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrder | |||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo | ||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoReplenishmentService | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderStatusRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderStatusRequest | ||||
| @@ -44,7 +45,10 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.Check4FTruckBatchResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoSearchRowResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.DoSearchRowResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLite | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLite | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLiteDto | import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfoLiteDto | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.DoReplenishmentResponse | |||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SubmitDoReplenishmentRequest | |||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import java.time.LocalDate | |||||
| @RequestMapping("/do") | @RequestMapping("/do") | ||||
| @RestController | @RestController | ||||
| @@ -52,7 +56,7 @@ class DeliveryOrderController( | |||||
| private val deliveryOrderService: DeliveryOrderService, | private val deliveryOrderService: DeliveryOrderService, | ||||
| private val stockInLineService: StockInLineService, | private val stockInLineService: StockInLineService, | ||||
| private val doPickOrderService: DoPickOrderService, | private val doPickOrderService: DoPickOrderService, | ||||
| private val doReplenishmentService: DoReplenishmentService, | |||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(javaClass) | private val log = LoggerFactory.getLogger(javaClass) | ||||
| @@ -122,6 +126,21 @@ class DeliveryOrderController( | |||||
| return deliveryOrderService.getDetailedDo(id); | return deliveryOrderService.getDetailedDo(id); | ||||
| } | } | ||||
| @PostMapping("/replenishment") | |||||
| fun submitReplenishment( | |||||
| @Valid @RequestBody request: SubmitDoReplenishmentRequest, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.submit(request) | |||||
| } | |||||
| @GetMapping("/replenishment") | |||||
| fun listReplenishment( | |||||
| @RequestParam(required = false) deliveryDate: LocalDate?, | |||||
| @RequestParam(required = false) status: String?, | |||||
| ): List<DoReplenishmentResponse> { | |||||
| return doReplenishmentService.list(deliveryDate, status) | |||||
| } | |||||
| @GetMapping("/search-code/{code}") | @GetMapping("/search-code/{code}") | ||||
| fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | ||||
| return deliveryOrderService.searchByCode(code); | return deliveryOrderService.searchByCode(code); | ||||
| @@ -22,6 +22,8 @@ data class DoDetailResponse( | |||||
| val isExtra: Boolean = false, | val isExtra: Boolean = false, | ||||
| /** 揀貨員名稱(來源:delivery_order_pick_order.handlerName) */ | /** 揀貨員名稱(來源:delivery_order_pick_order.handlerName) */ | ||||
| val handlerName: String? = null, | val handlerName: String? = null, | ||||
| /** 來源 DO 車線(do_pick_order / delivery_order_pick_order) */ | |||||
| val truckLaneCode: String? = null, | |||||
| val deliveryOrderLines: List<DoDetailLineResponse> | val deliveryOrderLines: List<DoDetailLineResponse> | ||||
| ) | ) | ||||
| @@ -0,0 +1,54 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import jakarta.validation.Valid | |||||
| import jakarta.validation.constraints.NotEmpty | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Positive | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | |||||
| data class SubmitDoReplenishmentLineRequest( | |||||
| @field:NotNull | |||||
| @field:JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val deliveryDate: LocalDate, | |||||
| @field:NotNull | |||||
| val sourceDoId: Long, | |||||
| @field:NotNull | |||||
| val sourceDoLineId: Long, | |||||
| @field:NotNull | |||||
| @field:Positive | |||||
| val replenishQty: BigDecimal, | |||||
| val truckLaneCode: String? = null, | |||||
| ) | |||||
| data class SubmitDoReplenishmentRequest( | |||||
| @field:NotEmpty | |||||
| @field:Valid | |||||
| val lines: List<SubmitDoReplenishmentLineRequest>, | |||||
| ) | |||||
| data class DoReplenishmentResponse( | |||||
| val id: Long, | |||||
| val code: String, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val deliveryDate: LocalDate, | |||||
| val sourceDoId: Long, | |||||
| val sourceDoCode: String?, | |||||
| val sourceDoLineId: Long, | |||||
| val itemId: Long, | |||||
| val itemNo: String?, | |||||
| val itemName: String?, | |||||
| val replenishQty: BigDecimal, | |||||
| val shortUom: String?, | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val truckLaneCode: String?, | |||||
| val targetDoId: Long?, | |||||
| val targetDoCode: String?, | |||||
| val pickOrderLineId: Long?, | |||||
| val status: String, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | |||||
| val created: LocalDateTime?, | |||||
| ) | |||||
| @@ -50,6 +50,91 @@ interface TruckLaneScheduleLineRepository : AbstractRepository<TruckLaneSchedule | |||||
| ): List<Long> | ): List<Long> | ||||
| /** 鎖定中的 truck rows:APPLYING 排程,或 executeAt 已進入鎖定時間窗的 PENDING 排程。 */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT DISTINCT l.truckRowId FROM TruckLaneScheduleLine l | |||||
| JOIN l.schedule s | |||||
| WHERE l.deleted = false AND s.deleted = false | |||||
| AND l.lineStatus = :pendingLine | |||||
| AND s.status IN :openStatuses | |||||
| AND (s.status = :applyingStatus OR s.executeAt <= :lockBefore) | |||||
| """, | |||||
| ) | |||||
| fun findLockingPendingTruckRowIds( | |||||
| @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, | |||||
| @Param("openStatuses") openStatuses: Collection<TruckLaneScheduleStatus>, | |||||
| @Param("applyingStatus") applyingStatus: TruckLaneScheduleStatus, | |||||
| @Param("lockBefore") lockBefore: java.time.LocalDateTime, | |||||
| ): List<Long> | |||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(l) FROM TruckLaneScheduleLine l | |||||
| JOIN l.schedule s | |||||
| WHERE l.deleted = false AND s.deleted = false | |||||
| AND l.truckRowId = :truckRowId | |||||
| AND l.lineStatus = :pendingLine | |||||
| AND s.status IN :openScheduleStatuses | |||||
| AND (s.status = :applyingStatus OR s.executeAt <= :lockBefore) | |||||
| AND (:excludeScheduleId IS NULL OR s.id <> :excludeScheduleId) | |||||
| """, | |||||
| ) | |||||
| fun countLockingOpenPendingForTruckRow( | |||||
| @Param("truckRowId") truckRowId: Long, | |||||
| @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, | |||||
| @Param("openScheduleStatuses") openScheduleStatuses: Collection<TruckLaneScheduleStatus>, | |||||
| @Param("applyingStatus") applyingStatus: TruckLaneScheduleStatus, | |||||
| @Param("lockBefore") lockBefore: java.time.LocalDateTime, | |||||
| @Param("excludeScheduleId") excludeScheduleId: Long?, | |||||
| ): Long | |||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(l) > 0 FROM TruckLaneScheduleLine l | |||||
| JOIN l.schedule s | |||||
| WHERE l.deleted = false AND s.deleted = false | |||||
| AND s.status IN :openScheduleStatuses | |||||
| AND l.lineStatus = :pendingLine | |||||
| AND l.toTruckLanceCode = :laneCode | |||||
| AND l.toStoreId = :storeId | |||||
| AND (s.status = :applyingStatus OR s.executeAt <= :lockBefore) | |||||
| AND (:excludeScheduleId IS NULL OR s.id <> :excludeScheduleId) | |||||
| """, | |||||
| ) | |||||
| fun existsLockingOpenScheduleForLaneBucket( | |||||
| @Param("laneCode") laneCode: String, | |||||
| @Param("storeId") storeId: String, | |||||
| @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, | |||||
| @Param("openScheduleStatuses") openScheduleStatuses: Collection<TruckLaneScheduleStatus>, | |||||
| @Param("applyingStatus") applyingStatus: TruckLaneScheduleStatus, | |||||
| @Param("lockBefore") lockBefore: java.time.LocalDateTime, | |||||
| @Param("excludeScheduleId") excludeScheduleId: Long?, | |||||
| ): Boolean | |||||
| /** 其他開放排程中,對同店鋪 + 同目標車線桶的 PENDING CREATE 行數。 */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(l) FROM TruckLaneScheduleLine l | |||||
| JOIN l.schedule s | |||||
| WHERE l.deleted = false AND s.deleted = false | |||||
| AND l.lineStatus = :pendingLine | |||||
| AND s.status IN :openScheduleStatuses | |||||
| AND l.lineAction = :createAction | |||||
| AND l.shopId = :shopId | |||||
| AND l.toTruckLanceCode = :laneCode | |||||
| AND l.toStoreId = :storeId | |||||
| """, | |||||
| ) | |||||
| fun countOpenPendingCreateForShopInBucket( | |||||
| @Param("shopId") shopId: Long, | |||||
| @Param("laneCode") laneCode: String, | |||||
| @Param("storeId") storeId: String, | |||||
| @Param("createAction") createAction: TruckLaneScheduleLineAction, | |||||
| @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, | |||||
| @Param("openScheduleStatuses") openScheduleStatuses: Collection<TruckLaneScheduleStatus>, | |||||
| ): Long | |||||
| @Query( | @Query( | ||||
| @@ -4,6 +4,11 @@ enum class TruckLaneScheduleStatus { | |||||
| PENDING, | PENDING, | ||||
| APPLYING, | APPLYING, | ||||
| APPLIED, | APPLIED, | ||||
| /** | |||||
| * Legacy only:現行 applier 失敗時整批還原並標 FAILED,不會再產生 PARTIAL。 | |||||
| * 保留此值僅為相容歷史資料列;勿在新邏輯中使用。 | |||||
| */ | |||||
| PARTIAL, | PARTIAL, | ||||
| FAILED, | FAILED, | ||||
| CANCELLED, | CANCELLED, | ||||
| @@ -206,10 +206,24 @@ open class TruckLaneScheduleApplier( | |||||
| var restoreNote: String? = null | var restoreNote: String? = null | ||||
| if (preApplySnapshotId != null) { | if (preApplySnapshotId != null) { | ||||
| // 僅還原受影響車線,避免洗掉套用期間其他車線的手動修改。 | |||||
| val affectedLaneCodes = | |||||
| lines | |||||
| .flatMap { | |||||
| listOfNotNull( | |||||
| it.fromTruckLanceCode?.trim(), | |||||
| it.toTruckLanceCode?.trim(), | |||||
| ) | |||||
| } | |||||
| .filter { it.isNotEmpty() } | |||||
| .toSet() | |||||
| val affectedTruckRowIds = lines.mapNotNull { it.truckRowId }.toSet() | |||||
| try { | try { | ||||
| restoreNote = | restoreNote = | ||||
| truckLaneVersionService.restore( | |||||
| truckLaneVersionService.restoreLanes( | |||||
| preApplySnapshotId, | preApplySnapshotId, | ||||
| affectedLaneCodes, | |||||
| affectedTruckRowIds, | |||||
| skipPostSnapshot = true, | skipPostSnapshot = true, | ||||
| ) | ) | ||||
| } catch (restoreEx: Exception) { | } catch (restoreEx: Exception) { | ||||
| @@ -336,8 +350,8 @@ open class TruckLaneScheduleApplier( | |||||
| line: TruckLaneScheduleLine, | line: TruckLaneScheduleLine, | ||||
| createdTruckRowIds: MutableList<Long>, | createdTruckRowIds: MutableList<Long>, | ||||
| ): String? { | ): String? { | ||||
| val shopId = line.shopId ?: throw IllegalArgumentException("缺�? shopId") | |||||
| val departure = line.departureTime ?: throw IllegalArgumentException("缺�? departureTime") | |||||
| val shopId = line.shopId ?: throw IllegalArgumentException("缺少 shopId") | |||||
| val departure = line.departureTime ?: throw IllegalArgumentException("缺少 departureTime") | |||||
| val toKey = truckService.toBucketKey(line) | val toKey = truckService.toBucketKey(line) | ||||
| val seq = line.toLoadingSequence ?: 0 | val seq = line.toLoadingSequence ?: 0 | ||||
| val saved = | val saved = | ||||
| @@ -356,22 +370,22 @@ open class TruckLaneScheduleApplier( | |||||
| logisticId = line.logisticId, | logisticId = line.logisticId, | ||||
| ), | ), | ||||
| ) | ) | ||||
| val rowId = saved.id ?: throw IllegalStateException("CREATE ?��???truck id") | |||||
| val rowId = saved.id ?: throw IllegalStateException("CREATE 未取得 truck id") | |||||
| line.truckRowId = rowId | line.truckRowId = rowId | ||||
| createdTruckRowIds.add(rowId) | createdTruckRowIds.add(rowId) | ||||
| return null | return null | ||||
| } | } | ||||
| private fun applyMove(line: TruckLaneScheduleLine): String? { | private fun applyMove(line: TruckLaneScheduleLine): String? { | ||||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺�? truckRowId") | |||||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺少 truckRowId") | |||||
| var truck = | var truck = | ||||
| truckService.findTruckRowByIdIncludingDeleted(truckRowId) | truckService.findTruckRowByIdIncludingDeleted(truckRowId) | ||||
| ?: throw IllegalArgumentException("?��???truck #$truckRowId") | |||||
| ?: throw IllegalArgumentException("找不到 truck #$truckRowId") | |||||
| val notes = mutableListOf<String>() | val notes = mutableListOf<String>() | ||||
| if (truck.deleted == true) { | if (truck.deleted == true) { | ||||
| truck = truckService.restoreTruckRowIfDeleted(truck) | truck = truckService.restoreTruckRowIfDeleted(truck) | ||||
| notes.add("\u5e97\u92ea\u5217\u5df2\u5f9e\u522a\u9664\u72c0\u614b\u9084\u539f") | |||||
| notes.add("店鋪列已從刪除狀態還原") | |||||
| } | } | ||||
| val current = truckService.truckLaneBucketKeyOf(truck) | val current = truckService.truckLaneBucketKeyOf(truck) | ||||
| @@ -382,7 +396,7 @@ open class TruckLaneScheduleApplier( | |||||
| current.storeId != fromStore || | current.storeId != fromStore || | ||||
| (current.remark ?: "") != (fromRemark ?: "") | (current.remark ?: "") != (fromRemark ?: "") | ||||
| ) { | ) { | ||||
| notes.add("?��??��??�已不在?��?建�??��?來�?車�?,�?�?truck.id 移至?��?") | |||||
| notes.add("店鋪列已不在排程建立時的來源車線,仍依 truck.id 移至目標") | |||||
| logger.info( | logger.info( | ||||
| "Schedule line {} truck {} moved from {} to target (was scheduled from {} {})", | "Schedule line {} truck {} moved from {} to target (was scheduled from {} {})", | ||||
| line.id, | line.id, | ||||
| @@ -401,12 +415,13 @@ open class TruckLaneScheduleApplier( | |||||
| toKey, | toKey, | ||||
| line.toDistrictReference, | line.toDistrictReference, | ||||
| seq, | seq, | ||||
| line.departureTime, | |||||
| ) | ) | ||||
| return notes.takeIf { it.isNotEmpty() }?.joinToString("\uFF1B") | |||||
| return notes.takeIf { it.isNotEmpty() }?.joinToString(";") | |||||
| } | } | ||||
| private fun applyDelete(line: TruckLaneScheduleLine): String? { | private fun applyDelete(line: TruckLaneScheduleLine): String? { | ||||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺�? truckRowId") | |||||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺少 truckRowId") | |||||
| truckService.deleteById(truckRowId) | truckService.deleteById(truckRowId) | ||||
| return null | return null | ||||
| } | } | ||||
| @@ -19,6 +19,7 @@ class TruckLaneSchedulePlanValidator( | |||||
| errors.addAll(validateR2DuplicateTruckRow(lines)) | errors.addAll(validateR2DuplicateTruckRow(lines)) | ||||
| errors.addAll(validateR3OpenScheduleOverlap(lines)) | errors.addAll(validateR3OpenScheduleOverlap(lines)) | ||||
| errors.addAll(validateR4OrphanMoveDelete(lines)) | errors.addAll(validateR4OrphanMoveDelete(lines)) | ||||
| errors.addAll(validateR5DuplicateCreate(lines)) | |||||
| return errors | return errors | ||||
| } | } | ||||
| @@ -95,6 +96,74 @@ class TruckLaneSchedulePlanValidator( | |||||
| return errors | return errors | ||||
| } | } | ||||
| /** | |||||
| * R5:CREATE 重複防護。 | |||||
| * - 同一計畫內:同店鋪 + 同目標車線桶不可重複 CREATE | |||||
| * - 目標車線已有該店鋪的有效列 | |||||
| * - 其他開放排程已有同店鋪 + 同目標車線桶的 PENDING CREATE | |||||
| */ | |||||
| private fun validateR5DuplicateCreate( | |||||
| lines: List<TruckLaneScheduleLineRequest>, | |||||
| ): List<SchedulePlanBlockingError> { | |||||
| val errors = mutableListOf<SchedulePlanBlockingError>() | |||||
| val seenInPlan = mutableSetOf<String>() | |||||
| for (line in lines) { | |||||
| if (line.action != TruckLaneScheduleLineAction.CREATE) continue | |||||
| val shopId = line.shopId ?: continue | |||||
| val storeNorm = truckService.normalizeRouteStoreId(line.toStoreId) | |||||
| val toKey = TruckService.TruckLaneBucketKey( | |||||
| truckLanceCode = line.toTruckLanceCode.trim(), | |||||
| storeId = storeNorm, | |||||
| remark = truckService.bucketRemarkForStore(storeNorm, line.toRemark), | |||||
| ) | |||||
| val planKey = "$shopId|${toKey.truckLanceCode}|${toKey.storeId}|${toKey.remark ?: ""}" | |||||
| if (!seenInPlan.add(planKey)) { | |||||
| errors.add( | |||||
| SchedulePlanBlockingError( | |||||
| code = "R5_DUPLICATE_CREATE_IN_PLAN", | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| truckRowId = null, | |||||
| messageKey = "schedule.plan.r5_duplicate_create_in_plan", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| if (truckService.trucksInLaneBucket(toKey).any { it.shop?.id == shopId }) { | |||||
| errors.add( | |||||
| SchedulePlanBlockingError( | |||||
| code = "R5_SHOP_ALREADY_IN_LANE", | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| truckRowId = null, | |||||
| messageKey = "schedule.plan.r5_shop_already_in_lane", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| if (scheduleLineRepository.countOpenPendingCreateForShopInBucket( | |||||
| shopId, | |||||
| toKey.truckLanceCode, | |||||
| toKey.storeId, | |||||
| TruckLaneScheduleLineAction.CREATE, | |||||
| TruckLaneScheduleLineStatus.PENDING, | |||||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||||
| ) > 0 | |||||
| ) { | |||||
| errors.add( | |||||
| SchedulePlanBlockingError( | |||||
| code = "R5_OPEN_CREATE_OVERLAP", | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| truckRowId = null, | |||||
| messageKey = "schedule.plan.r5_open_create_overlap", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| return errors | |||||
| } | |||||
| private fun validateR4OrphanMoveDelete( | private fun validateR4OrphanMoveDelete( | ||||
| lines: List<TruckLaneScheduleLineRequest>, | lines: List<TruckLaneScheduleLineRequest>, | ||||
| ): List<SchedulePlanBlockingError> { | ): List<SchedulePlanBlockingError> { | ||||
| @@ -17,6 +17,8 @@ open class TruckLaneScheduleService( | |||||
| private val scheduleApplier: TruckLaneScheduleApplier, | private val scheduleApplier: TruckLaneScheduleApplier, | ||||
| private val schedulePlanService: TruckLaneSchedulePlanService, | private val schedulePlanService: TruckLaneSchedulePlanService, | ||||
| private val schedulePlanValidator: TruckLaneSchedulePlanValidator, | private val schedulePlanValidator: TruckLaneSchedulePlanValidator, | ||||
| @org.springframework.beans.factory.annotation.Value("\${truck.lane.schedule.lock.hours:24}") | |||||
| private val scheduleLockWindowHours: Long = 24, | |||||
| ) { | ) { | ||||
| @Transactional | @Transactional | ||||
| open fun createManual(request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | open fun createManual(request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | ||||
| @@ -138,10 +140,17 @@ open class TruckLaneScheduleService( | |||||
| } | } | ||||
| open fun pendingTruckRowIds(): PendingTruckRowIdsResponse { | open fun pendingTruckRowIds(): PendingTruckRowIdsResponse { | ||||
| val openStatuses = listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING) | |||||
| return PendingTruckRowIdsResponse( | return PendingTruckRowIdsResponse( | ||||
| scheduleLineRepository.findPendingTruckRowIds( | |||||
| truckRowIds = scheduleLineRepository.findPendingTruckRowIds( | |||||
| TruckLaneScheduleLineStatus.PENDING, | TruckLaneScheduleLineStatus.PENDING, | ||||
| listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING), | |||||
| openStatuses, | |||||
| ), | |||||
| lockedTruckRowIds = scheduleLineRepository.findLockingPendingTruckRowIds( | |||||
| TruckLaneScheduleLineStatus.PENDING, | |||||
| openStatuses, | |||||
| TruckLaneScheduleStatus.APPLYING, | |||||
| LocalDateTime.now().plusHours(scheduleLockWindowHours), | |||||
| ), | ), | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -215,13 +224,19 @@ open class TruckLaneScheduleService( | |||||
| if (requestLines.isEmpty()) { | if (requestLines.isEmpty()) { | ||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無可重試的失敗列") | throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無可重試的失敗列") | ||||
| } | } | ||||
| return createManual( | |||||
| val created = createManual( | |||||
| CreateTruckLaneScheduleRequest( | CreateTruckLaneScheduleRequest( | ||||
| executeAt = whenAt, | executeAt = whenAt, | ||||
| note = "retry from schedule #$id", | note = "retry from schedule #$id", | ||||
| lines = requestLines, | lines = requestLines, | ||||
| ), | ), | ||||
| ) | ) | ||||
| // 重試排程已接手追蹤,原 FAILED 單標為 IGNORED 以免失敗徽章長亮。 | |||||
| s.status = TruckLaneScheduleStatus.IGNORED | |||||
| s.errorMessage = | |||||
| ((s.errorMessage?.let { "$it;" } ?: "") + "已由重試排程 #${created.id} 取代").take(2000) | |||||
| scheduleRepository.save(s) | |||||
| return created | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| @@ -271,6 +271,82 @@ open class TruckLaneVersionService( | |||||
| } | } | ||||
| } | } | ||||
| restoreRowsFromLines(version, lines) | |||||
| if (!skipPostSnapshot) { | |||||
| createSnapshot( | |||||
| CreateTruckLaneSnapshotRequest( | |||||
| truckLanceCode = null, | |||||
| note = "restore from versionId=$versionId", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| return "Restored versionId=$versionId" | |||||
| } | |||||
| /** | |||||
| * 範圍還原:僅還原指定車線(含 `truckRowIds` 指到的列),不動其他車線。 | |||||
| * 供排程失敗回滾使用,避免全板還原洗掉套用期間無關的手動修改。 | |||||
| * | |||||
| * Extras 規則:受影響車線中「完全不存在於快照」的列才軟刪; | |||||
| * 若該列在快照中屬於未受影響車線(套用期間被手動移入),保留不動。 | |||||
| */ | |||||
| @Transactional | |||||
| open fun restoreLanes( | |||||
| versionId: Long, | |||||
| laneCodes: Set<String>, | |||||
| truckRowIds: Set<Long>, | |||||
| skipPostSnapshot: Boolean = false, | |||||
| ): String { | |||||
| val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") | |||||
| val allLines = | |||||
| truckLaneVersionLineRepository.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId) | |||||
| if (allLines.isEmpty()) return "No lines to restore for versionId=$versionId" | |||||
| val codes = laneCodes.map { it.trim() }.filter { it.isNotEmpty() }.toSet() | |||||
| val scopedLines = allLines.filter { line -> | |||||
| val laneCode = line.truckLanceCode?.trim() | |||||
| (laneCode != null && laneCode in codes) || | |||||
| (line.truckRowId != null && line.truckRowId in truckRowIds) | |||||
| } | |||||
| if (scopedLines.isEmpty()) { | |||||
| return "No snapshot lines for affected lanes (versionId=$versionId)" | |||||
| } | |||||
| val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() | |||||
| for (lane in codes) { | |||||
| val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) | |||||
| val extras = currentLane.filter { t -> t.id != null && t.id !in fullSnapshotIds } | |||||
| extras.forEach { it.deleted = true } | |||||
| if (extras.isNotEmpty()) { | |||||
| truckRepository.saveAll(extras) | |||||
| } | |||||
| } | |||||
| restoreRowsFromLines(version, scopedLines) | |||||
| if (!skipPostSnapshot) { | |||||
| createSnapshot( | |||||
| CreateTruckLaneSnapshotRequest( | |||||
| truckLanceCode = null, | |||||
| note = "restore lanes from versionId=$versionId", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| return "Restored ${scopedLines.size} rows in ${codes.size} lane(s) from versionId=$versionId" | |||||
| } | |||||
| private fun restoreRowsFromLines( | |||||
| version: TruckLaneVersion, | |||||
| lines: List<TruckLaneVersionLine>, | |||||
| ) { | |||||
| val snapshottedIds = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() | |||||
| if (snapshottedIds.isEmpty()) return | |||||
| val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id } | val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id } | ||||
| val logisticIds = | val logisticIds = | ||||
| @@ -308,16 +384,5 @@ open class TruckLaneVersionService( | |||||
| if (updated.isNotEmpty()) { | if (updated.isNotEmpty()) { | ||||
| truckRepository.saveAll(updated) | truckRepository.saveAll(updated) | ||||
| } | } | ||||
| if (!skipPostSnapshot) { | |||||
| createSnapshot( | |||||
| CreateTruckLaneSnapshotRequest( | |||||
| truckLanceCode = null, | |||||
| note = "restore from versionId=$versionId", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| return "Restored versionId=$versionId" | |||||
| } | } | ||||
| } | } | ||||
| @@ -12,6 +12,7 @@ import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | import com.ffii.fpsms.modules.pickOrder.entity.Truck | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLine | import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLine | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineRepository | import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLineRepository | import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLineRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository | import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | ||||
| @@ -46,14 +47,22 @@ open class TruckService( | |||||
| private val truckLaneVersionRepository: TruckLaneVersionRepository, | private val truckLaneVersionRepository: TruckLaneVersionRepository, | ||||
| private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | ||||
| private val scheduleLineRepository: TruckLaneScheduleLineRepository, | private val scheduleLineRepository: TruckLaneScheduleLineRepository, | ||||
| @org.springframework.beans.factory.annotation.Value("\${truck.lane.schedule.lock.hours:24}") | |||||
| private val scheduleLockWindowHours: Long = 24, | |||||
| ) : AbstractBaseEntityService<Truck, Long, TruckRepository>(jdbcDao, truckRepository) { | ) : AbstractBaseEntityService<Truck, Long, TruckRepository>(jdbcDao, truckRepository) { | ||||
| /** 排程鎖僅在執行前 N 小時內(或排程 APPLYING 中)生效;遠期排程不鎖手改。 */ | |||||
| private fun scheduleLockBefore(): java.time.LocalDateTime = | |||||
| java.time.LocalDateTime.now().plusHours(scheduleLockWindowHours) | |||||
| private fun assertNotScheduleLocked(truckRowId: Long?) { | private fun assertNotScheduleLocked(truckRowId: Long?) { | ||||
| if (truckRowId == null) return | if (truckRowId == null) return | ||||
| if (scheduleLineRepository.countOpenPendingForTruckRow( | |||||
| if (scheduleLineRepository.countLockingOpenPendingForTruckRow( | |||||
| truckRowId, | truckRowId, | ||||
| TruckLaneScheduleLockSupport.pendingLineStatus, | TruckLaneScheduleLockSupport.pendingLineStatus, | ||||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | TruckLaneScheduleLockSupport.openScheduleStatuses, | ||||
| TruckLaneScheduleStatus.APPLYING, | |||||
| scheduleLockBefore(), | |||||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | TruckLaneScheduleLockSupport.applyingScheduleId(), | ||||
| ) > 0 | ) > 0 | ||||
| ) { | ) { | ||||
| @@ -62,11 +71,13 @@ open class TruckService( | |||||
| } | } | ||||
| private fun assertNotScheduleLocked(bucket: TruckLaneBucketKey) { | private fun assertNotScheduleLocked(bucket: TruckLaneBucketKey) { | ||||
| if (scheduleLineRepository.existsOpenScheduleForLaneBucket( | |||||
| if (scheduleLineRepository.existsLockingOpenScheduleForLaneBucket( | |||||
| bucket.truckLanceCode.trim(), | bucket.truckLanceCode.trim(), | ||||
| bucket.storeId.trim(), | bucket.storeId.trim(), | ||||
| TruckLaneScheduleLockSupport.pendingLineStatus, | TruckLaneScheduleLockSupport.pendingLineStatus, | ||||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | TruckLaneScheduleLockSupport.openScheduleStatuses, | ||||
| TruckLaneScheduleStatus.APPLYING, | |||||
| scheduleLockBefore(), | |||||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | TruckLaneScheduleLockSupport.applyingScheduleId(), | ||||
| ) | ) | ||||
| ) { | ) { | ||||
| @@ -611,6 +622,7 @@ open class TruckService( | |||||
| to: TruckLaneBucketKey, | to: TruckLaneBucketKey, | ||||
| districtReference: String?, | districtReference: String?, | ||||
| loadingSequence: Int, | loadingSequence: Int, | ||||
| departureTime: LocalTime? = null, | |||||
| ): SaveTruckLane { | ): SaveTruckLane { | ||||
| val targetBucket = trucksInLaneBucket(to) | val targetBucket = trucksInLaneBucket(to) | ||||
| val template = | val template = | ||||
| @@ -619,11 +631,13 @@ open class TruckService( | |||||
| val storeNorm = normalizeRouteStoreId(to.storeId) | val storeNorm = normalizeRouteStoreId(to.storeId) | ||||
| val toRemark = | val toRemark = | ||||
| if (storeNorm == "4F") to.remark?.trim()?.takeIf { it.isNotEmpty() } else null | if (storeNorm == "4F") to.remark?.trim()?.takeIf { it.isNotEmpty() } else null | ||||
| val resolvedDeparture = | |||||
| departureTime ?: template.departureTime ?: truck.departureTime ?: LocalTime.of(0, 0) | |||||
| return SaveTruckLane( | return SaveTruckLane( | ||||
| id = truck.id ?: 0L, | id = truck.id ?: 0L, | ||||
| truckLanceCode = to.truckLanceCode.trim(), | truckLanceCode = to.truckLanceCode.trim(), | ||||
| departureTime = template.departureTime ?: LocalTime.of(0, 0), | |||||
| departureTime = resolvedDeparture, | |||||
| loadingSequence = loadingSequence.coerceAtLeast(0).toLong(), | loadingSequence = loadingSequence.coerceAtLeast(0).toLong(), | ||||
| districtReference = districtReference ?: truck.districtReference, | districtReference = districtReference ?: truck.districtReference, | ||||
| storeId = storeNorm, | storeId = storeNorm, | ||||
| @@ -639,6 +653,7 @@ open class TruckService( | |||||
| to: TruckLaneBucketKey, | to: TruckLaneBucketKey, | ||||
| districtReference: String? = null, | districtReference: String? = null, | ||||
| loadingSequence: Int? = null, | loadingSequence: Int? = null, | ||||
| departureTime: LocalTime? = null, | |||||
| ): List<Truck> { | ): List<Truck> { | ||||
| assertNotScheduleLocked(truckRowId) | assertNotScheduleLocked(truckRowId) | ||||
| var truck = findTruckRowByIdIncludingDeleted(truckRowId) | var truck = findTruckRowByIdIncludingDeleted(truckRowId) | ||||
| @@ -667,14 +682,18 @@ open class TruckService( | |||||
| val currentSeq = truck.loadingSequence ?: 0 | val currentSeq = truck.loadingSequence ?: 0 | ||||
| val currentDistrict = truck.districtReference?.trim().orEmpty() | val currentDistrict = truck.districtReference?.trim().orEmpty() | ||||
| val nextDistrict = district?.trim().orEmpty() | val nextDistrict = district?.trim().orEmpty() | ||||
| if (currentSeq == seq && currentDistrict == nextDistrict) { | |||||
| val departureChanged = | |||||
| departureTime != null && departureTime != truck.departureTime | |||||
| if (currentSeq == seq && currentDistrict == nextDistrict && !departureChanged) { | |||||
| return listOf(truck) | return listOf(truck) | ||||
| } | } | ||||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||||
| val request = | |||||
| buildSaveTruckLaneForScheduledMove(truck, to, district, seq, departureTime) | |||||
| return updateTruckLaneByTruckLanceCode(request) | return updateTruckLaneByTruckLanceCode(request) | ||||
| } | } | ||||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||||
| val request = | |||||
| buildSaveTruckLaneForScheduledMove(truck, to, district, seq, departureTime) | |||||
| return updateTruckLaneByTruckLanceCode(request) | return updateTruckLaneByTruckLanceCode(request) | ||||
| } | } | ||||
| @@ -123,7 +123,10 @@ data class TruckLaneScheduleLineCounts( | |||||
| ) | ) | ||||
| data class PendingTruckRowIdsResponse( | data class PendingTruckRowIdsResponse( | ||||
| /** 所有開放排程(PENDING/APPLYING)涉及的 truck rows,供排程驗證、標記用。 */ | |||||
| val truckRowIds: List<Long>, | val truckRowIds: List<Long>, | ||||
| /** 已進入鎖定時間窗(或 APPLYING)的 truck rows,看板不可手改。 */ | |||||
| val lockedTruckRowIds: List<Long> = emptyList(), | |||||
| ) | ) | ||||
| data class RouteExcelSchedulePlanPreviewRow( | data class RouteExcelSchedulePlanPreviewRow( | ||||