Browse Source

no message

master
Fai Luk 1 day ago
parent
commit
5c07fbd3eb
9 changed files with 312 additions and 0 deletions
  1. +9
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  2. +197
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  3. +37
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt
  4. +14
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt
  5. +7
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt
  6. +10
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt
  7. +14
    -0
      src/main/java/com/ffii/fpsms/py/PyController.kt
  8. +13
    -0
      src/main/resources/db/changelog/changes/20260326_laser_print/01_laser_print_settings.sql
  9. +11
    -0
      src/main/resources/db/changelog/changes/20260326_laser_print/02_laser_print_item_codes.sql

+ 9
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java View File

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

}

+ 197
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt View File

@@ -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<PyJobOrderListItem> {
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<String> {
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<Boolean, String, String> {
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,


+ 37
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt View File

@@ -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<List<PyJobOrderListItem>> {
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<LaserBag2SendResponse> {
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<PrinterStatusResponse> {
val (connected, message) = plasticBagPrinterService.checkPrinterConnection(


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt View File

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

+ 7
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt View File

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

+ 10
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt View File

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

+ 14
- 0
src/main/java/com/ffii/fpsms/py/PyController.kt View File

@@ -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<List<PyJobOrderListItem>> {
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


+ 13
- 0
src/main/resources/db/changelog/changes/20260326_laser_print/01_laser_print_settings.sql View File

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

+ 11
- 0
src/main/resources/db/changelog/changes/20260326_laser_print/02_laser_print_item_codes.sql View File

@@ -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'
);

Loading…
Cancel
Save