浏览代码

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

production
[email protected] 1 个月前
父节点
当前提交
9cdb1f71b8
共有 15 个文件被更改,包括 1390 次插入2 次删除
  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 查看文件

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


+ 196
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt 查看文件

@@ -7,6 +7,13 @@ import org.springframework.stereotype.Service
import java.time.LocalDate import 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
}

} }

+ 42
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PSController.kt 查看文件

@@ -4,10 +4,11 @@ import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService
import com.ffii.fpsms.modules.jobOrder.service.PSService import com.ffii.fpsms.modules.jobOrder.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")))
}
}
} }

+ 14
- 1
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt 查看文件

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


+ 17
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/ClientPresenceOfflineScanner.kt 查看文件

@@ -0,0 +1,17 @@
package com.ffii.fpsms.modules.monitoring.scheduler

import com.ffii.fpsms.modules.monitoring.service.ClientPresenceService
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component

@Component
@ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true")
class ClientPresenceOfflineScanner(
private val clientPresenceService: ClientPresenceService,
) {
@Scheduled(fixedRateString = "\${fpsms.client-presence.offline-scan-interval-ms:60000}")
fun scanStaleOffline() {
clientPresenceService.recordStaleOfflineEvents()
}
}

+ 30
- 0
src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/PrinterMonitorScheduler.kt 查看文件

@@ -0,0 +1,30 @@
package com.ffii.fpsms.modules.monitoring.scheduler

import com.ffii.fpsms.modules.monitoring.service.PrinterMonitorService
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component

@Component
@ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true")
class PrinterMonitorScheduler(
private val printerMonitorService: PrinterMonitorService,
) {
private val log = LoggerFactory.getLogger(PrinterMonitorScheduler::class.java)

@Scheduled(fixedRateString = "\${fpsms.printer-monitor.scan-interval-ms:120000}")
fun scanPrinters() {
try {
val result = printerMonitorService.checkAllPrinters()
@Suppress("UNCHECKED_CAST")
val summary = result["summary"] as? Map<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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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


+ 15
- 0
src/main/resources/application.yml 查看文件

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


+ 26
- 0
src/main/resources/db/changelog/changes/20260520_01_client_presence/01_client_presence.sql 查看文件

@@ -0,0 +1,26 @@
--liquibase formatted sql

--changeset fpsms:client_presence
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'client_presence'
--comment: Live browser presence for tablet/desktop monitoring (heartbeat per device)
CREATE TABLE `client_presence` (
`device_id` varchar(64) NOT NULL,
`user_id` bigint DEFAULT NULL,
`username` varchar(100) DEFAULT NULL,
`display_name` varchar(128) DEFAULT NULL,
`current_path` varchar(512) DEFAULT NULL,
`client_type` varchar(16) NOT NULL DEFAULT 'unknown',
`user_agent` varchar(512) DEFAULT NULL,
`rtt_ms` int DEFAULT NULL,
`connection_quality` varchar(16) NOT NULL DEFAULT 'good',
`navigator_online` tinyint(1) NOT NULL DEFAULT 1,
`last_heartbeat` datetime NOT NULL,
`last_activity` datetime NOT NULL,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`device_id`),
KEY `idx_client_presence_last_heartbeat` (`last_heartbeat`),
KEY `idx_client_presence_username` (`username`),
KEY `idx_client_presence_client_type` (`client_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+ 26
- 0
src/main/resources/db/changelog/changes/20260520_02_client_presence_event/01_client_presence_event.sql 查看文件

@@ -0,0 +1,26 @@
--liquibase formatted sql

--changeset fpsms:client_presence_event
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'client_presence_event'
--comment: Historical device presence samples (offline / poor connection)
CREATE TABLE `client_presence_event` (
`id` bigint NOT NULL AUTO_INCREMENT,
`device_id` varchar(64) NOT NULL,
`user_id` bigint DEFAULT NULL,
`username` varchar(100) DEFAULT NULL,
`display_name` varchar(128) DEFAULT NULL,
`client_type` varchar(16) NOT NULL DEFAULT 'unknown',
`status` varchar(16) NOT NULL,
`connection_quality` varchar(16) DEFAULT NULL,
`rtt_ms` int DEFAULT NULL,
`navigator_online` tinyint(1) NOT NULL DEFAULT 1,
`current_path` varchar(512) DEFAULT NULL,
`recorded_at` datetime NOT NULL,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_cpe_recorded_at` (`recorded_at`),
KEY `idx_cpe_device_recorded` (`device_id`, `recorded_at`),
KEY `idx_cpe_status_recorded` (`status`, `recorded_at`),
KEY `idx_cpe_client_type_recorded` (`client_type`, `recorded_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

+ 35
- 0
src/main/resources/db/changelog/changes/20260520_03_printer_monitor/01_printer_monitor.sql 查看文件

@@ -0,0 +1,35 @@
--liquibase formatted sql

--changeset fpsms:printer_connectivity_columns
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'printer' AND column_name = 'last_reachable'
--comment: Last TCP connectivity probe result for printer monitoring
ALTER TABLE `printer`
ADD COLUMN `last_reachable` tinyint(1) DEFAULT NULL,
ADD COLUMN `last_check_at` datetime DEFAULT NULL,
ADD COLUMN `last_check_error` varchar(255) DEFAULT NULL,
ADD COLUMN `last_check_latency_ms` int DEFAULT NULL;

--changeset fpsms:printer_connectivity_event
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'printer_connectivity_event'
--comment: Printer offline / connectivity event log
CREATE TABLE `printer_connectivity_event` (
`id` bigint NOT NULL AUTO_INCREMENT,
`printer_id` int NOT NULL,
`printer_code` varchar(500) DEFAULT NULL,
`printer_name` varchar(500) DEFAULT NULL,
`printer_type` varchar(255) DEFAULT NULL,
`brand` varchar(255) DEFAULT NULL,
`ip` varchar(30) DEFAULT NULL,
`port` int DEFAULT NULL,
`reachable` tinyint(1) NOT NULL DEFAULT 0,
`error_message` varchar(255) DEFAULT NULL,
`latency_ms` int DEFAULT NULL,
`recorded_at` datetime NOT NULL,
`created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_pce_printer_recorded` (`printer_id`, `recorded_at`),
KEY `idx_pce_recorded_at` (`recorded_at`),
KEY `idx_pce_reachable_recorded` (`reachable`, `recorded_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

正在加载...
取消
保存