diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index a234894..dc16830 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -91,6 +91,16 @@ public class SecurityConfig { .hasAnyAuthority("TESTING", "ADMIN", "STOCK") .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") .hasAuthority("TESTING") + .requestMatchers(HttpMethod.GET, "/device-presence/active") + .hasAnyAuthority("TESTING", "ADMIN") + .requestMatchers(HttpMethod.GET, "/device-presence/history") + .hasAnyAuthority("TESTING", "ADMIN") + .requestMatchers(HttpMethod.GET, "/printer-monitor/status") + .hasAnyAuthority("TESTING", "ADMIN") + .requestMatchers(HttpMethod.GET, "/printer-monitor/history") + .hasAnyAuthority("TESTING", "ADMIN") + .requestMatchers(HttpMethod.POST, "/printer-monitor/check") + .hasAnyAuthority("TESTING", "ADMIN") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt index 337785f..120ab5a 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt @@ -7,6 +7,13 @@ import org.springframework.stereotype.Service import java.time.LocalDate import com.ffii.core.support.JdbcDao +import org.apache.poi.ss.usermodel.FillPatternType +import org.apache.poi.ss.usermodel.IndexedColors +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.apache.poi.xssf.usermodel.XSSFWorkbook +import java.io.ByteArrayOutputStream +import java.math.BigDecimal +import java.math.RoundingMode @Service open class PSService( @@ -160,6 +167,49 @@ open class PSService( } /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ + /** + * Recalculate [inventory.onHandQty] / hold / unavailable from [inventory_lot_line] for FG BOM items. + * Same aggregation as pick-issue manual inventory sync. + */ + fun refreshInventoryOnHandForFgBomItems(): Int { + val sql = """ + UPDATE inventory i + INNER JOIN ( + SELECT DISTINCT b.itemId + FROM bom b + WHERE b.deleted = 0 AND b.description = 'FG' + ) bom_items ON bom_items.itemId = i.itemId + LEFT JOIN ( + SELECT + il.itemId, + SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty, + SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty, + SUM(CASE + WHEN ill.status = 'unavailable' + THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) + ELSE 0 + END) AS totalUnavailableQty + FROM inventory_lot_line ill + INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 + WHERE ill.deleted = 0 + GROUP BY il.itemId + ) calc ON calc.itemId = i.itemId + SET + i.onHandQty = COALESCE(calc.totalOnHandQty, 0), + i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0), + i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0), + i.status = IF( + COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalOnHoldQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0, + 'available', + 'unavailable' + ), + i.modified = NOW(), + i.modifiedBy = 'ps-refresh-onhand' + WHERE i.deleted = 0 + """.trimIndent() + return jdbcDao.executeUpdate(sql, emptyMap()) + } + fun setCoffeeOrTea(itemCode: String, systemType: String, enabled: Boolean) { val args = mapOf("itemCode" to itemCode, "systemType" to systemType) jdbcDao.executeUpdate( @@ -214,4 +264,150 @@ open class PSService( return jdbcDao.queryForList(sql, args) } + /** + * Items with at least one BOM (deleted = 0), plus stock-unit label. + */ + fun listBomItemsWithStockUnit(): List> { + val sql = """ + SELECT DISTINCT + items.code AS itemCode, + items.name AS itemName, + uc_stock.udfudesc AS stockUnit + FROM bom + INNER JOIN items ON bom.itemId = items.id AND items.deleted = 0 + LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 + LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu_stock.uomId + WHERE bom.deleted = 0 + ORDER BY items.code + """.trimIndent() + return jdbcDao.queryForList(sql, emptyMap()) + } + + /** + * Sum of [delivery_order_line.qty] (already stored in stock unit) by item and ETA date. + * Only items that have a BOM. + */ + fun sumDeliveryOrderQtyByItemAndDate(fromDate: LocalDate, toDate: LocalDate): List> { + val args = mapOf( + "fromDate" to fromDate.toString(), + "toDate" to toDate.toString(), + ) + val sql = """ + SELECT + items.code AS itemCode, + DATE(do.estimatedArrivalDate) AS shipDate, + SUM(COALESCE(dol.qty, 0)) AS qtySum + FROM delivery_order do + INNER JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 + INNER JOIN items ON items.id = dol.itemId AND items.deleted = 0 + WHERE do.deleted = 0 + AND do.estimatedArrivalDate IS NOT NULL + AND DATE(do.estimatedArrivalDate) >= :fromDate + AND DATE(do.estimatedArrivalDate) <= :toDate + AND EXISTS ( + SELECT 1 FROM bom b + WHERE b.itemId = items.id AND b.deleted = 0 + ) + GROUP BY items.code, DATE(do.estimatedArrivalDate) + ORDER BY items.code, shipDate + """.trimIndent() + return jdbcDao.queryForList(sql, args) + } + + fun exportDeliveryOrderQtyByDateExcel(fromDate: LocalDate, toDate: LocalDate): ByteArray { + require(!fromDate.isAfter(toDate)) { "fromDate must be on or before toDate" } + val dayCount = java.time.temporal.ChronoUnit.DAYS.between(fromDate, toDate) + 1 + require(dayCount in 1..366) { "Date range must be between 1 and 366 days" } + + val dates = generateSequence(fromDate) { it.plusDays(1) }.takeWhile { !it.isAfter(toDate) }.toList() + val items = listBomItemsWithStockUnit() + val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate) + + val qtyByItemDate = mutableMapOf>() + qtyRows.forEach { row -> + val itemCode = row["itemCode"]?.toString() ?: return@forEach + val shipDate = parseSqlDate(row["shipDate"]) ?: return@forEach + val qty = toBigDecimal(row["qtySum"]) + qtyByItemDate.getOrPut(itemCode) { mutableMapOf() }[shipDate] = qty + } + + val workbook = XSSFWorkbook() + val sheet = workbook.createSheet("DO Qty by Date") + + val headerStyle = workbook.createCellStyle().apply { + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + verticalAlignment = VerticalAlignment.CENTER + val font = workbook.createFont() + font.bold = true + setFont(font) + } + val textStyle = workbook.createCellStyle().apply { + verticalAlignment = VerticalAlignment.CENTER + } + val numberStyle = workbook.createCellStyle().apply { + verticalAlignment = VerticalAlignment.CENTER + dataFormat = workbook.createDataFormat().getFormat("#,##0") + } + + val headerRow = sheet.createRow(0) + val headers = mutableListOf("Item Code", "Item Name", "UOM") + headers.addAll(dates.map { it.toString() }) + headers.forEachIndexed { col, title -> + headerRow.createCell(col).apply { + setCellValue(title) + cellStyle = headerStyle + } + } + + items.forEachIndexed { index, item -> + val row = sheet.createRow(index + 1) + val itemCode = item["itemCode"]?.toString() ?: "" + val itemName = item["itemName"]?.toString() ?: "" + val stockUnit = item["stockUnit"]?.toString() ?: "" + + row.createCell(0).apply { setCellValue(itemCode); cellStyle = textStyle } + row.createCell(1).apply { setCellValue(itemName); cellStyle = textStyle } + row.createCell(2).apply { setCellValue(stockUnit); cellStyle = textStyle } + + val dateQtyMap = qtyByItemDate[itemCode] + dates.forEachIndexed { dateIdx, date -> + val qty = (dateQtyMap?.get(date) ?: BigDecimal.ZERO) + .setScale(0, RoundingMode.HALF_UP) + row.createCell(3 + dateIdx).apply { + setCellValue(qty.toLong().toDouble()) + cellStyle = numberStyle + } + } + } + + for (col in 0 until headers.size) { + sheet.autoSizeColumn(col) + } + + ByteArrayOutputStream().use { out -> + workbook.write(out) + workbook.close() + return out.toByteArray() + } + } + + private fun parseSqlDate(value: Any?): LocalDate? = when (value) { + null -> null + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.time.LocalDateTime -> value.toLocalDate() + else -> { + val text = value.toString().trim() + if (text.length >= 10) LocalDate.parse(text.substring(0, 10)) else null + } + } + + private fun toBigDecimal(value: Any?): BigDecimal = when (value) { + null -> BigDecimal.ZERO + is BigDecimal -> value + is Number -> BigDecimal.valueOf(value.toDouble()) + else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO + } + } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt index b7a648c..fd05658 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt @@ -4,10 +4,11 @@ import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService import com.ffii.fpsms.modules.jobOrder.service.PSService import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest -import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType import org.springframework.web.bind.annotation.* import java.time.LocalDate +import java.time.format.DateTimeParseException import org.springframework.http.ResponseEntity @RestController @@ -79,6 +80,13 @@ class PSController( return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "onHandQty" to (onHandQty ?: "deleted"))) } + /** Recalculate inventory on-hand from lot lines for FG BOM items (排期設定 刷新庫存). */ + @PostMapping("/refresh-inventory-onhand") + fun refreshInventoryOnHand(): ResponseEntity> { + val updated = psService.refreshInventoryOnHandForFgBomItems() + return ResponseEntity.ok(mapOf("ok" to true, "updated" to updated)) + } + /** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */ @PostMapping("/setCoffeeOrTea") fun setCoffeeOrTea(@RequestBody body: Map): ResponseEntity> { @@ -91,4 +99,37 @@ class PSController( psService.setCoffeeOrTea(itemCode, systemType, enabled) return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "systemType" to systemType, "enabled" to enabled)) } + + /** + * Export delivery-order qty sums (stock unit) for BOM items, pivoted by ETA date. + */ + @GetMapping( + value = ["/export-do-qty-by-date"], + produces = ["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"], + ) + fun exportDoQtyByDate( + @RequestParam fromDate: String, + @RequestParam toDate: String, + ): ResponseEntity { + val from = try { + LocalDate.parse(fromDate) + } catch (_: DateTimeParseException) { + return ResponseEntity.badRequest().body(mapOf("error" to "Invalid fromDate")) + } + val to = try { + LocalDate.parse(toDate) + } catch (_: DateTimeParseException) { + return ResponseEntity.badRequest().body(mapOf("error" to "Invalid toDate")) + } + return try { + val bytes = psService.exportDeliveryOrderQtyByDateExcel(from, to) + val filename = "do_qty_${from}_to_${to}.xlsx" + ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=$filename") + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .body(bytes) + } catch (e: IllegalArgumentException) { + ResponseEntity.badRequest().body(mapOf("error" to (e.message ?: "Invalid date range"))) + } + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt index 32ca2ad..2817f27 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt @@ -1464,6 +1464,15 @@ open class ProductionScheduleService( dataFormat = workbook.createDataFormat().getFormat("#,##0.0") } + val daysLeftLowStyle = workbook.createCellStyle().apply { + cloneStyleFrom(numberDigitStyle) + fillForegroundColor = IndexedColors.RED.index + fillPattern = FillPatternType.SOLID_FOREGROUND + val font = workbook.createFont() + font.color = IndexedColors.WHITE.index + font.bold = true + setFont(font) + } // ── Group production lines by date ── val groupedData = lines.groupBy { @@ -1505,7 +1514,11 @@ open class ProductionScheduleService( row.createCell(j++).apply { setCellValue(line["itemName"]?.toString() ?: ""); cellStyle = wrapStyle } row.createCell(j++).apply { setCellValue(asDouble(line["avgQtyLastMonth"])); cellStyle = numberStyle } row.createCell(j++).apply { setCellValue(asDouble(line["stockQty"])); cellStyle = numberStyle } - row.createCell(j++).apply { setCellValue(asDouble(line["daysLeft"])); cellStyle = numberDigitStyle } + val daysLeftVal = asDouble(line["daysLeft"]) + row.createCell(j++).apply { + setCellValue(daysLeftVal) + cellStyle = if (daysLeftVal < 1.0) daysLeftLowStyle else numberDigitStyle + } row.createCell(j++).apply { setCellValue(asDouble(line["outputdQty"] ?: line["outputQty"])); cellStyle = numberStyle } row.createCell(j++).apply { setCellValue(asDouble(line["batchNeed"])); cellStyle = numberStyle } row.createCell(j++).apply { setCellValue(asDouble(line["itemPriority"])); cellStyle = numberStyle } diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/ClientPresenceOfflineScanner.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/ClientPresenceOfflineScanner.kt new file mode 100644 index 0000000..5992a97 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/ClientPresenceOfflineScanner.kt @@ -0,0 +1,17 @@ +package com.ffii.fpsms.modules.monitoring.scheduler + +import com.ffii.fpsms.modules.monitoring.service.ClientPresenceService +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true") +class ClientPresenceOfflineScanner( + private val clientPresenceService: ClientPresenceService, +) { + @Scheduled(fixedRateString = "\${fpsms.client-presence.offline-scan-interval-ms:60000}") + fun scanStaleOffline() { + clientPresenceService.recordStaleOfflineEvents() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/PrinterMonitorScheduler.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/PrinterMonitorScheduler.kt new file mode 100644 index 0000000..4195f8d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/PrinterMonitorScheduler.kt @@ -0,0 +1,30 @@ +package com.ffii.fpsms.modules.monitoring.scheduler + +import com.ffii.fpsms.modules.monitoring.service.PrinterMonitorService +import org.slf4j.LoggerFactory +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +@Component +@ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true") +class PrinterMonitorScheduler( + private val printerMonitorService: PrinterMonitorService, +) { + private val log = LoggerFactory.getLogger(PrinterMonitorScheduler::class.java) + + @Scheduled(fixedRateString = "\${fpsms.printer-monitor.scan-interval-ms:120000}") + fun scanPrinters() { + try { + val result = printerMonitorService.checkAllPrinters() + @Suppress("UNCHECKED_CAST") + val summary = result["summary"] as? Map + val offline = summary?.get("offline") ?: 0 + if ((offline as? Number)?.toInt()?.let { it > 0 } == true) { + log.warn("Printer monitor: {} printer(s) offline", offline) + } + } catch (e: Exception) { + log.error("Printer connectivity scan failed", e) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt new file mode 100644 index 0000000..bc678a0 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt @@ -0,0 +1,456 @@ +package com.ffii.fpsms.modules.monitoring.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.user.entity.User +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Service +open class ClientPresenceService( + private val jdbcDao: JdbcDao, +) { + @Value("\${fpsms.client-presence.offline-threshold-sec:90}") + private var offlineThresholdSec: Long = 90 + + @Value("\${fpsms.client-presence.idle-threshold-sec:300}") + private var idleThresholdSec: Long = 300 + + @Value("\${fpsms.client-presence.history-sample-sec:60}") + private var historySampleSec: Long = 60 + + @Value("\${fpsms.client-presence.history-max-range-days:31}") + private var historyMaxRangeDays: Long = 31 + + data class HeartbeatRequest( + val deviceId: String, + val displayName: String? = null, + val currentPath: String? = null, + val rttMs: Int? = null, + val connectionQuality: String? = null, + val navigatorOnline: Boolean? = true, + val userAgent: String? = null, + val activityBump: Boolean = true, + ) + + @Transactional + open fun upsertHeartbeat(user: User, req: HeartbeatRequest) { + val deviceId = req.deviceId.trim().take(64) + require(deviceId.isNotEmpty()) { "deviceId is required" } + + val quality = normalizeQuality(req.connectionQuality) + val clientType = detectClientType(req.userAgent) + val path = req.currentPath?.trim()?.take(512) + val displayName = req.displayName?.trim()?.take(128)?.ifBlank { null } + val now = LocalDateTime.now() + + val existing = jdbcDao.queryForList( + """ + SELECT + device_id AS deviceId, + user_id AS userId, + username, + display_name AS displayName, + current_path AS currentPath, + client_type AS clientType, + rtt_ms AS rttMs, + connection_quality AS connectionQuality, + navigator_online AS navigatorOnline, + last_heartbeat AS lastHeartbeat, + last_activity AS lastActivity + FROM client_presence + WHERE device_id = :deviceId + """.trimIndent(), + mapOf("deviceId" to deviceId), + ) + + val statusBefore = existing.firstOrNull()?.let { deriveStatus(it, now) } + + if (existing.isEmpty()) { + val insertSql = """ + INSERT INTO client_presence ( + device_id, user_id, username, display_name, current_path, client_type, + user_agent, rtt_ms, connection_quality, navigator_online, + last_heartbeat, last_activity + ) VALUES ( + :deviceId, :userId, :username, :displayName, :currentPath, :clientType, + :userAgent, :rttMs, :connectionQuality, :navigatorOnline, + :now, :lastActivity + ) + """.trimIndent() + jdbcDao.executeUpdate( + insertSql, + mapOf( + "deviceId" to deviceId, + "userId" to user.id, + "username" to user.username, + "displayName" to displayName, + "currentPath" to path, + "clientType" to clientType, + "userAgent" to req.userAgent?.take(512), + "rttMs" to req.rttMs, + "connectionQuality" to quality, + "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, + "now" to now, + "lastActivity" to now, + ), + ) + } else { + val lastActivity = if (req.activityBump) now else null + val updateSql = if (lastActivity != null) { + """ + UPDATE client_presence SET + user_id = :userId, + username = :username, + display_name = COALESCE(:displayName, display_name), + current_path = :currentPath, + client_type = :clientType, + user_agent = :userAgent, + rtt_ms = :rttMs, + connection_quality = :connectionQuality, + navigator_online = :navigatorOnline, + last_heartbeat = :now, + last_activity = :lastActivity + WHERE device_id = :deviceId + """.trimIndent() + } else { + """ + UPDATE client_presence SET + user_id = :userId, + username = :username, + display_name = COALESCE(:displayName, display_name), + current_path = :currentPath, + client_type = :clientType, + user_agent = :userAgent, + rtt_ms = :rttMs, + connection_quality = :connectionQuality, + navigator_online = :navigatorOnline, + last_heartbeat = :now + WHERE device_id = :deviceId + """.trimIndent() + } + + val args = mutableMapOf( + "deviceId" to deviceId, + "userId" to user.id, + "username" to user.username, + "displayName" to displayName, + "currentPath" to path, + "clientType" to clientType, + "userAgent" to req.userAgent?.take(512), + "rttMs" to req.rttMs, + "connectionQuality" to quality, + "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, + "now" to now, + ) + if (lastActivity != null) { + args["lastActivity"] = lastActivity + } + jdbcDao.executeUpdate(updateSql, args) + } + + val snapshot = mapOf( + "deviceId" to deviceId, + "userId" to user.id, + "username" to user.username, + "displayName" to displayName, + "currentPath" to path, + "clientType" to clientType, + "rttMs" to req.rttMs, + "connectionQuality" to quality, + "navigatorOnline" to if (req.navigatorOnline != false) 1 else 0, + "lastHeartbeat" to now, + "lastActivity" to if (req.activityBump) now else existing.firstOrNull()?.get("lastActivity"), + ) + val statusAfter = deriveStatus(snapshot, now) + maybeRecordEvent(snapshot, statusAfter, statusBefore, now) + } + + @Transactional + open fun recordStaleOfflineEvents() { + val now = LocalDateTime.now() + val cutoff = now.minusSeconds(offlineThresholdSec) + val rows = jdbcDao.queryForList( + """ + SELECT + device_id AS deviceId, + user_id AS userId, + username, + display_name AS displayName, + current_path AS currentPath, + client_type AS clientType, + rtt_ms AS rttMs, + connection_quality AS connectionQuality, + navigator_online AS navigatorOnline, + last_heartbeat AS lastHeartbeat, + last_activity AS lastActivity + FROM client_presence + WHERE last_heartbeat < :cutoff + """.trimIndent(), + mapOf("cutoff" to cutoff), + ) + for (row in rows) { + maybeRecordEvent(row, "offline", deriveStatus(row, now), now) + } + } + + @Transactional(readOnly = true) + open fun listActive(clientTypeFilter: String?): List> { + val type = clientTypeFilter?.trim()?.lowercase()?.takeIf { it.isNotBlank() && it != "all" } + val sql = buildString { + append( + """ + SELECT + device_id AS deviceId, + user_id AS userId, + username, + display_name AS displayName, + current_path AS currentPath, + client_type AS clientType, + user_agent AS userAgent, + rtt_ms AS rttMs, + connection_quality AS connectionQuality, + navigator_online AS navigatorOnline, + last_heartbeat AS lastHeartbeat, + last_activity AS lastActivity + FROM client_presence + WHERE 1=1 + """.trimIndent(), + ) + if (type != null) { + append(" AND client_type = :clientType") + } + append(" ORDER BY last_heartbeat DESC") + } + val args = if (type != null) mapOf("clientType" to type) else emptyMap() + val rows = jdbcDao.queryForList(sql, args) + val now = LocalDateTime.now() + return rows.map { row -> enrichWithStatus(row, now) } + } + + @Transactional(readOnly = true) + open fun listHistory( + from: LocalDateTime, + to: LocalDateTime, + clientTypeFilter: String?, + deviceIdFilter: String?, + statusFilter: List?, + ): Map { + require(!from.isAfter(to)) { "fromDateTime must be before toDateTime" } + val maxTo = from.plusDays(historyMaxRangeDays) + require(!to.isAfter(maxTo)) { "Date range must not exceed $historyMaxRangeDays days" } + + val type = clientTypeFilter?.trim()?.lowercase()?.takeIf { it.isNotBlank() && it != "all" } + val deviceId = deviceIdFilter?.trim()?.takeIf { it.isNotBlank() } + val statuses = (statusFilter?.map { it.trim().lowercase() }?.filter { it.isNotBlank() } + ?: listOf("offline", "poor")) + .distinct() + require(statuses.isNotEmpty()) { "At least one status is required" } + + val sql = buildString { + append( + """ + SELECT + id, + device_id AS deviceId, + user_id AS userId, + username, + display_name AS displayName, + client_type AS clientType, + status, + connection_quality AS connectionQuality, + rtt_ms AS rttMs, + navigator_online AS navigatorOnline, + current_path AS currentPath, + recorded_at AS recordedAt + FROM client_presence_event + WHERE recorded_at >= :from AND recorded_at <= :to + """.trimIndent(), + ) + append(" AND status IN (:statuses)") + if (type != null) { + append(" AND client_type = :clientType") + } + if (deviceId != null) { + append(" AND device_id = :deviceId") + } + append(" ORDER BY recorded_at DESC, id DESC") + } + + val args = mutableMapOf( + "from" to from, + "to" to to, + "statuses" to statuses, + ) + if (type != null) { + args["clientType"] = type + } + if (deviceId != null) { + args["deviceId"] = deviceId + } + + val events = jdbcDao.queryForList(sql, args) + val offlineCount = events.count { it["status"] == "offline" } + val poorCount = events.count { it["status"] == "poor" } + val deviceCount = events.mapNotNull { it["deviceId"]?.toString() }.distinct().size + + return mapOf( + "events" to events, + "summary" to mapOf( + "total" to events.size, + "offline" to offlineCount, + "poor" to poorCount, + "deviceCount" to deviceCount, + "from" to from.toString(), + "to" to to.toString(), + ), + ) + } + + private fun maybeRecordEvent( + row: Map, + status: String, + previousStatus: String?, + now: LocalDateTime, + ) { + val deviceId = row["deviceId"]?.toString()?.trim().orEmpty() + if (deviceId.isEmpty()) return + + val last = jdbcDao.queryForList( + """ + SELECT status, recorded_at AS recordedAt + FROM client_presence_event + WHERE device_id = :deviceId + ORDER BY id DESC + LIMIT 1 + """.trimIndent(), + mapOf("deviceId" to deviceId), + ) + + val lastStatus = last.firstOrNull()?.get("status")?.toString() + val lastAt = parseDateTime(last.firstOrNull()?.get("recordedAt")) + val secondsSinceLast = if (lastAt != null) ChronoUnit.SECONDS.between(lastAt, now) else Long.MAX_VALUE + + val isIncident = status == "offline" || status == "poor" + val statusChanged = lastStatus != status || previousStatus != status + val shouldRecord = when { + last.isEmpty() && isIncident -> true + statusChanged -> true + isIncident && secondsSinceLast >= historySampleSec -> true + else -> false + } + if (!shouldRecord) return + + jdbcDao.executeUpdate( + """ + INSERT INTO client_presence_event ( + device_id, user_id, username, display_name, client_type, + status, connection_quality, rtt_ms, navigator_online, current_path, recorded_at + ) VALUES ( + :deviceId, :userId, :username, :displayName, :clientType, + :status, :connectionQuality, :rttMs, :navigatorOnline, :currentPath, :recordedAt + ) + """.trimIndent(), + mapOf( + "deviceId" to deviceId, + "userId" to row["userId"], + "username" to row["username"], + "displayName" to row["displayName"], + "clientType" to (row["clientType"]?.toString() ?: "unknown"), + "status" to status, + "connectionQuality" to row["connectionQuality"], + "rttMs" to row["rttMs"], + "navigatorOnline" to when (row["navigatorOnline"]) { + false, 0, 0L -> 0 + else -> 1 + }, + "currentPath" to row["currentPath"], + "recordedAt" to now, + ), + ) + } + + private fun deriveStatus(row: Map, now: LocalDateTime): String { + val lastHb = parseDateTime(row["lastHeartbeat"]) + val lastAct = parseDateTime(row["lastActivity"]) ?: lastHb + val quality = row["connectionQuality"]?.toString()?.lowercase() ?: "good" + val navOnline = row["navigatorOnline"] == true || + row["navigatorOnline"] == 1 || + row["navigatorOnline"] == 1L + + val secondsSinceHb = if (lastHb != null) ChronoUnit.SECONDS.between(lastHb, now) else Long.MAX_VALUE + val secondsSinceAct = if (lastAct != null) ChronoUnit.SECONDS.between(lastAct, now) else Long.MAX_VALUE + + return when { + secondsSinceHb >= offlineThresholdSec -> "offline" + !navOnline || quality == "poor" -> "poor" + secondsSinceAct >= idleThresholdSec -> "idle" + else -> "online" + } + } + + private fun enrichWithStatus(row: Map, now: LocalDateTime): Map { + val lastHb = parseDateTime(row["lastHeartbeat"]) + val lastAct = parseDateTime(row["lastActivity"]) ?: lastHb + val quality = row["connectionQuality"]?.toString()?.lowercase() ?: "good" + val navOnline = row["navigatorOnline"] == true || + row["navigatorOnline"] == 1 || + row["navigatorOnline"] == 1L + + val secondsSinceHb = if (lastHb != null) ChronoUnit.SECONDS.between(lastHb, now) else Long.MAX_VALUE + val secondsSinceAct = if (lastAct != null) ChronoUnit.SECONDS.between(lastAct, now) else Long.MAX_VALUE + + val status = when { + secondsSinceHb >= offlineThresholdSec -> "offline" + !navOnline || quality == "poor" -> "poor" + secondsSinceAct >= idleThresholdSec -> "idle" + else -> "online" + } + + val result = LinkedHashMap(row.size + 4) + row.forEach { (k, v) -> result[k] = v } + result["status"] = status + result["secondsSinceHeartbeat"] = if (secondsSinceHb == Long.MAX_VALUE) null else secondsSinceHb + result["secondsSinceActivity"] = if (secondsSinceAct == Long.MAX_VALUE) null else secondsSinceAct + result["offlineThresholdSec"] = offlineThresholdSec + result["idleThresholdSec"] = idleThresholdSec + @Suppress("UNCHECKED_CAST") + return result as Map + } + + private fun parseDateTime(value: Any?): LocalDateTime? = when (value) { + null -> null + is LocalDateTime -> value + is java.sql.Timestamp -> value.toLocalDateTime() + is java.util.Date -> LocalDateTime.ofInstant(value.toInstant(), java.time.ZoneId.systemDefault()) + else -> { + val text = value.toString().trim() + if (text.length >= 19) { + runCatching { LocalDateTime.parse(text.replace(" ", "T").take(19)) }.getOrNull() + } else null + } + } + + private fun normalizeQuality(raw: String?): String { + val q = raw?.trim()?.lowercase() ?: return "good" + return when (q) { + "poor", "offline" -> q + else -> "good" + } + } + + fun detectClientType(userAgent: String?): String { + if (userAgent.isNullOrBlank()) return "unknown" + val ua = userAgent.lowercase() + return when { + ua.contains("ipad") || ua.contains("tablet") -> "tablet" + ua.contains("android") && !ua.contains("mobile") -> "tablet" + ua.contains("iphone") || ua.contains("ipod") || + (ua.contains("android") && ua.contains("mobile")) || + ua.contains("mobile") -> "mobile" + else -> "desktop" + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt new file mode 100644 index 0000000..e6f5906 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt @@ -0,0 +1,325 @@ +package com.ffii.fpsms.modules.monitoring.service + +import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.master.entity.Printer +import com.ffii.fpsms.modules.master.entity.PrinterRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.net.InetSocketAddress +import java.net.Socket +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +@Service +open class PrinterMonitorService( + private val printerRepository: PrinterRepository, + private val jdbcDao: JdbcDao, +) { + @Value("\${fpsms.printer-monitor.connect-timeout-ms:3000}") + private var connectTimeoutMs: Int = 3000 + + @Value("\${fpsms.printer-monitor.default-port:9100}") + private var defaultPort: Int = 9100 + + @Value("\${fpsms.printer-monitor.offline-event-sample-sec:300}") + private var offlineEventSampleSec: Long = 300 + + @Value("\${fpsms.printer-monitor.history-max-range-days:31}") + private var historyMaxRangeDays: Long = 31 + + data class ProbeResult( + val status: String, + val reachable: Boolean?, + val latencyMs: Int?, + val errorMessage: String?, + ) + + @Transactional + open fun checkAllPrinters(): Map { + val printers = printerRepository.findAllByDeletedIsFalse() + val results = printers.map { checkAndPersist(it) } + return buildListResponse(results) + } + + @Transactional(readOnly = true) + open fun listStatus(): Map { + val rows = jdbcDao.queryForList( + """ + SELECT + id, + code, + name, + type, + brand, + description, + ip, + port, + dpi, + last_reachable AS lastReachable, + last_check_at AS lastCheckAt, + last_check_error AS lastCheckError, + last_check_latency_ms AS lastCheckLatencyMs + FROM printer + WHERE deleted = 0 + ORDER BY name, code, id + """.trimIndent(), + ) + val results = rows.map { rowToStatus(it) } + return buildListResponse(results) + } + + @Transactional(readOnly = true) + open fun listHistory( + from: LocalDateTime, + to: LocalDateTime, + printerId: Long?, + ): Map { + require(!from.isAfter(to)) { "fromDateTime must be before toDateTime" } + require(!to.isAfter(from.plusDays(historyMaxRangeDays))) { + "Date range must not exceed $historyMaxRangeDays days" + } + + val sql = buildString { + append( + """ + SELECT + id, + printer_id AS printerId, + printer_code AS printerCode, + printer_name AS printerName, + printer_type AS printerType, + brand, + ip, + port, + reachable, + error_message AS errorMessage, + latency_ms AS latencyMs, + recorded_at AS recordedAt + FROM printer_connectivity_event + WHERE recorded_at >= :from AND recorded_at <= :to + AND reachable = 0 + """.trimIndent(), + ) + if (printerId != null) { + append(" AND printer_id = :printerId") + } + append(" ORDER BY recorded_at DESC, id DESC") + } + val args = mutableMapOf("from" to from, "to" to to) + if (printerId != null) { + args["printerId"] = printerId + } + val events = jdbcDao.queryForList(sql, args) + return mapOf( + "events" to events, + "summary" to mapOf( + "total" to events.size, + "from" to from.toString(), + "to" to to.toString(), + ), + ) + } + + private fun checkAndPersist(printer: Printer): Map { + val probe = probePrinter(printer) + val now = LocalDateTime.now() + val printerId = printer.id ?: return emptyMap() + + val previousReachable = jdbcDao.queryForList( + "SELECT last_reachable AS lastReachable FROM printer WHERE id = :id", + mapOf("id" to printerId), + ).firstOrNull()?.get("lastReachable") + + jdbcDao.executeUpdate( + """ + UPDATE printer SET + last_reachable = :reachable, + last_check_at = :checkedAt, + last_check_error = :error, + last_check_latency_ms = :latencyMs + WHERE id = :id + """.trimIndent(), + mapOf( + "id" to printerId, + "reachable" to probe.reachable?.let { if (it) 1 else 0 }, + "checkedAt" to now, + "error" to probe.errorMessage?.take(255), + "latencyMs" to probe.latencyMs, + ), + ) + + maybeRecordOfflineEvent(printer, probe, previousReachable, now) + + return mapOf( + "id" to printerId, + "code" to printer.code, + "name" to printer.name, + "type" to printer.type, + "brand" to printer.brand, + "description" to printer.description, + "ip" to printer.ip, + "port" to (printer.port ?: defaultPort), + "dpi" to printer.dpi, + "status" to probe.status, + "reachable" to probe.reachable, + "latencyMs" to probe.latencyMs, + "errorMessage" to probe.errorMessage, + "lastCheckAt" to now, + ) + } + + private fun rowToStatus(row: Map): Map { + val ip = row["ip"]?.toString()?.trim().orEmpty() + val lastReachable = row["lastReachable"] + val reachable = when (lastReachable) { + null -> null + true, 1, 1L -> true + else -> false + } + val status = when { + ip.isBlank() -> "unconfigured" + reachable == null -> "unchecked" + reachable == true -> "online" + else -> "offline" + } + return mapOf( + "id" to row["id"], + "code" to row["code"], + "name" to row["name"], + "type" to row["type"], + "brand" to row["brand"], + "description" to row["description"], + "ip" to row["ip"], + "port" to (row["port"] ?: defaultPort), + "dpi" to row["dpi"], + "status" to status, + "reachable" to reachable, + "latencyMs" to row["lastCheckLatencyMs"], + "errorMessage" to row["lastCheckError"], + "lastCheckAt" to row["lastCheckAt"], + ) + } + + private fun probePrinter(printer: Printer): ProbeResult { + val ip = printer.ip?.trim().orEmpty() + if (ip.isBlank()) { + return ProbeResult( + status = "unconfigured", + reachable = null, + latencyMs = null, + errorMessage = "未設定 IP", + ) + } + val port = printer.port ?: defaultPort + return probeTcp(ip, port) + } + + private fun probeTcp(ip: String, port: Int): ProbeResult { + val start = System.currentTimeMillis() + return try { + Socket().use { socket -> + socket.connect(InetSocketAddress(ip, port), connectTimeoutMs) + } + val latency = (System.currentTimeMillis() - start).toInt() + ProbeResult( + status = "online", + reachable = true, + latencyMs = latency, + errorMessage = null, + ) + } catch (e: Exception) { + ProbeResult( + status = "offline", + reachable = false, + latencyMs = null, + errorMessage = e.message?.take(200) ?: e.javaClass.simpleName, + ) + } + } + + private fun maybeRecordOfflineEvent( + printer: Printer, + probe: ProbeResult, + previousReachable: Any?, + now: LocalDateTime, + ) { + if (probe.reachable != false) return + val printerId = printer.id ?: return + + val prev = when (previousReachable) { + null -> null + true, 1, 1L -> true + else -> false + } + val lastEvent = jdbcDao.queryForList( + """ + SELECT reachable, recorded_at AS recordedAt + FROM printer_connectivity_event + WHERE printer_id = :printerId + ORDER BY id DESC + LIMIT 1 + """.trimIndent(), + mapOf("printerId" to printerId), + ).firstOrNull() + + val lastReachable = lastEvent?.get("reachable") + val lastWasOffline = lastReachable == false || lastReachable == 0 || lastReachable == 0L + val lastAt = parseDateTime(lastEvent?.get("recordedAt")) + val secondsSince = if (lastAt != null) ChronoUnit.SECONDS.between(lastAt, now) else Long.MAX_VALUE + + val shouldRecord = prev != false || !lastWasOffline || secondsSince >= offlineEventSampleSec + if (!shouldRecord) return + + jdbcDao.executeUpdate( + """ + INSERT INTO printer_connectivity_event ( + printer_id, printer_code, printer_name, printer_type, brand, + ip, port, reachable, error_message, latency_ms, recorded_at + ) VALUES ( + :printerId, :code, :name, :type, :brand, + :ip, :port, 0, :error, :latencyMs, :recordedAt + ) + """.trimIndent(), + mapOf( + "printerId" to printerId, + "code" to printer.code, + "name" to printer.name, + "type" to printer.type, + "brand" to printer.brand, + "ip" to printer.ip, + "port" to (printer.port ?: defaultPort), + "error" to probe.errorMessage?.take(255), + "latencyMs" to probe.latencyMs, + "recordedAt" to now, + ), + ) + } + + private fun buildListResponse(results: List>): Map { + val warnings = results.filter { it["status"] == "offline" } + return mapOf( + "printers" to results, + "warnings" to warnings, + "summary" to mapOf( + "total" to results.size, + "online" to results.count { it["status"] == "online" }, + "offline" to warnings.size, + "unconfigured" to results.count { it["status"] == "unconfigured" }, + "unchecked" to results.count { it["status"] == "unchecked" }, + ), + ) + } + + private fun parseDateTime(value: Any?): LocalDateTime? = when (value) { + null -> null + is LocalDateTime -> value + is java.sql.Timestamp -> value.toLocalDateTime() + else -> { + val text = value.toString().trim() + if (text.length >= 19) { + runCatching { LocalDateTime.parse(text.replace(" ", "T").take(19)) }.getOrNull() + } else null + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt new file mode 100644 index 0000000..37f2377 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt @@ -0,0 +1,130 @@ +package com.ffii.fpsms.modules.monitoring.web + +import com.ffii.core.exception.BadRequestException +import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.monitoring.service.ClientPresenceService +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +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 +import java.time.LocalDateTime +import java.time.format.DateTimeParseException + +@RestController +@RequestMapping("/device-presence") +@ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true") +class ClientPresenceController( + private val clientPresenceService: ClientPresenceService, +) { + + data class HeartbeatBody( + val deviceId: String? = null, + val displayName: String? = null, + val currentPath: String? = null, + val rttMs: Int? = null, + val connectionQuality: String? = null, + val navigatorOnline: Boolean? = true, + val userAgent: String? = null, + val activityBump: Boolean? = true, + ) + + @GetMapping("/ping") + fun ping(): ResponseEntity> { + return ResponseEntity.ok(mapOf("ok" to true, "ts" to System.currentTimeMillis())) + } + + @PostMapping("/heartbeat") + fun heartbeat(@RequestBody body: HeartbeatBody): ResponseEntity> { + val user = SecurityUtils.getUser().orElseThrow { BadRequestException("Not authenticated") } + val deviceId = body.deviceId?.trim().orEmpty() + if (deviceId.isEmpty()) { + throw BadRequestException("deviceId is required") + } + val ua = body.userAgent?.takeIf { it.isNotBlank() } + ?: run { + // allow server-side only if client omits; usually sent from browser + null + } + clientPresenceService.upsertHeartbeat( + user, + ClientPresenceService.HeartbeatRequest( + deviceId = deviceId, + displayName = body.displayName, + currentPath = body.currentPath, + rttMs = body.rttMs, + connectionQuality = body.connectionQuality, + navigatorOnline = body.navigatorOnline, + userAgent = ua, + activityBump = body.activityBump != false, + ), + ) + return ResponseEntity.ok(mapOf("ok" to true)) + } + + @GetMapping("/history") + fun listHistory( + @RequestParam fromDateTime: String, + @RequestParam toDateTime: String, + @RequestParam(required = false) clientType: String?, + @RequestParam(required = false) deviceId: String?, + @RequestParam(required = false) status: String?, + ): ResponseEntity { + val from = parseDateTimeParam(fromDateTime, endOfDay = false) + ?: return ResponseEntity.badRequest().body(mapOf("error" to "Invalid fromDateTime")) + val to = parseDateTimeParam(toDateTime, endOfDay = true) + ?: return ResponseEntity.badRequest().body(mapOf("error" to "Invalid toDateTime")) + if (from.isAfter(to)) { + return ResponseEntity.badRequest().body(mapOf("error" to "fromDateTime must be before toDateTime")) + } + val statuses = status + ?.split(",") + ?.map { it.trim().lowercase() } + ?.filter { it.isNotBlank() } + ?.takeIf { it.isNotEmpty() } + return ResponseEntity.ok( + clientPresenceService.listHistory(from, to, clientType, deviceId, statuses), + ) + } + + @GetMapping("/active") + fun listActive( + @RequestParam(required = false) clientType: String?, + ): ResponseEntity> { + val clients = clientPresenceService.listActive(clientType) + val offlineCount = clients.count { it["status"] == "offline" } + val poorCount = clients.count { it["status"] == "poor" } + return ResponseEntity.ok( + mapOf( + "clients" to clients, + "summary" to mapOf( + "total" to clients.size, + "offline" to offlineCount, + "poor" to poorCount, + "online" to clients.count { it["status"] == "online" }, + "idle" to clients.count { it["status"] == "idle" }, + ), + ), + ) + } + + private fun parseDateTimeParam(raw: String, endOfDay: Boolean): LocalDateTime? { + val text = raw.trim() + if (text.isEmpty()) return null + return try { + when { + text.length <= 10 -> { + val d = LocalDate.parse(text.take(10)) + if (endOfDay) d.atTime(23, 59, 59) else d.atStartOfDay() + } + else -> LocalDateTime.parse(text.replace(" ", "T").take(19)) + } + } catch (_: DateTimeParseException) { + null + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/web/PrinterMonitorController.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/web/PrinterMonitorController.kt new file mode 100644 index 0000000..af0ac39 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/web/PrinterMonitorController.kt @@ -0,0 +1,63 @@ +package com.ffii.fpsms.modules.monitoring.web + +import com.ffii.fpsms.modules.monitoring.service.PrinterMonitorService +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +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 +import java.time.LocalDateTime +import java.time.format.DateTimeParseException + +@RestController +@RequestMapping("/printer-monitor") +@ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true") +class PrinterMonitorController( + private val printerMonitorService: PrinterMonitorService, +) { + + @GetMapping("/status") + fun status(): ResponseEntity> { + return ResponseEntity.ok(printerMonitorService.listStatus()) + } + + @PostMapping("/check") + fun checkNow(): ResponseEntity> { + return ResponseEntity.ok(printerMonitorService.checkAllPrinters()) + } + + @GetMapping("/history") + fun history( + @RequestParam fromDateTime: String, + @RequestParam toDateTime: String, + @RequestParam(required = false) printerId: Long?, + ): ResponseEntity { + val from = parseDateTimeParam(fromDateTime, endOfDay = false) + ?: return ResponseEntity.badRequest().body(mapOf("error" to "Invalid fromDateTime")) + val to = parseDateTimeParam(toDateTime, endOfDay = true) + ?: return ResponseEntity.badRequest().body(mapOf("error" to "Invalid toDateTime")) + if (from.isAfter(to)) { + return ResponseEntity.badRequest().body(mapOf("error" to "fromDateTime must be before toDateTime")) + } + return ResponseEntity.ok(printerMonitorService.listHistory(from, to, printerId)) + } + + private fun parseDateTimeParam(raw: String, endOfDay: Boolean): LocalDateTime? { + val text = raw.trim() + if (text.isEmpty()) return null + return try { + when { + text.length <= 10 -> { + val d = LocalDate.parse(text.take(10)) + if (endOfDay) d.atTime(23, 59, 59) else d.atStartOfDay() + } + else -> LocalDateTime.parse(text.replace(" ", "T").take(19)) + } + } catch (_: DateTimeParseException) { + null + } + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 83b725d..9cd76a6 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,3 +1,8 @@ +# Device + printer connectivity monitoring (heartbeats, printer TCP checks). +fpsms: + monitoring: + enabled: true + # Session length in production; frontend can call /refresh-token before expiry to stay logged in. jwt: expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e8cd673..335a69f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,6 +36,21 @@ scheduler: fpsms: purchase-stock-in-alert: lookback-days: 7 + # Device + printer monitoring: enable only on production profile (application-prod*.yml). + monitoring: + enabled: false + client-presence: + offline-threshold-sec: 90 + idle-threshold-sec: 300 + history-sample-sec: 60 + history-max-range-days: 31 + offline-scan-interval-ms: 60000 + printer-monitor: + scan-interval-ms: 120000 + connect-timeout-ms: 3000 + default-port: 9100 + offline-event-sample-sec: 300 + history-max-range-days: 31 spring: servlet: diff --git a/src/main/resources/db/changelog/changes/20260520_01_client_presence/01_client_presence.sql b/src/main/resources/db/changelog/changes/20260520_01_client_presence/01_client_presence.sql new file mode 100644 index 0000000..3d25248 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260520_01_client_presence/01_client_presence.sql @@ -0,0 +1,26 @@ +--liquibase formatted sql + +--changeset fpsms:client_presence +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'client_presence' +--comment: Live browser presence for tablet/desktop monitoring (heartbeat per device) +CREATE TABLE `client_presence` ( + `device_id` varchar(64) NOT NULL, + `user_id` bigint DEFAULT NULL, + `username` varchar(100) DEFAULT NULL, + `display_name` varchar(128) DEFAULT NULL, + `current_path` varchar(512) DEFAULT NULL, + `client_type` varchar(16) NOT NULL DEFAULT 'unknown', + `user_agent` varchar(512) DEFAULT NULL, + `rtt_ms` int DEFAULT NULL, + `connection_quality` varchar(16) NOT NULL DEFAULT 'good', + `navigator_online` tinyint(1) NOT NULL DEFAULT 1, + `last_heartbeat` datetime NOT NULL, + `last_activity` datetime NOT NULL, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`device_id`), + KEY `idx_client_presence_last_heartbeat` (`last_heartbeat`), + KEY `idx_client_presence_username` (`username`), + KEY `idx_client_presence_client_type` (`client_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/changelog/changes/20260520_02_client_presence_event/01_client_presence_event.sql b/src/main/resources/db/changelog/changes/20260520_02_client_presence_event/01_client_presence_event.sql new file mode 100644 index 0000000..63043aa --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260520_02_client_presence_event/01_client_presence_event.sql @@ -0,0 +1,26 @@ +--liquibase formatted sql + +--changeset fpsms:client_presence_event +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'client_presence_event' +--comment: Historical device presence samples (offline / poor connection) +CREATE TABLE `client_presence_event` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `device_id` varchar(64) NOT NULL, + `user_id` bigint DEFAULT NULL, + `username` varchar(100) DEFAULT NULL, + `display_name` varchar(128) DEFAULT NULL, + `client_type` varchar(16) NOT NULL DEFAULT 'unknown', + `status` varchar(16) NOT NULL, + `connection_quality` varchar(16) DEFAULT NULL, + `rtt_ms` int DEFAULT NULL, + `navigator_online` tinyint(1) NOT NULL DEFAULT 1, + `current_path` varchar(512) DEFAULT NULL, + `recorded_at` datetime NOT NULL, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_cpe_recorded_at` (`recorded_at`), + KEY `idx_cpe_device_recorded` (`device_id`, `recorded_at`), + KEY `idx_cpe_status_recorded` (`status`, `recorded_at`), + KEY `idx_cpe_client_type_recorded` (`client_type`, `recorded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/db/changelog/changes/20260520_03_printer_monitor/01_printer_monitor.sql b/src/main/resources/db/changelog/changes/20260520_03_printer_monitor/01_printer_monitor.sql new file mode 100644 index 0000000..ea26f4b --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260520_03_printer_monitor/01_printer_monitor.sql @@ -0,0 +1,35 @@ +--liquibase formatted sql + +--changeset fpsms:printer_connectivity_columns +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'printer' AND column_name = 'last_reachable' +--comment: Last TCP connectivity probe result for printer monitoring +ALTER TABLE `printer` + ADD COLUMN `last_reachable` tinyint(1) DEFAULT NULL, + ADD COLUMN `last_check_at` datetime DEFAULT NULL, + ADD COLUMN `last_check_error` varchar(255) DEFAULT NULL, + ADD COLUMN `last_check_latency_ms` int DEFAULT NULL; + +--changeset fpsms:printer_connectivity_event +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'printer_connectivity_event' +--comment: Printer offline / connectivity event log +CREATE TABLE `printer_connectivity_event` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `printer_id` int NOT NULL, + `printer_code` varchar(500) DEFAULT NULL, + `printer_name` varchar(500) DEFAULT NULL, + `printer_type` varchar(255) DEFAULT NULL, + `brand` varchar(255) DEFAULT NULL, + `ip` varchar(30) DEFAULT NULL, + `port` int DEFAULT NULL, + `reachable` tinyint(1) NOT NULL DEFAULT 0, + `error_message` varchar(255) DEFAULT NULL, + `latency_ms` int DEFAULT NULL, + `recorded_at` datetime NOT NULL, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_pce_printer_recorded` (`printer_id`, `recorded_at`), + KEY `idx_pce_recorded_at` (`recorded_at`), + KEY `idx_pce_reachable_recorded` (`reachable`, `recorded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;