| @@ -1767,7 +1767,8 @@ return MessageResponse( | |||||
| dop.truckDepartureTime as truck_departure_time, | dop.truckDepartureTime as truck_departure_time, | ||||
| dop.shopCode as ShopCode, | dop.shopCode as ShopCode, | ||||
| dop.shopName as ShopName, | 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 | FROM fpsmsdb.delivery_order_pick_order dop | ||||
| WHERE dop.handledBy = :userId | WHERE dop.handledBy = :userId | ||||
| @@ -79,6 +79,7 @@ data class AssignByLaneRequest( | |||||
| val releaseType: String? = null, | val releaseType: String? = null, | ||||
| ) | ) | ||||
| data class DoPickOrderSummaryItem( | data class DoPickOrderSummaryItem( | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: java.time.LocalTime?, | val truckDepartureTime: java.time.LocalTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| @@ -120,11 +121,13 @@ interface DoSearchRowProjection { | |||||
| } | } | ||||
| data class ReleasedDoPickOrderListItem( | data class ReleasedDoPickOrderListItem( | ||||
| val id: Long, // doPickOrderId,用於 assign | val id: Long, // doPickOrderId,用於 assign | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, // Date 欄 | val requiredDeliveryDate: LocalDate?, // Date 欄 | ||||
| val shopCode: String?, // Shop | val shopCode: String?, // Shop | ||||
| val shopName: String?, // Shop | val shopName: String?, // Shop | ||||
| val storeId: String?, // 2/F or 4/F | val storeId: String?, // 2/F or 4/F | ||||
| val truckLanceCode: String?, // Truck (Lane) | val truckLanceCode: String?, // Truck (Lane) | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, // Truck 時間 | val truckDepartureTime: LocalTime?, // Truck 時間 | ||||
| val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示 | val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示 | ||||
| ) | ) | ||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | package com.ffii.fpsms.modules.deliveryOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -15,14 +16,18 @@ data class TicketReleaseTableResponse( | |||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| val ticketStatus: String?, | val ticketStatus: String?, | ||||
| val truckId: Long?, | val truckId: Long?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val shopId: Long?, | val shopId: Long?, | ||||
| val handledBy: Long?, | val handledBy: Long?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketReleaseTime: LocalDateTime?, | val ticketReleaseTime: LocalDateTime?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketCompleteDateTime: LocalDateTime?, | val ticketCompleteDateTime: LocalDateTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val shopCode: String?, | val shopCode: String?, | ||||
| val shopName: String?, | val shopName: String?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, | val requiredDeliveryDate: LocalDate?, | ||||
| val handlerName: String?, | val handlerName: String?, | ||||
| val numberOfFGItems: Int = 0, | val numberOfFGItems: Int = 0, | ||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | package com.ffii.fpsms.modules.deliveryOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -7,13 +8,16 @@ data class TruckScheduleDashboardResponse( | |||||
| val storeId: String?, | val storeId: String?, | ||||
| val truckId: Long?, | val truckId: Long?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val numberOfShopsToServe: Int, | val numberOfShopsToServe: Int, | ||||
| val numberOfPickTickets: Int, | val numberOfPickTickets: Int, | ||||
| val totalItemsToPick: Int, | val totalItemsToPick: Int, | ||||
| val numberOfTicketsReleased: Int, | val numberOfTicketsReleased: Int, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val firstTicketStartTime: LocalDateTime?, | val firstTicketStartTime: LocalDateTime?, | ||||
| val numberOfTicketsCompleted: Int, | val numberOfTicketsCompleted: Int, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val lastTicketEndTime: LocalDateTime?, | val lastTicketEndTime: LocalDateTime?, | ||||
| val pickTimeTakenMinutes: Long? | val pickTimeTakenMinutes: Long? | ||||
| ) | ) | ||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | package com.ffii.fpsms.modules.deliveryOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -10,13 +11,17 @@ data class WorkbenchTicketReleaseTableResponse( | |||||
| val ticketNo: String?, | val ticketNo: String?, | ||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| val ticketStatus: String?, | val ticketStatus: String?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | val truckDepartureTime: LocalTime?, | ||||
| val handledBy: Long?, | val handledBy: Long?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketReleaseTime: LocalDateTime?, | val ticketReleaseTime: LocalDateTime?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val ticketCompleteDateTime: LocalDateTime?, | val ticketCompleteDateTime: LocalDateTime?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| val shopCode: String?, | val shopCode: String?, | ||||
| val shopName: String?, | val shopName: String?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, | val requiredDeliveryDate: LocalDate?, | ||||
| val handlerName: String?, | val handlerName: String?, | ||||
| val numberOfFGItems: Int = 0, | val numberOfFGItems: Int = 0, | ||||
| @@ -0,0 +1,46 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.* | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| import java.time.LocalDateTime | |||||
| @Entity | |||||
| @Table(name = "truck_lane_schedule") | |||||
| open class TruckLaneSchedule : BaseEntity<Long>() { | |||||
| @NotNull | |||||
| @Column(name = "executeAt", nullable = false) | |||||
| open var executeAt: LocalDateTime? = null | |||||
| @field:NotNull | |||||
| @Enumerated(EnumType.STRING) | |||||
| @Column(name = "status", nullable = false, length = 20) | |||||
| open var status: TruckLaneScheduleStatus = TruckLaneScheduleStatus.PENDING | |||||
| @field:NotNull | |||||
| @Enumerated(EnumType.STRING) | |||||
| @Column(name = "source", nullable = false, length = 20) | |||||
| open var source: TruckLaneScheduleSource = TruckLaneScheduleSource.MANUAL | |||||
| @field:Size(max = 500) | |||||
| @Column(name = "note", length = 500) | |||||
| open var note: String? = null | |||||
| @Column(name = "appliedAt") | |||||
| open var appliedAt: LocalDateTime? = null | |||||
| @field:Size(max = 2000) | |||||
| @Column(name = "errorMessage", length = 2000) | |||||
| open var errorMessage: String? = null | |||||
| @Column(name = "snapshotVersionId") | |||||
| open var snapshotVersionId: Long? = null | |||||
| @Column(name = "preApplySnapshotVersionId") | |||||
| open var preApplySnapshotVersionId: Long? = null | |||||
| @OneToMany(mappedBy = "schedule", cascade = [CascadeType.ALL], orphanRemoval = true) | |||||
| open var lines: MutableList<TruckLaneScheduleLine> = mutableListOf() | |||||
| } | |||||
| @@ -0,0 +1,82 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.entity.BaseEntity | |||||
| import jakarta.persistence.* | |||||
| import jakarta.validation.constraints.Size | |||||
| import java.time.LocalDateTime | |||||
| import java.time.LocalTime | |||||
| @Entity | |||||
| @Table(name = "truck_lane_schedule_line") | |||||
| open class TruckLaneScheduleLine : BaseEntity<Long>() { | |||||
| @ManyToOne(fetch = FetchType.LAZY) | |||||
| @JoinColumn(name = "scheduleId", nullable = false) | |||||
| open var schedule: TruckLaneSchedule? = null | |||||
| @Enumerated(EnumType.STRING) | |||||
| @Column(name = "lineAction", nullable = false, length = 20) | |||||
| open var lineAction: TruckLaneScheduleLineAction = TruckLaneScheduleLineAction.MOVE | |||||
| @Column(name = "truckRowId") | |||||
| open var truckRowId: Long? = null | |||||
| @field:Size(max = 50) | |||||
| @Column(name = "shopCode", length = 50) | |||||
| open var shopCode: String? = null | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "shopName", length = 255) | |||||
| open var shopName: String? = null | |||||
| @Column(name = "shopId") | |||||
| open var shopId: Long? = null | |||||
| @field:Size(max = 100) | |||||
| @Column(name = "fromTruckLanceCode", length = 100) | |||||
| open var fromTruckLanceCode: String? = null | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "fromRemark", length = 255) | |||||
| open var fromRemark: String? = null | |||||
| @field:Size(max = 10) | |||||
| @Column(name = "fromStoreId", length = 10) | |||||
| open var fromStoreId: String? = null | |||||
| @field:Size(max = 100) | |||||
| @Column(name = "toTruckLanceCode", nullable = false, length = 100) | |||||
| open var toTruckLanceCode: String? = null | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "toRemark", length = 255) | |||||
| open var toRemark: String? = null | |||||
| @field:Size(max = 10) | |||||
| @Column(name = "toStoreId", nullable = false, length = 10) | |||||
| open var toStoreId: String? = null | |||||
| @field:Size(max = 255) | |||||
| @Column(name = "toDistrictReference", length = 255) | |||||
| open var toDistrictReference: String? = null | |||||
| @Column(name = "toLoadingSequence") | |||||
| open var toLoadingSequence: Int? = null | |||||
| @Column(name = "departureTime") | |||||
| open var departureTime: LocalTime? = null | |||||
| @Column(name = "logisticId") | |||||
| open var logisticId: Long? = null | |||||
| @Enumerated(EnumType.STRING) | |||||
| @Column(name = "lineStatus", nullable = false, length = 20) | |||||
| open var lineStatus: TruckLaneScheduleLineStatus = TruckLaneScheduleLineStatus.PENDING | |||||
| @field:Size(max = 2000) | |||||
| @Column(name = "errorMessage", length = 2000) | |||||
| open var errorMessage: String? = null | |||||
| @Column(name = "appliedAt") | |||||
| open var appliedAt: LocalDateTime? = null | |||||
| } | |||||
| @@ -0,0 +1,8 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| enum class TruckLaneScheduleLineAction { | |||||
| MOVE, | |||||
| CREATE, | |||||
| DELETE, | |||||
| ENSURE_LANE, | |||||
| } | |||||
| @@ -0,0 +1,94 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | |||||
| @Repository | |||||
| interface TruckLaneScheduleLineRepository : AbstractRepository<TruckLaneScheduleLine, Long> { | |||||
| fun findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(scheduleId: Long): List<TruckLaneScheduleLine> | |||||
| fun findAllBySchedule_IdInAndDeletedFalseOrderBySchedule_IdAscIdAsc( | |||||
| scheduleIds: Collection<Long>, | |||||
| ): List<TruckLaneScheduleLine> | |||||
| @Query( | |||||
| """ | |||||
| SELECT DISTINCT l.truckRowId FROM TruckLaneScheduleLine l | |||||
| JOIN l.schedule s | |||||
| WHERE l.deleted = false AND s.deleted = false | |||||
| AND l.lineStatus = :pendingLine | |||||
| AND s.status IN :openStatuses | |||||
| """, | |||||
| ) | |||||
| fun findPendingTruckRowIds( | |||||
| @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, | |||||
| @Param("openStatuses") openStatuses: Collection<TruckLaneScheduleStatus>, | |||||
| ): List<Long> | |||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(l) FROM TruckLaneScheduleLine l | |||||
| JOIN l.schedule s | |||||
| WHERE l.deleted = false AND s.deleted = false | |||||
| AND l.truckRowId = :truckRowId | |||||
| AND l.lineStatus = :pendingLine | |||||
| AND s.status IN :openScheduleStatuses | |||||
| AND (:excludeScheduleId IS NULL OR s.id <> :excludeScheduleId) | |||||
| """, | |||||
| ) | |||||
| fun countOpenPendingForTruckRow( | |||||
| @Param("truckRowId") truckRowId: Long, | |||||
| @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, | |||||
| @Param("openScheduleStatuses") openScheduleStatuses: Collection<TruckLaneScheduleStatus>, | |||||
| @Param("excludeScheduleId") excludeScheduleId: Long?, | |||||
| ): Long | |||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(l) > 0 FROM TruckLaneScheduleLine l | |||||
| JOIN l.schedule s | |||||
| WHERE l.deleted = false AND s.deleted = false | |||||
| AND s.status IN :openScheduleStatuses | |||||
| AND l.lineStatus = :pendingLine | |||||
| AND l.toTruckLanceCode = :laneCode | |||||
| AND l.toStoreId = :storeId | |||||
| AND (:excludeScheduleId IS NULL OR s.id <> :excludeScheduleId) | |||||
| """, | |||||
| ) | |||||
| fun existsOpenScheduleForLaneBucket( | |||||
| @Param("laneCode") laneCode: String, | |||||
| @Param("storeId") storeId: String, | |||||
| @Param("pendingLine") pendingLine: TruckLaneScheduleLineStatus, | |||||
| @Param("openScheduleStatuses") openScheduleStatuses: Collection<TruckLaneScheduleStatus>, | |||||
| @Param("excludeScheduleId") excludeScheduleId: Long?, | |||||
| ): Boolean | |||||
| } | |||||
| @@ -0,0 +1,68 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | |||||
| import jakarta.persistence.LockModeType | |||||
| import org.springframework.data.jpa.repository.Lock | |||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | |||||
| import java.time.LocalDateTime | |||||
| @Repository | |||||
| interface TruckLaneScheduleRepository : AbstractRepository<TruckLaneSchedule, Long> { | |||||
| fun findByIdAndDeletedFalse(id: Long): TruckLaneSchedule? | |||||
| @Lock(LockModeType.PESSIMISTIC_WRITE) | |||||
| @Query( | |||||
| """ | |||||
| SELECT s FROM TruckLaneSchedule s | |||||
| WHERE s.id = :id AND s.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findByIdAndDeletedFalseForUpdate(@Param("id") id: Long): TruckLaneSchedule? | |||||
| fun findAllByDeletedFalseAndStatusInOrderByExecuteAtDesc( | |||||
| statuses: Collection<TruckLaneScheduleStatus>, | |||||
| ): List<TruckLaneSchedule> | |||||
| @Query( | |||||
| """ | |||||
| SELECT s FROM TruckLaneSchedule s | |||||
| WHERE s.deleted = false | |||||
| AND s.status = :pending | |||||
| AND s.executeAt <= :now | |||||
| ORDER BY s.executeAt ASC | |||||
| """, | |||||
| ) | |||||
| fun findDuePending( | |||||
| @Param("now") now: LocalDateTime, | |||||
| @Param("pending") pending: TruckLaneScheduleStatus, | |||||
| ): List<TruckLaneSchedule> | |||||
| @Query( | |||||
| """ | |||||
| SELECT COUNT(s) > 0 FROM TruckLaneSchedule s | |||||
| WHERE s.deleted = false | |||||
| AND s.status = :applying | |||||
| AND s.id <> :excludeId | |||||
| """, | |||||
| ) | |||||
| fun existsOtherApplying( | |||||
| @Param("applying") applying: TruckLaneScheduleStatus, | |||||
| @Param("excludeId") excludeId: Long, | |||||
| ): Boolean | |||||
| @Query( | |||||
| """ | |||||
| SELECT s FROM TruckLaneSchedule s | |||||
| WHERE s.deleted = false | |||||
| AND s.status = :applying | |||||
| AND s.modified < :staleBefore | |||||
| """, | |||||
| ) | |||||
| fun findStaleApplying( | |||||
| @Param("applying") applying: TruckLaneScheduleStatus, | |||||
| @Param("staleBefore") staleBefore: LocalDateTime, | |||||
| ): List<TruckLaneSchedule> | |||||
| } | |||||
| @@ -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, | |||||
| } | |||||
| @@ -1,6 +1,9 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.entity | package com.ffii.fpsms.modules.pickOrder.entity | ||||
| import com.ffii.core.support.AbstractRepository | 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 | import org.springframework.stereotype.Repository | ||||
| @Repository | @Repository | ||||
| @@ -8,5 +11,15 @@ interface TruckLaneVersionRepository : AbstractRepository<TruckLaneVersion, Long | |||||
| fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion> | fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion> | ||||
| fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion> | fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion> | ||||
| fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? | fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? | ||||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||||
| @Query( | |||||
| """ | |||||
| UPDATE TruckLaneVersion v | |||||
| SET v.createdBy = :actor, v.modifiedBy = :actor | |||||
| WHERE v.id = :id | |||||
| """, | |||||
| ) | |||||
| fun updateActor(@Param("id") id: Long, @Param("actor") actor: String): Int | |||||
| } | } | ||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -40,6 +40,20 @@ object RouteLaneExcelSupport { | |||||
| const val COL_SHOP_CODE = 3 | const val COL_SHOP_CODE = 3 | ||||
| const val COL_SCHEDULE = 4 | const val COL_SCHEDULE = 4 | ||||
| const val COL_DEPARTURE_ROW = 5 | const val COL_DEPARTURE_ROW = 5 | ||||
| const val COL_LOADING_SEQUENCE = 6 | |||||
| const val COL_LAST = COL_LOADING_SEQUENCE | |||||
| const val HEADER_LOADING_SEQUENCE = "裝載順序" | |||||
| fun writeDataColumnHeaders(headerRow: org.apache.poi.ss.usermodel.Row) { | |||||
| headerRow.createCell(COL_AREA_PLATE).setCellValue("板") | |||||
| headerRow.createCell(COL_SHOP_NAME).setCellValue("店鋪名稱") | |||||
| headerRow.createCell(COL_BRAND).setCellValue("品牌") | |||||
| headerRow.createCell(COL_SHOP_CODE).setCellValue("店鋪編號") | |||||
| headerRow.createCell(COL_SCHEDULE).setCellValue("此店車期") | |||||
| headerRow.createCell(COL_DEPARTURE_ROW).setCellValue("出車時間") | |||||
| headerRow.createCell(COL_LOADING_SEQUENCE).setCellValue(HEADER_LOADING_SEQUENCE) | |||||
| } | |||||
| fun decodeLaneId(laneId: String): Pair<String, String?>? { | fun decodeLaneId(laneId: String): Pair<String, String?>? { | ||||
| val i = laneId.indexOf(SEP) | val i = laneId.indexOf(SEP) | ||||
| @@ -169,7 +183,7 @@ object RouteLaneExcelSupport { | |||||
| val headerRow = sheet.getRow(ROW_HEADER) | val headerRow = sheet.getRow(ROW_HEADER) | ||||
| if (headerRow != null) { | 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 | headerRow.getCell(c)?.cellStyle = st.header | ||||
| } | } | ||||
| } | } | ||||
| @@ -179,7 +193,7 @@ object RouteLaneExcelSupport { | |||||
| val alt = (r - firstDataRow) % 2 == 1 | val alt = (r - firstDataRow) % 2 == 1 | ||||
| val style = if (alt) st.dataAlt else st.data | val style = if (alt) st.dataAlt else st.data | ||||
| val row = sheet.getRow(r) ?: continue | 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 | row.getCell(c)?.cellStyle = style | ||||
| } | } | ||||
| } | } | ||||
| @@ -191,12 +205,13 @@ object RouteLaneExcelSupport { | |||||
| sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256) | sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256) | ||||
| sheet.setColumnWidth(COL_SCHEDULE, 12 * 256) | sheet.setColumnWidth(COL_SCHEDULE, 12 * 256) | ||||
| sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256) | sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256) | ||||
| sheet.setColumnWidth(COL_LOADING_SEQUENCE, 10 * 256) | |||||
| sheet.createFreezePane(0, ROW_FIRST_DATA) | sheet.createFreezePane(0, ROW_FIRST_DATA) | ||||
| val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER | val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER | ||||
| sheet.setAutoFilter( | sheet.setAutoFilter( | ||||
| CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_DEPARTURE_ROW), | |||||
| CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_LAST), | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,413 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.* | |||||
| import com.ffii.fpsms.modules.pickOrder.support.isOptimisticLockFailure | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckLaneSnapshotRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest | |||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Propagation | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.time.LocalDateTime | |||||
| import java.time.LocalTime | |||||
| import kotlin.random.Random | |||||
| enum class ScheduleApplyOutcome { | |||||
| APPLIED, | |||||
| FAILED, | |||||
| SKIPPED, | |||||
| NOT_FOUND, | |||||
| } | |||||
| private val lineActionApplyOrder = | |||||
| mapOf( | |||||
| TruckLaneScheduleLineAction.ENSURE_LANE to 0, | |||||
| TruckLaneScheduleLineAction.CREATE to 1, | |||||
| TruckLaneScheduleLineAction.MOVE to 2, | |||||
| TruckLaneScheduleLineAction.DELETE to 3, | |||||
| ) | |||||
| @Service | |||||
| open class TruckLaneScheduleApplier( | |||||
| private val scheduleRepository: TruckLaneScheduleRepository, | |||||
| private val scheduleLineRepository: TruckLaneScheduleLineRepository, | |||||
| private val truckService: TruckService, | |||||
| private val truckLaneVersionService: TruckLaneVersionService, | |||||
| ) { | |||||
| private val logger = LoggerFactory.getLogger(javaClass) | |||||
| @Transactional( | |||||
| propagation = Propagation.REQUIRES_NEW, | |||||
| noRollbackFor = [IllegalArgumentException::class, IllegalStateException::class], | |||||
| ) | |||||
| open fun applyOne(scheduleId: Long): ScheduleApplyOutcome { | |||||
| val schedule = scheduleRepository.findByIdAndDeletedFalseForUpdate(scheduleId) | |||||
| ?: return ScheduleApplyOutcome.NOT_FOUND | |||||
| if (schedule.status != TruckLaneScheduleStatus.PENDING) { | |||||
| return ScheduleApplyOutcome.SKIPPED | |||||
| } | |||||
| if (scheduleRepository.existsOtherApplying(TruckLaneScheduleStatus.APPLYING, scheduleId)) { | |||||
| return ScheduleApplyOutcome.SKIPPED | |||||
| } | |||||
| return TruckLaneScheduleLockSupport.withApplyingSchedule(scheduleId) { | |||||
| applyOneLocked(schedule) | |||||
| } | |||||
| } | |||||
| private fun applyOneLocked(schedule: TruckLaneSchedule): ScheduleApplyOutcome { | |||||
| val scheduleId = schedule.id ?: return ScheduleApplyOutcome.NOT_FOUND | |||||
| schedule.status = TruckLaneScheduleStatus.APPLYING | |||||
| scheduleRepository.saveAndFlush(schedule) | |||||
| val lines = | |||||
| scheduleLineRepository | |||||
| .findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(scheduleId) | |||||
| .sortedBy { lineActionApplyOrder[it.lineAction] ?: 99 } | |||||
| val createdTruckRowIds = mutableListOf<Long>() | |||||
| var preApplySnapshotId: Long? = null | |||||
| try { | |||||
| val preSnap = | |||||
| truckLaneVersionService.createSnapshot( | |||||
| CreateTruckLaneSnapshotRequest( | |||||
| truckLanceCode = null, | |||||
| note = "排程 #${schedule.id} 套用前快照 ${schedule.executeAt}", | |||||
| createdBy = schedule.createdBy, | |||||
| ), | |||||
| ) | |||||
| preApplySnapshotId = preSnap.id | |||||
| schedule.preApplySnapshotVersionId = preApplySnapshotId | |||||
| scheduleRepository.saveAndFlush(schedule) | |||||
| val now = LocalDateTime.now() | |||||
| for (line in lines) { | |||||
| if (line.lineStatus != TruckLaneScheduleLineStatus.PENDING) continue | |||||
| applyLineWithRetry(line, createdTruckRowIds) | |||||
| line.lineStatus = TruckLaneScheduleLineStatus.APPLIED | |||||
| line.appliedAt = now | |||||
| line.errorMessage = null | |||||
| scheduleLineRepository.save(line) | |||||
| } | |||||
| var postSnapshotId: Long? = null | |||||
| try { | |||||
| val postSnap = | |||||
| truckLaneVersionService.createSnapshot( | |||||
| CreateTruckLaneSnapshotRequest( | |||||
| truckLanceCode = null, | |||||
| note = "排程 #${schedule.id} 套用 (${ | |||||
| lines.count { | |||||
| it.lineStatus == TruckLaneScheduleLineStatus.APPLIED | |||||
| } | |||||
| } 列) ${schedule.executeAt}", | |||||
| createdBy = schedule.createdBy, | |||||
| ), | |||||
| ) | |||||
| postSnapshotId = postSnap.id | |||||
| } catch (e: Exception) { | |||||
| logger.warn("Post-apply snapshot failed for schedule {}", scheduleId, e) | |||||
| } | |||||
| persistScheduleState(scheduleId) { fresh -> | |||||
| fresh.appliedAt = now | |||||
| fresh.status = TruckLaneScheduleStatus.APPLIED | |||||
| fresh.errorMessage = null | |||||
| if (postSnapshotId != null) { | |||||
| fresh.snapshotVersionId = postSnapshotId | |||||
| } | |||||
| } | |||||
| return ScheduleApplyOutcome.APPLIED | |||||
| } catch (e: Exception) { | |||||
| return abortAndRestore( | |||||
| scheduleId = scheduleId, | |||||
| lines = lines, | |||||
| preApplySnapshotId = preApplySnapshotId, | |||||
| createdTruckRowIds = createdTruckRowIds, | |||||
| cause = e, | |||||
| ) | |||||
| } | |||||
| } | |||||
| @Transactional(propagation = Propagation.REQUIRES_NEW) | |||||
| open fun markFailed(scheduleId: Long, error: Throwable) { | |||||
| try { | |||||
| TruckLaneScheduleLockSupport.withApplyingSchedule(scheduleId) { | |||||
| val schedule = scheduleRepository.findByIdAndDeletedFalseForUpdate(scheduleId) | |||||
| if (schedule != null && | |||||
| (schedule.status == TruckLaneScheduleStatus.APPLYING || | |||||
| schedule.status == TruckLaneScheduleStatus.PENDING) | |||||
| ) { | |||||
| abortAndRestore( | |||||
| scheduleId = scheduleId, | |||||
| lines = | |||||
| scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc( | |||||
| schedule.id ?: 0L, | |||||
| ), | |||||
| preApplySnapshotId = schedule.preApplySnapshotVersionId, | |||||
| createdTruckRowIds = collectCreatedTruckIdsFromLines(schedule.id ?: 0L), | |||||
| cause = error, | |||||
| ) | |||||
| } | |||||
| } | |||||
| } catch (e: Exception) { | |||||
| if (isOptimisticLockFailure(e)) { | |||||
| logger.warn("Schedule {} markFailed optimistic lock, rethrowing", scheduleId) | |||||
| throw e | |||||
| } | |||||
| logger.warn("Schedule {} markFailed failed", scheduleId, e) | |||||
| throw e | |||||
| } | |||||
| } | |||||
| private fun applyLineWithRetry( | |||||
| line: TruckLaneScheduleLine, | |||||
| createdTruckRowIds: MutableList<Long>, | |||||
| ) { | |||||
| var last: Exception? = null | |||||
| repeat(3) { attempt -> | |||||
| try { | |||||
| val note = applyLine(line, createdTruckRowIds) | |||||
| line.errorMessage = note | |||||
| return | |||||
| } catch (e: Exception) { | |||||
| last = e | |||||
| if (!isOptimisticLockFailure(e) || attempt == 2) throw e | |||||
| Thread.sleep(50L + Random.nextLong(150)) | |||||
| } | |||||
| } | |||||
| throw last ?: IllegalStateException("apply line failed") | |||||
| } | |||||
| private fun applyLine( | |||||
| line: TruckLaneScheduleLine, | |||||
| createdTruckRowIds: MutableList<Long>, | |||||
| ): String? { | |||||
| return when (line.lineAction) { | |||||
| TruckLaneScheduleLineAction.ENSURE_LANE -> applyEnsureLane(line) | |||||
| TruckLaneScheduleLineAction.CREATE -> applyCreate(line, createdTruckRowIds) | |||||
| TruckLaneScheduleLineAction.MOVE -> applyMove(line) | |||||
| TruckLaneScheduleLineAction.DELETE -> applyDelete(line) | |||||
| } | |||||
| } | |||||
| private fun abortAndRestore( | |||||
| scheduleId: Long, | |||||
| lines: List<TruckLaneScheduleLine>, | |||||
| preApplySnapshotId: Long?, | |||||
| createdTruckRowIds: List<Long>, | |||||
| cause: Throwable, | |||||
| ): ScheduleApplyOutcome { | |||||
| val errMsg = (cause.message ?: cause.javaClass.simpleName).take(2000) | |||||
| logger.warn("Schedule {} apply failed, restoring: {}", scheduleId, errMsg, cause) | |||||
| var restoreNote: String? = null | |||||
| if (preApplySnapshotId != null) { | |||||
| try { | |||||
| restoreNote = | |||||
| truckLaneVersionService.restore( | |||||
| preApplySnapshotId, | |||||
| skipPostSnapshot = true, | |||||
| ) | |||||
| } catch (restoreEx: Exception) { | |||||
| restoreNote = "restore failed: ${restoreEx.message}" | |||||
| logger.error( | |||||
| "Schedule {} restore from snapshot {} failed", | |||||
| scheduleId, | |||||
| preApplySnapshotId, | |||||
| restoreEx, | |||||
| ) | |||||
| } | |||||
| } | |||||
| for (truckRowId in createdTruckRowIds.distinct()) { | |||||
| try { | |||||
| truckService.deleteById(truckRowId) | |||||
| } catch (delEx: Exception) { | |||||
| logger.warn( | |||||
| "Schedule {} secondary DELETE sweep failed for truck {}", | |||||
| scheduleId, | |||||
| truckRowId, | |||||
| delEx, | |||||
| ) | |||||
| } | |||||
| } | |||||
| val now = LocalDateTime.now() | |||||
| lines.forEach { line -> | |||||
| if (line.lineStatus == TruckLaneScheduleLineStatus.PENDING) { | |||||
| line.lineStatus = TruckLaneScheduleLineStatus.FAILED | |||||
| line.errorMessage = errMsg | |||||
| line.appliedAt = now | |||||
| scheduleLineRepository.save(line) | |||||
| } | |||||
| } | |||||
| val restoreSuffix = | |||||
| if (preApplySnapshotId != null) { | |||||
| ";已還原 snapshot #$preApplySnapshotId${restoreNote?.let { " ($it)" } ?: ""}" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| persistScheduleState(scheduleId) { fresh -> | |||||
| fresh.status = TruckLaneScheduleStatus.FAILED | |||||
| fresh.appliedAt = now | |||||
| fresh.errorMessage = (errMsg + restoreSuffix).take(2000) | |||||
| } | |||||
| return ScheduleApplyOutcome.FAILED | |||||
| } | |||||
| private fun persistScheduleState( | |||||
| scheduleId: Long, | |||||
| patch: (TruckLaneSchedule) -> Unit, | |||||
| ) { | |||||
| var last: Exception? = null | |||||
| repeat(3) { attempt -> | |||||
| try { | |||||
| val fresh = | |||||
| scheduleRepository.findByIdAndDeletedFalseForUpdate(scheduleId) | |||||
| ?: return | |||||
| patch(fresh) | |||||
| scheduleRepository.saveAndFlush(fresh) | |||||
| return | |||||
| } catch (e: Exception) { | |||||
| last = e | |||||
| if (!isOptimisticLockFailure(e) || attempt == 2) throw e | |||||
| Thread.sleep(50L + Random.nextLong(150)) | |||||
| } | |||||
| } | |||||
| throw last ?: IllegalStateException("persist schedule failed") | |||||
| } | |||||
| private fun collectCreatedTruckIdsFromLines(scheduleId: Long): List<Long> { | |||||
| return scheduleLineRepository | |||||
| .findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(scheduleId) | |||||
| .filter { | |||||
| it.lineAction == TruckLaneScheduleLineAction.CREATE && | |||||
| it.truckRowId != null && | |||||
| it.lineStatus == TruckLaneScheduleLineStatus.APPLIED | |||||
| } | |||||
| .mapNotNull { it.truckRowId } | |||||
| } | |||||
| private fun applyEnsureLane(line: TruckLaneScheduleLine): String? { | |||||
| val toKey = truckService.toBucketKey(line) | |||||
| if (truckService.trucksInLaneBucket(toKey).isNotEmpty()) { | |||||
| return "車線已存在,跳過建立" | |||||
| } | |||||
| ensureTargetLaneIfEmpty(line, fallbackDeparture = null) | |||||
| return null | |||||
| } | |||||
| /** | |||||
| * When MOVE/CREATE targets a lane bucket with no rows yet, create a placeholder row | |||||
| * (same as ENSURE_LANE) so apply matches schedule creation rules with allowEmptyTarget. | |||||
| */ | |||||
| private fun ensureTargetLaneIfEmpty( | |||||
| line: TruckLaneScheduleLine, | |||||
| fallbackDeparture: LocalTime?, | |||||
| ): String? { | |||||
| val toKey = truckService.toBucketKey(line) | |||||
| if (truckService.trucksInLaneBucket(toKey).isNotEmpty()) return null | |||||
| val departure = | |||||
| line.departureTime | |||||
| ?: fallbackDeparture | |||||
| ?: throw IllegalArgumentException( | |||||
| "目標車線 ${toKey.truckLanceCode} 不存在或尚無任何列,且排程未提供 departureTime", | |||||
| ) | |||||
| truckService.createTruckWithoutShop( | |||||
| CreateTruckWithoutShopRequest( | |||||
| store_id = toKey.storeId, | |||||
| truckLanceCode = toKey.truckLanceCode, | |||||
| departureTime = departure, | |||||
| loadingSequence = line.toLoadingSequence ?: 0, | |||||
| districtReference = line.toDistrictReference, | |||||
| remark = toKey.remark, | |||||
| logisticId = line.logisticId, | |||||
| ), | |||||
| ) | |||||
| return "已自動建立空白車線 ${toKey.truckLanceCode}" | |||||
| } | |||||
| private fun applyCreate( | |||||
| line: TruckLaneScheduleLine, | |||||
| createdTruckRowIds: MutableList<Long>, | |||||
| ): String? { | |||||
| val shopId = line.shopId ?: throw IllegalArgumentException("缺�? shopId") | |||||
| val departure = line.departureTime ?: throw IllegalArgumentException("缺�? departureTime") | |||||
| val toKey = truckService.toBucketKey(line) | |||||
| val seq = line.toLoadingSequence ?: 0 | |||||
| val saved = | |||||
| truckService.createTruckInShop( | |||||
| SaveTruckRequest( | |||||
| id = null, | |||||
| store_id = toKey.storeId, | |||||
| truckLanceCode = toKey.truckLanceCode, | |||||
| departureTime = departure, | |||||
| shopId = shopId, | |||||
| shopName = line.shopName.orEmpty(), | |||||
| shopCode = line.shopCode.orEmpty(), | |||||
| loadingSequence = seq, | |||||
| remark = toKey.remark, | |||||
| districtReference = line.toDistrictReference, | |||||
| logisticId = line.logisticId, | |||||
| ), | |||||
| ) | |||||
| val rowId = saved.id ?: throw IllegalStateException("CREATE ?��???truck id") | |||||
| line.truckRowId = rowId | |||||
| createdTruckRowIds.add(rowId) | |||||
| return null | |||||
| } | |||||
| private fun applyMove(line: TruckLaneScheduleLine): String? { | |||||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺�? truckRowId") | |||||
| var truck = | |||||
| truckService.findTruckRowByIdIncludingDeleted(truckRowId) | |||||
| ?: throw IllegalArgumentException("?��???truck #$truckRowId") | |||||
| val notes = mutableListOf<String>() | |||||
| if (truck.deleted == true) { | |||||
| truck = truckService.restoreTruckRowIfDeleted(truck) | |||||
| notes.add("\u5e97\u92ea\u5217\u5df2\u5f9e\u522a\u9664\u72c0\u614b\u9084\u539f") | |||||
| } | |||||
| val current = truckService.truckLaneBucketKeyOf(truck) | |||||
| val fromCode = line.fromTruckLanceCode?.trim().orEmpty() | |||||
| val fromStore = line.fromStoreId?.trim().orEmpty() | |||||
| val fromRemark = line.fromRemark | |||||
| if (current.truckLanceCode != fromCode || | |||||
| current.storeId != fromStore || | |||||
| (current.remark ?: "") != (fromRemark ?: "") | |||||
| ) { | |||||
| notes.add("?��??��??�已不在?��?建�??��?來�?車�?,�?�?truck.id 移至?��?") | |||||
| logger.info( | |||||
| "Schedule line {} truck {} moved from {} to target (was scheduled from {} {})", | |||||
| line.id, | |||||
| truckRowId, | |||||
| "${current.truckLanceCode}/${current.storeId}", | |||||
| fromCode, | |||||
| fromStore, | |||||
| ) | |||||
| } | |||||
| val toKey = truckService.toBucketKey(line) | |||||
| val seq = line.toLoadingSequence ?: throw IllegalArgumentException("缺少 toLoadingSequence") | |||||
| ensureTargetLaneIfEmpty(line, fallbackDeparture = truck.departureTime)?.let { notes.add(it) } | |||||
| truckService.applyScheduledTruckMove( | |||||
| truckRowId, | |||||
| toKey, | |||||
| line.toDistrictReference, | |||||
| seq, | |||||
| ) | |||||
| return notes.takeIf { it.isNotEmpty() }?.joinToString("\uFF1B") | |||||
| } | |||||
| private fun applyDelete(line: TruckLaneScheduleLine): String? { | |||||
| val truckRowId = line.truckRowId ?: throw IllegalArgumentException("缺�? truckRowId") | |||||
| truckService.deleteById(truckRowId) | |||||
| return null | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,185 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneScheduleExcelPreviewRow | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneScheduleExcelRowError | |||||
| import org.apache.poi.ss.usermodel.CellType | |||||
| import org.apache.poi.ss.usermodel.DataFormatter | |||||
| import org.apache.poi.ss.usermodel.Workbook | |||||
| import org.springframework.stereotype.Component | |||||
| import java.time.LocalDate | |||||
| import java.time.LocalDateTime | |||||
| import java.time.LocalTime | |||||
| import java.time.format.DateTimeFormatter | |||||
| import java.util.Locale | |||||
| @Component | |||||
| class TruckLaneScheduleExcelSupport( | |||||
| private val truckRepository: TruckRepository, | |||||
| private val truckService: TruckService, | |||||
| ) { | |||||
| private val formatter = DataFormatter() | |||||
| private val headerAliases: Map<String, Set<String>> = mapOf( | |||||
| "shop" to setOf("shopid", "shopcode", "shop_id", "shop_code", "店鋪", "店舖編號"), | |||||
| "targetlane" to setOf("targetlane", "target_lane", "車線", "目標車線"), | |||||
| "targetremark" to setOf("targetremark", "target_remark", "備註"), | |||||
| "targetstoreid" to setOf("targetstoreid", "target_store_id", "storeid", "store_id", "樓層"), | |||||
| "executedate" to setOf("executedate", "execute_date", "date", "執行日期"), | |||||
| "executetime" to setOf("executetime", "execute_time", "time", "執行時間"), | |||||
| ) | |||||
| fun parseWorkbook( | |||||
| workbook: Workbook?, | |||||
| defaultExecuteAt: LocalDateTime?, | |||||
| ): Pair<List<TruckLaneScheduleExcelPreviewRow>, List<TruckLaneScheduleExcelRowError>> { | |||||
| if (workbook == null || workbook.numberOfSheets == 0) { | |||||
| return emptyList<TruckLaneScheduleExcelPreviewRow>() to | |||||
| listOf(TruckLaneScheduleExcelRowError(0, "Excel 無工作表")) | |||||
| } | |||||
| val sheet = workbook.getSheetAt(0) | |||||
| if (sheet.physicalNumberOfRows < 2) { | |||||
| return emptyList<TruckLaneScheduleExcelPreviewRow>() to | |||||
| listOf(TruckLaneScheduleExcelRowError(0, "至少需要標題列與一筆資料")) | |||||
| } | |||||
| val headerRow = sheet.getRow(sheet.firstRowNum) ?: return emptyList<TruckLaneScheduleExcelPreviewRow>() to | |||||
| listOf(TruckLaneScheduleExcelRowError(0, "缺少標題列")) | |||||
| val colIndex = mutableMapOf<String, Int>() | |||||
| for (c in 0 until headerRow.lastCellNum) { | |||||
| val cell = headerRow.getCell(c) ?: continue | |||||
| val raw = formatter.formatCellValue(cell).trim().lowercase(Locale.ROOT) | |||||
| if (raw.isEmpty()) continue | |||||
| for ((key, aliases) in headerAliases) { | |||||
| if (aliases.any { raw == it || raw.replace(" ", "") == it }) { | |||||
| colIndex[key] = c | |||||
| } | |||||
| } | |||||
| } | |||||
| if (!colIndex.containsKey("shop") || !colIndex.containsKey("targetlane")) { | |||||
| return emptyList<TruckLaneScheduleExcelPreviewRow>() to listOf( | |||||
| TruckLaneScheduleExcelRowError( | |||||
| 0, | |||||
| "缺少必要欄位:ShopID/ShopCode 與 TargetLane", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| val preview = mutableListOf<TruckLaneScheduleExcelPreviewRow>() | |||||
| val errors = mutableListOf<TruckLaneScheduleExcelRowError>() | |||||
| val firstData = sheet.firstRowNum + 1 | |||||
| for (r in firstData..sheet.lastRowNum) { | |||||
| val row = sheet.getRow(r) ?: continue | |||||
| val rowIndex = r + 1 | |||||
| val shopRaw = cellString(row, colIndex["shop"]) | |||||
| val laneRaw = cellString(row, colIndex["targetlane"]) | |||||
| if (shopRaw.isBlank() && laneRaw.isBlank()) continue | |||||
| val storeRaw = colIndex["targetstoreid"]?.let { cellString(row, it) }.orEmpty() | |||||
| val storeId = truckService.normalizeRouteStoreId( | |||||
| if (storeRaw.isBlank()) "2F" else storeRaw, | |||||
| ) | |||||
| val remark = colIndex["targetremark"]?.let { cellString(row, it) } | |||||
| ?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val executeAt = parseExecuteAt( | |||||
| colIndex["executedate"]?.let { cellString(row, it) }, | |||||
| colIndex["executetime"]?.let { cellString(row, it) }, | |||||
| defaultExecuteAt, | |||||
| ) | |||||
| if (shopRaw.isBlank()) { | |||||
| errors.add(TruckLaneScheduleExcelRowError(rowIndex, "ShopID/ShopCode 不可為空")) | |||||
| continue | |||||
| } | |||||
| if (laneRaw.isBlank()) { | |||||
| errors.add(TruckLaneScheduleExcelRowError(rowIndex, "TargetLane 不可為空")) | |||||
| continue | |||||
| } | |||||
| val truck = resolveTruckByShopCode(shopRaw.trim()) | |||||
| if (truck == null) { | |||||
| errors.add(TruckLaneScheduleExcelRowError(rowIndex, "找不到店鋪:$shopRaw")) | |||||
| continue | |||||
| } | |||||
| val toKey = TruckService.TruckLaneBucketKey( | |||||
| truckLanceCode = laneRaw.trim(), | |||||
| storeId = storeId, | |||||
| remark = truckService.bucketRemarkForStore(storeId, remark), | |||||
| ) | |||||
| val validateErr = truckService.validateMoveToLane(truck.id ?: 0L, toKey) | |||||
| if (validateErr != null) { | |||||
| errors.add(TruckLaneScheduleExcelRowError(rowIndex, validateErr)) | |||||
| continue | |||||
| } | |||||
| preview.add( | |||||
| TruckLaneScheduleExcelPreviewRow( | |||||
| rowIndex = rowIndex, | |||||
| shopCode = truck.shopCode?.trim().orEmpty(), | |||||
| toTruckLanceCode = toKey.truckLanceCode, | |||||
| toRemark = toKey.remark, | |||||
| toStoreId = toKey.storeId, | |||||
| executeAt = executeAt, | |||||
| truckRowId = truck.id, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| return preview to errors | |||||
| } | |||||
| private fun cellString(row: org.apache.poi.ss.usermodel.Row, col: Int?): String { | |||||
| if (col == null) return "" | |||||
| val cell = row.getCell(col) ?: return "" | |||||
| return formatter.formatCellValue(cell).trim() | |||||
| } | |||||
| private fun resolveTruckByShopCode(shopCode: String): Truck? { | |||||
| val code = shopCode.trim() | |||||
| val all = truckRepository.findAllForRouteBoard() | |||||
| val matches = all.filter { t -> | |||||
| !t.deleted && | |||||
| t.shopCode?.trim()?.equals(code, ignoreCase = true) == true | |||||
| } | |||||
| return matches.minByOrNull { it.id ?: Long.MAX_VALUE } | |||||
| } | |||||
| private fun parseExecuteAt( | |||||
| dateStr: String?, | |||||
| timeStr: String?, | |||||
| default: LocalDateTime?, | |||||
| ): LocalDateTime? { | |||||
| if (dateStr.isNullOrBlank() && timeStr.isNullOrBlank()) return default | |||||
| val date = parseDate(dateStr) ?: default?.toLocalDate() ?: LocalDate.now() | |||||
| val time = parseTime(timeStr) ?: default?.toLocalTime() ?: LocalTime.of(0, 0) | |||||
| return LocalDateTime.of(date, time) | |||||
| } | |||||
| private fun parseDate(s: String?): LocalDate? { | |||||
| if (s.isNullOrBlank()) return null | |||||
| val t = s.trim() | |||||
| for (fmt in listOf("yyyy-MM-dd", "yyyy/MM/dd", "dd/MM/yyyy")) { | |||||
| try { | |||||
| return LocalDate.parse(t, DateTimeFormatter.ofPattern(fmt)) | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| return null | |||||
| } | |||||
| private fun parseTime(s: String?): LocalTime? { | |||||
| if (s.isNullOrBlank()) return null | |||||
| val t = s.trim() | |||||
| for (fmt in listOf("HH:mm", "HH:mm:ss", "H:mm")) { | |||||
| try { | |||||
| return LocalTime.parse(t, DateTimeFormatter.ofPattern(fmt)) | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| return null | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,24 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleStatus | |||||
| object TruckLaneScheduleLockSupport { | |||||
| val openScheduleStatuses: List<TruckLaneScheduleStatus> = | |||||
| listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING) | |||||
| val pendingLineStatus: TruckLaneScheduleLineStatus = TruckLaneScheduleLineStatus.PENDING | |||||
| private val applyingScheduleIdHolder = ThreadLocal<Long?>() | |||||
| fun applyingScheduleId(): Long? = applyingScheduleIdHolder.get() | |||||
| fun <T> withApplyingSchedule(scheduleId: Long, block: () -> T): T { | |||||
| applyingScheduleIdHolder.set(scheduleId) | |||||
| try { | |||||
| return block() | |||||
| } finally { | |||||
| applyingScheduleIdHolder.remove() | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,262 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineAction | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | |||||
| import org.apache.poi.ss.usermodel.Workbook | |||||
| import org.springframework.stereotype.Service | |||||
| import java.time.LocalTime | |||||
| @Service | |||||
| open class TruckLaneSchedulePlanService( | |||||
| private val truckService: TruckService, | |||||
| private val schedulePlanValidator: TruckLaneSchedulePlanValidator, | |||||
| ) { | |||||
| open fun planFromRouteExcel(workbook: Workbook?): RouteExcelSchedulePlanResponse { | |||||
| val parsed = truckService.parseRouteLanesExcel(workbook) | |||||
| return planFromParsedRows(parsed.rows, parsed.sheetCount) | |||||
| } | |||||
| open fun planFromParsedRows( | |||||
| rows: List<RouteLaneImportPreviewRow>, | |||||
| sheetCount: Int = 0, | |||||
| ): RouteExcelSchedulePlanResponse { | |||||
| val lines = mutableListOf<TruckLaneScheduleLineRequest>() | |||||
| val previews = mutableListOf<RouteExcelSchedulePlanPreviewRow>() | |||||
| val errors = mutableListOf<RouteExcelSchedulePlanError>() | |||||
| val moveTruckIds = mutableSetOf<Long>() | |||||
| val deleteTruckIds = mutableSetOf<Long>() | |||||
| val ensuredLaneKeys = mutableSetOf<String>() | |||||
| if (rows.isEmpty()) { | |||||
| return emptyPlan(sheetCount, rows.size) | |||||
| } | |||||
| val excelShopCodesGlobal = | |||||
| rows | |||||
| .map { normalizeShopCodeKey(it.shopCode) } | |||||
| .filter { it.isNotEmpty() } | |||||
| .toSet() | |||||
| val rowsByBucket = | |||||
| rows.groupBy { row -> | |||||
| laneKeyString(truckService.bucketKeyForRouteRow(row)) | |||||
| } | |||||
| for ((_, bucketRows) in rowsByBucket) { | |||||
| if (bucketRows.isEmpty()) continue | |||||
| val sample = bucketRows.first() | |||||
| val bucketKey = truckService.bucketKeyForRouteRow(sample) | |||||
| val currentBucket = | |||||
| truckService | |||||
| .trucksInLaneBucket(bucketKey) | |||||
| .filter { !truckService.isPlaceholderTruckRow(it) } | |||||
| if (currentBucket.isEmpty()) { | |||||
| val defaultDeparture = defaultDepartureForRows(bucketRows) | |||||
| if (defaultDeparture == null) { | |||||
| errors.add( | |||||
| RouteExcelSchedulePlanError( | |||||
| shopCode = sample.shopCode, | |||||
| shopName = sample.shopName, | |||||
| message = "invalid_departure", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| val laneKey = laneKeyString(bucketKey) | |||||
| if (ensuredLaneKeys.add(laneKey)) { | |||||
| addLine( | |||||
| lines, | |||||
| previews, | |||||
| TruckLaneScheduleLineRequest( | |||||
| action = TruckLaneScheduleLineAction.ENSURE_LANE, | |||||
| toTruckLanceCode = bucketKey.truckLanceCode, | |||||
| toRemark = bucketKey.remark, | |||||
| toStoreId = bucketKey.storeId, | |||||
| toLoadingSequence = 0, | |||||
| departureTime = defaultDeparture, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| for (row in bucketRows) { | |||||
| val toKey = truckService.bucketKeyForRouteRow(row) | |||||
| val departure = truckService.parseRouteDepartureTime(row.departureTime) | |||||
| if (departure == null) { | |||||
| errors.add( | |||||
| RouteExcelSchedulePlanError( | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| message = "invalid_departure", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| val truckRowId = row.truckRowId | |||||
| if (truckRowId == null) { | |||||
| addLine( | |||||
| lines, | |||||
| previews, | |||||
| TruckLaneScheduleLineRequest( | |||||
| action = TruckLaneScheduleLineAction.CREATE, | |||||
| toTruckLanceCode = toKey.truckLanceCode, | |||||
| toRemark = toKey.remark, | |||||
| toStoreId = toKey.storeId, | |||||
| toLoadingSequence = row.loadingSequence.coerceAtLeast(0), | |||||
| toDistrictReference = row.districtReference, | |||||
| shopId = row.shopId, | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| departureTime = departure, | |||||
| logisticId = row.logisticId, | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| val truck = truckService.findTruckById(truckRowId) | |||||
| if (truck == null) { | |||||
| errors.add( | |||||
| RouteExcelSchedulePlanError( | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| message = "truck_not_found", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| if (truckService.isPlaceholderTruckRow(truck)) { | |||||
| errors.add( | |||||
| RouteExcelSchedulePlanError( | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| message = "placeholder_row", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| if (!truckService.truckNeedsMoveForRouteRow(truck, row)) { | |||||
| continue | |||||
| } | |||||
| if (!moveTruckIds.add(truckRowId)) continue | |||||
| addLine( | |||||
| lines, | |||||
| previews, | |||||
| TruckLaneScheduleLineRequest( | |||||
| action = TruckLaneScheduleLineAction.MOVE, | |||||
| truckRowId = truckRowId, | |||||
| toTruckLanceCode = toKey.truckLanceCode, | |||||
| toRemark = toKey.remark, | |||||
| toStoreId = toKey.storeId, | |||||
| toLoadingSequence = row.loadingSequence.coerceAtLeast(0), | |||||
| toDistrictReference = row.districtReference, | |||||
| shopCode = row.shopCode, | |||||
| shopName = row.shopName, | |||||
| departureTime = departure, | |||||
| logisticId = row.logisticId, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| for (truck in currentBucket) { | |||||
| val truckRowId = truck.id ?: continue | |||||
| if (moveTruckIds.contains(truckRowId) || deleteTruckIds.contains(truckRowId)) { | |||||
| continue | |||||
| } | |||||
| val shopCodeKey = normalizeShopCodeKey(truck.shopCode) | |||||
| if (shopCodeKey.isNotEmpty() && excelShopCodesGlobal.contains(shopCodeKey)) { | |||||
| continue | |||||
| } | |||||
| if (!deleteTruckIds.add(truckRowId)) continue | |||||
| val fromKey = truckService.truckLaneBucketKeyOf(truck) | |||||
| addLine( | |||||
| lines, | |||||
| previews, | |||||
| TruckLaneScheduleLineRequest( | |||||
| action = TruckLaneScheduleLineAction.DELETE, | |||||
| truckRowId = truckRowId, | |||||
| toTruckLanceCode = fromKey.truckLanceCode, | |||||
| toRemark = fromKey.remark, | |||||
| toStoreId = fromKey.storeId, | |||||
| shopCode = truck.shopCode, | |||||
| shopName = truck.shopName, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| val counts = | |||||
| RouteExcelSchedulePlanCounts( | |||||
| moves = lines.count { it.action == TruckLaneScheduleLineAction.MOVE }, | |||||
| creates = lines.count { it.action == TruckLaneScheduleLineAction.CREATE }, | |||||
| deletes = lines.count { it.action == TruckLaneScheduleLineAction.DELETE }, | |||||
| ensureLanes = lines.count { it.action == TruckLaneScheduleLineAction.ENSURE_LANE }, | |||||
| ) | |||||
| val blocking = schedulePlanValidator.validatePlanLines(lines) | |||||
| return RouteExcelSchedulePlanResponse( | |||||
| sheetCount = if (sheetCount > 0) sheetCount else rowsByBucket.size, | |||||
| rowCount = rows.size, | |||||
| lines = lines, | |||||
| previews = previews, | |||||
| errors = errors, | |||||
| blockingErrors = blocking, | |||||
| canCreate = blocking.isEmpty() && errors.isEmpty(), | |||||
| counts = counts, | |||||
| ) | |||||
| } | |||||
| private fun emptyPlan(sheetCount: Int, rowCount: Int): RouteExcelSchedulePlanResponse { | |||||
| return RouteExcelSchedulePlanResponse( | |||||
| sheetCount = sheetCount, | |||||
| rowCount = rowCount, | |||||
| lines = emptyList(), | |||||
| previews = emptyList(), | |||||
| errors = emptyList(), | |||||
| blockingErrors = emptyList(), | |||||
| canCreate = true, | |||||
| counts = RouteExcelSchedulePlanCounts(0, 0, 0, 0), | |||||
| ) | |||||
| } | |||||
| private fun addLine( | |||||
| lines: MutableList<TruckLaneScheduleLineRequest>, | |||||
| previews: MutableList<RouteExcelSchedulePlanPreviewRow>, | |||||
| line: TruckLaneScheduleLineRequest, | |||||
| ) { | |||||
| lines.add(line) | |||||
| previews.add( | |||||
| RouteExcelSchedulePlanPreviewRow( | |||||
| action = line.action.name, | |||||
| truckRowId = line.truckRowId, | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| toTruckLanceCode = line.toTruckLanceCode, | |||||
| toRemark = line.toRemark, | |||||
| toStoreId = line.toStoreId, | |||||
| toLoadingSequence = line.toLoadingSequence, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| private fun defaultDepartureForRows(rows: List<RouteLaneImportPreviewRow>): LocalTime? { | |||||
| for (row in rows) { | |||||
| val dep = truckService.parseRouteDepartureTime(row.departureTime) | |||||
| if (dep != null) return dep | |||||
| } | |||||
| return null | |||||
| } | |||||
| private fun laneKeyString(key: TruckService.TruckLaneBucketKey): String { | |||||
| return "${key.truckLanceCode}|${key.storeId}|${key.remark ?: ""}" | |||||
| } | |||||
| private fun normalizeShopCodeKey(code: String?): String { | |||||
| return code?.trim()?.lowercase().orEmpty() | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,136 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineAction | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineRepository | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.SchedulePlanBlockingError | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneScheduleLineRequest | |||||
| import org.springframework.stereotype.Component | |||||
| @Component | |||||
| class TruckLaneSchedulePlanValidator( | |||||
| private val truckService: TruckService, | |||||
| private val scheduleLineRepository: TruckLaneScheduleLineRepository, | |||||
| ) { | |||||
| fun validatePlanLines(lines: List<TruckLaneScheduleLineRequest>): List<SchedulePlanBlockingError> { | |||||
| val errors = mutableListOf<SchedulePlanBlockingError>() | |||||
| errors.addAll(validateR1MoveDeleteOverlap(lines)) | |||||
| errors.addAll(validateR2DuplicateTruckRow(lines)) | |||||
| errors.addAll(validateR3OpenScheduleOverlap(lines)) | |||||
| errors.addAll(validateR4OrphanMoveDelete(lines)) | |||||
| return errors | |||||
| } | |||||
| private fun validateR1MoveDeleteOverlap( | |||||
| lines: List<TruckLaneScheduleLineRequest>, | |||||
| ): List<SchedulePlanBlockingError> { | |||||
| val moveIds = | |||||
| lines | |||||
| .filter { it.action == TruckLaneScheduleLineAction.MOVE } | |||||
| .mapNotNull { it.truckRowId } | |||||
| .toSet() | |||||
| val deleteIds = | |||||
| lines | |||||
| .filter { it.action == TruckLaneScheduleLineAction.DELETE } | |||||
| .mapNotNull { it.truckRowId } | |||||
| .toSet() | |||||
| return moveIds.intersect(deleteIds).map { id -> | |||||
| SchedulePlanBlockingError( | |||||
| code = "R1_MOVE_DELETE_OVERLAP", | |||||
| shopCode = lines.firstOrNull { it.truckRowId == id }?.shopCode, | |||||
| shopName = lines.firstOrNull { it.truckRowId == id }?.shopName, | |||||
| truckRowId = id, | |||||
| messageKey = "schedule.plan.r1_move_delete_overlap", | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun validateR2DuplicateTruckRow( | |||||
| lines: List<TruckLaneScheduleLineRequest>, | |||||
| ): List<SchedulePlanBlockingError> { | |||||
| val byTruck = | |||||
| lines | |||||
| .filter { it.truckRowId != null && it.action != TruckLaneScheduleLineAction.ENSURE_LANE } | |||||
| .groupBy { it.truckRowId!! } | |||||
| return byTruck | |||||
| .filter { (_, group) -> group.size > 1 } | |||||
| .flatMap { (id, group) -> | |||||
| group.map { | |||||
| SchedulePlanBlockingError( | |||||
| code = "R2_DUPLICATE_TRUCK_ROW", | |||||
| shopCode = it.shopCode, | |||||
| shopName = it.shopName, | |||||
| truckRowId = id, | |||||
| messageKey = "schedule.plan.r2_duplicate_truck_row", | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun validateR3OpenScheduleOverlap( | |||||
| lines: List<TruckLaneScheduleLineRequest>, | |||||
| ): List<SchedulePlanBlockingError> { | |||||
| val errors = mutableListOf<SchedulePlanBlockingError>() | |||||
| for (line in lines) { | |||||
| val truckRowId = line.truckRowId ?: continue | |||||
| if (scheduleLineRepository.countOpenPendingForTruckRow( | |||||
| truckRowId, | |||||
| TruckLaneScheduleLineStatus.PENDING, | |||||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||||
| null, | |||||
| ) > 0 | |||||
| ) { | |||||
| errors.add( | |||||
| SchedulePlanBlockingError( | |||||
| code = "R3_OPEN_SCHEDULE_OVERLAP", | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| truckRowId = truckRowId, | |||||
| messageKey = "schedule.plan.r3_open_schedule_overlap", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| return errors | |||||
| } | |||||
| private fun validateR4OrphanMoveDelete( | |||||
| lines: List<TruckLaneScheduleLineRequest>, | |||||
| ): List<SchedulePlanBlockingError> { | |||||
| val errors = mutableListOf<SchedulePlanBlockingError>() | |||||
| for (line in lines) { | |||||
| if (line.action != TruckLaneScheduleLineAction.MOVE && | |||||
| line.action != TruckLaneScheduleLineAction.DELETE | |||||
| ) { | |||||
| continue | |||||
| } | |||||
| val truckRowId = line.truckRowId | |||||
| if (truckRowId == null) { | |||||
| errors.add( | |||||
| SchedulePlanBlockingError( | |||||
| code = "R4_ORPHAN_LINE", | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| truckRowId = null, | |||||
| messageKey = "schedule.plan.r4_orphan_line", | |||||
| ), | |||||
| ) | |||||
| continue | |||||
| } | |||||
| val truck = truckService.findTruckById(truckRowId) | |||||
| if (truck == null) { | |||||
| errors.add( | |||||
| SchedulePlanBlockingError( | |||||
| code = "R4_TRUCK_NOT_FOUND", | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| truckRowId = truckRowId, | |||||
| messageKey = "schedule.plan.r4_truck_not_found", | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| return errors | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,575 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.service | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.* | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import org.springframework.http.HttpStatus | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.time.LocalDateTime | |||||
| @Service | |||||
| open class TruckLaneScheduleService( | |||||
| private val scheduleRepository: TruckLaneScheduleRepository, | |||||
| private val scheduleLineRepository: TruckLaneScheduleLineRepository, | |||||
| private val truckService: TruckService, | |||||
| private val excelSupport: TruckLaneScheduleExcelSupport, | |||||
| private val scheduleApplier: TruckLaneScheduleApplier, | |||||
| private val schedulePlanService: TruckLaneSchedulePlanService, | |||||
| private val schedulePlanValidator: TruckLaneSchedulePlanValidator, | |||||
| ) { | |||||
| @Transactional | |||||
| open fun createManual(request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | |||||
| validateExecuteAt(request.executeAt) | |||||
| val requestLines = resolveRequestLines(request) | |||||
| if (requestLines.isEmpty()) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "lines 不可為空") | |||||
| } | |||||
| val blocking = schedulePlanValidator.validatePlanLines(requestLines) | |||||
| if (blocking.isNotEmpty()) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| blocking.joinToString("; ") { "${it.code}:${it.messageKey}" }, | |||||
| ) | |||||
| } | |||||
| val ensuredLaneKeys = | |||||
| requestLines | |||||
| .filter { it.action == TruckLaneScheduleLineAction.ENSURE_LANE } | |||||
| .map { toBucketKey(it) } | |||||
| .toSet() | |||||
| val schedule = TruckLaneSchedule().apply { | |||||
| executeAt = request.executeAt | |||||
| status = TruckLaneScheduleStatus.PENDING | |||||
| source = TruckLaneScheduleSource.MANUAL | |||||
| note = request.note?.trim() | |||||
| } | |||||
| val lines = mutableListOf<TruckLaneScheduleLine>() | |||||
| for (req in requestLines) { | |||||
| lines.add(buildScheduleLine(req, ensuredLaneKeys)) | |||||
| } | |||||
| val savedHeader = scheduleRepository.save(schedule) | |||||
| for (line in lines) { | |||||
| line.schedule = savedHeader | |||||
| scheduleLineRepository.save(line) | |||||
| } | |||||
| return toResponse(savedHeader, includeLines = true) | |||||
| } | |||||
| open fun planFromRouteExcel(workbook: org.apache.poi.ss.usermodel.Workbook?): RouteExcelSchedulePlanResponse { | |||||
| return schedulePlanService.planFromRouteExcel(workbook) | |||||
| } | |||||
| @Transactional | |||||
| open fun createFromExcelRows( | |||||
| rows: List<TruckLaneScheduleExcelPreviewRow>, | |||||
| defaultExecuteAt: LocalDateTime, | |||||
| note: String?, | |||||
| ): List<TruckLaneScheduleResponse> { | |||||
| if (rows.isEmpty()) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無有效資料列") | |||||
| } | |||||
| val grouped = rows.groupBy { it.executeAt ?: defaultExecuteAt } | |||||
| return grouped.map { (executeAt, group) -> | |||||
| createManual( | |||||
| CreateTruckLaneScheduleRequest( | |||||
| executeAt = executeAt, | |||||
| note = note, | |||||
| lines = group.mapNotNull { r -> | |||||
| val id = r.truckRowId ?: return@mapNotNull null | |||||
| val storeNorm = truckService.normalizeRouteStoreId(r.toStoreId) | |||||
| val toKey = TruckService.TruckLaneBucketKey( | |||||
| truckLanceCode = r.toTruckLanceCode.trim(), | |||||
| storeId = storeNorm, | |||||
| remark = truckService.bucketRemarkForStore(storeNorm, r.toRemark), | |||||
| ) | |||||
| TruckLaneScheduleLineRequest( | |||||
| action = TruckLaneScheduleLineAction.MOVE, | |||||
| truckRowId = id, | |||||
| toTruckLanceCode = r.toTruckLanceCode, | |||||
| toRemark = r.toRemark, | |||||
| toStoreId = r.toStoreId, | |||||
| toLoadingSequence = defaultLoadingSequenceForMove(id, toKey), | |||||
| ) | |||||
| }, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| open fun list( | |||||
| statuses: List<TruckLaneScheduleStatus>?, | |||||
| limit: Int = 50, | |||||
| ): List<TruckLaneScheduleResponse> { | |||||
| val list = | |||||
| if (statuses.isNullOrEmpty()) { | |||||
| scheduleRepository.findAllByDeletedFalseAndStatusInOrderByExecuteAtDesc( | |||||
| TruckLaneScheduleStatus.entries.toList(), | |||||
| ) | |||||
| } else { | |||||
| scheduleRepository.findAllByDeletedFalseAndStatusInOrderByExecuteAtDesc(statuses) | |||||
| } | |||||
| val taken = list.take(limit) | |||||
| val ids = taken.mapNotNull { it.id } | |||||
| val linesByScheduleId = | |||||
| if (ids.isEmpty()) { | |||||
| emptyMap() | |||||
| } else { | |||||
| scheduleLineRepository | |||||
| .findAllBySchedule_IdInAndDeletedFalseOrderBySchedule_IdAscIdAsc(ids) | |||||
| .groupBy { it.schedule?.id ?: 0L } | |||||
| } | |||||
| return taken.map { s -> | |||||
| toResponse( | |||||
| s, | |||||
| includeLines = false, | |||||
| preloadedLines = linesByScheduleId[s.id ?: 0L], | |||||
| ) | |||||
| } | |||||
| } | |||||
| open fun getById(id: Long): TruckLaneScheduleResponse { | |||||
| val s = scheduleRepository.findByIdAndDeletedFalse(id) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在") | |||||
| return toResponse(s, includeLines = true) | |||||
| } | |||||
| open fun pendingTruckRowIds(): PendingTruckRowIdsResponse { | |||||
| return PendingTruckRowIdsResponse( | |||||
| scheduleLineRepository.findPendingTruckRowIds( | |||||
| TruckLaneScheduleLineStatus.PENDING, | |||||
| listOf(TruckLaneScheduleStatus.PENDING, TruckLaneScheduleStatus.APPLYING), | |||||
| ), | |||||
| ) | |||||
| } | |||||
| @Transactional | |||||
| open fun cancel(id: Long): TruckLaneScheduleResponse { | |||||
| val s = scheduleRepository.findByIdAndDeletedFalse(id) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在") | |||||
| if (s.status != TruckLaneScheduleStatus.PENDING) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "僅 PENDING 可取消") | |||||
| } | |||||
| s.status = TruckLaneScheduleStatus.CANCELLED | |||||
| val lineList = scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(s.id ?: 0L) | |||||
| lineList.forEach { it.lineStatus = TruckLaneScheduleLineStatus.SKIPPED } | |||||
| scheduleLineRepository.saveAll(lineList) | |||||
| return toResponse(scheduleRepository.save(s), includeLines = true) | |||||
| } | |||||
| open fun applyNow(id: Long): TruckLaneScheduleResponse { | |||||
| return when (scheduleApplier.applyOne(id)) { | |||||
| ScheduleApplyOutcome.NOT_FOUND -> | |||||
| throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在") | |||||
| ScheduleApplyOutcome.SKIPPED -> { | |||||
| val current = scheduleRepository.findByIdAndDeletedFalse(id) | |||||
| val msg = | |||||
| when (current?.status) { | |||||
| TruckLaneScheduleStatus.APPLYING -> | |||||
| "排程正在執行中,請稍後再試" | |||||
| TruckLaneScheduleStatus.PENDING -> | |||||
| "無法認領排程(可能已被其他程序處理)" | |||||
| else -> "僅 PENDING 可立即執行" | |||||
| } | |||||
| throw ResponseStatusException(HttpStatus.CONFLICT, msg) | |||||
| } | |||||
| ScheduleApplyOutcome.FAILED, | |||||
| ScheduleApplyOutcome.APPLIED, | |||||
| -> getById(id) | |||||
| } | |||||
| } | |||||
| @Transactional | |||||
| open fun retryFailed( | |||||
| id: Long, | |||||
| executeAt: LocalDateTime?, | |||||
| ): TruckLaneScheduleResponse { | |||||
| val s = scheduleRepository.findByIdAndDeletedFalse(id) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在") | |||||
| if (s.status == TruckLaneScheduleStatus.PARTIAL) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "PARTIAL 排程不可重試,請先還原看板後重新建立排程", | |||||
| ) | |||||
| } | |||||
| if (s.status != TruckLaneScheduleStatus.FAILED) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "僅 FAILED 可重試失敗列") | |||||
| } | |||||
| val lines = | |||||
| scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(s.id ?: 0L) | |||||
| val retryLines = | |||||
| lines.filter { | |||||
| it.lineStatus == TruckLaneScheduleLineStatus.FAILED || | |||||
| it.lineStatus == TruckLaneScheduleLineStatus.PENDING | |||||
| } | |||||
| if (retryLines.isEmpty()) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無可重試的失敗列") | |||||
| } | |||||
| val whenAt = executeAt ?: LocalDateTime.now().plusMinutes(1) | |||||
| validateExecuteAt(whenAt) | |||||
| val requestLines = | |||||
| retryLines.mapNotNull { line -> lineToRequest(line) } | |||||
| if (requestLines.isEmpty()) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無可重試的失敗列") | |||||
| } | |||||
| return createManual( | |||||
| CreateTruckLaneScheduleRequest( | |||||
| executeAt = whenAt, | |||||
| note = "retry from schedule #$id", | |||||
| lines = requestLines, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| @Transactional | |||||
| open fun ignore(id: Long): TruckLaneScheduleResponse { | |||||
| val s = scheduleRepository.findByIdAndDeletedFalse(id) | |||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "排程不存在") | |||||
| if (s.status == TruckLaneScheduleStatus.IGNORED) { | |||||
| return toResponse(s, includeLines = true) | |||||
| } | |||||
| if (s.status == TruckLaneScheduleStatus.CANCELLED) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "已取消的排程無法忽略") | |||||
| } | |||||
| val lines = | |||||
| scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc(s.id ?: 0L) | |||||
| val failedLineCount = | |||||
| lines.count { it.lineStatus == TruckLaneScheduleLineStatus.FAILED } | |||||
| val canIgnore = | |||||
| s.status == TruckLaneScheduleStatus.FAILED || | |||||
| s.status == TruckLaneScheduleStatus.PARTIAL || | |||||
| failedLineCount > 0 | |||||
| if (!canIgnore) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "僅含失敗明細的排程可忽略") | |||||
| } | |||||
| s.status = TruckLaneScheduleStatus.IGNORED | |||||
| return toResponse(scheduleRepository.save(s), includeLines = true) | |||||
| } | |||||
| open fun applyDueSchedules(batchLimit: Int = 20) { | |||||
| reaperStaleApplying() | |||||
| val due = scheduleRepository.findDuePending( | |||||
| LocalDateTime.now(), | |||||
| TruckLaneScheduleStatus.PENDING, | |||||
| ).take(batchLimit) | |||||
| for (schedule in due) { | |||||
| val scheduleId = schedule.id ?: continue | |||||
| try { | |||||
| scheduleApplier.applyOne(scheduleId) | |||||
| } catch (e: Exception) { | |||||
| scheduleApplier.markFailed(scheduleId, e) | |||||
| } | |||||
| } | |||||
| } | |||||
| @Transactional | |||||
| open fun reaperStaleApplying(staleMinutes: Long = 10) { | |||||
| val staleBefore = LocalDateTime.now().minusMinutes(staleMinutes) | |||||
| val stale = | |||||
| scheduleRepository.findStaleApplying( | |||||
| TruckLaneScheduleStatus.APPLYING, | |||||
| staleBefore, | |||||
| ) | |||||
| for (schedule in stale) { | |||||
| schedule.status = TruckLaneScheduleStatus.FAILED | |||||
| schedule.errorMessage = | |||||
| "排程執行逾時(>${staleMinutes} 分鐘仍為 APPLYING),請依 runbook 手動還原".take(2000) | |||||
| scheduleRepository.save(schedule) | |||||
| } | |||||
| } | |||||
| open fun parseExcel( | |||||
| workbook: org.apache.poi.ss.usermodel.Workbook?, | |||||
| defaultExecuteAt: LocalDateTime?, | |||||
| ): ParseTruckLaneScheduleExcelResponse { | |||||
| val (rows, errors) = excelSupport.parseWorkbook(workbook, defaultExecuteAt) | |||||
| return ParseTruckLaneScheduleExcelResponse( | |||||
| rowCount = rows.size + errors.size, | |||||
| validCount = rows.size, | |||||
| errorCount = errors.size, | |||||
| rows = rows, | |||||
| errors = errors, | |||||
| ) | |||||
| } | |||||
| private fun resolveRequestLines(request: CreateTruckLaneScheduleRequest): List<TruckLaneScheduleLineRequest> { | |||||
| if (!request.lines.isNullOrEmpty()) return request.lines | |||||
| return request.moves.orEmpty().map { move -> | |||||
| TruckLaneScheduleLineRequest( | |||||
| action = TruckLaneScheduleLineAction.MOVE, | |||||
| truckRowId = move.truckRowId, | |||||
| toTruckLanceCode = move.toTruckLanceCode, | |||||
| toRemark = move.toRemark, | |||||
| toStoreId = move.toStoreId, | |||||
| toLoadingSequence = move.toLoadingSequence, | |||||
| toDistrictReference = move.toDistrictReference, | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun buildScheduleLine( | |||||
| req: TruckLaneScheduleLineRequest, | |||||
| ensuredLaneKeys: Set<TruckService.TruckLaneBucketKey>, | |||||
| ): TruckLaneScheduleLine { | |||||
| val toKey = toBucketKey(req) | |||||
| val allowEmptyTarget = ensuredLaneKeys.contains(toKey) | |||||
| return when (req.action) { | |||||
| TruckLaneScheduleLineAction.ENSURE_LANE -> { | |||||
| val bucket = truckService.trucksInLaneBucket(toKey) | |||||
| if (bucket.isNotEmpty()) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "車線 ${toKey.truckLanceCode} 已存在,無需 ENSURE_LANE", | |||||
| ) | |||||
| } | |||||
| val departure = req.departureTime | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "ENSURE_LANE 缺少 departureTime") | |||||
| TruckLaneScheduleLine().apply { | |||||
| lineAction = TruckLaneScheduleLineAction.ENSURE_LANE | |||||
| toTruckLanceCode = toKey.truckLanceCode | |||||
| toRemark = toKey.remark | |||||
| toStoreId = toKey.storeId | |||||
| toLoadingSequence = req.toLoadingSequence ?: 0 | |||||
| this.departureTime = departure | |||||
| logisticId = req.logisticId | |||||
| lineStatus = TruckLaneScheduleLineStatus.PENDING | |||||
| } | |||||
| } | |||||
| TruckLaneScheduleLineAction.CREATE -> { | |||||
| val shopId = req.shopId | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "CREATE 缺少 shopId") | |||||
| val departure = req.departureTime | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "CREATE 缺少 departureTime") | |||||
| val bucket = truckService.trucksInLaneBucket(toKey) | |||||
| if (bucket.isEmpty() && !allowEmptyTarget) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "目標車線 ${toKey.truckLanceCode} 不存在,需先 ENSURE_LANE", | |||||
| ) | |||||
| } | |||||
| val seq = req.toLoadingSequence ?: 0 | |||||
| TruckLaneScheduleLine().apply { | |||||
| lineAction = TruckLaneScheduleLineAction.CREATE | |||||
| this.shopId = shopId | |||||
| shopCode = req.shopCode | |||||
| shopName = req.shopName | |||||
| toTruckLanceCode = toKey.truckLanceCode | |||||
| toRemark = toKey.remark | |||||
| toStoreId = toKey.storeId | |||||
| toLoadingSequence = seq | |||||
| toDistrictReference = req.toDistrictReference?.trim()?.takeIf { it.isNotEmpty() } | |||||
| this.departureTime = departure | |||||
| logisticId = req.logisticId | |||||
| lineStatus = TruckLaneScheduleLineStatus.PENDING | |||||
| } | |||||
| } | |||||
| TruckLaneScheduleLineAction.DELETE -> { | |||||
| val truckRowId = req.truckRowId | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "DELETE 缺少 truckRowId") | |||||
| assertNoOpenPending(truckRowId) | |||||
| val truck = truckService.findTruckById(truckRowId) | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "找不到 truck #$truckRowId") | |||||
| if (truckService.isPlaceholderTruckRow(truck)) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "無法刪除空白占位列") | |||||
| } | |||||
| val fromKey = truckService.truckLaneBucketKeyOf(truck) | |||||
| TruckLaneScheduleLine().apply { | |||||
| lineAction = TruckLaneScheduleLineAction.DELETE | |||||
| this.truckRowId = truckRowId | |||||
| shopCode = truck.shopCode | |||||
| shopName = truck.shopName | |||||
| fromTruckLanceCode = fromKey.truckLanceCode | |||||
| fromRemark = fromKey.remark | |||||
| fromStoreId = fromKey.storeId | |||||
| toTruckLanceCode = fromKey.truckLanceCode | |||||
| toRemark = fromKey.remark | |||||
| toStoreId = fromKey.storeId | |||||
| lineStatus = TruckLaneScheduleLineStatus.PENDING | |||||
| } | |||||
| } | |||||
| TruckLaneScheduleLineAction.MOVE -> { | |||||
| val truckRowId = req.truckRowId | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "MOVE 缺少 truckRowId") | |||||
| assertNoOpenPending(truckRowId) | |||||
| val truck = truckService.findTruckById(truckRowId) | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "找不到 truck #$truckRowId") | |||||
| val fromKey = truckService.truckLaneBucketKeyOf(truck) | |||||
| val err = truckService.validateMoveToLane(truckRowId, toKey, allowEmptyTarget) | |||||
| if (err != null) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "truck #$truckRowId: $err") | |||||
| } | |||||
| val seq = req.toLoadingSequence | |||||
| ?: throw ResponseStatusException(HttpStatus.BAD_REQUEST, "MOVE 缺少 toLoadingSequence") | |||||
| TruckLaneScheduleLine().apply { | |||||
| lineAction = TruckLaneScheduleLineAction.MOVE | |||||
| this.truckRowId = truckRowId | |||||
| shopCode = truck.shopCode | |||||
| shopName = truck.shopName | |||||
| fromTruckLanceCode = fromKey.truckLanceCode | |||||
| fromRemark = fromKey.remark | |||||
| fromStoreId = fromKey.storeId | |||||
| toTruckLanceCode = toKey.truckLanceCode | |||||
| toRemark = toKey.remark | |||||
| toStoreId = toKey.storeId | |||||
| toDistrictReference = | |||||
| req.toDistrictReference?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: truck.districtReference | |||||
| toLoadingSequence = seq | |||||
| departureTime = req.departureTime | |||||
| logisticId = req.logisticId | |||||
| lineStatus = TruckLaneScheduleLineStatus.PENDING | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun assertNoOpenPending(truckRowId: Long) { | |||||
| if (scheduleLineRepository.countOpenPendingForTruckRow( | |||||
| truckRowId, | |||||
| TruckLaneScheduleLineStatus.PENDING, | |||||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||||
| null, | |||||
| ) > 0 | |||||
| ) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "truck #$truckRowId 已有待執行排程", | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun lineToRequest(line: TruckLaneScheduleLine): TruckLaneScheduleLineRequest? { | |||||
| val action = line.lineAction | |||||
| val toCode = line.toTruckLanceCode?.trim().orEmpty() | |||||
| val toStore = line.toStoreId?.trim().orEmpty() | |||||
| if (toCode.isEmpty() || toStore.isEmpty()) return null | |||||
| return TruckLaneScheduleLineRequest( | |||||
| action = action, | |||||
| truckRowId = line.truckRowId, | |||||
| toTruckLanceCode = toCode, | |||||
| toRemark = line.toRemark, | |||||
| toStoreId = toStore, | |||||
| toLoadingSequence = line.toLoadingSequence, | |||||
| toDistrictReference = line.toDistrictReference, | |||||
| shopId = line.shopId, | |||||
| shopCode = line.shopCode, | |||||
| shopName = line.shopName, | |||||
| departureTime = line.departureTime, | |||||
| logisticId = line.logisticId, | |||||
| ) | |||||
| } | |||||
| private fun toBucketKey(req: TruckLaneScheduleLineRequest): TruckService.TruckLaneBucketKey { | |||||
| val storeNorm = truckService.normalizeRouteStoreId(req.toStoreId) | |||||
| return TruckService.TruckLaneBucketKey( | |||||
| truckLanceCode = req.toTruckLanceCode.trim(), | |||||
| storeId = storeNorm, | |||||
| remark = truckService.bucketRemarkForStore(storeNorm, req.toRemark), | |||||
| ) | |||||
| } | |||||
| private fun validateExecuteAt(executeAt: LocalDateTime) { | |||||
| val min = LocalDateTime.now().minusMinutes(2) | |||||
| if (executeAt.isBefore(min)) { | |||||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "執行時間不可早於目前時間") | |||||
| } | |||||
| } | |||||
| private fun toResponse( | |||||
| s: TruckLaneSchedule, | |||||
| includeLines: Boolean, | |||||
| preloadedLines: List<TruckLaneScheduleLine>? = null, | |||||
| ): TruckLaneScheduleResponse { | |||||
| val lines = | |||||
| preloadedLines | |||||
| ?: scheduleLineRepository.findAllBySchedule_IdAndDeletedFalseOrderByIdAsc( | |||||
| s.id ?: 0L, | |||||
| ) | |||||
| val counts = if (lines.isNotEmpty()) { | |||||
| TruckLaneScheduleLineCounts( | |||||
| total = lines.size, | |||||
| applied = lines.count { it.lineStatus == TruckLaneScheduleLineStatus.APPLIED }, | |||||
| failed = lines.count { it.lineStatus == TruckLaneScheduleLineStatus.FAILED }, | |||||
| pending = lines.count { it.lineStatus == TruckLaneScheduleLineStatus.PENDING }, | |||||
| ) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| return TruckLaneScheduleResponse( | |||||
| id = s.id ?: 0L, | |||||
| executeAt = s.executeAt?.toString().orEmpty(), | |||||
| status = s.status.name, | |||||
| source = s.source.name, | |||||
| note = s.note, | |||||
| appliedAt = s.appliedAt?.toString(), | |||||
| errorMessage = s.errorMessage, | |||||
| snapshotVersionId = s.snapshotVersionId, | |||||
| preApplySnapshotVersionId = s.preApplySnapshotVersionId, | |||||
| created = s.created?.toString(), | |||||
| createdBy = s.createdBy, | |||||
| modifiedBy = s.modifiedBy, | |||||
| lines = if (includeLines) lines.map { toLineResponse(it) } else null, | |||||
| lineCounts = counts, | |||||
| ) | |||||
| } | |||||
| private fun defaultLoadingSequenceForMove( | |||||
| truckRowId: Long, | |||||
| to: TruckService.TruckLaneBucketKey, | |||||
| ): Int { | |||||
| val truck = truckService.findTruckById(truckRowId) | |||||
| val district = truck?.districtReference?.trim().orEmpty() | |||||
| val bucket = truckService.trucksInLaneBucket(to) | |||||
| val maxSeq = | |||||
| bucket | |||||
| .filter { t -> | |||||
| val d = t.districtReference?.trim().orEmpty() | |||||
| district.isEmpty() || d == district | |||||
| } | |||||
| .maxOfOrNull { it.loadingSequence ?: 0 } ?: 0 | |||||
| return maxSeq + 1 | |||||
| } | |||||
| private fun toLineResponse(l: TruckLaneScheduleLine): TruckLaneScheduleLineResponse { | |||||
| var fromLoadingSequence: Int? = null | |||||
| var fromDistrictReference: String? = null | |||||
| var fromDepartureTime: String? = null | |||||
| val truckRowId = l.truckRowId | |||||
| if (l.lineStatus == TruckLaneScheduleLineStatus.PENDING && truckRowId != null) { | |||||
| truckService.findTruckById(truckRowId)?.let { truck -> | |||||
| fromLoadingSequence = truck.loadingSequence | |||||
| fromDistrictReference = truck.districtReference | |||||
| fromDepartureTime = truck.departureTime?.toString() | |||||
| } | |||||
| } | |||||
| return TruckLaneScheduleLineResponse( | |||||
| id = l.id ?: 0L, | |||||
| action = l.lineAction.name, | |||||
| truckRowId = l.truckRowId, | |||||
| shopCode = l.shopCode, | |||||
| shopName = l.shopName, | |||||
| fromTruckLanceCode = l.fromTruckLanceCode, | |||||
| fromRemark = l.fromRemark, | |||||
| fromStoreId = l.fromStoreId, | |||||
| fromLoadingSequence = fromLoadingSequence, | |||||
| fromDistrictReference = fromDistrictReference, | |||||
| fromDepartureTime = fromDepartureTime, | |||||
| toTruckLanceCode = l.toTruckLanceCode.orEmpty(), | |||||
| toRemark = l.toRemark, | |||||
| toStoreId = l.toStoreId.orEmpty(), | |||||
| toDistrictReference = l.toDistrictReference, | |||||
| toLoadingSequence = l.toLoadingSequence, | |||||
| departureTime = l.departureTime?.toString(), | |||||
| lineStatus = l.lineStatus.name, | |||||
| errorMessage = l.errorMessage, | |||||
| appliedAt = l.appliedAt?.toString(), | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -23,6 +23,7 @@ open class TruckLaneVersionService( | |||||
| truckLanceCode = v.truckLanceCode ?: "", | truckLanceCode = v.truckLanceCode ?: "", | ||||
| note = v.note, | note = v.note, | ||||
| created = v.created?.toString(), | created = v.created?.toString(), | ||||
| createdBy = v.createdBy, | |||||
| modifiedBy = v.modifiedBy, | modifiedBy = v.modifiedBy, | ||||
| ) | ) | ||||
| @@ -75,6 +76,13 @@ open class TruckLaneVersionService( | |||||
| truckLaneVersionLineRepository.saveAll(lines) | 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) | return toResponse(savedVersion) | ||||
| } | } | ||||
| @@ -231,7 +239,7 @@ open class TruckLaneVersionService( | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| open fun restore(versionId: Long): String { | |||||
| open fun restore(versionId: Long, skipPostSnapshot: Boolean = false): String { | |||||
| val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | ||||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $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 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 updated = lines.mapNotNull { line -> | ||||
| val truckId = line.truckRowId ?: return@mapNotNull null | val truckId = line.truckRowId ?: return@mapNotNull null | ||||
| if (truckId <= 0) return@mapNotNull null | if (truckId <= 0) return@mapNotNull null | ||||
| @@ -286,7 +299,7 @@ open class TruckLaneVersionService( | |||||
| val lid = line.logisticId | val lid = line.logisticId | ||||
| this.logistic = | this.logistic = | ||||
| if (lid != null && lid > 0) { | if (lid != null && lid > 0) { | ||||
| logisticRepository.findByIdAndDeletedFalse(lid) | |||||
| logisticsById[lid] | |||||
| } else { | } else { | ||||
| null | null | ||||
| } | } | ||||
| @@ -296,12 +309,14 @@ open class TruckLaneVersionService( | |||||
| truckRepository.saveAll(updated) | 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" | return "Restored versionId=$versionId" | ||||
| } | } | ||||
| @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service | |||||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | import com.ffii.fpsms.modules.logistic.entity.Logistic | ||||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | 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.TruckLaneVersionLineRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository | import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | 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.master.entity.projections.ShopAndTruck | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | ||||
| import jakarta.transaction.Transactional | import jakarta.transaction.Transactional | ||||
| import org.springframework.http.HttpStatus | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.text.Collator | import java.text.Collator | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| @@ -41,8 +45,44 @@ open class TruckService( | |||||
| private val logisticRepository: LogisticRepository, | private val logisticRepository: LogisticRepository, | ||||
| private val truckLaneVersionRepository: TruckLaneVersionRepository, | private val truckLaneVersionRepository: TruckLaneVersionRepository, | ||||
| private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | ||||
| private val scheduleLineRepository: TruckLaneScheduleLineRepository, | |||||
| ) : AbstractBaseEntityService<Truck, Long, TruckRepository>(jdbcDao, truckRepository) { | ) : AbstractBaseEntityService<Truck, Long, TruckRepository>(jdbcDao, truckRepository) { | ||||
| private fun assertNotScheduleLocked(truckRowId: Long?) { | |||||
| if (truckRowId == null) return | |||||
| if (scheduleLineRepository.countOpenPendingForTruckRow( | |||||
| truckRowId, | |||||
| TruckLaneScheduleLockSupport.pendingLineStatus, | |||||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | |||||
| ) > 0 | |||||
| ) { | |||||
| throw ResponseStatusException(HttpStatus.CONFLICT, "SCHEDULE_LOCKED") | |||||
| } | |||||
| } | |||||
| private fun assertNotScheduleLocked(bucket: TruckLaneBucketKey) { | |||||
| if (scheduleLineRepository.existsOpenScheduleForLaneBucket( | |||||
| bucket.truckLanceCode.trim(), | |||||
| bucket.storeId.trim(), | |||||
| TruckLaneScheduleLockSupport.pendingLineStatus, | |||||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | |||||
| ) | |||||
| ) { | |||||
| throw ResponseStatusException(HttpStatus.CONFLICT, "SCHEDULE_LOCKED") | |||||
| } | |||||
| } | |||||
| open fun toBucketKey(line: TruckLaneScheduleLine): TruckLaneBucketKey { | |||||
| val storeNorm = normalizeRouteStoreId(line.toStoreId) | |||||
| return TruckLaneBucketKey( | |||||
| truckLanceCode = line.toTruckLanceCode?.trim().orEmpty(), | |||||
| storeId = storeNorm, | |||||
| remark = bucketRemarkForStore(storeNorm, line.toRemark), | |||||
| ) | |||||
| } | |||||
| private fun logisticRefOrNull(id: Long?): Logistic? { | private fun logisticRefOrNull(id: Long?): Logistic? { | ||||
| if (id == null) return null | if (id == null) return null | ||||
| return logisticRepository.findById(id).orElseThrow { | return logisticRepository.findById(id).orElseThrow { | ||||
| @@ -50,6 +90,7 @@ open class TruckService( | |||||
| } | } | ||||
| } | } | ||||
| open fun saveTruck(request: SaveTruckRequest): Truck { | open fun saveTruck(request: SaveTruckRequest): Truck { | ||||
| assertNotScheduleLocked(request.id) | |||||
| val truck = request.id?.let { | val truck = request.id?.let { | ||||
| truckRepository.findById(it).orElse(null) | truckRepository.findById(it).orElse(null) | ||||
| } ?: Truck() | } ?: Truck() | ||||
| @@ -406,8 +447,240 @@ open class TruckService( | |||||
| return truckRepository.findByShopIdAndDeletedFalse(shopId) | return truckRepository.findByShopIdAndDeletedFalse(shopId) | ||||
| } | } | ||||
| open fun findTruckById(id: Long): Truck? { | |||||
| val t = truckRepository.findById(id).orElse(null) ?: return null | |||||
| return if (t.deleted == true) null else t | |||||
| } | |||||
| open fun findTruckRowByIdIncludingDeleted(id: Long): Truck? { | |||||
| return truckRepository.findById(id).orElse(null) | |||||
| } | |||||
| @Transactional | |||||
| open fun restoreTruckRowIfDeleted(truck: Truck): Truck { | |||||
| if (truck.deleted != true) return truck | |||||
| truck.deleted = false | |||||
| return truckRepository.save(truck) | |||||
| } | |||||
| data class TruckLaneBucketKey( | |||||
| val truckLanceCode: String, | |||||
| val storeId: String, | |||||
| val remark: String?, | |||||
| ) | |||||
| open fun normalizeRouteStoreId(storeId: String?): String { | |||||
| val s = storeId?.trim()?.uppercase().orEmpty() | |||||
| return if (s == "4F") "4F" else "2F" | |||||
| } | |||||
| open fun bucketRemarkForStore(storeId: String, remark: String?): String? { | |||||
| return if (normalizeRouteStoreId(storeId) == "4F") { | |||||
| remark?.trim()?.takeIf { it.isNotEmpty() } | |||||
| } else { | |||||
| null | |||||
| } | |||||
| } | |||||
| open fun trucksInLaneBucket(key: TruckLaneBucketKey): List<Truck> { | |||||
| return trucksInSameLaneBucket( | |||||
| key.truckLanceCode.trim(), | |||||
| key.storeId, | |||||
| key.remark, | |||||
| ) | |||||
| } | |||||
| open fun truckLaneBucketKeyOf(truck: Truck): TruckLaneBucketKey { | |||||
| val store = normalizeRouteStoreId(truck.storeId) | |||||
| return TruckLaneBucketKey( | |||||
| truckLanceCode = truck.truckLanceCode?.trim().orEmpty(), | |||||
| storeId = store, | |||||
| remark = bucketRemarkForStore(store, truck.remark), | |||||
| ) | |||||
| } | |||||
| open fun laneHasShopConflict( | |||||
| shop: Truck, | |||||
| bucket: List<Truck>, | |||||
| excludeTruckRowId: Long?, | |||||
| ): Boolean { | |||||
| val movingShopId = shop.shop?.id | |||||
| val movingCode = shop.shopCode?.trim()?.lowercase().orEmpty() | |||||
| for (s in bucket) { | |||||
| if (excludeTruckRowId != null && s.id == excludeTruckRowId) continue | |||||
| if (isLanePlaceholderTruck(s)) continue | |||||
| val otherShopId = s.shop?.id | |||||
| if (movingShopId != null && otherShopId != null && movingShopId == otherShopId) { | |||||
| return true | |||||
| } | |||||
| val otherCode = s.shopCode?.trim()?.lowercase().orEmpty() | |||||
| if (movingCode.isNotEmpty() && otherCode.isNotEmpty() && movingCode == otherCode) { | |||||
| return true | |||||
| } | |||||
| } | |||||
| return false | |||||
| } | |||||
| open fun validateMoveToLane(truckRowId: Long, to: TruckLaneBucketKey): String? { | |||||
| return validateMoveToLane(truckRowId, to, allowEmptyTarget = false) | |||||
| } | |||||
| /** | |||||
| * @return null if valid, otherwise human-readable error (Traditional Chinese for UI). | |||||
| */ | |||||
| open fun validateMoveToLane( | |||||
| truckRowId: Long, | |||||
| to: TruckLaneBucketKey, | |||||
| allowEmptyTarget: Boolean, | |||||
| ): String? { | |||||
| val truck = truckRepository.findById(truckRowId).orElse(null) | |||||
| ?: return "找不到 truck 列 id=$truckRowId" | |||||
| if (truck.deleted == true) return "truck 列已刪除" | |||||
| if (isLanePlaceholderTruck(truck)) return "無法排程移動空白占位列" | |||||
| val toCode = to.truckLanceCode.trim() | |||||
| if (toCode.isEmpty()) return "目標車線編號不可為空" | |||||
| val fromKey = truckLaneBucketKeyOf(truck) | |||||
| val sameLane = | |||||
| fromKey.truckLanceCode == toCode && | |||||
| fromKey.storeId == to.storeId && | |||||
| (fromKey.remark ?: "") == (to.remark ?: "") | |||||
| if (sameLane) return null | |||||
| val targetBucket = trucksInLaneBucket(to) | |||||
| if (targetBucket.isEmpty()) { | |||||
| if (allowEmptyTarget) return null | |||||
| return "目標車線不存在或尚無任何列:$toCode" | |||||
| } | |||||
| if (laneHasShopConflict(truck, targetBucket, excludeTruckRowId = truckRowId)) { | |||||
| return "目標車線已有相同店鋪" | |||||
| } | |||||
| return null | |||||
| } | |||||
| open fun isPlaceholderTruckRow(truck: Truck): Boolean = isLanePlaceholderTruck(truck) | |||||
| open fun parseRouteDepartureTime(timeStr: String?): LocalTime? = parseDepartureTime(timeStr) | |||||
| open fun bucketKeyForRouteRow(row: RouteLaneImportPreviewRow): TruckLaneBucketKey { | |||||
| val storeNorm = normalizeRouteStoreId(row.storeId) | |||||
| return TruckLaneBucketKey( | |||||
| truckLanceCode = row.truckLanceCode.trim(), | |||||
| storeId = storeNorm, | |||||
| remark = bucketRemarkForStore(storeNorm, row.remark), | |||||
| ) | |||||
| } | |||||
| open fun truckNeedsMoveForRouteRow(truck: Truck, row: RouteLaneImportPreviewRow): Boolean { | |||||
| val toKey = bucketKeyForRouteRow(row) | |||||
| val currentKey = truckLaneBucketKeyOf(truck) | |||||
| val targetSeq = row.loadingSequence.coerceAtLeast(0) | |||||
| val targetDistrict = row.districtReference?.trim().orEmpty() | |||||
| val currentDistrict = truck.districtReference?.trim().orEmpty() | |||||
| val targetDeparture = parseDepartureTime(row.departureTime) | |||||
| val currentDeparture = truck.departureTime | |||||
| if (currentKey.truckLanceCode != toKey.truckLanceCode || | |||||
| currentKey.storeId != toKey.storeId || | |||||
| (currentKey.remark ?: "") != (toKey.remark ?: "") | |||||
| ) { | |||||
| return true | |||||
| } | |||||
| if ((truck.loadingSequence ?: 0) != targetSeq) return true | |||||
| if (currentDistrict != targetDistrict) return true | |||||
| if (targetDeparture != null && targetDeparture != currentDeparture) return true | |||||
| return false | |||||
| } | |||||
| open fun buildSaveTruckLaneForMove(truck: Truck, to: TruckLaneBucketKey): SaveTruckLane { | |||||
| val targetBucket = trucksInLaneBucket(to) | |||||
| val maxSeq = targetBucket | |||||
| .filter { !isLanePlaceholderTruck(it) } | |||||
| .maxOfOrNull { it.loadingSequence ?: 0 } ?: 0 | |||||
| return buildSaveTruckLaneForScheduledMove( | |||||
| truck, | |||||
| to, | |||||
| truck.districtReference, | |||||
| maxSeq + 1, | |||||
| ) | |||||
| } | |||||
| open fun buildSaveTruckLaneForScheduledMove( | |||||
| truck: Truck, | |||||
| to: TruckLaneBucketKey, | |||||
| districtReference: String?, | |||||
| loadingSequence: Int, | |||||
| ): SaveTruckLane { | |||||
| val targetBucket = trucksInLaneBucket(to) | |||||
| val template = | |||||
| targetBucket.firstOrNull { !isLanePlaceholderTruck(it) } | |||||
| ?: targetBucket.first() | |||||
| val storeNorm = normalizeRouteStoreId(to.storeId) | |||||
| val toRemark = | |||||
| if (storeNorm == "4F") to.remark?.trim()?.takeIf { it.isNotEmpty() } else null | |||||
| return SaveTruckLane( | |||||
| id = truck.id ?: 0L, | |||||
| truckLanceCode = to.truckLanceCode.trim(), | |||||
| departureTime = template.departureTime ?: LocalTime.of(0, 0), | |||||
| loadingSequence = loadingSequence.coerceAtLeast(0).toLong(), | |||||
| districtReference = districtReference ?: truck.districtReference, | |||||
| storeId = storeNorm, | |||||
| remark = toRemark, | |||||
| logisticId = template.logistic?.id, | |||||
| updateLogistic = true, | |||||
| ) | |||||
| } | |||||
| @Transactional | |||||
| open fun applyScheduledTruckMove( | |||||
| truckRowId: Long, | |||||
| to: TruckLaneBucketKey, | |||||
| districtReference: String? = null, | |||||
| loadingSequence: Int? = null, | |||||
| ): List<Truck> { | |||||
| assertNotScheduleLocked(truckRowId) | |||||
| var truck = findTruckRowByIdIncludingDeleted(truckRowId) | |||||
| ?: throw IllegalArgumentException("找不到 truck #$truckRowId") | |||||
| truck = restoreTruckRowIfDeleted(truck) | |||||
| val err = validateMoveToLane(truckRowId, to) | |||||
| if (err != null) throw IllegalArgumentException(err) | |||||
| val fromKey = truckLaneBucketKeyOf(truck) | |||||
| val sameLane = | |||||
| fromKey.truckLanceCode == to.truckLanceCode.trim() && | |||||
| fromKey.storeId == to.storeId && | |||||
| (fromKey.remark ?: "") == (to.remark ?: "") | |||||
| val seq = | |||||
| loadingSequence | |||||
| ?: run { | |||||
| val bucket = trucksInLaneBucket(to) | |||||
| (bucket.filter { !isLanePlaceholderTruck(it) } | |||||
| .maxOfOrNull { it.loadingSequence ?: 0 } ?: 0) + 1 | |||||
| } | |||||
| val district = districtReference ?: truck.districtReference | |||||
| if (sameLane) { | |||||
| val currentSeq = truck.loadingSequence ?: 0 | |||||
| val currentDistrict = truck.districtReference?.trim().orEmpty() | |||||
| val nextDistrict = district?.trim().orEmpty() | |||||
| if (currentSeq == seq && currentDistrict == nextDistrict) { | |||||
| return listOf(truck) | |||||
| } | |||||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||||
| return updateTruckLaneByTruckLanceCode(request) | |||||
| } | |||||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||||
| return updateTruckLaneByTruckLanceCode(request) | |||||
| } | |||||
| @Transactional | @Transactional | ||||
| open fun updateTruckLaneByTruckLanceCode(request: SaveTruckLane): List<Truck> { | open fun updateTruckLaneByTruckLanceCode(request: SaveTruckLane): List<Truck> { | ||||
| assertNotScheduleLocked(request.id) | |||||
| val updateTruckLance = truckRepository.findById(request.id).orElseThrow() | val updateTruckLance = truckRepository.findById(request.id).orElseThrow() | ||||
| ?: throw IllegalArgumentException("Truck not found with truckLanceCode: $request.truckLanceCode") | ?: throw IllegalArgumentException("Truck not found with truckLanceCode: $request.truckLanceCode") | ||||
| @@ -431,6 +704,7 @@ open class TruckService( | |||||
| @Transactional | @Transactional | ||||
| open fun deleteById(id: Long): String { | open fun deleteById(id: Long): String { | ||||
| assertNotScheduleLocked(id) | |||||
| val deleteTruck = truckRepository.findById(id).orElseThrow().apply { | val deleteTruck = truckRepository.findById(id).orElseThrow().apply { | ||||
| deleted = true | deleted = true | ||||
| } | } | ||||
| @@ -440,6 +714,14 @@ open class TruckService( | |||||
| @Transactional | @Transactional | ||||
| open fun createTruckInShop(request: SaveTruckRequest): Truck { | 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) | val shop = shopRepository.findById(request.shopId).orElse(null) | ||||
| if (shop == null) { | if (shop == null) { | ||||
| throw IllegalArgumentException("Shop not found with id: ${request.shopId}") | throw IllegalArgumentException("Shop not found with id: ${request.shopId}") | ||||
| @@ -521,6 +803,7 @@ open class TruckService( | |||||
| @Transactional | @Transactional | ||||
| open fun updateTruckShopDetails(request: UpdateTruckShopDetailsRequest): Truck { | open fun updateTruckShopDetails(request: UpdateTruckShopDetailsRequest): Truck { | ||||
| assertNotScheduleLocked(request.id) | |||||
| val truck = truckRepository.findById(request.id).orElseThrow { | val truck = truckRepository.findById(request.id).orElseThrow { | ||||
| IllegalArgumentException("Truck not found with id: ${request.id}") | IllegalArgumentException("Truck not found with id: ${request.id}") | ||||
| } | } | ||||
| @@ -579,6 +862,14 @@ open class TruckService( | |||||
| @Transactional | @Transactional | ||||
| open fun createTruckWithoutShop(request: CreateTruckWithoutShopRequest): Truck { | 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( | val laneRows = trucksInSameLaneBucket( | ||||
| request.truckLanceCode, | request.truckLanceCode, | ||||
| request.store_id, | request.store_id, | ||||
| @@ -694,12 +985,7 @@ open class TruckService( | |||||
| ) | ) | ||||
| rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) | 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) | val segmentsForRows = sortDistrictSegmentsForPlateColumnExport(trucks) | ||||
| var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA | var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA | ||||
| @@ -716,6 +1002,8 @@ open class TruckService( | |||||
| dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue( | dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue( | ||||
| formatDepartureForExcel(t.departureTime), | formatDepartureForExcel(t.departureTime), | ||||
| ) | ) | ||||
| dataRow.createCell(RouteLaneExcelSupport.COL_LOADING_SEQUENCE) | |||||
| .setCellValue((t.loadingSequence ?: 0).toDouble()) | |||||
| } | } | ||||
| } | } | ||||
| RouteLaneExcelSupport.applyRouteLaneExportFinishing( | RouteLaneExcelSupport.applyRouteLaneExportFinishing( | ||||
| @@ -1258,12 +1546,7 @@ open class TruckService( | |||||
| rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault)) | rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault)) | ||||
| rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) | 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 | // segments by district changes in loading order | ||||
| val segments = ArrayList<List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>>() | val segments = ArrayList<List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>>() | ||||
| @@ -1299,6 +1582,8 @@ open class TruckService( | |||||
| dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "") | dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "") | ||||
| val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault | val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault | ||||
| dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(formatDepartureForExcel(dept)) | 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( | private fun appendVersionRouteReportSheet( | ||||
| wb: XSSFWorkbook, | wb: XSSFWorkbook, | ||||
| createdDate: String, | createdDate: String, | ||||
| @@ -1578,7 +1872,7 @@ open class TruckService( | |||||
| shopId = shop.id!!, | shopId = shop.id!!, | ||||
| shopName = shopName.ifEmpty { shop.name ?: "" }, | shopName = shopName.ifEmpty { shop.name ?: "" }, | ||||
| shopCode = normalizedShopCode, | shopCode = normalizedShopCode, | ||||
| loadingSequence = seq, | |||||
| loadingSequence = parseLoadingSequenceFromRouteRow(row, seq), | |||||
| districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict), | districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict), | ||||
| logisticId = logisticId, | logisticId = logisticId, | ||||
| ), | ), | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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.ParseRouteLanesExcelResponse | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.deleteTruckLane | 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.toLaneCombinationResponse | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.toMessageEntity | |||||
| import jakarta.validation.Valid | import jakarta.validation.Valid | ||||
| @RestController | @RestController | ||||
| @@ -50,7 +51,7 @@ open class TruckController( | |||||
| type = "truck", | type = "truck", | ||||
| message = if (truck.id != null) "Truck updated successfully" else "Truck created successfully", | message = if (truck.id != null) "Truck updated successfully" else "Truck created successfully", | ||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = truck | |||||
| entity = truck.toMessageEntity() | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -76,7 +77,7 @@ open class TruckController( | |||||
| type = "truck", | type = "truck", | ||||
| message = "Truck created successfully", | message = "Truck created successfully", | ||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = truck | |||||
| entity = truck.toMessageEntity() | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -427,7 +428,7 @@ open class TruckController( | |||||
| type = "truck", | type = "truck", | ||||
| message = "Truck shop details updated successfully", | message = "Truck shop details updated successfully", | ||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = truck | |||||
| entity = truck.toMessageEntity() | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -453,7 +454,7 @@ open class TruckController( | |||||
| type = "truck", | type = "truck", | ||||
| message = "Truck created successfully", | message = "Truck created successfully", | ||||
| errorPosition = null, | errorPosition = null, | ||||
| entity = truck | |||||
| entity = truck.toMessageEntity() | |||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -0,0 +1,162 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.service.TruckLaneScheduleService | |||||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | |||||
| import jakarta.servlet.http.HttpServletRequest | |||||
| import jakarta.validation.Valid | |||||
| import org.apache.poi.ss.usermodel.Workbook | |||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| import org.springframework.beans.factory.annotation.Autowired | |||||
| import org.springframework.format.annotation.DateTimeFormat | |||||
| import org.springframework.http.ResponseEntity | |||||
| import org.springframework.security.access.prepost.PreAuthorize | |||||
| import org.springframework.web.bind.ServletRequestBindingException | |||||
| import org.springframework.web.bind.annotation.* | |||||
| import org.springframework.web.multipart.MultipartHttpServletRequest | |||||
| import java.time.LocalDateTime | |||||
| @RestController | |||||
| @RequestMapping("/truckLaneSchedule") | |||||
| open class TruckLaneScheduleController @Autowired constructor( | |||||
| private val truckLaneScheduleService: TruckLaneScheduleService, | |||||
| ) { | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PostMapping | |||||
| open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | |||||
| return truckLaneScheduleService.createManual(request) | |||||
| } | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PostMapping("/planFromRouteExcel") | |||||
| @Throws(ServletRequestBindingException::class) | |||||
| open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> { | |||||
| var workbook: Workbook? = null | |||||
| try { | |||||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||||
| return ResponseEntity.ok(truckLaneScheduleService.planFromRouteExcel(workbook)) | |||||
| } finally { | |||||
| try { | |||||
| workbook?.close() | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| } | |||||
| @GetMapping | |||||
| open fun list( | |||||
| @RequestParam(required = false) status: List<String>?, | |||||
| @RequestParam(defaultValue = "50") limit: Int, | |||||
| ): List<TruckLaneScheduleResponse> { | |||||
| val statuses = status?.mapNotNull { raw -> | |||||
| runCatching { TruckLaneScheduleStatus.valueOf(raw.trim().uppercase()) }.getOrNull() | |||||
| } | |||||
| return truckLaneScheduleService.list(statuses, limit) | |||||
| } | |||||
| @GetMapping("/pendingShopIds") | |||||
| open fun pendingShopIds(): PendingTruckRowIdsResponse { | |||||
| return truckLaneScheduleService.pendingTruckRowIds() | |||||
| } | |||||
| @GetMapping("/{id}") | |||||
| open fun getById(@PathVariable id: Long): TruckLaneScheduleResponse { | |||||
| return truckLaneScheduleService.getById(id) | |||||
| } | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PostMapping("/{id}/cancel") | |||||
| open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { | |||||
| return truckLaneScheduleService.cancel(id) | |||||
| } | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PostMapping("/{id}/applyNow") | |||||
| open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { | |||||
| return truckLaneScheduleService.applyNow(id) | |||||
| } | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PostMapping("/{id}/retry-failed") | |||||
| open fun retryFailed( | |||||
| @PathVariable id: Long, | |||||
| @RequestBody(required = false) request: RetryFailedTruckLaneScheduleRequest?, | |||||
| ): TruckLaneScheduleResponse { | |||||
| return truckLaneScheduleService.retryFailed(id, request?.executeAt) | |||||
| } | |||||
| @PostMapping("/{id}/ignore") | |||||
| open fun ignore(@PathVariable id: Long): TruckLaneScheduleResponse { | |||||
| return truckLaneScheduleService.ignore(id) | |||||
| } | |||||
| @PostMapping("/parseExcel") | |||||
| @Throws(ServletRequestBindingException::class) | |||||
| open fun parseExcel( | |||||
| request: HttpServletRequest, | |||||
| @RequestParam(required = false) | |||||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) | |||||
| defaultExecuteAt: LocalDateTime?, | |||||
| ): ResponseEntity<ParseTruckLaneScheduleExcelResponse> { | |||||
| var workbook: Workbook? = null | |||||
| try { | |||||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||||
| return ResponseEntity.ok( | |||||
| truckLaneScheduleService.parseExcel(workbook, defaultExecuteAt), | |||||
| ) | |||||
| } finally { | |||||
| try { | |||||
| workbook?.close() | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| } | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PostMapping("/importExcel") | |||||
| @Throws(ServletRequestBindingException::class) | |||||
| open fun importExcel( | |||||
| request: HttpServletRequest, | |||||
| @RequestParam(required = false) | |||||
| @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) | |||||
| defaultExecuteAt: LocalDateTime?, | |||||
| @RequestParam(required = false) note: String?, | |||||
| ): ResponseEntity<*> { | |||||
| var workbook: Workbook? = null | |||||
| try { | |||||
| val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList") | |||||
| workbook = XSSFWorkbook(multipartFile?.inputStream) | |||||
| val parsed = truckLaneScheduleService.parseExcel( | |||||
| workbook, | |||||
| defaultExecuteAt ?: LocalDateTime.now().plusMinutes(1), | |||||
| ) | |||||
| if (parsed.validCount == 0) { | |||||
| return ResponseEntity.badRequest().body( | |||||
| MessageResponse( | |||||
| id = null, | |||||
| name = null, | |||||
| code = null, | |||||
| type = "truckLaneSchedule", | |||||
| message = "無有效列可匯入", | |||||
| errorPosition = null, | |||||
| entity = parsed, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| val created = truckLaneScheduleService.createFromExcelRows( | |||||
| parsed.rows, | |||||
| defaultExecuteAt ?: LocalDateTime.now().plusMinutes(1), | |||||
| note, | |||||
| ) | |||||
| return ResponseEntity.ok(created) | |||||
| } finally { | |||||
| try { | |||||
| workbook?.close() | |||||
| } catch (_: Exception) { | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | package com.ffii.fpsms.modules.pickOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -37,6 +38,7 @@ data class CompletedDoPickOrderResponse( | |||||
| val shopName: String?, | val shopName: String?, | ||||
| val deliveryNoteCode: String?, | val deliveryNoteCode: String?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val departureTime: LocalTime?, | val departureTime: LocalTime?, | ||||
| val pickOrderIds: List<Long>, | val pickOrderIds: List<Long>, | ||||
| val pickOrderCodes: List<String>, | val pickOrderCodes: List<String>, | ||||
| @@ -69,6 +71,7 @@ data class FgInfoResponse( | |||||
| val shopCode: String?, | val shopCode: String?, | ||||
| val shopName: String?, | val shopName: String?, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val departureTime: LocalTime? | val departureTime: LocalTime? | ||||
| ) | ) | ||||
| data class HierarchicalPickOrderResponse( | data class HierarchicalPickOrderResponse( | ||||
| @@ -88,6 +91,7 @@ data class PickOrderDetailResponse( | |||||
| val deliveryOrderCode: String?, | val deliveryOrderCode: String?, | ||||
| val consoCode: String?, | val consoCode: String?, | ||||
| val status: String?, | val status: String?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val targetDate: LocalDate?, | val targetDate: LocalDate?, | ||||
| val pickOrderLines: List<PickOrderLineDetailResponse> | val pickOrderLines: List<PickOrderLineDetailResponse> | ||||
| ) | ) | ||||
| @@ -113,6 +117,7 @@ data class ItemInfoResponse( | |||||
| data class LotDetailResponse( | data class LotDetailResponse( | ||||
| val id: Long?, | val id: Long?, | ||||
| val lotNo: String?, | val lotNo: String?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val expiryDate: LocalDate?, | val expiryDate: LocalDate?, | ||||
| val location: String?, | val location: String?, | ||||
| val stockUnit: String?, | val stockUnit: String?, | ||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | package com.ffii.fpsms.modules.pickOrder.web.models | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | import com.ffii.fpsms.modules.pickOrder.entity.Truck | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| @@ -10,6 +11,7 @@ import java.time.LocalTime | |||||
| data class TruckLaneCombinationResponse( | data class TruckLaneCombinationResponse( | ||||
| val id: Long, | val id: Long, | ||||
| val truckLanceCode: String?, | val truckLanceCode: String?, | ||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val departureTime: LocalTime?, | val departureTime: LocalTime?, | ||||
| val loadingSequence: Int?, | val loadingSequence: Int?, | ||||
| val districtReference: String?, | val districtReference: String?, | ||||
| @@ -0,0 +1,190 @@ | |||||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineAction | |||||
| import jakarta.validation.Valid | |||||
| import jakarta.validation.constraints.Min | |||||
| import jakarta.validation.constraints.NotBlank | |||||
| import jakarta.validation.constraints.NotNull | |||||
| import jakarta.validation.constraints.Size | |||||
| import java.time.LocalDateTime | |||||
| import java.time.LocalTime | |||||
| data class TruckLaneMoveTargetRequest( | |||||
| @field:NotNull | |||||
| val truckRowId: Long, | |||||
| @field:NotBlank | |||||
| @field:Size(max = 100) | |||||
| val toTruckLanceCode: String, | |||||
| @field:Size(max = 255) | |||||
| val toRemark: String? = null, | |||||
| @field:NotBlank | |||||
| @field:Size(max = 10) | |||||
| val toStoreId: String, | |||||
| @field:NotNull | |||||
| @field:Min(0) | |||||
| val toLoadingSequence: Int, | |||||
| @field:Size(max = 255) | |||||
| val toDistrictReference: String? = null, | |||||
| ) | |||||
| data class TruckLaneScheduleLineRequest( | |||||
| @field:NotNull | |||||
| val action: TruckLaneScheduleLineAction, | |||||
| val truckRowId: Long? = null, | |||||
| @field:NotBlank | |||||
| @field:Size(max = 100) | |||||
| val toTruckLanceCode: String, | |||||
| @field:Size(max = 255) | |||||
| val toRemark: String? = null, | |||||
| @field:NotBlank | |||||
| @field:Size(max = 10) | |||||
| val toStoreId: String, | |||||
| @field:Min(0) | |||||
| val toLoadingSequence: Int? = null, | |||||
| @field:Size(max = 255) | |||||
| val toDistrictReference: String? = null, | |||||
| val shopId: Long? = null, | |||||
| @field:Size(max = 50) | |||||
| val shopCode: String? = null, | |||||
| @field:Size(max = 255) | |||||
| val shopName: String? = null, | |||||
| val departureTime: LocalTime? = null, | |||||
| val logisticId: Long? = null, | |||||
| ) | |||||
| data class CreateTruckLaneScheduleRequest( | |||||
| @field:NotNull | |||||
| val executeAt: LocalDateTime, | |||||
| @field:Size(max = 500) | |||||
| val note: String? = null, | |||||
| @field:Valid | |||||
| val lines: List<TruckLaneScheduleLineRequest>? = null, | |||||
| @field:Valid | |||||
| val moves: List<TruckLaneMoveTargetRequest>? = null, | |||||
| ) | |||||
| data class RetryFailedTruckLaneScheduleRequest( | |||||
| val executeAt: LocalDateTime? = null, | |||||
| ) | |||||
| data class TruckLaneScheduleLineResponse( | |||||
| val id: Long, | |||||
| val action: String, | |||||
| val truckRowId: Long?, | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val fromTruckLanceCode: String?, | |||||
| val fromRemark: String?, | |||||
| val fromStoreId: String?, | |||||
| val toTruckLanceCode: String, | |||||
| val toRemark: String?, | |||||
| val toStoreId: String, | |||||
| val fromLoadingSequence: Int? = null, | |||||
| val fromDistrictReference: String? = null, | |||||
| val fromDepartureTime: String? = null, | |||||
| val toDistrictReference: String? = null, | |||||
| val toLoadingSequence: Int? = null, | |||||
| val departureTime: String? = null, | |||||
| val lineStatus: String, | |||||
| val errorMessage: String?, | |||||
| val appliedAt: String?, | |||||
| ) | |||||
| data class SchedulePlanBlockingError( | |||||
| val code: String, | |||||
| val shopCode: String? = null, | |||||
| val shopName: String? = null, | |||||
| val truckRowId: Long? = null, | |||||
| val messageKey: String, | |||||
| ) | |||||
| data class TruckLaneScheduleResponse( | |||||
| val id: Long, | |||||
| val executeAt: String, | |||||
| val status: String, | |||||
| val source: String, | |||||
| val note: String?, | |||||
| val appliedAt: String?, | |||||
| val errorMessage: String?, | |||||
| val snapshotVersionId: Long?, | |||||
| val preApplySnapshotVersionId: Long? = null, | |||||
| val created: String?, | |||||
| val createdBy: String? = null, | |||||
| val modifiedBy: String?, | |||||
| val lines: List<TruckLaneScheduleLineResponse>? = null, | |||||
| val lineCounts: TruckLaneScheduleLineCounts? = null, | |||||
| ) | |||||
| data class TruckLaneScheduleLineCounts( | |||||
| val total: Int, | |||||
| val applied: Int, | |||||
| val failed: Int, | |||||
| val pending: Int, | |||||
| ) | |||||
| data class PendingTruckRowIdsResponse( | |||||
| val truckRowIds: List<Long>, | |||||
| ) | |||||
| data class RouteExcelSchedulePlanPreviewRow( | |||||
| val action: String, | |||||
| val truckRowId: Long?, | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val toTruckLanceCode: String, | |||||
| val toRemark: String?, | |||||
| val toStoreId: String, | |||||
| val toLoadingSequence: Int?, | |||||
| ) | |||||
| data class RouteExcelSchedulePlanError( | |||||
| val shopCode: String, | |||||
| val shopName: String, | |||||
| val message: String, | |||||
| ) | |||||
| data class RouteExcelSchedulePlanResponse( | |||||
| val sheetCount: Int, | |||||
| val rowCount: Int, | |||||
| val lines: List<TruckLaneScheduleLineRequest>, | |||||
| val previews: List<RouteExcelSchedulePlanPreviewRow>, | |||||
| val errors: List<RouteExcelSchedulePlanError>, | |||||
| val blockingErrors: List<SchedulePlanBlockingError> = emptyList(), | |||||
| val canCreate: Boolean = true, | |||||
| val counts: RouteExcelSchedulePlanCounts, | |||||
| ) | |||||
| data class RouteExcelSchedulePlanCounts( | |||||
| val moves: Int, | |||||
| val creates: Int, | |||||
| val deletes: Int, | |||||
| val ensureLanes: Int, | |||||
| ) | |||||
| data class ParseTruckLaneScheduleExcelResponse( | |||||
| val rowCount: Int, | |||||
| val validCount: Int, | |||||
| val errorCount: Int, | |||||
| val rows: List<TruckLaneScheduleExcelPreviewRow>, | |||||
| val errors: List<TruckLaneScheduleExcelRowError>, | |||||
| ) | |||||
| data class TruckLaneScheduleExcelPreviewRow( | |||||
| val rowIndex: Int, | |||||
| val shopCode: String, | |||||
| val toTruckLanceCode: String, | |||||
| val toRemark: String?, | |||||
| val toStoreId: String, | |||||
| val executeAt: LocalDateTime?, | |||||
| val truckRowId: Long?, | |||||
| ) | |||||
| data class TruckLaneScheduleExcelRowError( | |||||
| val rowIndex: Int, | |||||
| val message: String, | |||||
| ) | |||||
| data class ImportTruckLaneScheduleExcelRequest( | |||||
| val executeAt: LocalDateTime? = null, | |||||
| val note: String? = null, | |||||
| ) | |||||
| @@ -7,6 +7,9 @@ data class CreateTruckLaneSnapshotRequest( | |||||
| val truckLanceCode: String? = null, | val truckLanceCode: String? = null, | ||||
| @field:Size(max = 500) | @field:Size(max = 500) | ||||
| val note: String? = null, | 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( | data class RestoreTruckLaneSnapshotRequest( | ||||
| @@ -18,6 +21,7 @@ data class TruckLaneVersionResponse( | |||||
| val truckLanceCode: String, | val truckLanceCode: String, | ||||
| val note: String?, | val note: String?, | ||||
| val created: String?, | val created: String?, | ||||
| val createdBy: String? = null, | |||||
| /** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */ | /** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */ | ||||
| val modifiedBy: String?, | val modifiedBy: String?, | ||||
| ) | ) | ||||
| @@ -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, | |||||
| ) | |||||
| @@ -1,5 +1,6 @@ | |||||
| package com.ffii.fpsms.modules.stock.web.model | package com.ffii.fpsms.modules.stock.web.model | ||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| /** | /** | ||||
| @@ -13,7 +14,9 @@ data class PurchaseStockInAlertRow( | |||||
| val itemNo: String?, | val itemNo: String?, | ||||
| val itemName: String?, | val itemName: String?, | ||||
| val status: String?, | val status: String?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val lineCreated: LocalDateTime?, | val lineCreated: LocalDateTime?, | ||||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||||
| val receiptDate: LocalDateTime?, | val receiptDate: LocalDateTime?, | ||||
| val lotNo: String?, | val lotNo: String?, | ||||
| ) | |||||
| ) | |||||
| @@ -13,6 +13,12 @@ server: | |||||
| # Set enabled: false to disable. Optional receiptDate: "yyyy-MM-dd" overrides for testing only. | # 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. | # 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). | # 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: | scheduler: | ||||
| m18Sync: | m18Sync: | ||||
| enabled: false | enabled: false | ||||
| @@ -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; | |||||
| @@ -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`; | |||||
| @@ -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; | |||||
| @@ -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`; | |||||
| @@ -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()) | |||||
| } | |||||
| } | |||||