From 65ed99ba39f2a288b43c6ab46608fef70edb3319 Mon Sep 17 00:00:00 2001 From: tommy Date: Fri, 5 Jun 2026 14:20:05 +0800 Subject: [PATCH] translate and route schedule --- .../service/DoWorkbenchMainService.kt | 3 +- .../web/models/DoDetailResponse.kt | 3 + .../web/models/TicketReleaseTableResponse.kt | 5 + .../models/TruckScheduleDashboardResponse.kt | 4 + .../WorkbenchTicketReleaseTableResponse.kt | 5 + .../pickOrder/entity/TruckLaneSchedule.kt | 46 ++ .../pickOrder/entity/TruckLaneScheduleLine.kt | 82 +++ .../entity/TruckLaneScheduleLineAction.kt | 8 + .../entity/TruckLaneScheduleLineRepository.kt | 94 +++ .../entity/TruckLaneScheduleRepository.kt | 68 +++ .../entity/TruckLaneScheduleStatus.kt | 23 + .../entity/TruckLaneVersionRepository.kt | 13 + .../scheduler/TruckLaneScheduleScheduler.kt | 35 ++ .../service/RouteLaneExcelSupport.kt | 21 +- .../service/TruckLaneScheduleApplier.kt | 413 +++++++++++++ .../service/TruckLaneScheduleExcelSupport.kt | 185 ++++++ .../service/TruckLaneScheduleLockSupport.kt | 24 + .../service/TruckLaneSchedulePlanService.kt | 262 ++++++++ .../service/TruckLaneSchedulePlanValidator.kt | 136 +++++ .../service/TruckLaneScheduleService.kt | 575 ++++++++++++++++++ .../service/TruckLaneVersionService.kt | 29 +- .../modules/pickOrder/service/TruckService.kt | 320 +++++++++- .../support/OptimisticLockSupport.kt | 21 + .../modules/pickOrder/web/TruckController.kt | 9 +- .../web/TruckLaneScheduleController.kt | 162 +++++ .../web/models/SearchPickOrderRequest.kt | 5 + .../models/TruckLaneCombinationResponse.kt | 2 + .../web/models/TruckLaneScheduleModels.kt | 190 ++++++ .../web/models/TruckLaneVersionModels.kt | 4 + .../web/models/TruckMessageEntity.kt | 37 ++ .../web/model/PurchaseStockInAlertRow.kt | 5 +- src/main/resources/application.yml | 6 + .../01_truck_lane_schedule.sql | 88 +++ .../02_truck_lane_schedule_line_placement.sql | 8 + .../03_truck_lane_schedule_line_actions.sql | 15 + ...truck_lane_schedule_pre_apply_snapshot.sql | 7 + .../TruckLaneScheduleLockSupportTest.kt | 19 + 37 files changed, 2903 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneSchedule.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLine.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineAction.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleRepository.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/scheduler/TruckLaneScheduleScheduler.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleExcelSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/support/OptimisticLockSupport.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckMessageEntity.kt create mode 100644 src/main/resources/db/changelog/changes/20260521_01_2fi/01_truck_lane_schedule.sql create mode 100644 src/main/resources/db/changelog/changes/20260522_01_2fi/02_truck_lane_schedule_line_placement.sql create mode 100644 src/main/resources/db/changelog/changes/20260526_01_2fi/03_truck_lane_schedule_line_actions.sql create mode 100644 src/main/resources/db/changelog/changes/20260602_01_2fi/04_truck_lane_schedule_pre_apply_snapshot.sql create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupportTest.kt diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index d0dd4b6..687a13c 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt index 4597732..e2519c3 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt @@ -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 // 多個 DO code,前端換行顯示 ) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt index 1eae138..45ec0df 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TicketReleaseTableResponse.kt +++ b/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, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt index 04918e2..486f782 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/TruckScheduleDashboardResponse.kt +++ b/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? ) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt index d0a504d..e47e436 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/WorkbenchTicketReleaseTableResponse.kt +++ b/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, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneSchedule.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneSchedule.kt new file mode 100644 index 0000000..cef164b --- /dev/null +++ b/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() { + + @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 = mutableListOf() +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLine.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLine.kt new file mode 100644 index 0000000..2e4804f --- /dev/null +++ b/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() { + + @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 +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineAction.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineAction.kt new file mode 100644 index 0000000..fb60086 --- /dev/null +++ b/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, +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleLineRepository.kt new file mode 100644 index 0000000..8c8d7fe --- /dev/null +++ b/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 { + + fun findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(scheduleId: Long): List + + fun findAllBySchedule_IdInAndDeletedFalseOrderBySchedule_IdAscIdAsc( + scheduleIds: Collection, + ): List + + + + @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, + + ): List + + + + @Query( + """ + SELECT COUNT(l) FROM TruckLaneScheduleLine l + JOIN l.schedule s + WHERE l.deleted = false AND s.deleted = false + AND l.truckRowId = :truckRowId + AND l.lineStatus = :pendingLine + AND s.status IN :openScheduleStatuses + AND (:excludeScheduleId IS NULL OR s.id <> :excludeScheduleId) + """, + ) + fun countOpenPendingForTruckRow( + @Param("truckRowId") truckRowId: Long, + @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, + @Param("openScheduleStatuses") openScheduleStatuses: Collection, + @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, + @Param("excludeScheduleId") excludeScheduleId: Long?, + ): Boolean + +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleRepository.kt new file mode 100644 index 0000000..24d59c7 --- /dev/null +++ b/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 { + 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, + ): List + + @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 + + @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 +} + diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneScheduleStatus.kt new file mode 100644 index 0000000..bd6ad20 --- /dev/null +++ b/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, +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt index f69ecc6..f712d27 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt +++ b/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 fun findAllByDeletedFalseOrderByCreatedDesc(): List 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 } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/scheduler/TruckLaneScheduleScheduler.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/scheduler/TruckLaneScheduleScheduler.kt new file mode 100644 index 0000000..15cbeca --- /dev/null +++ b/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) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt index 2537a54..94cd92c 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt +++ b/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? { 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), ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleApplier.kt new file mode 100644 index 0000000..5a984d9 --- /dev/null +++ b/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() + 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, + ) { + 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, + ): 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, + preApplySnapshotId: Long?, + createdTruckRowIds: List, + 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 { + 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, + ): 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() + 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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleExcelSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleExcelSupport.kt new file mode 100644 index 0000000..6174c92 --- /dev/null +++ b/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> = 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> { + if (workbook == null || workbook.numberOfSheets == 0) { + return emptyList() to + listOf(TruckLaneScheduleExcelRowError(0, "Excel 無工作表")) + } + val sheet = workbook.getSheetAt(0) + if (sheet.physicalNumberOfRows < 2) { + return emptyList() to + listOf(TruckLaneScheduleExcelRowError(0, "至少需要標題列與一筆資料")) + } + + val headerRow = sheet.getRow(sheet.firstRowNum) ?: return emptyList() to + listOf(TruckLaneScheduleExcelRowError(0, "缺少標題列")) + + val colIndex = mutableMapOf() + 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() to listOf( + TruckLaneScheduleExcelRowError( + 0, + "缺少必要欄位:ShopID/ShopCode 與 TargetLane", + ), + ) + } + + val preview = mutableListOf() + val errors = mutableListOf() + 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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupport.kt new file mode 100644 index 0000000..7d35e75 --- /dev/null +++ b/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 = + listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING) + + val pendingLineStatus: TruckLaneScheduleLineStatus = TruckLaneScheduleLineStatus.PENDING + + private val applyingScheduleIdHolder = ThreadLocal() + + fun applyingScheduleId(): Long? = applyingScheduleIdHolder.get() + + fun withApplyingSchedule(scheduleId: Long, block: () -> T): T { + applyingScheduleIdHolder.set(scheduleId) + try { + return block() + } finally { + applyingScheduleIdHolder.remove() + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanService.kt new file mode 100644 index 0000000..19768cf --- /dev/null +++ b/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, + sheetCount: Int = 0, + ): RouteExcelSchedulePlanResponse { + val lines = mutableListOf() + val previews = mutableListOf() + val errors = mutableListOf() + val moveTruckIds = mutableSetOf() + val deleteTruckIds = mutableSetOf() + val ensuredLaneKeys = mutableSetOf() + + 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, + previews: MutableList, + 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): 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() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneSchedulePlanValidator.kt new file mode 100644 index 0000000..15ae50b --- /dev/null +++ b/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): List { + val errors = mutableListOf() + errors.addAll(validateR1MoveDeleteOverlap(lines)) + errors.addAll(validateR2DuplicateTruckRow(lines)) + errors.addAll(validateR3OpenScheduleOverlap(lines)) + errors.addAll(validateR4OrphanMoveDelete(lines)) + return errors + } + + private fun validateR1MoveDeleteOverlap( + lines: List, + ): List { + 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, + ): List { + 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, + ): List { + val errors = mutableListOf() + 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, + ): List { + val errors = mutableListOf() + 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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleService.kt new file mode 100644 index 0000000..1b75ebd --- /dev/null +++ b/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() + 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, + defaultExecuteAt: LocalDateTime, + note: String?, + ): List { + 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?, + limit: Int = 50, + ): List { + 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 { + 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, + ): 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? = 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(), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt index 41fc24b..eab52d2 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt @@ -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" } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt index 5d60425..ea0fab5 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt @@ -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(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 { + 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, + 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 { + 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 { + 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>() @@ -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, ), diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/support/OptimisticLockSupport.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/support/OptimisticLockSupport.kt new file mode 100644 index 0000000..89b450b --- /dev/null +++ b/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 +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt index 9df44fa..c4b4edb 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt +++ b/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( diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt new file mode 100644 index 0000000..081a284 --- /dev/null +++ b/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 { + 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?, + @RequestParam(defaultValue = "50") limit: Int, + ): List { + 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 { + 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) { + } + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt index 0129c4a..104fac3 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SearchPickOrderRequest.kt +++ b/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, val pickOrderCodes: List, @@ -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 ) @@ -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?, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt index 35e4e09..542cb56 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt +++ b/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?, diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneScheduleModels.kt new file mode 100644 index 0000000..24b0d09 --- /dev/null +++ b/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? = null, + @field:Valid + val moves: List? = 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? = 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, +) + +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, + val previews: List, + val errors: List, + val blockingErrors: List = 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, + val errors: List, +) + +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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt index 8f59864..fccdf0d 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt +++ b/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?, ) diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckMessageEntity.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckMessageEntity.kt new file mode 100644 index 0000000..d85ac6e --- /dev/null +++ b/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, + ) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt index 01fe2ac..7b50695 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/PurchaseStockInAlertRow.kt +++ b/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?, -) +) \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index bd3a96d..bc992be 100644 --- a/src/main/resources/application.yml +++ b/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 diff --git a/src/main/resources/db/changelog/changes/20260521_01_2fi/01_truck_lane_schedule.sql b/src/main/resources/db/changelog/changes/20260521_01_2fi/01_truck_lane_schedule.sql new file mode 100644 index 0000000..5cf6cd2 --- /dev/null +++ b/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; diff --git a/src/main/resources/db/changelog/changes/20260522_01_2fi/02_truck_lane_schedule_line_placement.sql b/src/main/resources/db/changelog/changes/20260522_01_2fi/02_truck_lane_schedule_line_placement.sql new file mode 100644 index 0000000..eb8fd67 --- /dev/null +++ b/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`; diff --git a/src/main/resources/db/changelog/changes/20260526_01_2fi/03_truck_lane_schedule_line_actions.sql b/src/main/resources/db/changelog/changes/20260526_01_2fi/03_truck_lane_schedule_line_actions.sql new file mode 100644 index 0000000..c495fd5 --- /dev/null +++ b/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; diff --git a/src/main/resources/db/changelog/changes/20260602_01_2fi/04_truck_lane_schedule_pre_apply_snapshot.sql b/src/main/resources/db/changelog/changes/20260602_01_2fi/04_truck_lane_schedule_pre_apply_snapshot.sql new file mode 100644 index 0000000..f1e0764 --- /dev/null +++ b/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`; diff --git a/src/test/kotlin/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupportTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/pickOrder/service/TruckLaneScheduleLockSupportTest.kt new file mode 100644 index 0000000..57e158d --- /dev/null +++ b/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()) + } +}