From bff273ea9ad019e9e93f40a99d9434dd1c6cec28 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 15 Mar 2026 01:25:54 +0800 Subject: [PATCH] adding some charts to test --- .../modules/chart/service/ChartService.kt | 610 ++++++++++++++++++ .../modules/chart/web/ChartController.kt | 187 ++++++ .../service/PlasticBagPrinterService.kt | 3 +- 3 files changed, 799 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt 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 new file mode 100644 index 0000000..2f736f5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -0,0 +1,610 @@ +package com.ffii.fpsms.modules.chart.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service +import java.time.LocalDate + +@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> { + 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 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 + WHERE sl.deleted = 0 AND sl.date IS NOT NULL + $startSql $endSql + GROUP BY sl.date + ORDER BY sl.date + """.trimIndent() + return jdbcDao.queryForList(sql, args) + } + + /** + * Delivery orders: order count and total line qty by date. + * Uses delivery_order.completeDate or estimatedArrivalDate for date. + */ + fun getDeliveryOrderByDate(startDate: LocalDate?, endDate: LocalDate?): List> { + val args = mutableMapOf() + val startSql = if (startDate != null) { + args["startDate"] = startDate.toString() + "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" + } else "" + val endSql = if (endDate != null) { + args["endDate"] = endDate.toString() + "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" + } else "" + val sql = """ + SELECT + DATE_FORMAT(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate), '%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 $startSql $endSql + GROUP BY DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) + ORDER BY date + """.trimIndent() + return jdbcDao.queryForList(sql, args) + } + + /** + * Purchase orders: count by status (pending, receiving, completed). + * targetDate: when set, only POs whose orderDate is on that date; when null, all POs. + */ + fun getPurchaseOrderByStatus(targetDate: LocalDate?): List> { + val args = mutableMapOf() + val dateSql = if (targetDate != null) { + args["targetDate"] = targetDate.toString() + "AND DATE(po.orderDate) = :targetDate" + } else "" + val sql = """ + SELECT + COALESCE(po.status, 'unknown') AS status, + COUNT(po.id) AS count + FROM purchase_order po + WHERE po.deleted = 0 + $dateSql + GROUP BY po.status + ORDER BY count DESC + """.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. + */ + fun getStockInOutByDate(startDate: LocalDate?, endDate: LocalDate?): List> { + val args = mutableMapOf() + val startSql = if (startDate != null) { + args["startDate"] = startDate.toString() + "AND u.dt >= :startDate" + } else "" + val endSql = if (endDate != null) { + args["endDate"] = endDate.toString() + "AND u.dt <= :endDate" + } else "" + val sql = """ + SELECT DATE_FORMAT(u.dt, '%Y-%m-%d') 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, + SUM(COALESCE(sil.acceptedQty, 0)) AS inQty, 0 AS outQty + FROM stock_in_line sil + INNER JOIN stock_in si ON sil.stockInId = si.id AND si.deleted = 0 + WHERE sil.deleted = 0 + GROUP BY DATE(COALESCE(si.completeDate, sil.receiptDate, si.created)) + UNION ALL + SELECT DATE(COALESCE(so.completeDate, so.created)) AS dt, + 0 AS inQty, SUM(COALESCE(sol.qty, 0)) AS outQty + FROM stock_out_line sol + INNER JOIN stock_out so ON sol.stockOutId = so.id AND so.deleted = 0 + WHERE sol.deleted = 0 + GROUP BY DATE(COALESCE(so.completeDate, so.created)) + ) u + WHERE 1=1 $startSql $endSql + 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). + */ + fun getTopDeliveryItemsItemOptions(startDate: LocalDate?, endDate: LocalDate?): List> { + val args = mutableMapOf() + val startSql = if (startDate != null) { + args["startDate"] = startDate.toString() + "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" + } else "" + val endSql = if (endDate != null) { + args["endDate"] = endDate.toString() + "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" + } else "" + val sql = """ + SELECT DISTINCT it.code AS itemCode, COALESCE(it.name, '') AS itemName + FROM delivery_order_line dol + INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 + INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 + WHERE dol.deleted = 0 $startSql $endSql + 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). + */ + fun getTopDeliveryItems( + startDate: LocalDate?, + endDate: LocalDate?, + limit: Int, + itemCodes: List? + ): List> { + val args = mutableMapOf("limit" to limit) + val startSql = if (startDate != null) { + args["startDate"] = startDate.toString() + "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) >= :startDate" + } else "" + val endSql = if (endDate != null) { + args["endDate"] = endDate.toString() + "AND DATE(COALESCE(do.completeDate, do.estimatedArrivalDate, do.orderDate)) <= :endDate" + } else "" + 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_line dol + INNER JOIN delivery_order do ON dol.deliveryOrderId = do.id AND do.deleted = 0 + INNER JOIN items it ON dol.itemId = it.id AND it.deleted = 0 + WHERE dol.deleted = 0 $startSql $endSql $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> { + 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()) { + args["itemCode"] = "%$itemCode%" + "AND sl.itemCode LIKE :itemCode" + } else "" + 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 + """.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> { + 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" + } else "" + val itemSql = if (!itemCode.isNullOrBlank()) { + args["itemCode"] = "%$itemCode%" + "AND sl.itemCode LIKE :itemCode" + } else "" + 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 + 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> { + 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()) + } + + /** + * Staff delivery performance: daily pick ticket count and total time per staff. + * Uses do_pick_order_record (handler = handledBy); time = sum of (ticketCompleteDateTime - ticketReleaseTime) per record. + * Optionally use do_pick_order_line_record for line count; here orderCount = number of completed pick tickets. + * staffNos: when non-empty, filter to these staff by user.staffNo (multi-select). + */ + fun getStaffDeliveryPerformance( + startDate: LocalDate?, + endDate: LocalDate?, + staffNos: List? + ): List> { + val args = mutableMapOf() + val startSql = if (startDate != null) { + args["startDate"] = startDate.toString() + "AND DATE(dpor.ticketCompleteDateTime) >= :startDate" + } else "" + val endSql = if (endDate != null) { + args["endDate"] = endDate.toString() + "AND DATE(dpor.ticketCompleteDateTime) <= :endDate" + } 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 sql = """ + SELECT + DATE_FORMAT(dpor.ticketCompleteDateTime, '%Y-%m-%d') AS date, + COALESCE(u.name, dpor.handler_name, 'Unknown') AS staffName, + COUNT(dpor.id) AS orderCount, + COALESCE(SUM( + CASE + WHEN dpor.ticketReleaseTime IS NOT NULL AND dpor.ticketCompleteDateTime IS NOT NULL + THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dpor.ticket_release_time, dpor.ticketCompleteDateTime)) + ELSE 0 + END + ), 0) AS totalMinutes + FROM do_pick_order_record dpor + LEFT JOIN user u ON dpor.handled_by = u.id AND u.deleted = 0 + WHERE dpor.deleted = 0 + AND dpor.ticket_status = 'completed' + AND dpor.ticketCompleteDateTime IS NOT NULL + $startSql $endSql $staffSql + GROUP BY DATE(dpor.ticketCompleteDateTime), dpor.handled_by, u.name, dpor.handler_name + 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> { + val args = mutableMapOf() + 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> { + val args = mutableMapOf() + 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> { + val args = mutableMapOf() + 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() + 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> { + val args = mutableMapOf() + 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> { + val args = mutableMapOf() + 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> { + val args = mutableMapOf() + 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) + } + + // ---------- Forecast & planning reports ---------- + + /** + * Production schedule by produce date: scheduled item count (已排物料) and total estimated prod count. + */ + fun getProductionScheduleByDate(startDate: LocalDate?, endDate: LocalDate?): List> { + val args = mutableMapOf() + 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> { + val args = mutableMapOf("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> { + val args = mutableMapOf() + 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) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt new file mode 100644 index 0000000..0a7c4d5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -0,0 +1,187 @@ +package com.ffii.fpsms.modules.chart.web + +import com.ffii.fpsms.modules.chart.service.ChartService +import org.springframework.format.annotation.DateTimeFormat +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate + +@RestController +@RequestMapping("/chart") +class ChartController( + private val chartService: ChartService, +) { + + /** + * GET /chart/stock-transactions-by-date?startDate=2025-01-01&endDate=2025-01-31 + * Returns [{ date, inQty, outQty, totalQty }] + */ + @GetMapping("/stock-transactions-by-date") + fun getStockTransactionsByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getStockTransactionsByDate(startDate, endDate) + + /** + * GET /chart/delivery-order-by-date?startDate=&endDate= + * Returns [{ date, orderCount, totalQty }] + */ + @GetMapping("/delivery-order-by-date") + fun getDeliveryOrderByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getDeliveryOrderByDate(startDate, endDate) + + /** + * GET /chart/purchase-order-by-status?targetDate=2025-03-15 + * Returns [{ status, count }]. targetDate defaults to today on frontend; when set, only POs with orderDate on that date. + */ + @GetMapping("/purchase-order-by-status") + fun getPurchaseOrderByStatus( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + ): List> = + chartService.getPurchaseOrderByStatus(targetDate) + + /** + * GET /chart/stock-in-out-by-date?startDate=&endDate= + * Returns [{ date, inQty, outQty }] + */ + @GetMapping("/stock-in-out-by-date") + fun getStockInOutByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getStockInOutByDate(startDate, endDate) + + /** + * GET /chart/top-delivery-items-item-options?startDate=&endDate= + * Returns [{ itemCode, itemName }] — distinct items in delivery lines in the period (for multi-select). + */ + @GetMapping("/top-delivery-items-item-options") + fun getTopDeliveryItemsItemOptions( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getTopDeliveryItemsItemOptions(startDate, endDate) + + /** + * GET /chart/top-delivery-items?startDate=&endDate=&limit=20&itemCode=A&itemCode=B + * Returns [{ itemCode, itemName, totalQty }]. When itemCode present, only those items (still by totalQty, limit). + */ + @GetMapping("/top-delivery-items") + fun getTopDeliveryItems( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + @RequestParam(required = false, defaultValue = "10") limit: Int, + @RequestParam(required = false) itemCode: List?, + ): List> = + chartService.getTopDeliveryItems(startDate, endDate, limit.coerceIn(1, 200), itemCode) + + /** + * GET /chart/stock-balance-trend?startDate=&endDate=&itemCode= + * Returns [{ date, balance }] + */ + @GetMapping("/stock-balance-trend") + fun getStockBalanceTrend( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + @RequestParam(required = false) itemCode: String?, + ): List> = chartService.getStockBalanceTrend(startDate, endDate, itemCode) + + /** + * GET /chart/consumption-trend-by-month?year=&startDate=&endDate=&itemCode= + * Returns [{ month, outQty }] + */ + @GetMapping("/consumption-trend-by-month") + fun getConsumptionTrendByMonth( + @RequestParam(required = false) year: Int?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + @RequestParam(required = false) itemCode: String?, + ): List> = + chartService.getConsumptionTrendByMonth(year, startDate, endDate, itemCode) + + /** + * GET /chart/staff-delivery-performance-handlers — staff from user table (staffNo, name), only those with staffNo. + */ + @GetMapping("/staff-delivery-performance-handlers") + fun getStaffDeliveryPerformanceHandlers(): List> = + chartService.getStaffDeliveryPerformanceHandlers() + + /** + * GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002 + * Returns [{ date, staffName, orderCount, totalMinutes }]. Data from do_pick_order_record (handled_by), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime). + */ + @GetMapping("/staff-delivery-performance") + fun getStaffDeliveryPerformance( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + @RequestParam(required = false) staffNo: List?, + ): List> = + chartService.getStaffDeliveryPerformance(startDate, endDate, staffNo) + + // ---------- Job order reports ---------- + + /** GET /chart/job-order-by-status?targetDate= — count by status, optional planStart date filter. */ + @GetMapping("/job-order-by-status") + fun getJobOrderByStatus( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + ): List> = chartService.getJobOrderByStatus(targetDate) + + /** GET /chart/job-order-count-by-date?startDate=&endDate= — count by planStart date. */ + @GetMapping("/job-order-count-by-date") + fun getJobOrderCountByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getJobOrderCountByDate(startDate, endDate) + + /** GET /chart/job-order-created-completed-by-date?startDate=&endDate= — created vs completed by date. */ + @GetMapping("/job-order-created-completed-by-date") + fun getJobOrderCreatedCompletedByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getJobOrderCreatedCompletedByDate(startDate, endDate) + + /** GET /chart/job-material-pending-picked-by-date?startDate=&endDate= — material 待領/已揀 by date. */ + @GetMapping("/job-material-pending-picked-by-date") + fun getJobMaterialPendingPickedByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getJobMaterialPendingPickedByDate(startDate, endDate) + + /** GET /chart/job-process-pending-completed-by-date?startDate=&endDate= — process 待完成/已完成 by date. */ + @GetMapping("/job-process-pending-completed-by-date") + fun getJobProcessPendingCompletedByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getJobProcessPendingCompletedByDate(startDate, endDate) + + /** GET /chart/job-equipment-working-worked-by-date?startDate=&endDate= — equipment 使用中/已使用 by date. */ + @GetMapping("/job-equipment-working-worked-by-date") + fun getJobEquipmentWorkingWorkedByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getJobEquipmentWorkingWorkedByDate(startDate, endDate) + + // ---------- Forecast & planning ---------- + + /** GET /chart/production-schedule-by-date?startDate=&endDate= — schedule count & total est prod by produce date. */ + @GetMapping("/production-schedule-by-date") + fun getProductionScheduleByDate( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getProductionScheduleByDate(startDate, endDate) + + /** GET /chart/planned-daily-output-by-item?limit=20 — top items by planned daily qty (item_daily_out). */ + @GetMapping("/planned-daily-output-by-item") + fun getPlannedDailyOutputByItem( + @RequestParam(required = false, defaultValue = "20") limit: Int, + ): List> = chartService.getPlannedDailyOutputByItem(limit.coerceIn(1, 200)) + + /** GET /chart/planned-output-by-date-and-item?startDate=&endDate= — all items' planned production by date (production_schedule). */ + @GetMapping("/planned-output-by-date-and-item") + fun getPlannedOutputByDateAndItem( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, + ): List> = chartService.getPlannedOutputByDateAndItem(startDate, endDate) +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index f0de9b9..b765b18 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -194,7 +194,8 @@ open class PlasticBagPrinterService( val stockInLineId = stockInLine.id ?: return@forEach val qrContent = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" - val bmp = createQrCodeBitmap(qrContent, 600) + // Trim 90% of top/bottom/side whitespace: keep 4px padding per side (was 40) → totalSize = contentSize + 8 + val bmp = createQrCodeBitmap(qrContent, 600, 600 + 8) val zipEntryName = buildUniqueZipEntryName(filename, addedEntries) if (!addedEntries.add(zipEntryName)) return@forEach addToZip(zos, zipEntryName, bmp.bytes)