From 4c0a56ee40bfb90169a3676d80978c840095eb15 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 29 Mar 2026 01:44:13 +0800 Subject: [PATCH] added jo process and job order board as chart --- .../modules/chart/service/ChartService.kt | 529 ++++++++++++++++++ .../modules/chart/web/ChartController.kt | 31 + 2 files changed, 560 insertions(+) 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 e03f200..73b3a2e 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 @@ -962,6 +962,535 @@ open class ChartService( 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> { + val args = mutableMapOf() + 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> { + val args = mutableMapOf() + 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> { + val args = mutableMapOf() + 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 ---------- /** 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 index afdea12..c83ef36 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt @@ -246,6 +246,37 @@ class ChartController( @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, ): List> = chartService.getJobEquipmentWorkingWorkedByDate(startDate, endDate) + /** + * GET /chart/equipment-usage-board?targetDate= + * — equipment usage for the day: [job_order_process_detail] (legacy) **union** [productprocessline] (工藝流程), + * matching `/jo/edit` line equipment. Omit targetDate = server today. + */ + @GetMapping("/equipment-usage-board") + fun getEquipmentUsageBoard( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + ): List> = chartService.getEquipmentUsageBoardRows(targetDate) + + /** + * GET /chart/job-order-board?targetDate=&incompleteOnly= + * — per-job rows for status board (optional planStart date; incompleteOnly excludes status completed). + */ + @GetMapping("/job-order-board") + fun getJobOrderBoard( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + @RequestParam(required = false, defaultValue = "false") incompleteOnly: Boolean, + ): List> = chartService.getJobOrderBoardRows(targetDate, incompleteOnly) + + /** + * GET /chart/process-board?targetDate=&incompleteOnly= + * — per job_order_process row: job, master process, times, derived boardStatus (pending / in_progress / completed) + * from product process lines when job_order_process times are unset. + */ + @GetMapping("/process-board") + fun getProcessBoard( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) targetDate: LocalDate?, + @RequestParam(required = false, defaultValue = "false") incompleteOnly: Boolean, + ): List> = chartService.getProcessBoardRows(targetDate, incompleteOnly) + // ---------- Forecast & planning ---------- /** GET /chart/production-schedule-by-date?startDate=&endDate= — schedule count & total est prod by produce date. */