diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt new file mode 100644 index 0000000..427f11b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt @@ -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() { + + @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" + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt new file mode 100644 index 0000000..be1a035 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt @@ -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 { + + fun findByCodeAndDeletedIsFalse(code: String): DoReplenishment? + + fun existsBySourceDoLineIdAndStatusAndDeletedIsFalse(sourceDoLineId: Long, status: String): Boolean + + fun findFirstBySourceDoLineIdAndStatusAndDeletedIsFalse( + sourceDoLineId: Long, + status: String, + ): DoReplenishment? + + fun findByTargetDoIdInAndDeletedIsFalse(targetDoIds: Collection): List + + @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 + + @Query( + """ + SELECT r.code FROM DoReplenishment r + WHERE r.deleted = false + AND r.code LIKE CONCAT(:codePrefix, '%') + """, + ) + fun findCodesByPrefix(@Param("codePrefix") codePrefix: String): List +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index c5f36f5..65f684a 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -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() 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>, + private val pickOrderLineIds: Set, + ) { + 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): 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 = emptyMap(), headerTicketNo: String? = null, headerIsMerge: Boolean = false, + replenishPdfIndex: ReplenishPdfIndex = ReplenishPdfIndex.EMPTY, ): MutableMap { val field = mutableMapOf() 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) { diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt new file mode 100644 index 0000000..7686094 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt @@ -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 { + if (request.lines.isEmpty()) { + throw IllegalArgumentException("No replenishment lines to submit") + } + val nextSeqByDate = mutableMapOf() + val created = mutableListOf() + 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 { + val normalizedStatus = status?.trim()?.takeIf { it.isNotEmpty() && it != "all" } + val rows = doReplenishmentRepository.search(deliveryDate, normalizedStatus) + return toResponses(rows) + } + + open fun findReplenishmentsByTargetDoIds(targetDoIds: Collection): List { + 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, + ): List { + if (lines.size <= 1) return lines + val merged = linkedMapOf() + 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): List { + 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, + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 680fea7..6be74ad 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -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, ), ) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt index f57fbfe..e39a06d 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt @@ -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 { + return doReplenishmentService.submit(request) + } + + @GetMapping("/replenishment") + fun listReplenishment( + @RequestParam(required = false) deliveryDate: LocalDate?, + @RequestParam(required = false) status: String?, + ): List { + return doReplenishmentService.list(deliveryDate, status) + } + @GetMapping("/search-code/{code}") fun searchByCode(@PathVariable code: String): List { return deliveryOrderService.searchByCode(code); diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt index 3c5a533..acb1cf6 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt @@ -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 ) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt new file mode 100644 index 0000000..c404163 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt @@ -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, +) + +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?, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt index 8c8d7fe..1c0b424 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt @@ -50,6 +50,91 @@ interface TruckLaneScheduleLineRepository : AbstractRepository + /** 鎖定中的 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, + @Param("applyingStatus") applyingStatus: TruckLaneScheduleStatus, + @Param("lockBefore") lockBefore: java.time.LocalDateTime, + ): List + + @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, + @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, + @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, + ): Long + @Query( diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt index bd6ad20..593dd4f 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt @@ -4,6 +4,11 @@ enum class TruckLaneScheduleStatus { PENDING, APPLYING, APPLIED, + + /** + * Legacy only:現行 applier 失敗時整批還原並標 FAILED,不會再產生 PARTIAL。 + * 保留此值僅為相容歷史資料列;勿在新邏輯中使用。 + */ PARTIAL, FAILED, CANCELLED, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt index 5a984d9..26e67ee 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt @@ -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, ): 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() 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 } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt index 15ae50b..d291adb 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt @@ -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, + ): List { + val errors = mutableListOf() + val seenInPlan = mutableSetOf() + 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, ): List { diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt index 00d7507..cdf885f 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt index eab52d2..c5623d2 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -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, + truckRowIds: Set, + 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, + ) { + 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" } } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt index ea0fab5..ccb3fc7 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt @@ -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(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 { 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) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt index 24b0d09..ce8b39f 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt @@ -123,7 +123,10 @@ data class TruckLaneScheduleLineCounts( ) data class PendingTruckRowIdsResponse( + /** 所有開放排程(PENDING/APPLYING)涉及的 truck rows,供排程驗證、標記用。 */ val truckRowIds: List, + /** 已進入鎖定時間窗(或 APPLYING)的 truck rows,看板不可手改。 */ + val lockedTruckRowIds: List = emptyList(), ) data class RouteExcelSchedulePlanPreviewRow(