package com.ffii.fpsms.modules.chart.service

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.LocalDateTime

@Service
open class ChartService(
    private val jdbcDao: JdbcDao,
) {

    /**
     * Stock transactions: total qty by date (from stock_ledger).
     * Returns list of { date, inQty, outQty, totalQty } for chart.
     */
    fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate)
        val sql = """
            SELECT
                DATE_FORMAT(sl.date, '%Y-%m-%d') AS date,
                COALESCE(SUM(sl.inQty), 0) AS inQty,
                COALESCE(SUM(sl.outQty), 0) AS outQty,
                COALESCE(SUM(COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0)), 0) AS totalQty
            FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)
            WHERE sl.deleted = 0 AND sl.date IS NOT NULL
            $rangeSql
            GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d')
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Delivery orders: order count and total line qty by date.
     * X-axis date: [delivery_order.estimatedArrivalDate] only (no completeDate/orderDate fallback).
     * Rows without estimatedArrivalDate are excluded.
     */
    fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate)
        val sql = """
            SELECT
                DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date,
                COUNT(DISTINCT do.id) AS orderCount,
                COALESCE(SUM(dol.qty), 0) AS totalQty
            FROM delivery_order do
            LEFT JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
            WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL
            $rangeSql
            GROUP BY DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d')
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Purchase orders: count by status (pending, receiving, completed).
     * targetDate: when set, include POs whose estimatedArrivalDate is on that date OR have stock-in
     * activity (completeDate/receiptDate/created) on that date; when null, all POs.
     * Optional multi-filters: supplierIds, itemCodes (line itemNo), purchaseOrderNos (po.code).
     */
    fun getPurchaseOrderByStatus(
        targetDate: LocalDate?,
        supplierIds: List<Long>?,
        itemCodes: List<String>?,
        purchaseOrderNos: List<String>?,
    ): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val dateSql = buildPurchaseOrderActualSearchDateSql(targetDate, args)
        val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, null, args)
        val sql = """
            SELECT
                COALESCE(po.status, 'unknown') AS status,
                COUNT(po.id) AS count
            FROM purchase_order po
            WHERE po.deleted = 0
            $dateSql $multiSql
            GROUP BY po.status
            ORDER BY count DESC
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /** Filter options for purchase chart on the same date scope as 實際已送貨. */
    fun getPurchaseOrderFilterOptions(targetDate: LocalDate?): Map<String, Any> {
        if (targetDate == null) {
            return mapOf(
                "suppliers" to emptyList<Any>(),
                "items" to emptyList<Any>(),
                "poNos" to emptyList<Any>(),
            )
        }
        val args = mutableMapOf<String, Any>()
        val dateSql = buildPurchaseOrderActualSearchDateSql(targetDate, args)
        val suppliersSql = """
            SELECT DISTINCT po.supplierId AS supplierId, s.code AS code, COALESCE(s.name, '') AS name
            FROM purchase_order po
            LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0
            WHERE po.deleted = 0 $dateSql AND po.supplierId IS NOT NULL
            ORDER BY s.code
        """.trimIndent()
        val itemsSql = """
            SELECT DISTINCT pol.itemNo AS itemCode, COALESCE(it.name, '') AS itemName
            FROM purchase_order_line pol
            INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = 0
            LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0
            WHERE pol.deleted = 0 $dateSql
            ORDER BY pol.itemNo
        """.trimIndent()
        val poNosSql = """
            SELECT DISTINCT po.code AS poNo
            FROM purchase_order po
            WHERE po.deleted = 0 $dateSql
            ORDER BY po.code
        """.trimIndent()
        return mapOf(
            "suppliers" to jdbcDao.queryForList(suppliersSql, args),
            "items" to jdbcDao.queryForList(itemsSql, args),
            "poNos" to jdbcDao.queryForList(poNosSql, args),
        )
    }

    /**
     * 預計送貨: POs with estimatedArrivalDate on targetDate, grouped by 已送 / 未送 / 已取消.
     * 已取消: deleted = 1; 已送: deleted = 0 and status = completed; 未送: deleted = 0 and pending/receiving.
     * other: remaining active statuses (if any).
     */
    fun getPurchaseOrderEstimatedArrivalSummary(
        targetDate: LocalDate?,
        supplierIds: List<Long>?,
        itemCodes: List<String>?,
        purchaseOrderNos: List<String>?,
    ): List<Map<String, Any>> {
        if (targetDate == null) return emptyList()
        val args = mutableMapOf<String, Any>("targetDate" to targetDate.toString())
        val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, null, args)
        val sql = """
            SELECT
                CASE
                    WHEN COALESCE(po.deleted, 0) = 1 THEN 'cancelled'
                    WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) = 'completed' THEN 'delivered'
                    WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) IN ('pending', 'receiving') THEN 'not_delivered'
                    ELSE 'other'
                END AS bucket,
                COUNT(DISTINCT po.id) AS count
            FROM purchase_order po
            WHERE DATE(po.estimatedArrivalDate) = :targetDate
              AND po.estimatedArrivalDate IS NOT NULL
              $multiSql
            GROUP BY bucket
            ORDER BY count DESC
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * 預計送貨: for a single bucket (delivered / not_delivered / cancelled / other) on [targetDate]
     * (DATE(estimatedArrivalDate)), return distinct suppliers, items, and PO headers matching the same rules as
     * [getPurchaseOrderEstimatedArrivalSummary] plus [buildPoMultiFiltersSql] bar filters.
     */
    fun getPurchaseOrderEstimatedArrivalBreakdown(
        targetDate: LocalDate?,
        estimatedArrivalBucket: String?,
        supplierIds: List<Long>?,
        itemCodes: List<String>?,
        purchaseOrderNos: List<String>?,
    ): Map<String, Any> {
        if (targetDate == null || estimatedArrivalBucket.isNullOrBlank()) {
            return mapOf(
                "suppliers" to emptyList<Any>(),
                "items" to emptyList<Any>(),
                "purchaseOrders" to emptyList<Any>(),
            )
        }
        val args = mutableMapOf<String, Any>()
        val bucketSql = buildPoDeletedAndEstimatedArrivalBucketSql(estimatedArrivalBucket, targetDate, args)
        val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, null, args)
        val suppliersSql = """
            SELECT
                po.supplierId AS supplierId,
                s.code AS supplierCode,
                COALESCE(s.name, '') AS supplierName,
                COUNT(DISTINCT po.id) AS poCount
            FROM purchase_order po
            LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0
            WHERE $bucketSql
              $multiSql
            GROUP BY po.supplierId, s.code, s.name
            ORDER BY poCount DESC, supplierCode
        """.trimIndent()
        val itemsSql = """
            SELECT
                pol.itemNo AS itemCode,
                COALESCE(it.name, '') AS itemName,
                COUNT(DISTINCT po.id) AS poCount,
                COALESCE(SUM(pol.qty), 0) AS totalQty
            FROM purchase_order po
            INNER JOIN purchase_order_line pol ON pol.purchaseOrderId = po.id AND pol.deleted = 0
            LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0
            WHERE $bucketSql
              $multiSql
            GROUP BY pol.itemNo, it.name
            ORDER BY totalQty DESC, pol.itemNo
        """.trimIndent()
        val posSql = """
            SELECT
                po.id AS purchaseOrderId,
                po.code AS purchaseOrderNo,
                COALESCE(po.status, '') AS status,
                DATE_FORMAT(po.orderDate, '%Y-%m-%d') AS orderDate,
                po.supplierId AS supplierId,
                s.code AS supplierCode,
                COALESCE(s.name, '') AS supplierName
            FROM purchase_order po
            LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0
            WHERE $bucketSql
              $multiSql
            ORDER BY po.code
        """.trimIndent()
        return mapOf(
            "suppliers" to jdbcDao.queryForList(suppliersSql, args),
            "items" to jdbcDao.queryForList(itemsSql, args),
            "purchaseOrders" to jdbcDao.queryForList(posSql, args),
        )
    }

    private fun buildPoMultiFiltersSql(
        supplierIds: List<Long>?,
        itemCodes: List<String>?,
        purchaseOrderNos: List<String>?,
        supplierCode: String?,
        args: MutableMap<String, Any>,
    ): String {
        val sb = StringBuilder()
        val sids = supplierIds?.mapNotNull { it }?.filter { it > 0 }?.distinct() ?: emptyList()
        val scode = supplierCode?.trim()?.takeIf { it.isNotEmpty() }
        if (sids.isNotEmpty()) {
            args["filterSupplierIds"] = sids
            sb.append(" AND po.supplierId IN (:filterSupplierIds)")
        } else if (scode != null) {
            args["filterSupplierCode"] = scode
            sb.append(
                " AND EXISTS (SELECT 1 FROM shop sfc WHERE sfc.id = po.supplierId AND sfc.deleted = 0 AND sfc.code = :filterSupplierCode)"
            )
        }
        val icodes = itemCodes?.map { it.trim() }?.filter { it.isNotEmpty() }?.distinct() ?: emptyList()
        if (icodes.isNotEmpty()) {
            args["filterItemCodes"] = icodes
            sb.append(
                """ AND EXISTS (
                    SELECT 1 FROM purchase_order_line polf
                    WHERE polf.purchaseOrderId = po.id AND polf.deleted = 0 AND polf.itemNo IN (:filterItemCodes)
                )"""
            )
        }
        val poNos = purchaseOrderNos?.map { it.trim() }?.filter { it.isNotEmpty() }?.distinct() ?: emptyList()
        if (poNos.isNotEmpty()) {
            args["filterPoNos"] = poNos
            sb.append(" AND po.code IN (:filterPoNos)")
        }
        return sb.toString()
    }

    /**
     * Purchase orders detail rows by status for chart drill-down.
     * Example use: click "pending" segment -> show matching PO headers.
     * targetDate: optional filter — by order date or complete date (see dateFilter).
     * dateFilter: "order" = DATE(po.orderDate); "complete" = DATE(po.completeDate) (for received/completed on a day).
     * supplierIds / itemCodes / purchaseOrderNos: optional multi-filters (same as chart filter bar).
     * supplierCode: optional when supplierId list is empty (e.g. drill-down with code only).
     */
    fun getPurchaseOrderDetailsByStatus(
        status: String,
        targetDate: LocalDate?,
        dateFilter: String,
        supplierIds: List<Long>?,
        itemCodes: List<String>?,
        purchaseOrderNos: List<String>?,
        supplierCode: String?,
        estimatedArrivalBucket: String?,
    ): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>(
            "status" to status.trim().lowercase()
        )
        val dateSql = buildPurchaseOrderDrillOrderDateSql(estimatedArrivalBucket, targetDate, dateFilter, args)
        val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, supplierCode, args)
        val deletedAndEaSql = buildPoDeletedAndEstimatedArrivalBucketSql(estimatedArrivalBucket, targetDate, args)
        /** When 預計送貨 bucket is active, [buildPoDeletedAndEstimatedArrivalBucketSql] already fixes status shape (e.g. 未送 = pending|receiving). Do not AND with :status (would wrongly require e.g. completed). */
        val statusSingleFilterSql = if (estimatedArrivalBucket.isNullOrBlank() || targetDate == null) {
            "AND LOWER(COALESCE(po.status, 'unknown')) = :status"
        } else {
            ""
        }
        val sql = """
            SELECT
                po.id AS purchaseOrderId,
                po.code AS purchaseOrderNo,
                COALESCE(po.status, 'unknown') AS status,
                DATE_FORMAT(po.orderDate, '%Y-%m-%d') AS orderDate,
                DATE_FORMAT(po.estimatedArrivalDate, '%Y-%m-%d') AS estimatedArrivalDate,
                po.supplierId AS supplierId,
                s.code AS supplierCode,
                COALESCE(s.name, '') AS supplierName,
                COUNT(DISTINCT pol.id) AS itemCount,
                COALESCE(SUM(pol.qty), 0) AS totalQty
            FROM purchase_order po
            LEFT JOIN purchase_order_line pol ON pol.purchaseOrderId = po.id AND pol.deleted = 0
            LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0
            WHERE $deletedAndEaSql
              $statusSingleFilterSql
              $dateSql $multiSql
            GROUP BY
                po.id, po.code, po.status, po.orderDate, po.estimatedArrivalDate, po.supplierId, s.code, s.name
            ORDER BY po.orderDate DESC, po.code DESC
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * When [estimatedArrivalBucket] is set (delivered / not_delivered / cancelled / other), filter by
     * [getPurchaseOrderEstimatedArrivalSummary] bucket rules on [targetDate] (estimated arrival date).
     * Includes soft-deleted rows when bucket is cancelled.
     */
    private fun buildPoDeletedAndEstimatedArrivalBucketSql(
        estimatedArrivalBucket: String?,
        targetDate: LocalDate?,
        args: MutableMap<String, Any>,
    ): String {
        val b = estimatedArrivalBucket?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
        if (b == null || targetDate == null) {
            return "po.deleted = 0"
        }
        args["eaBucket"] = b
        args["eaTargetDate"] = targetDate.toString()
        return """
            1=1
            AND po.estimatedArrivalDate IS NOT NULL
            AND DATE(po.estimatedArrivalDate) = :eaTargetDate
            AND (
                CASE
                    WHEN COALESCE(po.deleted, 0) = 1 THEN 'cancelled'
                    WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) = 'completed' THEN 'delivered'
                    WHEN COALESCE(po.deleted, 0) = 0 AND LOWER(COALESCE(po.status, '')) IN ('pending', 'receiving') THEN 'not_delivered'
                    ELSE 'other'
                END
            ) = :eaBucket
        """.trimIndent()
    }

    private fun buildPurchaseOrderTargetDateSql(
        targetDate: LocalDate?,
        dateFilter: String,
        args: MutableMap<String, Any>,
    ): String {
        if (targetDate == null) return ""
        args["targetDate"] = targetDate.toString()
        return if (dateFilter.equals("complete", ignoreCase = true)) {
            "AND po.completeDate IS NOT NULL AND DATE(po.completeDate) = :targetDate"
        } else {
            "AND DATE(po.orderDate) = :targetDate"
        }
    }

    /**
     * Date scope for 實際已送貨:
     * - planned side: PO estimatedArrivalDate = targetDate
     * - actual side: any stock-in activity date = targetDate
     */
    private fun buildPurchaseOrderActualSearchDateSql(
        targetDate: LocalDate?,
        args: MutableMap<String, Any>,
    ): String {
        if (targetDate == null) return ""
        args["targetDate"] = targetDate.toString()
        return """
            AND (
                (po.estimatedArrivalDate IS NOT NULL AND DATE(po.estimatedArrivalDate) = :targetDate)
                OR EXISTS (
                    SELECT 1
                    FROM purchase_order_line polDate
                    INNER JOIN stock_in_line silDate ON silDate.purchaseOrderLineId = polDate.id AND silDate.deleted = 0
                    INNER JOIN stock_in siDate ON siDate.id = silDate.stockInId AND siDate.deleted = 0
                    WHERE polDate.purchaseOrderId = po.id
                      AND polDate.deleted = 0
                      AND DATE(COALESCE(siDate.completeDate, silDate.receiptDate, siDate.created)) = :targetDate
                )
            )
        """.trimIndent()
    }

    /**
     * Drill-down rows for 預計送貨 must match [getPurchaseOrderEstimatedArrivalSummary] (estimated arrival date only).
     * If we also applied [buildPurchaseOrderTargetDateSql], many POs (orderDate ≠ 預計到貨日) would drop out and
     * supplier / item charts would collapse incorrectly.
     *
     * For 實際已送貨 (no bucket), drill must follow [buildPurchaseOrderActualSearchDateSql] to keep lower charts
     * consistent with the top donut data scope.
     */
    private fun buildPurchaseOrderDrillOrderDateSql(
        estimatedArrivalBucket: String?,
        targetDate: LocalDate?,
        @Suppress("UNUSED_PARAMETER") dateFilter: String,
        args: MutableMap<String, Any>,
    ): String {
        if (!estimatedArrivalBucket.isNullOrBlank() && targetDate != null) {
            return ""
        }
        return buildPurchaseOrderActualSearchDateSql(targetDate, args)
    }

    /**
     * Purchase order item lines for drill-down.
     * Includes ordered qty, received qty and pending qty.
     */
    fun getPurchaseOrderItems(purchaseOrderId: Long): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>("purchaseOrderId" to purchaseOrderId)
        val sql = """
            SELECT
                pol.id AS purchaseOrderLineId,
                pol.itemNo AS itemCode,
                COALESCE(it.name, '') AS itemName,
                COALESCE(pol.qty, 0) AS orderedQty,
                COALESCE(u.udfudesc, u.code, u.udfShortDesc, '') AS uom,
                COALESCE(SUM(
                    CASE
                        WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0)
                        ELSE 0
                    END
                ), 0) AS receivedQty,
                GREATEST(
                    COALESCE(pol.qty, 0) - COALESCE(SUM(
                        CASE
                            WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0)
                            ELSE 0
                        END
                    ), 0),
                    0
                ) AS pendingQty
            FROM purchase_order_line pol
            INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = 0
            LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0
            LEFT JOIN uom_conversion u ON u.id = pol.uomId
            LEFT JOIN stock_in_line sil ON sil.purchaseOrderLineId = pol.id AND sil.deleted = 0
            WHERE pol.deleted = 0
              AND pol.purchaseOrderId = :purchaseOrderId
            GROUP BY pol.id, pol.itemNo, it.name, pol.qty, u.udfudesc, u.code, u.udfShortDesc
            ORDER BY pol.id
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Purchase order items grouped by item for a status (and optional targetDate).
     * Used for default drill-down chart when no PO is selected.
     * supplierIds / itemCodes / purchaseOrderNos: optional multi-filters.
     * supplierCode: optional when supplierId list is empty (e.g. drill-down with code only).
     */
    fun getPurchaseOrderItemsByStatus(
        status: String,
        targetDate: LocalDate?,
        dateFilter: String,
        supplierIds: List<Long>?,
        itemCodes: List<String>?,
        purchaseOrderNos: List<String>?,
        supplierCode: String?,
        estimatedArrivalBucket: String?,
    ): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>(
            "status" to status.trim().lowercase()
        )
        val dateSql = buildPurchaseOrderDrillOrderDateSql(estimatedArrivalBucket, targetDate, dateFilter, args)
        val multiSql = buildPoMultiFiltersSql(supplierIds, itemCodes, purchaseOrderNos, supplierCode, args)
        val deletedAndEaSql = buildPoDeletedAndEstimatedArrivalBucketSql(estimatedArrivalBucket, targetDate, args)
        val statusSingleFilterSql = if (estimatedArrivalBucket.isNullOrBlank() || targetDate == null) {
            "AND LOWER(COALESCE(po.status, 'unknown')) = :status"
        } else {
            ""
        }
        val sql = """
            SELECT
                pol.itemNo AS itemCode,
                COALESCE(it.name, '') AS itemName,
                COALESCE(u.udfudesc, u.code, u.udfShortDesc, '') AS uom,
                COALESCE(SUM(pol.qty), 0) AS orderedQty,
                COALESCE(SUM(
                    CASE
                        WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0)
                        ELSE 0
                    END
                ), 0) AS receivedQty,
                GREATEST(
                    COALESCE(SUM(pol.qty), 0) - COALESCE(SUM(
                        CASE
                            WHEN sil.id IS NOT NULL THEN COALESCE(sil.acceptedQty, 0)
                            ELSE 0
                        END
                    ), 0),
                    0
                ) AS pendingQty
            FROM purchase_order po
            INNER JOIN purchase_order_line pol ON pol.purchaseOrderId = po.id AND pol.deleted = 0
            LEFT JOIN shop s ON s.id = po.supplierId AND s.deleted = 0
            LEFT JOIN items it ON it.id = pol.itemId AND it.deleted = 0
            LEFT JOIN uom_conversion u ON u.id = pol.uomId
            LEFT JOIN stock_in_line sil ON sil.purchaseOrderLineId = pol.id AND sil.deleted = 0
            WHERE $deletedAndEaSql
              $statusSingleFilterSql
              $dateSql $multiSql
            GROUP BY pol.itemNo, it.name, u.udfudesc, u.code, u.udfShortDesc
            ORDER BY orderedQty DESC, pol.itemNo
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Stock in vs stock out by date.
     * Stock in: stock_in_line.acceptedQty, date from stock_in.completeDate or receiptDate/created.
     * Stock out: stock_out_line.qty, date from stock_out.completeDate or created.
     *
     * Date range is applied inside each UNION branch (predicate pushdown) so we do not aggregate
     * all history before filtering. Reads filtered headers first via STRAIGHT_JOIN (si/so then lines).
     */
    fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val rangeStart = startDate?.atStartOfDay()
        val rangeEndExclusive = endDate?.plusDays(1)?.atStartOfDay()
        if (rangeStart != null) args["inOutRangeStart"] = rangeStart
        if (rangeEndExclusive != null) args["inOutRangeEndExclusive"] = rangeEndExclusive
        val inDateFilter = stockInOutCoalescedDateRangeSql(
            "COALESCE(si.completeDate, sil.receiptDate, si.created)",
            rangeStart,
            rangeEndExclusive,
        )
        val outDateFilter = stockInOutCoalescedDateRangeSql(
            "COALESCE(so.completeDate, so.created)",
            rangeStart,
            rangeEndExclusive,
        )
        val sql = """
            SELECT u.dt AS date,
                   COALESCE(SUM(u.inQty), 0) AS inQty,
                   COALESCE(SUM(u.outQty), 0) AS outQty
            FROM (
                SELECT DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d') AS dt,
                       SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty
                FROM stock_in si
                STRAIGHT_JOIN stock_in_line sil ON sil.stockInId = si.id AND sil.deleted = 0
                WHERE si.deleted = 0$inDateFilter
                GROUP BY DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d')
                UNION ALL
                SELECT DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d') AS dt,
                       0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty
                FROM stock_out so
                STRAIGHT_JOIN stock_out_line sol ON sol.stockOutId = so.id AND sol.deleted = 0
                WHERE so.deleted = 0$outDateFilter
                GROUP BY DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d')
            ) u
            GROUP BY u.dt
            ORDER BY u.dt
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Distinct items that appear in delivery_order_line in the period (for multi-select options).
     * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded.
     * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`).
     */
    fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate)
        val sql = """
            SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName
            FROM delivery_order do
            STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
            STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0
            WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL
            $rangeSql
            ORDER BY it.code
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Top delivery items by total qty in the period. When itemCodes is non-empty, only those items (still ordered by totalQty, limit applied).
     * Period filter: [delivery_order.estimatedArrivalDate] only; null ETA excluded.
     * Uses STRAIGHT_JOIN so MySQL reads filtered `delivery_order` first (avoids full scan on `delivery_order_line`).
     */
    fun getTopDeliveryItems(
        startDate: LocalDate?,
        endDate: LocalDate?,
        limit: Int,
        itemCodes: List<String>?
    ): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>("limit" to limit)
        val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate)
        val itemSql = if (!itemCodes.isNullOrEmpty()) {
            val codes = itemCodes.map { it.trim() }.filter { it.isNotBlank() }
            if (codes.isEmpty()) "" else {
                args["itemCodes"] = codes
                "AND it.code IN (:itemCodes)"
            }
        } else ""
        val sql = """
            SELECT
                it.code AS itemCode,
                it.name AS itemName,
                SUM(COALESCE(dol.qty, 0)) AS totalQty
            FROM delivery_order do
            STRAIGHT_JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0
            STRAIGHT_JOIN items it ON it.id = dol.itemId AND it.deleted = 0
            WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL
            $rangeSql $itemSql
            GROUP BY dol.itemId, it.code, it.name
            ORDER BY totalQty DESC
            LIMIT :limit
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Stock balance trend: one series per item (or aggregated) by date from stock_ledger.
     * If itemCode is provided, filter to that item; otherwise returns daily total balance sum (across items).
     */
    fun getStockBalanceTrend(
        startDate: LocalDate?,
        endDate: LocalDate?,
        itemCode: String?
    ): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate)
        val hasItemFilter = !itemCode.isNullOrBlank()
        if (hasItemFilter) {
            args["itemCode"] = "%$itemCode%"
        }
        val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else ""
        val fromClause = if (hasItemFilter) {
            "FROM stock_ledger sl"
        } else {
            "FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)"
        }
        val sql = """
            SELECT
                DATE_FORMAT(sl.date, '%Y-%m-%d') AS date,
                COALESCE(SUM(sl.balance), 0) AS balance
            $fromClause
            WHERE sl.deleted = 0 AND sl.date IS NOT NULL
            $rangeSql $itemSql
            GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d')
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Consumption trend (out qty by month) for chart - from stock_ledger outQty.
     * Optional itemCode filter.
     */
    fun getConsumptionTrendByMonth(
        year: Int?,
        startDate: LocalDate?,
        endDate: LocalDate?,
        itemCode: String?
    ): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val yearSql = if (year != null) {
            args["consumptionYearStart"] = LocalDate.of(year, 1, 1).atStartOfDay()
            args["consumptionYearEndExclusive"] = LocalDate.of(year + 1, 1, 1).atStartOfDay()
            "AND sl.date >= :consumptionYearStart AND sl.date < :consumptionYearEndExclusive"
        } else ""
        val rangeSql = ledgerDateTimeRangeSql(
            args,
            "sl.date",
            startDate,
            endDate,
            startArg = "consumptionRangeStart",
            endArg = "consumptionRangeEndExclusive",
        )
        val hasItemFilter = !itemCode.isNullOrBlank()
        if (hasItemFilter) {
            args["itemCode"] = "%$itemCode%"
        }
        val itemSql = if (hasItemFilter) "AND sl.itemCode LIKE :itemCode" else ""
        val fromClause = if (hasItemFilter) {
            "FROM stock_ledger sl"
        } else {
            "FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date)"
        }
        val sql = """
            SELECT
                DATE_FORMAT(sl.date, '%Y-%m') AS month,
                COALESCE(SUM(sl.outQty), 0) AS outQty
            $fromClause
            WHERE sl.deleted = 0 AND sl.date IS NOT NULL
            $yearSql $rangeSql $itemSql
            GROUP BY DATE_FORMAT(sl.date, '%Y-%m')
            ORDER BY month
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Returns staff from user table for multi-select filter: staffNo and name, only users that have staffNo.
     */
    fun getStaffDeliveryPerformanceHandlers(): List<Map<String, Any>> {
        val sql = """
            SELECT u.staffNo AS staffNo, COALESCE(u.name, '') AS name
            FROM user u
            WHERE u.deleted = 0
              AND u.staffNo IS NOT NULL
              AND TRIM(u.staffNo) <> ''
            ORDER BY u.staffNo
        """.trimIndent()
        return jdbcDao.queryForList(sql, emptyMap<String, Any>())
    }

    /**
     * Staff delivery performance: daily pick ticket count and total time per staff.
     * Uses delivery_order_pick_order (handler = handledBy); time = sum of
     * (ticketCompleteDateTime - ticketReleaseTime) per completed ticket.
     * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select).
     * storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId).
     * storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed).
     * When no store filter, FORCE INDEX (idx_dopo_staff_perf_complete) so the optimizer uses a
     * ticketCompleteDateTime range scan instead of a less selective store composite index.
     */
    fun getStaffDeliveryPerformance(
        startDate: LocalDate?,
        endDate: LocalDate?,
        staffNos: List<String>?,
        storeId: String?,
        storeIdNull: Boolean?,
    ): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.atStartOfDay()
            "AND dop.ticketCompleteDateTime >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endExclusive"] = endDate.plusDays(1).atStartOfDay()
            "AND dop.ticketCompleteDateTime < :endExclusive"
        } else ""
        val staffSql = if (!staffNos.isNullOrEmpty()) {
            val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() }
            if (nos.isEmpty()) "" else {
                args["staffNos"] = nos
                "AND u.staffNo IN (:staffNos)"
            }
        } else ""
        val storeSql = when {
            storeIdNull == true -> "AND dop.storeId IS NULL"
            !storeId.isNullOrBlank() -> {
                args["filterStoreId"] = storeId.trim()
                "AND dop.storeId = :filterStoreId"
            }
            else -> ""
        }
        val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank()
        val fromClause = if (useStoreFilter) {
            "FROM delivery_order_pick_order dop"
        } else {
            "FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)"
        }
        val sql = """
            SELECT
                DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date,
                COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName,
                COUNT(dop.id) AS orderCount,
                COALESCE(SUM(
                    CASE
                        WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL
                        THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime))
                        ELSE 0
                    END
                ), 0) AS totalMinutes
            $fromClause
            LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0
            WHERE dop.deleted = 0
              AND dop.ticketStatus = 'completed'
              AND dop.ticketCompleteDateTime IS NOT NULL
            $startSql $endSql $staffSql $storeSql
            GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'),
                     dop.handledBy, u.name, dop.handlerName
            ORDER BY date, orderCount DESC
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    // ---------- Job order reports ----------

    /**
     * Job order count by status. targetDate: when set, only JOs with planStart on that date.
     */
    fun getJobOrderByStatus(targetDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val dateSql = if (targetDate != null) {
            args["targetDate"] = targetDate.toString()
            "AND DATE(jo.planStart) = :targetDate"
        } else ""
        val sql = """
            SELECT COALESCE(jo.status, 'unknown') AS status, COUNT(jo.id) AS count
            FROM job_order jo
            WHERE jo.deleted = 0
            $dateSql
            GROUP BY jo.status
            ORDER BY count DESC
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Job order count by date (by planStart date).
     */
    fun getJobOrderCountByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.toString()
            "AND DATE(jo.planStart) >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endDate"] = endDate.toString()
            "AND DATE(jo.planStart) <= :endDate"
        } else ""
        val sql = """
            SELECT DATE_FORMAT(jo.planStart, '%Y-%m-%d') AS date, COUNT(jo.id) AS orderCount
            FROM job_order jo
            WHERE jo.deleted = 0 AND jo.planStart IS NOT NULL
            $startSql $endSql
            GROUP BY DATE(jo.planStart)
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Job order created vs completed by date: created count (by created date), completed count (by actualEnd date).
     * Uses two simple queries and merges by date to avoid SQL complexity and parameter reuse issues.
     */
    fun getJobOrderCreatedCompletedByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.toString()
            "AND DATE(jo.created) >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endDate"] = endDate.toString()
            "AND DATE(jo.created) <= :endDate"
        } else ""
        val createdSql = """
            SELECT DATE_FORMAT(jo.created, '%Y-%m-%d') AS date, COUNT(jo.id) AS createdCount
            FROM job_order jo
            WHERE jo.deleted = 0 AND jo.created IS NOT NULL $startSql $endSql
            GROUP BY DATE(jo.created)
            ORDER BY date
        """.trimIndent()
        val createdRows = jdbcDao.queryForList(createdSql, args)

        val args2 = mutableMapOf<String, Any>()
        val startSql2 = if (startDate != null) {
            args2["startDate"] = startDate.toString()
            "AND DATE(jo.actualEnd) >= :startDate"
        } else ""
        val endSql2 = if (endDate != null) {
            args2["endDate"] = endDate.toString()
            "AND DATE(jo.actualEnd) <= :endDate"
        } else ""
        val completedSql = """
            SELECT DATE_FORMAT(jo.actualEnd, '%Y-%m-%d') AS date, COUNT(jo.id) AS completedCount
            FROM job_order jo
            WHERE jo.deleted = 0 AND jo.actualEnd IS NOT NULL AND jo.status = 'completed' $startSql2 $endSql2
            GROUP BY DATE(jo.actualEnd)
            ORDER BY date
        """.trimIndent()
        val completedRows = jdbcDao.queryForList(completedSql, args2)

        val dateToCreated = createdRows.mapNotNull { r ->
            val d = r["date"]?.toString()
            val c = (r["createdCount"] as? Number)?.toInt() ?: 0
            if (d != null) d to c else null
        }.toMap()
        val dateToCompleted = completedRows.mapNotNull { r ->
            val d = r["date"]?.toString()
            val c = (r["completedCount"] as? Number)?.toInt() ?: 0
            if (d != null) d to c else null
        }.toMap()
        val allDates = (dateToCreated.keys + dateToCompleted.keys).sorted()
        return allDates.map { dt ->
            mapOf(
                "date" to dt,
                "createdCount" to (dateToCreated[dt] ?: 0),
                "completedCount" to (dateToCompleted[dt] ?: 0)
            )
        }
    }

    /**
     * Job order material: pending vs picked (by planStart date).
     * From job_order_bom_material: status pending = 待領, completed = 已揀.
     */
    fun getJobMaterialPendingPickedByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.toString()
            "AND DATE(jo.planStart) >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endDate"] = endDate.toString()
            "AND DATE(jo.planStart) <= :endDate"
        } else ""
        val sql = """
            SELECT DATE_FORMAT(jo.planStart, '%Y-%m-%d') AS date,
                COALESCE(SUM(CASE WHEN jobm.status = 'pending' THEN 1 ELSE 0 END), 0) AS pendingCount,
                COALESCE(SUM(CASE WHEN jobm.status = 'completed' THEN 1 ELSE 0 END), 0) AS pickedCount
            FROM job_order jo
            LEFT JOIN job_order_bom_material jobm ON jobm.jobOrderId = jo.id AND jobm.deleted = 0
            WHERE jo.deleted = 0 AND jo.planStart IS NOT NULL
            $startSql $endSql
            GROUP BY DATE(jo.planStart)
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Job order process: pending vs completed by date (by jo planStart).
     * Pending = endTime IS NULL, completed = endTime IS NOT NULL.
     */
    fun getJobProcessPendingCompletedByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.toString()
            "AND DATE(jo.planStart) >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endDate"] = endDate.toString()
            "AND DATE(jo.planStart) <= :endDate"
        } else ""
        val sql = """
            SELECT DATE_FORMAT(jo.planStart, '%Y-%m-%d') AS date,
                COALESCE(SUM(CASE WHEN jop.endTime IS NULL THEN 1 ELSE 0 END), 0) AS pendingCount,
                COALESCE(SUM(CASE WHEN jop.endTime IS NOT NULL THEN 1 ELSE 0 END), 0) AS completedCount
            FROM job_order jo
            JOIN job_order_process jop ON jop.joId = jo.id AND jop.deleted = 0
            WHERE jo.deleted = 0 AND jo.planStart IS NOT NULL
            $startSql $endSql
            GROUP BY DATE(jo.planStart)
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Job order equipment: working vs worked by date (by process detail operatingStart).
     * Working = operatingEnd IS NULL, worked = operatingEnd IS NOT NULL.
     */
    fun getJobEquipmentWorkingWorkedByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.toString()
            "AND DATE(COALESCE(jopd.operatingStart, jo.planStart)) >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endDate"] = endDate.toString()
            "AND DATE(COALESCE(jopd.operatingStart, jo.planStart)) <= :endDate"
        } else ""
        val sql = """
            SELECT DATE_FORMAT(COALESCE(jopd.operatingStart, jo.planStart), '%Y-%m-%d') AS date,
                COALESCE(SUM(CASE WHEN jopd.operatingEnd IS NULL THEN 1 ELSE 0 END), 0) AS workingCount,
                COALESCE(SUM(CASE WHEN jopd.operatingEnd IS NOT NULL THEN 1 ELSE 0 END), 0) AS workedCount
            FROM job_order_process_detail jopd
            JOIN job_order_process jop ON jop.id = jopd.jopId AND jop.deleted = 0
            JOIN job_order jo ON jo.id = jop.joId AND jo.deleted = 0
            WHERE jopd.deleted = 0 AND (jopd.operatingStart IS NOT NULL OR jo.planStart IS NOT NULL)
            $startSql $endSql
            GROUP BY DATE(COALESCE(jopd.operatingStart, jo.planStart))
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Equipment usage board: equipment usage for the selected calendar day.
     *
     * **Legacy:** one row per [job_order_process_detail] when the parent step is closed
     * ([job_order_process.endTime] or job **completed**) and the row matches the day.
     *
     * **Production flow (工藝流程):** one row per [productprocessline] with equipment (id, detail, or free-text
     * [equipment_name]) when the line is done (Pass/Completed / [endTime]) or the step/job is closed—aligned with
     * `/jo/edit` line display. [jopdId] is **negative** for line-sourced rows (stable client key; not a real jopd id).
     *
     * Day filter: `COALESCE(operatingEnd, operatingStart, …)` for jopd; for lines
     * `COALESCE(line.endTime, line.startTime, jop.endTime, planStart)`.
     * Each row includes **usageMinutes** (jopd: operating span; line: start–end minutes or [processingTime] when Pass).
     * - [targetDate] null: uses server **LocalDate.now** (prefer passing the client calendar date from the UI).
     */
    fun getEquipmentUsageBoardRows(targetDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val effectiveDate = targetDate ?: LocalDate.now()
        args["eqUsageDate"] = effectiveDate.toString()
        val sql = """
            SELECT
                t.jopdId AS jopdId,
                t.equipmentId AS equipmentId,
                t.equipmentCode AS equipmentCode,
                t.equipmentName AS equipmentName,
                t.jobOrderId AS jobOrderId,
                t.jobOrderCode AS jobOrderCode,
                t.jobPlanStart AS jobPlanStart,
                t.processCode AS processCode,
                t.processName AS processName,
                t.operatingStart AS operatingStart,
                t.operatingEnd AS operatingEnd,
                t.usageMinutes AS usageMinutes,
                t.workingNow AS workingNow,
                t.operatorUsername AS operatorUsername,
                t.operatorName AS operatorName
            FROM (
                SELECT
                    jopd.id AS jopdId,
                    e.id AS equipmentId,
                    e.code AS equipmentCode,
                    e.name AS equipmentName,
                    jo.id AS jobOrderId,
                    jo.code AS jobOrderCode,
                    DATE_FORMAT(jo.planStart, '%Y-%m-%d %H:%i:%s') AS jobPlanStart,
                    p.code AS processCode,
                    p.name AS processName,
                    DATE_FORMAT(jopd.operatingStart, '%Y-%m-%d %H:%i:%s') AS operatingStart,
                    DATE_FORMAT(jopd.operatingEnd, '%Y-%m-%d %H:%i:%s') AS operatingEnd,
                    CASE
                        WHEN jopd.operatingStart IS NOT NULL AND jopd.operatingEnd IS NOT NULL
                        THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, jopd.operatingStart, jopd.operatingEnd))
                        ELSE 0
                    END AS usageMinutes,
                    CASE
                        WHEN jopd.operatingStart IS NOT NULL AND jopd.operatingEnd IS NULL THEN 1
                        ELSE 0
                    END AS workingNow,
                    COALESCE(u.username, '') AS operatorUsername,
                    COALESCE(NULLIF(TRIM(COALESCE(u.fullname, '')), ''), u.name, u.username, '') AS operatorName,
                    COALESCE(jopd.operatingEnd, jopd.operatingStart, jop.endTime, jo.planStart) AS _sort
                FROM job_order_process_detail jopd
                INNER JOIN equipment e ON e.id = jopd.equipmentId AND e.deleted = 0
                INNER JOIN job_order_process jop ON jop.id = jopd.jopId AND jop.deleted = 0
                INNER JOIN job_order jo ON jo.id = jop.joId AND jo.deleted = 0
                INNER JOIN process p ON p.id = jop.processId AND p.deleted = 0
                LEFT JOIN user u ON u.id = jopd.operatorId AND u.deleted = 0
                WHERE jopd.deleted = 0
                AND (
                    jop.endTime IS NOT NULL
                    OR LOWER(TRIM(COALESCE(jo.status, ''))) = 'completed'
                )
                AND COALESCE(jopd.operatingEnd, jopd.operatingStart, jop.endTime, jo.planStart) IS NOT NULL
                AND DATE(COALESCE(jopd.operatingEnd, jopd.operatingStart, jop.endTime, jo.planStart)) = :eqUsageDate
                UNION ALL
                SELECT
                    -ppl.id AS jopdId,
                    COALESCE(ppl.equipmentId, 0) AS equipmentId,
                    COALESCE(NULLIF(TRIM(e.code), ''), '') AS equipmentCode,
                    COALESCE(
                        NULLIF(TRIM(CONCAT_WS('-',
                            NULLIF(TRIM(ppl.equipment_name), ''),
                            NULLIF(TRIM(COALESCE(e.name, '')), ''),
                            NULLIF(TRIM(COALESCE(ed.code, '')), '')
                        )), ''),
                        NULLIF(TRIM(COALESCE(e.name, '')), ''),
                        NULLIF(TRIM(ppl.equipment_name), ''),
                        '—'
                    ) AS equipmentName,
                    jo.id AS jobOrderId,
                    jo.code AS jobOrderCode,
                    DATE_FORMAT(jo.planStart, '%Y-%m-%d %H:%i:%s') AS jobPlanStart,
                    p.code AS processCode,
                    p.name AS processName,
                    DATE_FORMAT(ppl.startTime, '%Y-%m-%d %H:%i:%s') AS operatingStart,
                    DATE_FORMAT(ppl.endTime, '%Y-%m-%d %H:%i:%s') AS operatingEnd,
                    CASE
                        WHEN ppl.startTime IS NOT NULL AND ppl.endTime IS NOT NULL
                        THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, ppl.startTime, ppl.endTime))
                        WHEN ppl.startTime IS NOT NULL
                            AND LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                        THEN GREATEST(0, COALESCE(ppl.processingTime, 0))
                        ELSE 0
                    END AS usageMinutes,
                    CASE
                        WHEN ppl.startTime IS NOT NULL AND ppl.endTime IS NULL THEN 1
                        ELSE 0
                    END AS workingNow,
                    COALESCE(u2.username, '') AS operatorUsername,
                    COALESCE(NULLIF(TRIM(COALESCE(u2.fullname, '')), ''), u2.name, u2.username, '') AS operatorName,
                    COALESCE(ppl.endTime, ppl.startTime, jop.endTime, jo.planStart) AS _sort
                FROM productprocessline ppl
                INNER JOIN productprocess pp ON pp.id = ppl.productprocessid AND pp.deleted = 0
                INNER JOIN job_order jo ON jo.id = pp.jobOrderId AND jo.deleted = 0
                INNER JOIN bom_process bp ON bp.id = ppl.bomProcessId AND bp.deleted = 0 AND bp.bomId = pp.bomId
                INNER JOIN job_order_process jop ON jop.joId = jo.id
                    AND jop.processId = bp.processId
                    AND jop.seqNo = bp.seqNo
                    AND jop.deleted = 0
                INNER JOIN process p ON p.id = jop.processId AND p.deleted = 0
                LEFT JOIN equipment e ON e.id = ppl.equipmentId AND e.deleted = 0
                LEFT JOIN equipment_detail ed ON ed.id = ppl.equipmentDetailId AND ed.deleted = 0
                LEFT JOIN user u2 ON u2.id = ppl.operatorId AND u2.deleted = 0
                WHERE ppl.deleted = 0
                AND (
                    jop.endTime IS NOT NULL
                    OR ppl.endTime IS NOT NULL
                    OR LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                    OR LOWER(TRIM(COALESCE(jo.status, ''))) = 'completed'
                )
                AND (
                    ppl.equipmentId IS NOT NULL
                    OR ppl.equipmentDetailId IS NOT NULL
                    OR NULLIF(TRIM(COALESCE(ppl.equipment_name, '')), '') IS NOT NULL
                )
                AND COALESCE(ppl.endTime, ppl.startTime, jop.endTime, jo.planStart) IS NOT NULL
                AND DATE(COALESCE(ppl.endTime, ppl.startTime, jop.endTime, jo.planStart)) = :eqUsageDate
            ) t
            ORDER BY t.workingNow DESC, t._sort DESC
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Process board: one row per [job_order_process] with master [process] and parent [job_order].
     *
     * [boardStatus] aligns with the job-order 工藝流程 UI (product process lines): we aggregate
     * [productprocessline] rows for the same job order, BOM step ([bom_process] processId + seqNo),
     * and fall back to [JobOrderProcess.startTime]/[endTime] when no lines match (legacy).
     * Also returns line step name, description, equipment (type-name-code), and operator display name
     * via [GROUP_CONCAT] when multiple lines match one [job_order_process] row.
     *
     * Same optional filters as [getJobOrderBoardRows]: plan-start day and incomplete jobs only.
     */
    fun getProcessBoardRows(targetDate: LocalDate?, incompleteOnly: Boolean): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val dateSql = if (targetDate != null) {
            args["procBoardDate"] = targetDate.toString()
            "AND jo.planStart IS NOT NULL AND DATE(jo.planStart) = :procBoardDate"
        } else ""
        val incompleteSql = if (incompleteOnly) {
            "AND LOWER(TRIM(COALESCE(jo.status, ''))) <> 'completed'"
        } else ""
        val sql = """
            SELECT
                jop.id AS jopId,
                jo.id AS jobOrderId,
                jo.code AS jobOrderCode,
                COALESCE(jo.status, 'unknown') AS jobOrderStatus,
                p.id AS processId,
                p.code AS processCode,
                p.name AS processName,
                jop.seqNo AS seqNo,
                COALESCE(jop.status, '') AS rowStatus,
                DATE_FORMAT(jo.planStart, '%Y-%m-%d %H:%i:%s') AS jobPlanStart,
                DATE_FORMAT(COALESCE(jop.startTime, lineAgg.minLineStart), '%Y-%m-%d %H:%i:%s') AS startTime,
                DATE_FORMAT(COALESCE(jop.endTime, lineAgg.maxLineEndAllDone), '%Y-%m-%d %H:%i:%s') AS endTime,
                COALESCE(lineAgg.lineStepName, p.name) AS lineStepName,
                COALESCE(lineAgg.lineDescription, '') AS lineDescription,
                COALESCE(lineAgg.lineEquipmentLabel, '') AS lineEquipmentLabel,
                COALESCE(lineAgg.lineOperatorInfo, '') AS lineOperatorInfo,
                CASE
                    WHEN jop.endTime IS NOT NULL THEN 'completed'
                    WHEN COALESCE(lineAgg.lineCount, 0) = 0 THEN
                        CASE
                            WHEN jop.startTime IS NOT NULL THEN 'in_progress'
                            ELSE 'pending'
                        END
                    WHEN lineAgg.notDoneCount = 0 THEN 'completed'
                    WHEN COALESCE(lineAgg.inProgressCount, 0) > 0 OR jop.startTime IS NOT NULL THEN 'in_progress'
                    ELSE 'pending'
                END AS boardStatus,
                COALESCE(it.code, '') AS itemCode,
                COALESCE(it.name, '') AS itemName,
                COALESCE(jt.name, '') AS jobTypeName,
                jo.reqQty AS reqQty,
                COALESCE(b.outputQtyUom, '') AS outputQtyUom,
                (
                    SELECT DATE_FORMAT(ppd.date, '%Y-%m-%d')
                    FROM productprocess ppd
                    WHERE ppd.jobOrderId = jo.id AND ppd.deleted = 0
                    ORDER BY ppd.id ASC
                    LIMIT 1
                ) AS productionDate,
                (
                    SELECT COALESCE(SUM(COALESCE(pplp.processingTime, 0)), 0)
                    FROM productprocessline pplp
                    INNER JOIN productprocess ppp ON ppp.id = pplp.productprocessid AND ppp.deleted = 0
                    WHERE ppp.jobOrderId = jo.id AND pplp.deleted = 0
                ) AS planProcessingMinsTotal,
                (
                    SELECT COALESCE(SUM(
                        COALESCE(ppls.setupTime, 0) + COALESCE(ppls.changeoverTime, 0)
                    ), 0)
                    FROM productprocessline ppls
                    INNER JOIN productprocess pps ON pps.id = ppls.productprocessid AND pps.deleted = 0
                    WHERE pps.jobOrderId = jo.id AND ppls.deleted = 0
                ) AS planSetupChangeoverMinsTotal,
                (
                    SELECT DATE_FORMAT(MIN(ppst.startTime), '%Y-%m-%d %H:%i:%s')
                    FROM productprocess ppst
                    WHERE ppst.jobOrderId = jo.id AND ppst.deleted = 0 AND ppst.startTime IS NOT NULL
                ) AS productProcessStart,
                (
                    SELECT ROUND(COALESCE(SUM(
                        CASE
                            WHEN pplx.startTime IS NOT NULL AND pplx.endTime IS NOT NULL
                            THEN GREATEST(0, TIMESTAMPDIFF(SECOND, pplx.startTime, pplx.endTime))
                            WHEN pplx.startTime IS NOT NULL
                                AND LOWER(REPLACE(REPLACE(TRIM(COALESCE(pplx.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                            THEN GREATEST(0, COALESCE(pplx.processingTime, 0) * 60)
                            ELSE 0
                        END
                    ), 0) / 60.0, 2)
                    FROM productprocessline pplx
                    INNER JOIN productprocess ppx ON ppx.id = pplx.productprocessid AND ppx.deleted = 0
                    WHERE ppx.jobOrderId = jo.id AND pplx.deleted = 0
                ) AS actualLineMinsTotal,
                COALESCE(lineAgg.stepPlanMins, 0) AS stepPlanMins,
                COALESCE(lineAgg.stepActualMins, 0) AS stepActualMins
            FROM job_order_process jop
            INNER JOIN job_order jo ON jo.id = jop.joId AND jo.deleted = 0
            INNER JOIN process p ON p.id = jop.processId AND p.deleted = 0
            LEFT JOIN bom b ON b.id = jo.bomId AND b.deleted = 0
            LEFT JOIN items it ON it.id = b.itemId AND it.deleted = 0
            LEFT JOIN jobtype jt ON jt.id = jo.jobTypeId AND jt.deleted = 0
            LEFT JOIN (
                SELECT
                    pp.jobOrderId AS joId,
                    bp.processId AS processId,
                    bp.seqNo AS seqNo,
                    COUNT(*) AS lineCount,
                    SUM(
                        CASE
                            WHEN LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                                OR ppl.endTime IS NOT NULL
                            THEN 0 ELSE 1
                        END
                    ) AS notDoneCount,
                    SUM(
                        CASE
                            WHEN (
                                LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                                OR ppl.endTime IS NOT NULL
                            ) THEN 0
                            WHEN (
                                LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('inprogress', 'paused')
                                OR (ppl.startTime IS NOT NULL AND ppl.endTime IS NULL)
                            ) THEN 1
                            ELSE 0
                        END
                    ) AS inProgressCount,
                    MIN(ppl.startTime) AS minLineStart,
                    CASE
                        WHEN SUM(
                            CASE
                                WHEN LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                                    OR ppl.endTime IS NOT NULL
                                THEN 0 ELSE 1
                            END
                        ) = 0 AND COUNT(*) > 0
                        THEN MAX(ppl.endTime)
                        ELSE NULL
                    END AS maxLineEndAllDone,
                    GROUP_CONCAT(
                        NULLIF(TRIM(ppl.name), '')
                        ORDER BY ppl.seqNo ASC, ppl.id ASC
                        SEPARATOR ' | '
                    ) AS lineStepName,
                    GROUP_CONCAT(
                        NULLIF(TRIM(ppl.description), '')
                        ORDER BY ppl.seqNo ASC, ppl.id ASC
                        SEPARATOR ' | '
                    ) AS lineDescription,
                    GROUP_CONCAT(
                        NULLIF(TRIM(COALESCE(
                            NULLIF(TRIM(ed.code), ''),
                            NULLIF(TRIM(ppl.equipment_name), ''),
                            NULLIF(TRIM(e.name), '')
                        )), '')
                        ORDER BY ppl.seqNo ASC, ppl.id ASC
                        SEPARATOR ' | '
                    ) AS lineEquipmentLabel,
                    GROUP_CONCAT(
                        NULLIF(TRIM(COALESCE(NULLIF(TRIM(COALESCE(u.fullname, '')), ''), u.name, u.username, '')), '')
                        ORDER BY ppl.seqNo ASC, ppl.id ASC
                        SEPARATOR ' | '
                    ) AS lineOperatorInfo,
                    COALESCE(SUM(
                        COALESCE(ppl.processingTime, 0) + COALESCE(ppl.setupTime, 0) + COALESCE(ppl.changeoverTime, 0)
                    ), 0) AS stepPlanMins,
                    ROUND(COALESCE(SUM(
                        CASE
                            WHEN ppl.startTime IS NOT NULL AND ppl.endTime IS NOT NULL
                            THEN GREATEST(0, TIMESTAMPDIFF(SECOND, ppl.startTime, ppl.endTime))
                            WHEN ppl.startTime IS NOT NULL
                                AND LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                            THEN GREATEST(0, COALESCE(ppl.processingTime, 0) * 60)
                            ELSE 0
                        END
                    ), 0) / 60.0, 2) AS stepActualMins
                FROM productprocessline ppl
                INNER JOIN productprocess pp ON pp.id = ppl.productprocessid AND pp.deleted = 0
                INNER JOIN bom_process bp ON bp.id = ppl.bomProcessId AND bp.deleted = 0 AND bp.bomId = pp.bomId
                LEFT JOIN equipment e ON e.id = ppl.equipmentId AND e.deleted = 0
                LEFT JOIN equipment_detail ed ON ed.id = ppl.equipmentDetailId AND ed.deleted = 0
                LEFT JOIN user u ON u.id = ppl.operatorId AND u.deleted = 0
                WHERE ppl.deleted = 0
                GROUP BY pp.jobOrderId, bp.processId, bp.seqNo
            ) lineAgg ON lineAgg.joId = jo.id
                AND lineAgg.processId = jop.processId
                AND lineAgg.seqNo = jop.seqNo
            WHERE jop.deleted = 0
            $dateSql
            $incompleteSql
            ORDER BY jo.planStart DESC, jo.code ASC, jop.seqNo ASC, jop.id ASC
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Job order board: one row per JO for visualization (planStart date filter when [targetDate] set).
     * Stock-in metrics: only lines on jobs whose BOM is FG/WIP; splits QC 中 / 已驗待入庫 / 已入庫 by [stock_in_line].status.
     * Material picked: status completed / linked stock out / or JO completed (BOM rows often stay pending if never bulk-updated).
     * Process done: [job_order_process.endTime], or all [productprocessline] rows for the step Pass/Completed, or JO completed fallback.
     */
    fun getJobOrderBoardRows(targetDate: LocalDate?, incompleteOnly: Boolean): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val dateSql = if (targetDate != null) {
            args["boardTargetDate"] = targetDate.toString()
            "AND jo.planStart IS NOT NULL AND DATE(jo.planStart) = :boardTargetDate"
        } else ""
        val incompleteSql = if (incompleteOnly) {
            "AND LOWER(TRIM(COALESCE(jo.status, ''))) <> 'completed'"
        } else ""
        val sql = """
            SELECT
                jo.id AS jobOrderId,
                jo.code AS code,
                COALESCE(jo.status, 'unknown') AS status,
                DATE_FORMAT(jo.planStart, '%Y-%m-%d %H:%i:%s') AS planStart,
                DATE_FORMAT(jo.actualStart, '%Y-%m-%d %H:%i:%s') AS actualStart,
                DATE_FORMAT(jo.planEnd, '%Y-%m-%d %H:%i:%s') AS planEnd,
                DATE_FORMAT(jo.actualEnd, '%Y-%m-%d %H:%i:%s') AS actualEnd,
                GREATEST(0, COALESCE(mat.matTotal, 0) - COALESCE(mat.pickedCnt, 0)) AS materialPendingCount,
                COALESCE(mat.pickedCnt, 0) AS materialPickedCount,
                COALESCE(proc.totalCnt, 0) AS processTotalCount,
                COALESCE(proc.doneCnt, 0) AS processCompletedCount,
                (
                    SELECT p.code
                    FROM job_order_process jop2
                    INNER JOIN process p ON p.id = jop2.processId AND p.deleted = 0
                    WHERE jop2.joId = jo.id AND jop2.deleted = 0 AND jop2.endTime IS NULL
                        AND LOWER(TRIM(COALESCE(jo.status, ''))) <> 'completed'
                    ORDER BY jop2.seqNo ASC, jop2.id ASC
                    LIMIT 1
                ) AS currentProcessCode,
                (
                    SELECT p.name
                    FROM job_order_process jop2
                    INNER JOIN process p ON p.id = jop2.processId AND p.deleted = 0
                    WHERE jop2.joId = jo.id AND jop2.deleted = 0 AND jop2.endTime IS NULL
                        AND LOWER(TRIM(COALESCE(jo.status, ''))) <> 'completed'
                    ORDER BY jop2.seqNo ASC, jop2.id ASC
                    LIMIT 1
                ) AS currentProcessName,
                (
                    SELECT DATE_FORMAT(jop2.startTime, '%Y-%m-%d %H:%i:%s')
                    FROM job_order_process jop2
                    WHERE jop2.joId = jo.id AND jop2.deleted = 0 AND jop2.endTime IS NULL
                        AND LOWER(TRIM(COALESCE(jo.status, ''))) <> 'completed'
                    ORDER BY jop2.seqNo ASC, jop2.id ASC
                    LIMIT 1
                ) AS currentProcessStartTime,
                COALESCE(silAgg.stockInAcceptedQtyTotal, 0) AS stockInAcceptedQtyTotal,
                COALESCE(silAgg.fgReadyToStockInCount, 0) AS fgReadyToStockInCount,
                COALESCE(silAgg.fgReadyToStockInQty, 0) AS fgReadyToStockInQty,
                COALESCE(silAgg.fgInQcLineCount, 0) AS fgInQcLineCount,
                COALESCE(silAgg.fgInQcQty, 0) AS fgInQcQty,
                COALESCE(silAgg.fgStockedQty, 0) AS fgStockedQty,
                COALESCE(it.code, '') AS itemCode,
                COALESCE(it.name, '') AS itemName,
                COALESCE(jt.name, '') AS jobTypeName,
                jo.reqQty AS reqQty,
                COALESCE(b.outputQtyUom, '') AS outputQtyUom,
                (
                    SELECT DATE_FORMAT(ppd.date, '%Y-%m-%d')
                    FROM productprocess ppd
                    WHERE ppd.jobOrderId = jo.id AND ppd.deleted = 0
                    ORDER BY ppd.id ASC
                    LIMIT 1
                ) AS productionDate,
                (
                    SELECT COALESCE(SUM(COALESCE(pplp.processingTime, 0)), 0)
                    FROM productprocessline pplp
                    INNER JOIN productprocess ppp ON ppp.id = pplp.productprocessid AND ppp.deleted = 0
                    WHERE ppp.jobOrderId = jo.id AND pplp.deleted = 0
                ) AS planProcessingMinsTotal,
                (
                    SELECT COALESCE(SUM(
                        COALESCE(ppls.setupTime, 0) + COALESCE(ppls.changeoverTime, 0)
                    ), 0)
                    FROM productprocessline ppls
                    INNER JOIN productprocess pps ON pps.id = ppls.productprocessid AND pps.deleted = 0
                    WHERE pps.jobOrderId = jo.id AND ppls.deleted = 0
                ) AS planSetupChangeoverMinsTotal,
                (
                    SELECT DATE_FORMAT(MIN(ppst.startTime), '%Y-%m-%d %H:%i:%s')
                    FROM productprocess ppst
                    WHERE ppst.jobOrderId = jo.id AND ppst.deleted = 0 AND ppst.startTime IS NOT NULL
                ) AS productProcessStart,
                (
                    SELECT ROUND(COALESCE(SUM(
                        CASE
                            WHEN pplx.startTime IS NOT NULL AND pplx.endTime IS NOT NULL
                            THEN GREATEST(0, TIMESTAMPDIFF(SECOND, pplx.startTime, pplx.endTime))
                            WHEN pplx.startTime IS NOT NULL
                                AND LOWER(REPLACE(REPLACE(TRIM(COALESCE(pplx.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                            THEN GREATEST(0, COALESCE(pplx.processingTime, 0) * 60)
                            ELSE 0
                        END
                    ), 0) / 60.0, 2)
                    FROM productprocessline pplx
                    INNER JOIN productprocess ppx ON ppx.id = pplx.productprocessid AND ppx.deleted = 0
                    WHERE ppx.jobOrderId = jo.id AND pplx.deleted = 0
                ) AS actualLineMinsTotal
            FROM job_order jo
            LEFT JOIN bom b ON b.id = jo.bomId AND b.deleted = 0
            LEFT JOIN items it ON it.id = b.itemId AND it.deleted = 0
            LEFT JOIN jobtype jt ON jt.id = jo.jobTypeId AND jt.deleted = 0
            LEFT JOIN (
                SELECT
                    jobm.jobOrderId AS joId,
                    COUNT(jobm.id) AS matTotal,
                    COALESCE(SUM(CASE
                        WHEN LOWER(TRIM(COALESCE(jobm.status, ''))) IN ('completed', 'complete')
                            OR jobm.stockOutLineId IS NOT NULL
                            OR LOWER(TRIM(COALESCE(joMat.status, ''))) = 'completed'
                        THEN 1 ELSE 0 END), 0) AS pickedCnt
                FROM job_order_bom_material jobm
                INNER JOIN job_order joMat ON joMat.id = jobm.jobOrderId AND joMat.deleted = 0
                WHERE jobm.deleted = 0
                GROUP BY jobm.jobOrderId
            ) mat ON mat.joId = jo.id
            LEFT JOIN (
                SELECT
                    jop.joId AS joId,
                    COUNT(jop.id) AS totalCnt,
                    COALESCE(SUM(CASE
                        WHEN jop.endTime IS NOT NULL THEN 1
                        WHEN COALESCE(lineAgg.lineCount, 0) > 0 AND lineAgg.notDoneCount = 0 THEN 1
                        WHEN LOWER(TRIM(COALESCE(joProc.status, ''))) = 'completed' THEN 1
                        ELSE 0 END), 0) AS doneCnt
                FROM job_order_process jop
                INNER JOIN job_order joProc ON joProc.id = jop.joId AND joProc.deleted = 0
                LEFT JOIN (
                    SELECT
                        pp.jobOrderId AS joId,
                        bp.processId AS processId,
                        bp.seqNo AS seqNo,
                        COUNT(*) AS lineCount,
                        SUM(
                            CASE
                                WHEN LOWER(REPLACE(REPLACE(TRIM(COALESCE(ppl.status, '')), '_', ''), ' ', '')) IN ('completed', 'pass')
                                    OR ppl.endTime IS NOT NULL
                                THEN 0 ELSE 1
                            END
                        ) AS notDoneCount
                    FROM productprocessline ppl
                    INNER JOIN productprocess pp ON pp.id = ppl.productprocessid AND pp.deleted = 0
                    INNER JOIN bom_process bp ON bp.id = ppl.bomProcessId AND bp.deleted = 0 AND bp.bomId = pp.bomId
                    WHERE ppl.deleted = 0
                    GROUP BY pp.jobOrderId, bp.processId, bp.seqNo
                ) lineAgg ON lineAgg.joId = jop.joId
                    AND lineAgg.processId = jop.processId
                    AND lineAgg.seqNo = jop.seqNo
                WHERE jop.deleted = 0
                GROUP BY jop.joId
            ) proc ON proc.joId = jo.id
            LEFT JOIN (
                SELECT
                    sil.jobOrderId AS joId,
                    COALESCE(SUM(COALESCE(sil.acceptedQty, 0)), 0) AS stockInAcceptedQtyTotal,
                    COALESCE(SUM(CASE WHEN LOWER(COALESCE(sil.status, '')) IN ('receiving', 'received') THEN 1 ELSE 0 END), 0) AS fgReadyToStockInCount,
                    COALESCE(SUM(CASE WHEN LOWER(COALESCE(sil.status, '')) IN ('receiving', 'received') THEN COALESCE(sil.acceptedQty, 0) ELSE 0 END), 0) AS fgReadyToStockInQty,
                    COALESCE(SUM(CASE WHEN LOWER(COALESCE(sil.status, '')) IN (
                        'pending', 'qc1', 'qc2', 'qc3', 'qc', 'escalated',
                        'determine1', 'determine2', 'determine3'
                    ) THEN 1 ELSE 0 END), 0) AS fgInQcLineCount,
                    COALESCE(SUM(CASE WHEN LOWER(COALESCE(sil.status, '')) IN (
                        'pending', 'qc1', 'qc2', 'qc3', 'qc', 'escalated',
                        'determine1', 'determine2', 'determine3'
                    ) THEN COALESCE(sil.acceptedQty, 0) ELSE 0 END), 0) AS fgInQcQty,
                    COALESCE(SUM(CASE WHEN LOWER(COALESCE(sil.status, '')) IN ('completed', 'complete', 'partially_completed') THEN COALESCE(sil.acceptedQty, 0) ELSE 0 END), 0) AS fgStockedQty
                FROM stock_in_line sil
                INNER JOIN job_order joSil ON joSil.id = sil.jobOrderId AND joSil.deleted = 0
                INNER JOIN bom b ON b.id = joSil.bomId AND b.deleted = 0
                    AND UPPER(TRIM(COALESCE(b.description, ''))) IN ('FG', 'WIP')
                WHERE sil.deleted = 0 AND sil.jobOrderId IS NOT NULL
                GROUP BY sil.jobOrderId
            ) silAgg ON silAgg.joId = jo.id
            WHERE jo.deleted = 0
            $dateSql
            $incompleteSql
            ORDER BY jo.planStart DESC, jo.code
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    // ---------- Forecast & planning reports ----------

    /**
     * Production schedule by produce date: scheduled item count (已排物料) and total estimated prod count.
     */
    fun getProductionScheduleByDate(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.toString()
            "AND DATE(ps.produceAt) >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endDate"] = endDate.toString()
            "AND DATE(ps.produceAt) <= :endDate"
        } else ""
        val startSql2 = if (startDate != null) "AND DATE(ps2.produceAt) >= :startDate" else ""
        val endSql2 = if (endDate != null) "AND DATE(ps2.produceAt) <= :endDate" else ""
        val sql = """
            SELECT DATE_FORMAT(ps.produceAt, '%Y-%m-%d') AS date,
                COUNT(DISTINCT psl.itemId) AS scheduledItemCount,
                (SELECT COALESCE(SUM(ps2.totalEstProdCount), 0)
                 FROM production_schedule ps2
                 WHERE DATE(ps2.produceAt) = DATE(ps.produceAt) AND ps2.deleted = 0
                 $startSql2 $endSql2) AS totalEstProdCount
            FROM production_schedule ps
            LEFT JOIN production_schedule_line psl ON psl.prodScheduleId = ps.id AND psl.deleted = 0
            WHERE ps.deleted = 0 AND ps.produceAt IS NOT NULL
            $startSql $endSql
            GROUP BY DATE(ps.produceAt)
            ORDER BY date
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Planned daily output (forecast): from item_daily_out + items, for chart (top items by daily qty).
     * Optional limit. Returns itemCode, itemName, dailyQty.
     */
    fun getPlannedDailyOutputByItem(limit: Int): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>("limit" to limit.coerceIn(1, 200))
        val sql = """
            SELECT ido.itemCode AS itemCode, COALESCE(it.name, '') AS itemName, COALESCE(ido.dailyQty, 0) AS dailyQty
            FROM item_daily_out ido
            LEFT JOIN items it ON it.code = ido.itemCode AND it.deleted = 0
            WHERE ido.dailyQty IS NOT NULL AND ido.dailyQty > 0
            ORDER BY ido.dailyQty DESC
            LIMIT :limit
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /**
     * Planned production by date and by item (from production_schedule + production_schedule_line).
     * Returns [{ date, itemCode, itemName, qty }] for chart: all items' planned production per date.
     */
    fun getPlannedOutputByDateAndItem(startDate: LocalDate?, endDate: LocalDate?): List<Map<String, Any>> {
        val args = mutableMapOf<String, Any>()
        val startSql = if (startDate != null) {
            args["startDate"] = startDate.toString()
            "AND DATE(ps.produceAt) >= :startDate"
        } else ""
        val endSql = if (endDate != null) {
            args["endDate"] = endDate.toString()
            "AND DATE(ps.produceAt) <= :endDate"
        } else ""
        val sql = """
            SELECT
                DATE_FORMAT(ps.produceAt, '%Y-%m-%d') AS date,
                it.code AS itemCode,
                COALESCE(it.name, '') AS itemName,
                COALESCE(SUM(psl.prodQty), 0) AS qty
            FROM production_schedule ps
            JOIN production_schedule_line psl ON psl.prodScheduleId = ps.id AND psl.deleted = 0
            JOIN items it ON it.id = psl.itemId AND it.deleted = 0
            WHERE ps.deleted = 0 AND ps.produceAt IS NOT NULL
            $startSql $endSql
            GROUP BY DATE(ps.produceAt), it.code, it.name
            ORDER BY date, itemCode
        """.trimIndent()
        return jdbcDao.queryForList(sql, args)
    }

    /** Half-open [start, end+1 day) on a DATE/DATETIME column (no DATE() wrapper). */
    private fun localDateRangeSql(
        args: MutableMap<String, Any>,
        column: String,
        startDate: LocalDate?,
        endDate: LocalDate?,
        startArg: String = "chartRangeStart",
        endArg: String = "chartRangeEndExclusive",
    ): String = buildString {
        if (startDate != null) {
            args[startArg] = startDate
            append(" AND $column >= :$startArg")
        }
        if (endDate != null) {
            args[endArg] = endDate.plusDays(1)
            append(" AND $column < :$endArg")
        }
    }

    /** Half-open range on stock_ledger.date (DATETIME). */
    private fun ledgerDateTimeRangeSql(
        args: MutableMap<String, Any>,
        column: String,
        startDate: LocalDate?,
        endDate: LocalDate?,
        startArg: String = "ledgerRangeStart",
        endArg: String = "ledgerRangeEndExclusive",
    ): String = buildString {
        if (startDate != null) {
            args[startArg] = startDate.atStartOfDay()
            append(" AND $column >= :$startArg")
        }
        if (endDate != null) {
            args[endArg] = endDate.plusDays(1).atStartOfDay()
            append(" AND $column < :$endArg")
        }
    }

    /** COALESCE datetime expression; args [inOutRangeStart] / [inOutRangeEndExclusive] must already be in map when non-null. */
    private fun stockInOutCoalescedDateRangeSql(
        coalescedExpr: String,
        rangeStart: LocalDateTime?,
        rangeEndExclusive: LocalDateTime?,
    ): String = buildString {
        if (rangeStart != null) {
            append(" AND $coalescedExpr >= :inOutRangeStart")
        }
        if (rangeEndExclusive != null) {
            append(" AND $coalescedExpr < :inOutRangeEndExclusive")
        }
    }
}
