Просмотр исходного кода

translate and route schedule

production
tommy 2 недель назад
Родитель
Сommit
65ed99ba39
37 измененных файлов: 2903 добавлений и 29 удалений
  1. +2
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  2. +3
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt
  3. +5
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt
  4. +4
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt
  5. +5
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt
  6. +46
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneSchedule.kt
  7. +82
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLine.kt
  8. +8
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineAction.kt
  9. +94
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt
  10. +68
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleRepository.kt
  11. +23
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt
  12. +13
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt
  13. +35
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/scheduler/TruckLaneScheduleScheduler.kt
  14. +18
    -3
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt
  15. +413
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt
  16. +185
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleExcelSupport.kt
  17. +24
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupport.kt
  18. +262
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanService.kt
  19. +136
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt
  20. +575
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt
  21. +22
    -7
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt
  22. +307
    -13
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt
  23. +21
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/support/OptimisticLockSupport.kt
  24. +5
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt
  25. +162
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt
  26. +5
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt
  27. +2
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt
  28. +190
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt
  29. +4
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt
  30. +37
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckMessageEntity.kt
  31. +4
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt
  32. +6
    -0
      src/main/resources/application.yml
  33. +88
    -0
      src/main/resources/db/changelog/changes/20260521_01_2fi/01_truck_lane_schedule.sql
  34. +8
    -0
      src/main/resources/db/changelog/changes/20260522_01_2fi/02_truck_lane_schedule_line_placement.sql
  35. +15
    -0
      src/main/resources/db/changelog/changes/20260526_01_2fi/03_truck_lane_schedule_line_actions.sql
  36. +7
    -0
      src/main/resources/db/changelog/changes/20260602_01_2fi/04_truck_lane_schedule_pre_apply_snapshot.sql
  37. +19
    -0
      src/test/kotlin/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupportTest.kt

+ 2
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Просмотреть файл

@@ -1767,7 +1767,8 @@ return MessageResponse(
dop.truckDepartureTime as truck_departure_time,
dop.shopCode as ShopCode,
dop.shopName as ShopName,
dop.ticketStatus as doTicketStatus
dop.ticketStatus as doTicketStatus,
dop.requiredDeliveryDate as required_delivery_date
FROM fpsmsdb.delivery_order_pick_order dop
WHERE dop.handledBy = :userId



+ 3
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt Просмотреть файл

@@ -79,6 +79,7 @@ data class AssignByLaneRequest(
val releaseType: String? = null,
)
data class DoPickOrderSummaryItem(
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: java.time.LocalTime?,
val truckLanceCode: String?,
val loadingSequence: Int?,
@@ -120,11 +121,13 @@ interface DoSearchRowProjection {
}
data class ReleasedDoPickOrderListItem(
val id: Long, // doPickOrderId,用於 assign
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?, // Date 欄
val shopCode: String?, // Shop
val shopName: String?, // Shop
val storeId: String?, // 2/F or 4/F
val truckLanceCode: String?, // Truck (Lane)
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?, // Truck 時間
val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示
)


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt Просмотреть файл

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime
import java.time.LocalDate
import java.time.LocalTime
@@ -15,14 +16,18 @@ data class TicketReleaseTableResponse(
val loadingSequence: Int?,
val ticketStatus: String?,
val truckId: Long?,
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?,
val shopId: Long?,
val handledBy: Long?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketReleaseTime: LocalDateTime?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketCompleteDateTime: LocalDateTime?,
val truckLanceCode: String?,
val shopCode: String?,
val shopName: String?,
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?,
val handlerName: String?,
val numberOfFGItems: Int = 0,


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt Просмотреть файл

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime
import java.time.LocalTime

@@ -7,13 +8,16 @@ data class TruckScheduleDashboardResponse(
val storeId: String?,
val truckId: Long?,
val truckLanceCode: String?,
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?,
val numberOfShopsToServe: Int,
val numberOfPickTickets: Int,
val totalItemsToPick: Int,
val numberOfTicketsReleased: Int,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val firstTicketStartTime: LocalDateTime?,
val numberOfTicketsCompleted: Int,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val lastTicketEndTime: LocalDateTime?,
val pickTimeTakenMinutes: Long?
)


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt Просмотреть файл

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.deliveryOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
@@ -10,13 +11,17 @@ data class WorkbenchTicketReleaseTableResponse(
val ticketNo: String?,
val loadingSequence: Int?,
val ticketStatus: String?,
@JsonFormat(pattern = "HH:mm")
val truckDepartureTime: LocalTime?,
val handledBy: Long?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketReleaseTime: LocalDateTime?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val ticketCompleteDateTime: LocalDateTime?,
val truckLanceCode: String?,
val shopCode: String?,
val shopName: String?,
@JsonFormat(pattern = "yyyy-MM-dd")
val requiredDeliveryDate: LocalDate?,
val handlerName: String?,
val numberOfFGItems: Int = 0,


+ 46
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneSchedule.kt Просмотреть файл

@@ -0,0 +1,46 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.time.LocalDateTime

@Entity
@Table(name = "truck_lane_schedule")
open class TruckLaneSchedule : BaseEntity<Long>() {

@NotNull
@Column(name = "executeAt", nullable = false)
open var executeAt: LocalDateTime? = null

@field:NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
open var status: TruckLaneScheduleStatus = TruckLaneScheduleStatus.PENDING

@field:NotNull
@Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false, length = 20)
open var source: TruckLaneScheduleSource = TruckLaneScheduleSource.MANUAL

@field:Size(max = 500)
@Column(name = "note", length = 500)
open var note: String? = null

@Column(name = "appliedAt")
open var appliedAt: LocalDateTime? = null

@field:Size(max = 2000)
@Column(name = "errorMessage", length = 2000)
open var errorMessage: String? = null

@Column(name = "snapshotVersionId")
open var snapshotVersionId: Long? = null

@Column(name = "preApplySnapshotVersionId")
open var preApplySnapshotVersionId: Long? = null

@OneToMany(mappedBy = "schedule", cascade = [CascadeType.ALL], orphanRemoval = true)
open var lines: MutableList<TruckLaneScheduleLine> = mutableListOf()
}

+ 82
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLine.kt Просмотреть файл

@@ -0,0 +1,82 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.*
import jakarta.validation.constraints.Size
import java.time.LocalDateTime
import java.time.LocalTime

@Entity
@Table(name = "truck_lane_schedule_line")
open class TruckLaneScheduleLine : BaseEntity<Long>() {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "scheduleId", nullable = false)
open var schedule: TruckLaneSchedule? = null

@Enumerated(EnumType.STRING)
@Column(name = "lineAction", nullable = false, length = 20)
open var lineAction: TruckLaneScheduleLineAction = TruckLaneScheduleLineAction.MOVE

@Column(name = "truckRowId")
open var truckRowId: Long? = null

@field:Size(max = 50)
@Column(name = "shopCode", length = 50)
open var shopCode: String? = null

@field:Size(max = 255)
@Column(name = "shopName", length = 255)
open var shopName: String? = null

@Column(name = "shopId")
open var shopId: Long? = null

@field:Size(max = 100)
@Column(name = "fromTruckLanceCode", length = 100)
open var fromTruckLanceCode: String? = null

@field:Size(max = 255)
@Column(name = "fromRemark", length = 255)
open var fromRemark: String? = null

@field:Size(max = 10)
@Column(name = "fromStoreId", length = 10)
open var fromStoreId: String? = null

@field:Size(max = 100)
@Column(name = "toTruckLanceCode", nullable = false, length = 100)
open var toTruckLanceCode: String? = null

@field:Size(max = 255)
@Column(name = "toRemark", length = 255)
open var toRemark: String? = null

@field:Size(max = 10)
@Column(name = "toStoreId", nullable = false, length = 10)
open var toStoreId: String? = null

@field:Size(max = 255)
@Column(name = "toDistrictReference", length = 255)
open var toDistrictReference: String? = null

@Column(name = "toLoadingSequence")
open var toLoadingSequence: Int? = null

@Column(name = "departureTime")
open var departureTime: LocalTime? = null

@Column(name = "logisticId")
open var logisticId: Long? = null

@Enumerated(EnumType.STRING)
@Column(name = "lineStatus", nullable = false, length = 20)
open var lineStatus: TruckLaneScheduleLineStatus = TruckLaneScheduleLineStatus.PENDING

@field:Size(max = 2000)
@Column(name = "errorMessage", length = 2000)
open var errorMessage: String? = null

@Column(name = "appliedAt")
open var appliedAt: LocalDateTime? = null
}

+ 8
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineAction.kt Просмотреть файл

@@ -0,0 +1,8 @@
package com.ffii.fpsms.modules.pickOrder.entity

enum class TruckLaneScheduleLineAction {
MOVE,
CREATE,
DELETE,
ENSURE_LANE,
}

+ 94
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt Просмотреть файл

@@ -0,0 +1,94 @@
package com.ffii.fpsms.modules.pickOrder.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



@Repository

interface TruckLaneScheduleLineRepository : AbstractRepository<TruckLaneScheduleLine, Long> {

fun findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(scheduleId: Long): List<TruckLaneScheduleLine>

fun findAllBySchedule_IdInAndDeletedFalseOrderBySchedule_IdAscIdAsc(
scheduleIds: Collection<Long>,
): List<TruckLaneScheduleLine>



@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

""",

)

fun findPendingTruckRowIds(

@Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus,

@Param("openStatuses") openStatuses: Collection<TruckLaneScheduleStatus>,

): 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 (:excludeScheduleId IS NULL OR s.id <> :excludeScheduleId)
""",
)
fun countOpenPendingForTruckRow(
@Param("truckRowId") truckRowId: Long,
@Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus,
@Param("openScheduleStatuses") openScheduleStatuses: Collection<TruckLaneScheduleStatus>,
@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 (:excludeScheduleId IS NULL OR s.id <> :excludeScheduleId)
""",
)
fun existsOpenScheduleForLaneBucket(
@Param("laneCode") laneCode: String,
@Param("storeId") storeId: String,
@Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus,
@Param("openScheduleStatuses") openScheduleStatuses: Collection<TruckLaneScheduleStatus>,
@Param("excludeScheduleId") excludeScheduleId: Long?,
): Boolean

}


+ 68
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleRepository.kt Просмотреть файл

@@ -0,0 +1,68 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.support.AbstractRepository
import jakarta.persistence.LockModeType
import org.springframework.data.jpa.repository.Lock
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.time.LocalDateTime

@Repository
interface TruckLaneScheduleRepository : AbstractRepository<TruckLaneSchedule, Long> {
fun findByIdAndDeletedFalse(id: Long): TruckLaneSchedule?

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(
"""
SELECT s FROM TruckLaneSchedule s
WHERE s.id = :id AND s.deleted = false
""",
)
fun findByIdAndDeletedFalseForUpdate(@Param("id") id: Long): TruckLaneSchedule?

fun findAllByDeletedFalseAndStatusInOrderByExecuteAtDesc(
statuses: Collection<TruckLaneScheduleStatus>,
): List<TruckLaneSchedule>

@Query(
"""
SELECT s FROM TruckLaneSchedule s
WHERE s.deleted = false
AND s.status = :pending
AND s.executeAt <= :now
ORDER BY s.executeAt ASC
""",
)
fun findDuePending(
@Param("now") now: LocalDateTime,
@Param("pending") pending: TruckLaneScheduleStatus,
): List<TruckLaneSchedule>

@Query(
"""
SELECT COUNT(s) > 0 FROM TruckLaneSchedule s
WHERE s.deleted = false
AND s.status = :applying
AND s.id <> :excludeId
""",
)
fun existsOtherApplying(
@Param("applying") applying: TruckLaneScheduleStatus,
@Param("excludeId") excludeId: Long,
): Boolean

@Query(
"""
SELECT s FROM TruckLaneSchedule s
WHERE s.deleted = false
AND s.status = :applying
AND s.modified < :staleBefore
""",
)
fun findStaleApplying(
@Param("applying") applying: TruckLaneScheduleStatus,
@Param("staleBefore") staleBefore: LocalDateTime,
): List<TruckLaneSchedule>
}


+ 23
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt Просмотреть файл

@@ -0,0 +1,23 @@
package com.ffii.fpsms.modules.pickOrder.entity

enum class TruckLaneScheduleStatus {
PENDING,
APPLYING,
APPLIED,
PARTIAL,
FAILED,
CANCELLED,
IGNORED,
}

enum class TruckLaneScheduleSource {
MANUAL,
EXCEL,
}

enum class TruckLaneScheduleLineStatus {
PENDING,
APPLIED,
FAILED,
SKIPPED,
}

+ 13
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt Просмотреть файл

@@ -1,6 +1,9 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository

@Repository
@@ -8,5 +11,15 @@ interface TruckLaneVersionRepository : AbstractRepository<TruckLaneVersion, Long
fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion>
fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion>
fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion?

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
UPDATE TruckLaneVersion v
SET v.createdBy = :actor, v.modifiedBy = :actor
WHERE v.id = :id
""",
)
fun updateActor(@Param("id") id: Long, @Param("actor") actor: String): Int
}


+ 35
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/scheduler/TruckLaneScheduleScheduler.kt Просмотреть файл

@@ -0,0 +1,35 @@
package com.ffii.fpsms.modules.pickOrder.scheduler

import com.ffii.fpsms.modules.pickOrder.service.TruckLaneScheduleService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component

@Component
class TruckLaneScheduleScheduler(
private val truckLaneScheduleService: TruckLaneScheduleService,
@Value("\${truck.lane.schedule.enabled:true}") private val enabled: Boolean,
) {
private val logger = LoggerFactory.getLogger(javaClass)

@Scheduled(cron = "\${truck.lane.schedule.cron:0 * * * * *}")
fun applyDueSchedules() {
if (!enabled) return
try {
truckLaneScheduleService.applyDueSchedules()
} catch (e: Exception) {
logger.error("Truck lane schedule tick failed", e)
}
}

@Scheduled(cron = "\${truck.lane.schedule.reaper.cron:0 */5 * * * *}")
fun reaperStaleApplying() {
if (!enabled) return
try {
truckLaneScheduleService.reaperStaleApplying()
} catch (e: Exception) {
logger.error("Truck lane schedule reaper failed", e)
}
}
}

+ 18
- 3
src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt Просмотреть файл

@@ -40,6 +40,20 @@ object RouteLaneExcelSupport {
const val COL_SHOP_CODE = 3
const val COL_SCHEDULE = 4
const val COL_DEPARTURE_ROW = 5
const val COL_LOADING_SEQUENCE = 6
const val COL_LAST = COL_LOADING_SEQUENCE

const val HEADER_LOADING_SEQUENCE = "裝載順序"

fun writeDataColumnHeaders(headerRow: org.apache.poi.ss.usermodel.Row) {
headerRow.createCell(COL_AREA_PLATE).setCellValue("板")
headerRow.createCell(COL_SHOP_NAME).setCellValue("店鋪名稱")
headerRow.createCell(COL_BRAND).setCellValue("品牌")
headerRow.createCell(COL_SHOP_CODE).setCellValue("店鋪編號")
headerRow.createCell(COL_SCHEDULE).setCellValue("此店車期")
headerRow.createCell(COL_DEPARTURE_ROW).setCellValue("出車時間")
headerRow.createCell(COL_LOADING_SEQUENCE).setCellValue(HEADER_LOADING_SEQUENCE)
}

fun decodeLaneId(laneId: String): Pair<String, String?>? {
val i = laneId.indexOf(SEP)
@@ -169,7 +183,7 @@ object RouteLaneExcelSupport {

val headerRow = sheet.getRow(ROW_HEADER)
if (headerRow != null) {
for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) {
for (c in COL_AREA_PLATE..COL_LAST) {
headerRow.getCell(c)?.cellStyle = st.header
}
}
@@ -179,7 +193,7 @@ object RouteLaneExcelSupport {
val alt = (r - firstDataRow) % 2 == 1
val style = if (alt) st.dataAlt else st.data
val row = sheet.getRow(r) ?: continue
for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) {
for (c in COL_AREA_PLATE..COL_LAST) {
row.getCell(c)?.cellStyle = style
}
}
@@ -191,12 +205,13 @@ object RouteLaneExcelSupport {
sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256)
sheet.setColumnWidth(COL_SCHEDULE, 12 * 256)
sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256)
sheet.setColumnWidth(COL_LOADING_SEQUENCE, 10 * 256)

sheet.createFreezePane(0, ROW_FIRST_DATA)

val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER
sheet.setAutoFilter(
CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_DEPARTURE_ROW),
CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_LAST),
)
}
}

+ 413
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt Просмотреть файл

@@ -0,0 +1,413 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.pickOrder.entity.*
import com.ffii.fpsms.modules.pickOrder.support.isOptimisticLockFailure
import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckLaneSnapshotRequest
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest
import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
import java.time.LocalTime
import kotlin.random.Random

enum class ScheduleApplyOutcome {
APPLIED,
FAILED,
SKIPPED,
NOT_FOUND,
}

private val lineActionApplyOrder =
mapOf(
TruckLaneScheduleLineAction.ENSURE_LANE to 0,
TruckLaneScheduleLineAction.CREATE to 1,
TruckLaneScheduleLineAction.MOVE to 2,
TruckLaneScheduleLineAction.DELETE to 3,
)

@Service
open class TruckLaneScheduleApplier(
private val scheduleRepository: TruckLaneScheduleRepository,
private val scheduleLineRepository: TruckLaneScheduleLineRepository,
private val truckService: TruckService,
private val truckLaneVersionService: TruckLaneVersionService,
) {
private val logger = LoggerFactory.getLogger(javaClass)

@Transactional(
propagation = Propagation.REQUIRES_NEW,
noRollbackFor = [IllegalArgumentException::class, IllegalStateException::class],
)
open fun applyOne(scheduleId: Long): ScheduleApplyOutcome {
val schedule = scheduleRepository.findByIdAndDeletedFalseForUpdate(scheduleId)
?: return ScheduleApplyOutcome.NOT_FOUND
if (schedule.status != TruckLaneScheduleStatus.PENDING) {
return ScheduleApplyOutcome.SKIPPED
}
if (scheduleRepository.existsOtherApplying(TruckLaneScheduleStatus.APPLYING, scheduleId)) {
return ScheduleApplyOutcome.SKIPPED
}

return TruckLaneScheduleLockSupport.withApplyingSchedule(scheduleId) {
applyOneLocked(schedule)
}
}

private fun applyOneLocked(schedule: TruckLaneSchedule): ScheduleApplyOutcome {
val scheduleId = schedule.id ?: return ScheduleApplyOutcome.NOT_FOUND

schedule.status = TruckLaneScheduleStatus.APPLYING
scheduleRepository.saveAndFlush(schedule)

val lines =
scheduleLineRepository
.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(scheduleId)
.sortedBy { lineActionApplyOrder[it.lineAction] ?: 99 }

val createdTruckRowIds = mutableListOf<Long>()
var preApplySnapshotId: Long? = null

try {
val preSnap =
truckLaneVersionService.createSnapshot(
CreateTruckLaneSnapshotRequest(
truckLanceCode = null,
note = "排程 #${schedule.id} 套用前快照 ${schedule.executeAt}",
createdBy = schedule.createdBy,
),
)
preApplySnapshotId = preSnap.id
schedule.preApplySnapshotVersionId = preApplySnapshotId
scheduleRepository.saveAndFlush(schedule)

val now = LocalDateTime.now()
for (line in lines) {
if (line.lineStatus != TruckLaneScheduleLineStatus.PENDING) continue
applyLineWithRetry(line, createdTruckRowIds)
line.lineStatus = TruckLaneScheduleLineStatus.APPLIED
line.appliedAt = now
line.errorMessage = null
scheduleLineRepository.save(line)
}

var postSnapshotId: Long? = null
try {
val postSnap =
truckLaneVersionService.createSnapshot(
CreateTruckLaneSnapshotRequest(
truckLanceCode = null,
note = "排程 #${schedule.id} 套用 (${
lines.count {
it.lineStatus == TruckLaneScheduleLineStatus.APPLIED
}
} 列) ${schedule.executeAt}",
createdBy = schedule.createdBy,
),
)
postSnapshotId = postSnap.id
} catch (e: Exception) {
logger.warn("Post-apply snapshot failed for schedule {}", scheduleId, e)
}

persistScheduleState(scheduleId) { fresh ->
fresh.appliedAt = now
fresh.status = TruckLaneScheduleStatus.APPLIED
fresh.errorMessage = null
if (postSnapshotId != null) {
fresh.snapshotVersionId = postSnapshotId
}
}
return ScheduleApplyOutcome.APPLIED
} catch (e: Exception) {
return abortAndRestore(
scheduleId = scheduleId,
lines = lines,
preApplySnapshotId = preApplySnapshotId,
createdTruckRowIds = createdTruckRowIds,
cause = e,
)
}
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
open fun markFailed(scheduleId: Long, error: Throwable) {
try {
TruckLaneScheduleLockSupport.withApplyingSchedule(scheduleId) {
val schedule = scheduleRepository.findByIdAndDeletedFalseForUpdate(scheduleId)
if (schedule != null &&
(schedule.status == TruckLaneScheduleStatus.APPLYING ||
schedule.status == TruckLaneScheduleStatus.PENDING)
) {
abortAndRestore(
scheduleId = scheduleId,
lines =
scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(
schedule.id ?: 0L,
),
preApplySnapshotId = schedule.preApplySnapshotVersionId,
createdTruckRowIds = collectCreatedTruckIdsFromLines(schedule.id ?: 0L),
cause = error,
)
}
}
} catch (e: Exception) {
if (isOptimisticLockFailure(e)) {
logger.warn("Schedule {} markFailed optimistic lock, rethrowing", scheduleId)
throw e
}
logger.warn("Schedule {} markFailed failed", scheduleId, e)
throw e
}
}

private fun applyLineWithRetry(
line: TruckLaneScheduleLine,
createdTruckRowIds: MutableList<Long>,
) {
var last: Exception? = null
repeat(3) { attempt ->
try {
val note = applyLine(line, createdTruckRowIds)
line.errorMessage = note
return
} catch (e: Exception) {
last = e
if (!isOptimisticLockFailure(e) || attempt == 2) throw e
Thread.sleep(50L + Random.nextLong(150))
}
}
throw last ?: IllegalStateException("apply line failed")
}

private fun applyLine(
line: TruckLaneScheduleLine,
createdTruckRowIds: MutableList<Long>,
): String? {
return when (line.lineAction) {
TruckLaneScheduleLineAction.ENSURE_LANE -> applyEnsureLane(line)
TruckLaneScheduleLineAction.CREATE -> applyCreate(line, createdTruckRowIds)
TruckLaneScheduleLineAction.MOVE -> applyMove(line)
TruckLaneScheduleLineAction.DELETE -> applyDelete(line)
}
}

private fun abortAndRestore(
scheduleId: Long,
lines: List<TruckLaneScheduleLine>,
preApplySnapshotId: Long?,
createdTruckRowIds: List<Long>,
cause: Throwable,
): ScheduleApplyOutcome {
val errMsg = (cause.message ?: cause.javaClass.simpleName).take(2000)
logger.warn("Schedule {} apply failed, restoring: {}", scheduleId, errMsg, cause)

var restoreNote: String? = null
if (preApplySnapshotId != null) {
try {
restoreNote =
truckLaneVersionService.restore(
preApplySnapshotId,
skipPostSnapshot = true,
)
} catch (restoreEx: Exception) {
restoreNote = "restore failed: ${restoreEx.message}"
logger.error(
"Schedule {} restore from snapshot {} failed",
scheduleId,
preApplySnapshotId,
restoreEx,
)
}
}

for (truckRowId in createdTruckRowIds.distinct()) {
try {
truckService.deleteById(truckRowId)
} catch (delEx: Exception) {
logger.warn(
"Schedule {} secondary DELETE sweep failed for truck {}",
scheduleId,
truckRowId,
delEx,
)
}
}

val now = LocalDateTime.now()
lines.forEach { line ->
if (line.lineStatus == TruckLaneScheduleLineStatus.PENDING) {
line.lineStatus = TruckLaneScheduleLineStatus.FAILED
line.errorMessage = errMsg
line.appliedAt = now
scheduleLineRepository.save(line)
}
}

val restoreSuffix =
if (preApplySnapshotId != null) {
";已還原 snapshot #$preApplySnapshotId${restoreNote?.let { " ($it)" } ?: ""}"
} else {
""
}
persistScheduleState(scheduleId) { fresh ->
fresh.status = TruckLaneScheduleStatus.FAILED
fresh.appliedAt = now
fresh.errorMessage = (errMsg + restoreSuffix).take(2000)
}
return ScheduleApplyOutcome.FAILED
}

private fun persistScheduleState(
scheduleId: Long,
patch: (TruckLaneSchedule) -> Unit,
) {
var last: Exception? = null
repeat(3) { attempt ->
try {
val fresh =
scheduleRepository.findByIdAndDeletedFalseForUpdate(scheduleId)
?: return
patch(fresh)
scheduleRepository.saveAndFlush(fresh)
return
} catch (e: Exception) {
last = e
if (!isOptimisticLockFailure(e) || attempt == 2) throw e
Thread.sleep(50L + Random.nextLong(150))
}
}
throw last ?: IllegalStateException("persist schedule failed")
}

private fun collectCreatedTruckIdsFromLines(scheduleId: Long): List<Long> {
return scheduleLineRepository
.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(scheduleId)
.filter {
it.lineAction == TruckLaneScheduleLineAction.CREATE &&
it.truckRowId != null &&
it.lineStatus == TruckLaneScheduleLineStatus.APPLIED
}
.mapNotNull { it.truckRowId }
}

private fun applyEnsureLane(line: TruckLaneScheduleLine): String? {
val toKey = truckService.toBucketKey(line)
if (truckService.trucksInLaneBucket(toKey).isNotEmpty()) {
return "車線已存在,跳過建立"
}
ensureTargetLaneIfEmpty(line, fallbackDeparture = null)
return null
}

/**
* When MOVE/CREATE targets a lane bucket with no rows yet, create a placeholder row
* (same as ENSURE_LANE) so apply matches schedule creation rules with allowEmptyTarget.
*/
private fun ensureTargetLaneIfEmpty(
line: TruckLaneScheduleLine,
fallbackDeparture: LocalTime?,
): String? {
val toKey = truckService.toBucketKey(line)
if (truckService.trucksInLaneBucket(toKey).isNotEmpty()) return null
val departure =
line.departureTime
?: fallbackDeparture
?: throw IllegalArgumentException(
"目標車線 ${toKey.truckLanceCode} 不存在或尚無任何列,且排程未提供 departureTime",
)
truckService.createTruckWithoutShop(
CreateTruckWithoutShopRequest(
store_id = toKey.storeId,
truckLanceCode = toKey.truckLanceCode,
departureTime = departure,
loadingSequence = line.toLoadingSequence ?: 0,
districtReference = line.toDistrictReference,
remark = toKey.remark,
logisticId = line.logisticId,
),
)
return "已自動建立空白車線 ${toKey.truckLanceCode}"
}

private fun applyCreate(
line: TruckLaneScheduleLine,
createdTruckRowIds: MutableList<Long>,
): String? {
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 =
truckService.createTruckInShop(
SaveTruckRequest(
id = null,
store_id = toKey.storeId,
truckLanceCode = toKey.truckLanceCode,
departureTime = departure,
shopId = shopId,
shopName = line.shopName.orEmpty(),
shopCode = line.shopCode.orEmpty(),
loadingSequence = seq,
remark = toKey.remark,
districtReference = line.toDistrictReference,
logisticId = line.logisticId,
),
)
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")
var truck =
truckService.findTruckRowByIdIncludingDeleted(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")
}

val current = truckService.truckLaneBucketKeyOf(truck)
val fromCode = line.fromTruckLanceCode?.trim().orEmpty()
val fromStore = line.fromStoreId?.trim().orEmpty()
val fromRemark = line.fromRemark
if (current.truckLanceCode != fromCode ||
current.storeId != fromStore ||
(current.remark ?: "") != (fromRemark ?: "")
) {
notes.add("?��??��??�已不在?��?建�??��?來�?車�?,�?�?truck.id 移至?��?")
logger.info(
"Schedule line {} truck {} moved from {} to target (was scheduled from {} {})",
line.id,
truckRowId,
"${current.truckLanceCode}/${current.storeId}",
fromCode,
fromStore,
)
}

val toKey = truckService.toBucketKey(line)
val seq = line.toLoadingSequence ?: throw IllegalArgumentException("缺少 toLoadingSequence")
ensureTargetLaneIfEmpty(line, fallbackDeparture = truck.departureTime)?.let { notes.add(it) }
truckService.applyScheduledTruckMove(
truckRowId,
toKey,
line.toDistrictReference,
seq,
)
return notes.takeIf { it.isNotEmpty() }?.joinToString("\uFF1B")
}

private fun applyDelete(line: TruckLaneScheduleLine): String? {
val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺�? truckRowId")
truckService.deleteById(truckRowId)
return null
}
}

+ 185
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleExcelSupport.kt Просмотреть файл

@@ -0,0 +1,185 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.pickOrder.entity.Truck
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneScheduleExcelPreviewRow
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneScheduleExcelRowError
import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.ss.usermodel.DataFormatter
import org.apache.poi.ss.usermodel.Workbook
import org.springframework.stereotype.Component
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.util.Locale

@Component
class TruckLaneScheduleExcelSupport(
private val truckRepository: TruckRepository,
private val truckService: TruckService,
) {
private val formatter = DataFormatter()

private val headerAliases: Map<String, Set<String>> = mapOf(
"shop" to setOf("shopid", "shopcode", "shop_id", "shop_code", "店鋪", "店舖編號"),
"targetlane" to setOf("targetlane", "target_lane", "車線", "目標車線"),
"targetremark" to setOf("targetremark", "target_remark", "備註"),
"targetstoreid" to setOf("targetstoreid", "target_store_id", "storeid", "store_id", "樓層"),
"executedate" to setOf("executedate", "execute_date", "date", "執行日期"),
"executetime" to setOf("executetime", "execute_time", "time", "執行時間"),
)

fun parseWorkbook(
workbook: Workbook?,
defaultExecuteAt: LocalDateTime?,
): Pair<List<TruckLaneScheduleExcelPreviewRow>, List<TruckLaneScheduleExcelRowError>> {
if (workbook == null || workbook.numberOfSheets == 0) {
return emptyList<TruckLaneScheduleExcelPreviewRow>() to
listOf(TruckLaneScheduleExcelRowError(0, "Excel 無工作表"))
}
val sheet = workbook.getSheetAt(0)
if (sheet.physicalNumberOfRows < 2) {
return emptyList<TruckLaneScheduleExcelPreviewRow>() to
listOf(TruckLaneScheduleExcelRowError(0, "至少需要標題列與一筆資料"))
}

val headerRow = sheet.getRow(sheet.firstRowNum) ?: return emptyList<TruckLaneScheduleExcelPreviewRow>() to
listOf(TruckLaneScheduleExcelRowError(0, "缺少標題列"))

val colIndex = mutableMapOf<String, Int>()
for (c in 0 until headerRow.lastCellNum) {
val cell = headerRow.getCell(c) ?: continue
val raw = formatter.formatCellValue(cell).trim().lowercase(Locale.ROOT)
if (raw.isEmpty()) continue
for ((key, aliases) in headerAliases) {
if (aliases.any { raw == it || raw.replace(" ", "") == it }) {
colIndex[key] = c
}
}
}

if (!colIndex.containsKey("shop") || !colIndex.containsKey("targetlane")) {
return emptyList<TruckLaneScheduleExcelPreviewRow>() to listOf(
TruckLaneScheduleExcelRowError(
0,
"缺少必要欄位:ShopID/ShopCode 與 TargetLane",
),
)
}

val preview = mutableListOf<TruckLaneScheduleExcelPreviewRow>()
val errors = mutableListOf<TruckLaneScheduleExcelRowError>()
val firstData = sheet.firstRowNum + 1
for (r in firstData..sheet.lastRowNum) {
val row = sheet.getRow(r) ?: continue
val rowIndex = r + 1
val shopRaw = cellString(row, colIndex["shop"])
val laneRaw = cellString(row, colIndex["targetlane"])
if (shopRaw.isBlank() && laneRaw.isBlank()) continue

val storeRaw = colIndex["targetstoreid"]?.let { cellString(row, it) }.orEmpty()
val storeId = truckService.normalizeRouteStoreId(
if (storeRaw.isBlank()) "2F" else storeRaw,
)
val remark = colIndex["targetremark"]?.let { cellString(row, it) }
?.trim()?.takeIf { it.isNotEmpty() }

val executeAt = parseExecuteAt(
colIndex["executedate"]?.let { cellString(row, it) },
colIndex["executetime"]?.let { cellString(row, it) },
defaultExecuteAt,
)

if (shopRaw.isBlank()) {
errors.add(TruckLaneScheduleExcelRowError(rowIndex, "ShopID/ShopCode 不可為空"))
continue
}
if (laneRaw.isBlank()) {
errors.add(TruckLaneScheduleExcelRowError(rowIndex, "TargetLane 不可為空"))
continue
}

val truck = resolveTruckByShopCode(shopRaw.trim())
if (truck == null) {
errors.add(TruckLaneScheduleExcelRowError(rowIndex, "找不到店鋪:$shopRaw"))
continue
}

val toKey = TruckService.TruckLaneBucketKey(
truckLanceCode = laneRaw.trim(),
storeId = storeId,
remark = truckService.bucketRemarkForStore(storeId, remark),
)
val validateErr = truckService.validateMoveToLane(truck.id ?: 0L, toKey)
if (validateErr != null) {
errors.add(TruckLaneScheduleExcelRowError(rowIndex, validateErr))
continue
}

preview.add(
TruckLaneScheduleExcelPreviewRow(
rowIndex = rowIndex,
shopCode = truck.shopCode?.trim().orEmpty(),
toTruckLanceCode = toKey.truckLanceCode,
toRemark = toKey.remark,
toStoreId = toKey.storeId,
executeAt = executeAt,
truckRowId = truck.id,
),
)
}
return preview to errors
}

private fun cellString(row: org.apache.poi.ss.usermodel.Row, col: Int?): String {
if (col == null) return ""
val cell = row.getCell(col) ?: return ""
return formatter.formatCellValue(cell).trim()
}

private fun resolveTruckByShopCode(shopCode: String): Truck? {
val code = shopCode.trim()
val all = truckRepository.findAllForRouteBoard()
val matches = all.filter { t ->
!t.deleted &&
t.shopCode?.trim()?.equals(code, ignoreCase = true) == true
}
return matches.minByOrNull { it.id ?: Long.MAX_VALUE }
}

private fun parseExecuteAt(
dateStr: String?,
timeStr: String?,
default: LocalDateTime?,
): LocalDateTime? {
if (dateStr.isNullOrBlank() && timeStr.isNullOrBlank()) return default
val date = parseDate(dateStr) ?: default?.toLocalDate() ?: LocalDate.now()
val time = parseTime(timeStr) ?: default?.toLocalTime() ?: LocalTime.of(0, 0)
return LocalDateTime.of(date, time)
}

private fun parseDate(s: String?): LocalDate? {
if (s.isNullOrBlank()) return null
val t = s.trim()
for (fmt in listOf("yyyy-MM-dd", "yyyy/MM/dd", "dd/MM/yyyy")) {
try {
return LocalDate.parse(t, DateTimeFormatter.ofPattern(fmt))
} catch (_: Exception) {
}
}
return null
}

private fun parseTime(s: String?): LocalTime? {
if (s.isNullOrBlank()) return null
val t = s.trim()
for (fmt in listOf("HH:mm", "HH:mm:ss", "H:mm")) {
try {
return LocalTime.parse(t, DateTimeFormatter.ofPattern(fmt))
} catch (_: Exception) {
}
}
return null
}
}

+ 24
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupport.kt Просмотреть файл

@@ -0,0 +1,24 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineStatus
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleStatus

object TruckLaneScheduleLockSupport {
val openScheduleStatuses: List<TruckLaneScheduleStatus> =
listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING)

val pendingLineStatus: TruckLaneScheduleLineStatus = TruckLaneScheduleLineStatus.PENDING

private val applyingScheduleIdHolder = ThreadLocal<Long?>()

fun applyingScheduleId(): Long? = applyingScheduleIdHolder.get()

fun <T> withApplyingSchedule(scheduleId: Long, block: () -> T): T {
applyingScheduleIdHolder.set(scheduleId)
try {
return block()
} finally {
applyingScheduleIdHolder.remove()
}
}
}

+ 262
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanService.kt Просмотреть файл

@@ -0,0 +1,262 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.pickOrder.entity.Truck
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineAction
import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.apache.poi.ss.usermodel.Workbook
import org.springframework.stereotype.Service
import java.time.LocalTime

@Service
open class TruckLaneSchedulePlanService(
private val truckService: TruckService,
private val schedulePlanValidator: TruckLaneSchedulePlanValidator,
) {
open fun planFromRouteExcel(workbook: Workbook?): RouteExcelSchedulePlanResponse {
val parsed = truckService.parseRouteLanesExcel(workbook)
return planFromParsedRows(parsed.rows, parsed.sheetCount)
}

open fun planFromParsedRows(
rows: List<RouteLaneImportPreviewRow>,
sheetCount: Int = 0,
): RouteExcelSchedulePlanResponse {
val lines = mutableListOf<TruckLaneScheduleLineRequest>()
val previews = mutableListOf<RouteExcelSchedulePlanPreviewRow>()
val errors = mutableListOf<RouteExcelSchedulePlanError>()
val moveTruckIds = mutableSetOf<Long>()
val deleteTruckIds = mutableSetOf<Long>()
val ensuredLaneKeys = mutableSetOf<String>()

if (rows.isEmpty()) {
return emptyPlan(sheetCount, rows.size)
}

val excelShopCodesGlobal =
rows
.map { normalizeShopCodeKey(it.shopCode) }
.filter { it.isNotEmpty() }
.toSet()

val rowsByBucket =
rows.groupBy { row ->
laneKeyString(truckService.bucketKeyForRouteRow(row))
}

for ((_, bucketRows) in rowsByBucket) {
if (bucketRows.isEmpty()) continue
val sample = bucketRows.first()
val bucketKey = truckService.bucketKeyForRouteRow(sample)
val currentBucket =
truckService
.trucksInLaneBucket(bucketKey)
.filter { !truckService.isPlaceholderTruckRow(it) }

if (currentBucket.isEmpty()) {
val defaultDeparture = defaultDepartureForRows(bucketRows)
if (defaultDeparture == null) {
errors.add(
RouteExcelSchedulePlanError(
shopCode = sample.shopCode,
shopName = sample.shopName,
message = "invalid_departure",
),
)
continue
}
val laneKey = laneKeyString(bucketKey)
if (ensuredLaneKeys.add(laneKey)) {
addLine(
lines,
previews,
TruckLaneScheduleLineRequest(
action = TruckLaneScheduleLineAction.ENSURE_LANE,
toTruckLanceCode = bucketKey.truckLanceCode,
toRemark = bucketKey.remark,
toStoreId = bucketKey.storeId,
toLoadingSequence = 0,
departureTime = defaultDeparture,
),
)
}
}

for (row in bucketRows) {
val toKey = truckService.bucketKeyForRouteRow(row)
val departure = truckService.parseRouteDepartureTime(row.departureTime)
if (departure == null) {
errors.add(
RouteExcelSchedulePlanError(
shopCode = row.shopCode,
shopName = row.shopName,
message = "invalid_departure",
),
)
continue
}

val truckRowId = row.truckRowId
if (truckRowId == null) {
addLine(
lines,
previews,
TruckLaneScheduleLineRequest(
action = TruckLaneScheduleLineAction.CREATE,
toTruckLanceCode = toKey.truckLanceCode,
toRemark = toKey.remark,
toStoreId = toKey.storeId,
toLoadingSequence = row.loadingSequence.coerceAtLeast(0),
toDistrictReference = row.districtReference,
shopId = row.shopId,
shopCode = row.shopCode,
shopName = row.shopName,
departureTime = departure,
logisticId = row.logisticId,
),
)
continue
}

val truck = truckService.findTruckById(truckRowId)
if (truck == null) {
errors.add(
RouteExcelSchedulePlanError(
shopCode = row.shopCode,
shopName = row.shopName,
message = "truck_not_found",
),
)
continue
}
if (truckService.isPlaceholderTruckRow(truck)) {
errors.add(
RouteExcelSchedulePlanError(
shopCode = row.shopCode,
shopName = row.shopName,
message = "placeholder_row",
),
)
continue
}

if (!truckService.truckNeedsMoveForRouteRow(truck, row)) {
continue
}
if (!moveTruckIds.add(truckRowId)) continue

addLine(
lines,
previews,
TruckLaneScheduleLineRequest(
action = TruckLaneScheduleLineAction.MOVE,
truckRowId = truckRowId,
toTruckLanceCode = toKey.truckLanceCode,
toRemark = toKey.remark,
toStoreId = toKey.storeId,
toLoadingSequence = row.loadingSequence.coerceAtLeast(0),
toDistrictReference = row.districtReference,
shopCode = row.shopCode,
shopName = row.shopName,
departureTime = departure,
logisticId = row.logisticId,
),
)
}

for (truck in currentBucket) {
val truckRowId = truck.id ?: continue
if (moveTruckIds.contains(truckRowId) || deleteTruckIds.contains(truckRowId)) {
continue
}
val shopCodeKey = normalizeShopCodeKey(truck.shopCode)
if (shopCodeKey.isNotEmpty() && excelShopCodesGlobal.contains(shopCodeKey)) {
continue
}
if (!deleteTruckIds.add(truckRowId)) continue
val fromKey = truckService.truckLaneBucketKeyOf(truck)
addLine(
lines,
previews,
TruckLaneScheduleLineRequest(
action = TruckLaneScheduleLineAction.DELETE,
truckRowId = truckRowId,
toTruckLanceCode = fromKey.truckLanceCode,
toRemark = fromKey.remark,
toStoreId = fromKey.storeId,
shopCode = truck.shopCode,
shopName = truck.shopName,
),
)
}
}

val counts =
RouteExcelSchedulePlanCounts(
moves = lines.count { it.action == TruckLaneScheduleLineAction.MOVE },
creates = lines.count { it.action == TruckLaneScheduleLineAction.CREATE },
deletes = lines.count { it.action == TruckLaneScheduleLineAction.DELETE },
ensureLanes = lines.count { it.action == TruckLaneScheduleLineAction.ENSURE_LANE },
)

val blocking = schedulePlanValidator.validatePlanLines(lines)
return RouteExcelSchedulePlanResponse(
sheetCount = if (sheetCount > 0) sheetCount else rowsByBucket.size,
rowCount = rows.size,
lines = lines,
previews = previews,
errors = errors,
blockingErrors = blocking,
canCreate = blocking.isEmpty() && errors.isEmpty(),
counts = counts,
)
}

private fun emptyPlan(sheetCount: Int, rowCount: Int): RouteExcelSchedulePlanResponse {
return RouteExcelSchedulePlanResponse(
sheetCount = sheetCount,
rowCount = rowCount,
lines = emptyList(),
previews = emptyList(),
errors = emptyList(),
blockingErrors = emptyList(),
canCreate = true,
counts = RouteExcelSchedulePlanCounts(0, 0, 0, 0),
)
}

private fun addLine(
lines: MutableList<TruckLaneScheduleLineRequest>,
previews: MutableList<RouteExcelSchedulePlanPreviewRow>,
line: TruckLaneScheduleLineRequest,
) {
lines.add(line)
previews.add(
RouteExcelSchedulePlanPreviewRow(
action = line.action.name,
truckRowId = line.truckRowId,
shopCode = line.shopCode,
shopName = line.shopName,
toTruckLanceCode = line.toTruckLanceCode,
toRemark = line.toRemark,
toStoreId = line.toStoreId,
toLoadingSequence = line.toLoadingSequence,
),
)
}

private fun defaultDepartureForRows(rows: List<RouteLaneImportPreviewRow>): LocalTime? {
for (row in rows) {
val dep = truckService.parseRouteDepartureTime(row.departureTime)
if (dep != null) return dep
}
return null
}

private fun laneKeyString(key: TruckService.TruckLaneBucketKey): String {
return "${key.truckLanceCode}|${key.storeId}|${key.remark ?: ""}"
}

private fun normalizeShopCodeKey(code: String?): String {
return code?.trim()?.lowercase().orEmpty()
}
}

+ 136
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt Просмотреть файл

@@ -0,0 +1,136 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineAction
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineStatus
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleStatus
import com.ffii.fpsms.modules.pickOrder.web.models.SchedulePlanBlockingError
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneScheduleLineRequest
import org.springframework.stereotype.Component

@Component
class TruckLaneSchedulePlanValidator(
private val truckService: TruckService,
private val scheduleLineRepository: TruckLaneScheduleLineRepository,
) {
fun validatePlanLines(lines: List<TruckLaneScheduleLineRequest>): List<SchedulePlanBlockingError> {
val errors = mutableListOf<SchedulePlanBlockingError>()
errors.addAll(validateR1MoveDeleteOverlap(lines))
errors.addAll(validateR2DuplicateTruckRow(lines))
errors.addAll(validateR3OpenScheduleOverlap(lines))
errors.addAll(validateR4OrphanMoveDelete(lines))
return errors
}

private fun validateR1MoveDeleteOverlap(
lines: List<TruckLaneScheduleLineRequest>,
): List<SchedulePlanBlockingError> {
val moveIds =
lines
.filter { it.action == TruckLaneScheduleLineAction.MOVE }
.mapNotNull { it.truckRowId }
.toSet()
val deleteIds =
lines
.filter { it.action == TruckLaneScheduleLineAction.DELETE }
.mapNotNull { it.truckRowId }
.toSet()
return moveIds.intersect(deleteIds).map { id ->
SchedulePlanBlockingError(
code = "R1_MOVE_DELETE_OVERLAP",
shopCode = lines.firstOrNull { it.truckRowId == id }?.shopCode,
shopName = lines.firstOrNull { it.truckRowId == id }?.shopName,
truckRowId = id,
messageKey = "schedule.plan.r1_move_delete_overlap",
)
}
}

private fun validateR2DuplicateTruckRow(
lines: List<TruckLaneScheduleLineRequest>,
): List<SchedulePlanBlockingError> {
val byTruck =
lines
.filter { it.truckRowId != null && it.action != TruckLaneScheduleLineAction.ENSURE_LANE }
.groupBy { it.truckRowId!! }
return byTruck
.filter { (_, group) -> group.size > 1 }
.flatMap { (id, group) ->
group.map {
SchedulePlanBlockingError(
code = "R2_DUPLICATE_TRUCK_ROW",
shopCode = it.shopCode,
shopName = it.shopName,
truckRowId = id,
messageKey = "schedule.plan.r2_duplicate_truck_row",
)
}
}
}

private fun validateR3OpenScheduleOverlap(
lines: List<TruckLaneScheduleLineRequest>,
): List<SchedulePlanBlockingError> {
val errors = mutableListOf<SchedulePlanBlockingError>()
for (line in lines) {
val truckRowId = line.truckRowId ?: continue
if (scheduleLineRepository.countOpenPendingForTruckRow(
truckRowId,
TruckLaneScheduleLineStatus.PENDING,
TruckLaneScheduleLockSupport.openScheduleStatuses,
null,
) > 0
) {
errors.add(
SchedulePlanBlockingError(
code = "R3_OPEN_SCHEDULE_OVERLAP",
shopCode = line.shopCode,
shopName = line.shopName,
truckRowId = truckRowId,
messageKey = "schedule.plan.r3_open_schedule_overlap",
),
)
}
}
return errors
}

private fun validateR4OrphanMoveDelete(
lines: List<TruckLaneScheduleLineRequest>,
): List<SchedulePlanBlockingError> {
val errors = mutableListOf<SchedulePlanBlockingError>()
for (line in lines) {
if (line.action != TruckLaneScheduleLineAction.MOVE &&
line.action != TruckLaneScheduleLineAction.DELETE
) {
continue
}
val truckRowId = line.truckRowId
if (truckRowId == null) {
errors.add(
SchedulePlanBlockingError(
code = "R4_ORPHAN_LINE",
shopCode = line.shopCode,
shopName = line.shopName,
truckRowId = null,
messageKey = "schedule.plan.r4_orphan_line",
),
)
continue
}
val truck = truckService.findTruckById(truckRowId)
if (truck == null) {
errors.add(
SchedulePlanBlockingError(
code = "R4_TRUCK_NOT_FOUND",
shopCode = line.shopCode,
shopName = line.shopName,
truckRowId = truckRowId,
messageKey = "schedule.plan.r4_truck_not_found",
),
)
}
}
return errors
}
}

+ 575
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt Просмотреть файл

@@ -0,0 +1,575 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.fpsms.modules.pickOrder.entity.*
import com.ffii.fpsms.modules.pickOrder.web.models.*
import org.springframework.transaction.annotation.Transactional
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import java.time.LocalDateTime

@Service
open class TruckLaneScheduleService(
private val scheduleRepository: TruckLaneScheduleRepository,
private val scheduleLineRepository: TruckLaneScheduleLineRepository,
private val truckService: TruckService,
private val excelSupport: TruckLaneScheduleExcelSupport,
private val scheduleApplier: TruckLaneScheduleApplier,
private val schedulePlanService: TruckLaneSchedulePlanService,
private val schedulePlanValidator: TruckLaneSchedulePlanValidator,
) {
@Transactional
open fun createManual(request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse {
validateExecuteAt(request.executeAt)
val requestLines = resolveRequestLines(request)
if (requestLines.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "lines 不可為空")
}
val blocking = schedulePlanValidator.validatePlanLines(requestLines)
if (blocking.isNotEmpty()) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
blocking.joinToString("; ") { "${it.code}:${it.messageKey}" },
)
}

val ensuredLaneKeys =
requestLines
.filter { it.action == TruckLaneScheduleLineAction.ENSURE_LANE }
.map { toBucketKey(it) }
.toSet()

val schedule = TruckLaneSchedule().apply {
executeAt = request.executeAt
status = TruckLaneScheduleStatus.PENDING
source = TruckLaneScheduleSource.MANUAL
note = request.note?.trim()
}

val lines = mutableListOf<TruckLaneScheduleLine>()
for (req in requestLines) {
lines.add(buildScheduleLine(req, ensuredLaneKeys))
}

val savedHeader = scheduleRepository.save(schedule)
for (line in lines) {
line.schedule = savedHeader
scheduleLineRepository.save(line)
}
return toResponse(savedHeader, includeLines = true)
}

open fun planFromRouteExcel(workbook: org.apache.poi.ss.usermodel.Workbook?): RouteExcelSchedulePlanResponse {
return schedulePlanService.planFromRouteExcel(workbook)
}

@Transactional
open fun createFromExcelRows(
rows: List<TruckLaneScheduleExcelPreviewRow>,
defaultExecuteAt: LocalDateTime,
note: String?,
): List<TruckLaneScheduleResponse> {
if (rows.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無有效資料列")
}
val grouped = rows.groupBy { it.executeAt ?: defaultExecuteAt }
return grouped.map { (executeAt, group) ->
createManual(
CreateTruckLaneScheduleRequest(
executeAt = executeAt,
note = note,
lines = group.mapNotNull { r ->
val id = r.truckRowId ?: return@mapNotNull null
val storeNorm = truckService.normalizeRouteStoreId(r.toStoreId)
val toKey = TruckService.TruckLaneBucketKey(
truckLanceCode = r.toTruckLanceCode.trim(),
storeId = storeNorm,
remark = truckService.bucketRemarkForStore(storeNorm, r.toRemark),
)
TruckLaneScheduleLineRequest(
action = TruckLaneScheduleLineAction.MOVE,
truckRowId = id,
toTruckLanceCode = r.toTruckLanceCode,
toRemark = r.toRemark,
toStoreId = r.toStoreId,
toLoadingSequence = defaultLoadingSequenceForMove(id, toKey),
)
},
),
)
}
}

open fun list(
statuses: List<TruckLaneScheduleStatus>?,
limit: Int = 50,
): List<TruckLaneScheduleResponse> {
val list =
if (statuses.isNullOrEmpty()) {
scheduleRepository.findAllByDeletedFalseAndStatusInOrderByExecuteAtDesc(
TruckLaneScheduleStatus.entries.toList(),
)
} else {
scheduleRepository.findAllByDeletedFalseAndStatusInOrderByExecuteAtDesc(statuses)
}
val taken = list.take(limit)
val ids = taken.mapNotNull { it.id }
val linesByScheduleId =
if (ids.isEmpty()) {
emptyMap()
} else {
scheduleLineRepository
.findAllBySchedule_IdInAndDeletedFalseOrderBySchedule_IdAscIdAsc(ids)
.groupBy { it.schedule?.id ?: 0L }
}
return taken.map { s ->
toResponse(
s,
includeLines = false,
preloadedLines = linesByScheduleId[s.id ?: 0L],
)
}
}

open fun getById(id: Long): TruckLaneScheduleResponse {
val s = scheduleRepository.findByIdAndDeletedFalse(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在")
return toResponse(s, includeLines = true)
}

open fun pendingTruckRowIds(): PendingTruckRowIdsResponse {
return PendingTruckRowIdsResponse(
scheduleLineRepository.findPendingTruckRowIds(
TruckLaneScheduleLineStatus.PENDING,
listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING),
),
)
}

@Transactional
open fun cancel(id: Long): TruckLaneScheduleResponse {
val s = scheduleRepository.findByIdAndDeletedFalse(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在")
if (s.status != TruckLaneScheduleStatus.PENDING) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "僅 PENDING 可取消")
}
s.status = TruckLaneScheduleStatus.CANCELLED
val lineList = scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(s.id ?: 0L)
lineList.forEach { it.lineStatus = TruckLaneScheduleLineStatus.SKIPPED }
scheduleLineRepository.saveAll(lineList)
return toResponse(scheduleRepository.save(s), includeLines = true)
}

open fun applyNow(id: Long): TruckLaneScheduleResponse {
return when (scheduleApplier.applyOne(id)) {
ScheduleApplyOutcome.NOT_FOUND ->
throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在")
ScheduleApplyOutcome.SKIPPED -> {
val current = scheduleRepository.findByIdAndDeletedFalse(id)
val msg =
when (current?.status) {
TruckLaneScheduleStatus.APPLYING ->
"排程正在執行中,請稍後再試"
TruckLaneScheduleStatus.PENDING ->
"無法認領排程(可能已被其他程序處理)"
else -> "僅 PENDING 可立即執行"
}
throw ResponseStatusException(HttpStatus.CONFLICT, msg)
}
ScheduleApplyOutcome.FAILED,
ScheduleApplyOutcome.APPLIED,
-> getById(id)
}
}

@Transactional
open fun retryFailed(
id: Long,
executeAt: LocalDateTime?,
): TruckLaneScheduleResponse {
val s = scheduleRepository.findByIdAndDeletedFalse(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在")
if (s.status == TruckLaneScheduleStatus.PARTIAL) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"PARTIAL 排程不可重試,請先還原看板後重新建立排程",
)
}
if (s.status != TruckLaneScheduleStatus.FAILED) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "僅 FAILED 可重試失敗列")
}
val lines =
scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(s.id ?: 0L)
val retryLines =
lines.filter {
it.lineStatus == TruckLaneScheduleLineStatus.FAILED ||
it.lineStatus == TruckLaneScheduleLineStatus.PENDING
}
if (retryLines.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無可重試的失敗列")
}
val whenAt = executeAt ?: LocalDateTime.now().plusMinutes(1)
validateExecuteAt(whenAt)
val requestLines =
retryLines.mapNotNull { line -> lineToRequest(line) }
if (requestLines.isEmpty()) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無可重試的失敗列")
}
return createManual(
CreateTruckLaneScheduleRequest(
executeAt = whenAt,
note = "retry from schedule #$id",
lines = requestLines,
),
)
}

@Transactional
open fun ignore(id: Long): TruckLaneScheduleResponse {
val s = scheduleRepository.findByIdAndDeletedFalse(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在")
if (s.status == TruckLaneScheduleStatus.IGNORED) {
return toResponse(s, includeLines = true)
}
if (s.status == TruckLaneScheduleStatus.CANCELLED) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "已取消的排程無法忽略")
}
val lines =
scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(s.id ?: 0L)
val failedLineCount =
lines.count { it.lineStatus == TruckLaneScheduleLineStatus.FAILED }
val canIgnore =
s.status == TruckLaneScheduleStatus.FAILED ||
s.status == TruckLaneScheduleStatus.PARTIAL ||
failedLineCount > 0
if (!canIgnore) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "僅含失敗明細的排程可忽略")
}
s.status = TruckLaneScheduleStatus.IGNORED
return toResponse(scheduleRepository.save(s), includeLines = true)
}

open fun applyDueSchedules(batchLimit: Int = 20) {
reaperStaleApplying()
val due = scheduleRepository.findDuePending(
LocalDateTime.now(),
TruckLaneScheduleStatus.PENDING,
).take(batchLimit)
for (schedule in due) {
val scheduleId = schedule.id ?: continue
try {
scheduleApplier.applyOne(scheduleId)
} catch (e: Exception) {
scheduleApplier.markFailed(scheduleId, e)
}
}
}

@Transactional
open fun reaperStaleApplying(staleMinutes: Long = 10) {
val staleBefore = LocalDateTime.now().minusMinutes(staleMinutes)
val stale =
scheduleRepository.findStaleApplying(
TruckLaneScheduleStatus.APPLYING,
staleBefore,
)
for (schedule in stale) {
schedule.status = TruckLaneScheduleStatus.FAILED
schedule.errorMessage =
"排程執行逾時(>${staleMinutes} 分鐘仍為 APPLYING),請依 runbook 手動還原".take(2000)
scheduleRepository.save(schedule)
}
}

open fun parseExcel(
workbook: org.apache.poi.ss.usermodel.Workbook?,
defaultExecuteAt: LocalDateTime?,
): ParseTruckLaneScheduleExcelResponse {
val (rows, errors) = excelSupport.parseWorkbook(workbook, defaultExecuteAt)
return ParseTruckLaneScheduleExcelResponse(
rowCount = rows.size + errors.size,
validCount = rows.size,
errorCount = errors.size,
rows = rows,
errors = errors,
)
}

private fun resolveRequestLines(request: CreateTruckLaneScheduleRequest): List<TruckLaneScheduleLineRequest> {
if (!request.lines.isNullOrEmpty()) return request.lines
return request.moves.orEmpty().map { move ->
TruckLaneScheduleLineRequest(
action = TruckLaneScheduleLineAction.MOVE,
truckRowId = move.truckRowId,
toTruckLanceCode = move.toTruckLanceCode,
toRemark = move.toRemark,
toStoreId = move.toStoreId,
toLoadingSequence = move.toLoadingSequence,
toDistrictReference = move.toDistrictReference,
)
}
}

private fun buildScheduleLine(
req: TruckLaneScheduleLineRequest,
ensuredLaneKeys: Set<TruckService.TruckLaneBucketKey>,
): TruckLaneScheduleLine {
val toKey = toBucketKey(req)
val allowEmptyTarget = ensuredLaneKeys.contains(toKey)

return when (req.action) {
TruckLaneScheduleLineAction.ENSURE_LANE -> {
val bucket = truckService.trucksInLaneBucket(toKey)
if (bucket.isNotEmpty()) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"車線 ${toKey.truckLanceCode} 已存在,無需 ENSURE_LANE",
)
}
val departure = req.departureTime
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "ENSURE_LANE 缺少 departureTime")
TruckLaneScheduleLine().apply {
lineAction = TruckLaneScheduleLineAction.ENSURE_LANE
toTruckLanceCode = toKey.truckLanceCode
toRemark = toKey.remark
toStoreId = toKey.storeId
toLoadingSequence = req.toLoadingSequence ?: 0
this.departureTime = departure
logisticId = req.logisticId
lineStatus = TruckLaneScheduleLineStatus.PENDING
}
}

TruckLaneScheduleLineAction.CREATE -> {
val shopId = req.shopId
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "CREATE 缺少 shopId")
val departure = req.departureTime
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "CREATE 缺少 departureTime")
val bucket = truckService.trucksInLaneBucket(toKey)
if (bucket.isEmpty() && !allowEmptyTarget) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"目標車線 ${toKey.truckLanceCode} 不存在,需先 ENSURE_LANE",
)
}
val seq = req.toLoadingSequence ?: 0
TruckLaneScheduleLine().apply {
lineAction = TruckLaneScheduleLineAction.CREATE
this.shopId = shopId
shopCode = req.shopCode
shopName = req.shopName
toTruckLanceCode = toKey.truckLanceCode
toRemark = toKey.remark
toStoreId = toKey.storeId
toLoadingSequence = seq
toDistrictReference = req.toDistrictReference?.trim()?.takeIf { it.isNotEmpty() }
this.departureTime = departure
logisticId = req.logisticId
lineStatus = TruckLaneScheduleLineStatus.PENDING
}
}

TruckLaneScheduleLineAction.DELETE -> {
val truckRowId = req.truckRowId
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "DELETE 缺少 truckRowId")
assertNoOpenPending(truckRowId)
val truck = truckService.findTruckById(truckRowId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "找不到 truck #$truckRowId")
if (truckService.isPlaceholderTruckRow(truck)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無法刪除空白占位列")
}
val fromKey = truckService.truckLaneBucketKeyOf(truck)
TruckLaneScheduleLine().apply {
lineAction = TruckLaneScheduleLineAction.DELETE
this.truckRowId = truckRowId
shopCode = truck.shopCode
shopName = truck.shopName
fromTruckLanceCode = fromKey.truckLanceCode
fromRemark = fromKey.remark
fromStoreId = fromKey.storeId
toTruckLanceCode = fromKey.truckLanceCode
toRemark = fromKey.remark
toStoreId = fromKey.storeId
lineStatus = TruckLaneScheduleLineStatus.PENDING
}
}

TruckLaneScheduleLineAction.MOVE -> {
val truckRowId = req.truckRowId
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "MOVE 缺少 truckRowId")
assertNoOpenPending(truckRowId)
val truck = truckService.findTruckById(truckRowId)
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "找不到 truck #$truckRowId")
val fromKey = truckService.truckLaneBucketKeyOf(truck)
val err = truckService.validateMoveToLane(truckRowId, toKey, allowEmptyTarget)
if (err != null) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "truck #$truckRowId: $err")
}
val seq = req.toLoadingSequence
?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "MOVE 缺少 toLoadingSequence")
TruckLaneScheduleLine().apply {
lineAction = TruckLaneScheduleLineAction.MOVE
this.truckRowId = truckRowId
shopCode = truck.shopCode
shopName = truck.shopName
fromTruckLanceCode = fromKey.truckLanceCode
fromRemark = fromKey.remark
fromStoreId = fromKey.storeId
toTruckLanceCode = toKey.truckLanceCode
toRemark = toKey.remark
toStoreId = toKey.storeId
toDistrictReference =
req.toDistrictReference?.trim()?.takeIf { it.isNotEmpty() }
?: truck.districtReference
toLoadingSequence = seq
departureTime = req.departureTime
logisticId = req.logisticId
lineStatus = TruckLaneScheduleLineStatus.PENDING
}
}
}
}

private fun assertNoOpenPending(truckRowId: Long) {
if (scheduleLineRepository.countOpenPendingForTruckRow(
truckRowId,
TruckLaneScheduleLineStatus.PENDING,
TruckLaneScheduleLockSupport.openScheduleStatuses,
null,
) > 0
) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"truck #$truckRowId 已有待執行排程",
)
}
}

private fun lineToRequest(line: TruckLaneScheduleLine): TruckLaneScheduleLineRequest? {
val action = line.lineAction
val toCode = line.toTruckLanceCode?.trim().orEmpty()
val toStore = line.toStoreId?.trim().orEmpty()
if (toCode.isEmpty() || toStore.isEmpty()) return null
return TruckLaneScheduleLineRequest(
action = action,
truckRowId = line.truckRowId,
toTruckLanceCode = toCode,
toRemark = line.toRemark,
toStoreId = toStore,
toLoadingSequence = line.toLoadingSequence,
toDistrictReference = line.toDistrictReference,
shopId = line.shopId,
shopCode = line.shopCode,
shopName = line.shopName,
departureTime = line.departureTime,
logisticId = line.logisticId,
)
}

private fun toBucketKey(req: TruckLaneScheduleLineRequest): TruckService.TruckLaneBucketKey {
val storeNorm = truckService.normalizeRouteStoreId(req.toStoreId)
return TruckService.TruckLaneBucketKey(
truckLanceCode = req.toTruckLanceCode.trim(),
storeId = storeNorm,
remark = truckService.bucketRemarkForStore(storeNorm, req.toRemark),
)
}

private fun validateExecuteAt(executeAt: LocalDateTime) {
val min = LocalDateTime.now().minusMinutes(2)
if (executeAt.isBefore(min)) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "執行時間不可早於目前時間")
}
}

private fun toResponse(
s: TruckLaneSchedule,
includeLines: Boolean,
preloadedLines: List<TruckLaneScheduleLine>? = null,
): TruckLaneScheduleResponse {
val lines =
preloadedLines
?: scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(
s.id ?: 0L,
)
val counts = if (lines.isNotEmpty()) {
TruckLaneScheduleLineCounts(
total = lines.size,
applied = lines.count { it.lineStatus == TruckLaneScheduleLineStatus.APPLIED },
failed = lines.count { it.lineStatus == TruckLaneScheduleLineStatus.FAILED },
pending = lines.count { it.lineStatus == TruckLaneScheduleLineStatus.PENDING },
)
} else {
null
}
return TruckLaneScheduleResponse(
id = s.id ?: 0L,
executeAt = s.executeAt?.toString().orEmpty(),
status = s.status.name,
source = s.source.name,
note = s.note,
appliedAt = s.appliedAt?.toString(),
errorMessage = s.errorMessage,
snapshotVersionId = s.snapshotVersionId,
preApplySnapshotVersionId = s.preApplySnapshotVersionId,
created = s.created?.toString(),
createdBy = s.createdBy,
modifiedBy = s.modifiedBy,
lines = if (includeLines) lines.map { toLineResponse(it) } else null,
lineCounts = counts,
)
}

private fun defaultLoadingSequenceForMove(
truckRowId: Long,
to: TruckService.TruckLaneBucketKey,
): Int {
val truck = truckService.findTruckById(truckRowId)
val district = truck?.districtReference?.trim().orEmpty()
val bucket = truckService.trucksInLaneBucket(to)
val maxSeq =
bucket
.filter { t ->
val d = t.districtReference?.trim().orEmpty()
district.isEmpty() || d == district
}
.maxOfOrNull { it.loadingSequence ?: 0 } ?: 0
return maxSeq + 1
}

private fun toLineResponse(l: TruckLaneScheduleLine): TruckLaneScheduleLineResponse {
var fromLoadingSequence: Int? = null
var fromDistrictReference: String? = null
var fromDepartureTime: String? = null
val truckRowId = l.truckRowId
if (l.lineStatus == TruckLaneScheduleLineStatus.PENDING && truckRowId != null) {
truckService.findTruckById(truckRowId)?.let { truck ->
fromLoadingSequence = truck.loadingSequence
fromDistrictReference = truck.districtReference
fromDepartureTime = truck.departureTime?.toString()
}
}
return TruckLaneScheduleLineResponse(
id = l.id ?: 0L,
action = l.lineAction.name,
truckRowId = l.truckRowId,
shopCode = l.shopCode,
shopName = l.shopName,
fromTruckLanceCode = l.fromTruckLanceCode,
fromRemark = l.fromRemark,
fromStoreId = l.fromStoreId,
fromLoadingSequence = fromLoadingSequence,
fromDistrictReference = fromDistrictReference,
fromDepartureTime = fromDepartureTime,
toTruckLanceCode = l.toTruckLanceCode.orEmpty(),
toRemark = l.toRemark,
toStoreId = l.toStoreId.orEmpty(),
toDistrictReference = l.toDistrictReference,
toLoadingSequence = l.toLoadingSequence,
departureTime = l.departureTime?.toString(),
lineStatus = l.lineStatus.name,
errorMessage = l.errorMessage,
appliedAt = l.appliedAt?.toString(),
)
}
}

+ 22
- 7
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt Просмотреть файл

@@ -23,6 +23,7 @@ open class TruckLaneVersionService(
truckLanceCode = v.truckLanceCode ?: "",
note = v.note,
created = v.created?.toString(),
createdBy = v.createdBy,
modifiedBy = v.modifiedBy,
)

@@ -75,6 +76,13 @@ open class TruckLaneVersionService(
truckLaneVersionLineRepository.saveAll(lines)
}

val actor = request.createdBy?.trim()?.takeIf { it.isNotEmpty() }
if (actor != null && savedVersion.id != null) {
truckLaneVersionRepository.updateActor(savedVersion.id!!, actor)
savedVersion.createdBy = actor
savedVersion.modifiedBy = actor
}

return toResponse(savedVersion)
}

@@ -231,7 +239,7 @@ open class TruckLaneVersionService(
}

@Transactional
open fun restore(versionId: Long): String {
open fun restore(versionId: Long, skipPostSnapshot: Boolean = false): String {
val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId")

@@ -265,6 +273,11 @@ open class TruckLaneVersionService(

val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id }

val logisticIds =
lines.mapNotNull { it.logisticId }.filter { it > 0 }.distinct()
val logisticsById =
logisticIds.associateWith { id -> logisticRepository.findByIdAndDeletedFalse(id) }

val updated = lines.mapNotNull { line ->
val truckId = line.truckRowId ?: return@mapNotNull null
if (truckId <= 0) return@mapNotNull null
@@ -286,7 +299,7 @@ open class TruckLaneVersionService(
val lid = line.logisticId
this.logistic =
if (lid != null && lid > 0) {
logisticRepository.findByIdAndDeletedFalse(lid)
logisticsById[lid]
} else {
null
}
@@ -296,12 +309,14 @@ open class TruckLaneVersionService(
truckRepository.saveAll(updated)
}

createSnapshot(
CreateTruckLaneSnapshotRequest(
truckLanceCode = null,
note = "restore from versionId=$versionId",
if (!skipPostSnapshot) {
createSnapshot(
CreateTruckLaneSnapshotRequest(
truckLanceCode = null,
note = "restore from versionId=$versionId",
),
)
)
}

return "Restored versionId=$versionId"
}


+ 307
- 13
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt Просмотреть файл

@@ -10,6 +10,8 @@ import org.springframework.stereotype.Service
import com.ffii.fpsms.modules.logistic.entity.Logistic
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.TruckLaneVersionLineRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
@@ -23,6 +25,8 @@ import com.ffii.fpsms.modules.master.entity.ShopRepository
import com.ffii.fpsms.modules.master.entity.projections.ShopAndTruck
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane
import jakarta.transaction.Transactional
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.io.ByteArrayOutputStream
import java.text.Collator
import java.time.LocalDate
@@ -41,8 +45,44 @@ open class TruckService(
private val logisticRepository: LogisticRepository,
private val truckLaneVersionRepository: TruckLaneVersionRepository,
private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository,
private val scheduleLineRepository: TruckLaneScheduleLineRepository,
) : AbstractBaseEntityService<Truck, Long, TruckRepository>(jdbcDao, truckRepository) {

private fun assertNotScheduleLocked(truckRowId: Long?) {
if (truckRowId == null) return
if (scheduleLineRepository.countOpenPendingForTruckRow(
truckRowId,
TruckLaneScheduleLockSupport.pendingLineStatus,
TruckLaneScheduleLockSupport.openScheduleStatuses,
TruckLaneScheduleLockSupport.applyingScheduleId(),
) > 0
) {
throw ResponseStatusException(HttpStatus.CONFLICT, "SCHEDULE_LOCKED")
}
}

private fun assertNotScheduleLocked(bucket: TruckLaneBucketKey) {
if (scheduleLineRepository.existsOpenScheduleForLaneBucket(
bucket.truckLanceCode.trim(),
bucket.storeId.trim(),
TruckLaneScheduleLockSupport.pendingLineStatus,
TruckLaneScheduleLockSupport.openScheduleStatuses,
TruckLaneScheduleLockSupport.applyingScheduleId(),
)
) {
throw ResponseStatusException(HttpStatus.CONFLICT, "SCHEDULE_LOCKED")
}
}

open fun toBucketKey(line: TruckLaneScheduleLine): TruckLaneBucketKey {
val storeNorm = normalizeRouteStoreId(line.toStoreId)
return TruckLaneBucketKey(
truckLanceCode = line.toTruckLanceCode?.trim().orEmpty(),
storeId = storeNorm,
remark = bucketRemarkForStore(storeNorm, line.toRemark),
)
}

private fun logisticRefOrNull(id: Long?): Logistic? {
if (id == null) return null
return logisticRepository.findById(id).orElseThrow {
@@ -50,6 +90,7 @@ open class TruckService(
}
}
open fun saveTruck(request: SaveTruckRequest): Truck {
assertNotScheduleLocked(request.id)
val truck = request.id?.let {
truckRepository.findById(it).orElse(null)
} ?: Truck()
@@ -406,8 +447,240 @@ open class TruckService(
return truckRepository.findByShopIdAndDeletedFalse(shopId)
}

open fun findTruckById(id: Long): Truck? {
val t = truckRepository.findById(id).orElse(null) ?: return null
return if (t.deleted == true) null else t
}

open fun findTruckRowByIdIncludingDeleted(id: Long): Truck? {
return truckRepository.findById(id).orElse(null)
}

@Transactional
open fun restoreTruckRowIfDeleted(truck: Truck): Truck {
if (truck.deleted != true) return truck
truck.deleted = false
return truckRepository.save(truck)
}

data class TruckLaneBucketKey(
val truckLanceCode: String,
val storeId: String,
val remark: String?,
)

open fun normalizeRouteStoreId(storeId: String?): String {
val s = storeId?.trim()?.uppercase().orEmpty()
return if (s == "4F") "4F" else "2F"
}

open fun bucketRemarkForStore(storeId: String, remark: String?): String? {
return if (normalizeRouteStoreId(storeId) == "4F") {
remark?.trim()?.takeIf { it.isNotEmpty() }
} else {
null
}
}

open fun trucksInLaneBucket(key: TruckLaneBucketKey): List<Truck> {
return trucksInSameLaneBucket(
key.truckLanceCode.trim(),
key.storeId,
key.remark,
)
}

open fun truckLaneBucketKeyOf(truck: Truck): TruckLaneBucketKey {
val store = normalizeRouteStoreId(truck.storeId)
return TruckLaneBucketKey(
truckLanceCode = truck.truckLanceCode?.trim().orEmpty(),
storeId = store,
remark = bucketRemarkForStore(store, truck.remark),
)
}

open fun laneHasShopConflict(
shop: Truck,
bucket: List<Truck>,
excludeTruckRowId: Long?,
): Boolean {
val movingShopId = shop.shop?.id
val movingCode = shop.shopCode?.trim()?.lowercase().orEmpty()
for (s in bucket) {
if (excludeTruckRowId != null && s.id == excludeTruckRowId) continue
if (isLanePlaceholderTruck(s)) continue
val otherShopId = s.shop?.id
if (movingShopId != null && otherShopId != null && movingShopId == otherShopId) {
return true
}
val otherCode = s.shopCode?.trim()?.lowercase().orEmpty()
if (movingCode.isNotEmpty() && otherCode.isNotEmpty() && movingCode == otherCode) {
return true
}
}
return false
}

open fun validateMoveToLane(truckRowId: Long, to: TruckLaneBucketKey): String? {
return validateMoveToLane(truckRowId, to, allowEmptyTarget = false)
}

/**
* @return null if valid, otherwise human-readable error (Traditional Chinese for UI).
*/
open fun validateMoveToLane(
truckRowId: Long,
to: TruckLaneBucketKey,
allowEmptyTarget: Boolean,
): String? {
val truck = truckRepository.findById(truckRowId).orElse(null)
?: return "找不到 truck 列 id=$truckRowId"
if (truck.deleted == true) return "truck 列已刪除"
if (isLanePlaceholderTruck(truck)) return "無法排程移動空白占位列"

val toCode = to.truckLanceCode.trim()
if (toCode.isEmpty()) return "目標車線編號不可為空"

val fromKey = truckLaneBucketKeyOf(truck)
val sameLane =
fromKey.truckLanceCode == toCode &&
fromKey.storeId == to.storeId &&
(fromKey.remark ?: "") == (to.remark ?: "")
if (sameLane) return null

val targetBucket = trucksInLaneBucket(to)
if (targetBucket.isEmpty()) {
if (allowEmptyTarget) return null
return "目標車線不存在或尚無任何列:$toCode"
}

if (laneHasShopConflict(truck, targetBucket, excludeTruckRowId = truckRowId)) {
return "目標車線已有相同店鋪"
}
return null
}

open fun isPlaceholderTruckRow(truck: Truck): Boolean = isLanePlaceholderTruck(truck)

open fun parseRouteDepartureTime(timeStr: String?): LocalTime? = parseDepartureTime(timeStr)

open fun bucketKeyForRouteRow(row: RouteLaneImportPreviewRow): TruckLaneBucketKey {
val storeNorm = normalizeRouteStoreId(row.storeId)
return TruckLaneBucketKey(
truckLanceCode = row.truckLanceCode.trim(),
storeId = storeNorm,
remark = bucketRemarkForStore(storeNorm, row.remark),
)
}

open fun truckNeedsMoveForRouteRow(truck: Truck, row: RouteLaneImportPreviewRow): Boolean {
val toKey = bucketKeyForRouteRow(row)
val currentKey = truckLaneBucketKeyOf(truck)
val targetSeq = row.loadingSequence.coerceAtLeast(0)
val targetDistrict = row.districtReference?.trim().orEmpty()
val currentDistrict = truck.districtReference?.trim().orEmpty()
val targetDeparture = parseDepartureTime(row.departureTime)
val currentDeparture = truck.departureTime
if (currentKey.truckLanceCode != toKey.truckLanceCode ||
currentKey.storeId != toKey.storeId ||
(currentKey.remark ?: "") != (toKey.remark ?: "")
) {
return true
}
if ((truck.loadingSequence ?: 0) != targetSeq) return true
if (currentDistrict != targetDistrict) return true
if (targetDeparture != null && targetDeparture != currentDeparture) return true
return false
}

open fun buildSaveTruckLaneForMove(truck: Truck, to: TruckLaneBucketKey): SaveTruckLane {
val targetBucket = trucksInLaneBucket(to)
val maxSeq = targetBucket
.filter { !isLanePlaceholderTruck(it) }
.maxOfOrNull { it.loadingSequence ?: 0 } ?: 0
return buildSaveTruckLaneForScheduledMove(
truck,
to,
truck.districtReference,
maxSeq + 1,
)
}

open fun buildSaveTruckLaneForScheduledMove(
truck: Truck,
to: TruckLaneBucketKey,
districtReference: String?,
loadingSequence: Int,
): SaveTruckLane {
val targetBucket = trucksInLaneBucket(to)
val template =
targetBucket.firstOrNull { !isLanePlaceholderTruck(it) }
?: targetBucket.first()
val storeNorm = normalizeRouteStoreId(to.storeId)
val toRemark =
if (storeNorm == "4F") to.remark?.trim()?.takeIf { it.isNotEmpty() } else null

return SaveTruckLane(
id = truck.id ?: 0L,
truckLanceCode = to.truckLanceCode.trim(),
departureTime = template.departureTime ?: LocalTime.of(0, 0),
loadingSequence = loadingSequence.coerceAtLeast(0).toLong(),
districtReference = districtReference ?: truck.districtReference,
storeId = storeNorm,
remark = toRemark,
logisticId = template.logistic?.id,
updateLogistic = true,
)
}

@Transactional
open fun applyScheduledTruckMove(
truckRowId: Long,
to: TruckLaneBucketKey,
districtReference: String? = null,
loadingSequence: Int? = null,
): List<Truck> {
assertNotScheduleLocked(truckRowId)
var truck = findTruckRowByIdIncludingDeleted(truckRowId)
?: throw IllegalArgumentException("找不到 truck #$truckRowId")
truck = restoreTruckRowIfDeleted(truck)

val err = validateMoveToLane(truckRowId, to)
if (err != null) throw IllegalArgumentException(err)

val fromKey = truckLaneBucketKeyOf(truck)
val sameLane =
fromKey.truckLanceCode == to.truckLanceCode.trim() &&
fromKey.storeId == to.storeId &&
(fromKey.remark ?: "") == (to.remark ?: "")

val seq =
loadingSequence
?: run {
val bucket = trucksInLaneBucket(to)
(bucket.filter { !isLanePlaceholderTruck(it) }
.maxOfOrNull { it.loadingSequence ?: 0 } ?: 0) + 1
}
val district = districtReference ?: truck.districtReference

if (sameLane) {
val currentSeq = truck.loadingSequence ?: 0
val currentDistrict = truck.districtReference?.trim().orEmpty()
val nextDistrict = district?.trim().orEmpty()
if (currentSeq == seq && currentDistrict == nextDistrict) {
return listOf(truck)
}
val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq)
return updateTruckLaneByTruckLanceCode(request)
}

val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq)
return updateTruckLaneByTruckLanceCode(request)
}

@Transactional
open fun updateTruckLaneByTruckLanceCode(request: SaveTruckLane): List<Truck> {
assertNotScheduleLocked(request.id)

val updateTruckLance = truckRepository.findById(request.id).orElseThrow()
?: throw IllegalArgumentException("Truck not found with truckLanceCode: $request.truckLanceCode")
@@ -431,6 +704,7 @@ open class TruckService(

@Transactional
open fun deleteById(id: Long): String {
assertNotScheduleLocked(id)
val deleteTruck = truckRepository.findById(id).orElseThrow().apply {
deleted = true
}
@@ -440,6 +714,14 @@ open class TruckService(

@Transactional
open fun createTruckInShop(request: SaveTruckRequest): Truck {
val storeNorm = normalizeRouteStoreId(request.store_id)
assertNotScheduleLocked(
TruckLaneBucketKey(
truckLanceCode = request.truckLanceCode.trim(),
storeId = storeNorm,
remark = bucketRemarkForStore(storeNorm, request.remark),
),
)
val shop = shopRepository.findById(request.shopId).orElse(null)
if (shop == null) {
throw IllegalArgumentException("Shop not found with id: ${request.shopId}")
@@ -521,6 +803,7 @@ open class TruckService(

@Transactional
open fun updateTruckShopDetails(request: UpdateTruckShopDetailsRequest): Truck {
assertNotScheduleLocked(request.id)
val truck = truckRepository.findById(request.id).orElseThrow {
IllegalArgumentException("Truck not found with id: ${request.id}")
}
@@ -579,6 +862,14 @@ open class TruckService(

@Transactional
open fun createTruckWithoutShop(request: CreateTruckWithoutShopRequest): Truck {
val storeNorm = normalizeRouteStoreId(request.store_id)
assertNotScheduleLocked(
TruckLaneBucketKey(
truckLanceCode = request.truckLanceCode.trim(),
storeId = storeNorm,
remark = bucketRemarkForStore(storeNorm, request.remark),
),
)
val laneRows = trucksInSameLaneBucket(
request.truckLanceCode,
request.store_id,
@@ -694,12 +985,7 @@ open class TruckService(
)

rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER)
rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板")
rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱")
rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌")
rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號")
rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期")
rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間")
RouteLaneExcelSupport.writeDataColumnHeaders(rr)

val segmentsForRows = sortDistrictSegmentsForPlateColumnExport(trucks)
var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA
@@ -716,6 +1002,8 @@ open class TruckService(
dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(
formatDepartureForExcel(t.departureTime),
)
dataRow.createCell(RouteLaneExcelSupport.COL_LOADING_SEQUENCE)
.setCellValue((t.loadingSequence ?: 0).toDouble())
}
}
RouteLaneExcelSupport.applyRouteLaneExportFinishing(
@@ -1258,12 +1546,7 @@ open class TruckService(
rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault))

rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER)
rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板")
rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱")
rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌")
rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號")
rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期")
rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間")
RouteLaneExcelSupport.writeDataColumnHeaders(rr)

// segments by district changes in loading order
val segments = ArrayList<List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>>()
@@ -1299,6 +1582,8 @@ open class TruckService(
dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "")
val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault
dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(formatDepartureForExcel(dept))
dataRow.createCell(RouteLaneExcelSupport.COL_LOADING_SEQUENCE)
.setCellValue((l.loadingSequence ?: 0).toDouble())
}
}

@@ -1311,6 +1596,15 @@ open class TruckService(
}
}

private fun parseLoadingSequenceFromRouteRow(
row: org.apache.poi.ss.usermodel.Row,
fallback: Int,
): Int {
val cell = row.getCell(RouteLaneExcelSupport.COL_LOADING_SEQUENCE) ?: return fallback
val parsed = ExcelUtils.getIntValue(cell, -1)
return if (parsed >= 0) parsed else fallback
}

private fun appendVersionRouteReportSheet(
wb: XSSFWorkbook,
createdDate: String,
@@ -1578,7 +1872,7 @@ open class TruckService(
shopId = shop.id!!,
shopName = shopName.ifEmpty { shop.name ?: "" },
shopCode = normalizedShopCode,
loadingSequence = seq,
loadingSequence = parseLoadingSequenceFromRouteRow(row, seq),
districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict),
logisticId = logisticId,
),


+ 21
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/support/OptimisticLockSupport.kt Просмотреть файл

@@ -0,0 +1,21 @@
package com.ffii.fpsms.modules.pickOrder.support

import jakarta.persistence.OptimisticLockException
import org.hibernate.StaleObjectStateException
import org.springframework.orm.ObjectOptimisticLockingFailureException

fun isOptimisticLockFailure(t: Throwable?): Boolean {
var c: Throwable? = t
while (c != null) {
when (c) {
is StaleObjectStateException -> return true
is OptimisticLockException -> return true
is ObjectOptimisticLockingFailureException -> return true
}
if (c.message?.contains("Row was updated or deleted by another transaction", ignoreCase = true) == true) {
return true
}
c = c.cause
}
return false
}

+ 5
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt Просмотреть файл

@@ -29,6 +29,7 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateLaneLogisticRequest
import com.ffii.fpsms.modules.pickOrder.web.models.ParseRouteLanesExcelResponse
import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane
import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse
import com.ffii.fpsms.modules.pickOrder.web.models.toMessageEntity
import jakarta.validation.Valid

@RestController
@@ -50,7 +51,7 @@ open class TruckController(
type = "truck",
message = if (truck.id != null) "Truck updated successfully" else "Truck created successfully",
errorPosition = null,
entity = truck
entity = truck.toMessageEntity()
)
} catch (e: Exception) {
return MessageResponse(
@@ -76,7 +77,7 @@ open class TruckController(
type = "truck",
message = "Truck created successfully",
errorPosition = null,
entity = truck
entity = truck.toMessageEntity()
)
} catch (e: Exception) {
return MessageResponse(
@@ -427,7 +428,7 @@ open class TruckController(
type = "truck",
message = "Truck shop details updated successfully",
errorPosition = null,
entity = truck
entity = truck.toMessageEntity()
)
} catch (e: Exception) {
return MessageResponse(
@@ -453,7 +454,7 @@ open class TruckController(
type = "truck",
message = "Truck created successfully",
errorPosition = null,
entity = truck
entity = truck.toMessageEntity()
)
} catch (e: Exception) {
return MessageResponse(


+ 162
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt Просмотреть файл

@@ -0,0 +1,162 @@
package com.ffii.fpsms.modules.pickOrder.web

import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleStatus
import com.ffii.fpsms.modules.pickOrder.service.TruckLaneScheduleService
import com.ffii.fpsms.modules.pickOrder.web.models.*
import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.ServletRequestBindingException
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartHttpServletRequest
import java.time.LocalDateTime

@RestController
@RequestMapping("/truckLaneSchedule")
open class TruckLaneScheduleController @Autowired constructor(
private val truckLaneScheduleService: TruckLaneScheduleService,
) {
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping
open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse {
return truckLaneScheduleService.createManual(request)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/planFromRouteExcel")
@Throws(ServletRequestBindingException::class)
open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> {
var workbook: Workbook? = null
try {
val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList")
workbook = XSSFWorkbook(multipartFile?.inputStream)
return ResponseEntity.ok(truckLaneScheduleService.planFromRouteExcel(workbook))
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}

@GetMapping
open fun list(
@RequestParam(required = false) status: List<String>?,
@RequestParam(defaultValue = "50") limit: Int,
): List<TruckLaneScheduleResponse> {
val statuses = status?.mapNotNull { raw ->
runCatching { TruckLaneScheduleStatus.valueOf(raw.trim().uppercase()) }.getOrNull()
}
return truckLaneScheduleService.list(statuses, limit)
}

@GetMapping("/pendingShopIds")
open fun pendingShopIds(): PendingTruckRowIdsResponse {
return truckLaneScheduleService.pendingTruckRowIds()
}

@GetMapping("/{id}")
open fun getById(@PathVariable id: Long): TruckLaneScheduleResponse {
return truckLaneScheduleService.getById(id)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/{id}/cancel")
open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse {
return truckLaneScheduleService.cancel(id)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/{id}/applyNow")
open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse {
return truckLaneScheduleService.applyNow(id)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/{id}/retry-failed")
open fun retryFailed(
@PathVariable id: Long,
@RequestBody(required = false) request: RetryFailedTruckLaneScheduleRequest?,
): TruckLaneScheduleResponse {
return truckLaneScheduleService.retryFailed(id, request?.executeAt)
}

@PostMapping("/{id}/ignore")
open fun ignore(@PathVariable id: Long): TruckLaneScheduleResponse {
return truckLaneScheduleService.ignore(id)
}

@PostMapping("/parseExcel")
@Throws(ServletRequestBindingException::class)
open fun parseExcel(
request: HttpServletRequest,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
defaultExecuteAt: LocalDateTime?,
): ResponseEntity<ParseTruckLaneScheduleExcelResponse> {
var workbook: Workbook? = null
try {
val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList")
workbook = XSSFWorkbook(multipartFile?.inputStream)
return ResponseEntity.ok(
truckLaneScheduleService.parseExcel(workbook, defaultExecuteAt),
)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/importExcel")
@Throws(ServletRequestBindingException::class)
open fun importExcel(
request: HttpServletRequest,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
defaultExecuteAt: LocalDateTime?,
@RequestParam(required = false) note: String?,
): ResponseEntity<*> {
var workbook: Workbook? = null
try {
val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList")
workbook = XSSFWorkbook(multipartFile?.inputStream)
val parsed = truckLaneScheduleService.parseExcel(
workbook,
defaultExecuteAt ?: LocalDateTime.now().plusMinutes(1),
)
if (parsed.validCount == 0) {
return ResponseEntity.badRequest().body(
MessageResponse(
id = null,
name = null,
code = null,
type = "truckLaneSchedule",
message = "無有效列可匯入",
errorPosition = null,
entity = parsed,
),
)
}
val created = truckLaneScheduleService.createFromExcelRows(
parsed.rows,
defaultExecuteAt ?: LocalDateTime.now().plusMinutes(1),
note,
)
return ResponseEntity.ok(created)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}
}

+ 5
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt Просмотреть файл

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.pickOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import org.springframework.web.bind.annotation.RequestParam
import java.time.LocalDateTime
import java.time.LocalTime
@@ -37,6 +38,7 @@ data class CompletedDoPickOrderResponse(
val shopName: String?,
val deliveryNoteCode: String?,
val truckLanceCode: String?,
@JsonFormat(pattern = "HH:mm")
val departureTime: LocalTime?,
val pickOrderIds: List<Long>,
val pickOrderCodes: List<String>,
@@ -69,6 +71,7 @@ data class FgInfoResponse(
val shopCode: String?,
val shopName: String?,
val truckLanceCode: String?,
@JsonFormat(pattern = "HH:mm")
val departureTime: LocalTime?
)
data class HierarchicalPickOrderResponse(
@@ -88,6 +91,7 @@ data class PickOrderDetailResponse(
val deliveryOrderCode: String?,
val consoCode: String?,
val status: String?,
@JsonFormat(pattern = "yyyy-MM-dd")
val targetDate: LocalDate?,
val pickOrderLines: List<PickOrderLineDetailResponse>
)
@@ -113,6 +117,7 @@ data class ItemInfoResponse(
data class LotDetailResponse(
val id: Long?,
val lotNo: String?,
@JsonFormat(pattern = "yyyy-MM-dd")
val expiryDate: LocalDate?,
val location: String?,
val stockUnit: String?,


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt Просмотреть файл

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.pickOrder.web.models

import com.fasterxml.jackson.annotation.JsonFormat
import com.ffii.fpsms.modules.pickOrder.entity.Truck
import java.time.LocalTime

@@ -10,6 +11,7 @@ import java.time.LocalTime
data class TruckLaneCombinationResponse(
val id: Long,
val truckLanceCode: String?,
@JsonFormat(pattern = "HH:mm")
val departureTime: LocalTime?,
val loadingSequence: Int?,
val districtReference: String?,


+ 190
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt Просмотреть файл

@@ -0,0 +1,190 @@
package com.ffii.fpsms.modules.pickOrder.web.models

import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineAction
import jakarta.validation.Valid
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.time.LocalDateTime
import java.time.LocalTime

data class TruckLaneMoveTargetRequest(
@field:NotNull
val truckRowId: Long,
@field:NotBlank
@field:Size(max = 100)
val toTruckLanceCode: String,
@field:Size(max = 255)
val toRemark: String? = null,
@field:NotBlank
@field:Size(max = 10)
val toStoreId: String,
@field:NotNull
@field:Min(0)
val toLoadingSequence: Int,
@field:Size(max = 255)
val toDistrictReference: String? = null,
)

data class TruckLaneScheduleLineRequest(
@field:NotNull
val action: TruckLaneScheduleLineAction,
val truckRowId: Long? = null,
@field:NotBlank
@field:Size(max = 100)
val toTruckLanceCode: String,
@field:Size(max = 255)
val toRemark: String? = null,
@field:NotBlank
@field:Size(max = 10)
val toStoreId: String,
@field:Min(0)
val toLoadingSequence: Int? = null,
@field:Size(max = 255)
val toDistrictReference: String? = null,
val shopId: Long? = null,
@field:Size(max = 50)
val shopCode: String? = null,
@field:Size(max = 255)
val shopName: String? = null,
val departureTime: LocalTime? = null,
val logisticId: Long? = null,
)

data class CreateTruckLaneScheduleRequest(
@field:NotNull
val executeAt: LocalDateTime,
@field:Size(max = 500)
val note: String? = null,
@field:Valid
val lines: List<TruckLaneScheduleLineRequest>? = null,
@field:Valid
val moves: List<TruckLaneMoveTargetRequest>? = null,
)

data class RetryFailedTruckLaneScheduleRequest(
val executeAt: LocalDateTime? = null,
)

data class TruckLaneScheduleLineResponse(
val id: Long,
val action: String,
val truckRowId: Long?,
val shopCode: String?,
val shopName: String?,
val fromTruckLanceCode: String?,
val fromRemark: String?,
val fromStoreId: String?,
val toTruckLanceCode: String,
val toRemark: String?,
val toStoreId: String,
val fromLoadingSequence: Int? = null,
val fromDistrictReference: String? = null,
val fromDepartureTime: String? = null,
val toDistrictReference: String? = null,
val toLoadingSequence: Int? = null,
val departureTime: String? = null,
val lineStatus: String,
val errorMessage: String?,
val appliedAt: String?,
)

data class SchedulePlanBlockingError(
val code: String,
val shopCode: String? = null,
val shopName: String? = null,
val truckRowId: Long? = null,
val messageKey: String,
)

data class TruckLaneScheduleResponse(
val id: Long,
val executeAt: String,
val status: String,
val source: String,
val note: String?,
val appliedAt: String?,
val errorMessage: String?,
val snapshotVersionId: Long?,
val preApplySnapshotVersionId: Long? = null,
val created: String?,
val createdBy: String? = null,
val modifiedBy: String?,
val lines: List<TruckLaneScheduleLineResponse>? = null,
val lineCounts: TruckLaneScheduleLineCounts? = null,
)

data class TruckLaneScheduleLineCounts(
val total: Int,
val applied: Int,
val failed: Int,
val pending: Int,
)

data class PendingTruckRowIdsResponse(
val truckRowIds: List<Long>,
)

data class RouteExcelSchedulePlanPreviewRow(
val action: String,
val truckRowId: Long?,
val shopCode: String?,
val shopName: String?,
val toTruckLanceCode: String,
val toRemark: String?,
val toStoreId: String,
val toLoadingSequence: Int?,
)

data class RouteExcelSchedulePlanError(
val shopCode: String,
val shopName: String,
val message: String,
)

data class RouteExcelSchedulePlanResponse(
val sheetCount: Int,
val rowCount: Int,
val lines: List<TruckLaneScheduleLineRequest>,
val previews: List<RouteExcelSchedulePlanPreviewRow>,
val errors: List<RouteExcelSchedulePlanError>,
val blockingErrors: List<SchedulePlanBlockingError> = emptyList(),
val canCreate: Boolean = true,
val counts: RouteExcelSchedulePlanCounts,
)

data class RouteExcelSchedulePlanCounts(
val moves: Int,
val creates: Int,
val deletes: Int,
val ensureLanes: Int,
)

data class ParseTruckLaneScheduleExcelResponse(
val rowCount: Int,
val validCount: Int,
val errorCount: Int,
val rows: List<TruckLaneScheduleExcelPreviewRow>,
val errors: List<TruckLaneScheduleExcelRowError>,
)

data class TruckLaneScheduleExcelPreviewRow(
val rowIndex: Int,
val shopCode: String,
val toTruckLanceCode: String,
val toRemark: String?,
val toStoreId: String,
val executeAt: LocalDateTime?,
val truckRowId: Long?,
)

data class TruckLaneScheduleExcelRowError(
val rowIndex: Int,
val message: String,
)

data class ImportTruckLaneScheduleExcelRequest(
val executeAt: LocalDateTime? = null,
val note: String? = null,
)

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt Просмотреть файл

@@ -7,6 +7,9 @@ data class CreateTruckLaneSnapshotRequest(
val truckLanceCode: String? = null,
@field:Size(max = 500)
val note: String? = null,
/** When set (e.g. scheduled apply without JWT), attributes snapshot to the scheduler. */
@field:Size(max = 30)
val createdBy: String? = null,
)

data class RestoreTruckLaneSnapshotRequest(
@@ -18,6 +21,7 @@ data class TruckLaneVersionResponse(
val truckLanceCode: String,
val note: String?,
val created: String?,
val createdBy: String? = null,
/** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */
val modifiedBy: String?,
)


+ 37
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckMessageEntity.kt Просмотреть файл

@@ -0,0 +1,37 @@
package com.ffii.fpsms.modules.pickOrder.web.models

import com.ffii.fpsms.modules.pickOrder.entity.Truck
import java.time.LocalTime

/**
* MessageResponse.entity for truck mutations.
* Do not return [Truck] JPA entities directly — lazy `logistic` / `shop` proxies break Jackson serialization.
*/
data class TruckMessageEntity(
val id: Long?,
val truckLanceCode: String?,
val departureTime: LocalTime?,
val shopName: String?,
val shopCode: String?,
val loadingSequence: Int?,
val storeId: String?,
val districtReference: String?,
val remark: String?,
val shopId: Long? = null,
val logisticId: Long? = null,
)

fun Truck.toMessageEntity(): TruckMessageEntity =
TruckMessageEntity(
id = id,
truckLanceCode = truckLanceCode,
departureTime = departureTime,
shopName = shopName,
shopCode = shopCode,
loadingSequence = loadingSequence,
storeId = storeId,
districtReference = districtReference,
remark = remark,
shopId = shop?.id,
logisticId = logistic?.id,
)

+ 4
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt Просмотреть файл

@@ -1,5 +1,6 @@
package com.ffii.fpsms.modules.stock.web.model

import com.fasterxml.jackson.annotation.JsonFormat
import java.time.LocalDateTime

/**
@@ -13,7 +14,9 @@ data class PurchaseStockInAlertRow(
val itemNo: String?,
val itemName: String?,
val status: String?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val lineCreated: LocalDateTime?,
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
val receiptDate: LocalDateTime?,
val lotNo: String?,
)
)

+ 6
- 0
src/main/resources/application.yml Просмотреть файл

@@ -13,6 +13,12 @@ server:
# Set enabled: false to disable. Optional receiptDate: "yyyy-MM-dd" overrides for testing only.
# m18Grn.createEnabled: M18 GRN PUT/create — false outside production so UAT/dev never posts GRNs.
# m18Sync: M18 cron jobs for PO, DO1, DO2, BOM→M18 udfBomForShop ([SCHEDULE.m18.bom.shop], default 23:00), master data — false outside production (manual /trigger/* still works).
truck:
lane:
schedule:
enabled: true
cron: "0 * * * * *"

scheduler:
m18Sync:
enabled: false


+ 88
- 0
src/main/resources/db/changelog/changes/20260521_01_2fi/01_truck_lane_schedule.sql Просмотреть файл

@@ -0,0 +1,88 @@
-- liquibase formatted sql
-- changeset 2fi:20260521_01_truck_lane_schedule
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'truck_lane_schedule'

CREATE TABLE IF NOT EXISTS `truck_lane_schedule`
(
`id` BIGINT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',
`executeAt` DATETIME NOT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`source` VARCHAR(20) NOT NULL DEFAULT 'MANUAL',
`note` VARCHAR(500) NULL DEFAULT NULL,
`appliedAt` DATETIME NULL DEFAULT NULL,
`errorMessage` VARCHAR(2000) NULL DEFAULT NULL,
`snapshotVersionId` BIGINT NULL DEFAULT NULL,
CONSTRAINT pk_truck_lane_schedule PRIMARY KEY (`id`)
);

CREATE TABLE IF NOT EXISTS `truck_lane_schedule_line`
(
`id` BIGINT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',
`scheduleId` BIGINT NOT NULL,
`truckRowId` BIGINT NOT NULL,
`shopCode` VARCHAR(50) NULL DEFAULT NULL,
`shopName` VARCHAR(255) NULL DEFAULT NULL,
`fromTruckLanceCode` VARCHAR(100) NOT NULL,
`fromRemark` VARCHAR(255) NULL DEFAULT NULL,
`fromStoreId` VARCHAR(10) NOT NULL,
`toTruckLanceCode` VARCHAR(100) NOT NULL,
`toRemark` VARCHAR(255) NULL DEFAULT NULL,
`toStoreId` VARCHAR(10) NOT NULL,
`lineStatus` VARCHAR(20) NOT NULL DEFAULT 'PENDING',
`errorMessage` VARCHAR(2000) NULL DEFAULT NULL,
`appliedAt` DATETIME NULL DEFAULT NULL,
CONSTRAINT pk_truck_lane_schedule_line PRIMARY KEY (`id`),
CONSTRAINT fk_tlsl_schedule FOREIGN KEY (`scheduleId`) REFERENCES `truck_lane_schedule` (`id`)
);

SET @idx_tls_status_exec := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'truck_lane_schedule' AND index_name = 'idx_tls_status_execute'
);
SET @sql_tls := IF(
@idx_tls_status_exec = 0,
'CREATE INDEX idx_tls_status_execute ON `truck_lane_schedule` (`status`, `executeAt`)',
'SELECT 1'
);
PREPARE stmt_tls FROM @sql_tls;
EXECUTE stmt_tls;
DEALLOCATE PREPARE stmt_tls;

SET @idx_tlsl_schedule := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'truck_lane_schedule_line' AND index_name = 'idx_tlsl_schedule'
);
SET @sql_tlsl_s := IF(
@idx_tlsl_schedule = 0,
'CREATE INDEX idx_tlsl_schedule ON `truck_lane_schedule_line` (`scheduleId`)',
'SELECT 1'
);
PREPARE stmt_tlsl_s FROM @sql_tlsl_s;
EXECUTE stmt_tlsl_s;
DEALLOCATE PREPARE stmt_tlsl_s;

SET @idx_tlsl_truck := (
SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = DATABASE() AND table_name = 'truck_lane_schedule_line' AND index_name = 'idx_tlsl_truck_row'
);
SET @sql_tlsl_t := IF(
@idx_tlsl_truck = 0,
'CREATE INDEX idx_tlsl_truck_row ON `truck_lane_schedule_line` (`truckRowId`, `lineStatus`)',
'SELECT 1'
);
PREPARE stmt_tlsl_t FROM @sql_tlsl_t;
EXECUTE stmt_tlsl_t;
DEALLOCATE PREPARE stmt_tlsl_t;

+ 8
- 0
src/main/resources/db/changelog/changes/20260522_01_2fi/02_truck_lane_schedule_line_placement.sql Просмотреть файл

@@ -0,0 +1,8 @@
-- liquibase formatted sql
-- changeset 2fi:20260522_02_truck_lane_schedule_line_placement
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_schedule_line' AND column_name = 'toDistrictReference'

ALTER TABLE `truck_lane_schedule_line`
ADD COLUMN `toDistrictReference` VARCHAR(255) NULL DEFAULT NULL AFTER `toStoreId`,
ADD COLUMN `toLoadingSequence` INT NULL DEFAULT NULL AFTER `toDistrictReference`;

+ 15
- 0
src/main/resources/db/changelog/changes/20260526_01_2fi/03_truck_lane_schedule_line_actions.sql Просмотреть файл

@@ -0,0 +1,15 @@
-- liquibase formatted sql
-- changeset 2fi:20260526_03_truck_lane_schedule_line_actions
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_schedule_line' AND column_name = 'lineAction'

ALTER TABLE `truck_lane_schedule_line`
ADD COLUMN `lineAction` VARCHAR(20) NOT NULL DEFAULT 'MOVE' AFTER `scheduleId`,
ADD COLUMN `shopId` BIGINT NULL DEFAULT NULL AFTER `shopName`,
ADD COLUMN `departureTime` TIME NULL DEFAULT NULL AFTER `toLoadingSequence`,
ADD COLUMN `logisticId` BIGINT NULL DEFAULT NULL AFTER `departureTime`;

ALTER TABLE `truck_lane_schedule_line`
MODIFY COLUMN `truckRowId` BIGINT NULL DEFAULT NULL,
MODIFY COLUMN `fromTruckLanceCode` VARCHAR(100) NULL DEFAULT NULL,
MODIFY COLUMN `fromStoreId` VARCHAR(10) NULL DEFAULT NULL;

+ 7
- 0
src/main/resources/db/changelog/changes/20260602_01_2fi/04_truck_lane_schedule_pre_apply_snapshot.sql Просмотреть файл

@@ -0,0 +1,7 @@
-- liquibase formatted sql
-- changeset 2fi:20260602_01_truck_lane_schedule_pre_apply_snapshot
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_schedule' AND column_name = 'preApplySnapshotVersionId'

ALTER TABLE `truck_lane_schedule`
ADD COLUMN `preApplySnapshotVersionId` BIGINT NULL DEFAULT NULL AFTER `snapshotVersionId`;

+ 19
- 0
src/test/kotlin/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupportTest.kt Просмотреть файл

@@ -0,0 +1,19 @@
package com.ffii.fpsms.modules.pickOrder.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test

class TruckLaneScheduleLockSupportTest {
@Test
fun withApplyingSchedule_sets_and_clears_thread_local() {
assertNull(TruckLaneScheduleLockSupport.applyingScheduleId())

TruckLaneScheduleLockSupport.withApplyingSchedule(8L) {
assertEquals(8L, TruckLaneScheduleLockSupport.applyingScheduleId())
"done"
}

assertNull(TruckLaneScheduleLockSupport.applyingScheduleId())
}
}

Загрузка…
Отмена
Сохранить