Explorar el Código

補貨 + truck scheduler update

production
tommy hace 1 semana
padre
commit
22e982fea1
Se han modificado 16 ficheros con 852 adiciones y 47 borrados
  1. +99
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt
  2. +45
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt
  3. +109
    -15
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  4. +214
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt
  5. +3
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  6. +20
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt
  7. +2
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  8. +54
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt
  9. +85
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt
  10. +5
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt
  11. +25
    -10
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt
  12. +69
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt
  13. +18
    -3
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt
  14. +76
    -11
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt
  15. +25
    -6
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt
  16. +3
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt

+ 99
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt Ver fichero

@@ -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"
}
}

+ 45
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt Ver fichero

@@ -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>
}

+ 109
- 15
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt Ver fichero

@@ -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) {


+ 214
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt Ver fichero

@@ -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,
)
}
}
}

+ 3
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Ver fichero

@@ -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,
),
)
}


+ 20
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt Ver fichero

@@ -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);


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt Ver fichero

@@ -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>
)



+ 54
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt Ver fichero

@@ -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?,
)

+ 85
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt Ver fichero

@@ -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(


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt Ver fichero

@@ -4,6 +4,11 @@ enum class TruckLaneScheduleStatus {
PENDING,
APPLYING,
APPLIED,

/**
* Legacy only:現行 applier 失敗時整批還原並標 FAILED,不會再產生 PARTIAL。
* 保留此值僅為相容歷史資料列;勿在新邏輯中使用。
*/
PARTIAL,
FAILED,
CANCELLED,


+ 25
- 10
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt Ver fichero

@@ -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
}


+ 69
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt Ver fichero

@@ -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> {


+ 18
- 3
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt Ver fichero

@@ -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


+ 76
- 11
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt Ver fichero

@@ -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"
}
}

+ 25
- 6
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt Ver fichero

@@ -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)
}



+ 3
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt Ver fichero

@@ -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(


Cargando…
Cancelar
Guardar