From 76ff1cd22f2604048ad4460976ebdf6b2e5feb5d Mon Sep 17 00:00:00 2001 From: tommy Date: Wed, 17 Jun 2026 16:02:42 +0800 Subject: [PATCH] label printer --- .../fpsms/config/security/SecurityConfig.java | 6 + .../service/DeliveryOrderService.kt | 55 ++- .../service/DoWorkbenchMainService.kt | 10 +- .../scheduler/LabelPrinterMonitorScheduler.kt | 30 ++ .../service/LabelPrinterMonitorService.kt | 365 ++++++++++++++++++ .../web/LabelPrinterMonitorController.kt | 62 +++ .../monitoring/zebra/ZebraLinkOsClient.kt | 121 ++++++ .../DeliveryNoteCartonLabelsPDF.jrxml | 166 +++++--- src/main/resources/application.yml | 7 + .../01_label_printer_monitor.sql | 29 ++ .../jasper/StockTakeVarianceReport.jrxml | 53 ++- 11 files changed, 800 insertions(+), 104 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/LabelPrinterMonitorScheduler.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/monitoring/service/LabelPrinterMonitorService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/monitoring/web/LabelPrinterMonitorController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/monitoring/zebra/ZebraLinkOsClient.kt create mode 100644 src/main/resources/db/changelog/changes/20260617_label_printer_monitor/01_label_printer_monitor.sql diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 9c85317..c82ca67 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -103,6 +103,12 @@ public class SecurityConfig { .hasAnyAuthority("TESTING", "ADMIN") .requestMatchers(HttpMethod.POST, "/printer-monitor/check") .hasAnyAuthority("TESTING", "ADMIN") + .requestMatchers(HttpMethod.GET, "/label-printer-monitor/status") + .hasAnyAuthority("TESTING", "ADMIN") + .requestMatchers(HttpMethod.POST, "/label-printer-monitor/check") + .hasAnyAuthority("TESTING", "ADMIN") + .requestMatchers(HttpMethod.GET, "/label-printer-monitor/label-stats") + .hasAnyAuthority("TESTING", "ADMIN") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index 9d01c52..d695fb0 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -1881,42 +1881,60 @@ open class DeliveryOrderService( data class ParsedShopLabelForCartonLabel( val shopCode: String, - val shopCodeAbbr: String, - val shopNameForLabel: String + val shopNameForLabel: String, ) fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel { - // Fixed input format: shopCode - shopName1-shopName2 + // Input format: shopCode - shopName1-shopName2 (e.g. CT001 - 茶泰-啟田店) + // Optional trailing note in parentheses on its own line, e.g. (午更落貨表:15:00到貨) val raw = rawInput.trim() val (shopCodePartRaw, restPart) = raw.split(" - ", limit = 2).let { parts -> (parts.getOrNull(0)?.trim().orEmpty()) to (parts.getOrNull(1)?.trim().orEmpty()) } - val shopCode = shopCodePartRaw.let { code -> - val trimmed = code.trim() - if (trimmed.length > 5) trimmed.substring(0, 5) else trimmed - } + val trailingNoteRegex = Regex("\\s*(\\([^)]+\\))\\s*$") + val trailingNoteMatch = trailingNoteRegex.find(restPart) + val namePart = trailingNoteMatch + ?.let { restPart.removeRange(it.range).trim() } + ?: restPart + val trailingNote = trailingNoteMatch?.groupValues?.get(1)?.trim().orEmpty() - val (shopName1, shopName2) = restPart.split("-", limit = 2).let { parts -> + val (shopName1, shopName2) = namePart.split("-", limit = 2).let { parts -> (parts.getOrNull(0)?.trim().orEmpty()) to (parts.getOrNull(1)?.trim().orEmpty()) } - val shopNameForLabel = if (shopName2.isNotBlank()) { - "$shopName1\n$shopName2" - } else { - shopName1 + val shopNameLines = buildList { + if (shopName1.isNotBlank()) add(shopName1) + if (shopName2.isNotBlank()) add(shopName2) + if (trailingNote.isNotBlank()) add(trailingNote) } - val shopCodeAbbr = if (shopCode.length >= 2) shopCode.substring(0, 2) else shopCode - return ParsedShopLabelForCartonLabel( - shopCode = shopCode, - shopCodeAbbr = shopCodeAbbr, - shopNameForLabel = shopNameForLabel + shopCode = shopCodePartRaw.trim(), + shopNameForLabel = shopNameLines.joinToString("\n"), ) } + fun formatTruckLaneCodeForCartonLabel(raw: String?): String { + val trimmed = raw?.trim().orEmpty() + if (trimmed.isEmpty()) return trimmed + + val withoutLanePrefix = if (trimmed.startsWith("車線-")) { + trimmed.removePrefix("車線-") + } else { + trimmed + } + + // 4F route board: P06B_Wed_區6_新界,九龍 → 區6_新界,九龍 + val pRouteDistrict = Regex("^P[^_]+_[^_]+_(.+)$").matchEntire(withoutLanePrefix) + if (pRouteDistrict != null) { + return pRouteDistrict.groupValues[1] + } + + return withoutLanePrefix + } + //Print Carton Labels @Transactional @@ -2047,9 +2065,8 @@ open class DeliveryOrderService( val rawShopLabel = doPickOrderRecord.shopName ?: cartonLabelInfo[0].shopName ?: "" val parsedShopLabel = parseShopLabelForCartonLabel(rawShopLabel) params["shopCode"] = parsedShopLabel.shopCode - params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr params["shopName"] = parsedShopLabel.shopNameForLabel - params["truckNo"] = doPickOrderRecord.truckLanceCode ?: "" + params["truckNo"] = formatTruckLaneCodeForCartonLabel(doPickOrderRecord.truckLanceCode) return DnLabelExportContext( cartonLabelTemplate = cartonLabelTemplate, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index 142f160..8662d78 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -1765,6 +1765,7 @@ return MessageResponse( dop.storeId as store_id, dop.truckLanceCode as TruckLanceCode, dop.truckDepartureTime as truck_departure_time, + dop.requiredDeliveryDate as required_delivery_date, dop.shopCode as ShopCode, dop.shopName as ShopName, dop.ticketStatus as doTicketStatus @@ -2824,19 +2825,14 @@ return MessageResponse( cartonLabelInfo: MutableList, ): MutableMap { val params = mutableMapOf() - params["shopPurchaseOrderNo"] = if (ctx.deliveryOrderIds.size > 1) { - "請查閲送貨單(採購單共${ctx.deliveryOrderIds.size}張)" - } else { - cartonLabelInfo[0].code - } + params["shopPurchaseOrderNo"] = "請查閲送貨單(採購單共${ctx.deliveryOrderIds.size}張)" params["deliveryNoteCode"] = ctx.header.deliveryNoteCode ?: "" params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: "" val rawShopLabel = ctx.header.shopName ?: cartonLabelInfo[0].shopName ?: "" val parsedShopLabel = deliveryOrderService.parseShopLabelForCartonLabel(rawShopLabel) params["shopCode"] = parsedShopLabel.shopCode - params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr params["shopName"] = parsedShopLabel.shopNameForLabel - params["truckNo"] = ctx.header.truckLanceCode ?: "" + params["truckNo"] = deliveryOrderService.formatTruckLaneCodeForCartonLabel(ctx.header.truckLanceCode) return params } diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/LabelPrinterMonitorScheduler.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/LabelPrinterMonitorScheduler.kt new file mode 100644 index 0000000..e766d4a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/scheduler/LabelPrinterMonitorScheduler.kt @@ -0,0 +1,30 @@ +package com.ffii.fpsms.modules.monitoring.scheduler + +import com.ffii.fpsms.modules.monitoring.service.LabelPrinterMonitorService +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 LabelPrinterMonitorScheduler( + private val labelPrinterMonitorService: LabelPrinterMonitorService, +) { + private val log = LoggerFactory.getLogger(LabelPrinterMonitorScheduler::class.java) + + @Scheduled(fixedRateString = "\${fpsms.label-printer-monitor.check-interval-ms:120000}") + fun scanLabelPrinters() { + try { + val result = labelPrinterMonitorService.checkAllLabelPrinters() + @Suppress("UNCHECKED_CAST") + val summary = result["summary"] as? Map + val offline = summary?.get("offline") ?: 0 + if ((offline as? Number)?.toInt()?.let { it > 0 } == true) { + log.warn("Label printer monitor: {} label printer(s) offline", offline) + } + } catch (e: Exception) { + log.error("Label printer connectivity / odometer scan failed", e) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/service/LabelPrinterMonitorService.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/service/LabelPrinterMonitorService.kt new file mode 100644 index 0000000..82a36ae --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/service/LabelPrinterMonitorService.kt @@ -0,0 +1,365 @@ +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 com.ffii.fpsms.modules.monitoring.zebra.ZebraLinkOsClient +import com.ffii.fpsms.py.PyPrintChannel +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.LocalDate +import java.time.LocalDateTime + +@Service +open class LabelPrinterMonitorService( + private val printerRepository: PrinterRepository, + private val jdbcDao: JdbcDao, +) { + @Value("\${fpsms.label-printer-monitor.connect-timeout-ms:3000}") + private var connectTimeoutMs: Int = 3000 + + @Value("\${fpsms.label-printer-monitor.zebra-query-timeout-ms:2500}") + private var zebraQueryTimeoutMs: Int = 2500 + + @Value("\${fpsms.label-printer-monitor.default-port:9100}") + private var defaultPort: Int = 9100 + + @Value("\${fpsms.label-printer-monitor.zebra-odometer-enabled:true}") + private var zebraOdometerEnabled: Boolean = true + + @Value("\${fpsms.label-printer-monitor.history-max-range-days:30}") + private var historyMaxRangeDays: Long = 30 + + data class ProbeResult( + val reachable: Boolean, + val latencyMs: Int?, + val errorMessage: String?, + ) + + @Transactional + open fun checkAllLabelPrinters(): Map { + val printers = labelPrinters() + val results = printers.map { checkAndPersist(it) } + val today = LocalDate.now() + val stats = labelPrintStats(today.atStartOfDay(), LocalDateTime.now()) + return mapOf( + "printers" to results, + "labelStats" to stats, + "summary" to buildSummary(results), + ) + } + + @Transactional(readOnly = true) + open fun listStatus(): Map { + val rows = jdbcDao.queryForList( + """ + SELECT + p.id, + p.code, + p.name, + p.type, + p.brand, + p.ip, + p.port, + p.last_label_odometer AS lastLabelOdometer, + p.last_label_odometer_at AS lastLabelOdometerAt, + log.odometer_total AS odometerTotal, + log.delta_since_previous AS deltaSincePrevious, + log.reachable AS lastReachable, + log.latency_ms AS latencyMs, + log.error_message AS errorMessage, + log.host_status_snippet AS hostStatusSnippet, + log.recorded_at AS lastCheckAt + FROM printer p + LEFT JOIN printer_label_odometer_log log ON log.id = ( + SELECT l2.id + FROM printer_label_odometer_log l2 + WHERE l2.printer_id = p.id + ORDER BY l2.id DESC + LIMIT 1 + ) + WHERE p.deleted = 0 + AND p.type = 'Label' + ORDER BY p.name, p.code, p.id + """.trimIndent(), + ) + val results = rows.map { rowToPrinterStatus(it) } + val today = LocalDate.now() + val stats = labelPrintStats(today.atStartOfDay(), LocalDateTime.now()) + return mapOf( + "printers" to results, + "labelStats" to stats, + "summary" to buildSummary(results), + ) + } + + @Transactional(readOnly = true) + open fun labelPrintStats(from: LocalDateTime, to: LocalDateTime): Map { + require(!from.isAfter(to)) { "fromDateTime must be before toDateTime" } + require(!to.isAfter(from.plusDays(historyMaxRangeDays))) { + "Date range must not exceed $historyMaxRangeDays days" + } + + val totalRow = jdbcDao.queryForList( + """ + SELECT COALESCE(SUM(qty), 0) AS total + FROM py_job_order_print_submit + WHERE deleted = 0 + AND print_channel = :channel + AND created >= :from + AND created <= :to + """.trimIndent(), + mapOf( + "channel" to PyPrintChannel.LABEL, + "from" to from, + "to" to to, + ), + ).firstOrNull() + + val todayStart = LocalDate.now().atStartOfDay() + val todayRow = jdbcDao.queryForList( + """ + SELECT COALESCE(SUM(qty), 0) AS total + FROM py_job_order_print_submit + WHERE deleted = 0 + AND print_channel = :channel + AND created >= :from + AND created <= :to + """.trimIndent(), + mapOf( + "channel" to PyPrintChannel.LABEL, + "from" to todayStart, + "to" to LocalDateTime.now(), + ), + ).firstOrNull() + + val recent = jdbcDao.queryForList( + """ + SELECT + s.id, + s.job_order_id AS jobOrderId, + s.qty, + s.created, + jo.code AS jobCode + FROM py_job_order_print_submit s + LEFT JOIN job_order jo ON jo.id = s.job_order_id + WHERE s.deleted = 0 + AND s.print_channel = :channel + AND s.created >= :from + AND s.created <= :to + ORDER BY s.created DESC, s.id DESC + LIMIT 50 + """.trimIndent(), + mapOf( + "channel" to PyPrintChannel.LABEL, + "from" to from, + "to" to to, + ), + ) + + return mapOf( + "todayTotal" to (numberValue(todayRow?.get("total")) ?: 0L), + "rangeTotal" to (numberValue(totalRow?.get("total")) ?: 0L), + "from" to from.toString(), + "to" to to.toString(), + "recentSubmits" to recent, + ) + } + + private fun labelPrinters(): List = + printerRepository.findAllByDeletedIsFalse().filter { it.type == "Label" } + + private fun checkAndPersist(printer: Printer): Map { + val printerId = printer.id ?: return emptyMap() + val now = LocalDateTime.now() + val probe = probeTcp(printer.ip?.trim().orEmpty(), printer.port ?: defaultPort) + val status = connectivityStatus(printer.ip, probe) + + var odometerTotal: Long? = null + var deltaSincePrevious: Long? = null + var hostStatusSnippet: String? = null + var odometerError: String? = null + + val previousOdometer = loadPreviousOdometer(printerId) + + if ( + probe.reachable && + zebraOdometerEnabled && + ZebraLinkOsClient.isZebraBrand(printer.brand) + ) { + val ip = printer.ip?.trim().orEmpty() + val port = printer.port ?: defaultPort + val zebra = ZebraLinkOsClient.queryOdometer( + ip = ip, + port = port, + connectTimeoutMs = connectTimeoutMs, + queryTimeoutMs = zebraQueryTimeoutMs, + ) + odometerTotal = zebra.totalLabelCount + hostStatusSnippet = zebra.hostStatusSnippet + odometerError = zebra.errorMessage + if (odometerTotal != null && previousOdometer != null && odometerTotal >= previousOdometer) { + deltaSincePrevious = odometerTotal - previousOdometer + } + } + + val combinedError = when { + !probe.reachable -> probe.errorMessage + odometerError != null && ZebraLinkOsClient.isZebraBrand(printer.brand) && zebraOdometerEnabled -> + odometerError + else -> null + } + + jdbcDao.executeUpdate( + """ + INSERT INTO printer_label_odometer_log ( + printer_id, odometer_total, delta_since_previous, + reachable, latency_ms, error_message, host_status_snippet, recorded_at + ) VALUES ( + :printerId, :odometerTotal, :deltaSincePrevious, + :reachable, :latencyMs, :error, :hostStatus, :recordedAt + ) + """.trimIndent(), + mapOf( + "printerId" to printerId, + "odometerTotal" to odometerTotal, + "deltaSincePrevious" to deltaSincePrevious, + "reachable" to if (probe.reachable) 1 else 0, + "latencyMs" to probe.latencyMs, + "error" to combinedError?.take(255), + "hostStatus" to hostStatusSnippet?.take(500), + "recordedAt" to now, + ), + ) + + if (odometerTotal != null) { + jdbcDao.executeUpdate( + """ + UPDATE printer SET + last_label_odometer = :odometer, + last_label_odometer_at = :checkedAt + WHERE id = :id + """.trimIndent(), + mapOf( + "id" to printerId, + "odometer" to odometerTotal, + "checkedAt" to now, + ), + ) + } + + return mapOf( + "id" 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), + "status" to status, + "reachable" to probe.reachable, + "latencyMs" to probe.latencyMs, + "errorMessage" to combinedError, + "odometerTotal" to odometerTotal, + "deltaSincePrevious" to deltaSincePrevious, + "lastLabelOdometer" to (odometerTotal ?: previousOdometer), + "hostStatusSnippet" to hostStatusSnippet, + "zebraOdometerEnabled" to zebraOdometerEnabled, + "lastCheckAt" to now, + ) + } + + private fun loadPreviousOdometer(printerId: Long): Long? { + val row = jdbcDao.queryForList( + "SELECT last_label_odometer AS v FROM printer WHERE id = :id", + mapOf("id" to printerId), + ).firstOrNull() ?: return null + return numberValue(row["v"]) + } + + private fun rowToPrinterStatus(row: Map): Map { + val ip = row["ip"]?.toString()?.trim().orEmpty() + val lastReachable = row["lastReachable"] + val reachable = when (lastReachable) { + null -> null + true, 1, 1L -> true + else -> false + } + val status = when { + ip.isBlank() -> "unconfigured" + reachable == null -> "unchecked" + reachable == true -> "online" + else -> "offline" + } + return mapOf( + "id" to row["id"], + "code" to row["code"], + "name" to row["name"], + "type" to row["type"], + "brand" to row["brand"], + "ip" to row["ip"], + "port" to (row["port"] ?: defaultPort), + "status" to status, + "reachable" to reachable, + "latencyMs" to row["latencyMs"], + "errorMessage" to row["errorMessage"], + "odometerTotal" to numberValue(row["odometerTotal"] ?: row["lastLabelOdometer"]), + "deltaSincePrevious" to numberValue(row["deltaSincePrevious"]), + "lastLabelOdometerAt" to row["lastLabelOdometerAt"], + "hostStatusSnippet" to row["hostStatusSnippet"], + "zebraOdometerEnabled" to zebraOdometerEnabled, + "lastCheckAt" to row["lastCheckAt"], + ) + } + + private fun connectivityStatus(ip: String?, probe: ProbeResult): String { + val trimmed = ip?.trim().orEmpty() + return when { + trimmed.isBlank() -> "unconfigured" + probe.reachable -> "online" + else -> "offline" + } + } + + private fun probeTcp(ip: String, port: Int): ProbeResult { + if (ip.isBlank()) { + return ProbeResult(reachable = false, latencyMs = null, errorMessage = "未設定 IP") + } + val start = System.currentTimeMillis() + return try { + Socket().use { socket -> + socket.connect(InetSocketAddress(ip, port), connectTimeoutMs) + } + ProbeResult( + reachable = true, + latencyMs = (System.currentTimeMillis() - start).toInt(), + errorMessage = null, + ) + } catch (e: Exception) { + ProbeResult( + reachable = false, + latencyMs = null, + errorMessage = e.message?.take(200) ?: e.javaClass.simpleName, + ) + } + } + + private fun buildSummary(results: List>): Map { + return mapOf( + "total" to results.size, + "online" to results.count { it["status"] == "online" }, + "offline" to results.count { it["status"] == "offline" }, + "unconfigured" to results.count { it["status"] == "unconfigured" }, + "unchecked" to results.count { it["status"] == "unchecked" }, + ) + } + + private fun numberValue(value: Any?): Long? = when (value) { + null -> null + is Number -> value.toLong() + else -> value.toString().trim().toLongOrNull() + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/web/LabelPrinterMonitorController.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/web/LabelPrinterMonitorController.kt new file mode 100644 index 0000000..be76a5d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/web/LabelPrinterMonitorController.kt @@ -0,0 +1,62 @@ +package com.ffii.fpsms.modules.monitoring.web + +import com.ffii.fpsms.modules.monitoring.service.LabelPrinterMonitorService +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("/label-printer-monitor") +@ConditionalOnProperty(prefix = "fpsms.monitoring", name = ["enabled"], havingValue = "true") +class LabelPrinterMonitorController( + private val labelPrinterMonitorService: LabelPrinterMonitorService, +) { + + @GetMapping("/status") + fun status(): ResponseEntity> { + return ResponseEntity.ok(labelPrinterMonitorService.listStatus()) + } + + @PostMapping("/check") + fun checkNow(): ResponseEntity> { + return ResponseEntity.ok(labelPrinterMonitorService.checkAllLabelPrinters()) + } + + @GetMapping("/label-stats") + fun labelStats( + @RequestParam fromDateTime: String, + @RequestParam toDateTime: String, + ): ResponseEntity { + val from = parseDateTimeParam(fromDateTime, endOfDay = false) + ?: return ResponseEntity.badRequest().body(mapOf("error" to "Invalid fromDateTime")) + val to = parseDateTimeParam(toDateTime, endOfDay = true) + ?: return ResponseEntity.badRequest().body(mapOf("error" to "Invalid toDateTime")) + if (from.isAfter(to)) { + return ResponseEntity.badRequest().body(mapOf("error" to "fromDateTime must be before toDateTime")) + } + return ResponseEntity.ok(labelPrinterMonitorService.labelPrintStats(from, to)) + } + + private fun parseDateTimeParam(raw: String, endOfDay: Boolean): LocalDateTime? { + val text = raw.trim() + if (text.isEmpty()) return null + return try { + when { + text.length <= 10 -> { + val d = LocalDate.parse(text.take(10)) + if (endOfDay) d.atTime(23, 59, 59) else d.atStartOfDay() + } + else -> LocalDateTime.parse(text.replace(" ", "T").take(19)) + } + } catch (_: DateTimeParseException) { + null + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/monitoring/zebra/ZebraLinkOsClient.kt b/src/main/java/com/ffii/fpsms/modules/monitoring/zebra/ZebraLinkOsClient.kt new file mode 100644 index 0000000..b62ef3c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/monitoring/zebra/ZebraLinkOsClient.kt @@ -0,0 +1,121 @@ +package com.ffii.fpsms.modules.monitoring.zebra + +import java.io.IOException +import java.net.InetSocketAddress +import java.net.Socket +import java.nio.charset.StandardCharsets + +data class ZebraOdometerResult( + val totalLabelCount: Long?, + val hostStatusSnippet: String?, + val errorMessage: String?, +) + +object ZebraLinkOsClient { + + private const val ODOMETER_VAR = "odometer.total_label_count" + + fun isZebraBrand(brand: String?): Boolean = + brand?.contains("zebra", ignoreCase = true) == true + + fun queryOdometer( + ip: String, + port: Int, + connectTimeoutMs: Int, + queryTimeoutMs: Int, + includeHostStatus: Boolean = true, + ): ZebraOdometerResult { + val safeIp = ip.trim() + if (safeIp.isEmpty()) { + return ZebraOdometerResult(null, null, "IP is blank") + } + return try { + Socket().use { socket -> + socket.tcpNoDelay = true + socket.soTimeout = queryTimeoutMs.coerceAtLeast(500) + socket.connect(InetSocketAddress(safeIp, port), connectTimeoutMs.coerceAtLeast(500)) + val out = socket.getOutputStream() + val cmd = """! U1 getvar "$ODOMETER_VAR"""".toByteArray(StandardCharsets.US_ASCII) + "\r\n".toByteArray() + out.write(cmd) + out.flush() + val odometerBytes = readAvailable(socket, queryTimeoutMs) + val odometerText = String(odometerBytes, StandardCharsets.UTF_8).trim() + val total = parseOdometerValue(odometerText) + + val hostSnippet = if (includeHostStatus) { + out.write("~HS\r\n".toByteArray(StandardCharsets.US_ASCII)) + out.flush() + val hsBytes = readAvailable(socket, queryTimeoutMs) + val hs = String(hsBytes, StandardCharsets.UTF_8).trim() + hs.take(500).ifBlank { null } + } else { + null + } + + if (total == null) { + ZebraOdometerResult( + totalLabelCount = null, + hostStatusSnippet = hostSnippet, + errorMessage = odometerText.ifBlank { "No odometer response" }.take(255), + ) + } else { + ZebraOdometerResult( + totalLabelCount = total, + hostStatusSnippet = hostSnippet, + errorMessage = null, + ) + } + } + } catch (e: IOException) { + ZebraOdometerResult(null, null, e.message?.take(255) ?: e.javaClass.simpleName) + } catch (e: Exception) { + ZebraOdometerResult(null, null, e.message?.take(255) ?: e.javaClass.simpleName) + } + } + + private fun readAvailable(socket: Socket, waitMs: Int): ByteArray { + val input = socket.getInputStream() + val buffer = ByteArray(4096) + val chunks = ArrayList() + var total = 0 + val deadline = System.currentTimeMillis() + waitMs.coerceAtLeast(300) + while (System.currentTimeMillis() < deadline && total < 16384) { + val available = try { + input.available() + } catch (_: IOException) { + break + } + if (available > 0) { + val n = input.read(buffer, 0, minOf(buffer.size, available)) + if (n <= 0) break + chunks.add(buffer.copyOf(n)) + total += n + continue + } + if (total > 0) break + try { + Thread.sleep(50) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + break + } + } + if (chunks.isEmpty()) return ByteArray(0) + val merged = ByteArray(total) + var offset = 0 + for (chunk in chunks) { + System.arraycopy(chunk, 0, merged, offset, chunk.size) + offset += chunk.size + } + return merged + } + + internal fun parseOdometerValue(raw: String): Long? { + val text = raw.trim() + if (text.isEmpty()) return null + val unquoted = text.trim('"', '\'', ' ') + val digits = unquoted.filter { it.isDigit() } + if (digits.isEmpty()) return null + return digits.toLongOrNull() + } +} diff --git a/src/main/resources/DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml b/src/main/resources/DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml index b00d29b..c0fa026 100644 --- a/src/main/resources/DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml +++ b/src/main/resources/DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml @@ -1,5 +1,5 @@ - + @@ -16,167 +16,209 @@ - - + - - + + + + + + + + - + - - + + + + + + + + - + - - + + + + - + - + + + + - + - + - - + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + + + + + - - - - - - - - - - + - - + + + + + + + + + - + + + + + - + + + + - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ceedd96..733eb95 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,6 +65,13 @@ fpsms: default-port: 9100 offline-event-sample-sec: 300 history-max-range-days: 30 + label-printer-monitor: + zebra-odometer-enabled: true + connect-timeout-ms: 3000 + zebra-query-timeout-ms: 2500 + default-port: 9100 + check-interval-ms: 120000 + history-max-range-days: 30 spring: servlet: diff --git a/src/main/resources/db/changelog/changes/20260617_label_printer_monitor/01_label_printer_monitor.sql b/src/main/resources/db/changelog/changes/20260617_label_printer_monitor/01_label_printer_monitor.sql new file mode 100644 index 0000000..e56b101 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260617_label_printer_monitor/01_label_printer_monitor.sql @@ -0,0 +1,29 @@ +--liquibase formatted sql + +--changeset fpsms:printer_label_odometer_cache_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_label_odometer' +--comment: Cache latest Zebra label odometer reading for label-printer-monitor +ALTER TABLE `printer` + ADD COLUMN `last_label_odometer` bigint DEFAULT NULL, + ADD COLUMN `last_label_odometer_at` datetime DEFAULT NULL; + +--changeset fpsms:printer_label_odometer_log +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'printer_label_odometer_log' +--comment: Per-check label odometer snapshot for Zebra label printers +CREATE TABLE `printer_label_odometer_log` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `printer_id` int NOT NULL, + `odometer_total` bigint DEFAULT NULL, + `delta_since_previous` bigint DEFAULT NULL, + `reachable` tinyint(1) NOT NULL DEFAULT 0, + `latency_ms` int DEFAULT NULL, + `error_message` varchar(255) DEFAULT NULL, + `host_status_snippet` varchar(500) DEFAULT NULL, + `recorded_at` datetime NOT NULL, + `created` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_plol_printer_recorded` (`printer_id`, `recorded_at`), + KEY `idx_plol_recorded_at` (`recorded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/src/main/resources/jasper/StockTakeVarianceReport.jrxml b/src/main/resources/jasper/StockTakeVarianceReport.jrxml index 26fb9a0..04020a3 100644 --- a/src/main/resources/jasper/StockTakeVarianceReport.jrxml +++ b/src/main/resources/jasper/StockTakeVarianceReport.jrxml @@ -1,5 +1,5 @@ - + @@ -65,10 +65,11 @@ + - + @@ -235,7 +236,7 @@ - + @@ -246,7 +247,7 @@ - + @@ -257,7 +258,7 @@ - + @@ -268,7 +269,7 @@ - + @@ -290,7 +291,7 @@ - + @@ -301,7 +302,7 @@ - + @@ -312,7 +313,7 @@ - + @@ -327,12 +328,23 @@ + + + + + + + + + + + - + @@ -341,7 +353,7 @@ - + @@ -351,7 +363,7 @@ - + @@ -360,7 +372,7 @@ - + @@ -378,7 +390,7 @@ - + @@ -387,7 +399,7 @@ - + @@ -396,7 +408,7 @@ - + @@ -404,6 +416,15 @@ + + + + + + + + +