| @@ -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 itemsRepository: ItemsRepository, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| private val doReplenishmentService: DoReplenishmentService, | |||
| ) { | |||
| /** | |||
| * 樓層篩選:2F/4F/ALL 由 [DoFloorSupplierSettingsService] 讀 `settings`。 | |||
| @@ -525,6 +526,51 @@ open class DeliveryOrderService( | |||
| 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? { | |||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(id) ?: return null | |||
| val handlerName = try { | |||
| @@ -533,6 +579,12 @@ open class DeliveryOrderService( | |||
| log.warn("Failed to resolve handler name for delivery order {}: {}", id, ex.message) | |||
| 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 stockQtyByItemId = itemIds.associateWith { itemId -> | |||
| @@ -552,6 +604,7 @@ open class DeliveryOrderService( | |||
| status = deliveryOrder.status?.value, | |||
| isExtra = deliveryOrder.isExtra, | |||
| handlerName = handlerName, | |||
| truckLaneCode = truckLaneCode, | |||
| deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> | |||
| val itemId = line.item?.id | |||
| val stockQty = itemId?.let { stockQtyByItemId[it] } ?: BigDecimal.ZERO | |||
| @@ -1219,6 +1272,7 @@ open class DeliveryOrderService( | |||
| val deliveryOrders = deliveryOrderRepository.findAllById(deliveryOrderIds) | |||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | |||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | |||
| val replenishPdfIndex = buildReplenishPdfIndex(deliveryOrderIds) | |||
| val exportLines = deliveryNoteExportLines(deliveryNoteInfo) | |||
| val sortedLines = exportLines.sortedBy { row -> | |||
| @@ -1237,14 +1291,16 @@ open class DeliveryOrderService( | |||
| val line = row.line | |||
| val field = mutableMapOf<String, Any>() | |||
| val sequenceNumber = fields.size + 1 | |||
| val isExtra = isExtraDeliveryTicket( | |||
| lineTicketNo = null, | |||
| deliveryOrderIsExtra = isExtraByDoId[row.deliveryOrderId] == true, | |||
| headerIsMerge = false, | |||
| ) | |||
| field["sequenceNumber"] = formatSequenceNumber( | |||
| sequenceNumber, | |||
| isExtraDeliveryTicket( | |||
| lineTicketNo = null, | |||
| deliveryOrderIsExtra = isExtraByDoId[row.deliveryOrderId] == true, | |||
| headerIsMerge = false, | |||
| ), | |||
| isExtra, | |||
| replenishPdfIndex.matches(row.deliveryOrderId, line.itemId, null), | |||
| ) | |||
| field["deliveryOrderCode"] = formatDoCodeWithUnderlineLast4( | |||
| deliveryOrderCodeById[row.deliveryOrderId].orEmpty(), | |||
| @@ -1403,6 +1459,7 @@ open class DeliveryOrderService( | |||
| val deliveryOrders = deliveryOrderRepository.findAllById(deliveryOrderIds) | |||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | |||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | |||
| val replenishPdfIndex = buildReplenishPdfIndex(deliveryOrderIds) | |||
| sortedLines.forEach { row -> | |||
| fields.add( | |||
| @@ -1419,6 +1476,7 @@ open class DeliveryOrderService( | |||
| isExtraByDoId = isExtraByDoId, | |||
| headerTicketNo = null, | |||
| headerIsMerge = false, | |||
| replenishPdfIndex = replenishPdfIndex, | |||
| ), | |||
| ) | |||
| } | |||
| @@ -1527,8 +1585,41 @@ open class DeliveryOrderService( | |||
| codes.filter { it.isNotBlank() }.distinct().sorted() | |||
| .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( | |||
| deliveryOrderId: Long, | |||
| @@ -1543,6 +1634,7 @@ open class DeliveryOrderService( | |||
| isExtraByDoId: Map<Long, Boolean> = emptyMap(), | |||
| headerTicketNo: String? = null, | |||
| headerIsMerge: Boolean = false, | |||
| replenishPdfIndex: ReplenishPdfIndex = ReplenishPdfIndex.EMPTY, | |||
| ): MutableMap<String, Any> { | |||
| val field = mutableMapOf<String, Any>() | |||
| val isExtra = isExtraDeliveryTicket( | |||
| @@ -1550,14 +1642,6 @@ open class DeliveryOrderService( | |||
| deliveryOrderIsExtra = isExtraByDoId[deliveryOrderId] == true, | |||
| 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( | |||
| deliveryOrderId = deliveryOrderId, | |||
| @@ -1565,6 +1649,16 @@ open class DeliveryOrderService( | |||
| pickOrderIdByDoId = pickOrderIdByDoId, | |||
| 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) | |||
| 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) { | |||
| println("❌ No delivery_order_pick_order found for workbench user $userId") | |||
| val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000 | |||
| log.info( | |||
| log. info( | |||
| "workbench all-lots-hierarchical-workbench timing: userId={} totalMs={} detail={}", | |||
| userId, | |||
| totalMs, | |||
| @@ -2764,6 +2764,7 @@ return MessageResponse( | |||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | |||
| val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true | |||
| val headerTicketNo = ctx.header.ticketNo | |||
| val replenishPdfIndex = deliveryOrderService.buildReplenishPdfIndex(ctx.deliveryOrderIds) | |||
| sortedLines.forEach { row -> | |||
| fields.add( | |||
| @@ -2780,6 +2781,7 @@ return MessageResponse( | |||
| isExtraByDoId = isExtraByDoId, | |||
| headerTicketNo = headerTicketNo, | |||
| 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.enums.DeliveryOrderStatus | |||
| 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.SaveDeliveryOrderResponse | |||
| 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.entity.models.DeliveryOrderInfoLite | |||
| 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 java.time.LocalDate | |||
| @RequestMapping("/do") | |||
| @RestController | |||
| @@ -52,7 +56,7 @@ class DeliveryOrderController( | |||
| private val deliveryOrderService: DeliveryOrderService, | |||
| private val stockInLineService: StockInLineService, | |||
| private val doPickOrderService: DoPickOrderService, | |||
| private val doReplenishmentService: DoReplenishmentService, | |||
| ) { | |||
| private val log = LoggerFactory.getLogger(javaClass) | |||
| @@ -122,6 +126,21 @@ class DeliveryOrderController( | |||
| 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}") | |||
| fun searchByCode(@PathVariable code: String): List<DeliveryOrderInfo> { | |||
| return deliveryOrderService.searchByCode(code); | |||
| @@ -22,6 +22,8 @@ data class DoDetailResponse( | |||
| val isExtra: Boolean = false, | |||
| /** 揀貨員名稱(來源:delivery_order_pick_order.handlerName) */ | |||
| val handlerName: String? = null, | |||
| /** 來源 DO 車線(do_pick_order / delivery_order_pick_order) */ | |||
| val truckLaneCode: String? = null, | |||
| 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> | |||
| /** 鎖定中的 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( | |||
| @@ -4,6 +4,11 @@ enum class TruckLaneScheduleStatus { | |||
| PENDING, | |||
| APPLYING, | |||
| APPLIED, | |||
| /** | |||
| * Legacy only:現行 applier 失敗時整批還原並標 FAILED,不會再產生 PARTIAL。 | |||
| * 保留此值僅為相容歷史資料列;勿在新邏輯中使用。 | |||
| */ | |||
| PARTIAL, | |||
| FAILED, | |||
| CANCELLED, | |||
| @@ -206,10 +206,24 @@ open class TruckLaneScheduleApplier( | |||
| var restoreNote: String? = 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 { | |||
| restoreNote = | |||
| truckLaneVersionService.restore( | |||
| truckLaneVersionService.restoreLanes( | |||
| preApplySnapshotId, | |||
| affectedLaneCodes, | |||
| affectedTruckRowIds, | |||
| skipPostSnapshot = true, | |||
| ) | |||
| } catch (restoreEx: Exception) { | |||
| @@ -336,8 +350,8 @@ open class TruckLaneScheduleApplier( | |||
| line: TruckLaneScheduleLine, | |||
| createdTruckRowIds: MutableList<Long>, | |||
| ): 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 seq = line.toLoadingSequence ?: 0 | |||
| val saved = | |||
| @@ -356,22 +370,22 @@ open class TruckLaneScheduleApplier( | |||
| logisticId = line.logisticId, | |||
| ), | |||
| ) | |||
| val rowId = saved.id ?: throw IllegalStateException("CREATE ?��???truck id") | |||
| val rowId = saved.id ?: throw IllegalStateException("CREATE 未取得 truck id") | |||
| line.truckRowId = rowId | |||
| createdTruckRowIds.add(rowId) | |||
| return null | |||
| } | |||
| private fun applyMove(line: TruckLaneScheduleLine): String? { | |||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺�? truckRowId") | |||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺少 truckRowId") | |||
| var truck = | |||
| truckService.findTruckRowByIdIncludingDeleted(truckRowId) | |||
| ?: throw IllegalArgumentException("?��???truck #$truckRowId") | |||
| ?: throw IllegalArgumentException("找不到 truck #$truckRowId") | |||
| val notes = mutableListOf<String>() | |||
| if (truck.deleted == true) { | |||
| truck = truckService.restoreTruckRowIfDeleted(truck) | |||
| notes.add("\u5e97\u92ea\u5217\u5df2\u5f9e\u522a\u9664\u72c0\u614b\u9084\u539f") | |||
| notes.add("店鋪列已從刪除狀態還原") | |||
| } | |||
| val current = truckService.truckLaneBucketKeyOf(truck) | |||
| @@ -382,7 +396,7 @@ open class TruckLaneScheduleApplier( | |||
| current.storeId != fromStore || | |||
| (current.remark ?: "") != (fromRemark ?: "") | |||
| ) { | |||
| notes.add("?��??��??�已不在?��?建�??��?來�?車�?,�?�?truck.id 移至?��?") | |||
| notes.add("店鋪列已不在排程建立時的來源車線,仍依 truck.id 移至目標") | |||
| logger.info( | |||
| "Schedule line {} truck {} moved from {} to target (was scheduled from {} {})", | |||
| line.id, | |||
| @@ -401,12 +415,13 @@ open class TruckLaneScheduleApplier( | |||
| toKey, | |||
| line.toDistrictReference, | |||
| seq, | |||
| line.departureTime, | |||
| ) | |||
| return notes.takeIf { it.isNotEmpty() }?.joinToString("\uFF1B") | |||
| return notes.takeIf { it.isNotEmpty() }?.joinToString(";") | |||
| } | |||
| private fun applyDelete(line: TruckLaneScheduleLine): String? { | |||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺�? truckRowId") | |||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺少 truckRowId") | |||
| truckService.deleteById(truckRowId) | |||
| return null | |||
| } | |||
| @@ -19,6 +19,7 @@ class TruckLaneSchedulePlanValidator( | |||
| errors.addAll(validateR2DuplicateTruckRow(lines)) | |||
| errors.addAll(validateR3OpenScheduleOverlap(lines)) | |||
| errors.addAll(validateR4OrphanMoveDelete(lines)) | |||
| errors.addAll(validateR5DuplicateCreate(lines)) | |||
| return errors | |||
| } | |||
| @@ -95,6 +96,74 @@ class TruckLaneSchedulePlanValidator( | |||
| 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( | |||
| lines: List<TruckLaneScheduleLineRequest>, | |||
| ): List<SchedulePlanBlockingError> { | |||
| @@ -17,6 +17,8 @@ open class TruckLaneScheduleService( | |||
| private val scheduleApplier: TruckLaneScheduleApplier, | |||
| private val schedulePlanService: TruckLaneSchedulePlanService, | |||
| private val schedulePlanValidator: TruckLaneSchedulePlanValidator, | |||
| @org.springframework.beans.factory.annotation.Value("\${truck.lane.schedule.lock.hours:24}") | |||
| private val scheduleLockWindowHours: Long = 24, | |||
| ) { | |||
| @Transactional | |||
| open fun createManual(request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | |||
| @@ -138,10 +140,17 @@ open class TruckLaneScheduleService( | |||
| } | |||
| open fun pendingTruckRowIds(): PendingTruckRowIdsResponse { | |||
| val openStatuses = listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING) | |||
| return PendingTruckRowIdsResponse( | |||
| scheduleLineRepository.findPendingTruckRowIds( | |||
| truckRowIds = scheduleLineRepository.findPendingTruckRowIds( | |||
| 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()) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無可重試的失敗列") | |||
| } | |||
| return createManual( | |||
| val created = createManual( | |||
| CreateTruckLaneScheduleRequest( | |||
| executeAt = whenAt, | |||
| note = "retry from schedule #$id", | |||
| lines = requestLines, | |||
| ), | |||
| ) | |||
| // 重試排程已接手追蹤,原 FAILED 單標為 IGNORED 以免失敗徽章長亮。 | |||
| s.status = TruckLaneScheduleStatus.IGNORED | |||
| s.errorMessage = | |||
| ((s.errorMessage?.let { "$it;" } ?: "") + "已由重試排程 #${created.id} 取代").take(2000) | |||
| scheduleRepository.save(s) | |||
| return created | |||
| } | |||
| @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 logisticIds = | |||
| @@ -308,16 +384,5 @@ open class TruckLaneVersionService( | |||
| if (updated.isNotEmpty()) { | |||
| 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.TruckLaneScheduleLine | |||
| 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.TruckLaneVersionRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||
| @@ -46,14 +47,22 @@ open class TruckService( | |||
| private val truckLaneVersionRepository: TruckLaneVersionRepository, | |||
| private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | |||
| 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) { | |||
| /** 排程鎖僅在執行前 N 小時內(或排程 APPLYING 中)生效;遠期排程不鎖手改。 */ | |||
| private fun scheduleLockBefore(): java.time.LocalDateTime = | |||
| java.time.LocalDateTime.now().plusHours(scheduleLockWindowHours) | |||
| private fun assertNotScheduleLocked(truckRowId: Long?) { | |||
| if (truckRowId == null) return | |||
| if (scheduleLineRepository.countOpenPendingForTruckRow( | |||
| if (scheduleLineRepository.countLockingOpenPendingForTruckRow( | |||
| truckRowId, | |||
| TruckLaneScheduleLockSupport.pendingLineStatus, | |||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||
| TruckLaneScheduleStatus.APPLYING, | |||
| scheduleLockBefore(), | |||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | |||
| ) > 0 | |||
| ) { | |||
| @@ -62,11 +71,13 @@ open class TruckService( | |||
| } | |||
| private fun assertNotScheduleLocked(bucket: TruckLaneBucketKey) { | |||
| if (scheduleLineRepository.existsOpenScheduleForLaneBucket( | |||
| if (scheduleLineRepository.existsLockingOpenScheduleForLaneBucket( | |||
| bucket.truckLanceCode.trim(), | |||
| bucket.storeId.trim(), | |||
| TruckLaneScheduleLockSupport.pendingLineStatus, | |||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||
| TruckLaneScheduleStatus.APPLYING, | |||
| scheduleLockBefore(), | |||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | |||
| ) | |||
| ) { | |||
| @@ -611,6 +622,7 @@ open class TruckService( | |||
| to: TruckLaneBucketKey, | |||
| districtReference: String?, | |||
| loadingSequence: Int, | |||
| departureTime: LocalTime? = null, | |||
| ): SaveTruckLane { | |||
| val targetBucket = trucksInLaneBucket(to) | |||
| val template = | |||
| @@ -619,11 +631,13 @@ open class TruckService( | |||
| val storeNorm = normalizeRouteStoreId(to.storeId) | |||
| val toRemark = | |||
| if (storeNorm == "4F") to.remark?.trim()?.takeIf { it.isNotEmpty() } else null | |||
| val resolvedDeparture = | |||
| departureTime ?: template.departureTime ?: truck.departureTime ?: LocalTime.of(0, 0) | |||
| return SaveTruckLane( | |||
| id = truck.id ?: 0L, | |||
| truckLanceCode = to.truckLanceCode.trim(), | |||
| departureTime = template.departureTime ?: LocalTime.of(0, 0), | |||
| departureTime = resolvedDeparture, | |||
| loadingSequence = loadingSequence.coerceAtLeast(0).toLong(), | |||
| districtReference = districtReference ?: truck.districtReference, | |||
| storeId = storeNorm, | |||
| @@ -639,6 +653,7 @@ open class TruckService( | |||
| to: TruckLaneBucketKey, | |||
| districtReference: String? = null, | |||
| loadingSequence: Int? = null, | |||
| departureTime: LocalTime? = null, | |||
| ): List<Truck> { | |||
| assertNotScheduleLocked(truckRowId) | |||
| var truck = findTruckRowByIdIncludingDeleted(truckRowId) | |||
| @@ -667,14 +682,18 @@ open class TruckService( | |||
| val currentSeq = truck.loadingSequence ?: 0 | |||
| val currentDistrict = truck.districtReference?.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) | |||
| } | |||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||
| val request = | |||
| buildSaveTruckLaneForScheduledMove(truck, to, district, seq, departureTime) | |||
| return updateTruckLaneByTruckLanceCode(request) | |||
| } | |||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||
| val request = | |||
| buildSaveTruckLaneForScheduledMove(truck, to, district, seq, departureTime) | |||
| return updateTruckLaneByTruckLanceCode(request) | |||
| } | |||
| @@ -123,7 +123,10 @@ data class TruckLaneScheduleLineCounts( | |||
| ) | |||
| data class PendingTruckRowIdsResponse( | |||
| /** 所有開放排程(PENDING/APPLYING)涉及的 truck rows,供排程驗證、標記用。 */ | |||
| val truckRowIds: List<Long>, | |||
| /** 已進入鎖定時間窗(或 APPLYING)的 truck rows,看板不可手改。 */ | |||
| val lockedTruckRowIds: List<Long> = emptyList(), | |||
| ) | |||
| data class RouteExcelSchedulePlanPreviewRow( | |||