Procházet zdrojové kódy

routeboard

production
tommy před 1 měsícem
rodič
revize
57ab57dd65
36 změnil soubory, kde provedl 3840 přidání a 52 odebrání
  1. +33
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt
  2. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt
  3. +82
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt
  4. +65
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt
  5. +9
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt
  6. +10
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt
  7. +21
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt
  8. +12
    -0
      src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt
  9. +10
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt
  10. +6
    -1
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt
  11. +19
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt
  12. +55
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt
  13. +10
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt
  14. +12
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt
  15. +81
    -4
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt
  16. +202
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt
  17. +359
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt
  18. +194
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt
  19. +300
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt
  20. +306
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt
  21. +1309
    -31
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt
  22. +218
    -15
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt
  23. +73
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt
  24. +5
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt
  25. +11
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt
  26. +7
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt
  27. +22
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt
  28. +15
    -1
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt
  29. +34
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt
  30. +65
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt
  31. +8
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt
  32. +130
    -0
      src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql
  33. +46
    -0
      src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql
  34. +10
    -0
      src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql
  35. +52
    -0
      src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql
  36. +37
    -0
      src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql

+ 33
- 0
src/main/java/com/ffii/fpsms/modules/logistic/entity/Logistic.kt Zobrazit soubor

@@ -0,0 +1,33 @@
package com.ffii.fpsms.modules.logistic.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

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

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

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

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

@field:NotNull
@Column(name = "driverNumber", nullable = false)
open var driverNumber: Int? = null
}


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/logistic/entity/LogisticRepository.kt Zobrazit soubor

@@ -0,0 +1,12 @@
package com.ffii.fpsms.modules.logistic.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface LogisticRepository : AbstractRepository<Logistic, Long> {
fun findAllByDeletedFalseOrderByIdAsc(): List<Logistic>
fun findByIdAndDeletedFalse(id: Long): Logistic?
fun findByCarPlateAndDeletedFalse(carPlate: String): Logistic?
}


+ 82
- 0
src/main/java/com/ffii/fpsms/modules/logistic/service/LogisticService.kt Zobrazit soubor

@@ -0,0 +1,82 @@
package com.ffii.fpsms.modules.logistic.service

import com.ffii.fpsms.modules.logistic.entity.Logistic
import com.ffii.fpsms.modules.logistic.entity.LogisticRepository
import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest
import jakarta.transaction.Transactional
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus

@Service
open class LogisticService(
private val logisticRepository: LogisticRepository,
) {
open fun findAll(): List<Logistic> {
return logisticRepository.findAllByDeletedFalseOrderByIdAsc()
}

open fun findById(id: Long): Logistic? {
return logisticRepository.findByIdAndDeletedFalse(id)
}

open fun requireById(id: Long): Logistic {
return logisticRepository.findByIdAndDeletedFalse(id)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Logistic not found with id: $id")
}

@Transactional
open fun save(request: SaveLogisticRequest): Logistic {
val entity = request.id?.let { requireById(it) } ?: Logistic()

entity.apply {
logisticName = request.logisticName.trim()
carPlate = request.carPlate.trim()
driverName = request.driverName.trim()
driverNumber = request.driverNumber
}

return logisticRepository.save(entity)
}

/**
* 批次「新增」物流主檔:同一交易內寫入,任一筆失敗則整批 rollback。
* 供看板一次儲存多筆暫存主檔,避免逐筆 POST 中途失敗留下孤兒列。
*/
@Transactional
open fun saveBatchCreate(requests: List<SaveLogisticRequest>): List<Logistic> {
if (requests.isEmpty()) return emptyList()
if (requests.size > 100) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Batch size exceeds limit (100)",
)
}
requests.forEach { r ->
if (r.id != null) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"save-batch only accepts new rows (id must be null)",
)
}
}
return requests.map { req ->
val entity = Logistic().apply {
logisticName = req.logisticName.trim()
carPlate = req.carPlate.trim()
driverName = req.driverName.trim()
driverNumber = req.driverNumber
}
logisticRepository.save(entity)
}
}

@Transactional
open fun deleteById(id: Long): String {
val entity = requireById(id)
entity.deleted = true
logisticRepository.save(entity)
return "Logistic deleted successfully with id: $id"
}
}


+ 65
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/LogisticController.kt Zobrazit soubor

@@ -0,0 +1,65 @@
package com.ffii.fpsms.modules.logistic.web

import com.ffii.fpsms.modules.logistic.service.LogisticService
import com.ffii.fpsms.modules.logistic.web.models.DeleteLogisticRequest
import com.ffii.fpsms.modules.logistic.web.models.LogisticResponse
import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticRequest
import com.ffii.fpsms.modules.logistic.web.models.SaveLogisticsBatchRequest
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/logistic")
class LogisticController(
private val logisticService: LogisticService,
) {
@GetMapping("/all")
fun findAll(): List<LogisticResponse> {
return logisticService.findAll().map { it.toResponse() }
}

@GetMapping("/{id}")
fun findById(@PathVariable id: Long): LogisticResponse {
return logisticService.requireById(id).toResponse()
}

@PostMapping("/save")
fun save(@Valid @RequestBody request: SaveLogisticRequest): LogisticResponse {
return logisticService.save(request).toResponse()
}

/** 批次新增主檔;單一 transaction,與 [save] 分開避免誤用 id 更新混進批次。 */
@PostMapping("/save-batch")
fun saveBatch(@Valid @RequestBody body: SaveLogisticsBatchRequest): List<LogisticResponse> {
return logisticService.saveBatchCreate(body.items).map { it.toResponse() }
}

@PostMapping("/delete")
fun delete(@Valid @RequestBody request: DeleteLogisticRequest): ResponseEntity<MessageResponse> {
val result = logisticService.deleteById(request.id)
return ResponseEntity.ok(
MessageResponse(
id = request.id,
name = null,
code = null,
type = "logistic",
message = result,
errorPosition = null,
entity = null,
)
)
}

private fun com.ffii.fpsms.modules.logistic.entity.Logistic.toResponse(): LogisticResponse {
return LogisticResponse(
id = this.id ?: 0L,
logisticName = this.logisticName ?: "",
carPlate = this.carPlate ?: "",
driverName = this.driverName ?: "",
driverNumber = this.driverNumber ?: 0,
)
}
}


+ 9
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/DeleteLogisticRequest.kt Zobrazit soubor

@@ -0,0 +1,9 @@
package com.ffii.fpsms.modules.logistic.web.models

import jakarta.validation.constraints.NotNull

data class DeleteLogisticRequest(
@field:NotNull
val id: Long,
)


+ 10
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/LogisticResponse.kt Zobrazit soubor

@@ -0,0 +1,10 @@
package com.ffii.fpsms.modules.logistic.web.models

data class LogisticResponse(
val id: Long,
val logisticName: String,
val carPlate: String,
val driverName: String,
val driverNumber: Int,
)


+ 21
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticRequest.kt Zobrazit soubor

@@ -0,0 +1,21 @@
package com.ffii.fpsms.modules.logistic.web.models

import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

data class SaveLogisticRequest(
val id: Long? = null,
@field:NotBlank
@field:Size(max = 255)
val logisticName: String,
@field:NotBlank
@field:Size(max = 50)
val carPlate: String,
@field:NotBlank
@field:Size(max = 255)
val driverName: String,
@field:NotNull
val driverNumber: Int,
)


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/logistic/web/models/SaveLogisticsBatchRequest.kt Zobrazit soubor

@@ -0,0 +1,12 @@
package com.ffii.fpsms.modules.logistic.web.models

import jakarta.validation.Valid
import jakarta.validation.constraints.NotEmpty
import jakarta.validation.constraints.Size

data class SaveLogisticsBatchRequest(
@field:NotEmpty
@field:Size(max = 100)
@field:Valid
val items: List<SaveLogisticRequest>,
)

+ 10
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ShopRepository.kt Zobrazit soubor

@@ -6,6 +6,7 @@ import com.ffii.fpsms.modules.master.entity.projections.ShopCombo
import com.ffii.fpsms.modules.master.enums.ShopType
import com.ffii.fpsms.modules.pickOrder.entity.Truck
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository

@Repository
@@ -30,6 +31,15 @@ interface ShopRepository : AbstractRepository<Shop, Long> {

fun findByCode(code: String): Shop?

@Query(
"""
SELECT s FROM Shop s
WHERE s.deleted = false
AND s.code IN :codes
"""
)
fun findAllByCodeInAndDeletedIsFalse(@Param("codes") codes: Collection<String>): List<Shop>

@Query(
nativeQuery = true,
value = """


+ 6
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/Truck.kt Zobrazit soubor

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

import com.ffii.core.entity.BaseEntity
import com.ffii.fpsms.modules.logistic.entity.Logistic
import com.ffii.fpsms.modules.master.entity.Shop
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@@ -42,4 +43,8 @@ open class Truck : BaseEntity<Long>() {
@Column(name = "remark")
open var remark: String? = null

}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "logisticId")
open var logistic: Logistic? = null

}

+ 19
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersion.kt Zobrazit soubor

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

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.*
import jakarta.validation.constraints.Size

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

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

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


+ 55
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLine.kt Zobrazit soubor

@@ -0,0 +1,55 @@
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

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "truckLaneVersionId", nullable = false)
open var truckLaneVersion: TruckLaneVersion? = null

@field:NotNull
@Column(name = "truckRowId", nullable = false)
open var truckRowId: Long? = null

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

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

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

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

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

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

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

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

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


+ 10
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionLineRepository.kt Zobrazit soubor

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

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface TruckLaneVersionLineRepository : AbstractRepository<TruckLaneVersionLine, Long> {
fun findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(truckLaneVersionId: Long): List<TruckLaneVersionLine>
}


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneVersionRepository.kt Zobrazit soubor

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

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface TruckLaneVersionRepository : AbstractRepository<TruckLaneVersion, Long> {
fun findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(truckLanceCode: String): List<TruckLaneVersion>
fun findAllByDeletedFalseOrderByCreatedDesc(): List<TruckLaneVersion>
fun findByIdAndDeletedFalse(id: Long): TruckLaneVersion?
}


+ 81
- 4
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckRepository.kt Zobrazit soubor

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

import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.logistic.entity.Logistic
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
@@ -32,8 +34,81 @@ interface TruckRepository : AbstractRepository<Truck, Long> {
fun findByTruckLanceCode(truckLanceCode: String): Truck?
@Query("SELECT t FROM Truck t WHERE t.truckLanceCode = :truckLanceCode AND t.deleted = false")
fun findAllByTruckLanceCodeAndDeletedFalse(@Param("truckLanceCode") truckLanceCode: String): List<Truck>

/**
* Same lane group as `findAllUniqueTruckLanceCodeAndRemarkCombinations`:
* remark NULL / blank belong to one bucket; non-blank matches exactly.
*/
@Query(
"""
SELECT DISTINCT t FROM Truck t
LEFT JOIN FETCH t.logistic
LEFT JOIN FETCH t.shop
WHERE t.truckLanceCode = :truckLanceCode
AND t.deleted = false
AND (
(:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = ''))
OR (:blankRemark = false AND trim(t.remark) = :exactRemark)
)
"""
)
fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(
@Param("truckLanceCode") truckLanceCode: String,
@Param("blankRemark") blankRemark: Boolean,
@Param("exactRemark") exactRemark: String?,
): List<Truck>

/**
* RouteBoard O(1) load: return all truck rows used by lanes, with logistic pre-fetched.
* Frontend groups by (truckLanceCode, normalizedRemark) where normalizedRemark is:
* - NULL / blank => ""
* - else TRIM(remark)
*/
@Query(
"""
SELECT t FROM Truck t
LEFT JOIN FETCH t.logistic
LEFT JOIN FETCH t.shop
WHERE t.deleted = false
AND t.truckLanceCode IS NOT NULL
AND trim(t.truckLanceCode) <> ''
ORDER BY t.truckLanceCode ASC,
CASE WHEN t.remark IS NULL OR trim(t.remark) = '' THEN '' ELSE trim(t.remark) END ASC,
t.loadingSequence ASC,
t.id ASC
"""
)
fun findAllForRouteBoard(): List<Truck>

/**
* 單一 UPDATE 寫入整條 lane 的 logistic,避免先 JOIN FETCH 載入再逐列 save(大車線會極慢)。
*/
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
"""
UPDATE Truck t SET t.logistic = :logistic
WHERE t.truckLanceCode = :truckLanceCode
AND t.deleted = false
AND (
(:blankRemark = true AND (t.remark IS NULL OR trim(t.remark) = ''))
OR (:blankRemark = false AND trim(t.remark) = :exactRemark)
)
""",
)
fun bulkUpdateLogisticForLaneGroup(
@Param("logistic") logistic: Logistic?,
@Param("truckLanceCode") truckLanceCode: String,
@Param("blankRemark") blankRemark: Boolean,
@Param("exactRemark") exactRemark: String?,
): Int

fun findAllByTruckLanceCodeAndStoreIdAndDeletedFalse(truckLanceCode: String, storeId: String): List<Truck>
fun findByShopNameAndStoreIdAndTruckLanceCode(shopName: String, storeId: String, truckLanceCode: String): Truck?
fun findByShopCodeAndStoreId(shopCode: String, storeId: String): Truck?
/** 同店同樓層重複列時取 id 最小一筆,避免 NonUniqueResultException */
fun findFirstByShopCodeAndStoreIdAndDeletedFalseOrderByIdAsc(
shopCode: String,
storeId: String,
): Truck?


fun findByShopIdAndStoreId(shopId: Long, storeId: String): Truck?
@@ -60,15 +135,17 @@ fun findByShopIdAndStoreIdAndDayOfWeek(
SELECT t.*
FROM truck t
INNER JOIN (
SELECT TruckLanceCode, remark, MIN(id) as min_id
SELECT TruckLanceCode,
COALESCE(NULLIF(TRIM(remark), ''), '') AS remark_norm,
MIN(id) AS min_id
FROM truck
WHERE deleted = false
AND TruckLanceCode IS NOT NULL
GROUP BY TruckLanceCode, remark
GROUP BY TruckLanceCode, COALESCE(NULLIF(TRIM(remark), ''), '')
) AS unique_combos
ON t.id = unique_combos.min_id
WHERE t.deleted = false
ORDER BY t.TruckLanceCode, t.remark
ORDER BY t.TruckLanceCode, COALESCE(NULLIF(TRIM(t.remark), ''), '')
"""
)
fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck>


+ 202
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteLaneExcelSupport.kt Zobrazit soubor

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

/**
* MTMS 車線 Excel(PDF 圖1):每個車線一個 worksheet,格式版本 MTMS_ROUTE_V1。
* laneId 與前端 [encodeLaneId] 一致:`encodeURIComponent(code)|encodeURIComponent(remark)`。
*/
object RouteLaneExcelSupport {
const val FORMAT_MARKER = "MTMS_ROUTE_V1"
const val SEP = "|"

/** 0-based row indices */
const val ROW_MARKER = 0
const val ROW_STORE = 1
const val ROW_DEPARTURE_DEFAULT = 2
const val ROW_HEADER = 3
const val ROW_FIRST_DATA = 4

const val COL_META_A = 0
const val COL_META_B = 1
const val COL_META_C = 2

const val COL_AREA_PLATE = 0
const val COL_SHOP_NAME = 1
const val COL_BRAND = 2
const val COL_SHOP_CODE = 3
const val COL_SCHEDULE = 4
const val COL_DEPARTURE_ROW = 5

fun decodeLaneId(laneId: String): Pair<String, String?>? {
val i = laneId.indexOf(SEP)
if (i < 0) return null
return try {
val code = URLDecoder.decode(laneId.substring(0, i), StandardCharsets.UTF_8).trim()
val rem = URLDecoder.decode(laneId.substring(i + SEP.length), StandardCharsets.UTF_8).trim()
if (code.isEmpty()) return null
code to if (rem.isEmpty()) null else rem
} catch (_: Exception) {
null
}
}

fun plateLabel(groupIndexZeroBased: Int): String {
val n = groupIndexZeroBased + 1
val digits = arrayOf("一", "二", "三", "四", "五", "六", "七", "八", "九", "十")
val cn = when {
n in 1..10 -> digits[n - 1]
n in 11..19 -> "十" + digits[n - 11]
else -> "$n"
}
return "板$cn"
}

fun uniqueSheetName(workbook: Workbook, truckLanceCode: String, remark: String?): String {
val remarkPart = remark?.trim()?.takeIf { it.isNotEmpty() }?.let { "_${it.take(8)}" } ?: ""
val raw = (truckLanceCode.take(22) + remarkPart).take(31)
var base = WorkbookUtil.createSafeSheetName(raw).take(31)
if (base.isEmpty()) base = "Lane"
var name = base
var i = 0
while (workbook.getSheet(name) != null) {
val suffix = "_$i"
val truncated = base.take((31 - suffix.length).coerceAtLeast(1))
name = WorkbookUtil.createSafeSheetName(truncated + suffix).take(31)
i++
}
return name
}

private data class RouteLaneExportStyles(
val metaKey: XSSFCellStyle,
val metaValue: XSSFCellStyle,
val header: XSSFCellStyle,
val data: XSSFCellStyle,
val dataAlt: XSSFCellStyle,
)

private fun buildExportStyles(wb: XSSFWorkbook): RouteLaneExportStyles {
fun XSSFCellStyle.borders() {
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

val metaKey = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_40_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders()
val f = wb.createFont() as XSSFFont
f.bold = true
f.fontHeightInPoints = 11
setFont(f)
}
val metaValue = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.WHITE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders()
val f = wb.createFont()
f.fontHeightInPoints = 11
setFont(f)
}
val headerFont = (wb.createFont() as XSSFFont).apply {
bold = true
fontHeightInPoints = 11
color = IndexedColors.WHITE.index
}
val header = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.ROYAL_BLUE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders()
setFont(headerFont)
}
val data = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
wrapText = true
borders()
val f = wb.createFont()
f.fontHeightInPoints = 11
setFont(f)
}
val dataAlt = (wb.createCellStyle() as XSSFCellStyle).apply {
cloneStyleFrom(data)
fillPattern = FillPatternType.SOLID_FOREGROUND
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
}
return RouteLaneExportStyles(metaKey, metaValue, header, data, dataAlt)
}

/**
* 表頭/邊框/隔行底色/欄寬/凍結首列資料之上/AutoFilter。不改儲存格值(import 仍讀 raw)。
*/
fun applyRouteLaneExportFinishing(
sheet: Sheet,
wb: XSSFWorkbook,
firstDataRow: Int,
lastDataRow: Int,
) {
val st = buildExportStyles(wb)

for (r in intArrayOf(ROW_MARKER, ROW_STORE, ROW_DEPARTURE_DEFAULT)) {
val row = sheet.getRow(r) ?: continue
for (c in 0..COL_META_C) {
val cell = row.getCell(c) ?: continue
cell.cellStyle = if (c == COL_META_A) st.metaKey else st.metaValue
}
}

val headerRow = sheet.getRow(ROW_HEADER)
if (headerRow != null) {
for (c in COL_AREA_PLATE..COL_DEPARTURE_ROW) {
headerRow.getCell(c)?.cellStyle = st.header
}
}

if (lastDataRow >= firstDataRow) {
for (r in firstDataRow..lastDataRow) {
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) {
row.getCell(c)?.cellStyle = style
}
}
}

sheet.setColumnWidth(COL_AREA_PLATE, 14 * 256)
sheet.setColumnWidth(COL_SHOP_NAME, 28 * 256)
sheet.setColumnWidth(COL_BRAND, 14 * 256)
sheet.setColumnWidth(COL_SHOP_CODE, 12 * 256)
sheet.setColumnWidth(COL_SCHEDULE, 12 * 256)
sheet.setColumnWidth(COL_DEPARTURE_ROW, 12 * 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),
)
}
}

+ 359
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/RouteReportExcelSupport.kt Zobrazit soubor

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.RegionUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook

object RouteReportExcelSupport {
const val SHEET_NAME = "車線Report"
const val BLOCK_WIDTH = 2 // 每間物流公司一個 block:2 欄

data class Styles(
val title: XSSFCellStyle,
val titlePreparedBy: XSSFCellStyle,
val company: XSSFCellStyle,
val plate: XSSFCellStyle,
val timeHeader: XSSFCellStyle,
val laneLeft: XSSFCellStyle,
val laneFill: XSSFCellStyle,
val district: XSSFCellStyle,
val shopNo: XSSFCellStyle,
val shopText: XSSFCellStyle,
val total: XSSFCellStyle,
val driverLabel: XSSFCellStyle,
val driverValue: XSSFCellStyle,
)

private fun borders(st: XSSFCellStyle, border: BorderStyle = BorderStyle.THIN) {
st.borderTop = border
st.borderBottom = border
st.borderLeft = border
st.borderRight = border
}

fun buildStyles(wb: XSSFWorkbook): Styles {
fun font(
size: Short,
bold: Boolean = false,
color: Short? = null,
): XSSFFont {
val f = wb.createFont() as XSSFFont
f.fontHeightInPoints = size
f.bold = bold
if (color != null) f.color = color
return f
}

val title = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.MEDIUM)
setFont(font(16, bold = true))
}

val titlePreparedBy = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val company = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(12, bold = true))
}

val plate = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.THIN)
setFont(font(11, bold = true))
}

val timeHeader = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.LIGHT_YELLOW.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val laneLeft = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_40_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true, color = IndexedColors.WHITE.index))
}

val laneFill = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val district = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.LIGHT_CORNFLOWER_BLUE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.THIN)
setFont(font(11, bold = true))
}

val shopNo = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.TOP
borders(this, BorderStyle.THIN)
setFont(font(11, bold = true))
}

val shopText = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.TOP
wrapText = true
borders(this, BorderStyle.THIN)
setFont(font(11))
}

val total = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

val driverLabel = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_40_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true, color = IndexedColors.WHITE.index))
}

val driverValue = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borders(this, BorderStyle.MEDIUM)
setFont(font(11, bold = true))
}

return Styles(
title = title,
titlePreparedBy = titlePreparedBy,
company = company,
plate = plate,
timeHeader = timeHeader,
laneLeft = laneLeft,
laneFill = laneFill,
district = district,
shopNo = shopNo,
shopText = shopText,
total = total,
driverLabel = driverLabel,
driverValue = driverValue,
)
}

private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r)
private fun ensureCell(sheet: Sheet, r: Int, c: Int) =
ensureRow(sheet, r).let { row -> row.getCell(c) ?: row.createCell(c) }

fun styleRange(
sheet: Sheet,
row: Int,
firstCol: Int,
lastCol: Int,
style: XSSFCellStyle,
) {
for (c in firstCol..lastCol) {
ensureCell(sheet, row, c).cellStyle = style
}
}

fun mergeAndStyle(
sheet: Sheet,
row: Int,
firstCol: Int,
lastCol: Int,
style: XSSFCellStyle,
border: BorderStyle = BorderStyle.MEDIUM,
) {
for (c in firstCol..lastCol) {
ensureCell(sheet, row, c).cellStyle = style
}
// POI 不允許 merge 單一 cell(需 2+ cells)。此時只套 style + cell border 即可。
if (firstCol == lastCol) return
val region = CellRangeAddress(row, row, firstCol, lastCol)
sheet.addMergedRegion(region)
RegionUtil.setBorderTop(border, region, sheet)
RegionUtil.setBorderBottom(border, region, sheet)
RegionUtil.setBorderLeft(border, region, sheet)
RegionUtil.setBorderRight(border, region, sheet)
}

fun applyColumnWidths(sheet: Sheet, blockIndex: Int) {
val base = blockIndex * BLOCK_WIDTH
sheet.setColumnWidth(base + 0, 10 * 256)
sheet.setColumnWidth(base + 1, 26 * 256)
}

fun writeTitle(
sheet: Sheet,
st: Styles,
titleText: String,
preparedByText: String,
totalBlocks: Int,
) {
val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0)
val r = 0
// 預留右邊 2 欄顯示「製表: xxx」
val preparedCols = 1.coerceAtMost(lastCol + 1)
val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0)
val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0)

if (preparedFirstCol == 0) {
// 欄位不足:整行仍以 title style 輸出(避免 merge 範圍倒轉)
mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM)
ensureCell(sheet, r, 0).setCellValue("$titleText $preparedByText")
} else {
mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM)
ensureCell(sheet, r, 0).setCellValue(titleText)

mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM)
ensureCell(sheet, r, preparedFirstCol).setCellValue(preparedByText)
}
sheet.getRow(r)?.heightInPoints = 26f
}

data class BlockMeta(
val companyName: String,
val plate: String,
val driverName: String,
val driverNumber: String,
)

/**
* @return 最後寫到的 row index(含)
*/
fun writeCompanyBlock(
sheet: Sheet,
st: Styles,
blockIndex: Int,
startRow: Int,
meta: BlockMeta,
groups: List<TimeGroup>,
totalShopCount: Int,
): Int {
val baseCol = blockIndex * BLOCK_WIDTH
applyColumnWidths(sheet, blockIndex)

var r = startRow

// 公司名
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue(meta.companyName)
sheet.getRow(r)?.heightInPoints = 18f
r++

// 車牌
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN)
ensureCell(sheet, r, baseCol).setCellValue(meta.plate)
r++

for (tg in groups) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue(tg.timeLabel)
r++

for (lg in tg.lanes) {
// 車線標題:左一格強調,右三格補底
ensureCell(sheet, r, baseCol).apply {
cellStyle = st.laneLeft
setCellValue(lg.laneCode)
}
// 2 欄版:右側只剩 1 格(不 merge)
ensureCell(sheet, r, baseCol + 1).cellStyle = st.laneFill
r++

for (dg in lg.districts) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN)
ensureCell(sheet, r, baseCol).setCellValue(dg.districtLabel)
r++

var idx = 1
for (s in dg.shops) {
ensureCell(sheet, r, baseCol).apply {
cellStyle = st.shopNo
setCellValue("$idx.")
}
// shop row 不做 merge:避免 merged regions 爆量導致寫檔/開檔變慢
styleRange(sheet, r, baseCol + 1, baseCol + 1, st.shopText)
ensureCell(sheet, r, baseCol + 1).setCellValue(s)
val lines = (s.count { it == '\n' } + 1).coerceAtLeast(1)
val h = (16f * lines).coerceIn(18f, 72f)
sheet.getRow(r)?.heightInPoints = h
r++
idx++
}
}
}
}

// 分店數目
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount")
r++

// 車長 / driver
ensureCell(sheet, r, baseCol).cellStyle = st.driverLabel
ensureCell(sheet, r, baseCol).setCellValue("車長")
ensureCell(sheet, r, baseCol + 1).cellStyle = st.driverValue
ensureCell(sheet, r, baseCol + 1).setCellValue(meta.driverName)
r++

// driver number
// 2 欄版:電話/司機號碼跨兩欄合併成一格(像截圖的大白格)
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM)
ensureCell(sheet, r, baseCol).setCellValue(meta.driverNumber)
r++

return r - 1
}

data class TimeGroup(
val timeLabel: String,
val lanes: List<LaneGroup>,
)

data class LaneGroup(
val laneCode: String,
val districts: List<DistrictGroup>,
)

data class DistrictGroup(
val districtLabel: String,
val shops: List<String>,
)
}


+ 194
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionReportExcelSupport.kt Zobrazit soubor

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.RegionUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook

object TruckLaneVersionReportExcelSupport {
const val SUMMARY_SHEET = "版本異動報告"

private data class Styles(
val title: XSSFCellStyle,
val metaKey: XSSFCellStyle,
val metaVal: XSSFCellStyle,
val header: XSSFCellStyle,
val normal: XSSFCellStyle,
val added: XSSFCellStyle,
val deleted: XSSFCellStyle,
val moved: XSSFCellStyle,
val edited: XSSFCellStyle,
val highlight: XSSFCellStyle,
)

private fun buildStyles(wb: XSSFWorkbook): Styles {
fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont {
val f = wb.createFont() as XSSFFont
f.fontHeightInPoints = size
f.bold = bold
if (color != null) f.color = color
return f
}

fun style(
align: HorizontalAlignment,
vAlign: VerticalAlignment = VerticalAlignment.CENTER,
bg: Short? = null,
bold: Boolean = false,
size: Short = 11,
border: BorderStyle = BorderStyle.THIN,
): XSSFCellStyle {
return (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = align
verticalAlignment = vAlign
borderTop = border
borderBottom = border
borderLeft = border
borderRight = border
if (bg != null) {
fillForegroundColor = bg
fillPattern = FillPatternType.SOLID_FOREGROUND
}
setFont(font(size, bold = bold))
wrapText = true
}
}

val title = style(HorizontalAlignment.CENTER, bg = IndexedColors.WHITE.index, bold = true, size = 16, border = BorderStyle.MEDIUM)
val metaKey = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.THIN)
val metaVal = style(HorizontalAlignment.LEFT, bg = IndexedColors.WHITE.index, bold = false, border = BorderStyle.THIN)
val header = (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.ROYAL_BLUE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.MEDIUM
borderBottom = BorderStyle.MEDIUM
borderLeft = BorderStyle.MEDIUM
borderRight = BorderStyle.MEDIUM
setFont(font(11, bold = true, color = IndexedColors.WHITE.index))
}
val normal = style(HorizontalAlignment.LEFT)
val added = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_GREEN.index, border = BorderStyle.THIN)
val deleted = style(HorizontalAlignment.LEFT, bg = IndexedColors.ROSE.index, border = BorderStyle.THIN)
val moved = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_YELLOW.index, border = BorderStyle.THIN)
val edited = style(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, border = BorderStyle.THIN)
val highlight = style(HorizontalAlignment.LEFT, bg = IndexedColors.LIGHT_ORANGE.index, border = BorderStyle.THIN, bold = true)
return Styles(title, metaKey, metaVal, header, normal, added, deleted, moved, edited, highlight)
}

private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r)
private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c)

private fun mergeRow(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle) {
for (c in c0..c1) cell(sheet, r, c).cellStyle = style
if (c0 == c1) return
val region = CellRangeAddress(r, r, c0, c1)
sheet.addMergedRegion(region)
RegionUtil.setBorderTop(BorderStyle.MEDIUM, region, sheet)
RegionUtil.setBorderBottom(BorderStyle.MEDIUM, region, sheet)
RegionUtil.setBorderLeft(BorderStyle.MEDIUM, region, sheet)
RegionUtil.setBorderRight(BorderStyle.MEDIUM, region, sheet)
}

data class SummaryMeta(
val title: String,
val editor: String,
val created: String,
val fromVersionId: Long,
val toVersionId: Long,
val note: String?,
val statsText: String,
)

enum class RowType { ADDED, DELETED, MOVED, EDITED }

data class SummaryRow(
val type: RowType,
val shopName: String,
val shopCode: String,
val fromLane: String,
val toLane: String,
val changeText: String,
/** 欄位名集合,用於高亮「變更資訊」cell */
val changedFields: Set<String> = emptySet(),
)

fun writeSummarySheet(wb: XSSFWorkbook, meta: SummaryMeta, rows: List<SummaryRow>) {
val st = buildStyles(wb)
val sheet = wb.createSheet(SUMMARY_SHEET)

// column widths
sheet.setColumnWidth(0, 10 * 256) // type
sheet.setColumnWidth(1, 22 * 256) // shop
sheet.setColumnWidth(2, 12 * 256) // code
sheet.setColumnWidth(3, 18 * 256) // from
sheet.setColumnWidth(4, 18 * 256) // to
sheet.setColumnWidth(5, 60 * 256) // text

var r = 0
mergeRow(sheet, r, 0, 5, st.title)
cell(sheet, r, 0).setCellValue(meta.title)
sheet.getRow(r)?.heightInPoints = 26f
r++

fun metaRow(k: String, v: String) {
cell(sheet, r, 0).apply { cellStyle = st.metaKey; setCellValue(k) }
mergeRow(sheet, r, 1, 5, st.metaVal)
cell(sheet, r, 1).setCellValue(v)
r++
}

metaRow("編輯者", meta.editor)
metaRow("建立時間", meta.created)
metaRow("版本", "from #${meta.fromVersionId} → to #${meta.toVersionId}")
metaRow("摘要", meta.statsText)
if (!meta.note.isNullOrBlank()) metaRow("備註", meta.note.trim())

r++

// header
val headerRowIndex = r
val headers = listOf("類型", "分店", "代碼", "From 車線", "To 車線", "變更資訊")
for (c in headers.indices) {
cell(sheet, r, c).apply { cellStyle = st.header; setCellValue(headers[c]) }
}
sheet.getRow(r)?.heightInPoints = 18f
r++

for (row in rows) {
val baseStyle =
when (row.type) {
RowType.ADDED -> st.added
RowType.DELETED -> st.deleted
RowType.MOVED -> st.moved
RowType.EDITED -> st.edited
}

fun set(c: Int, v: String, highlight: Boolean = false) {
cell(sheet, r, c).apply {
cellStyle = if (highlight) st.highlight else baseStyle
setCellValue(v)
}
}

set(0, row.type.name)
set(1, row.shopName)
set(2, row.shopCode)
set(3, row.fromLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED)
set(4, row.toLane, highlight = row.type == RowType.MOVED || row.type == RowType.ADDED || row.type == RowType.DELETED)
set(5, row.changeText, highlight = row.changedFields.isNotEmpty())
r++
}

sheet.createFreezePane(0, headerRowIndex + 1)
}
}


+ 300
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionRouteReportExcelSupport.kt Zobrazit soubor

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

import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.RegionUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFFont
import org.apache.poi.xssf.usermodel.XSSFWorkbook

/**
* 版本 Log 用:輸出「車線報告」版面(同正常 RouteReport),但把異動的 shop row 高亮。
*
* 2 欄 block:左序號 / label、右內容。
*/
object TruckLaneVersionRouteReportExcelSupport {
const val SHEET_NAME = "車線報告(版本)"
const val BLOCK_WIDTH = 2

data class Styles(
val title: XSSFCellStyle,
val titlePreparedBy: XSSFCellStyle,
val company: XSSFCellStyle,
val plate: XSSFCellStyle,
val timeHeader: XSSFCellStyle,
val laneLeft: XSSFCellStyle,
val laneFill: XSSFCellStyle,
val district: XSSFCellStyle,
val shopNo: XSSFCellStyle,
val shopText: XSSFCellStyle,
val shopNoChanged: XSSFCellStyle,
val shopTextChanged: XSSFCellStyle,
val total: XSSFCellStyle,
val driverLabel: XSSFCellStyle,
val driverValue: XSSFCellStyle,
)

fun buildStyles(wb: XSSFWorkbook): Styles {
fun font(size: Short, bold: Boolean = false, color: Short? = null): XSSFFont {
val f = wb.createFont() as XSSFFont
f.fontHeightInPoints = size
f.bold = bold
if (color != null) f.color = color
return f
}

fun borders(st: XSSFCellStyle, border: BorderStyle) {
st.borderTop = border
st.borderBottom = border
st.borderLeft = border
st.borderRight = border
}

fun baseCell(
align: HorizontalAlignment,
bg: Short? = null,
bold: Boolean = false,
size: Short = 11,
border: BorderStyle = BorderStyle.THIN,
wrap: Boolean = false,
): XSSFCellStyle {
return (wb.createCellStyle() as XSSFCellStyle).apply {
alignment = align
verticalAlignment = VerticalAlignment.CENTER
borders(this, border)
if (bg != null) {
fillForegroundColor = bg
fillPattern = FillPatternType.SOLID_FOREGROUND
}
setFont(font(size, bold = bold))
wrapText = wrap
}
}

val title = baseCell(HorizontalAlignment.CENTER, bold = true, size = 16, border = BorderStyle.MEDIUM)
val titlePreparedBy = baseCell(HorizontalAlignment.RIGHT, bold = true, size = 11, border = BorderStyle.MEDIUM)
val company = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.GREY_25_PERCENT.index,
bold = true,
size = 12,
border = BorderStyle.MEDIUM,
)
val plate = baseCell(HorizontalAlignment.CENTER, bold = true, border = BorderStyle.THIN)
val timeHeader = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.LIGHT_YELLOW.index,
bold = true,
border = BorderStyle.MEDIUM,
)
val laneLeft = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.GREY_40_PERCENT.index,
bold = true,
border = BorderStyle.MEDIUM,
).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) }

val laneFill = baseCell(
HorizontalAlignment.LEFT,
bg = IndexedColors.GREY_25_PERCENT.index,
bold = true,
border = BorderStyle.MEDIUM,
)

val district = baseCell(
HorizontalAlignment.LEFT,
bg = IndexedColors.LIGHT_CORNFLOWER_BLUE.index,
bold = true,
border = BorderStyle.THIN,
)

val shopNo = baseCell(HorizontalAlignment.RIGHT, border = BorderStyle.THIN).apply {
verticalAlignment = VerticalAlignment.TOP
setFont(font(11, bold = true))
}
val shopText = baseCell(HorizontalAlignment.LEFT, border = BorderStyle.THIN, wrap = true).apply {
verticalAlignment = VerticalAlignment.TOP
}

val shopNoChanged = (wb.createCellStyle() as XSSFCellStyle).apply {
cloneStyleFrom(shopNo)
fillForegroundColor = IndexedColors.LIGHT_ORANGE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
}
val shopTextChanged = (wb.createCellStyle() as XSSFCellStyle).apply {
cloneStyleFrom(shopText)
fillForegroundColor = IndexedColors.LIGHT_ORANGE.index
fillPattern = FillPatternType.SOLID_FOREGROUND
val f = wb.createFont()
f.fontHeightInPoints = 11
f.bold = true
setFont(f)
}

val total = baseCell(HorizontalAlignment.LEFT, bg = IndexedColors.GREY_25_PERCENT.index, bold = true, border = BorderStyle.MEDIUM)
val driverLabel = baseCell(
HorizontalAlignment.CENTER,
bg = IndexedColors.GREY_40_PERCENT.index,
bold = true,
border = BorderStyle.MEDIUM,
).apply { setFont(font(11, bold = true, color = IndexedColors.WHITE.index)) }
val driverValue = baseCell(HorizontalAlignment.LEFT, bold = true, border = BorderStyle.MEDIUM)

return Styles(
title = title,
titlePreparedBy = titlePreparedBy,
company = company,
plate = plate,
timeHeader = timeHeader,
laneLeft = laneLeft,
laneFill = laneFill,
district = district,
shopNo = shopNo,
shopText = shopText,
shopNoChanged = shopNoChanged,
shopTextChanged = shopTextChanged,
total = total,
driverLabel = driverLabel,
driverValue = driverValue,
)
}

private fun ensureRow(sheet: Sheet, r: Int) = sheet.getRow(r) ?: sheet.createRow(r)
private fun cell(sheet: Sheet, r: Int, c: Int) = ensureRow(sheet, r).getCell(c) ?: ensureRow(sheet, r).createCell(c)

private fun mergeAndStyle(sheet: Sheet, r: Int, c0: Int, c1: Int, style: XSSFCellStyle, border: BorderStyle) {
for (c in c0..c1) cell(sheet, r, c).cellStyle = style
if (c0 == c1) return
val region = CellRangeAddress(r, r, c0, c1)
sheet.addMergedRegion(region)
RegionUtil.setBorderTop(border, region, sheet)
RegionUtil.setBorderBottom(border, region, sheet)
RegionUtil.setBorderLeft(border, region, sheet)
RegionUtil.setBorderRight(border, region, sheet)
}

fun applyColumnWidths(sheet: Sheet, blockIndex: Int) {
val base = blockIndex * BLOCK_WIDTH
sheet.setColumnWidth(base + 0, 10 * 256)
sheet.setColumnWidth(base + 1, 30 * 256)
}

data class BlockMeta(
val companyName: String,
val plate: String,
val driverName: String,
val driverNumber: String,
)

data class ShopRow(
val truckRowId: Long,
val text: String,
val changed: Boolean,
)

data class DistrictGroup(
val district: String,
val shops: List<ShopRow>,
)

data class LaneGroup(
val laneLabel: String,
val districts: List<DistrictGroup>,
)

data class TimeGroup(
val timeLabel: String,
val lanes: List<LaneGroup>,
)

fun writeTitle(sheet: Sheet, st: Styles, titleText: String, preparedByText: String, totalBlocks: Int) {
val lastCol = (totalBlocks * BLOCK_WIDTH - 1).coerceAtLeast(0)
val r = 0
val preparedCols = 1.coerceAtMost(lastCol + 1)
val preparedFirstCol = (lastCol - preparedCols + 1).coerceAtLeast(0)
val titleLastCol = (preparedFirstCol - 1).coerceAtLeast(0)

if (preparedFirstCol == 0) {
mergeAndStyle(sheet, r, 0, lastCol, st.title, BorderStyle.MEDIUM)
cell(sheet, r, 0).setCellValue("$titleText $preparedByText")
} else {
mergeAndStyle(sheet, r, 0, titleLastCol, st.title, BorderStyle.MEDIUM)
cell(sheet, r, 0).setCellValue(titleText)
mergeAndStyle(sheet, r, preparedFirstCol, lastCol, st.titlePreparedBy, BorderStyle.MEDIUM)
cell(sheet, r, preparedFirstCol).setCellValue(preparedByText)
}
sheet.getRow(r)?.heightInPoints = 26f
}

fun writeCompanyBlock(
sheet: Sheet,
st: Styles,
blockIndex: Int,
startRow: Int,
meta: BlockMeta,
groups: List<TimeGroup>,
totalShopCount: Int,
): Int {
val baseCol = blockIndex * BLOCK_WIDTH
applyColumnWidths(sheet, blockIndex)
var r = startRow

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.company, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue(meta.companyName)
r++

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.plate, BorderStyle.THIN)
cell(sheet, r, baseCol).setCellValue(meta.plate)
r++

for (tg in groups) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.timeHeader, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue(tg.timeLabel)
r++

for (lg in tg.lanes) {
cell(sheet, r, baseCol).apply { cellStyle = st.laneLeft; setCellValue(lg.laneLabel) }
cell(sheet, r, baseCol + 1).cellStyle = st.laneFill
r++

for (dg in lg.districts) {
mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.district, BorderStyle.THIN)
cell(sheet, r, baseCol).setCellValue(dg.district)
r++

var idx = 1
for (s in dg.shops) {
val noStyle = if (s.changed) st.shopNoChanged else st.shopNo
val txtStyle = if (s.changed) st.shopTextChanged else st.shopText
cell(sheet, r, baseCol).apply { cellStyle = noStyle; setCellValue("$idx.") }
cell(sheet, r, baseCol + 1).apply { cellStyle = txtStyle; setCellValue(s.text) }
val lines = (s.text.count { it == '\n' } + 1).coerceAtLeast(1)
sheet.getRow(r)?.heightInPoints = (16f * lines).coerceIn(18f, 90f)
r++
idx++
}
}
}
}

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.total, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue("分店數目:$totalShopCount")
r++

cell(sheet, r, baseCol).apply { cellStyle = st.driverLabel; setCellValue("車長") }
cell(sheet, r, baseCol + 1).apply { cellStyle = st.driverValue; setCellValue(meta.driverName) }
r++

mergeAndStyle(sheet, r, baseCol, baseCol + 1, st.driverValue, BorderStyle.MEDIUM)
cell(sheet, r, baseCol).setCellValue(meta.driverNumber)
r++

return r - 1
}
}


+ 306
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt Zobrazit soubor

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

import com.ffii.fpsms.modules.logistic.entity.LogisticRepository
import com.ffii.fpsms.modules.pickOrder.entity.*
import com.ffii.fpsms.modules.pickOrder.web.models.LogisticMasterDiffLine
import com.ffii.fpsms.modules.pickOrder.web.models.*
import jakarta.transaction.Transactional
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime

@Service
open class TruckLaneVersionService(
private val truckRepository: TruckRepository,
private val truckLaneVersionRepository: TruckLaneVersionRepository,
private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository,
private val logisticRepository: LogisticRepository,
) {
private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse =
TruckLaneVersionResponse(
id = v.id ?: 0,
truckLanceCode = v.truckLanceCode ?: "",
note = v.note,
created = v.created?.toString(),
modifiedBy = v.modifiedBy,
)

/**
* 全看板 snapshot:`TruckLaneVersion.truckLanceCode` 為空(建立 snapshot 時未指定單線)。
* 另:若 line 上出現多種 `truckLanceCode`,視為全看板誤標成單線的舊資料,仍應對「整個 findAllForRouteBoard」做 extras 軟刪。
*/
private fun isFullBoardSnapshot(
version: TruckLaneVersion,
lines: List<TruckLaneVersionLine>,
): Boolean {
if (version.truckLanceCode.isNullOrBlank()) return true
val distinctLaneCodes =
lines.mapNotNull { it.truckLanceCode?.trim()?.takeIf { c -> c.isNotEmpty() } }.distinct()
return distinctLaneCodes.size > 1
}

@Transactional
open fun createSnapshot(request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse {
val lane = request.truckLanceCode?.trim()?.takeIf { it.isNotEmpty() }

val version = TruckLaneVersion().apply {
this.truckLanceCode = lane
this.note = request.note?.trim()
}
val savedVersion = truckLaneVersionRepository.save(version)

val rows =
if (lane != null) {
truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane)
} else {
truckRepository.findAllForRouteBoard()
}
val lines = rows.map { t ->
TruckLaneVersionLine().apply {
this.truckLaneVersion = savedVersion
this.truckRowId = t.id
this.truckLanceCode = t.truckLanceCode
this.shopCode = t.shopCode
this.branchName = t.shopName
this.districtReference = t.districtReference
this.loadingSequence = t.loadingSequence
this.departureTime = t.departureTime?.toString()
this.storeId = t.storeId?.trim()?.takeIf { it.isNotEmpty() } ?: "-"
this.remark = t.remark
this.logisticId = t.logistic?.id
}
}
if (lines.isNotEmpty()) {
truckLaneVersionLineRepository.saveAll(lines)
}

return toResponse(savedVersion)
}

open fun listVersionsByLane(truckLanceCode: String): List<TruckLaneVersionResponse> {
val lane = truckLanceCode.trim()
return truckLaneVersionRepository
.findAllByTruckLanceCodeAndDeletedFalseOrderByCreatedDesc(lane)
.map(::toResponse)
}

open fun listAllVersions(): List<TruckLaneVersionResponse> {
return truckLaneVersionRepository
.findAllByDeletedFalseOrderByCreatedDesc()
.map(::toResponse)
}

open fun getVersionLines(versionId: Long): List<TruckLaneVersionLineResponse> {
return truckLaneVersionLineRepository
.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId)
.map {
TruckLaneVersionLineResponse(
truckRowId = it.truckRowId ?: 0,
truckLanceCode = it.truckLanceCode,
shopCode = it.shopCode,
branchName = it.branchName,
districtReference = it.districtReference,
loadingSequence = it.loadingSequence,
departureTime = it.departureTime,
storeId = it.storeId ?: "",
remark = it.remark,
logisticId = it.logisticId,
)
}
}

open fun diff(fromVersionId: Long, toVersionId: Long): TruckLaneVersionDiffResponse {
val fromLines = truckLaneVersionLineRepository
.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(fromVersionId)
val toLines = truckLaneVersionLineRepository
.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(toVersionId)

val fromByRow = fromLines.associateBy { it.truckRowId ?: -1 }
val toByRow = toLines.associateBy { it.truckRowId ?: -1 }

val allKeys = (fromByRow.keys + toByRow.keys).filter { it > 0 }.sorted()
val changed = mutableListOf<TruckLaneVersionDiffLine>()

fun s(v: Any?): String? = v?.toString()

allKeys.forEach { key ->
val a = fromByRow[key]
val b = toByRow[key]
val changes = mutableListOf<DiffFieldChange>()

if (s(a?.truckLanceCode) != s(b?.truckLanceCode)) changes.add(DiffFieldChange("truckLanceCode", s(a?.truckLanceCode), s(b?.truckLanceCode)))
if (s(a?.shopCode) != s(b?.shopCode)) changes.add(DiffFieldChange("shopCode", s(a?.shopCode), s(b?.shopCode)))
if (s(a?.branchName) != s(b?.branchName)) changes.add(DiffFieldChange("branchName", s(a?.branchName), s(b?.branchName)))
if (s(a?.districtReference) != s(b?.districtReference)) changes.add(DiffFieldChange("districtReference", s(a?.districtReference), s(b?.districtReference)))
if (s(a?.loadingSequence) != s(b?.loadingSequence)) changes.add(DiffFieldChange("loadingSequence", s(a?.loadingSequence), s(b?.loadingSequence)))
if (s(a?.departureTime) != s(b?.departureTime)) changes.add(DiffFieldChange("departureTime", s(a?.departureTime), s(b?.departureTime)))
if (s(a?.storeId) != s(b?.storeId)) changes.add(DiffFieldChange("storeId", s(a?.storeId), s(b?.storeId)))
if (s(a?.remark) != s(b?.remark)) changes.add(DiffFieldChange("remark", s(a?.remark), s(b?.remark)))
if (s(a?.logisticId) != s(b?.logisticId)) changes.add(DiffFieldChange("logisticId", s(a?.logisticId), s(b?.logisticId)))

if (changes.isNotEmpty()) {
changed.add(
TruckLaneVersionDiffLine(
truckRowId = key,
shopCode = b?.shopCode ?: a?.shopCode,
changes = changes,
)
)
}
}

val fromV = truckLaneVersionRepository.findByIdAndDeletedFalse(fromVersionId)
val toV = truckLaneVersionRepository.findByIdAndDeletedFalse(toVersionId)
val logisticMasterChanges =
if (fromV != null && toV != null) {
diffLogisticMastersBetweenVersions(fromV, toV)
} else {
emptyList()
}

return TruckLaneVersionDiffResponse(
fromVersionId = fromVersionId,
toVersionId = toVersionId,
changed = changed,
logisticMasterChanges = logisticMasterChanges,
)
}

/**
* 物流主檔在兩個版本快照時間之間的新增/修改(含尚未指派到任何 truck 列者)。
*/
private fun diffLogisticMastersBetweenVersions(
fromVersion: TruckLaneVersion,
toVersion: TruckLaneVersion,
): List<LogisticMasterDiffLine> {
val fromAt = fromVersion.created ?: return emptyList()
val toAt = toVersion.created ?: return emptyList()
if (!toAt.isAfter(fromAt)) return emptyList()

fun inOpenInterval(ts: java.time.LocalDateTime?): Boolean {
if (ts == null) return false
return ts.isAfter(fromAt) && !ts.isAfter(toAt)
}

val out = ArrayList<LogisticMasterDiffLine>()
for (l in logisticRepository.findAllByDeletedFalseOrderByIdAsc()) {
val id = l.id ?: continue
val name = l.logisticName?.trim().orEmpty().ifEmpty { "—" }
val plate = l.carPlate?.trim().orEmpty().ifEmpty { "—" }
val created = l.created
val modified = l.modified

if (inOpenInterval(created)) {
out.add(
LogisticMasterDiffLine(
logisticId = id,
type = "ADDED",
logisticName = name,
carPlate = plate,
changeText = "新增物流公司:$name($plate)",
),
)
continue
}

if (created != null && !created.isAfter(fromAt) && inOpenInterval(modified)) {
out.add(
LogisticMasterDiffLine(
logisticId = id,
type = "EDITED",
logisticName = name,
carPlate = plate,
changeText = "修改物流公司:$name($plate)",
),
)
}
}
return out
}

@Transactional
open fun updateNote(versionId: Long, note: String?): TruckLaneVersionResponse {
val v = truckLaneVersionRepository.findByIdAndDeletedFalse(versionId)
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Version not found: $versionId")
val trimmed = note?.trim()?.takeIf { it.isNotEmpty() }
v.note = trimmed
return toResponse(truckLaneVersionRepository.save(v))
}

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

val lines = truckLaneVersionLineRepository.findAllByTruckLaneVersion_IdAndDeletedFalseOrderByIdAsc(versionId)
if (lines.isEmpty()) return "No lines to restore for versionId=$versionId"

val snapshottedIds = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet()
if (snapshottedIds.isEmpty()) {
return "No valid truckRowIds in snapshot for versionId=$versionId"
}

val fullBoard = isFullBoardSnapshot(version, lines)
if (fullBoard) {
val currentAll = truckRepository.findAllForRouteBoard()
val extras = currentAll.filter { t -> t.id != null && t.id !in snapshottedIds }
extras.forEach { it.deleted = true }
if (extras.isNotEmpty()) {
truckRepository.saveAll(extras)
}
} else {
val lane = version.truckLanceCode!!.trim()
if (lane.isNotEmpty()) {
val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane)
val extras = currentLane.filter { t -> t.id != null && t.id !in snapshottedIds }
extras.forEach { it.deleted = true }
if (extras.isNotEmpty()) {
truckRepository.saveAll(extras)
}
}
}

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

val updated = lines.mapNotNull { line ->
val truckId = line.truckRowId ?: return@mapNotNull null
if (truckId <= 0) return@mapNotNull null
val truck = trucksById[truckId] ?: return@mapNotNull null

truck.deleted = false
truck.apply {
// Restore only the fields we snapshot.
this.truckLanceCode = line.truckLanceCode ?: version.truckLanceCode
this.loadingSequence = line.loadingSequence
this.districtReference = line.districtReference
val sid = line.storeId?.trim()?.takeUnless { it.isEmpty() || it == "-" }
if (sid != null) this.storeId = sid
this.shopCode = line.shopCode
this.shopName = line.branchName
this.remark = line.remark
this.departureTime =
line.departureTime?.trim()?.takeIf { it.isNotEmpty() }?.let { LocalTime.parse(it) }
val lid = line.logisticId
this.logistic =
if (lid != null && lid > 0) {
logisticRepository.findByIdAndDeletedFalse(lid)
} else {
null
}
}
}
if (updated.isNotEmpty()) {
truckRepository.saveAll(updated)
}

createSnapshot(
CreateTruckLaneSnapshotRequest(
truckLanceCode = null,
note = "restore from versionId=$versionId",
)
)

return "Restored versionId=$versionId"
}
}

+ 1309
- 31
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckService.kt
Diff nebyl zobrazen, protože je příliš veliký
Zobrazit soubor


+ 218
- 15
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt Zobrazit soubor

@@ -7,7 +7,11 @@ import org.springframework.web.bind.ServletRequestBindingException
import jakarta.servlet.http.HttpServletRequest
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.http.ContentDisposition
import org.springframework.http.HttpHeaders
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import java.nio.charset.StandardCharsets
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartHttpServletRequest

@@ -17,12 +21,19 @@ import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest
import com.ffii.fpsms.modules.pickOrder.service.TruckService
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse
import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest
import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteReportRequest
import com.ffii.fpsms.modules.pickOrder.web.models.ExportTruckLaneVersionReportExcelRequest
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 jakarta.validation.Valid

@RestController
@RequestMapping("/truck")
class TruckController(
open class TruckController(
private val truckService: TruckService,
private val truckRepository: TruckRepository,
) {
@@ -80,6 +91,142 @@ class TruckController(
}
}

/**
* PDF 圖1:多車線匯出;每個 laneId(encodeLaneId)一個 worksheet,格式 MTMS_ROUTE_V1。
*/
@PostMapping(
"/exportRouteLanesExcel",
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
)
fun exportRouteLanesExcel(@RequestBody req: ExportRouteLanesRequest): ResponseEntity<ByteArray> {
val bytes = truckService.exportRouteLanesExcelBytes(req.laneIds)
val filename = "MTMS_車線_${System.currentTimeMillis()}.xlsx"
val disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(bytes)
}

/**
* 圖2:車線 Report(單一 sheet;每間物流公司一個水平區塊)
*/
@PostMapping(
"/exportRouteReportExcel",
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
)
fun exportRouteReportExcel(
request: HttpServletRequest,
@RequestBody req: ExportRouteReportRequest,
): ResponseEntity<ByteArray> {
val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user"
val bytes = truckService.exportRouteReportExcelBytes(req.laneIds, preparedBy)
val filename = truckService.buildRouteReportFilename()
val disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(bytes)
}

@PostMapping(
"/exportTruckLaneVersionReportExcel",
consumes = [MediaType.APPLICATION_JSON_VALUE],
produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
)
open fun exportTruckLaneVersionReportExcel(
request: HttpServletRequest,
@RequestBody req: ExportTruckLaneVersionReportExcelRequest,
): ResponseEntity<ByteArray> {
val preparedBy = request.userPrincipal?.name?.trim().takeUnless { it.isNullOrBlank() } ?: "current user"
val bytes = truckService.exportTruckLaneVersionReportExcelBytes(
TruckService.ExportTruckLaneVersionReportInput(
fromVersionId = req.fromVersionId,
toVersionId = req.toVersionId,
preparedBy = preparedBy,
),
)
val filename = "車線版本報告_${System.currentTimeMillis()}.xlsx"
val disposition = ContentDisposition.attachment()
.filename(filename, StandardCharsets.UTF_8)
.build()
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(bytes)
}

/** 與 [importRouteLanesExcel] 同一格式;僅解析、不寫入 DB(看板 staged import 預覽)。 */
@PostMapping("/parseRouteLanesExcel")
@Throws(ServletRequestBindingException::class)
fun parseRouteLanesExcel(request: HttpServletRequest): ResponseEntity<ParseRouteLanesExcelResponse> {
var workbook: Workbook? = null
try {
val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList")
workbook = XSSFWorkbook(multipartFile?.inputStream)
return ResponseEntity.ok(truckService.parseRouteLanesExcel(workbook))
} catch (e: Exception) {
println("Error reading Excel file: ${e.message}")
return ResponseEntity.badRequest().body(
ParseRouteLanesExcelResponse(0, 0, emptyList()),
)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}

/** 與 [exportRouteLanesExcel] 同一格式;一個檔案內多 sheet,每 sheet 一條車線。 */
@PostMapping("/importRouteLanesExcel")
@Throws(ServletRequestBindingException::class)
fun importRouteLanesExcel(request: HttpServletRequest): ResponseEntity<*> {
var workbook: Workbook? = null
try {
val multipartFile = (request as MultipartHttpServletRequest).getFile("multipartFileList")
workbook = XSSFWorkbook(multipartFile?.inputStream)
} catch (e: Exception) {
println("Error reading Excel file: ${e.message}")
return ResponseEntity.badRequest().body(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = "Error reading Excel file: ${e.message}",
errorPosition = null,
entity = null,
),
)
}
try {
val result = truckService.importRouteLanesExcel(workbook)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = result,
errorPosition = null,
entity = null,
),
)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}

@PostMapping("/importExcel")
@Throws(ServletRequestBindingException::class)
fun importExcel(request: HttpServletRequest): ResponseEntity<*> {
@@ -103,18 +250,25 @@ class TruckController(
)
}

val result = truckService.importExcel(workbook)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = result,
errorPosition = null,
entity = null
try {
val result = truckService.importExcel(workbook)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = result,
errorPosition = null,
entity = null
)
)
)
} finally {
try {
workbook?.close()
} catch (_: Exception) {
}
}
}

@GetMapping("/findTruckLane/{shopId}")
@@ -136,7 +290,7 @@ class TruckController(
type = "truck",
message = if (truck != null) "Truck lane updated successfully" else "Truck lane not found",
errorPosition = null,
entity = truck
entity = null
)
} catch (e: Exception) {
return MessageResponse(
@@ -151,6 +305,32 @@ class TruckController(
}
}

@PostMapping("/updateLaneLogistic")
fun updateLaneLogistic(@Valid @RequestBody request: UpdateLaneLogisticRequest): MessageResponse {
try {
val n = truckService.updateLogisticForEntireLane(request)
return MessageResponse(
id = null,
name = null,
code = request.truckLanceCode,
type = "truck",
message = "Updated logistic for $n truck row(s)",
errorPosition = null,
entity = null,
)
} catch (e: Exception) {
return MessageResponse(
id = null,
name = null,
code = null,
type = "truck",
message = "Error: ${e.message}",
errorPosition = null,
entity = null,
)
}
}

@PostMapping("/deleteTruckLane")
fun deleteTruckLane(@Valid @RequestBody request: deleteTruckLane): MessageResponse {
try {
@@ -178,8 +358,10 @@ class TruckController(
}

@GetMapping("/findAllUniqueTruckLanceCodeAndRemarkCombinations")
fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<Truck> {
return truckService.findAllUniqueTruckLanceCodeAndRemarkCombinations()
fun findAllUniqueTruckLanceCodeAndRemarkCombinations(): List<TruckLaneCombinationResponse> {
return truckService
.findAllUniqueTruckLanceCodeAndRemarkCombinations()
.map { it.toLaneCombinationResponse() }
}


@@ -193,6 +375,27 @@ class TruckController(
return truckService.findAllByTruckLanceCodeAndDeletedFalse(truckLanceCode)
}

/**
* Filter trucks by the same (truckLanceCode, remark) group as the unique-combinations query.
* Omit `remark` or pass empty for rows with NULL/empty remark.
*/
@GetMapping("/findAllByTruckLanceCodeAndRemarkAndDeletedFalse")
fun findAllByTruckLanceCodeAndRemarkAndDeletedFalse(
@RequestParam truckLanceCode: String,
@RequestParam(required = false) remark: String?,
): List<Truck> {
return truckService.findAllByTruckLanceCodeAndRemarkAndDeletedFalse(truckLanceCode, remark)
}

/**
* RouteBoard O(1) load: return all truck rows (deleted=false) once.
* Frontend groups by (truckLanceCode, normalizedRemark).
*/
@GetMapping("/findAllForRouteBoard")
fun findAllForRouteBoard(): List<Truck> {
return truckService.findAllForRouteBoard()
}

@GetMapping("/findAllUniqueShopNamesAndCodesFromTrucks")
fun findAllUniqueShopNamesAndCodesFromTrucks(): List<Map<String, String>> {
return truckService.findAllUniqueShopNamesAndCodesFromTrucks()


+ 73
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneVersionController.kt Zobrazit soubor

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

import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.pickOrder.service.TruckLaneVersionService
import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckLaneSnapshotRequest
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionDiffResponse
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionLineResponse
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneVersionResponse
import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckLaneVersionNoteRequest
import jakarta.validation.Valid
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/truckLaneVersion")
class TruckLaneVersionController(
private val truckLaneVersionService: TruckLaneVersionService,
) {
@PostMapping("/snapshot")
fun createSnapshot(@Valid @RequestBody request: CreateTruckLaneSnapshotRequest): TruckLaneVersionResponse {
return truckLaneVersionService.createSnapshot(request)
}

@GetMapping
fun listVersions(
@RequestParam(required = false) truckLanceCode: String?,
): List<TruckLaneVersionResponse> {
val lane = truckLanceCode?.trim()?.takeIf { it.isNotEmpty() }
return if (lane != null) {
truckLaneVersionService.listVersionsByLane(lane)
} else {
truckLaneVersionService.listAllVersions()
}
}

@GetMapping("/{versionId}/lines")
fun getLines(@PathVariable versionId: Long): List<TruckLaneVersionLineResponse> {
return truckLaneVersionService.getVersionLines(versionId)
}

@PatchMapping("/{versionId}/note")
fun updateNote(
@PathVariable versionId: Long,
@Valid @RequestBody request: UpdateTruckLaneVersionNoteRequest,
): TruckLaneVersionResponse {
return truckLaneVersionService.updateNote(versionId, request.note)
}

@GetMapping("/diff")
fun diff(
@RequestParam fromVersionId: Long,
@RequestParam toVersionId: Long,
): TruckLaneVersionDiffResponse {
return truckLaneVersionService.diff(fromVersionId, toVersionId)
}

@PostMapping("/{versionId}/restore")
fun restore(@PathVariable versionId: Long): ResponseEntity<MessageResponse> {
val msg = truckLaneVersionService.restore(versionId)
return ResponseEntity.ok(
MessageResponse(
id = null,
name = null,
code = null,
type = "OK",
message = msg,
errorPosition = null,
entity = null,
)
)
}
}


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteLanesRequest.kt Zobrazit soubor

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

data class ExportRouteLanesRequest(
val laneIds: List<String>,
)

+ 11
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportRouteReportRequest.kt Zobrazit soubor

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

/**
* 匯出「車線 Report」(圖2):單一 workbook/單 sheet。
* laneIds 與前端 encodeLaneId 一致:encodeURIComponent(code)|encodeURIComponent(remark)。
* 若 laneIds 為空,視為匯出 RouteBoard 全部車線。
*/
data class ExportRouteReportRequest(
val laneIds: List<String> = emptyList(),
)


+ 7
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ExportTruckLaneVersionReportExcelRequest.kt Zobrazit soubor

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

data class ExportTruckLaneVersionReportExcelRequest(
val fromVersionId: Long,
val toVersionId: Long,
)


+ 22
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/ParseRouteLanesExcelModels.kt Zobrazit soubor

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

/** Preview row for staged route Excel import (no DB write). */
data class RouteLaneImportPreviewRow(
val truckRowId: Long?,
val truckLanceCode: String,
val remark: String?,
val storeId: String,
val departureTime: String,
val shopId: Long,
val shopName: String,
val shopCode: String,
val loadingSequence: Int,
val districtReference: String?,
val logisticId: Long?,
)

data class ParseRouteLanesExcelResponse(
val sheetCount: Int,
val rowCount: Int,
val rows: List<RouteLaneImportPreviewRow>,
)

+ 15
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SaveTruckRequest.kt Zobrazit soubor

@@ -1,4 +1,5 @@
package com.ffii.fpsms.modules.pickOrder.web.models
import jakarta.validation.constraints.NotBlank
import java.time.LocalTime
data class SaveTruckRequest(
val id: Long? = null,
@@ -11,6 +12,7 @@ data class SaveTruckRequest(
val loadingSequence: Int,
val remark: String? = null,
val districtReference: String? = null,
val logisticId: Long? = null,
)
data class SaveTruckLane(
val id: Long,
@@ -19,7 +21,10 @@ data class SaveTruckLane(
val loadingSequence: Long,
val districtReference: String?,
val storeId: String,
val remark: String? = null
val remark: String? = null,
val logisticId: Long? = null,
/** When true, apply [logisticId] (including null to clear); when false, leave truck.logistic unchanged. */
val updateLogistic: Boolean = false,
)
data class deleteTruckLane(
val id: Long
@@ -39,4 +44,13 @@ data class CreateTruckWithoutShopRequest(
val loadingSequence: Int = 0,
val districtReference: String? = null,
val remark: String? = null,
val logisticId: Long? = null,
)

/** 單一 transaction 更新同 (truckLanceCode, remark) 桶內所有 truck 的 logistic。 */
data class UpdateLaneLogisticRequest(
@field:NotBlank
val truckLanceCode: String,
val remark: String? = null,
val logisticId: Long? = null,
)

+ 34
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneCombinationResponse.kt Zobrazit soubor

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

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

/**
* 僅供 `findAllUniqueTruckLanceCodeAndRemarkCombinations` 回傳,避免序列化 JPA
* 關聯(shop / logistic)產生超大或非法 JSON。
*/
data class TruckLaneCombinationResponse(
val id: Long,
val truckLanceCode: String?,
val departureTime: LocalTime?,
val loadingSequence: Int?,
val districtReference: String?,
val storeId: String?,
val remark: String?,
val shopName: String?,
val shopCode: String?,
)

fun Truck.toLaneCombinationResponse(): TruckLaneCombinationResponse {
return TruckLaneCombinationResponse(
id = this.id ?: 0L,
truckLanceCode = this.truckLanceCode,
departureTime = this.departureTime,
loadingSequence = this.loadingSequence,
districtReference = this.districtReference,
storeId = this.storeId,
remark = this.remark,
shopName = this.shopName,
shopCode = this.shopCode,
)
}

+ 65
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneVersionModels.kt Zobrazit soubor

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

import jakarta.validation.constraints.Size

data class CreateTruckLaneSnapshotRequest(
@field:Size(max = 100)
val truckLanceCode: String? = null,
@field:Size(max = 500)
val note: String? = null,
)

data class RestoreTruckLaneSnapshotRequest(
val versionId: Long,
)

data class TruckLaneVersionResponse(
val id: Long,
val truckLanceCode: String,
val note: String?,
val created: String?,
/** BaseEntity `modifiedBy`:最後寫入此快照的使用者(通常為 JWT name / staffNo) */
val modifiedBy: String?,
)

data class TruckLaneVersionLineResponse(
val truckRowId: Long,
val truckLanceCode: String?,
val shopCode: String?,
val branchName: String?,
val districtReference: String?,
val loadingSequence: Int?,
val departureTime: String?,
val storeId: String,
val remark: String?,
val logisticId: Long?,
)

data class DiffFieldChange(
val field: String,
val from: String?,
val to: String?,
)

data class TruckLaneVersionDiffLine(
val truckRowId: Long,
val shopCode: String?,
val changes: List<DiffFieldChange>,
)

/** 物流主檔異動(版本區間內新增/修改;不依賴 truck 列是否已指派) */
data class LogisticMasterDiffLine(
val logisticId: Long,
val type: String,
val logisticName: String,
val carPlate: String,
val changeText: String,
)

data class TruckLaneVersionDiffResponse(
val fromVersionId: Long,
val toVersionId: Long,
val changed: List<TruckLaneVersionDiffLine>,
val logisticMasterChanges: List<LogisticMasterDiffLine> = emptyList(),
)


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/UpdateTruckLaneVersionNoteRequest.kt Zobrazit soubor

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

import jakarta.validation.constraints.Size

data class UpdateTruckLaneVersionNoteRequest(
@field:Size(max = 500)
val note: String? = null,
)

+ 130
- 0
src/main/resources/db/changelog/changes/20260430_02_2fi/01_truck_lane_version_snapshot.sql Zobrazit soubor

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

CREATE TABLE IF NOT EXISTS `truck_lane_version`
(
`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',
`storeId` VARCHAR(10) NOT NULL,
`truckLanceCode` VARCHAR(100) NOT NULL,
`note` VARCHAR(500) NULL DEFAULT NULL,
CONSTRAINT pk_truck_lane_version PRIMARY KEY (`id`)
);

-- When upgrading an existing database, CREATE TABLE IF NOT EXISTS will not add missing columns.
-- Old DB snapshots might already have `truck_lane_version` without `storeId`, which would break the index creation below.
SET @col_tlv_storeId := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND column_name = 'storeId'
);
SET @sql_add_tlv_storeId := IF(
@col_tlv_storeId = 0,
'ALTER TABLE `truck_lane_version` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `deleted`',
'SELECT 1'
);
PREPARE stmt_add_tlv_storeId FROM @sql_add_tlv_storeId;
EXECUTE stmt_add_tlv_storeId;
DEALLOCATE PREPARE stmt_add_tlv_storeId;

SET @idx_tlv := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND index_name = 'idx_tlv_lane'
);
SET @col_tlv_truckLanceCode := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND column_name = 'truckLanceCode'
);
SET @sql_tlv := IF(
@idx_tlv = 0 AND @col_tlv_storeId > 0 AND @col_tlv_truckLanceCode > 0,
'CREATE INDEX idx_tlv_lane ON `truck_lane_version` (`storeId`, `truckLanceCode`, `created`)',
'SELECT 1'
);
PREPARE stmt_tlv FROM @sql_tlv;
EXECUTE stmt_tlv;
DEALLOCATE PREPARE stmt_tlv;

CREATE TABLE IF NOT EXISTS `truck_lane_version_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',
`truckLaneVersionId` BIGINT NOT NULL,
`truckRowId` BIGINT NOT NULL,
`shopCode` VARCHAR(50) NULL DEFAULT NULL,
`branchName` VARCHAR(255) NULL DEFAULT NULL,
`districtReference` VARCHAR(255) NULL DEFAULT NULL,
`loadingSequence` INT NULL DEFAULT NULL,
`departureTime` VARCHAR(30) NULL DEFAULT NULL,
`storeId` VARCHAR(10) NOT NULL,
`remark` VARCHAR(255) NULL DEFAULT NULL,
CONSTRAINT pk_truck_lane_version_line PRIMARY KEY (`id`),
CONSTRAINT fk_tlvl_version FOREIGN KEY (`truckLaneVersionId`) REFERENCES `truck_lane_version` (`id`)
);

SET @col_tlvl_storeId := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'storeId'
);
SET @sql_add_tlvl_storeId := IF(
@col_tlvl_storeId = 0,
'ALTER TABLE `truck_lane_version_line` ADD COLUMN `storeId` VARCHAR(10) NULL DEFAULT NULL AFTER `departureTime`',
'SELECT 1'
);
PREPARE stmt_add_tlvl_storeId FROM @sql_add_tlvl_storeId;
EXECUTE stmt_add_tlvl_storeId;
DEALLOCATE PREPARE stmt_add_tlvl_storeId;

SET @idx_tlvl_v := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_version'
);
SET @sql_tlvl_v := IF(
@idx_tlvl_v = 0,
'CREATE INDEX idx_tlvl_version ON `truck_lane_version_line` (`truckLaneVersionId`)',
'SELECT 1'
);
PREPARE stmt_tlvl_v FROM @sql_tlvl_v;
EXECUTE stmt_tlvl_v;
DEALLOCATE PREPARE stmt_tlvl_v;

SET @idx_tlvl_tr := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_truck_row'
);
SET @sql_tlvl_tr := IF(
@idx_tlvl_tr = 0,
'CREATE INDEX idx_tlvl_truck_row ON `truck_lane_version_line` (`truckRowId`)',
'SELECT 1'
);
PREPARE stmt_tlvl_tr FROM @sql_tlvl_tr;
EXECUTE stmt_tlvl_tr;
DEALLOCATE PREPARE stmt_tlvl_tr;

+ 46
- 0
src/main/resources/db/changelog/changes/20260430_02_2fi/02_truck_lane_version_snapshot_patch.sql Zobrazit soubor

@@ -0,0 +1,46 @@
-- liquibase formatted sql
-- changeset 2fi:20260430_04_truck_lane_version_snapshot_patch
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'truckLanceCode'

ALTER TABLE `truck_lane_version`
MODIFY COLUMN `truckLanceCode` VARCHAR(100) NULL;

SET @col_tlvl_storeId := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'storeId'
);
SET @col_tlvl_tlc := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'truckLanceCode'
);
SET @sql_add_tlc := IF(
@col_tlvl_tlc = 0,
'ALTER TABLE `truck_lane_version_line` ADD COLUMN `truckLanceCode` VARCHAR(100) NULL DEFAULT NULL AFTER `truckRowId`',
'SELECT 1'
);
PREPARE stmt_add_tlc FROM @sql_add_tlc;
EXECUTE stmt_add_tlc;
DEALLOCATE PREPARE stmt_add_tlc;

SET @idx_tlvl_lane := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_lane'
);
SET @sql_tlvl_lane := IF(
@idx_tlvl_lane = 0 AND @col_tlvl_storeId > 0 AND @col_tlvl_tlc > 0,
'CREATE INDEX idx_tlvl_lane ON `truck_lane_version_line` (`storeId`, `truckLanceCode`)',
'SELECT 1'
);
PREPARE stmt_tlvl_lane FROM @sql_tlvl_lane;
EXECUTE stmt_tlvl_lane;
DEALLOCATE PREPARE stmt_tlvl_lane;

+ 10
- 0
src/main/resources/db/changelog/changes/20260504_01_2fi/01_truck_add_logistic_id.sql Zobrazit soubor

@@ -0,0 +1,10 @@
-- liquibase formatted sql
-- changeset 2fi:20260504_01_truck_add_logistic_id
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck' AND column_name = 'logisticId'

ALTER TABLE `truck`
ADD COLUMN `logisticId` INT NULL;

ALTER TABLE `truck`
ADD CONSTRAINT `fk_truck_logistic` FOREIGN KEY (`logisticId`) REFERENCES `logistic` (`id`);

+ 52
- 0
src/main/resources/db/changelog/changes/20260505_01_2fi/01_truck_lane_version_drop_store_id.sql Zobrazit soubor

@@ -0,0 +1,52 @@
-- liquibase formatted sql
-- changeset 2fi:20260505_01_truck_lane_version_drop_store_id
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version' AND column_name = 'storeId'

SET @idx_tlv := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND index_name = 'idx_tlv_lane'
);
SET @sql_drop_idx := IF(
@idx_tlv > 0,
'DROP INDEX idx_tlv_lane ON `truck_lane_version`',
'SELECT 1'
);
PREPARE stmt_drop_idx FROM @sql_drop_idx;
EXECUTE stmt_drop_idx;
DEALLOCATE PREPARE stmt_drop_idx;

SET @col_tlv_sid := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND column_name = 'storeId'
);
SET @sql_drop_col := IF(
@col_tlv_sid > 0,
'ALTER TABLE `truck_lane_version` DROP COLUMN `storeId`',
'SELECT 1'
);
PREPARE stmt_drop_col FROM @sql_drop_col;
EXECUTE stmt_drop_col;
DEALLOCATE PREPARE stmt_drop_col;

SET @idx_new := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version'
AND index_name = 'idx_tlv_lane_created'
);
SET @sql_new_idx := IF(
@idx_new = 0,
'CREATE INDEX idx_tlv_lane_created ON `truck_lane_version` (`truckLanceCode`, `created`)',
'SELECT 1'
);
PREPARE stmt_new_idx FROM @sql_new_idx;
EXECUTE stmt_new_idx;
DEALLOCATE PREPARE stmt_new_idx;

+ 37
- 0
src/main/resources/db/changelog/changes/20260507_01_2fi/01_truck_lane_version_line_add_logistic_id.sql Zobrazit soubor

@@ -0,0 +1,37 @@
-- liquibase formatted sql
-- changeset 2fi:20260507_01_truck_lane_version_line_add_logisticId
-- preconditions onFail:MARK_RAN
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'truck_lane_version_line' AND column_name = 'logisticId'

SET @col_tlvl_lid := (
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND column_name = 'logisticId'
);
SET @sql_add_tlvl_lid := IF(
@col_tlvl_lid = 0,
'ALTER TABLE `truck_lane_version_line` ADD COLUMN `logisticId` BIGINT NULL DEFAULT NULL AFTER `remark`',
'SELECT 1'
);
PREPARE stmt_add_tlvl_lid FROM @sql_add_tlvl_lid;
EXECUTE stmt_add_tlvl_lid;
DEALLOCATE PREPARE stmt_add_tlvl_lid;

SET @idx_tlvl_lid := (
SELECT COUNT(*)
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'truck_lane_version_line'
AND index_name = 'idx_tlvl_logisticId'
);
SET @sql_tlvl_lid := IF(
@idx_tlvl_lid = 0,
'CREATE INDEX idx_tlvl_logisticId ON `truck_lane_version_line` (`logisticId`)',
'SELECT 1'
);
PREPARE stmt_tlvl_lid FROM @sql_tlvl_lid;
EXECUTE stmt_tlvl_lid;
DEALLOCATE PREPARE stmt_tlvl_lid;


Načítá se…
Zrušit
Uložit