| @@ -1767,7 +1767,8 @@ return MessageResponse( | |||
| dop.truckDepartureTime as truck_departure_time, | |||
| dop.shopCode as ShopCode, | |||
| dop.shopName as ShopName, | |||
| dop.ticketStatus as doTicketStatus | |||
| dop.ticketStatus as doTicketStatus, | |||
| dop.requiredDeliveryDate as required_delivery_date | |||
| FROM fpsmsdb.delivery_order_pick_order dop | |||
| WHERE dop.handledBy = :userId | |||
| @@ -79,6 +79,7 @@ data class AssignByLaneRequest( | |||
| val releaseType: String? = null, | |||
| ) | |||
| data class DoPickOrderSummaryItem( | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: java.time.LocalTime?, | |||
| val truckLanceCode: String?, | |||
| val loadingSequence: Int?, | |||
| @@ -120,11 +121,13 @@ interface DoSearchRowProjection { | |||
| } | |||
| data class ReleasedDoPickOrderListItem( | |||
| val id: Long, // doPickOrderId,用於 assign | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val requiredDeliveryDate: LocalDate?, // Date 欄 | |||
| val shopCode: String?, // Shop | |||
| val shopName: String?, // Shop | |||
| val storeId: String?, // 2/F or 4/F | |||
| val truckLanceCode: String?, // Truck (Lane) | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, // Truck 時間 | |||
| val deliveryOrderCodes: List<String> // 多個 DO code,前端換行顯示 | |||
| ) | |||
| @@ -1,5 +1,6 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| @@ -15,14 +16,18 @@ data class TicketReleaseTableResponse( | |||
| val loadingSequence: Int?, | |||
| val ticketStatus: String?, | |||
| val truckId: Long?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, | |||
| val shopId: Long?, | |||
| val handledBy: Long?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketReleaseTime: LocalDateTime?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketCompleteDateTime: LocalDateTime?, | |||
| val truckLanceCode: String?, | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val requiredDeliveryDate: LocalDate?, | |||
| val handlerName: String?, | |||
| val numberOfFGItems: Int = 0, | |||
| @@ -1,5 +1,6 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalTime | |||
| @@ -7,13 +8,16 @@ data class TruckScheduleDashboardResponse( | |||
| val storeId: String?, | |||
| val truckId: Long?, | |||
| val truckLanceCode: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, | |||
| val numberOfShopsToServe: Int, | |||
| val numberOfPickTickets: Int, | |||
| val totalItemsToPick: Int, | |||
| val numberOfTicketsReleased: Int, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val firstTicketStartTime: LocalDateTime?, | |||
| val numberOfTicketsCompleted: Int, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val lastTicketEndTime: LocalDateTime?, | |||
| val pickTimeTakenMinutes: Long? | |||
| ) | |||
| @@ -1,5 +1,6 @@ | |||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalTime | |||
| @@ -10,13 +11,17 @@ data class WorkbenchTicketReleaseTableResponse( | |||
| val ticketNo: String?, | |||
| val loadingSequence: Int?, | |||
| val ticketStatus: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val truckDepartureTime: LocalTime?, | |||
| val handledBy: Long?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketReleaseTime: LocalDateTime?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val ticketCompleteDateTime: LocalDateTime?, | |||
| val truckLanceCode: String?, | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val requiredDeliveryDate: LocalDate?, | |||
| val handlerName: String?, | |||
| val numberOfFGItems: Int = 0, | |||
| @@ -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 | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| @@ -8,5 +11,15 @@ interface TruckLaneVersionRepository : AbstractRepository<TruckLaneVersion, Long | |||
| fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion> | |||
| fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion> | |||
| fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion? | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| """ | |||
| UPDATE TruckLaneVersion v | |||
| SET v.createdBy = :actor, v.modifiedBy = :actor | |||
| WHERE v.id = :id | |||
| """, | |||
| ) | |||
| fun updateActor(@Param("id") id: Long, @Param("actor") actor: String): Int | |||
| } | |||
| @@ -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_SCHEDULE = 4 | |||
| const val COL_DEPARTURE_ROW = 5 | |||
| const val COL_LOADING_SEQUENCE = 6 | |||
| const val COL_LAST = COL_LOADING_SEQUENCE | |||
| const val HEADER_LOADING_SEQUENCE = "裝載順序" | |||
| fun writeDataColumnHeaders(headerRow: org.apache.poi.ss.usermodel.Row) { | |||
| headerRow.createCell(COL_AREA_PLATE).setCellValue("板") | |||
| headerRow.createCell(COL_SHOP_NAME).setCellValue("店鋪名稱") | |||
| headerRow.createCell(COL_BRAND).setCellValue("品牌") | |||
| headerRow.createCell(COL_SHOP_CODE).setCellValue("店鋪編號") | |||
| headerRow.createCell(COL_SCHEDULE).setCellValue("此店車期") | |||
| headerRow.createCell(COL_DEPARTURE_ROW).setCellValue("出車時間") | |||
| headerRow.createCell(COL_LOADING_SEQUENCE).setCellValue(HEADER_LOADING_SEQUENCE) | |||
| } | |||
| fun decodeLaneId(laneId: String): Pair<String, String?>? { | |||
| val i = laneId.indexOf(SEP) | |||
| @@ -169,7 +183,7 @@ object RouteLaneExcelSupport { | |||
| val headerRow = sheet.getRow(ROW_HEADER) | |||
| if (headerRow != null) { | |||
| for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { | |||
| for (c in COL_AREA_PLATE..COL_LAST) { | |||
| headerRow.getCell(c)?.cellStyle = st.header | |||
| } | |||
| } | |||
| @@ -179,7 +193,7 @@ object RouteLaneExcelSupport { | |||
| val alt = (r - firstDataRow) % 2 == 1 | |||
| val style = if (alt) st.dataAlt else st.data | |||
| val row = sheet.getRow(r) ?: continue | |||
| for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) { | |||
| for (c in COL_AREA_PLATE..COL_LAST) { | |||
| row.getCell(c)?.cellStyle = style | |||
| } | |||
| } | |||
| @@ -191,12 +205,13 @@ object RouteLaneExcelSupport { | |||
| sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256) | |||
| sheet.setColumnWidth(COL_SCHEDULE, 12 * 256) | |||
| sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 256) | |||
| sheet.setColumnWidth(COL_LOADING_SEQUENCE, 10 * 256) | |||
| sheet.createFreezePane(0, ROW_FIRST_DATA) | |||
| val filterLast = if (lastDataRow >= firstDataRow) lastDataRow else ROW_HEADER | |||
| sheet.setAutoFilter( | |||
| CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_DEPARTURE_ROW), | |||
| CellRangeAddress(ROW_HEADER, filterLast, COL_AREA_PLATE, COL_LAST), | |||
| ) | |||
| } | |||
| } | |||
| @@ -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 ?: "", | |||
| note = v.note, | |||
| created = v.created?.toString(), | |||
| createdBy = v.createdBy, | |||
| modifiedBy = v.modifiedBy, | |||
| ) | |||
| @@ -75,6 +76,13 @@ open class TruckLaneVersionService( | |||
| truckLaneVersionLineRepository.saveAll(lines) | |||
| } | |||
| val actor = request.createdBy?.trim()?.takeIf { it.isNotEmpty() } | |||
| if (actor != null && savedVersion.id != null) { | |||
| truckLaneVersionRepository.updateActor(savedVersion.id!!, actor) | |||
| savedVersion.createdBy = actor | |||
| savedVersion.modifiedBy = actor | |||
| } | |||
| return toResponse(savedVersion) | |||
| } | |||
| @@ -231,7 +239,7 @@ open class TruckLaneVersionService( | |||
| } | |||
| @Transactional | |||
| open fun restore(versionId: Long): String { | |||
| open fun restore(versionId: Long, skipPostSnapshot: Boolean = false): String { | |||
| val version = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId) | |||
| ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId") | |||
| @@ -265,6 +273,11 @@ open class TruckLaneVersionService( | |||
| val trucksById = truckRepository.findAllById(snapshottedIds.toList()).associateBy { it.id } | |||
| val logisticIds = | |||
| lines.mapNotNull { it.logisticId }.filter { it > 0 }.distinct() | |||
| val logisticsById = | |||
| logisticIds.associateWith { id -> logisticRepository.findByIdAndDeletedFalse(id) } | |||
| val updated = lines.mapNotNull { line -> | |||
| val truckId = line.truckRowId ?: return@mapNotNull null | |||
| if (truckId <= 0) return@mapNotNull null | |||
| @@ -286,7 +299,7 @@ open class TruckLaneVersionService( | |||
| val lid = line.logisticId | |||
| this.logistic = | |||
| if (lid != null && lid > 0) { | |||
| logisticRepository.findByIdAndDeletedFalse(lid) | |||
| logisticsById[lid] | |||
| } else { | |||
| null | |||
| } | |||
| @@ -296,12 +309,14 @@ open class TruckLaneVersionService( | |||
| truckRepository.saveAll(updated) | |||
| } | |||
| createSnapshot( | |||
| CreateTruckLaneSnapshotRequest( | |||
| truckLanceCode = null, | |||
| note = "restore from versionId=$versionId", | |||
| if (!skipPostSnapshot) { | |||
| createSnapshot( | |||
| CreateTruckLaneSnapshotRequest( | |||
| truckLanceCode = null, | |||
| note = "restore from versionId=$versionId", | |||
| ), | |||
| ) | |||
| ) | |||
| } | |||
| return "Restored versionId=$versionId" | |||
| } | |||
| @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service | |||
| import com.ffii.fpsms.modules.logistic.entity.Logistic | |||
| import com.ffii.fpsms.modules.logistic.entity.LogisticRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLine | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneScheduleLineRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLineRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||
| @@ -23,6 +25,8 @@ import com.ffii.fpsms.modules.master.entity.ShopRepository | |||
| import com.ffii.fpsms.modules.master.entity.projections.ShopAndTruck | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | |||
| import jakarta.transaction.Transactional | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import java.io.ByteArrayOutputStream | |||
| import java.text.Collator | |||
| import java.time.LocalDate | |||
| @@ -41,8 +45,44 @@ open class TruckService( | |||
| private val logisticRepository: LogisticRepository, | |||
| private val truckLaneVersionRepository: TruckLaneVersionRepository, | |||
| private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | |||
| private val scheduleLineRepository: TruckLaneScheduleLineRepository, | |||
| ) : AbstractBaseEntityService<Truck, Long, TruckRepository>(jdbcDao, truckRepository) { | |||
| private fun assertNotScheduleLocked(truckRowId: Long?) { | |||
| if (truckRowId == null) return | |||
| if (scheduleLineRepository.countOpenPendingForTruckRow( | |||
| truckRowId, | |||
| TruckLaneScheduleLockSupport.pendingLineStatus, | |||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | |||
| ) > 0 | |||
| ) { | |||
| throw ResponseStatusException(HttpStatus.CONFLICT, "SCHEDULE_LOCKED") | |||
| } | |||
| } | |||
| private fun assertNotScheduleLocked(bucket: TruckLaneBucketKey) { | |||
| if (scheduleLineRepository.existsOpenScheduleForLaneBucket( | |||
| bucket.truckLanceCode.trim(), | |||
| bucket.storeId.trim(), | |||
| TruckLaneScheduleLockSupport.pendingLineStatus, | |||
| TruckLaneScheduleLockSupport.openScheduleStatuses, | |||
| TruckLaneScheduleLockSupport.applyingScheduleId(), | |||
| ) | |||
| ) { | |||
| throw ResponseStatusException(HttpStatus.CONFLICT, "SCHEDULE_LOCKED") | |||
| } | |||
| } | |||
| open fun toBucketKey(line: TruckLaneScheduleLine): TruckLaneBucketKey { | |||
| val storeNorm = normalizeRouteStoreId(line.toStoreId) | |||
| return TruckLaneBucketKey( | |||
| truckLanceCode = line.toTruckLanceCode?.trim().orEmpty(), | |||
| storeId = storeNorm, | |||
| remark = bucketRemarkForStore(storeNorm, line.toRemark), | |||
| ) | |||
| } | |||
| private fun logisticRefOrNull(id: Long?): Logistic? { | |||
| if (id == null) return null | |||
| return logisticRepository.findById(id).orElseThrow { | |||
| @@ -50,6 +90,7 @@ open class TruckService( | |||
| } | |||
| } | |||
| open fun saveTruck(request: SaveTruckRequest): Truck { | |||
| assertNotScheduleLocked(request.id) | |||
| val truck = request.id?.let { | |||
| truckRepository.findById(it).orElse(null) | |||
| } ?: Truck() | |||
| @@ -406,8 +447,240 @@ open class TruckService( | |||
| return truckRepository.findByShopIdAndDeletedFalse(shopId) | |||
| } | |||
| open fun findTruckById(id: Long): Truck? { | |||
| val t = truckRepository.findById(id).orElse(null) ?: return null | |||
| return if (t.deleted == true) null else t | |||
| } | |||
| open fun findTruckRowByIdIncludingDeleted(id: Long): Truck? { | |||
| return truckRepository.findById(id).orElse(null) | |||
| } | |||
| @Transactional | |||
| open fun restoreTruckRowIfDeleted(truck: Truck): Truck { | |||
| if (truck.deleted != true) return truck | |||
| truck.deleted = false | |||
| return truckRepository.save(truck) | |||
| } | |||
| data class TruckLaneBucketKey( | |||
| val truckLanceCode: String, | |||
| val storeId: String, | |||
| val remark: String?, | |||
| ) | |||
| open fun normalizeRouteStoreId(storeId: String?): String { | |||
| val s = storeId?.trim()?.uppercase().orEmpty() | |||
| return if (s == "4F") "4F" else "2F" | |||
| } | |||
| open fun bucketRemarkForStore(storeId: String, remark: String?): String? { | |||
| return if (normalizeRouteStoreId(storeId) == "4F") { | |||
| remark?.trim()?.takeIf { it.isNotEmpty() } | |||
| } else { | |||
| null | |||
| } | |||
| } | |||
| open fun trucksInLaneBucket(key: TruckLaneBucketKey): List<Truck> { | |||
| return trucksInSameLaneBucket( | |||
| key.truckLanceCode.trim(), | |||
| key.storeId, | |||
| key.remark, | |||
| ) | |||
| } | |||
| open fun truckLaneBucketKeyOf(truck: Truck): TruckLaneBucketKey { | |||
| val store = normalizeRouteStoreId(truck.storeId) | |||
| return TruckLaneBucketKey( | |||
| truckLanceCode = truck.truckLanceCode?.trim().orEmpty(), | |||
| storeId = store, | |||
| remark = bucketRemarkForStore(store, truck.remark), | |||
| ) | |||
| } | |||
| open fun laneHasShopConflict( | |||
| shop: Truck, | |||
| bucket: List<Truck>, | |||
| excludeTruckRowId: Long?, | |||
| ): Boolean { | |||
| val movingShopId = shop.shop?.id | |||
| val movingCode = shop.shopCode?.trim()?.lowercase().orEmpty() | |||
| for (s in bucket) { | |||
| if (excludeTruckRowId != null && s.id == excludeTruckRowId) continue | |||
| if (isLanePlaceholderTruck(s)) continue | |||
| val otherShopId = s.shop?.id | |||
| if (movingShopId != null && otherShopId != null && movingShopId == otherShopId) { | |||
| return true | |||
| } | |||
| val otherCode = s.shopCode?.trim()?.lowercase().orEmpty() | |||
| if (movingCode.isNotEmpty() && otherCode.isNotEmpty() && movingCode == otherCode) { | |||
| return true | |||
| } | |||
| } | |||
| return false | |||
| } | |||
| open fun validateMoveToLane(truckRowId: Long, to: TruckLaneBucketKey): String? { | |||
| return validateMoveToLane(truckRowId, to, allowEmptyTarget = false) | |||
| } | |||
| /** | |||
| * @return null if valid, otherwise human-readable error (Traditional Chinese for UI). | |||
| */ | |||
| open fun validateMoveToLane( | |||
| truckRowId: Long, | |||
| to: TruckLaneBucketKey, | |||
| allowEmptyTarget: Boolean, | |||
| ): String? { | |||
| val truck = truckRepository.findById(truckRowId).orElse(null) | |||
| ?: return "找不到 truck 列 id=$truckRowId" | |||
| if (truck.deleted == true) return "truck 列已刪除" | |||
| if (isLanePlaceholderTruck(truck)) return "無法排程移動空白占位列" | |||
| val toCode = to.truckLanceCode.trim() | |||
| if (toCode.isEmpty()) return "目標車線編號不可為空" | |||
| val fromKey = truckLaneBucketKeyOf(truck) | |||
| val sameLane = | |||
| fromKey.truckLanceCode == toCode && | |||
| fromKey.storeId == to.storeId && | |||
| (fromKey.remark ?: "") == (to.remark ?: "") | |||
| if (sameLane) return null | |||
| val targetBucket = trucksInLaneBucket(to) | |||
| if (targetBucket.isEmpty()) { | |||
| if (allowEmptyTarget) return null | |||
| return "目標車線不存在或尚無任何列:$toCode" | |||
| } | |||
| if (laneHasShopConflict(truck, targetBucket, excludeTruckRowId = truckRowId)) { | |||
| return "目標車線已有相同店鋪" | |||
| } | |||
| return null | |||
| } | |||
| open fun isPlaceholderTruckRow(truck: Truck): Boolean = isLanePlaceholderTruck(truck) | |||
| open fun parseRouteDepartureTime(timeStr: String?): LocalTime? = parseDepartureTime(timeStr) | |||
| open fun bucketKeyForRouteRow(row: RouteLaneImportPreviewRow): TruckLaneBucketKey { | |||
| val storeNorm = normalizeRouteStoreId(row.storeId) | |||
| return TruckLaneBucketKey( | |||
| truckLanceCode = row.truckLanceCode.trim(), | |||
| storeId = storeNorm, | |||
| remark = bucketRemarkForStore(storeNorm, row.remark), | |||
| ) | |||
| } | |||
| open fun truckNeedsMoveForRouteRow(truck: Truck, row: RouteLaneImportPreviewRow): Boolean { | |||
| val toKey = bucketKeyForRouteRow(row) | |||
| val currentKey = truckLaneBucketKeyOf(truck) | |||
| val targetSeq = row.loadingSequence.coerceAtLeast(0) | |||
| val targetDistrict = row.districtReference?.trim().orEmpty() | |||
| val currentDistrict = truck.districtReference?.trim().orEmpty() | |||
| val targetDeparture = parseDepartureTime(row.departureTime) | |||
| val currentDeparture = truck.departureTime | |||
| if (currentKey.truckLanceCode != toKey.truckLanceCode || | |||
| currentKey.storeId != toKey.storeId || | |||
| (currentKey.remark ?: "") != (toKey.remark ?: "") | |||
| ) { | |||
| return true | |||
| } | |||
| if ((truck.loadingSequence ?: 0) != targetSeq) return true | |||
| if (currentDistrict != targetDistrict) return true | |||
| if (targetDeparture != null && targetDeparture != currentDeparture) return true | |||
| return false | |||
| } | |||
| open fun buildSaveTruckLaneForMove(truck: Truck, to: TruckLaneBucketKey): SaveTruckLane { | |||
| val targetBucket = trucksInLaneBucket(to) | |||
| val maxSeq = targetBucket | |||
| .filter { !isLanePlaceholderTruck(it) } | |||
| .maxOfOrNull { it.loadingSequence ?: 0 } ?: 0 | |||
| return buildSaveTruckLaneForScheduledMove( | |||
| truck, | |||
| to, | |||
| truck.districtReference, | |||
| maxSeq + 1, | |||
| ) | |||
| } | |||
| open fun buildSaveTruckLaneForScheduledMove( | |||
| truck: Truck, | |||
| to: TruckLaneBucketKey, | |||
| districtReference: String?, | |||
| loadingSequence: Int, | |||
| ): SaveTruckLane { | |||
| val targetBucket = trucksInLaneBucket(to) | |||
| val template = | |||
| targetBucket.firstOrNull { !isLanePlaceholderTruck(it) } | |||
| ?: targetBucket.first() | |||
| val storeNorm = normalizeRouteStoreId(to.storeId) | |||
| val toRemark = | |||
| if (storeNorm == "4F") to.remark?.trim()?.takeIf { it.isNotEmpty() } else null | |||
| return SaveTruckLane( | |||
| id = truck.id ?: 0L, | |||
| truckLanceCode = to.truckLanceCode.trim(), | |||
| departureTime = template.departureTime ?: LocalTime.of(0, 0), | |||
| loadingSequence = loadingSequence.coerceAtLeast(0).toLong(), | |||
| districtReference = districtReference ?: truck.districtReference, | |||
| storeId = storeNorm, | |||
| remark = toRemark, | |||
| logisticId = template.logistic?.id, | |||
| updateLogistic = true, | |||
| ) | |||
| } | |||
| @Transactional | |||
| open fun applyScheduledTruckMove( | |||
| truckRowId: Long, | |||
| to: TruckLaneBucketKey, | |||
| districtReference: String? = null, | |||
| loadingSequence: Int? = null, | |||
| ): List<Truck> { | |||
| assertNotScheduleLocked(truckRowId) | |||
| var truck = findTruckRowByIdIncludingDeleted(truckRowId) | |||
| ?: throw IllegalArgumentException("找不到 truck #$truckRowId") | |||
| truck = restoreTruckRowIfDeleted(truck) | |||
| val err = validateMoveToLane(truckRowId, to) | |||
| if (err != null) throw IllegalArgumentException(err) | |||
| val fromKey = truckLaneBucketKeyOf(truck) | |||
| val sameLane = | |||
| fromKey.truckLanceCode == to.truckLanceCode.trim() && | |||
| fromKey.storeId == to.storeId && | |||
| (fromKey.remark ?: "") == (to.remark ?: "") | |||
| val seq = | |||
| loadingSequence | |||
| ?: run { | |||
| val bucket = trucksInLaneBucket(to) | |||
| (bucket.filter { !isLanePlaceholderTruck(it) } | |||
| .maxOfOrNull { it.loadingSequence ?: 0 } ?: 0) + 1 | |||
| } | |||
| val district = districtReference ?: truck.districtReference | |||
| if (sameLane) { | |||
| val currentSeq = truck.loadingSequence ?: 0 | |||
| val currentDistrict = truck.districtReference?.trim().orEmpty() | |||
| val nextDistrict = district?.trim().orEmpty() | |||
| if (currentSeq == seq && currentDistrict == nextDistrict) { | |||
| return listOf(truck) | |||
| } | |||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||
| return updateTruckLaneByTruckLanceCode(request) | |||
| } | |||
| val request = buildSaveTruckLaneForScheduledMove(truck, to, district, seq) | |||
| return updateTruckLaneByTruckLanceCode(request) | |||
| } | |||
| @Transactional | |||
| open fun updateTruckLaneByTruckLanceCode(request: SaveTruckLane): List<Truck> { | |||
| assertNotScheduleLocked(request.id) | |||
| val updateTruckLance = truckRepository.findById(request.id).orElseThrow() | |||
| ?: throw IllegalArgumentException("Truck not found with truckLanceCode: $request.truckLanceCode") | |||
| @@ -431,6 +704,7 @@ open class TruckService( | |||
| @Transactional | |||
| open fun deleteById(id: Long): String { | |||
| assertNotScheduleLocked(id) | |||
| val deleteTruck = truckRepository.findById(id).orElseThrow().apply { | |||
| deleted = true | |||
| } | |||
| @@ -440,6 +714,14 @@ open class TruckService( | |||
| @Transactional | |||
| open fun createTruckInShop(request: SaveTruckRequest): Truck { | |||
| val storeNorm = normalizeRouteStoreId(request.store_id) | |||
| assertNotScheduleLocked( | |||
| TruckLaneBucketKey( | |||
| truckLanceCode = request.truckLanceCode.trim(), | |||
| storeId = storeNorm, | |||
| remark = bucketRemarkForStore(storeNorm, request.remark), | |||
| ), | |||
| ) | |||
| val shop = shopRepository.findById(request.shopId).orElse(null) | |||
| if (shop == null) { | |||
| throw IllegalArgumentException("Shop not found with id: ${request.shopId}") | |||
| @@ -521,6 +803,7 @@ open class TruckService( | |||
| @Transactional | |||
| open fun updateTruckShopDetails(request: UpdateTruckShopDetailsRequest): Truck { | |||
| assertNotScheduleLocked(request.id) | |||
| val truck = truckRepository.findById(request.id).orElseThrow { | |||
| IllegalArgumentException("Truck not found with id: ${request.id}") | |||
| } | |||
| @@ -579,6 +862,14 @@ open class TruckService( | |||
| @Transactional | |||
| open fun createTruckWithoutShop(request: CreateTruckWithoutShopRequest): Truck { | |||
| val storeNorm = normalizeRouteStoreId(request.store_id) | |||
| assertNotScheduleLocked( | |||
| TruckLaneBucketKey( | |||
| truckLanceCode = request.truckLanceCode.trim(), | |||
| storeId = storeNorm, | |||
| remark = bucketRemarkForStore(storeNorm, request.remark), | |||
| ), | |||
| ) | |||
| val laneRows = trucksInSameLaneBucket( | |||
| request.truckLanceCode, | |||
| request.store_id, | |||
| @@ -694,12 +985,7 @@ open class TruckService( | |||
| ) | |||
| rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) | |||
| rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") | |||
| rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") | |||
| rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") | |||
| rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") | |||
| rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") | |||
| rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") | |||
| RouteLaneExcelSupport.writeDataColumnHeaders(rr) | |||
| val segmentsForRows = sortDistrictSegmentsForPlateColumnExport(trucks) | |||
| var rowNum = RouteLaneExcelSupport.ROW_FIRST_DATA | |||
| @@ -716,6 +1002,8 @@ open class TruckService( | |||
| dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue( | |||
| formatDepartureForExcel(t.departureTime), | |||
| ) | |||
| dataRow.createCell(RouteLaneExcelSupport.COL_LOADING_SEQUENCE) | |||
| .setCellValue((t.loadingSequence ?: 0).toDouble()) | |||
| } | |||
| } | |||
| RouteLaneExcelSupport.applyRouteLaneExportFinishing( | |||
| @@ -1258,12 +1546,7 @@ open class TruckService( | |||
| rr.createCell(RouteLaneExcelSupport.COL_META_B).setCellValue(formatDepartureForExcel(deptDefault)) | |||
| rr = sheet.createRow(RouteLaneExcelSupport.ROW_HEADER) | |||
| rr.createCell(RouteLaneExcelSupport.COL_AREA_PLATE).setCellValue("板") | |||
| rr.createCell(RouteLaneExcelSupport.COL_SHOP_NAME).setCellValue("店鋪名稱") | |||
| rr.createCell(RouteLaneExcelSupport.COL_BRAND).setCellValue("品牌") | |||
| rr.createCell(RouteLaneExcelSupport.COL_SHOP_CODE).setCellValue("店鋪編號") | |||
| rr.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue("此店車期") | |||
| rr.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue("出車時間") | |||
| RouteLaneExcelSupport.writeDataColumnHeaders(rr) | |||
| // segments by district changes in loading order | |||
| val segments = ArrayList<List<com.ffii.fpsms.modules.pickOrder.entity.TruckLaneVersionLine>>() | |||
| @@ -1299,6 +1582,8 @@ open class TruckService( | |||
| dataRow.createCell(RouteLaneExcelSupport.COL_SCHEDULE).setCellValue(l.remark ?: "") | |||
| val dept = parseLocalTimeOrNull(l.departureTime) ?: deptDefault | |||
| dataRow.createCell(RouteLaneExcelSupport.COL_DEPARTURE_ROW).setCellValue(formatDepartureForExcel(dept)) | |||
| dataRow.createCell(RouteLaneExcelSupport.COL_LOADING_SEQUENCE) | |||
| .setCellValue((l.loadingSequence ?: 0).toDouble()) | |||
| } | |||
| } | |||
| @@ -1311,6 +1596,15 @@ open class TruckService( | |||
| } | |||
| } | |||
| private fun parseLoadingSequenceFromRouteRow( | |||
| row: org.apache.poi.ss.usermodel.Row, | |||
| fallback: Int, | |||
| ): Int { | |||
| val cell = row.getCell(RouteLaneExcelSupport.COL_LOADING_SEQUENCE) ?: return fallback | |||
| val parsed = ExcelUtils.getIntValue(cell, -1) | |||
| return if (parsed >= 0) parsed else fallback | |||
| } | |||
| private fun appendVersionRouteReportSheet( | |||
| wb: XSSFWorkbook, | |||
| createdDate: String, | |||
| @@ -1578,7 +1872,7 @@ open class TruckService( | |||
| shopId = shop.id!!, | |||
| shopName = shopName.ifEmpty { shop.name ?: "" }, | |||
| shopCode = normalizedShopCode, | |||
| loadingSequence = seq, | |||
| loadingSequence = parseLoadingSequenceFromRouteRow(row, seq), | |||
| districtReference = normalizeDistrictReferenceForRouteLaneImport(currentDistrict), | |||
| logisticId = logisticId, | |||
| ), | |||
| @@ -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.deleteTruckLane | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.toLaneCombinationResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.toMessageEntity | |||
| import jakarta.validation.Valid | |||
| @RestController | |||
| @@ -50,7 +51,7 @@ open class TruckController( | |||
| type = "truck", | |||
| message = if (truck.id != null) "Truck updated successfully" else "Truck created successfully", | |||
| errorPosition = null, | |||
| entity = truck | |||
| entity = truck.toMessageEntity() | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| @@ -76,7 +77,7 @@ open class TruckController( | |||
| type = "truck", | |||
| message = "Truck created successfully", | |||
| errorPosition = null, | |||
| entity = truck | |||
| entity = truck.toMessageEntity() | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| @@ -427,7 +428,7 @@ open class TruckController( | |||
| type = "truck", | |||
| message = "Truck shop details updated successfully", | |||
| errorPosition = null, | |||
| entity = truck | |||
| entity = truck.toMessageEntity() | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| @@ -453,7 +454,7 @@ open class TruckController( | |||
| type = "truck", | |||
| message = "Truck created successfully", | |||
| errorPosition = null, | |||
| entity = truck | |||
| entity = truck.toMessageEntity() | |||
| ) | |||
| } catch (e: Exception) { | |||
| return MessageResponse( | |||
| @@ -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 | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| import java.time.LocalDateTime | |||
| import java.time.LocalTime | |||
| @@ -37,6 +38,7 @@ data class CompletedDoPickOrderResponse( | |||
| val shopName: String?, | |||
| val deliveryNoteCode: String?, | |||
| val truckLanceCode: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val departureTime: LocalTime?, | |||
| val pickOrderIds: List<Long>, | |||
| val pickOrderCodes: List<String>, | |||
| @@ -69,6 +71,7 @@ data class FgInfoResponse( | |||
| val shopCode: String?, | |||
| val shopName: String?, | |||
| val truckLanceCode: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val departureTime: LocalTime? | |||
| ) | |||
| data class HierarchicalPickOrderResponse( | |||
| @@ -88,6 +91,7 @@ data class PickOrderDetailResponse( | |||
| val deliveryOrderCode: String?, | |||
| val consoCode: String?, | |||
| val status: String?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val targetDate: LocalDate?, | |||
| val pickOrderLines: List<PickOrderLineDetailResponse> | |||
| ) | |||
| @@ -113,6 +117,7 @@ data class ItemInfoResponse( | |||
| data class LotDetailResponse( | |||
| val id: Long?, | |||
| val lotNo: String?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||
| val expiryDate: LocalDate?, | |||
| val location: String?, | |||
| val stockUnit: String?, | |||
| @@ -1,5 +1,6 @@ | |||
| package com.ffii.fpsms.modules.pickOrder.web.models | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | |||
| import java.time.LocalTime | |||
| @@ -10,6 +11,7 @@ import java.time.LocalTime | |||
| data class TruckLaneCombinationResponse( | |||
| val id: Long, | |||
| val truckLanceCode: String?, | |||
| @JsonFormat(pattern = "HH:mm") | |||
| val departureTime: LocalTime?, | |||
| val loadingSequence: Int?, | |||
| val districtReference: String?, | |||
| @@ -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, | |||
| @field:Size(max = 500) | |||
| val note: String? = null, | |||
| /** When set (e.g. scheduled apply without JWT), attributes snapshot to the scheduler. */ | |||
| @field:Size(max = 30) | |||
| val createdBy: String? = null, | |||
| ) | |||
| data class RestoreTruckLaneSnapshotRequest( | |||
| @@ -18,6 +21,7 @@ data class TruckLaneVersionResponse( | |||
| val truckLanceCode: String, | |||
| val note: String?, | |||
| val created: String?, | |||
| val createdBy: String? = null, | |||
| /** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */ | |||
| val modifiedBy: String?, | |||
| ) | |||
| @@ -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 | |||
| import com.fasterxml.jackson.annotation.JsonFormat | |||
| import java.time.LocalDateTime | |||
| /** | |||
| @@ -13,7 +14,9 @@ data class PurchaseStockInAlertRow( | |||
| val itemNo: String?, | |||
| val itemName: String?, | |||
| val status: String?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val lineCreated: LocalDateTime?, | |||
| @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") | |||
| val receiptDate: LocalDateTime?, | |||
| val lotNo: String?, | |||
| ) | |||
| ) | |||
| @@ -13,6 +13,12 @@ server: | |||
| # Set enabled: false to disable. Optional receiptDate: "yyyy-MM-dd" overrides for testing only. | |||
| # m18Grn.createEnabled: M18 GRN PUT/create — false outside production so UAT/dev never posts GRNs. | |||
| # m18Sync: M18 cron jobs for PO, DO1, DO2, BOM→M18 udfBomForShop ([SCHEDULE.m18.bom.shop], default 23:00), master data — false outside production (manual /trigger/* still works). | |||
| truck: | |||
| lane: | |||
| schedule: | |||
| enabled: true | |||
| cron: "0 * * * * *" | |||
| scheduler: | |||
| m18Sync: | |||
| enabled: false | |||
| @@ -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()) | |||
| } | |||
| } | |||