| @@ -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"))) | |||
| @@ -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<String, Any>()) | |||
| } | |||
| 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<Map<String, Any>> { | |||
| 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<String, Any>()) | |||
| } | |||
| /** | |||
| * 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<Map<String, Any>> { | |||
| 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<String, MutableMap<LocalDate, BigDecimal>>() | |||
| 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 | |||
| } | |||
| } | |||
| @@ -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<Map<String, Any>> { | |||
| 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<String, Any>): ResponseEntity<Map<String, Any>> { | |||
| @@ -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<Any> { | |||
| 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"))) | |||
| } | |||
| } | |||
| } | |||
| @@ -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 } | |||
| @@ -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() | |||
| } | |||
| } | |||
| @@ -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<String, Any> | |||
| 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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<String, Any?>( | |||
| "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<String, Any?>( | |||
| "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<Map<String, Any>> { | |||
| 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<String, Any>() | |||
| 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<String>?, | |||
| ): Map<String, Any> { | |||
| 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<String, Any>( | |||
| "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<String, Any?>, | |||
| 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<String, Any?>, 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<String, Any>, now: LocalDateTime): Map<String, Any> { | |||
| 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<String, Any?>(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<String, Any> | |||
| } | |||
| 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" | |||
| } | |||
| } | |||
| } | |||
| @@ -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<String, Any> { | |||
| val printers = printerRepository.findAllByDeletedIsFalse() | |||
| val results = printers.map { checkAndPersist(it) } | |||
| return buildListResponse(results) | |||
| } | |||
| @Transactional(readOnly = true) | |||
| open fun listStatus(): Map<String, Any> { | |||
| 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<String, Any> { | |||
| 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<String, Any>("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<String, Any?> { | |||
| 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<String, Any>): Map<String, Any?> { | |||
| 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<String, Any?>>): Map<String, Any> { | |||
| 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 | |||
| } | |||
| } | |||
| } | |||
| @@ -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<Map<String, Any>> { | |||
| return ResponseEntity.ok(mapOf("ok" to true, "ts" to System.currentTimeMillis())) | |||
| } | |||
| @PostMapping("/heartbeat") | |||
| fun heartbeat(@RequestBody body: HeartbeatBody): ResponseEntity<Map<String, Any>> { | |||
| 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<Any> { | |||
| 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<Map<String, Any>> { | |||
| 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 | |||
| } | |||
| } | |||
| } | |||
| @@ -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<Map<String, Any>> { | |||
| return ResponseEntity.ok(printerMonitorService.listStatus()) | |||
| } | |||
| @PostMapping("/check") | |||
| fun checkNow(): ResponseEntity<Map<String, Any>> { | |||
| return ResponseEntity.ok(printerMonitorService.checkAllPrinters()) | |||
| } | |||
| @GetMapping("/history") | |||
| fun history( | |||
| @RequestParam fromDateTime: String, | |||
| @RequestParam toDateTime: String, | |||
| @RequestParam(required = false) printerId: Long?, | |||
| ): ResponseEntity<Any> { | |||
| 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 | |||
| } | |||
| } | |||
| } | |||
| @@ -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 | |||
| @@ -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: | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -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; | |||