diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt index e53ad3d..f0de9b9 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt @@ -1,8 +1,11 @@ package com.ffii.fpsms.modules.jobOrder.service +import com.ffii.core.support.JdbcDao import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository 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.OnPackQrJobOrderRequest +import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import org.springframework.stereotype.Service import java.awt.Color import java.awt.Font @@ -32,6 +35,8 @@ data class BitmapResult(val bytes: ByteArray, val width: Int) @Service open class PlasticBagPrinterService( val jobOrderRepository: JobOrderRepository, + private val jdbcDao: JdbcDao, + private val stockInLineRepository: StockInLineRepository, ) { fun generatePrintJobBundle( @@ -142,6 +147,65 @@ open class PlasticBagPrinterService( return baos.toByteArray() } + fun generateOnPackQrZip(jobOrders: List): ByteArray { + val normalizedJobOrders = jobOrders + .map { + OnPackQrJobOrderRequest( + jobOrderId = it.jobOrderId, + itemCode = it.itemCode.trim(), + ) + } + .filter { it.jobOrderId > 0 && it.itemCode.isNotBlank() } + + require(normalizedJobOrders.isNotEmpty()) { "No job orders provided" } + + val normalizedCodes = normalizedJobOrders + .map { it.itemCode } + .distinct() + + val sql = """ + select code, filename + from onpack_qr + where code in (:itemCodes) + order by code asc + """.trimIndent() + + val rows = jdbcDao.queryForList( + sql, + mapOf("itemCodes" to normalizedCodes), + ) + + require(rows.isNotEmpty()) { "No OnPack QR records found for the selected date" } + + val filenameByCode = rows.associate { row -> + row["code"]?.toString()?.trim().orEmpty() to row["filename"]?.toString()?.trim().orEmpty() + } + + val baos = ByteArrayOutputStream() + ZipOutputStream(baos).use { zos -> + val addedEntries = linkedSetOf() + normalizedJobOrders.forEach { jobOrder -> + val filename = filenameByCode[jobOrder.itemCode].orEmpty() + if (filename.isBlank()) return@forEach + + val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(jobOrder.jobOrderId) + ?: return@forEach + val itemId = stockInLine.item?.id ?: return@forEach + val stockInLineId = stockInLine.id ?: return@forEach + + val qrContent = """{"itemId": $itemId, "stockInLineId": $stockInLineId}""" + val bmp = createQrCodeBitmap(qrContent, 600) + val zipEntryName = buildUniqueZipEntryName(filename, addedEntries) + if (!addedEntries.add(zipEntryName)) return@forEach + addToZip(zos, zipEntryName, bmp.bytes) + } + + require(addedEntries.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } + } + + return baos.toByteArray() + } + private fun createMonochromeBitmap(text: String, targetHeight: Int): BitmapResult { // Step 1: Measure text width with temporary image val tempImg = BufferedImage(1, 1, BufferedImage.TYPE_BYTE_BINARY) @@ -208,6 +272,73 @@ open class PlasticBagPrinterService( } } + fun checkPrinterConnection( + printerType: String, + printerIp: String?, + printerPort: Int?, + labelCom: String?, + ): Pair { + return when (printerType.lowercase()) { + "dataflex" -> checkTcpPrinter(printerIp, printerPort ?: 3008, "DataFlex") + "laser" -> checkTcpPrinter(printerIp, printerPort ?: 45678, "Laser") + "label" -> { + val comPort = labelCom?.trim().orEmpty() + if (comPort.isBlank()) { + false to "Label printer COM port is not configured" + } else { + checkSerialPrinter(comPort) + } + } + else -> false to "Unsupported printer type: $printerType" + } + } + + private fun checkTcpPrinter(printerIp: String?, port: Int, printerName: String): Pair { + val ip = printerIp?.trim().orEmpty() + if (ip.isBlank()) { + return false to "$printerName IP is not configured" + } + + return try { + Socket().use { socket -> + socket.connect(InetSocketAddress(ip, port), 3000) + true to "$printerName connected" + } + } catch (e: SocketTimeoutException) { + false to "$printerName connection timed out" + } catch (e: ConnectException) { + false to "$printerName connection refused" + } catch (e: Exception) { + false to "$printerName connection failed: ${e.message}" + } + } + + private fun checkSerialPrinter(comPort: String): Pair { + val normalizedPort = comPort.trim().uppercase() + val osName = System.getProperty("os.name")?.lowercase().orEmpty() + + if (!osName.contains("win")) { + return false to "Label printer COM check is only supported on Windows" + } + + return try { + val process = ProcessBuilder("cmd", "/c", "mode", normalizedPort) + .redirectErrorStream(true) + .start() + + val output = process.inputStream.bufferedReader().use { it.readText() } + val exitCode = process.waitFor() + + if (exitCode == 0) { + true to "Label printer connected on $normalizedPort" + } else { + false to (output.trim().ifBlank { "Label printer $normalizedPort not available" }) + } + } catch (e: Exception) { + false to "Label printer check failed: ${e.message}" + } + } + private fun createQrCodeBitmap(content: String, contentSize: Int, totalSize: Int = contentSize + 80): BitmapResult { if (totalSize < contentSize) throw IllegalArgumentException("totalSize must be >= contentSize") @@ -272,6 +403,34 @@ open class PlasticBagPrinterService( zos.closeEntry() } + private fun sanitizeFilePart(value: String): String { + return value.replace(Regex("""[\\/:*?"<>|]"""), "_") + } + + private fun buildUniqueZipEntryName(filename: String, existingEntries: Set): String { + val sanitized = sanitizeFilePart(filename) + if (sanitized.isBlank()) { + return buildUniqueZipEntryName("qr.bmp", existingEntries) + } + + if (!existingEntries.contains(sanitized)) { + return sanitized + } + + val dotIndex = sanitized.lastIndexOf('.') + val baseName = if (dotIndex > 0) sanitized.substring(0, dotIndex) else sanitized + val extension = if (dotIndex > 0) sanitized.substring(dotIndex) else "" + + var counter = 2 + while (true) { + val candidate = "${baseName}_${counter}${extension}" + if (!existingEntries.contains(candidate)) { + return candidate + } + counter++ + } + } + // ──────────────────────────────────────────────── // The rest of your printer communication methods remain unchanged // ──────────────────────────────────────────────── diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt index 1c15144..80c49e8 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt @@ -4,6 +4,9 @@ import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService 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.Laser2Request +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrDownloadRequest +import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusRequest +import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusResponse import jakarta.servlet.http.HttpServletResponse import org.springframework.http.HttpHeaders import org.springframework.web.bind.annotation.* @@ -16,6 +19,50 @@ class PlasticBagPrinterController( private val plasticBagPrinterService: PlasticBagPrinterService, ) { + @PostMapping("/check-printer") + fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity { + val (connected, message) = plasticBagPrinterService.checkPrinterConnection( + printerType = request.printerType, + printerIp = request.printerIp, + printerPort = request.printerPort, + labelCom = request.labelCom, + ) + + val body = PrinterStatusResponse( + connected = connected, + message = message, + ) + + return if (connected) { + ResponseEntity.ok(body) + } else { + ResponseEntity.status(503).body(body) + } + } + + @PostMapping("/download-onpack-qr") + fun downloadOnPackQr( + @RequestBody request: OnPackQrDownloadRequest, + response: HttpServletResponse, + ) { + try { + val zipBytes = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) + response.contentType = "application/zip" + response.setHeader( + HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"onpack_qr_codes.zip\"" + ) + response.setContentLength(zipBytes.size) + response.outputStream.write(zipBytes) + response.outputStream.flush() + } catch (e: IllegalArgumentException) { + response.status = HttpServletResponse.SC_BAD_REQUEST + response.contentType = "text/plain;charset=UTF-8" + response.writer.write(e.message ?: "Invalid request") + response.writer.flush() + } + } + /** * Test API to generate and download the printer job files as a ZIP. * ONPACK2030 diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt index d701116..2bc2940 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt @@ -31,4 +31,25 @@ data class Laser2Request( val textChannel4: String? = null, val text3ObjectName: String? = "Text3", val text4ObjectName: String? = "Text4" +) + +data class PrinterStatusRequest( + val printerType: String, + val printerIp: String? = null, + val printerPort: Int? = null, + val labelCom: String? = null, +) + +data class PrinterStatusResponse( + val connected: Boolean, + val message: String, +) + +data class OnPackQrDownloadRequest( + val jobOrders: List, +) + +data class OnPackQrJobOrderRequest( + val jobOrderId: Long, + val itemCode: String, ) \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260310_fai/01_onpack.sql b/src/main/resources/db/changelog/changes/20260310_fai/01_onpack.sql new file mode 100644 index 0000000..7e6bbeb --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260310_fai/01_onpack.sql @@ -0,0 +1,10 @@ +--liquibase formatted sql +--changeset fai:onpack_qr + +CREATE TABLE `onpack_qr` ( + `code` varchar(100) NOT NULL, + `filename` varchar(200) NOT NULL, + PRIMARY KEY (`code`,`filename`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +INSERT INTO `onpack_qr` VALUES ('PP1074','7ca95d6d3bac41260fd574ef1f3bdeee.bmp'),('PP1082','0f18c99102e492558b58881b6b870bff.bmp'),('PP1137','d52f73249ac72b3e98cc53690d9f32a9.bmp'),('PP1144','0f4099201cb4a745d7cd4060b265b319.bmp'),('PP1152','9a67037ccb6424b1f03074cf4d481756.bmp'),('PP1156','729613796bffee0191efa65cdb1ac56a.bmp'),('PP1178','d01a522c4b1469bb4208f8341253a29d.bmp'),('PP1181','6486634fee7e1115dceb242881598c84.bmp'),('PP1185','4759a46839b9c83b759c443c346b2925.bmp'),('PP1185','c8ad8bf83d9b34fd75dadc0a380920bc.bmp'),('PP1213','5efebf816c4a7900e9958a8f770b5df1.bmp'),('PP1214','b31bc80a6b29a4886afae778c69cf8f6.bmp'),('PP1216','9f51ebf7d98f4b1235d5be8a2e107f20.bmp'),('PP1217','55cb367a93c36a4658a031ef6d90a043.bmp'),('PP1234','264471bab05dd256ff27cde6dc9fc673.bmp'),('PP2214','d401cf78e5cc3a1d592571ceb42da22a.bmp'),('PP2215','d8dc55bf3e2bf26aa121caebbdde8c2d.bmp'),('PP2243','0cea359e7334fd827579af62b7141552.bmp'),('PP2331','d532731e132cb2207cd67ef2944b4001.bmp');