From 5c07fbd3eb72158000005357c96f59ff8f903b4f Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Thu, 26 Mar 2026 02:01:03 +0800 Subject: [PATCH] no message --- .../fpsms/modules/common/SettingNames.java | 9 + .../service/PlasticBagPrinterService.kt | 197 ++++++++++++++++++ .../web/PlasticBagPrinterController.kt | 37 ++++ .../web/model/LaserBag2SendRequest.kt | 14 ++ .../web/model/LaserBag2SendResponse.kt | 7 + .../web/model/LaserBag2SettingsResponse.kt | 10 + .../java/com/ffii/fpsms/py/PyController.kt | 14 ++ .../01_laser_print_settings.sql | 13 ++ .../02_laser_print_item_codes.sql | 11 + 9 files changed, 312 insertions(+) create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt create mode 100644 src/main/resources/db/changelog/changes/20260326_laser_print/01_laser_print_settings.sql create mode 100644 src/main/resources/db/changelog/changes/20260326_laser_print/02_laser_print_item_codes.sql diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index df59ecb..23dea74 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -90,4 +90,13 @@ public abstract class SettingNames { public static final String LCTS_FLOOR = "LCTS.floor"; + /** Laser marking (Bag2.py TCP protocol): host for GET/POST defaults */ + public static final String LASER_PRINT_HOST = "LASER_PRINT.host"; + + /** Laser marking TCP port (default 45678) */ + public static final String LASER_PRINT_PORT = "LASER_PRINT.port"; + + /** Comma-separated BOM item codes shown on /laserPrint job list (e.g. PP1175); blank = no filter (all packaging JOs) */ + public static final String LASER_PRINT_ITEM_CODES = "LASER_PRINT.itemCodes"; + } 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 f81fc8f..faeae19 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,12 +1,20 @@ package com.ffii.fpsms.modules.jobOrder.service import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.jobOrder.entity.JobOrder import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse 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.settings.service.SettingsService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import com.ffii.fpsms.py.PyJobOrderListItem import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.awt.Color import java.awt.Font import java.awt.Graphics2D @@ -32,6 +40,7 @@ import java.net.ConnectException import java.net.SocketTimeoutException import org.springframework.core.io.ClassPathResource import org.slf4j.LoggerFactory +import java.time.LocalDate // Data class to store bitmap bytes + width (for XML) data class BitmapResult(val bytes: ByteArray, val width: Int) @@ -41,9 +50,197 @@ open class PlasticBagPrinterService( val jobOrderRepository: JobOrderRepository, private val jdbcDao: JdbcDao, private val stockInLineRepository: StockInLineRepository, + private val settingsService: SettingsService, ) { private val logger = LoggerFactory.getLogger(javaClass) + companion object { + private const val DEFAULT_LASER_BAG2_HOST = "192.168.18.77" + private const val DEFAULT_LASER_BAG2_PORT = 45678 + private const val DEFAULT_LASER_ITEM_CODES = "PP1175" + private const val PACKAGING_PROCESS_NAME = "包裝" + } + + fun getLaserBag2Settings(): LaserBag2SettingsResponse { + val hostRaw = settingsService.findByName(SettingNames.LASER_PRINT_HOST) + .map { it.value } + .orElse(null) + ?.trim() + val host = if (hostRaw.isNullOrBlank()) DEFAULT_LASER_BAG2_HOST else hostRaw + val portStr = settingsService.findByName(SettingNames.LASER_PRINT_PORT) + .map { it.value } + .orElse(null) + ?.trim() + .orEmpty() + val port = portStr.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT + val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES) + .map { it.value?.trim() ?: "" } + .orElse(DEFAULT_LASER_ITEM_CODES) + return LaserBag2SettingsResponse(host = host, port = port, itemCodes = itemCodes) + } + + /** + * Same criteria as [com.ffii.fpsms.py.PyController.listJobOrders], filtered by [SettingNames.LASER_PRINT_ITEM_CODES] + * (comma-separated item codes). Blank / whitespace-only setting value means no filter (all packaging job orders). + */ + @Transactional(readOnly = true) + open fun listLaserPrintJobOrders(planStart: LocalDate): List { + val dayStart = planStart.atStartOfDay() + val dayEndExclusive = planStart.plusDays(1).atStartOfDay() + val orders = jobOrderRepository.findByDeletedFalseAndPlanStartBetweenAndBomProcessNameOrderByIdAsc( + dayStart, + dayEndExclusive, + PACKAGING_PROCESS_NAME, + ) + val opt = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES) + val raw = if (opt.isPresent) opt.get().value else null + val allowed = when { + raw == null -> parseLaserItemCodeFilters(DEFAULT_LASER_ITEM_CODES) + raw.trim().isEmpty() -> emptySet() + else -> parseLaserItemCodeFilters(raw.trim()) + } + val filtered = if (allowed.isEmpty()) { + orders + } else { + orders.filter { jo -> + val code = (jo.bom?.item?.code ?: jo.bom?.code)?.trim().orEmpty() + allowed.contains(code) + } + } + return filtered.map { jo -> toPyJobOrderListItem(jo) } + } + + private fun parseLaserItemCodeFilters(raw: String?): Set { + if (raw.isNullOrBlank()) return emptySet() + return raw.split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .toSet() + } + + private fun toPyJobOrderListItem(jo: JobOrder): PyJobOrderListItem { + val itemCode = jo.bom?.item?.code ?: jo.bom?.code + val itemName = jo.bom?.name ?: jo.bom?.item?.name + val itemId = jo.bom?.item?.id + val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } + val stockInLineId = stockInLine?.id + val lotNo = stockInLine?.lotNo + return PyJobOrderListItem( + id = jo.id!!, + code = jo.code, + planStart = jo.planStart, + itemCode = itemCode, + itemName = itemName, + reqQty = jo.reqQty, + stockInLineId = stockInLineId, + itemId = itemId, + lotNo = lotNo, + ) + } + + /** + * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. + */ + fun sendLaserBag2Job(request: LaserBag2SendRequest): LaserBag2SendResponse { + val ip = (request.printerIp?.trim()?.takeIf { it.isNotEmpty() } + ?: resolveLaserBag2Host()).trim() + val port = request.printerPort ?: resolveLaserBag2Port() + val first = sendLaserBag2TcpOnce( + ip = ip, + port = port, + itemId = request.itemId, + stockInLineId = request.stockInLineId, + itemCode = request.itemCode, + itemName = request.itemName, + ) + if (first.first) { + return LaserBag2SendResponse(success = true, message = first.second, payloadSent = first.third) + } + val second = sendLaserBag2TcpOnce( + ip = ip, + port = port, + itemId = request.itemId, + stockInLineId = request.stockInLineId, + itemCode = request.itemCode, + itemName = request.itemName, + ) + return LaserBag2SendResponse( + success = second.first, + message = second.second, + payloadSent = second.third, + ) + } + + private fun resolveLaserBag2Host(): String { + val v = settingsService.findByName(SettingNames.LASER_PRINT_HOST) + .map { it.value } + .orElse(null) + ?.trim() + .orEmpty() + return v.ifBlank { DEFAULT_LASER_BAG2_HOST } + } + + private fun resolveLaserBag2Port(): Int { + val v = settingsService.findByName(SettingNames.LASER_PRINT_PORT) + .map { it.value } + .orElse(null) + ?.trim() + .orEmpty() + return v.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT + } + + private fun sendLaserBag2TcpOnce( + ip: String, + port: Int, + itemId: Long?, + stockInLineId: Long?, + itemCode: String?, + itemName: String?, + ): Triple { + val codeStr = (itemCode ?: "").trim().replace(";", ",") + val nameStr = (itemName ?: "").trim().replace(";", ",") + val payload = if (itemId != null && stockInLineId != null) { + "{\"itemID\":$itemId,\"stockInLineId\":$stockInLineId};$codeStr;$nameStr;;" + } else { + "0;$codeStr;$nameStr;;" + } + val bytes = payload.toByteArray(StandardCharsets.UTF_8) + var socket: Socket? = null + try { + socket = Socket() + socket.soTimeout = 3000 + socket.connect(InetSocketAddress(ip, port), 3000) + val out = socket.getOutputStream() + out.write(bytes) + out.flush() + socket.soTimeout = 500 + try { + val buf = ByteArray(4096) + val n = socket.getInputStream().read(buf) + if (n > 0) { + val ack = String(buf, 0, n, StandardCharsets.UTF_8).trim().lowercase() + if (ack.contains("receive") && !ack.contains("invalid")) { + return Triple(true, "已送出激光機:$payload(已確認)", payload) + } + } + } catch (_: SocketTimeoutException) { + // Same as Python: ignore read timeout, treat as sent + } + return Triple(true, "已送出激光機:$payload", payload) + } catch (e: ConnectException) { + return Triple(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload) + } catch (e: SocketTimeoutException) { + return Triple(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload) + } catch (e: Exception) { + return Triple(false, "激光機送出失敗:${e.message}", payload) + } finally { + try { + socket?.close() + } catch (_: Exception) { + } + } + } + fun generatePrintJobBundle( itemCode: String, lotNo: String, 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 6a97919..97641df 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,11 +4,17 @@ 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.LaserBag2SendRequest +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse +import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse 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 com.ffii.fpsms.py.PyJobOrderListItem import jakarta.servlet.http.HttpServletResponse +import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.HttpHeaders +import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* import java.time.LocalDate import org.springframework.http.ResponseEntity @@ -19,6 +25,37 @@ class PlasticBagPrinterController( private val plasticBagPrinterService: PlasticBagPrinterService, ) { + /** System defaults + current values from [LASER_PRINT.host] / [LASER_PRINT.port] / [LASER_PRINT.itemCodes] (see Liquibase). */ + @GetMapping("/laser-bag2-settings") + fun getLaserBag2Settings(): LaserBag2SettingsResponse { + return plasticBagPrinterService.getLaserBag2Settings() + } + + /** + * Job orders for /laserPrint: same as GET /py/job-orders but filtered by [LASER_PRINT.itemCodes] (comma-separated). + * Blank itemCodes setting = no filter (all 包裝 job orders for the day). + */ + @GetMapping("/laser-job-orders") + fun listLaserJobOrders( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) planStart: LocalDate?, + ): ResponseEntity> { + val date = planStart ?: LocalDate.now() + return ResponseEntity.ok(plasticBagPrinterService.listLaserPrintJobOrders(date)) + } + + /** + * Bag2.py laser TCP protocol: `{"itemID":n,"stockInLineId":m};code;name;;` or `0;code;name;;` + */ + @PostMapping("/print-laser-bag2") + fun printLaserBag2(@RequestBody request: LaserBag2SendRequest): ResponseEntity { + val resp = plasticBagPrinterService.sendLaserBag2Job(request) + return if (resp.success) { + ResponseEntity.ok(resp) + } else { + ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(resp) + } + } + @PostMapping("/check-printer") fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity { val (connected, message) = plasticBagPrinterService.checkPrinterConnection( diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt new file mode 100644 index 0000000..c135a19 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt @@ -0,0 +1,14 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +/** + * Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8). + * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. + */ +data class LaserBag2SendRequest( + val itemId: Long? = null, + val stockInLineId: Long? = null, + val itemCode: String? = null, + val itemName: String? = null, + val printerIp: String? = null, + val printerPort: Int? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt new file mode 100644 index 0000000..07c42df --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt @@ -0,0 +1,7 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +data class LaserBag2SendResponse( + val success: Boolean, + val message: String, + val payloadSent: String? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt new file mode 100644 index 0000000..673e3a1 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt @@ -0,0 +1,10 @@ +package com.ffii.fpsms.modules.jobOrder.web.model + +/** + * @param itemCodes Comma-separated item codes for the laser job list filter (e.g. `PP1175` or `PP1175,AB123`). Empty string means no filter (show all packaging job orders). + */ +data class LaserBag2SettingsResponse( + val host: String, + val port: Int, + val itemCodes: String, +) diff --git a/src/main/java/com/ffii/fpsms/py/PyController.kt b/src/main/java/com/ffii/fpsms/py/PyController.kt index f020ae6..2fc88d9 100644 --- a/src/main/java/com/ffii/fpsms/py/PyController.kt +++ b/src/main/java/com/ffii/fpsms/py/PyController.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.py import com.ffii.fpsms.modules.jobOrder.entity.JobOrder import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository +import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import org.springframework.format.annotation.DateTimeFormat import org.springframework.http.ResponseEntity @@ -21,6 +22,7 @@ import java.time.LocalDateTime open class PyController( private val jobOrderRepository: JobOrderRepository, private val stockInLineRepository: StockInLineRepository, + private val plasticBagPrinterService: PlasticBagPrinterService, ) { companion object { private const val PACKAGING_PROCESS_NAME = "包裝" @@ -48,6 +50,18 @@ open class PyController( return ResponseEntity.ok(list) } + /** + * Same as [listJobOrders] but filtered by system setting [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_ITEM_CODES] + * (comma-separated item codes). Public — no login (same as /py/job-orders). + */ + @GetMapping("/laser-job-orders") + open fun listLaserJobOrders( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) planStart: LocalDate?, + ): ResponseEntity> { + val date = planStart ?: LocalDate.now() + return ResponseEntity.ok(plasticBagPrinterService.listLaserPrintJobOrders(date)) + } + private fun toListItem(jo: JobOrder): PyJobOrderListItem { val itemCode = jo.bom?.item?.code ?: jo.bom?.code val itemName = jo.bom?.name ?: jo.bom?.item?.name diff --git a/src/main/resources/db/changelog/changes/20260326_laser_print/01_laser_print_settings.sql b/src/main/resources/db/changelog/changes/20260326_laser_print/01_laser_print_settings.sql new file mode 100644 index 0000000..2d3a915 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260326_laser_print/01_laser_print_settings.sql @@ -0,0 +1,13 @@ +--liquibase formatted sql +--changeset fpsms:20260326_laser_print_settings + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'LASER_PRINT.host' AS name, '192.168.18.77' AS value, 'LASER' AS category, 'string' AS type + UNION ALL + SELECT 'LASER_PRINT.port', '45678', 'LASER', 'string' +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = v.name +); diff --git a/src/main/resources/db/changelog/changes/20260326_laser_print/02_laser_print_item_codes.sql b/src/main/resources/db/changelog/changes/20260326_laser_print/02_laser_print_item_codes.sql new file mode 100644 index 0000000..eec18f0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260326_laser_print/02_laser_print_item_codes.sql @@ -0,0 +1,11 @@ +--liquibase formatted sql +--changeset fpsms:20260326_laser_print_item_codes + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT v.name, v.value, v.category, v.type +FROM ( + SELECT 'LASER_PRINT.itemCodes' AS name, 'PP1175' AS value, 'LASER' AS category, 'string' AS type +) v +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` s WHERE s.name = 'LASER_PRINT.itemCodes' +);