| @@ -91,6 +91,16 @@ public class SecurityConfig { | |||||
| .hasAnyAuthority("TESTING", "ADMIN", "STOCK") | .hasAnyAuthority("TESTING", "ADMIN", "STOCK") | ||||
| .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") | .requestMatchers(HttpMethod.GET, "/product-process/Demo/Process/alerts/fg-qc-putaway") | ||||
| .hasAuthority("TESTING") | .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()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | ||||
| @@ -7,6 +7,13 @@ import org.springframework.stereotype.Service | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import com.ffii.core.support.JdbcDao | 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 | @Service | ||||
| open class PSService( | open class PSService( | ||||
| @@ -160,6 +167,49 @@ open class PSService( | |||||
| } | } | ||||
| /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ | /** 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) { | fun setCoffeeOrTea(itemCode: String, systemType: String, enabled: Boolean) { | ||||
| val args = mapOf("itemCode" to itemCode, "systemType" to systemType) | val args = mapOf("itemCode" to itemCode, "systemType" to systemType) | ||||
| jdbcDao.executeUpdate( | jdbcDao.executeUpdate( | ||||
| @@ -214,4 +264,150 @@ open class PSService( | |||||
| return jdbcDao.queryForList(sql, args) | 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.service.PSService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | ||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import org.springframework.http.HttpHeaders | import org.springframework.http.HttpHeaders | ||||
| import org.springframework.http.MediaType | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeParseException | |||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| @RestController | @RestController | ||||
| @@ -79,6 +80,13 @@ class PSController( | |||||
| return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "onHandQty" to (onHandQty ?: "deleted"))) | 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. */ | /** Set or clear coffee/tea/lemon for an item. systemType: coffee | tea | lemon, enabled: boolean. */ | ||||
| @PostMapping("/setCoffeeOrTea") | @PostMapping("/setCoffeeOrTea") | ||||
| fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | fun setCoffeeOrTea(@RequestBody body: Map<String, Any>): ResponseEntity<Map<String, Any>> { | ||||
| @@ -91,4 +99,37 @@ class PSController( | |||||
| psService.setCoffeeOrTea(itemCode, systemType, enabled) | psService.setCoffeeOrTea(itemCode, systemType, enabled) | ||||
| return ResponseEntity.ok(mapOf("ok" to true, "itemCode" to itemCode, "systemType" to systemType, "enabled" to 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") | 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 ── | // ── Group production lines by date ── | ||||
| val groupedData = lines.groupBy { | 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(line["itemName"]?.toString() ?: ""); cellStyle = wrapStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["avgQtyLastMonth"])); cellStyle = numberStyle } | 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["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["outputdQty"] ?: line["outputQty"])); cellStyle = numberStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["batchNeed"])); cellStyle = numberStyle } | row.createCell(j++).apply { setCellValue(asDouble(line["batchNeed"])); cellStyle = numberStyle } | ||||
| row.createCell(j++).apply { setCellValue(asDouble(line["itemPriority"])); 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. | # Session length in production; frontend can call /refresh-token before expiry to stay logged in. | ||||
| jwt: | jwt: | ||||
| expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK | expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK | ||||
| @@ -36,6 +36,21 @@ scheduler: | |||||
| fpsms: | fpsms: | ||||
| purchase-stock-in-alert: | purchase-stock-in-alert: | ||||
| lookback-days: 7 | 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: | spring: | ||||
| servlet: | 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; | |||||