diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index e46c5a4..3f1c503 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -3,6 +3,7 @@ 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( @@ -15,25 +16,18 @@ open class ChartService( */ fun getStockTransactionsByDate(startDate: LocalDate?, endDate: LocalDate?): List> { val args = mutableMapOf() - val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND DATE(sl.date) >= :startDate" - } else "" - val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND DATE(sl.date) <= :endDate" - } else "" + 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 + FROM stock_ledger sl FORCE INDEX (idx_sl_deleted_date) WHERE sl.deleted = 0 AND sl.date IS NOT NULL - $startSql $endSql - GROUP BY sl.date - ORDER BY sl.date + $rangeSql + GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d') + ORDER BY date """.trimIndent() return jdbcDao.queryForList(sql, args) } @@ -45,14 +39,7 @@ open class ChartService( */ fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List> { val args = mutableMapOf() - val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND DATE(do.estimatedArrivalDate) >= :startDate" - } else "" - val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND DATE(do.estimatedArrivalDate) <= :endDate" - } else "" + val rangeSql = localDateRangeSql(args, "do.estimatedArrivalDate", startDate, endDate) val sql = """ SELECT DATE_FORMAT(do.estimatedArrivalDate, '%Y-%m-%d') AS date, @@ -60,8 +47,9 @@ open class ChartService( 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 $startSql $endSql - GROUP BY DATE(do.estimatedArrivalDate) + 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) @@ -536,46 +524,39 @@ open class ChartService( */ fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List> { val args = mutableMapOf() - if (startDate != null) args["startDate"] = startDate.toString() - if (endDate != null) args["endDate"] = endDate.toString() - val inDateFilter = buildString { - if (startDate != null) { - append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) >= :startDate") - } - if (endDate != null) { - append(" AND DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) <= :endDate") - } - } - val outDateFilter = buildString { - if (startDate != null) { - append(" AND DATE(COALESCE(so.completeDate, so.created)) >= :startDate") - } - if (endDate != null) { - append(" AND DATE(COALESCE(so.completeDate, so.created)) <= :endDate") - } - } - val startSql = if (startDate != null) "AND u.dt >= :startDate" else "" - val endSql = if (endDate != null) "AND u.dt <= :endDate" else "" + 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 DATE_FORMAT(u.dt, '%Y-%m-%d') AS date, + SELECT u.dt AS date, COALESCE(SUM(u.inQty), 0) AS inQty, COALESCE(SUM(u.outQty), 0) AS outQty FROM ( - SELECT DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) AS dt, + 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(COALESCE(si.completeDate, sil.receiptDate, si.created)) + GROUP BY DATE_FORMAT(COALESCE(si.completeDate, sil.receiptDate, si.created), '%Y-%m-%d') UNION ALL - SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, + 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(COALESCE(so.completeDate, so.created)) + GROUP BY DATE_FORMAT(COALESCE(so.completeDate, so.created), '%Y-%m-%d') ) u - WHERE 1=1 $startSql $endSql GROUP BY u.dt ORDER BY u.dt """.trimIndent() @@ -589,20 +570,14 @@ open class ChartService( */ fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List> { val args = mutableMapOf() - val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND DATE(do.estimatedArrivalDate) >= :startDate" - } else "" - val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND DATE(do.estimatedArrivalDate) <= :endDate" - } else "" + 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 $startSql $endSql + WHERE do.deleted = 0 AND do.estimatedArrivalDate IS NOT NULL + $rangeSql ORDER BY it.code """.trimIndent() return jdbcDao.queryForList(sql, args) @@ -620,14 +595,7 @@ open class ChartService( itemCodes: List? ): List> { val args = mutableMapOf("limit" to limit) - val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND DATE(do.estimatedArrivalDate) >= :startDate" - } else "" - val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND DATE(do.estimatedArrivalDate) <= :endDate" - } else "" + 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 { @@ -643,7 +611,8 @@ open class ChartService( 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 $startSql $endSql $itemSql + 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 @@ -661,26 +630,26 @@ open class ChartService( itemCode: String? ): List> { val args = mutableMapOf() - val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND sl.date >= :startDate" - } else "" - val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND sl.date <= :endDate" - } else "" - val itemSql = if (!itemCode.isNullOrBlank()) { + val rangeSql = ledgerDateTimeRangeSql(args, "sl.date", startDate, endDate) + val hasItemFilter = !itemCode.isNullOrBlank() + if (hasItemFilter) { args["itemCode"] = "%$itemCode%" - "AND sl.itemCode LIKE :itemCode" - } else "" + } + 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 - FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.date IS NOT NULL $startSql $endSql $itemSql - GROUP BY sl.date - ORDER BY sl.date + $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) } @@ -697,27 +666,35 @@ open class ChartService( ): List> { val args = mutableMapOf() val yearSql = if (year != null) { - args["year"] = year - "AND YEAR(sl.date) = :year" - } else "" - val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND sl.date >= :startDate" - } else "" - val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND sl.date <= :endDate" + 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 itemSql = if (!itemCode.isNullOrBlank()) { + val rangeSql = ledgerDateTimeRangeSql( + args, + "sl.date", + startDate, + endDate, + startArg = "consumptionRangeStart", + endArg = "consumptionRangeEndExclusive", + ) + val hasItemFilter = !itemCode.isNullOrBlank() + if (hasItemFilter) { args["itemCode"] = "%$itemCode%" - "AND sl.itemCode LIKE :itemCode" - } else "" + } + 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 - FROM stock_ledger sl - WHERE sl.deleted = 0 AND sl.date IS NOT NULL $yearSql $startSql $endSql $itemSql + $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() @@ -746,6 +723,8 @@ open class ChartService( * 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?, @@ -756,12 +735,12 @@ open class ChartService( ): List> { val args = mutableMapOf() val startSql = if (startDate != null) { - args["startDate"] = startDate.toString() - "AND DATE(dop.ticketCompleteDateTime) >= :startDate" + args["startDate"] = startDate.atStartOfDay() + "AND dop.ticketCompleteDateTime >= :startDate" } else "" val endSql = if (endDate != null) { - args["endDate"] = endDate.toString() - "AND DATE(dop.ticketCompleteDateTime) <= :endDate" + 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() } @@ -778,6 +757,12 @@ open class ChartService( } 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, @@ -790,13 +775,14 @@ open class ChartService( ELSE 0 END ), 0) AS totalMinutes - FROM delivery_order_pick_order dop + $fromClause LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0 WHERE dop.deleted = 0 - AND LOWER(COALESCE(dop.ticketStatus, '')) = 'completed' + AND dop.ticketStatus = 'completed' AND dop.ticketCompleteDateTime IS NOT NULL $startSql $endSql $staffSql $storeSql - GROUP BY DATE(dop.ticketCompleteDateTime), dop.handledBy, u.name, dop.handlerName + 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) @@ -1604,4 +1590,56 @@ open class ChartService( """.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, + 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, + 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") + } + } } diff --git a/src/main/resources/db/changelog/changes/20260516_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260516_Enson/02_setting.sql new file mode 100644 index 0000000..25bad22 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260516_Enson/02_setting.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +-- 修改 stock_ledger 表的 date 欄位為 datetime +--changeset Enson:20260516-01 +ALTER TABLE `stock_ledger` + MODIFY COLUMN `date` DATE; \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260517_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260517_Enson/02_setting.sql new file mode 100644 index 0000000..6ad7032 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260517_Enson/02_setting.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +-- 修改 stock_ledger 表的 date 欄位為 datetime +--changeset Enson:20260517-02 +CREATE INDEX idx_dopo_staff_perf_complete +ON fpsmsdb.delivery_order_pick_order (deleted, ticketStatus, ticketCompleteDateTime); +CREATE INDEX idx_dopo_staff_perf_store_complete +ON fpsmsdb.delivery_order_pick_order (deleted, ticketStatus, storeId, ticketCompleteDateTime); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260517_Enson/03_setting.sql b/src/main/resources/db/changelog/changes/20260517_Enson/03_setting.sql new file mode 100644 index 0000000..43e5c82 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260517_Enson/03_setting.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +-- 修改 stock_ledger 表的 date 欄位為 datetime +--changeset Enson:20260519-03 +CREATE INDEX idx_sl_deleted_date +ON stock_ledger (deleted, date); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260519_01_Enson/01_chart_perf_indexes.sql b/src/main/resources/db/changelog/changes/20260519_01_Enson/01_chart_perf_indexes.sql new file mode 100644 index 0000000..d0fa203 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260519_01_Enson/01_chart_perf_indexes.sql @@ -0,0 +1,12 @@ +--liquibase formatted sql + +--changeset Enson:20260519-01-chart-do-eta-idx +CREATE INDEX idx_do_deleted_eta +ON delivery_order (deleted, estimatedArrivalDate); + +--changeset Enson:20260519-02-chart-stock-in-out-idx +CREATE INDEX idx_si_deleted_complete +ON stock_in (deleted, completeDate); + +CREATE INDEX idx_so_deleted_complete +ON stock_out (deleted, completeDate); diff --git a/src/main/resources/db/changelog/changes/20260519_01_Enson/02_chart_perf_indexes.sql b/src/main/resources/db/changelog/changes/20260519_01_Enson/02_chart_perf_indexes.sql new file mode 100644 index 0000000..c4521f9 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260519_01_Enson/02_chart_perf_indexes.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql + +--changeset Enson:20260519-04-chart-stock-ledger-idx +CREATE INDEX idx_sl_deleted_itemcode_created +ON stock_ledger (deleted, itemCode, created);