Explorar el Código

added export do qty in /ps for daily delivery qty; added monitor page for production use

production
padre
commit
9cdb1f71b8
Se han modificado 15 ficheros con 1390 adiciones y 2 borrados
  1. +10
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  2. +196
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt
  3. +42
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt
  4. +14
    -1
      src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt
  5. +17
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/ClientPresenceOfflineScanner.kt
  6. +30
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/PrinterMonitorScheduler.kt
  7. +456
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt
  8. +325
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt
  9. +130
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt
  10. +63
    -0
      src/main/java/com/ffii/fpsms/modules/monitoring/web/PrinterMonitorController.kt
  11. +5
    -0
      src/main/resources/application-prod.yml
  12. +15
    -0
      src/main/resources/application.yml
  13. +26
    -0
      src/main/resources/db/changelog/changes/20260520_01_client_presence/01_client_presence.sql
  14. +26
    -0
      src/main/resources/db/changelog/changes/20260520_02_client_presence_event/01_client_presence_event.sql
  15. +35
    -0
      src/main/resources/db/changelog/changes/20260520_03_printer_monitor/01_printer_monitor.sql

+ 10
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Ver fichero

@@ -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")))


+ 196
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt Ver fichero

@@ -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
}

}

+ 42
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt Ver fichero

@@ -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")))
}
}
}

+ 14
- 1
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt Ver fichero

@@ -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 }


+ 17
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/ClientPresenceOfflineScanner.kt Ver fichero

@@ -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()
}
}

+ 30
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/PrinterMonitorScheduler.kt Ver fichero

@@ -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)
}
}
}

+ 456
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/service/ClientPresenceService.kt Ver fichero

@@ -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"
}
}
}

+ 325
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/service/PrinterMonitorService.kt Ver fichero

@@ -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
}
}
}

+ 130
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/web/ClientPresenceController.kt Ver fichero

@@ -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
}
}
}

+ 63
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/web/PrinterMonitorController.kt Ver fichero

@@ -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
}
}
}

+ 5
- 0
src/main/resources/application-prod.yml Ver fichero

@@ -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


+ 15
- 0
src/main/resources/application.yml Ver fichero

@@ -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:


+ 26
- 0
src/main/resources/db/changelog/changes/20260520_01_client_presence/01_client_presence.sql Ver fichero

@@ -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;

+ 26
- 0
src/main/resources/db/changelog/changes/20260520_02_client_presence_event/01_client_presence_event.sql Ver fichero

@@ -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;

+ 35
- 0
src/main/resources/db/changelog/changes/20260520_03_printer_monitor/01_printer_monitor.sql Ver fichero

@@ -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;

Cargando…
Cancelar
Guardar