
package com.ffii.fpsms.modules.deliveryOrder.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.bag.entity.BagRepository
import com.ffii.fpsms.modules.bag.service.BagService
import com.ffii.fpsms.modules.bag.web.model.CreateBagLotLineRequest
import com.ffii.fpsms.modules.common.CodeGenerator
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecord
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecordRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecord
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderPickOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.deliveryOrder.enums.DoPickOrderStatus
import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchScanPickRequest
import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.pickOrder.web.models.CompletedDoPickOrderResponse
import com.ffii.fpsms.modules.pickOrder.web.models.FgPickOrderSummary
import com.ffii.fpsms.modules.pickOrder.web.models.GetCompletedDoPickOrdersRequest
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderLineStatus
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus
import com.ffii.fpsms.modules.pickOrder.enums.PickOrderType
import com.ffii.fpsms.modules.pickOrder.service.PickOrderService
import com.ffii.fpsms.modules.pickOrder.service.assembleHierarchicalFgPayload
import com.ffii.fpsms.modules.stock.entity.Inventory
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository
import com.ffii.fpsms.modules.stock.entity.InventoryLotRepository
import com.ffii.fpsms.modules.stock.entity.StockOutLine
import com.ffii.fpsms.modules.stock.entity.StockLedger
import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository
import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository
import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress
import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.deliveryOrder.web.models.StoreLaneSummary
import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneRow
import com.ffii.fpsms.modules.deliveryOrder.web.models.LaneBtn
import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchEtraShopLaneGroup
import com.ffii.fpsms.modules.deliveryOrder.web.models.ReleasedDoPickOrderListItem
import com.ffii.fpsms.modules.deliveryOrder.web.models.WorkbenchTicketReleaseTableResponse
import com.ffii.fpsms.modules.user.service.UserService
import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import kotlin.system.measureTimeMillis
import org.slf4j.LoggerFactory
import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.orm.ObjectOptimisticLockingFailureException
import com.ffii.core.utils.PdfUtils
import com.ffii.core.utils.ZebraPrinterUtil
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import com.ffii.fpsms.modules.master.service.PrinterService
import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderPickOrder
import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderInfo
import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDeliveryNoteRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.ExportDeliveryNoteRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.ExportDNLabelsRequest
import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintRequest
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import org.springframework.core.io.ClassPathResource
import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperPrint
import java.io.File
import java.io.FileNotFoundException
import java.util.Locale
import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.TransactionDefinition
import org.springframework.transaction.support.TransactionSynchronization
import org.springframework.transaction.support.TransactionSynchronizationManager
import org.springframework.transaction.support.TransactionTemplate
/**
 * DO workbench: pick execution and related operations (v1: [scanPick]).
 */
@Service
open class DoWorkbenchMainService(
    private val stockOutLineWorkbenchService: StockOutLineWorkbenchService,
    private val stockOutLIneRepository: StockOutLIneRepository,
    private val pickOrderLineRepository: PickOrderLineRepository,
    private val pickOrderRepository: PickOrderRepository,
    private val inventoryLotLineRepository: InventoryLotLineRepository,
    private val inventoryLotRepository: InventoryLotRepository,
    private val suggestPickLotRepository: SuggestPickLotRepository,
    private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
    private val stockLedgerRepository: StockLedgerRepository,
    private val itemUomService: ItemUomService,
    private val itemUomRespository: ItemUomRespository,
    private val bagRepository: BagRepository,
    private val bagService: BagService,
    private val pickOrderService: PickOrderService,
    private val deliveryOrderService: DeliveryOrderService,
    private val doPickOrderRepository: DoPickOrderRepository,
    private val doPickOrderRecordRepository: DoPickOrderRecordRepository,
    private val doPickOrderLineRepository: DoPickOrderLineRepository,
    private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository,
    private val deliveryOrderPickOrderRepository: DeliveryOrderPickOrderRepository,
    private val deliveryOrderRepository: DeliveryOrderRepository,
    private val jdbcDao: JdbcDao,
    private val truckRepository: TruckRepository,
    private val userService: UserService,
    private val jdbcTemplate: JdbcTemplate,
    private val joPickOrderRepository: JoPickOrderRepository,
    private val printerService: PrinterService,
    private val itemsRepository: ItemsRepository,
    private val transactionManager: PlatformTransactionManager,
) {
    @PersistenceContext
    private lateinit var entityManager: EntityManager

    companion object {
        private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java)
    }

    private sealed interface WorkbenchScanIllResolve {
        data class Found(val ill: InventoryLotLine) : WorkbenchScanIllResolve
        data class NotFound(val message: String) : WorkbenchScanIllResolve
    }

    /**
     * Prefer QR [stockInLineId] → `inventory_lot` → `inventory_lot_line` so duplicate [lotNo] on the same item
     * does not pick the wrong row (`OrderByIdDesc`).
     */
    private fun resolveInventoryLotLineByIdForWorkbenchScan(
        inventoryLotLineId: Long,
        itemId: Long,
        lotNoTrimmed: String,
    ): WorkbenchScanIllResolve {
        val ill = inventoryLotLineRepository.findById(inventoryLotLineId).orElse(null)
            ?: return WorkbenchScanIllResolve.NotFound("Inventory lot line not found (id=$inventoryLotLineId)")
        if (ill.deleted) {
            return WorkbenchScanIllResolve.NotFound("Inventory lot line is deleted")
        }
        if (ill.inventoryLot?.item?.id != itemId) {
            return WorkbenchScanIllResolve.NotFound("Scanned lot line item does not match pick line item")
        }
        val invLotNo = (ill.inventoryLot?.lotNo ?: "").trim()
        if (lotNoTrimmed.isNotEmpty() && invLotNo.isNotEmpty() && invLotNo != lotNoTrimmed) {
            return WorkbenchScanIllResolve.NotFound(
                "Lot number mismatch: inventory has $invLotNo, request was $lotNoTrimmed",
            )
        }
        val expiry = ill.inventoryLot?.expiryDate
        if (expiry != null && expiry.isBefore(LocalDate.now())) {
            return WorkbenchScanIllResolve.NotFound("Lot is expired (expiry=$expiry)")
        }
        return WorkbenchScanIllResolve.Found(ill)
    }

    private fun resolveWorkbenchScannedInventoryLotLine(
        stockInLineId: Long?,
        lotNoTrimmed: String,
        itemId: Long,
        sol: StockOutLine,
    ): WorkbenchScanIllResolve {
        if (stockInLineId != null && stockInLineId > 0) {
            val invLot = inventoryLotRepository.findByStockInLineIdAndDeletedFalse(stockInLineId)
                ?: return WorkbenchScanIllResolve.NotFound("No inventory lot for stockInLineId=$stockInLineId")
            if (invLot.item?.id != itemId) {
                return WorkbenchScanIllResolve.NotFound(
                    "Stock-in line lot item does not match pick line (stockInLineId=$stockInLineId)",
                )
            }
            val invLotNo = (invLot.lotNo ?: "").trim()
            if (lotNoTrimmed.isNotEmpty() && invLotNo.isNotEmpty() && invLotNo != lotNoTrimmed) {
                return WorkbenchScanIllResolve.NotFound(
                    "Lot number mismatch: stock-in has $invLotNo, request was $lotNoTrimmed",
                )
            }
            val lotPk = invLot.id
                ?: return WorkbenchScanIllResolve.NotFound("Inventory lot id missing (stockInLineId=$stockInLineId)")
            val lines = inventoryLotLineRepository.findAllByInventoryLotId(lotPk).filter { !it.deleted }
            if (lines.isEmpty()) {
                return WorkbenchScanIllResolve.NotFound("No inventory lot lines for inventoryLotId=$lotPk")
            }
            val solIll = sol.inventoryLotLine?.takeIf { sill -> lines.any { it.id == sill.id } }
            val picked: InventoryLotLine = when {
                solIll != null -> solIll
                lines.size == 1 -> lines.first()
                else -> {
                    val avail = lines.filter { it.status == InventoryLotLineStatus.AVAILABLE }
                    when {
                        avail.size == 1 -> avail.first()
                        avail.isNotEmpty() -> avail.minByOrNull { it.id ?: Long.MAX_VALUE }!!
                        else -> lines.minByOrNull { it.id ?: Long.MAX_VALUE }!!
                    }
                }
            }
            return WorkbenchScanIllResolve.Found(picked)
        }
        val byLotNo = inventoryLotLineRepository
            .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc(lotNoTrimmed, itemId)
            ?: return WorkbenchScanIllResolve.NotFound("Lot not found for this item: $lotNoTrimmed")
        return WorkbenchScanIllResolve.Found(byLotNo)
    }

    /**
     * Job-order workbench: mirror [StockOutLine.handledBy] onto [jo_pick_order.handledBy] for the same pick line.
     * Delivery-order pick orders ([PickOrderType.DELIVERY_ORDER] / `do`) are unchanged.
     */
    private fun updateJoPickOrderHandledByIfJobOrder(
        pickOrderId: Long?,
        itemId: Long?,
        userId: Long,
    ) {
        if (pickOrderId == null || itemId == null) return
        val po = pickOrderRepository.findById(pickOrderId).orElse(null) ?: return
        if (po.type != PickOrderType.JOB_ORDER) return
        val jpo = joPickOrderRepository.findByPickOrderIdAndItemId(pickOrderId, itemId).orElse(null) ?: return
        jpo.handledBy = userId
        joPickOrderRepository.save(jpo)
    }

    /**
     * Workbench scan-pick (DO FG):
     * 1) Post outbound on scanned inventory lot line first; on failure return a clear message.
     * 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty],
     *    treat as **split**: complete this SOL, rebuild suggestions **for this pick order line only**, ensure next SOL —
     *    set **pick_order_line.status** to `partially_completed` (only in this split case).
     * 3) If the user passes [qty] less than remaining on this SOL (short submit), SOL and POL both go **completed**
     *    even when sum &lt; POL.req (no `partially_completed` on POL).
     * 4) If the user passes [qty] greater than this SOL’s remaining chunk, honour it up to **available** on the scanned
     *    lot line (`plannedDelta`); `rebuild` + `ensureStockOutLines` then reconcile extra SOLs / POL as needed.
     */
    @Transactional(rollbackFor = [Exception::class])
    open fun scanPick(request: WorkbenchScanPickRequest): MessageResponse {
        val wall0 = System.nanoTime()
        var mark = wall0
        fun lapMs(): Long {
            val n = System.nanoTime()
            val d = (n - mark) / 1_000_000
            mark = n
            return d
        }

        val lotNo = request.lotNo.trim()
        // Workbench "zero-complete": allow empty lotNo when explicit qty=0, completing SOL without inventory posting.
        // This is used by batch submit to close noLot / expired / unavailable lines.
        val explicitQty = request.qty?.coerceAtLeast(BigDecimal.ZERO)
        if (explicitQty != null && explicitQty.compareTo(BigDecimal.ZERO) == 0) {
            val sol = stockOutLIneRepository.findById(request.stockOutLineId).orElse(null)
                ?: return MessageResponse(
                    id = request.stockOutLineId, name = "scan_pick", code = "NOT_FOUND", type = "workbench_scan_pick",
                    message = "Stock out line not found", errorPosition = null, entity = null,
                )
            val st = sol.status?.trim()?.lowercase() ?: ""
            if (st == "completed" || st == StockOutLineStatus.COMPLETE.status.lowercase()) {
                // Idempotent: treat as success so batch operations can retry safely.
                // Still refresh POL/PO/DOPO ticket — zero-complete previously did not run header completion.
                refreshWorkbenchPickOrderProgressAfterSolChange(sol.id!!)
                val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!)
                return MessageResponse(
                    id = sol.id,
                    name = lotNo,
                    code = "SUCCESS",
                    type = "workbench_scan_pick",
                    message = "Stock out line already completed",
                    errorPosition = null,
                    entity = mapped,
                )
            }
            if (st == "rejected" || st == StockOutLineStatus.REJECTED.status.lowercase()) {
                return MessageResponse(
                    id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                    message = "Stock out line is rejected", errorPosition = null, entity = null,
                )
            }
            sol.status = StockOutLineStatus.COMPLETE.status
            sol.pickTime = LocalDateTime.now()
            sol.handledBy = request.userId
            if (sol.startTime == null) sol.startTime = LocalDateTime.now()
            sol.endTime = LocalDateTime.now()
            stockOutLIneRepository.saveAndFlush(sol)
            val polZero = sol.pickOrderLine
            updateJoPickOrderHandledByIfJobOrder(
                pickOrderId = polZero?.pickOrder?.id,
                itemId = polZero?.item?.id,
                userId = request.userId,
            )
            // Do NOT call ensureStockOutLinesForPickOrderLineNoHold here: it creates new PENDING SOL rows from
            // suggestions and prevents POL completion (tryCompletePickOrderLineWorkbench / checkWorkbenchPickOrderLineCompleted).
            // Zero-complete means this line is intentionally closed without inventory posting (noLot / batch close).
            refreshWorkbenchPickOrderProgressAfterSolChange(sol.id!!)
            val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!)
            return MessageResponse(
                id = sol.id,
                name = lotNo,
                code = "SUCCESS",
                type = "workbench_scan_pick",
                message = "Workbench SOL completed with qty=0 (no inventory posting)",
                errorPosition = null,
                entity = mapped,
            )
        }

        if (lotNo.isEmpty()) {
            return MessageResponse(
                id = null, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "lotNo is required", errorPosition = null, entity = null,
            )
        }

        val sol = stockOutLIneRepository.findById(request.stockOutLineId).orElse(null)
            ?: return MessageResponse(
                id = request.stockOutLineId, name = "scan_pick", code = "NOT_FOUND", type = "workbench_scan_pick",
                message = "Stock out line not found", errorPosition = null, entity = null,
            )

        val st = sol.status?.trim()?.lowercase() ?: ""
        if (st == "completed" || st == StockOutLineStatus.COMPLETE.status.lowercase()) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Stock out line already completed", errorPosition = null, entity = null,
            )
        }
        if (st == "rejected" || st == StockOutLineStatus.REJECTED.status.lowercase()) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Stock out line is rejected", errorPosition = null, entity = null,
            )
        }

        val pol = sol.pickOrderLine
            ?: return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Pick order line is missing", errorPosition = null, entity = null,
            )
        val item = pol.item
            ?: return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Item is missing on pick order line", errorPosition = null, entity = null,
            )
        val itemId = item.id!!
        val polId = pol.id!!

        val scanIllResolved =
            if (request.inventoryLotLineId != null && request.inventoryLotLineId!! > 0) {
                resolveInventoryLotLineByIdForWorkbenchScan(
                    inventoryLotLineId = request.inventoryLotLineId!!,
                    itemId = itemId,
                    lotNoTrimmed = lotNo,
                )
            } else {
                resolveWorkbenchScannedInventoryLotLine(
                    stockInLineId = request.stockInLineId,
                    lotNoTrimmed = lotNo,
                    itemId = itemId,
                    sol = sol,
                )
            }
        val scannedIll = when (scanIllResolved) {
            is WorkbenchScanIllResolve.Found -> scanIllResolved.ill
            is WorkbenchScanIllResolve.NotFound -> return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = scanIllResolved.message,
                errorPosition = null, entity = null,
            )
        }

        if (scannedIll.deleted) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Lot line is deleted", errorPosition = null, entity = null,
            )
            println("Lot line is deleted")
        }
        val expiry = scannedIll.inventoryLot?.expiryDate
        if (expiry != null && expiry.isBefore(LocalDate.now())) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Lot is expired (expiry=$expiry)", errorPosition = null, entity = null,
            )
            println("Lot is expired (expiry=$expiry)")
        }
        if (scannedIll.status?.value == InventoryLotLineStatus.UNAVAILABLE.value) {
            return MessageResponse(
                id = sol.id,
                name = "scan_pick",
                code = "INVALID",
                type = "workbench_scan_pick",
                message = "Lot line is unavailable",
                errorPosition = null,
                entity = null,
            )
            println("Lot line is unavailable")
        }
        if (scannedIll.inventoryLot?.item?.id != itemId) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Scanned lot does not match pick line item", errorPosition = null, entity = null,
            )
            println("Scanned lot does not match pick line item")
        }

        sol.inventoryLotLine = scannedIll

        val required = pol.qty ?: BigDecimal.ZERO
        val infos = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
        val endedSumOthers = infos
            .filter { WorkbenchStockOutLinePickProgress.isCountedAsPicked(it) && it.id != sol.id }
            .fold(BigDecimal.ZERO) { acc, i -> acc + BigDecimal(i.qty?.toString() ?: "0") }
        val currentQtyBd = BigDecimal(sol.qty?.toString() ?: "0")
        val remainingPol = required.subtract(endedSumOthers).subtract(currentQtyBd).coerceAtLeast(BigDecimal.ZERO)

        val splForLot = suggestPickLotRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
            .firstOrNull { it.suggestedLotLine?.id == scannedIll.id }
        val chunkTarget = when {
            splForLot?.qty != null && splForLot.qty!! > BigDecimal.ZERO ->
                splForLot.qty!!.min(remainingPol.add(currentQtyBd))
            else -> remainingPol.add(currentQtyBd)
        }
        val stillNeedOnThisSol = chunkTarget.subtract(currentQtyBd).coerceAtLeast(BigDecimal.ZERO)
        val hasExplicitQty = request.qty != null
        // Implicit (null qty) path uses chunkTarget to enforce planning quantities.
        if (!hasExplicitQty && stillNeedOnThisSol <= BigDecimal.ZERO) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "No remaining quantity to pick on this stock out line", errorPosition = null, entity = null,
            )
        }
        val requestedQtyRaw = if (hasExplicitQty) request.qty!!.coerceAtLeast(BigDecimal.ZERO) else stillNeedOnThisSol
        // Explicit qty: do NOT cap by POL; only cap by lot availability (plannedDelta). This enables over-pick vs POL planning qty.
        val requestedCap = requestedQtyRaw
        if (!hasExplicitQty && requestedCap <= BigDecimal.ZERO) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "INVALID", type = "workbench_scan_pick",
                message = "Pick quantity must be positive", errorPosition = null, entity = null,
            )
        }

        val isUserShortPick = false // explicit qty closes SOL; implicit qty uses chunkTarget completion rules

        val illId = scannedIll.id!!
        val freshIll = inventoryLotLineRepository.findById(illId).orElse(scannedIll)
        val availablePickable = (freshIll.inQty ?: BigDecimal.ZERO)
            .subtract(freshIll.outQty ?: BigDecimal.ZERO)
        val plannedDelta = requestedCap.min(availablePickable)
        // For explicit qty, allow 0 (close SOL without picking); for implicit qty, 0 means conflict/no stock.
        if (!hasExplicitQty && plannedDelta <= BigDecimal.ZERO) {
            return MessageResponse(
                id = sol.id, name = "scan_pick", code = "CONFLICT", type = "workbench_scan_pick",
                message = "Insufficient available quantity on lot (may have been picked by another user)",
                errorPosition = null, entity = null,
            )
        }

        // Only the implicit (auto-fill) path uses lot-exhausted split behaviour.
        val isLotExhaustedSplit = !hasExplicitQty &&
            !isUserShortPick &&
            plannedDelta < stillNeedOnThisSol &&
            plannedDelta.compareTo(availablePickable) == 0 &&
            requestedCap >= stillNeedOnThisSol

        // Explicit qty split: if requested exceeds lot availability, allocate remainder to next SPL/SOL.
       // var explicitRemainder = BigDecimal.ZERO

       // ----- keep your existing code above -----

val solSnapshot = infos.joinToString("; ") { info ->
    "sol${info.id} st=${info.status} qty=${info.qty} picked=${WorkbenchStockOutLinePickProgress.isCountedAsPicked(info)}"
}

val prepMs = lapMs()

// retry-related state
var effectiveDelta = plannedDelta
var updated = 0
val maxRetries = 2
var attempt = 0
var effectiveAvailablePickable = availablePickable

while (attempt <= maxRetries) {
    val latestIll = inventoryLotLineRepository.findById(illId).orElse(scannedIll)
    val latestAvailablePickable = (latestIll.inQty ?: BigDecimal.ZERO)
        .subtract(latestIll.outQty ?: BigDecimal.ZERO)
    effectiveAvailablePickable = latestAvailablePickable

    // Recompute by latest stock each attempt
    effectiveDelta = requestedCap.min(latestAvailablePickable)

    if (effectiveDelta <= BigDecimal.ZERO) {
        return MessageResponse(
            id = sol.id,
            name = "scan_pick",
            code = "CONFLICT",
            type = "workbench_scan_pick",
            message = "Insufficient available quantity on lot (may have been picked by another user)",
            errorPosition = null,
            entity = null,
        )
    }

    val traceId = java.util.UUID.randomUUID().toString().substring(0, 8)
    val expectedVersion = latestIll.version ?: 0

    val connIdBefore = jdbcTemplate.queryForObject("SELECT CONNECTION_ID()", Long::class.java)
    val beforeRow = jdbcTemplate.queryForMap(
        "SELECT id, inQty, outQty, status, version FROM inventory_lot_line WHERE id = ?",
        illId
    )


    updated = inventoryLotLineRepository.incrementOutQtyIfAvailable(illId, effectiveDelta, expectedVersion)
   // println("updated: $updated")

    val connIdAfter = jdbcTemplate.queryForObject("SELECT CONNECTION_ID()", Long::class.java)
    val afterRow = jdbcTemplate.queryForMap(
        "SELECT id, inQty, outQty, status, version FROM inventory_lot_line WHERE id = ?",
        illId
    )

    if (updated > 0) break

    attempt++
    if (attempt <= maxRetries) {
        Thread.sleep(15L)
    }
}

if (updated == 0) {
    return MessageResponse(
        id = sol.id,
        name = "scan_pick",
        code = "CONFLICT",
        type = "workbench_scan_pick",
        message = "庫存或版本已變更，請重掃或重算（Concurrent pick or stock changed）",
        errorPosition = null,
        entity = null,
    )
}

val outboundMs = lapMs()

val newQtyBd = currentQtyBd.add(effectiveDelta)

// Recompute split/remainder from final successful delta
val effectiveLotExhaustedSplit = !hasExplicitQty &&
    !isUserShortPick &&
    effectiveDelta < stillNeedOnThisSol &&
    effectiveDelta.compareTo(effectiveAvailablePickable) == 0 &&
    requestedCap >= stillNeedOnThisSol

val explicitRemainder = if (hasExplicitQty) {
    requestedCap.subtract(effectiveDelta).coerceAtLeast(BigDecimal.ZERO)
} else {
    BigDecimal.ZERO
}

val solEndStatus: String = when {
    hasExplicitQty -> StockOutLineStatus.COMPLETE.status
    effectiveLotExhaustedSplit || isUserShortPick -> StockOutLineStatus.COMPLETE.status
    else -> {
        val doneThisRow = newQtyBd.compareTo(chunkTarget) >= 0
        if (doneThisRow) StockOutLineStatus.COMPLETE.status
        else StockOutLineStatus.PARTIALLY_COMPLETE.status
    }
}

sol.qty = newQtyBd.toDouble()
sol.status = solEndStatus
sol.pickTime = LocalDateTime.now()
sol.handledBy = request.userId
if (sol.startTime == null) sol.startTime = LocalDateTime.now()
if (solEndStatus.equals(StockOutLineStatus.COMPLETE.status, ignoreCase = true)) {
    sol.endTime = LocalDateTime.now()
}
stockOutLIneRepository.save(sol)
stockOutLIneRepository.flush()
sol.id?.let { suggestedPickLotWorkbenchService.linkSplToStockOutLineAfterWorkbenchPick(it) }
val saveSolMs = lapMs()

val pickOrderId = pol.pickOrder?.id
val poType = pol.pickOrder?.type
val effectiveExcludeWarehouseCodes = when (poType) {
    PickOrderType.JOB_ORDER -> request.excludeWarehouseCodes ?: emptyList()
    else -> request.excludeWarehouseCodes  // null → DO 走 default；有傳則整份取代 default
}
val ledgerMs = measureTimeMillis { createWorkbenchPickLedger(sol, effectiveDelta) }
registerAfterCommit {
    runInNewTransaction {
        runWorkbenchPickDeferredFollowUps(
            solId = sol.id!!,
            polId = polId,
            pickOrderId = pickOrderId,
            itemId = itemId,
            userId = request.userId,
            hasExplicitQty = hasExplicitQty,
            explicitRemainder = explicitRemainder,
            scannedIllId = scannedIll.id,
            requestStoreId = request.storeId,
            effectiveExcludeWarehouseCodes = effectiveExcludeWarehouseCodes,
            effectiveLotExhaustedSplit = effectiveLotExhaustedSplit,
            effectiveDelta = effectiveDelta,
        )
    }
}

val mapFetchT0 = System.nanoTime()
val mapped = stockOutLIneRepository.findStockOutLineInfoById(sol.id!!)
val mapFetchMs = (System.nanoTime() - mapFetchT0) / 1_000_000

val totalMs = (System.nanoTime() - wall0) / 1_000_000
/*
log.info(
    "workbench scanPick timing (ms): total={} prep={} outbound={} saveSol={} ledger={} mapFetch={} lotSplit={} solId={} polId={} poId={}",
    totalMs,
    prepMs,
    outboundMs,
    saveSolMs,
    ledgerMs,
    mapFetchMs,
    effectiveLotExhaustedSplit,
    sol.id,
    polId,
    pickOrderId ?: 0L,
)
*/
return MessageResponse(
    id = sol.id,
    name = scannedIll.inventoryLot?.lotNo ?: lotNo,
    code = "SUCCESS",
    type = "workbench_scan_pick",
    message = if (effectiveLotExhaustedSplit) {
        " remaining quantity allocated to next stock-out line"
    } else {
        "pick successful"
    },
    errorPosition = null,
    entity = mapped,
)
    }
    
    /**
     * Lane summary for DO workbench: one card per **delivery_order_pick_order** ticket (not per pick_order).
     * `unassigned` = tickets with `handledBy` null (aligned with normal FG `do_pick_order.handledBy` semantics).
     * `total` = all tickets in the lane/time bucket (including already assigned), so denominators match normal FG.
     */
    open fun getDeliveryOrderPickOrderSummaryByStore(storeId: String, requiredDate: LocalDate?, releaseType: String): StoreLaneSummary {
        val targetDate = requiredDate ?: LocalDate.now()
        val actualStoreId = when (storeId) {
            "2/F" -> "2/F"
            "4/F" -> "4/F"
            else -> storeId
        }
        val rt = releaseType.lowercase()
        val releaseFilterClause = when (rt) {
            "batch" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'batch' "
            "single" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'single' "
            "isExtra" -> " AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' "
            else -> ""
        }
        val sql = """
    SELECT
      dop.truckDepartureTime AS truckDepartureTime,
      dop.truckLanceCode AS truckLanceCode,
      dop.loadingSequence AS loadingSequence,
      COUNT(DISTINCT dop.id) AS total_cnt,
      SUM(
        CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END
      ) AS unassigned_cnt,
      GROUP_CONCAT(
        DISTINCT NULLIF(TRIM(dop.handlerName), '')
        ORDER BY dop.handlerName
        SEPARATOR ', '
      ) AS handler_names
    FROM fpsmsdb.delivery_order_pick_order dop
    WHERE dop.deleted = 0
      AND dop.storeId = :storeId
      AND dop.requiredDeliveryDate = :requiredDate
      AND dop.ticketStatus IN ('pending', 'released')
      AND EXISTS (
        SELECT 1 FROM fpsmsdb.pick_order po
        WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
      )
      $releaseFilterClause
    GROUP BY dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence
""".trimIndent()
        val params = mapOf(
            "storeId" to actualStoreId,
            "requiredDate" to targetDate,
        )
        val rawRows: List<Map<String, Any?>> = try {
            jdbcDao.queryForList(sql, params)
        } catch (e: Exception) {
            println("❌ getDeliveryOrderPickOrderSummaryByStore: ${e.message}")
            emptyList()
        }

        val defaultTruck = truckRepository.findById(5577L).orElse(null)
        val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: ""

        data class LaneAgg(
            val sortTime: LocalTime,
            val lance: String,
            val loadingSequence: Int?,
            val unassigned: Int,
            val total: Int,
            val handlerName: String?, // 改成可空，名稱也統一
        )

        fun cellStr(row: Map<String, Any?>, name: String): String? {
            val k = row.keys.find { it.equals(name, true) } ?: return null
            return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
        }

        fun cellNum(row: Map<String, Any?>, vararg names: String): Int {
            for (n in names) {
                val k = row.keys.find { it.equals(n, true) } ?: continue
                (row[k] as? Number)?.toInt()?.let { return it }
            }
            return 0
        }

        fun cellNullableInt(row: Map<String, Any?>, vararg names: String): Int? {
            for (n in names) {
                val k = row.keys.find { it.equals(n, true) } ?: continue
                val v = row[k] ?: continue
                when (v) {
                    is Number -> return v.toInt()
                    is String -> v.trim().toIntOrNull()?.let { return it }
                }
            }
            return null
        }

        fun cellTime(row: Map<String, Any?>): LocalTime? {
            val k = row.keys.find { it.equals("truckDepartureTime", true) } ?: return null
            val v = row[k] ?: return null
            return when (v) {
                is java.sql.Time -> v.toLocalTime()
                is LocalTime -> v
                is java.sql.Timestamp -> v.toLocalDateTime().toLocalTime()
                else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull()
                    ?: runCatching { LocalTime.parse(v.toString()) }.getOrNull()
            }
        }

        val laneAggs = rawRows.mapNotNull { row ->
            val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null
            if (lance == defaultTruckLaneCode) return@mapNotNull null
            val loadingSequence = cellNullableInt(row, "loadingSequence")
            val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt")
            val total = cellNum(row, "total_cnt", "totalCnt")
            if (total <= 0) return@mapNotNull null
            val sortTime = cellTime(row) ?: LocalTime.MIDNIGHT
            val handlerName = cellStr(row, "handler_names")
            LaneAgg(sortTime, lance, loadingSequence, unassigned, total, handlerName)
        }

        // 2/F：與 [DoPickOrderQueryService.getSummaryByStore] 一致，只按「出發時間 + 車線」合併，不按 loadingSequence 拆開。
        // 否則同一車線會出現多顆 (1/1) 按鈕，與 normal FG 畫面不一致。
        val laneAggsForUi = if (actualStoreId == "2/F") {
            laneAggs
                .groupBy { it.sortTime to it.lance }
                .values
                .map { group ->
                    val head = group.first()
                    val mergedHandlers = group
                        .mapNotNull { it.handlerName?.trim()?.takeIf { n -> n.isNotEmpty() } }
                        .flatMap { s -> s.split(",").map { it.trim() }.filter { it.isNotEmpty() } }
                        .distinct()
                        .sorted()
                        .joinToString(", ")
                        .takeIf { it.isNotEmpty() }
                    LaneAgg(
                        sortTime = head.sortTime,
                        lance = head.lance,
                        loadingSequence = null,
                        unassigned = group.sumOf { it.unassigned },
                        total = group.sumOf { it.total },
                        handlerName = mergedHandlers,
                    )
                }
        } else {
            laneAggs
        }

        val groupedByTime = laneAggsForUi.groupBy { it.sortTime }
        val sortedTimes = groupedByTime.keys.sorted()
        val timeGroups = sortedTimes.take(4).map { st ->
            val items = groupedByTime[st].orEmpty()
            LaneRow(
                truckDepartureTime = st.toString(),
                lanes = items
                    .map {
                        LaneBtn(
                            truckLanceCode = it.lance,
                            loadingSequence = if (actualStoreId == "4/F") it.loadingSequence else null,
                            unassigned = it.unassigned,
                            total = it.total,
                            handlerName = it.handlerName,
                            storeId = actualStoreId,
                        )
                    }
                    .sortedWith(
                        compareByDescending<LaneBtn> { it.unassigned }
                            .thenBy { it.truckLanceCode }
                            .thenBy { it.loadingSequence ?: 999 }
                    )
            )
        }

        val defaultCountSql = """
            SELECT COUNT(DISTINCT dop.id) AS c
            FROM fpsmsdb.delivery_order_pick_order dop
            WHERE dop.deleted = 0
              AND dop.requiredDeliveryDate = :requiredDate
              AND dop.ticketStatus IN ('pending', 'released')
              AND (dop.storeId IS NULL OR TRIM(dop.storeId) = '')
              AND dop.truckLanceCode = '車線-X'
              AND EXISTS (
                SELECT 1 FROM fpsmsdb.pick_order po
                WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
              )
              $releaseFilterClause
        """.trimIndent()

        val defaultTruckCount = try {
            val r: List<Map<String, Any?>> = jdbcDao.queryForList(defaultCountSql, mapOf("requiredDate" to targetDate))
            val first: Map<String, Any?>? = r.firstOrNull()
            if (first == null) 0
            else (first.values.firstOrNull() as? Number)?.toInt() ?: 0
        } catch (_: Exception) {
            0
        }

        return StoreLaneSummary(
            storeId = storeId,
            rows = timeGroups,
            defaultTruckCount = defaultTruckCount
        )
    }

    /**
     * Workbench Etra view: all `delivery_order_pick_order` with `releaseType` = isExtra (case-insensitive),
     * for one [requiredDeliveryDate], grouped by shop then by truck / time / loading sequence.
     */
    open fun getWorkbenchEtraLaneSummary(requiredDate: LocalDate?): List<WorkbenchEtraShopLaneGroup> {
        val targetDate = requiredDate ?: LocalDate.now()
        val defaultTruck = truckRepository.findById(5577L).orElse(null)
        val defaultTruckLaneCode = defaultTruck?.truckLanceCode ?: ""

        val sql = """
            SELECT
              dop.shopCode AS shopCode,
              dop.shopName AS shopName,
              dop.storeId AS storeId,
              dop.truckDepartureTime AS truckDepartureTime,
              dop.truckLanceCode AS truckLanceCode,
              dop.loadingSequence AS loadingSequence,
              COUNT(DISTINCT dop.id) AS total_cnt,
              SUM(CASE WHEN dop.handledBy IS NULL THEN 1 ELSE 0 END) AS unassigned_cnt,
              GROUP_CONCAT(
                DISTINCT NULLIF(TRIM(dop.handlerName), '')
                ORDER BY dop.handlerName
                SEPARATOR ', '
              ) AS handler_names
            FROM fpsmsdb.delivery_order_pick_order dop
            WHERE dop.deleted = 0
              AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra'
              AND dop.requiredDeliveryDate = :requiredDate
              AND dop.ticketStatus IN ('pending', 'released')
              AND EXISTS (
                SELECT 1 FROM fpsmsdb.pick_order po
                WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
              )
            GROUP BY dop.shopCode, dop.shopName, dop.storeId, dop.truckDepartureTime, dop.truckLanceCode, dop.loadingSequence
        """.trimIndent()

        val rawRows: List<Map<String, Any?>> = try {
            jdbcDao.queryForList(sql, mapOf("requiredDate" to targetDate))
        } catch (e: Exception) {
            println("❌ getWorkbenchEtraLaneSummary: ${e.message}")
            emptyList()
        }

        fun cellStr(row: Map<String, Any?>, name: String): String? {
            val k = row.keys.find { it.equals(name, true) } ?: return null
            return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
        }
        fun cellNum(row: Map<String, Any?>, vararg names: String): Int {
            for (n in names) {
                val k = row.keys.find { it.equals(n, true) } ?: continue
                (row[k] as? Number)?.toInt()?.let { return it }
            }
            return 0
        }
        fun cellNullableInt(row: Map<String, Any?>, vararg names: String): Int? {
            for (n in names) {
                val k = row.keys.find { it.equals(n, true) } ?: continue
                val v = row[k] ?: continue
                when (v) {
                    is Number -> return v.toInt()
                    is String -> v.trim().toIntOrNull()?.let { return it }
                }
            }
            return null
        }

        data class EtraAgg(
            val shopCode: String?,
            val shopName: String?,
            val storeId: String?,
            val sortTime: LocalTime,
            val lance: String,
            val loadingSequence: Int?,
            val unassigned: Int,
            val total: Int,
            val handlerName: String?,
        )

        val aggs = rawRows.mapNotNull { row ->
            val lance = cellStr(row, "truckLanceCode") ?: return@mapNotNull null
            if (lance == defaultTruckLaneCode) return@mapNotNull null
            val storeIdCol = cellStr(row, "storeId")
            val ttKey = row.keys.find { it.equals("truckDepartureTime", true) }
            val ttVal = ttKey?.let { row[it] }
            val sortTime = when (ttVal) {
                null -> LocalTime.MIDNIGHT
                is java.sql.Time -> ttVal.toLocalTime()
                is LocalTime -> ttVal
                is java.sql.Timestamp -> ttVal.toLocalDateTime().toLocalTime()
                else -> runCatching { LocalTime.parse(ttVal.toString().take(8)) }.getOrNull()
                    ?: runCatching { LocalTime.parse(ttVal.toString()) }.getOrNull()
                    ?: LocalTime.MIDNIGHT
            }
            val loadingSeq = cellNullableInt(row, "loadingSequence")
            val unassigned = cellNum(row, "unassigned_cnt", "unassignedCnt")
            val total = cellNum(row, "total_cnt", "totalCnt")
            if (total <= 0) return@mapNotNull null
            EtraAgg(
                shopCode = cellStr(row, "shopCode"),
                shopName = cellStr(row, "shopName"),
                storeId = storeIdCol,
                sortTime = sortTime,
                lance = lance,
                loadingSequence = loadingSeq,
                unassigned = unassigned,
                total = total,
                handlerName = cellStr(row, "handler_names"),
            )
        }

        val byShop = aggs.groupBy { a ->
            listOf(a.shopCode ?: "", a.shopName ?: "").joinToString("|")
        }

        return byShop.entries
            .map { (key, group) ->
                val head = group.first()
                val lanes = group
                    .sortedWith(
                        compareBy<EtraAgg> { it.sortTime }
                            .thenBy { it.lance }
                            .thenBy { it.loadingSequence ?: 999 }
                    )
                    .map {
                        val is4F = it.storeId?.replace("/", "")?.trim()?.equals("4F", ignoreCase = true) == true
                        LaneBtn(
                            truckLanceCode = it.lance,
                            loadingSequence = if (is4F) it.loadingSequence else null,
                            unassigned = it.unassigned,
                            total = it.total,
                            handlerName = it.handlerName,
                            storeId = it.storeId,
                            truckDepartureTime = it.sortTime.toString(),
                        )
                    }
                WorkbenchEtraShopLaneGroup(
                    shopCode = head.shopCode,
                    shopName = head.shopName,
                    lanes = lanes,
                )
            }
            .sortedWith(
                compareBy<WorkbenchEtraShopLaneGroup> { it.shopName ?: it.shopCode ?: "" }
                    .thenBy { it.shopCode ?: "" }
            )
    }

    open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelection(
        shopName: String?,
        storeId: String?,
        truck: String?,
        releaseTypeFilter: String? = null,
    ): List<ReleasedDoPickOrderListItem> =
        queryWorkbenchReleasedDopoList(shopName, storeId, truck, beforeToday = true, releaseTypeFilter = releaseTypeFilter)

    /**
     * @param requiredDeliveryDate when null, uses [LocalDate.now] (calendar today).
     * When set, filters `dop.requiredDeliveryDate = :targetDate` (workbench date picker / select day).
     * @param releaseTypeFilter when `isExtra` (case-insensitive), only `delivery_order_pick_order.releaseType = isExtra` rows.
     */
    open fun findWorkbenchReleasedDeliveryOrderPickOrdersForSelectionToday(
        shopName: String?,
        storeId: String?,
        truck: String?,
        requiredDeliveryDate: LocalDate? = null,
        releaseTypeFilter: String? = null,
    ): List<ReleasedDoPickOrderListItem> =
        queryWorkbenchReleasedDopoList(
            shopName,
            storeId,
            truck,
            beforeToday = false,
            equalsDeliveryDate = requiredDeliveryDate,
            releaseTypeFilter = releaseTypeFilter,
        )

    /**
     * Workbench completed tickets: query `delivery_order_pick_order` where `ticketStatus = completed`.
     * Scope is "mine": any linked pick_order assigned to the given user.
     *
     * Returned shape aligns with legacy [CompletedDoPickOrderResponse] so UI can reuse record list.
     * Workbench has no do_pick_order_record id, so `doPickOrderRecordId`/`recordId` use the DOPO id.
     */
    open fun getCompletedDoPickOrdersWorkbench(
        userId: Long,
        request: GetCompletedDoPickOrdersRequest,
    ): List<CompletedDoPickOrderResponse> {
        val targetDate: LocalDate? = request.targetDate
            ?.trim()
            ?.takeIf { it.isNotEmpty() }
            ?.let { runCatching { LocalDate.parse(it) }.getOrNull() }

        val params = mutableMapOf<String, Any>(
            "userId" to userId,
        )

        val sql = StringBuilder(
            """
            SELECT
              dop.id AS id,
              dop.ticketNo AS ticketNo,
              dop.storeId AS storeId,
              dop.shopCode AS shopCode,
              dop.shopName AS shopName,
              dop.truckLanceCode AS truckLanceCode,
              dop.truckDepartureTime AS departureTime,
              dop.requiredDeliveryDate AS requiredDeliveryDate,
              dop.modified AS completedAt,
              dop.deliveryNoteCode AS deliveryNoteCode,
              dop.cartonQty AS cartonQty,
              dop.handlerName AS handlerName,
              GROUP_CONCAT(DISTINCT po.id ORDER BY po.id SEPARATOR ',') AS pickOrderIdsStr,
              GROUP_CONCAT(DISTINCT po.code ORDER BY po.code SEPARATOR ',') AS pickOrderCodesStr,
              GROUP_CONCAT(DISTINCT d.id ORDER BY d.id SEPARATOR ',') AS deliveryOrderIdsStr,
              GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',') AS deliveryNosStr,
              MIN(po.consoCode) AS consoCode
            FROM fpsmsdb.delivery_order_pick_order dop
            INNER JOIN fpsmsdb.pick_order po
              ON po.deliveryOrderPickOrderId = dop.id
             AND po.deleted = 0
            LEFT JOIN fpsmsdb.delivery_order d
              ON d.id = po.doId
             AND d.deleted = 0
            WHERE dop.deleted = 0
              AND dop.ticketStatus = 'completed'
              AND po.assignTo = :userId
            """.trimIndent()
        )

        if (targetDate != null) {
            sql.append(" AND dop.requiredDeliveryDate = :targetDate ")
            params["targetDate"] = targetDate
        }
        if (!request.shopName.isNullOrBlank()) {
            sql.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ")
            params["shopPat"] = "%${request.shopName!!.trim()}%"
        }
        if (!request.truckLanceCode.isNullOrBlank()) {
            sql.append(" AND dop.truckLanceCode LIKE :truckPat ")
            params["truckPat"] = "%${request.truckLanceCode!!.trim()}%"
        }
        if (!request.deliveryNoteCode.isNullOrBlank()) {
            sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ")
            params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%"
        }
        if (!request.ticketNo.isNullOrBlank()) {
            sql.append(" AND dop.ticketNo LIKE :ticketNoPat ")
            params["ticketNoPat"] = "%${request.ticketNo!!.trim()}%"
        }
        sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ")

        val rows: List<Map<String, Any?>> = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (e: Exception) {
            println("❌ getCompletedDoPickOrdersWorkbench: ${e.message}")
            emptyList()
        }

        fun str(row: Map<String, Any?>, key: String): String? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
        }

        fun parseIds(csv: String?): List<Long> =
            csv?.split(",")
                ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() }?.toLongOrNull() }
                ?: emptyList()

        fun parseStrs(csv: String?): List<String> =
            csv?.split(",")
                ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() } }
                ?: emptyList()

        val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

        return rows.mapNotNull { row ->
            val id = (row["id"] as? Number)?.toLong() ?: return@mapNotNull null
            val ticketNo = str(row, "ticketNo") ?: ""
            val storeId = str(row, "storeId")
            val shopCode = str(row, "shopCode")
            val shopName = str(row, "shopName")
            val truckLanceCode = str(row, "truckLanceCode")
            val dep: LocalTime? = row[row.keys.find { it.equals("departureTime", true) } ?: "departureTime"]?.let { v ->
                when (v) {
                    is java.sql.Time -> v.toLocalTime()
                    is LocalTime -> v
                    else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull()
                }
            }
            val requiredDateStr = str(row, "requiredDeliveryDate")
            val completedAtStr = row[row.keys.find { it.equals("completedAt", true) } ?: "completedAt"]?.let { v ->
                when (v) {
                    is LocalDateTime -> v.format(dtf)
                    is java.sql.Timestamp -> v.toLocalDateTime().format(dtf)
                    else -> v.toString()
                }
            }

            val pickOrderIds = parseIds(str(row, "pickOrderIdsStr"))
            val pickOrderCodes = parseStrs(str(row, "pickOrderCodesStr"))
            val deliveryOrderIds = parseIds(str(row, "deliveryOrderIdsStr"))
            val deliveryNos = parseStrs(str(row, "deliveryNosStr"))
            val deliveryNoteCode = str(row, "deliveryNoteCode")
            val cartonQty = (row[row.keys.find { it.equals("cartonQty", true) } ?: "cartonQty"] as? Number)?.toInt() ?: 0

            CompletedDoPickOrderResponse(
                id = id,
                doPickOrderRecordId = id,
                recordId = id,
                ticketNo = ticketNo,
                storeId = storeId,
                completedDate = completedAtStr,
                shopCode = shopCode,
                shopName = shopName,
                deliveryNoteCode = deliveryNoteCode,
                truckLanceCode = truckLanceCode,
                departureTime = dep,
                pickOrderIds = pickOrderIds,
                pickOrderCodes = pickOrderCodes,
                deliveryOrderIds = deliveryOrderIds,
                deliveryNos = deliveryNos,
                pickOrderConsoCode = str(row, "consoCode"),
                pickOrderStatus = "completed",
                deliveryDate = requiredDateStr,
                numberOfCartons = cartonQty,
                shopId = null,
                shopAddress = null,
                fgPickOrders = emptyList<FgPickOrderSummary>(),
                handlerName = str(row, "handlerName"),
            )
        }
    }

    /**
     * Workbench completed tickets (all users):
     * query `delivery_order_pick_order` where `ticketStatus = completed`.
     */
    open fun getCompletedDoPickOrdersWorkbenchAll(
        request: GetCompletedDoPickOrdersRequest,
    ): List<CompletedDoPickOrderResponse> {
        val targetDate: LocalDate? = request.targetDate
            ?.trim()
            ?.takeIf { it.isNotEmpty() }
            ?.let { runCatching { LocalDate.parse(it) }.getOrNull() }

        val params = mutableMapOf<String, Any>()

        val sql = StringBuilder(
            """
            SELECT
              dop.id AS id,
              dop.ticketNo AS ticketNo,
              dop.storeId AS storeId,
              dop.shopCode AS shopCode,
              dop.shopName AS shopName,
              dop.truckLanceCode AS truckLanceCode,
              dop.truckDepartureTime AS departureTime,
              dop.requiredDeliveryDate AS requiredDeliveryDate,
              dop.modified AS completedAt,
              dop.deliveryNoteCode AS deliveryNoteCode,
              dop.cartonQty AS cartonQty,
              dop.handlerName AS handlerName,
              GROUP_CONCAT(DISTINCT po.id ORDER BY po.id SEPARATOR ',') AS pickOrderIdsStr,
              GROUP_CONCAT(DISTINCT po.code ORDER BY po.code SEPARATOR ',') AS pickOrderCodesStr,
              GROUP_CONCAT(DISTINCT d.id ORDER BY d.id SEPARATOR ',') AS deliveryOrderIdsStr,
              GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',') AS deliveryNosStr,
              MIN(po.consoCode) AS consoCode
            FROM fpsmsdb.delivery_order_pick_order dop
            INNER JOIN fpsmsdb.pick_order po
              ON po.deliveryOrderPickOrderId = dop.id
             AND po.deleted = 0
            LEFT JOIN fpsmsdb.delivery_order d
              ON d.id = po.doId
             AND d.deleted = 0
            WHERE dop.deleted = 0
              AND dop.ticketStatus = 'completed'
            """.trimIndent()
        )

        if (targetDate != null) {
            sql.append(" AND dop.requiredDeliveryDate = :targetDate ")
            params["targetDate"] = targetDate
        }
        if (!request.shopName.isNullOrBlank()) {
            sql.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ")
            params["shopPat"] = "%${request.shopName!!.trim()}%"
        }
        if (!request.truckLanceCode.isNullOrBlank()) {
            sql.append(" AND dop.truckLanceCode LIKE :truckPat ")
            params["truckPat"] = "%${request.truckLanceCode!!.trim()}%"
        }
        if (!request.deliveryNoteCode.isNullOrBlank()) {
            sql.append(" AND dop.deliveryNoteCode LIKE :dnPat ")
            params["dnPat"] = "%${request.deliveryNoteCode!!.trim()}%"
        }
        if (!request.ticketNo.isNullOrBlank()) {
            sql.append(" AND dop.ticketNo LIKE :ticketNoPat ")
            params["ticketNoPat"] = "%${request.ticketNo!!.trim()}%"
        }
        sql.append(" GROUP BY dop.id ORDER BY dop.modified DESC ")

        val rows: List<Map<String, Any?>> = try {
            jdbcDao.queryForList(sql.toString(), params)
        } catch (e: Exception) {
            println("❌ getCompletedDoPickOrdersWorkbenchAll: ${e.message}")
            emptyList()
        }

        fun str(row: Map<String, Any?>, key: String): String? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
        }

        fun parseIds(csv: String?): List<Long> =
            csv?.split(",")
                ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() }?.toLongOrNull() }
                ?: emptyList()

        fun parseStrs(csv: String?): List<String> =
            csv?.split(",")
                ?.mapNotNull { it.trim().takeIf { s -> s.isNotEmpty() } }
                ?: emptyList()

        val dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")

        return rows.mapNotNull { row ->
            val id = (row["id"] as? Number)?.toLong() ?: return@mapNotNull null
            val ticketNo = str(row, "ticketNo") ?: ""
            val storeId = str(row, "storeId")
            val shopCode = str(row, "shopCode")
            val shopName = str(row, "shopName")
            val truckLanceCode = str(row, "truckLanceCode")
            val dep: LocalTime? = row[row.keys.find { it.equals("departureTime", true) } ?: "departureTime"]?.let { v ->
                when (v) {
                    is java.sql.Time -> v.toLocalTime()
                    is LocalTime -> v
                    else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull()
                }
            }
            val requiredDateStr = str(row, "requiredDeliveryDate")
            val completedAtStr = row[row.keys.find { it.equals("completedAt", true) } ?: "completedAt"]?.let { v ->
                when (v) {
                    is LocalDateTime -> v.format(dtf)
                    is java.sql.Timestamp -> v.toLocalDateTime().format(dtf)
                    else -> v.toString()
                }
            }

            val pickOrderIds = parseIds(str(row, "pickOrderIdsStr"))
            val pickOrderCodes = parseStrs(str(row, "pickOrderCodesStr"))
            val deliveryOrderIds = parseIds(str(row, "deliveryOrderIdsStr"))
            val deliveryNos = parseStrs(str(row, "deliveryNosStr"))
            val deliveryNoteCode = str(row, "deliveryNoteCode")
            val cartonQty = (row[row.keys.find { it.equals("cartonQty", true) } ?: "cartonQty"] as? Number)?.toInt() ?: 0

            CompletedDoPickOrderResponse(
                id = id,
                doPickOrderRecordId = id,
                recordId = id,
                ticketNo = ticketNo,
                storeId = storeId,
                completedDate = completedAtStr,
                shopCode = shopCode,
                shopName = shopName,
                deliveryNoteCode = deliveryNoteCode,
                truckLanceCode = truckLanceCode,
                departureTime = dep,
                pickOrderIds = pickOrderIds,
                pickOrderCodes = pickOrderCodes,
                deliveryOrderIds = deliveryOrderIds,
                deliveryNos = deliveryNos,
                pickOrderConsoCode = str(row, "consoCode"),
                pickOrderStatus = "completed",
                deliveryDate = requiredDateStr,
                numberOfCartons = cartonQty,
                shopId = null,
                shopAddress = null,
                fgPickOrders = emptyList<FgPickOrderSummary>(),
                handlerName = str(row, "handlerName"),
            )
        }
    }

    /**
     * Workbench ticket release table:
     * source of truth is `delivery_order_pick_order` (+ linked `pick_order` / `pick_order_line`).
     * This API is intentionally independent from legacy `do_pick_order` ticket table.
     */
    open fun getWorkbenchTicketReleaseTable(
        startDate: LocalDate? = null,
        endDate: LocalDate? = null,
    ): List<WorkbenchTicketReleaseTableResponse> {
        val params = mutableMapOf<String, Any>()
        val dateClause = if (startDate != null && endDate != null) {
            params["startDate"] = startDate
            params["endDate"] = endDate
            " AND dop.requiredDeliveryDate BETWEEN :startDate AND :endDate "
        } else {
            ""
        }
        val rows = jdbcDao.queryForList(
            """
            SELECT
              dop.id AS deliveryOrderPickOrderId,
              dop.storeId AS storeId,
              dop.ticketNo AS ticketNo,
              dop.loadingSequence AS loadingSequence,
              dop.ticketStatus AS ticketStatus,
              dop.truckDepartureTime AS truckDepartureTime,
              dop.handledBy AS handledBy,
              dop.ticketReleaseTime AS ticketReleaseTime,
              dop.ticketCompleteDateTime AS ticketCompleteDateTime,
              dop.truckLanceCode AS truckLanceCode,
              dop.shopCode AS shopCode,
              dop.shopName AS shopName,
              dop.requiredDeliveryDate AS requiredDeliveryDate,
              dop.handlerName AS handlerName,
              COALESCE(SUM(polCount.cnt), 0) AS numberOfFGItems
            FROM fpsmsdb.delivery_order_pick_order dop
            LEFT JOIN (
              SELECT
                po.deliveryOrderPickOrderId AS dopId,
                po.id AS pickOrderId,
                COUNT(pol.id) AS cnt
              FROM fpsmsdb.pick_order po
              LEFT JOIN fpsmsdb.pick_order_line pol
                ON pol.poId = po.id
               AND pol.deleted = 0
              WHERE po.deleted = 0
              GROUP BY po.deliveryOrderPickOrderId, po.id
            ) polCount ON polCount.dopId = dop.id
            WHERE dop.deleted = 0
            $dateClause
            GROUP BY
              dop.id, dop.storeId, dop.ticketNo, dop.loadingSequence, dop.ticketStatus,
              dop.truckDepartureTime, dop.handledBy, dop.ticketReleaseTime, dop.ticketCompleteDateTime,
              dop.truckLanceCode, dop.shopCode, dop.shopName, dop.requiredDeliveryDate, dop.handlerName
            ORDER BY COALESCE(dop.ticketReleaseTime, dop.modified, dop.created) DESC
            """.trimIndent(),
            params,
        )

        fun str(row: Map<String, Any?>, key: String): String? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            return row[k]?.toString()?.trim()?.takeIf { it.isNotEmpty() }
        }
        fun longVal(row: Map<String, Any?>, key: String): Long? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            return (row[k] as? Number)?.toLong()
        }
        fun intVal(row: Map<String, Any?>, key: String): Int? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            return (row[k] as? Number)?.toInt()
        }
        fun localDateVal(row: Map<String, Any?>, key: String): LocalDate? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            val v = row[k] ?: return null
            return when (v) {
                is LocalDate -> v
                is java.sql.Date -> v.toLocalDate()
                else -> runCatching { LocalDate.parse(v.toString().take(10)) }.getOrNull()
            }
        }
        fun localTimeVal(row: Map<String, Any?>, key: String): LocalTime? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            val v = row[k] ?: return null
            return when (v) {
                is LocalTime -> v
                is java.sql.Time -> v.toLocalTime()
                else -> runCatching { LocalTime.parse(v.toString().take(8)) }.getOrNull()
            }
        }
        fun localDateTimeVal(row: Map<String, Any?>, key: String): LocalDateTime? {
            val k = row.keys.find { it.equals(key, true) } ?: return null
            val v = row[k] ?: return null
            return when (v) {
                is LocalDateTime -> v
                is java.sql.Timestamp -> v.toLocalDateTime()
                else -> runCatching { LocalDateTime.parse(v.toString()) }.getOrNull()
            }
        }

        return rows.mapNotNull { row ->
            val id = longVal(row, "deliveryOrderPickOrderId") ?: return@mapNotNull null
            val status = str(row, "ticketStatus")
            WorkbenchTicketReleaseTableResponse(
                deliveryOrderPickOrderId = id,
                storeId = str(row, "storeId"),
                ticketNo = str(row, "ticketNo"),
                loadingSequence = intVal(row, "loadingSequence"),
                ticketStatus = status,
                truckDepartureTime = localTimeVal(row, "truckDepartureTime"),
                handledBy = longVal(row, "handledBy"),
                ticketReleaseTime = localDateTimeVal(row, "ticketReleaseTime"),
                ticketCompleteDateTime = localDateTimeVal(row, "ticketCompleteDateTime"),
                truckLanceCode = str(row, "truckLanceCode"),
                shopCode = str(row, "shopCode"),
                shopName = str(row, "shopName"),
                requiredDeliveryDate = localDateVal(row, "requiredDeliveryDate"),
                handlerName = str(row, "handlerName"),
                numberOfFGItems = intVal(row, "numberOfFGItems") ?: 0,
                isActiveWorkbenchTicket = !status.equals("completed", ignoreCase = true),
            )
        }
    }

    @Transactional
    open fun forceCompleteWorkbenchTicket(deliveryOrderPickOrderId: Long): MessageResponse {
        val dop = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElse(null)
            ?: return MessageResponse(
                id = null,
                code = "NOT_FOUND",
                name = null,
                type = null,
                message = "delivery_order_pick_order not found",
                errorPosition = null,
                entity = null,
            )
        if (dop.deleted) {
            return MessageResponse(
                id = null,
                code = "NOT_FOUND",
                name = null,
                type = null,
                message = "delivery_order_pick_order not found",
                errorPosition = null,
                entity = null,
            )
        }

        val linkedPickOrderIds = pickOrderRepository.findIdsByDeliveryOrderPickOrderId(deliveryOrderPickOrderId)
        if (linkedPickOrderIds.isNotEmpty()) {
            val linkedPickOrders = pickOrderRepository.findAllById(linkedPickOrderIds)
            linkedPickOrders.forEach { po ->
                po.assignTo = null
                if (po.status != PickOrderStatus.COMPLETED) {
                    po.status = PickOrderStatus.COMPLETED
                    po.completeDate = LocalDateTime.now()
                }
            }
            pickOrderRepository.saveAll(linkedPickOrders)

            val linkedPickOrderLines = pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(linkedPickOrderIds)
            linkedPickOrderLines.forEach { pol ->
                if (pol.status != PickOrderLineStatus.COMPLETED) {
                    pol.status = PickOrderLineStatus.COMPLETED
                }
            }
            if (linkedPickOrderLines.isNotEmpty()) {
                pickOrderLineRepository.saveAll(linkedPickOrderLines)
            }
        }

        if (dop.ticketStatus != DoPickOrderStatus.completed) {
            dop.ticketStatus = DoPickOrderStatus.completed
            dop.ticketCompleteDateTime = LocalDateTime.now()
            if (dop.deliveryNoteCode.isNullOrBlank()) {
                val prefix = "DN"
                val midfix = CodeGenerator.DEFAULT_MIDFIX
                val codePrefix = "$prefix-$midfix"
                val latestLegacyDn = doPickOrderRecordRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix)
                val latestWorkbenchDn = deliveryOrderPickOrderRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix)
                val latestCode = listOfNotNull(latestLegacyDn, latestWorkbenchDn).maxOrNull()
                dop.deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode)
            }
            deliveryOrderPickOrderRepository.save(dop)
        }
        markDeliveryOrdersCompletedForDeliveryOrderPickOrder(deliveryOrderPickOrderId)
        return MessageResponse(
            id = dop.id,
            code = "SUCCESS",
            name = dop.ticketNo,
            type = null,
            message = "Workbench ticket completed",
            errorPosition = null,
            entity = mapOf(
                "deliveryOrderPickOrderId" to dop.id,
                "ticketStatus" to (dop.ticketStatus?.name ?: ""),
                "deliveryNoteCode" to dop.deliveryNoteCode,
            ),
        )
    }

    @Transactional
    open fun revertWorkbenchTicketAssignment(deliveryOrderPickOrderId: Long): MessageResponse {
        val dop = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElse(null)
            ?: return MessageResponse(
                id = null,
                code = "NOT_FOUND",
                name = null,
                type = null,
                message = "delivery_order_pick_order not found",
                errorPosition = null,
                entity = null,
            )
        if (dop.deleted) {
            return MessageResponse(
                id = null,
                code = "NOT_FOUND",
                name = null,
                type = null,
                message = "delivery_order_pick_order not found",
                errorPosition = null,
                entity = null,
            )
        }
        if (dop.ticketStatus == DoPickOrderStatus.completed) {
            return MessageResponse(
                id = dop.id,
                code = "INVALID_STATUS",
                name = dop.ticketNo,
                type = null,
                message = "Cannot revert a completed ticket",
                errorPosition = null,
                entity = null,
            )
        }
        if (dop.handledBy == null) {
            return MessageResponse(
                id = dop.id,
                code = "NOT_ASSIGNED",
                name = dop.ticketNo,
                type = null,
                message = "No user assignment to revert",
                errorPosition = null,
                entity = null,
            )
        }

        dop.handledBy = null
        dop.handlerName = null
        dop.ticketStatus = DoPickOrderStatus.pending
        dop.ticketReleaseTime = null
        dop.ticketCompleteDateTime = null
        deliveryOrderPickOrderRepository.save(dop)

        val now = LocalDateTime.now()
        jdbcDao.executeUpdate(
            """
            UPDATE fpsmsdb.pick_order po
            SET po.assignTo = NULL,
                po.status = 'pending',
                po.modified = :modified,
                po.modifiedBy = :modifiedBy
            WHERE po.deliveryOrderPickOrderId = :dopoId
              AND po.deleted = 0
              AND po.status <> 'completed'
            """.trimIndent(),
            mapOf(
                "dopoId" to deliveryOrderPickOrderId,
                "modified" to now,
                "modifiedBy" to "workbench",
            ),
        )

        return MessageResponse(
            id = dop.id,
            code = "SUCCESS",
            name = dop.ticketNo,
            type = null,
            message = "Workbench assignment reverted",
            errorPosition = null,
            entity = mapOf("deliveryOrderPickOrderId" to dop.id),
        )
    }

    private fun queryWorkbenchReleasedDopoList(
        shopName: String?,
        storeId: String?,
        truck: String?,
        beforeToday: Boolean,
        equalsDeliveryDate: LocalDate? = null,
        releaseTypeFilter: String? = null,
    ): List<ReleasedDoPickOrderListItem> {
        val today = LocalDate.now()
        val params = mutableMapOf<String, Any>()
        val dateClause = if (beforeToday) {
            params["today"] = today
            " dop.requiredDeliveryDate < :today "
        } else {
            val target = equalsDeliveryDate ?: today
            params["targetDate"] = target
            " dop.requiredDeliveryDate = :targetDate "
        }
        val sqlBuilder = StringBuilder(
            """
            SELECT
              dop.id AS id,
              dop.requiredDeliveryDate AS requiredDeliveryDate,
              dop.shopCode AS shopCode,
              dop.shopName AS shopName,
              dop.storeId AS storeId,
              dop.truckLanceCode AS truckLanceCode,
              dop.truckDepartureTime AS truckDepartureTime,
              (
                SELECT GROUP_CONCAT(DISTINCT d.code ORDER BY d.code SEPARATOR ',')
                FROM fpsmsdb.pick_order po
                INNER JOIN fpsmsdb.delivery_order d ON d.id = po.doId AND d.deleted = 0
                WHERE po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
              ) AS doCodesStr
            FROM fpsmsdb.delivery_order_pick_order dop
            WHERE dop.deleted = 0
              AND dop.ticketStatus IN ('pending', 'released')
              AND $dateClause
            """.trimIndent()
        )
        if (!storeId.isNullOrBlank()) {
            val sid = when (storeId) {
                "2/F" -> "2/F"
                "4/F" -> "4/F"
                else -> storeId.trim()
            }
            sqlBuilder.append(" AND dop.storeId = :storeId ")
            params["storeId"] = sid
        }
        if (!truck.isNullOrBlank()) {
            sqlBuilder.append(" AND dop.truckLanceCode = :truck ")
            params["truck"] = truck.trim()
        }
        if (!shopName.isNullOrBlank()) {
            sqlBuilder.append(" AND (dop.shopName LIKE :shopPat OR dop.shopCode LIKE :shopPat) ")
            params["shopPat"] = "%${shopName.trim()}%"
        }
        val rtNorm = releaseTypeFilter?.trim()?.lowercase().orEmpty()
        if (rtNorm == "isExtra") {
            sqlBuilder.append(" AND LOWER(COALESCE(dop.releaseType, '')) = 'isExtra' ")
        }
        sqlBuilder.append(" ORDER BY dop.requiredDeliveryDate, dop.truckDepartureTime, dop.truckLanceCode, dop.id ")
        val rows: List<Map<String, Any?>> = try {
            jdbcDao.queryForList(sqlBuilder.toString(), params)
        } catch (e: Exception) {
            println("❌ queryWorkbenchReleasedDopoList: ${e.message}")
            emptyList()
        }
        return rows.mapNotNull { mapRowToReleasedDoPickOrderListItem(it) }
    }

    private fun mapRowToReleasedDoPickOrderListItem(row: Map<String, Any?>): ReleasedDoPickOrderListItem? {
        val idKey = row.keys.find { it.equals("id", true) } ?: return null
        // MySQL BIGINT may come back as BigInteger, which is not a Kotlin Number — avoid dropping rows.
        val id = cellToLong(row[idKey]) ?: return null
        val rdKey = row.keys.find { it.equals("requiredDeliveryDate", true) }
        val reqDate = when (val v = rdKey?.let { row[it] }) {
            null -> null
            is java.sql.Date -> v.toLocalDate()
            is LocalDate -> v
            else -> runCatching { LocalDate.parse(v.toString().take(10)) }.getOrNull()
        }
        fun strCol(n: String) = row.keys.find { it.equals(n, true) }?.let { row[it]?.toString()?.trim() }?.takeIf { it.isNotEmpty() }
        val ttKey = row.keys.find { it.equals("truckDepartureTime", true) }
        val ttVal = ttKey?.let { row[it] }
        val truckDepartureTime = when (ttVal) {
            null -> null
            is java.sql.Time -> ttVal.toLocalTime()
            is LocalTime -> ttVal
            is java.sql.Timestamp -> ttVal.toLocalDateTime().toLocalTime()
            else -> runCatching { LocalTime.parse(ttVal.toString().take(8)) }.getOrNull()
                ?: runCatching { LocalTime.parse(ttVal.toString()) }.getOrNull()
        }
        val doCodesStr = strCol("doCodesStr") ?: strCol("docodesstr")
        val codes = doCodesStr?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
        return ReleasedDoPickOrderListItem(
            id = id,
            requiredDeliveryDate = reqDate,
            shopCode = strCol("shopCode"),
            shopName = strCol("shopName"),
            storeId = strCol("storeId"),
            truckLanceCode = strCol("truckLanceCode"),
            truckDepartureTime = truckDepartureTime,
            deliveryOrderCodes = codes,
        )
    }

    private fun cellToLong(v: Any?): Long? {
        if (v == null) return null
        return when (v) {
            is Number -> v.toLong()
            is java.lang.Number -> v.longValue()
            is String -> v.trim().toLongOrNull()
            else -> v.toString().trim().toLongOrNull()
        }
    }

    /**
     * DO workbench: header [delivery_order_pick_order] + lines from [pick_order.deliveryOrderPickOrderId] (no do_pick_order_line).
     */
    open fun getAllPickOrderLotsWithDetailsHierarchicalWorkbench(userId: Long): Map<String, Any?> {
        println("=== Debug: getAllPickOrderLotsWithDetailsHierarchicalWorkbench (delivery_order_pick_order) ===")
        println("userId filter: $userId")
        val wallStartNs = System.nanoTime()
        val timing = linkedMapOf<String, Long>()
        fun mark(name: String, ms: Long) {
            timing[name] = (timing[name] ?: 0L) + ms
        }

        val user = userService.find(userId).orElse(null)
        if (user == null) {
            println("❌ User not found: $userId")
            return emptyMap()
        }

        val dopSql = """
    SELECT DISTINCT
        dop.id as do_pick_order_id,
        dop.ticketNo as ticket_no,
        dop.storeId as store_id,
        dop.truckLanceCode as TruckLanceCode,
        dop.truckDepartureTime as truck_departure_time,
        dop.shopCode as ShopCode,
        dop.shopName as ShopName,
        dop.ticketStatus as doTicketStatus
    FROM fpsmsdb.delivery_order_pick_order dop
    WHERE dop.handledBy = :userId

    AND dop.ticketStatus IN ('pending', 'released')
    AND dop.deleted = 0
    AND EXISTS (
        SELECT 1
        FROM fpsmsdb.pick_order po
        WHERE po.deliveryOrderPickOrderId = dop.id
          AND po.assignTo = :userId

          AND po.type = 'do'
          AND po.deleted = 0
    )
    ORDER BY dop.requiredDeliveryDate DESC, dop.truckDepartureTime DESC, dop.id DESC
    LIMIT 1
        """.trimIndent()

        var doPickOrderInfo: Map<String, Any?>? = null
        val dopQueryMs = measureTimeMillis {
            doPickOrderInfo = jdbcDao.queryForMap(dopSql, mapOf("userId" to userId)).orElse(null)
        }
        mark("query.dopMs", dopQueryMs)
        if (doPickOrderInfo == null) {
            println("❌ No delivery_order_pick_order found for workbench user $userId")
            val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000
            log.info(
                "workbench all-lots-hierarchical-workbench timing: userId={} totalMs={} detail={}",
                userId,
                totalMs,
                timing.entries.joinToString(", ") { "${it.key}=${it.value}" },
            )
            return mapOf(
                "fgInfo" to null,
                "pickOrders" to emptyList<Any>(),
            )
        }

        val doPickOrderInfoSafe = doPickOrderInfo ?: return mapOf(
            "fgInfo" to null,
            "pickOrders" to emptyList<Any>(),
        )
        val doTicketStatus = doPickOrderInfoSafe["doTicketStatus"]?.toString()?.trim()?.lowercase()
        val doPickOrderId = (doPickOrderInfoSafe["do_pick_order_id"] as? Number)?.toLong()
        if (doPickOrderId == null) {
            println("❌ Invalid delivery_order_pick_order id for user $userId")
            return mapOf(
                "fgInfo" to null,
                "pickOrders" to emptyList<Any>(),
            )
        }
        //println("    Found delivery_order_pick_order ID: $doPickOrderId")
        //println("    delivery_order_pick_order ticketStatus: $doTicketStatus")

        if (doTicketStatus == "completed") {
            return mapOf(
                "ticketStatus" to "completed",
                "fgInfo" to null,
                "pickOrders" to emptyList<Any>(),
            )
        }

        val pickOrdersSql = """
        SELECT DISTINCT
            po.id as pick_order_id,
            po.code as pick_order_code,
            do_tbl.id as do_order_id,
            do_tbl.code as delivery_order_code,
            po.consoCode,
            po.status,
            DATE_FORMAT(po.targetDate, '%Y-%m-%d') as targetDate
        FROM fpsmsdb.pick_order po
        LEFT JOIN fpsmsdb.delivery_order do_tbl ON do_tbl.id = po.doId
        WHERE po.deliveryOrderPickOrderId = :doPickOrderId
        AND po.assignTo = :userId
        AND po.type = 'do'
        AND po.deleted = false
        ORDER BY po.id
        """.trimIndent()

        var pickOrdersInfo: List<Map<String, Any?>> = emptyList()
        val pickOrdersQueryMs = measureTimeMillis {
            pickOrdersInfo = jdbcDao.queryForList(
                pickOrdersSql,
                mapOf("doPickOrderId" to doPickOrderId, "userId" to userId),
            )
        }
        mark("query.pickOrdersMs", pickOrdersQueryMs)
       // println("    Found ${pickOrdersInfo.size} pick orders (workbench)")

        var payload: Map<String, Any?> = emptyMap()
        val assembleMs = measureTimeMillis {
            payload = assembleHierarchicalFgPayload(
                jdbcDao = jdbcDao,
                doPickOrderId = doPickOrderId,
                doPickOrderInfo = doPickOrderInfoSafe,
                pickOrdersInfo = pickOrdersInfo,
                availableQtyInOutOnly = true,
                timingSink = ::mark,
            )
        }
        mark("assemble.totalMs", assembleMs)
        val totalMs = (System.nanoTime() - wallStartNs) / 1_000_000
        log.info(
            "workbench all-lots-hierarchical-workbench timing: userId={} dopoId={} pickOrders={} totalMs={} detail={}",
            userId,
            doPickOrderId,
            pickOrdersInfo.size,
            totalMs,
            timing.entries.joinToString(", ") { "${it.key}=${it.value}" },
        )
        return payload + mapOf("ticketStatus" to doTicketStatus)
    }

    /**
     * Completed-record detail for DO workbench.
     * Input id is `delivery_order_pick_order.id` (frontend `doPickOrderRecordId` in workbench mode).
     */
    open fun getCompletedLotDetailsByDeliveryOrderPickOrderId(deliveryOrderPickOrderId: Long): Map<String, Any?> {
        val dopSql = """
            SELECT
                dop.id as do_pick_order_id,
                dop.ticketNo as ticket_no,
                dop.storeId as store_id,
                dop.truckLanceCode as TruckLanceCode,
                dop.truckDepartureTime as truck_departure_time,
                dop.shopCode as ShopCode,
                dop.shopName as ShopName,
                dop.ticketStatus as doTicketStatus
            FROM fpsmsdb.delivery_order_pick_order dop
            WHERE dop.id = :dopoId
              AND dop.deleted = 0
            LIMIT 1
        """.trimIndent()

        val doPickOrderInfo = jdbcDao.queryForMap(dopSql, mapOf("dopoId" to deliveryOrderPickOrderId)).orElse(null)
            ?: return mapOf("fgInfo" to null, "pickOrders" to emptyList<Any>())

        val pickOrdersSql = """
            SELECT DISTINCT
                po.id as pick_order_id,
                po.code as pick_order_code,
                do_tbl.id as do_order_id,
                do_tbl.code as delivery_order_code,
                po.consoCode,
                po.status,
                DATE_FORMAT(po.targetDate, '%Y-%m-%d') as targetDate
            FROM fpsmsdb.pick_order po
            LEFT JOIN fpsmsdb.delivery_order do_tbl ON do_tbl.id = po.doId
            WHERE po.deliveryOrderPickOrderId = :dopoId
              AND po.type = 'do'
              AND po.deleted = 0
            ORDER BY po.id
        """.trimIndent()

        val pickOrdersInfo = jdbcDao.queryForList(pickOrdersSql, mapOf("dopoId" to deliveryOrderPickOrderId))
        if (pickOrdersInfo.isEmpty()) {
            return mapOf("fgInfo" to null, "pickOrders" to emptyList<Any>())
        }

        // Completed-record detail should keep each PO as an independent group
        // (ticket can link multiple POs, e.g. 20 + 25), not merged into a single 45-line bucket.
        val perPoPayloads = pickOrdersInfo.map { poInfo ->
            assembleHierarchicalFgPayload(
                jdbcDao = jdbcDao,
                doPickOrderId = deliveryOrderPickOrderId,
                doPickOrderInfo = doPickOrderInfo,
                pickOrdersInfo = listOf(poInfo),
                availableQtyInOutOnly = true,
            )
        }

        val fgInfo = perPoPayloads.firstOrNull()?.get("fgInfo")
        val pickOrders = perPoPayloads.flatMap { payload ->
            (payload["pickOrders"] as? List<*>)?.filterIsInstance<Map<String, Any?>>() ?: emptyList()
        }
        return mapOf(
            "fgInfo" to fgInfo,
            "pickOrders" to pickOrders,
        )
    }


    /** DO workbench FG list from [delivery_order_pick_order] + linked [pick_order] (no do_pick_order_line). */
    open fun getFgPickOrdersByUserIdWorkbench(userId: Long): List<Map<String, Any?>> {
        try {
            //println("    Starting getFgPickOrdersByUserIdWorkbench with userId: $userId")

            val sql = """
                SELECT 
                    dop.id as doPickOrderId,
                    dop.storeId as storeId,
                    dop.ticketNo as ticketNo,
                    dop.truckLanceCode as truckLanceCode,
                    dop.truckDepartureTime as DepartureTime,
                    dop.shopCode as shopCode,
                    dop.shopName as shopName,
                    s.id as shopId,
                    s.name as shopNameFromShop,
                    CONCAT_WS(', ', s.addr1, s.addr2, s.addr3, s.addr4, s.district) as shopAddress,
                    GROUP_CONCAT(DISTINCT po.id ORDER BY po.id) as pickOrderIds,
                    GROUP_CONCAT(DISTINCT po.code ORDER BY po.id SEPARATOR ', ') as pickOrderCodes,
                    GROUP_CONCAT(DISTINCT do_tbl.id ORDER BY do_tbl.id) as deliveryOrderIds,
                    GROUP_CONCAT(DISTINCT do_tbl.code ORDER BY do_tbl.id SEPARATOR ', ') as deliveryNos,
                    (SELECT po2.consoCode FROM fpsmsdb.pick_order po2 WHERE po2.id = MIN(po.id) LIMIT 1) as pickOrderConsoCode,
                    (SELECT po2.targetDate FROM fpsmsdb.pick_order po2 WHERE po2.id = MIN(po.id) LIMIT 1) as pickOrderTargetDate,
                    (SELECT po2.status FROM fpsmsdb.pick_order po2 WHERE po2.id = MIN(po.id) LIMIT 1) as pickOrderStatus,
                    (SELECT do2.orderDate FROM fpsmsdb.delivery_order do2 WHERE do2.id = MIN(do_tbl.id) LIMIT 1) as deliveryDate,
                    COUNT(DISTINCT po.id) as numberOfPickOrders,
                    (SELECT SUM(pol_count.line_count)
                     FROM (
                         SELECT po3.id, COUNT(*) as line_count
                         FROM fpsmsdb.pick_order po3
                         JOIN fpsmsdb.pick_order_line pol3 ON pol3.poId = po3.id AND pol3.deleted = false
                         WHERE po3.deliveryOrderPickOrderId = dop.id AND po3.deleted = 0
                         GROUP BY po3.id
                     ) pol_count
                    ) as numberOfCartons
                FROM fpsmsdb.delivery_order_pick_order dop
                INNER JOIN fpsmsdb.pick_order po ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
                LEFT JOIN fpsmsdb.delivery_order do_tbl ON do_tbl.id = po.doId
                LEFT JOIN fpsmsdb.shop s ON s.id = dop.shopId
                WHERE po.assignTo = :userId
                AND po.type = 'do'
                AND po.status IN ('assigned', 'released', 'picking')
                AND dop.handledBy = :userId
                AND dop.ticketStatus = 'released'
                AND dop.deleted = 0
                GROUP BY dop.id, dop.storeId, dop.ticketNo, dop.truckLanceCode, dop.truckDepartureTime,
                         dop.shopCode, dop.shopName, s.id, s.name, s.addr1, s.addr2, s.addr3, s.addr4, s.district
                ORDER BY MIN(po.targetDate) DESC, MIN(po.code) ASC
            """.trimIndent()

           // println("    Executing SQL for FG pick orders (workbench) by userId: $sql")
            val results = jdbcDao.queryForList(sql, mapOf("userId" to userId))

            if (results.isEmpty()) {
                println("❌ No active FG workbench pick orders for user: $userId")
                return emptyList()
            }

           // println("    Found ${results.size} active FG workbench pick orders for user: $userId")

            return results.map { row ->
                val pickOrderIdsStr = row["pickOrderIds"] as? String ?: ""
                val pickOrderIds = if (pickOrderIdsStr.isNotEmpty()) {
                    pickOrderIdsStr.split(",").mapNotNull { it.toLongOrNull() }
                } else {
                    emptyList()
                }

                mapOf(
                    "doPickOrderId" to (row["doPickOrderId"] ?: 0L),
                    "pickOrderIds" to pickOrderIds,
                    "pickOrderId" to (pickOrderIds.firstOrNull() ?: 0L),
                    "pickOrderCodes" to (row["pickOrderCodes"] ?: ""),
                    "pickOrderCode" to ((row["pickOrderCodes"] as? String)?.split(", ")?.firstOrNull() ?: ""),
                    "deliveryOrderIds" to (row["deliveryOrderIds"] as? String ?: "").split(",")
                        .mapNotNull { it.toLongOrNull() },
                    "deliveryNos" to (row["deliveryNos"] ?: ""),
                    "pickOrderConsoCode" to (row["pickOrderConsoCode"] ?: ""),
                    "pickOrderTargetDate" to (row["pickOrderTargetDate"]?.toString() ?: ""),
                    "pickOrderStatus" to (row["pickOrderStatus"] ?: ""),
                    "deliveryDate" to (row["deliveryDate"]?.toString() ?: ""),
                    "shopId" to (row["shopId"] ?: 0L),
                    "shopCode" to (row["shopCode"] ?: ""),
                    "shopName" to (row["shopName"] ?: row["shopNameFromShop"] ?: ""),
                    "shopAddress" to (row["shopAddress"] ?: ""),
                    "shopPoNo" to "",
                    "numberOfPickOrders" to (row["numberOfPickOrders"] ?: 0),
                    "numberOfCartons" to (row["numberOfCartons"] ?: 0),
                    "truckLanceCode" to (row["truckLanceCode"] ?: ""),
                    "DepartureTime" to (row["DepartureTime"]?.toString() ?: ""),
                    "ticketNo" to (row["ticketNo"] ?: ""),
                    "storeId" to (row["storeId"] ?: ""),
                    "qrCodeData" to (row["doPickOrderId"] ?: 0L),
                )
            }
        } catch (e: Exception) {
            println("❌ Error in getFgPickOrdersByUserIdWorkbench: ${e.message}")
            e.printStackTrace()
            return emptyList()
        }
    }

    /**
     * After a stock-out line is in an end state (including qty=0 zero-complete), refresh pick_order_line /
     * pick_order and, when all linked pick_orders are completed, mark [delivery_order_pick_order] completed.
     * Mirrors the POL/PO/ticket tail of [postWorkbenchPickSideEffects] without ledger/bag (no inventory delta).
     */
    private fun refreshWorkbenchPickOrderProgressAfterSolChange(stockOutLineId: Long) {
        val saved = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return
        val pol = saved.pickOrderLine ?: return
        val polId = pol.id ?: return
        val solsForPol = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
        if (isWorkbenchSolEndStatus(saved.status)) {
            tryCompletePickOrderLineWorkbench(polId, solsForPol)
        }
        checkWorkbenchPickOrderLineCompleted(polId, solsForPol)
        val pickOrder = pol.pickOrder ?: return
        val poId = pol.pickOrder?.id ?: return
        val freshPo = pickOrderRepository.findById(poId).orElse(null) ?: return
        val total = freshPo.totalLines ?: 0
        val completed = freshPo.submittedLines ?: 0
        val allCompleted = total > 0 && completed >= total
        if (allCompleted) {
            completePickOrderWithRetry(poId)
            tryCompleteDeliveryOrderPickOrderTicketCompleted(poId)
        }
    }

    private fun registerAfterCommit(action: () -> Unit) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            action()
            return
        }
        TransactionSynchronizationManager.registerSynchronization(
            object : TransactionSynchronization {
                override fun afterCommit() {
                    action()
                }
            },
        )
    }

    private fun runInNewTransaction(action: () -> Unit) {
        val txTemplate = TransactionTemplate(transactionManager).apply {
            propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
        }
        txTemplate.executeWithoutResult {
            action()
        }
    }

    private fun runWorkbenchPickDeferredFollowUps(
        solId: Long,
        polId: Long,
        pickOrderId: Long?,
        itemId: Long,
        userId: Long,
        hasExplicitQty: Boolean,
        explicitRemainder: BigDecimal,
        scannedIllId: Long?,
        requestStoreId: String?,
        effectiveExcludeWarehouseCodes: List<String>?,
        effectiveLotExhaustedSplit: Boolean,
        effectiveDelta: BigDecimal,
    ) {
        val deferredStart = System.nanoTime()
        var rebuildMs = 0L
        var ensureMs = 0L
        var polPartialMs = 0L
        var joHandledByMs = 0L
        var postMs = 0L
        try {
            if (pickOrderId != null) {
                if (hasExplicitQty) {
                    rebuildMs = measureTimeMillis {
                        if (explicitRemainder > BigDecimal.ZERO) {
                            suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineNextSingleLot(
                                pickOrderLineId = polId,
                                targetQty = explicitRemainder,
                                storeId = requestStoreId,
                                excludeInventoryLotLineId = scannedIllId,
                                excludeWarehouseCodes = effectiveExcludeWarehouseCodes,
                            )
                        } else {
                            suggestedPickLotWorkbenchService.setNoHoldSuggestionsForPickOrderLineExactQty(
                                pickOrderLineId = polId,
                                targetQty = BigDecimal.ZERO,
                                excludeWarehouseCodes = effectiveExcludeWarehouseCodes,
                            )
                        }
                    }
                    ensureMs = measureTimeMillis {
                        if (explicitRemainder > BigDecimal.ZERO) {
                            stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, userId)
                        } else {
                            val allSolEntities =
                                stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(listOf(polId))
                            val toClose = allSolEntities.filter { it.id != solId && !isWorkbenchSolEndStatus(it.status) }
                            if (toClose.isNotEmpty()) {
                                toClose.forEach { s ->
                                    s.status = StockOutLineStatus.COMPLETE.status
                                    if (s.startTime == null) s.startTime = LocalDateTime.now()
                                    s.endTime = LocalDateTime.now()
                                }
                                stockOutLIneRepository.saveAll(toClose)
                                stockOutLIneRepository.flush()
                            }
                        }
                    }
                } else {
                    rebuildMs = measureTimeMillis {
                        suggestedPickLotWorkbenchService.rebuildNoHoldSuggestionsForPickOrderLine(
                            pickOrderLineId = polId,
                            storeId = requestStoreId,
                            excludeWarehouseCodes = effectiveExcludeWarehouseCodes,
                        )
                    }
                    ensureMs = measureTimeMillis {
                        stockOutLineWorkbenchService.ensureStockOutLinesForPickOrderLineNoHold(polId, userId)
                    }
                }

                if (effectiveLotExhaustedSplit) {
                    polPartialMs = measureTimeMillis {
                        val polEntity = pickOrderLineRepository.findById(polId).orElse(null)
                        if (polEntity != null) {
                            polEntity.status = PickOrderLineStatus.PARTIALLY_COMPLETE
                            pickOrderLineRepository.save(polEntity)
                        }
                    }
                }

                joHandledByMs = measureTimeMillis {
                    updateJoPickOrderHandledByIfJobOrder(
                        pickOrderId = pickOrderId,
                        itemId = itemId,
                        userId = userId,
                    )
                }
            }
            val savedSol = stockOutLIneRepository.findById(solId).orElse(null)
            if (savedSol != null) {
                postMs = measureTimeMillis {
                    postWorkbenchPickSideEffects(savedSol, effectiveDelta, createLedger = false)
                }
            }
        } catch (e: Exception) {
            log.error("WORKBENCH_DEFERRED_PICK_FAILED solId={} polId={} msg={}", solId, polId, e.message, e)
        } finally {
            val totalMs = (System.nanoTime() - deferredStart) / 1_000_000
            log.info(
                "WORKBENCH_DEFERRED_PICK_TRACE solId={} polId={} totalMs={} rebuildMs={} ensureMs={} polPartialMs={} joHandledByMs={} postMs={}",
                solId,
                polId,
                totalMs,
                rebuildMs,
                ensureMs,
                polPartialMs,
                joHandledByMs,
                postMs,
            )
        }
    }

    private fun postWorkbenchPickSideEffects(savedStockOutLine: StockOutLine, deltaQty: BigDecimal, createLedger: Boolean = true) {
        if (deltaQty <= BigDecimal.ZERO) return
        val wall0 = System.nanoTime()
        var ledgerMs = 0L
        var bagMs = 0L
        var polCompleteMs = 0L
        var polCheckMs = 0L
        var poLinesFetchMs = 0L
        var poCompleteAndDoMs = 0L

        if (createLedger) {
            ledgerMs = measureTimeMillis { createWorkbenchPickLedger(savedStockOutLine, deltaQty) }
        }
        try {
            val bagT0 = System.nanoTime()
            val solItem = savedStockOutLine.item
            val inventoryLotLine = savedStockOutLine.inventoryLotLine
            val reqDeltaQty = deltaQty.toDouble()
            val statusLower = savedStockOutLine.status?.trim()?.lowercase() ?: ""
            val isCompletedOrPartiallyCompleted =
                statusLower == "completed" ||
                    statusLower == "partially_completed" ||
                    statusLower == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase()
            if (solItem?.isBag == true &&
                inventoryLotLine != null &&
                isCompletedOrPartiallyCompleted &&
                reqDeltaQty > 0
            ) {
                val bag = bagRepository.findByItemIdAndDeletedIsFalse(solItem.id!!)
                if (bag != null) {
                    val ln = inventoryLotLine.inventoryLot?.lotNo
                    if (ln != null) {
                        bagService.createBagLotLinesByBagId(
                            CreateBagLotLineRequest(
                                bagId = bag.id!!,
                                lotId = inventoryLotLine.inventoryLot?.id ?: 0L,
                                itemId = solItem.id!!,
                                lotNo = ln,
                                stockQty = reqDeltaQty.toInt(),
                                date = LocalDate.now(),
                                time = LocalTime.now(),
                                stockOutLineId = savedStockOutLine.id,
                            ),
                        )
                    }
                }
            }
            bagMs = (System.nanoTime() - bagT0) / 1_000_000
        } catch (e: Exception) {
            println("Workbench bag side effect: ${e.message}")
            e.printStackTrace()
        }
        val pol = savedStockOutLine.pickOrderLine ?: return
        val polId = pol.id ?: return
        val solsForPol = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
        if (isWorkbenchSolEndStatus(savedStockOutLine.status)) {
            polCompleteMs = measureTimeMillis { tryCompletePickOrderLineWorkbench(polId, solsForPol) }
        }
        polCheckMs = measureTimeMillis { checkWorkbenchPickOrderLineCompleted(polId, solsForPol) }
        val poId = pol.pickOrder?.id ?: return
        val freshPo = pickOrderRepository.findById(poId).orElse(null) ?: return
        if (freshPo != null && freshPo.id != null) {
            val poId = freshPo.id!!
            val t0 = System.nanoTime()
            //val allLines = pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(poId)
            poLinesFetchMs = (System.nanoTime() - t0) / 1_000_000
            // Split rows use pick_order_line.status = partially_completed until fully picked; do not complete header PO until every line is completed.
           // val allCompleted = allLines.all { it.status == PickOrderLineStatus.COMPLETED }
           val total = freshPo.totalLines ?: 0
           val completed = freshPo.submittedLines ?: 0
           val allCompleted = total > 0 && completed >= total
            if (allCompleted) {
                poCompleteAndDoMs = measureTimeMillis {
                    // Use reload+retry to avoid optimistic lock when other flows update the same PO.
                    completePickOrderWithRetry(poId)
                    // Workbench should only update delivery_order_pick_order.ticketStatus; do not touch do_pick_order* tables/records.
                    tryCompleteDeliveryOrderPickOrderTicketCompleted(poId)
                }
            }
        }
        val totalMs = (System.nanoTime() - wall0) / 1_000_000
        log.info(
            "WORKBENCH_POST_TRACE solId={} polId={} deltaQty={} totalMs={} ledgerMs={} bagMs={} polCompleteMs={} polCheckMs={} poLinesFetchMs={} poCompleteAndDoMs={}",
            savedStockOutLine.id ?: 0L,
            polId,
            deltaQty,
            totalMs,
            ledgerMs,
            bagMs,
            polCompleteMs,
            polCheckMs,
            poLinesFetchMs,
            poCompleteAndDoMs,
        )
    }

    private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) {
        if (deltaQty <= BigDecimal.ZERO) return
        val solItem = sol.item ?: return
        val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return
        // Fast path (batch-submit style): avoid querying latest ledger.
        // At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change.
        // So previousBalance can be reconstructed as (onHandAfter + deltaQty).
        val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
        val previousBalance = onHandAfter + deltaQty.toDouble()
        val newBalance = previousBalance - deltaQty.toDouble()
        val ledger = StockLedger().apply {
            this.stockOutLine = sol
            this.inventory = inventory
            this.inQty = null
            this.outQty = deltaQty.toDouble()
            this.balance = newBalance
            this.type = "NOR"
            this.itemId = solItem.id
            this.itemCode = solItem.code
            this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(solItem.id!!)?.uom?.id
                ?: inventory.uom?.id
            this.date = LocalDate.now()
        }
        // Do not flush per pick; transaction flush is sufficient and avoids slow roundtrips/locks.
        stockLedgerRepository.save(ledger)
    }

    private fun resolveWorkbenchPreviousBalance(
        itemId: Long,
        inventory: Inventory,
        onHandQtyBeforeUpdate: Double?,
    ): Double {
        if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate
        val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId)
        return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
    }

    private fun isWorkbenchSolEndStatus(status: String?): Boolean {
        val s = status?.trim()?.lowercase() ?: return false
        return s == "completed" || s == "rejected" || s == "partially_completed" ||
            s == StockOutLineStatus.COMPLETE.status.lowercase() ||
            s == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase()
    }

    /**
     * When every SOL on the POL is in a workbench "ended" state, mark POL completed and run conso completion.
     * Skips [checkAndCompletePickOrderByConsoCode] if POL was already completed (avoids full conso scan every pick).
     */
    private fun tryCompletePickOrderLineWorkbench(pickOrderLineId: Long, sols: List<StockOutLineInfo>) {
        if (sols.isEmpty()) return
        val allEnded = sols.all { isWorkbenchSolEndStatus(it.status) }
        if (!allEnded) return

        val polEntity = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return
        val poId = polEntity.pickOrder?.id ?: return

        // Atomic gate: only one concurrent request can transition this POL to COMPLETED.
        val changed = pickOrderLineRepository.markCompletedIfNotCompleted(pickOrderLineId)
        if (changed > 0) {
            pickOrderRepository.incrementSubmittedLines(poId)
        }
    }

    /**
     * Complete PO header with optimistic-lock retry.
     * Workbench should have a single source of truth for PO completion (avoid conso completion overlap).
     */
    private fun completePickOrderWithRetry(poId: Long, maxAttempts: Int = 3) {
        var last: Exception? = null
        repeat(maxAttempts) {
            try {
                entityManager.clear()
                val po = pickOrderRepository.findById(poId).orElse(null) ?: return
                if (po.status == PickOrderStatus.COMPLETED) return
                po.status = PickOrderStatus.COMPLETED
                po.completeDate = LocalDateTime.now()
                pickOrderRepository.saveAndFlush(po)
                return
            } catch (e: ObjectOptimisticLockingFailureException) {
                last = e
                Thread.sleep((10L..50L).random())
            }
        }
        throw last ?: RuntimeException("Failed to complete pick order after retries (poId=$poId)")
    }

     /**
     * Workbench completion: if all pick_orders under the same delivery_order_pick_order are completed,
     * update delivery_order_pick_order.ticketStatus and related delivery_order.status → completed.
     * Does not create do_pick_order / do_pick_order_line records.
     */
    private fun tryCompleteDeliveryOrderPickOrderTicketCompleted(poId: Long) {
        val dopRow = jdbcDao.queryForMap(
            "SELECT deliveryOrderPickOrderId AS dopId FROM fpsmsdb.pick_order WHERE id = :poId AND deleted = 0",
            mapOf("poId" to poId),
        ).orElse(null) ?: return
        val dopId = (dopRow["dopId"] as? Number)?.toLong() ?: return
        if (dopId <= 0L) return

        val unfinished = jdbcDao.queryForInt(
            """
            SELECT COUNT(1)
            FROM fpsmsdb.pick_order po
            WHERE po.deliveryOrderPickOrderId = :dopId
              AND po.deleted = 0
              AND po.type = 'do'
              AND po.status <> 'completed'
            """.trimIndent(),
            mapOf("dopId" to dopId),
        )
        if (unfinished > 0) return

        val prefix = "DN"
        val midfix = CodeGenerator.DEFAULT_MIDFIX
        val codePrefix = "$prefix-$midfix"
        val latestLegacyDn = doPickOrderRecordRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix)
        val latestWorkbenchDn = deliveryOrderPickOrderRepository.findLatestDeliveryNoteCodeByPrefix(codePrefix)
        val latestCode = listOfNotNull(latestLegacyDn, latestWorkbenchDn).maxOrNull()
        val newDeliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode)

        jdbcDao.executeUpdate(
            """
            UPDATE fpsmsdb.delivery_order_pick_order dop
            SET dop.ticketStatus = 'completed',
                dop.ticketCompleteDateTime = NOW(),
                dop.deliveryNoteCode = CASE
                    WHEN dop.deliveryNoteCode IS NULL OR TRIM(dop.deliveryNoteCode) = '' THEN :deliveryNoteCode
                    ELSE dop.deliveryNoteCode
                END
            WHERE dop.id = :dopId
              AND dop.deleted = 0
            """.trimIndent(),
            mapOf("dopId" to dopId, "deliveryNoteCode" to newDeliveryNoteCode),
        )
        markDeliveryOrdersCompletedForDeliveryOrderPickOrder(dopId)
    }
    /**
     * When a workbench ticket ([delivery_order_pick_order]) is completed, align linked [delivery_order] headers.
     */
    private fun markDeliveryOrdersCompletedForDeliveryOrderPickOrder(dopId: Long) {
        if (dopId <= 0L) return
        val rows = try {
            jdbcDao.queryForList(
                """
                SELECT DISTINCT po.doId AS doId
                FROM fpsmsdb.pick_order po
                WHERE po.deliveryOrderPickOrderId = :dopId
                  AND po.deleted = 0
                  AND po.doId IS NOT NULL
                """.trimIndent(),
                mapOf("dopId" to dopId),
            )
        } catch (_: Exception) {
            emptyList()
        }
        for (row in rows) {
            val doId = (row["doId"] as? Number)?.toLong() ?: continue
            val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId) ?: continue
            if (deliveryOrder.status == DeliveryOrderStatus.COMPLETED) continue
            deliveryOrder.status = DeliveryOrderStatus.COMPLETED
            deliveryOrder.completeDate = LocalDateTime.now()
            deliveryOrderRepository.save(deliveryOrder)
        }
    }
    private fun checkWorkbenchPickOrderLineCompleted(pickOrderLineId: Long, allStockOutLines: List<StockOutLineInfo>) {
        val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return
        if (pol.status == PickOrderLineStatus.COMPLETED) return
        val unfinishedLine = allStockOutLines.filter {
            val rawStatus = it.status.trim()
            val status = rawStatus.lowercase()
            val isComplete =
                status == "completed" || status == StockOutLineStatus.COMPLETE.status.lowercase()
            val isRejected =
                status == "rejected" || status == StockOutLineStatus.REJECTED.status.lowercase()
            val isPartiallyComplete =
                status == "partially_completed" || status == StockOutLineStatus.PARTIALLY_COMPLETE.status.lowercase()
            !(isComplete || isRejected || isPartiallyComplete)
        }
        if (unfinishedLine.isEmpty()) {
            pickOrderLineRepository.save(
                pol.apply { this.status = PickOrderLineStatus.COMPLETED },
            )
        }
    }

    private fun completeDoForPickOrderWorkbench(pickOrderId: Long) {
        val dpos = doPickOrderRepository.findByPickOrderId(pickOrderId)
        if (dpos.isNotEmpty()) {
            dpos.forEach {
                it.ticketStatus = DoPickOrderStatus.completed
                it.ticketCompleteDateTime = LocalDateTime.now()
            }
            doPickOrderRepository.saveAll(dpos)
            dpos.forEach { dpo ->
                dpo.doOrderId?.let { doId ->
                    val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId)
                    if (deliveryOrder != null && deliveryOrder.status != DeliveryOrderStatus.COMPLETED) {
                        deliveryOrder.status = DeliveryOrderStatus.COMPLETED
                        deliveryOrderRepository.save(deliveryOrder)
                    }
                }
            }
        }
        val dporList = doPickOrderRecordRepository.findByPickOrderId(pickOrderId)
        if (dporList.isNotEmpty()) {
            dporList.forEach {
                it.ticketStatus = DoPickOrderStatus.completed
                it.ticketCompleteDateTime = LocalDateTime.now()
            }
            doPickOrderRecordRepository.saveAll(dporList)
        }
    }

    private fun completeDoIfAllPickOrdersCompletedWorkbench(pickOrderId: Long) {
        val lines = doPickOrderLineRepository.findByPickOrderIdAndDeletedFalse(pickOrderId)
        val doPickOrderIds =
            if (lines.isNotEmpty()) lines.mapNotNull { it.doPickOrderId }.distinct()
            else doPickOrderRepository.findByPickOrderId(pickOrderId).mapNotNull { it.id }

        doPickOrderIds.forEach { dpoId ->
            val allLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(dpoId)
            val allPickOrderIdsInDpo = if (allLines.isNotEmpty()) {
                allLines.mapNotNull { it.pickOrderId }.distinct()
            } else {
                doPickOrderRepository.findById(dpoId).orElse(null)?.pickOrderId?.let { listOf(it) } ?: emptyList()
            }
            if (allPickOrderIdsInDpo.isEmpty()) return@forEach

            val statuses = allPickOrderIdsInDpo.map { id ->
                pickOrderRepository.findById(id).orElse(null)?.status
            }
            val allCompleted = statuses.all { it == PickOrderStatus.COMPLETED }
            if (!allCompleted) return@forEach

            val dpo = doPickOrderRepository.findById(dpoId).orElse(null) ?: return@forEach
            dpo.ticketStatus = DoPickOrderStatus.completed
            dpo.ticketCompleteDateTime = LocalDateTime.now()
            doPickOrderRepository.save(dpo)

            val prefix = "DN"
            val midfix = CodeGenerator.DEFAULT_MIDFIX
            val latestCode = doPickOrderRecordRepository.findLatestDeliveryNoteCodeByPrefix("${prefix}-${midfix}")
            val deliveryNoteCode = CodeGenerator.generateNo(prefix = prefix, midfix = midfix, latestCode = latestCode)

            val dpoRecord = DoPickOrderRecord(
                recordId = dpoId,
                storeId = dpo.storeId ?: "",
                ticketNo = dpo.ticketNo ?: "",
                ticketStatus = DoPickOrderStatus.completed,
                truckId = dpo.truckId,
                pickOrderId = dpo.pickOrderId,
                truckDepartureTime = dpo.truckDepartureTime,
                shopId = dpo.shopId,
                handledBy = dpo.handledBy,
                handlerName = dpo.handlerName,
                doOrderId = dpo.doOrderId,
                pickOrderCode = dpo.pickOrderCode,
                deliveryOrderCode = dpo.deliveryOrderCode,
                deliveryNoteCode = deliveryNoteCode,
                loadingSequence = dpo.loadingSequence,
                ticketReleaseTime = dpo.ticketReleaseTime,
                ticketCompleteDateTime = LocalDateTime.now(),
                truckLanceCode = dpo.truckLanceCode,
                shopCode = dpo.shopCode,
                shopName = dpo.shopName,
                requiredDeliveryDate = dpo.requiredDeliveryDate,
            )
            val savedHeader = doPickOrderRecordRepository.save(dpoRecord)

            val lineRecords = allLines.map { l ->
                DoPickOrderLineRecord().apply {
                    this.recordId = l.id
                    this.doPickOrderId = savedHeader.recordId
                    this.pickOrderId = l.pickOrderId
                    this.doOrderId = l.doOrderId
                    this.pickOrderCode = l.pickOrderCode
                    this.deliveryOrderCode = l.deliveryOrderCode
                    this.status = l.status
                }
            }
            if (lineRecords.isNotEmpty()) doPickOrderLineRecordRepository.saveAll(lineRecords)
            if (allLines.isNotEmpty()) doPickOrderLineRepository.deleteAll(allLines)
            doPickOrderRepository.delete(dpo)

            dpo.doOrderId?.let { doId ->
                val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(doId)
                if (deliveryOrder != null && deliveryOrder.status != DeliveryOrderStatus.COMPLETED) {
                    deliveryOrder.status = DeliveryOrderStatus.COMPLETED
                    deliveryOrderRepository.save(deliveryOrder)
                }
            }
        }
    }
    @Transactional
    open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) {
        val printer =
            printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer")

        val pdf = exportDeliveryNoteWorkbench(
            ExportDeliveryNoteRequest(
                doPickOrderId = request.doPickOrderId,
                numOfCarton = request.numOfCarton,
                isDraft = request.isDraft,
            ),
        )

        val jasperPrint = pdf["report"] as JasperPrint
        val tempPdfFile = File.createTempFile("print_job_", ".pdf")
        try {
            JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
            val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
            printer.ip?.let { ip ->
                val port = printer.port ?: 9100
                val driver = A4PrintDriverRegistry.getDriver(printer.brand)
                driver.print(tempPdfFile, ip, port, printQty)
            }
            updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton)
        } finally {
            //tempPdfFile.delete()
        }
    }

    private data class WorkbenchPrintContext(
        val header: DeliveryOrderPickOrder,
        val pickOrderIds: List<Long>,
        val pickOrderCodes: List<String>,
        val deliveryOrderIds: List<Long>,
    )

    private fun getWorkbenchPrintContext(deliveryOrderPickOrderId: Long): WorkbenchPrintContext {
        val header = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElseThrow {
            NoSuchElementException("DeliveryOrderPickOrder not found with ID: $deliveryOrderPickOrderId")
        }

        val rows = jdbcDao.queryForList(
            """
            SELECT po.id AS id, po.code AS code, po.doId AS doId
            FROM fpsmsdb.pick_order po
            WHERE po.deliveryOrderPickOrderId = :dopoId
              AND po.deleted = 0
            """.trimIndent(),
            mapOf("dopoId" to deliveryOrderPickOrderId),
        )
        if (rows.isEmpty()) {
            throw IllegalStateException("DeliveryOrderPickOrder $deliveryOrderPickOrderId has no associated pick orders")
        }

        val pickOrderIds = rows.mapNotNull { (it["id"] as? Number)?.toLong() }.distinct()
        val pickOrderCodes = rows.mapNotNull { it["code"]?.toString()?.takeIf { s -> s.isNotBlank() } }.distinct()
        val deliveryOrderIds = rows.mapNotNull { (it["doId"] as? Number)?.toLong() }.distinct()
        if (deliveryOrderIds.isEmpty()) {
            throw IllegalStateException("DeliveryOrderPickOrder $deliveryOrderPickOrderId has no associated delivery orders")
        }

        return WorkbenchPrintContext(
            header = header,
            pickOrderIds = pickOrderIds,
            pickOrderCodes = pickOrderCodes,
            deliveryOrderIds = deliveryOrderIds,
        )
    }

    private fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map<String, Any> {
        val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml"
        val resource = ClassPathResource(DELIVERYNOTE_PDF)
        if (!resource.exists()) {
            throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF")
        }

        val inputStream = resource.inputStream
        val deliveryNote = JasperCompileManager.compileReport(inputStream)
        val fields = mutableListOf<MutableMap<String, Any>>()
        val params = mutableMapOf<String, Any>()

        val ctx = getWorkbenchPrintContext(request.doPickOrderId)
        val deliveryNoteInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId ->
            deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId)
        }.toMutableList()
        if (deliveryNoteInfo.isEmpty()) {
            throw NoSuchElementException("Delivery orders not found for IDs: ${ctx.deliveryOrderIds}")
        }

        val allLines = deliveryNoteInfo.flatMap { info -> info.deliveryOrderLines.map { it } }
        val pickOrderLines = ctx.pickOrderIds.flatMap { pid ->
            pickOrderLineRepository.findAllByPickOrderId(pid)
        }
        val pickOrderLineIdsByItemId = pickOrderLines
            .groupBy { it.item?.id }
            .mapValues { (_, lines) -> lines.mapNotNull { it.id } }
        val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id }
        val stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>> =
            if (allPickOrderLineIds.isNotEmpty()) {
                allPickOrderLineIds.associateWith { polId ->
                    stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
                }
            } else {
                emptyMap()
            }

        val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT)
            ?.replace("/", "")
            ?.replace(" ", "")
            ?: ""
        val uniqueItemIdsForSort = allLines.mapNotNull { it.itemId }.distinct()
        val itemsById = if (uniqueItemIdsForSort.isNotEmpty()) {
            itemsRepository.findAllById(uniqueItemIdsForSort).associateBy { it.id!! }
        } else {
            emptyMap()
        }
        val sortedLines = when (doStoreFloorKey) {
            "2F" -> allLines.sortedWith(
                compareBy(
                    { line -> itemsById[line.itemId]?.item_Order ?: Int.MAX_VALUE },
                    { line -> line.itemNo },
                ),
            )
            else -> allLines.sortedBy { line ->
                line.itemId?.let { deliveryOrderService.getWarehouseOrderByItemId(it) } ?: Int.MAX_VALUE
            }
        }
        val allIllIds = stockOutLinesByPickOrderLineId.values
            .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } }
            .distinct()
        val illById: Map<Long, InventoryLotLine> = if (allIllIds.isNotEmpty()) {
            inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! }
        } else {
            emptyMap()
        }

        sortedLines.forEach { line ->
            val field = mutableMapOf<String, Any>()
            field["sequenceNumber"] = (fields.size + 1).toString()
            field["itemNo"] = line.itemNo
            field["itemName"] = line.itemName ?: ""
            field["uom"] = line.uom ?: ""
            val actualPickQty = if (line.itemId != null) {
                val pickOrderLineIdsForItem = pickOrderLineIdsByItemId[line.itemId] ?: emptyList()
                val totalQty = pickOrderLineIdsForItem.sumOf { polId ->
                    stockOutLinesByPickOrderLineId[polId].orEmpty().sumOf { it.qty }
                }
                totalQty.toString()
            } else {
                line.qty.toString()
            }
            field["qty"] = actualPickQty
            field["shortName"] = line.uomShortDesc ?: ""
            val route = line.itemId?.let { itemId ->
                 deliveryOrderService.routeFromStockOutsForItem(
                    itemId,
                    pickOrderLineIdsByItemId,
                    stockOutLinesByPickOrderLineId,
                    illById,
                ).takeIf { it != "-" } ?: deliveryOrderService.getWarehouseCodeByItemId(itemId)
            } ?: "-"
            field["route"] = route
            val lotNo = line.itemId?.let { itemId ->
                val pickOrderLineIdsForItem = pickOrderLineIdsByItemId[itemId] ?: emptyList()
                val lotNumbers = pickOrderLineIdsForItem.flatMap { polId ->
                    stockOutLinesByPickOrderLineId[polId].orEmpty().mapNotNull { it.lotNo }
                }.distinct().joinToString(", ")
                lotNumbers.ifBlank { "沒有庫存" }
            } ?: "沒有庫存"
            field["lotNo"] = lotNo
            val signOff = line.itemId?.let { itemId ->
                if (itemsById[itemId]?.isEgg == true) "簽署: __________" else ""
            } ?: ""
            field["signOff"] = signOff
            fields.add(field)
        }

        params["dnTitle"] = "送貨單"
        params["colQty"] = "已提數量"
        params["totalCartonTitle"] = "總箱數："
        params["deliveryNoteCodeTitle"] = "送貨單編號："
        params["deliveryNoteCode"] = ctx.header.deliveryNoteCode ?: ""
        params["numOfCarton"] = request.numOfCarton.toString().takeUnless { it == "0" } ?: ""
        params["shopName"] = ctx.header.shopName ?: deliveryNoteInfo[0].shopName ?: ""
        params["shopAddress"] = deliveryNoteInfo[0].shopAddress ?: ""
        params["deliveryDate"] = ctx.header.requiredDeliveryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
            ?: deliveryNoteInfo[0].estimatedArrivalDate?.format(DateTimeFormatter.ISO_LOCAL_DATE)
            ?: ""
        params["truckNo"] = ctx.header.truckLanceCode ?: ""
        params["ShopPurchaseOrderNo"] = deliveryNoteInfo.joinToString(", ") { it.code }
        params["FGPickOrderNo"] = ctx.pickOrderCodes.joinToString(", ")
        params["loadingSequence"] = ""

        return mapOf(
            "report" to PdfUtils.fillReport(deliveryNote, fields, params),
            "filename" to deliveryNoteInfo.joinToString("_") { it.code },
        )
    }
        @Transactional
    open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) {
        val printer =
            printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer")

        val pdf = exportDNLabelsWorkbench(
            ExportDNLabelsRequest(
                doPickOrderId = request.doPickOrderId,
                numOfCarton = request.numOfCarton,
            ),
        )

        val jasperPrint = pdf["report"] as JasperPrint
        val tempPdfFile = File.createTempFile("print_job_", ".pdf")
        try {
            JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
            val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
            printer.ip?.let { ip ->
                printer.port?.let { port ->
                    ZebraPrinterUtil.printPdfToZebra(
                        tempPdfFile,
                        ip,
                        port,
                        printQty,
                        ZebraPrinterUtil.PrintDirection.ROTATED,
                        printer.dpi,
                    )
                }
            }
            updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton)
        } finally {
            //tempPdfFile.delete()
        }
    }

    private fun workbenchCartonLabelParams(
        ctx: WorkbenchPrintContext,
        cartonLabelInfo: MutableList<DeliveryOrderInfo>,
    ): MutableMap<String, Any> {
        val params = mutableMapOf<String, Any>()
        params["shopPurchaseOrderNo"] = if (ctx.deliveryOrderIds.size > 1) {
            "請查閲送貨單（採購單共${ctx.deliveryOrderIds.size}張）"
        } else {
            cartonLabelInfo[0].code
        }
        params["deliveryNoteCode"] = ctx.header.deliveryNoteCode ?: ""
        params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: ""
        val rawShopLabel = ctx.header.shopName ?: cartonLabelInfo[0].shopName ?: ""
        val parsedShopLabel = deliveryOrderService.parseShopLabelForCartonLabel(rawShopLabel)
        params["shopCode"] = parsedShopLabel.shopCode
        params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr
        params["shopName"] = parsedShopLabel.shopNameForLabel
        params["truckNo"] = ctx.header.truckLanceCode ?: ""
        return params
    }

    private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map<String, Any> {
        val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml"
        val resource = ClassPathResource(DNLABELS_PDF)
        if (!resource.exists()) {
            throw FileNotFoundException("Label file not found: $DNLABELS_PDF")
        }

        val ctx = getWorkbenchPrintContext(request.doPickOrderId)
        val inputStream = resource.inputStream
        val cartonLabel = JasperCompileManager.compileReport(inputStream)
        val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId ->
            deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId)
        }.toMutableList()
        if (cartonLabelInfo.isEmpty()) {
            throw NoSuchElementException("Delivery orders not found for IDs: ${ctx.deliveryOrderIds}")
        }

        val params = workbenchCartonLabelParams(ctx, cartonLabelInfo)
        val fields = mutableListOf<MutableMap<String, Any>>()

        for (cartonNumber in 1..request.numOfCarton) {
            fields.add(
                mutableMapOf(
                    "cartonIndex" to cartonNumber,
                    "cartonTotal" to request.numOfCarton,
                )
            )
        }

        return mapOf(
            "report" to PdfUtils.fillReport(cartonLabel, fields, params),
            "filename" to "${cartonLabelInfo[0].code}_carton_labels",
        )
    }

    private fun validateWorkbenchCartonReprintRange(
        fromCarton: Int,
        toCarton: Int,
        totalCartonsOnShipment: Int,
    ) {
        require(totalCartonsOnShipment >= 1) {
            "totalCartonsOnShipment must be at least 1"
        }
        require(fromCarton >= 1) {
            "fromCarton must be at least 1"
        }
        require(toCarton >= fromCarton) {
            "toCarton must be greater than or equal to fromCarton"
        }
        require(toCarton <= totalCartonsOnShipment) {
            "toCarton must be less than or equal to totalCartonsOnShipment"
        }
    }


    private fun exportDNLabelsReprintWorkbench(request: PrintDNLabelsReprintRequest): Map<String, Any> {
        validateWorkbenchCartonReprintRange(
            fromCarton = request.fromCarton,
            toCarton = request.toCarton,
            totalCartonsOnShipment = request.totalCartonsOnShipment,
        )

        val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml"
        val resource = ClassPathResource(DNLABELS_PDF)
        if (!resource.exists()) {
            throw FileNotFoundException("Label file not found: $DNLABELS_PDF")
        }

        val ctx = getWorkbenchPrintContext(request.doPickOrderId)
        val inputStream = resource.inputStream
        val cartonLabel = JasperCompileManager.compileReport(inputStream)
        val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId ->
            deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId)
        }.toMutableList()
        if (cartonLabelInfo.isEmpty()) {
            throw NoSuchElementException("Delivery orders not found for IDs: ${ctx.deliveryOrderIds}")
        }

        val params = workbenchCartonLabelParams(ctx, cartonLabelInfo)
        val fields = mutableListOf<MutableMap<String, Any>>()

        for (cartonNumber in request.fromCarton..request.toCarton) {
            fields.add(
                mutableMapOf(
                    "cartonIndex" to cartonNumber,
                    "cartonTotal" to request.totalCartonsOnShipment,
                ),
            )
        }

        return mapOf(
            "report" to PdfUtils.fillReport(cartonLabel, fields, params),
            "filename" to "${cartonLabelInfo[0].code}_carton_labels_reprint_${request.fromCarton}-${request.toCarton}",
        )
    }

    private fun updateWorkbenchCartonQty(deliveryOrderPickOrderId: Long, cartonQty: Int) {
        if (cartonQty <= 0) return
        val workbenchHeader = deliveryOrderPickOrderRepository.findById(deliveryOrderPickOrderId).orElse(null) ?: return
        workbenchHeader.cartonQty = cartonQty
        deliveryOrderPickOrderRepository.save(workbenchHeader)
    }
    @Transactional
    open fun printDNLabelsReprint(request: PrintDNLabelsReprintRequest) {
        val printer =
            printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer")

        val pdf = exportDNLabelsReprintWorkbench(request)
        val jasperPrint = pdf["report"] as JasperPrint
        val tempPdfFile = File.createTempFile("print_job_", ".pdf")
        try {
            JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
            val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
            printer.ip?.let { ip ->
                printer.port?.let { port ->
                    ZebraPrinterUtil.printPdfToZebra(
                        tempPdfFile,
                        ip,
                        port,
                        printQty,
                        ZebraPrinterUtil.PrintDirection.ROTATED,
                        printer.dpi,
                    )
                }
            }
        } finally {
            // tempPdfFile.delete()
        }
    }
}
