| @@ -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"))) | |||
| @@ -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, | |||
| @@ -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<DeliveryOrderInfo>, | |||
| ): MutableMap<String, Any> { | |||
| val params = mutableMapOf<String, Any>() | |||
| 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 | |||
| } | |||
| @@ -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<String, Any> | |||
| 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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<String, Any> { | |||
| 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<String, Any> { | |||
| 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<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 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<Printer> = | |||
| printerRepository.findAllByDeletedIsFalse().filter { it.type == "Label" } | |||
| private fun checkAndPersist(printer: Printer): Map<String, Any?> { | |||
| 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<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"], | |||
| "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<String, Any?>>): Map<String, Any> { | |||
| 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() | |||
| } | |||
| } | |||
| @@ -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<Map<String, Any>> { | |||
| return ResponseEntity.ok(labelPrinterMonitorService.listStatus()) | |||
| } | |||
| @PostMapping("/check") | |||
| fun checkNow(): ResponseEntity<Map<String, Any>> { | |||
| return ResponseEntity.ok(labelPrinterMonitorService.checkAllLabelPrinters()) | |||
| } | |||
| @GetMapping("/label-stats") | |||
| fun labelStats( | |||
| @RequestParam fromDateTime: String, | |||
| @RequestParam toDateTime: 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")) | |||
| } | |||
| 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 | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ByteArray>() | |||
| 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() | |||
| } | |||
| } | |||
| @@ -1,5 +1,5 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!-- Created with Jaspersoft Studio version 6.17.0.final using JasperReports Library version 6.17.0-6d93193241dd8cc42629e188b94f9e0bc5722efd --> | |||
| <!-- Created with Jaspersoft Studio version 6.20.6.final using JasperReports Library version 6.20.6-5c96b6aa8a39ac1dc6b6bea4b81168e16dd39231 --> | |||
| <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="Blank_A4" pageWidth="405" pageHeight="283" columnWidth="405" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0" uuid="baa9f270-b398-4f1c-b01e-ba216b7997e9"> | |||
| <property name="com.jaspersoft.studio.unit." value="pixel"/> | |||
| <property name="com.jaspersoft.studio.unit.pageHeight" value="pixel"/> | |||
| @@ -16,167 +16,209 @@ | |||
| <parameter name="deliveryNoteCode" class="java.lang.String"/> | |||
| <parameter name="truckNo" class="java.lang.String"/> | |||
| <parameter name="shopCode" class="java.lang.String"/> | |||
| <parameter name="shopCodeAbbr" class="java.lang.String"/> | |||
| <queryString> | |||
| <![CDATA[]]> | |||
| </queryString> | |||
| <field name="cartonIndex" class="java.lang.Integer"/> | |||
| <field name="cartonTotal" class="java.lang.Integer"/> | |||
| <background> | |||
| <band splitType="Stretch"/> | |||
| <band height="7" splitType="Stretch"/> | |||
| </background> | |||
| <detail> | |||
| <band height="243" splitType="Stretch"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <textField> | |||
| <reportElement x="155" y="210" width="240" height="30" uuid="8fac39f8-4936-43a5-8e1f-1afbc8ccca9c"> | |||
| <textField textAdjust="ScaleFont"> | |||
| <reportElement x="126" y="211" width="164" height="30" uuid="8fac39f8-4936-43a5-8e1f-1afbc8ccca9c"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <topPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <leftPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <rightPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| </box> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| <font fontName="微軟正黑體" size="14"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{shopPurchaseOrderNo}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="15" y="210" width="140" height="30" uuid="e03fcb92-259c-4427-a68e-60fe5924d763"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <reportElement x="14" y="211" width="112" height="30" uuid="e03fcb92-259c-4427-a68e-60fe5924d763"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <topPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <leftPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <rightPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| </box> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| <font fontName="微軟正黑體" size="14"/> | |||
| </textElement> | |||
| <text><![CDATA[店鋪採購單編號: | |||
| ]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement x="15" y="180" width="140" height="30" uuid="f3ffd4ee-0513-41a5-94d7-f1fdb9966a76"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <reportElement x="14" y="166" width="85" height="45" uuid="f3ffd4ee-0513-41a5-94d7-f1fdb9966a76"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <leftPen lineWidth="1.0" lineStyle="Solid"/> | |||
| </box> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| <font fontName="微軟正黑體" size="14"/> | |||
| </textElement> | |||
| <text><![CDATA[送貨單編號:]]></text> | |||
| </staticText> | |||
| <textField> | |||
| <reportElement x="155" y="180" width="240" height="30" uuid="4319059b-9096-4c49-8275-287be93d3e6a"> | |||
| <reportElement x="99" y="166" width="191" height="45" uuid="4319059b-9096-4c49-8275-287be93d3e6a"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <rightPen lineWidth="1.0" lineStyle="Solid"/> | |||
| </box> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| <font fontName="微軟正黑體" size="14"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{deliveryNoteCode}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont"> | |||
| <reportElement x="13" y="0" width="220" height="50" uuid="9a440925-1bd4-4001-9b4b-7163ac27551e"> | |||
| <reportElement x="200" y="6" width="194" height="130" uuid="ed6f8ce7-e351-4eeb-9f95-49d64e7ed2dd"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Left" verticalAlignment="Top"> | |||
| <font fontName="微軟正黑體" size="38" isBold="true" isUnderline="false"/> | |||
| <box> | |||
| <pen lineWidth="1.0"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="300" isBold="true" isUnderline="false"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{shopCode}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="15" y="150" width="140" height="30" uuid="c8b9fafb-9e8b-479f-9a9f-dadda7854f95"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| </textElement> | |||
| <text><![CDATA[貨車班次: | |||
| ]]></text> | |||
| </staticText> | |||
| <textField> | |||
| <reportElement x="155" y="150" width="240" height="30" uuid="57f8e4fa-cea0-42c5-b9e5-a33f0a2710b8"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{truckNo}]]></textFieldExpression> | |||
| </textField> | |||
| <line> | |||
| <reportElement x="15" y="140" width="380" height="1" uuid="3e37c027-d6e9-4a88-b64d-58ba1dd3b22e"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| </line> | |||
| <textField textAdjust="ScaleFont"> | |||
| <reportElement x="243" y="0" width="152" height="99" uuid="ed6f8ce7-e351-4eeb-9f95-49d64e7ed2dd"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <reportElement x="14" y="6" width="186" height="130" uuid="75a47bc6-5830-4636-9c62-1285163bf0b6"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <pen lineWidth="4.0"/> | |||
| <pen lineWidth="1.0" lineStyle="Solid"/> | |||
| <topPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <leftPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <rightPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="100" isBold="true" isUnderline="false"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{shopCodeAbbr}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont"> | |||
| <reportElement x="13" y="50" width="220" height="49" uuid="75a47bc6-5830-4636-9c62-1285163bf0b6"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Left" verticalAlignment="Top"> | |||
| <font fontName="微軟正黑體" size="22" isBold="true" isUnderline="false"/> | |||
| <font fontName="微軟正黑體" size="30" isBold="true" isUnderline="false"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{shopName}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="15" y="106" width="140" height="30" uuid="0ccaeebc-681b-449e-b547-97fc86c35662"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <reportElement x="14" y="136" width="55" height="30" uuid="0ccaeebc-681b-449e-b547-97fc86c35662"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <pen lineWidth="0.25" lineStyle="Solid"/> | |||
| <topPen lineWidth="0.0" lineColor="#000000"/> | |||
| <leftPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <rightPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| </box> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| </textElement> | |||
| <text><![CDATA[箱數:]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement x="275" y="106" width="120" height="30" uuid="05bc180b-a58d-4ad8-95f6-bc3090ee2c2d"> | |||
| <reportElement x="170" y="136" width="120" height="30" uuid="05bc180b-a58d-4ad8-95f6-bc3090ee2c2d"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid"/> | |||
| <rightPen lineWidth="1.0" lineStyle="Solid"/> | |||
| </box> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| </textElement> | |||
| <text><![CDATA[ 箱(蛋類除外)]]></text> | |||
| </staticText> | |||
| <textField isBlankWhenNull="true"> | |||
| <reportElement x="155" y="106" width="40" height="30" uuid="dab335a5-e253-498c-a9bf-b9707d8e1099"> | |||
| <reportElement x="69" y="136" width="40" height="30" uuid="dab335a5-e253-498c-a9bf-b9707d8e1099"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font size="16"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{cartonIndex}]]></textFieldExpression> | |||
| </textField> | |||
| <textField evaluationTime="Report" isBlankWhenNull="true"> | |||
| <reportElement x="230" y="106" width="40" height="30" uuid="66d50bad-7b39-49c1-b127-4d763133ee0c"> | |||
| <reportElement x="130" y="136" width="40" height="30" uuid="66d50bad-7b39-49c1-b127-4d763133ee0c"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font size="16"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{cartonTotal}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="195" y="106" width="35" height="30" uuid="73c18ae5-a07b-4215-a753-fa72d6db87eb"> | |||
| <reportElement x="109" y="136" width="21" height="30" uuid="73c18ae5-a07b-4215-a753-fa72d6db87eb"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16"/> | |||
| </textElement> | |||
| <text><![CDATA[/]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement x="290" y="136" width="104" height="19" uuid="c8b9fafb-9e8b-479f-9a9f-dadda7854f95"> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <pen lineWidth="1.0"/> | |||
| <topPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <leftPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <bottomPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <rightPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="14"/> | |||
| </textElement> | |||
| <text><![CDATA[車線 | |||
| ]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont"> | |||
| <reportElement x="290" y="155" width="104" height="86" uuid="57f8e4fa-cea0-42c5-b9e5-a33f0a2710b8"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <box> | |||
| <pen lineWidth="1.0" lineStyle="Solid"/> | |||
| <topPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <leftPen lineWidth="0.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <bottomPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| <rightPen lineWidth="1.0" lineStyle="Solid" lineColor="#000000"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="40" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{truckNo}]]></textFieldExpression> | |||
| </textField> | |||
| </band> | |||
| </detail> | |||
| </jasperReport> | |||
| @@ -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: | |||
| @@ -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; | |||
| @@ -1,5 +1,5 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!-- Created with Jaspersoft Studio version 6.17.0.final using JasperReports Library version 6.17.0-6d93193241dd8cc42629e188b94f9e0bc5722efd --> | |||
| <!-- Created with Jaspersoft Studio version 6.20.6.final using JasperReports Library version 6.20.6-5c96b6aa8a39ac1dc6b6bea4b81168e16dd39231 --> | |||
| <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="StockTakeVarianceReport" pageWidth="842" pageHeight="595" orientation="Landscape" columnWidth="802" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="f00b4a24-af11-4263-9e82-3966acf01017"> | |||
| <parameter name="stockSubCategory" class="java.lang.String"> | |||
| <defaultValueExpression><![CDATA["stockSubCategory"]]></defaultValueExpression> | |||
| @@ -65,10 +65,11 @@ | |||
| <field name="variance" class="java.lang.String"/> | |||
| <field name="variancePercentage" class="java.lang.String"/> | |||
| <field name="totalStockTakeQty" class="java.lang.String"/> | |||
| <field name="type" class="java.lang.String"/> | |||
| <group name="Group1" keepTogether="true" preventOrphanFooter="true"> | |||
| <groupExpression><![CDATA[$F{itemNo}]]></groupExpression> | |||
| <groupHeader> | |||
| <band height="19"> | |||
| <band height="18"> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="10" y="0" width="579" height="18" uuid="89a0d4b3-860b-4fa9-b8b4-cb1d9a19b053"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Top" markup="none"> | |||
| @@ -235,7 +236,7 @@ | |||
| <columnHeader> | |||
| <band height="20"> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="10" y="1" width="118" height="18" isPrintInFirstWholeBand="true" uuid="12b01171-4800-444d-b50a-e9b204f526a7"> | |||
| <reportElement isPrintRepeatedValues="false" x="10" y="1" width="100" height="18" isPrintInFirstWholeBand="true" uuid="12b01171-4800-444d-b50a-e9b204f526a7"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -246,7 +247,7 @@ | |||
| <text><![CDATA[批號]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="130" y="1" width="98" height="18" isPrintInFirstWholeBand="true" uuid="8d6b48f0-38f2-45b9-b306-9e7c81b99d5f"> | |||
| <reportElement isPrintRepeatedValues="false" x="110" y="1" width="98" height="18" isPrintInFirstWholeBand="true" uuid="8d6b48f0-38f2-45b9-b306-9e7c81b99d5f"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -257,7 +258,7 @@ | |||
| <text><![CDATA[到期日]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="344" y="1" width="64" height="18" isPrintInFirstWholeBand="true" uuid="38b97df1-2013-4b2d-a48d-06015c159953"> | |||
| <reportElement isPrintRepeatedValues="false" x="324" y="1" width="64" height="18" isPrintInFirstWholeBand="true" uuid="38b97df1-2013-4b2d-a48d-06015c159953"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -268,7 +269,7 @@ | |||
| <text><![CDATA[盤點前存量]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="228" y="1" width="110" height="18" isPrintInFirstWholeBand="true" uuid="e95a755d-4ecb-4900-ac9a-3a6e3b9b3470"> | |||
| <reportElement isPrintRepeatedValues="false" x="208" y="1" width="110" height="18" isPrintInFirstWholeBand="true" uuid="e95a755d-4ecb-4900-ac9a-3a6e3b9b3470"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -290,7 +291,7 @@ | |||
| <text><![CDATA[審核時間]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="558" y="1" width="100" height="18" isPrintInFirstWholeBand="true" uuid="921c16b3-172b-43b9-a090-24d2ee88a1b2"> | |||
| <reportElement isPrintRepeatedValues="false" x="520" y="1" width="82" height="18" isPrintInFirstWholeBand="true" uuid="921c16b3-172b-43b9-a090-24d2ee88a1b2"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -301,7 +302,7 @@ | |||
| <text><![CDATA[盤盈虧百分比]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="481" y="1" width="74" height="18" isPrintInFirstWholeBand="true" uuid="5dfc210f-b576-472b-a8a2-9db870c19b92"> | |||
| <reportElement isPrintRepeatedValues="false" x="461" y="1" width="59" height="18" isPrintInFirstWholeBand="true" uuid="5dfc210f-b576-472b-a8a2-9db870c19b92"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -312,7 +313,7 @@ | |||
| <text><![CDATA[盤盈虧]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="408" y="1" width="72" height="18" isPrintInFirstWholeBand="true" uuid="1590e6c1-5f5a-46ca-84ae-a45e2d4c5a0b"> | |||
| <reportElement isPrintRepeatedValues="false" x="388" y="1" width="72" height="18" isPrintInFirstWholeBand="true" uuid="1590e6c1-5f5a-46ca-84ae-a45e2d4c5a0b"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -327,12 +328,23 @@ | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| </line> | |||
| <staticText> | |||
| <reportElement isPrintRepeatedValues="false" x="602" y="1" width="86" height="18" isPrintInFirstWholeBand="true" uuid="0b8d0574-7a70-46f4-a622-384e0c32f0de"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <text><![CDATA[類型]]></text> | |||
| </staticText> | |||
| </band> | |||
| </columnHeader> | |||
| <detail> | |||
| <band height="18" splitType="Stretch"> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="130" y="0" width="98" height="18" uuid="608e1ba1-d37b-492e-a6c5-c8e97dfaf14a"> | |||
| <reportElement x="110" y="0" width="98" height="18" uuid="608e1ba1-d37b-492e-a6c5-c8e97dfaf14a"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| @@ -341,7 +353,7 @@ | |||
| <textFieldExpression><![CDATA[$F{expiryDate}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="10" y="0" width="118" height="18" uuid="1d7293e1-dab1-473e-bdb9-f13cc2b29e19"> | |||
| <reportElement x="10" y="0" width="100" height="18" uuid="1d7293e1-dab1-473e-bdb9-f13cc2b29e19"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| </reportElement> | |||
| @@ -351,7 +363,7 @@ | |||
| <textFieldExpression><![CDATA[$F{lotNo}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="344" y="0" width="64" height="18" uuid="48601d57-e240-4390-9ec7-71c77773ee86"> | |||
| <reportElement x="324" y="0" width="64" height="18" uuid="48601d57-e240-4390-9ec7-71c77773ee86"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| @@ -360,7 +372,7 @@ | |||
| <textFieldExpression><![CDATA[$F{currentBookBalance}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="228" y="0" width="110" height="18" uuid="f8664cfc-0eb6-497a-bce6-2171e3d9e43a"> | |||
| <reportElement x="208" y="0" width="110" height="18" uuid="f8664cfc-0eb6-497a-bce6-2171e3d9e43a"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| @@ -378,7 +390,7 @@ | |||
| <textFieldExpression><![CDATA[$F{stockTakeDate}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="408" y="0" width="72" height="18" uuid="02d11283-5166-45fd-a900-6ae62315ac0a"> | |||
| <reportElement x="388" y="0" width="72" height="18" uuid="02d11283-5166-45fd-a900-6ae62315ac0a"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| @@ -387,7 +399,7 @@ | |||
| <textFieldExpression><![CDATA[$F{stockTakeQty}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="481" y="0" width="74" height="18" uuid="68b8a311-ac96-4df6-9b9f-fb0db60c8a2d"> | |||
| <reportElement x="461" y="0" width="59" height="18" uuid="68b8a311-ac96-4df6-9b9f-fb0db60c8a2d"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| @@ -396,7 +408,7 @@ | |||
| <textFieldExpression><![CDATA[$F{variance}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="558" y="0" width="100" height="18" uuid="c49f3615-0417-4724-a048-127fafce1d10"> | |||
| <reportElement x="520" y="0" width="82" height="18" uuid="c49f3615-0417-4724-a048-127fafce1d10"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| @@ -404,6 +416,15 @@ | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{variancePercentage}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="602" y="0" width="86" height="18" uuid="ce9b9eb3-2260-4b06-852b-21fc42b56e9a"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{type}]]></textFieldExpression> | |||
| </textField> | |||
| </band> | |||
| </detail> | |||
| </jasperReport> | |||