@@ -31,6 +31,7 @@ import java.io.InputStreamReader
import java.net.ConnectException
import java.net.SocketTimeoutException
import org.springframework.core.io.ClassPathResource
import org.slf4j.LoggerFactory
// Data class to store bitmap bytes + width (for XML)
data class BitmapResult(val bytes: ByteArray, val width: Int)
@@ -41,6 +42,7 @@ open class PlasticBagPrinterService(
private val jdbcDao: JdbcDao,
private val stockInLineRepository: StockInLineRepository,
) {
private val logger = LoggerFactory.getLogger(javaClass)
fun generatePrintJobBundle(
itemCode: String,
@@ -186,7 +188,7 @@ open class PlasticBagPrinterService(
val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds }
require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" }
val exportItems = packagingJobOrders
val exportItemsRaw = packagingJobOrders
.groupBy { it.itemCode.trim().lowercase() }
.mapNotNull { (codeLower, orders) ->
val order = orders.firstOrNull() ?: return@mapNotNull null
@@ -197,7 +199,13 @@ open class PlasticBagPrinterService(
Triple(codeLower, itemId, stockInLineId)
}
require(exportItems.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }
require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }
val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet()
val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp")
val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.first.uppercase()) }
require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" }
val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
@@ -228,6 +236,124 @@ open class PlasticBagPrinterService(
return baos.toByteArray()
}
/**
* OnPack2023 檸檬機: templates under classpath `onpack2030_2/{code}.image` with embedded QR (Static text).
* Only replaces `<Text>...</Text>` under `<Static type="StaticSrc.V1">` with JSON payload. No separate .bmp in zip.
*/
fun generateOnPackQrTextZip(jobOrders: List<OnPackQrJobOrderRequest>): 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 requestedJobOrderIds = normalizedJobOrders.map { it.jobOrderId }.distinct()
val allowedRows = jdbcDao.queryForList(
"""
SELECT DISTINCT jo.id AS jobOrderId
FROM job_order jo
JOIN bom b ON b.id = jo.bomId
JOIN bom_process bp ON bp.bomId = b.id
JOIN process p ON p.id = bp.processId
WHERE jo.id IN (:jobOrderIds)
AND jo.deleted = 0
AND p.name = :processName
""".trimIndent(),
mapOf(
"jobOrderIds" to requestedJobOrderIds,
"processName" to "包裝",
)
)
val allowedJobOrderIds = allowedRows
.mapNotNull { (it["jobOrderId"] as? Number)?.toLong() }
.toSet()
val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds }
require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" }
val exportItemsRaw = packagingJobOrders
.groupBy { it.itemCode.trim().lowercase() }
.mapNotNull { (codeLower, orders) ->
val order = orders.firstOrNull() ?: return@mapNotNull null
val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId)
?: return@mapNotNull null
val itemId = stockInLine.item?.id ?: return@mapNotNull null
val stockInLineId = stockInLine.id ?: return@mapNotNull null
Triple(codeLower, itemId, stockInLineId)
}
require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" }
val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet()
val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text")
val exportItems = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) }
require(exportItems.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" }
val baos = ByteArrayOutputStream()
ZipOutputStream(baos).use { zos ->
val addedEntries = linkedSetOf<String>()
exportItems.forEach { (codeLower, itemId, stockInLineId) ->
val imageTemplate = loadOnPack2030_2ImageTemplateOrNull(codeLower) ?: run {
logger.warn("OnPack text ZIP: missing classpath template onpack2030_2/{}.image", codeLower)
return@forEach
}
val imageFileName = "$codeLower.image"
val imageContent = withOnPackStaticQrText(codeLower, imageTemplate, itemId, stockInLineId)
if (addedEntries.add(imageFileName)) {
addToZip(zos, imageFileName, imageContent)
}
val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageTemplate).first
extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName ->
if (!addedEntries.add(bmpName)) return@forEach
val bmpBytes = loadOnPack2030_2AssetOrNull(bmpName) ?: run {
logger.warn("OnPack text ZIP: missing classpath asset onpack2030_2/{}", bmpName)
return@forEach
}
addToZip(zos, bmpName, bmpBytes)
}
val jobFileName = "$codeLower.job"
loadOnPack2030_2AssetOrNull(jobFileName)?.let { jobBytes ->
if (addedEntries.add(jobFileName)) {
addToZip(zos, jobFileName, jobBytes)
}
}
}
require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" }
}
return baos.toByteArray()
}
/**
* Returns uppercase item codes present in `onpack_qr` with the given [templateType] (`bmp` or `text`).
* Empty or NULL `template_type` is treated as `bmp` for backward compatibility.
*/
private fun codesOnPackMatchingTemplateType(codesUpper: Set<String>, templateType: String): Set<String> {
if (codesUpper.isEmpty()) return emptySet()
val normalizedType = templateType.trim().lowercase()
val sql = when (normalizedType) {
"bmp" -> """
SELECT DISTINCT UPPER(TRIM(code)) AS code
FROM onpack_qr
WHERE UPPER(TRIM(code)) IN (:codes)
AND COALESCE(NULLIF(TRIM(template_type), ''), 'bmp') = 'bmp'
""".trimIndent()
"text" -> """
SELECT DISTINCT UPPER(TRIM(code)) AS code
FROM onpack_qr
WHERE UPPER(TRIM(code)) IN (:codes)
AND LOWER(TRIM(template_type)) = 'text'
""".trimIndent()
else -> return emptySet()
}
val rows = jdbcDao.queryForList(sql, mapOf("codes" to codesUpper.toList()))
return rows.mapNotNull { it["code"]?.toString()?.trim()?.uppercase() }.toSet()
}
private fun loadOnPackImageTemplateOrNull(codeLower: String): ByteArray? {
val resourcePath = "onpack2030/${codeLower}.image"
val resource = ClassPathResource(resourcePath)
@@ -235,6 +361,31 @@ open class PlasticBagPrinterService(
return resource.inputStream.use { it.readBytes() }
}
private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? {
val resourcePath = "onpack2030_2/${codeLower}.image"
val resource = ClassPathResource(resourcePath)
if (!resource.exists()) return null
return resource.inputStream.use { it.readBytes() }
}
private fun loadOnPack2030_2AssetOrNull(fileName: String): ByteArray? {
val safe = fileName.trim().replace(Regex("""[\\/]+"""), "").ifBlank { return null }
val resource = ClassPathResource("onpack2030_2/$safe")
if (!resource.exists()) return null
return resource.inputStream.use { it.readBytes() }
}
/** Collect `<FileName>xxx.bmp</FileName>` inside each `<Logo>...</Logo>` block (decoded template XML). */
private fun extractLogoBmpFileNamesFromOnPackImageXml(xml: String): Set<String> {
val out = linkedSetOf<String>()
Regex("""(?s)<Logo[^>]*>[\s\S]*?</Logo>""").findAll(xml).forEach { block ->
Regex("""<FileName>([^<]+\.bmp)</FileName>""", RegexOption.IGNORE_CASE).findAll(block.value).forEach { m ->
out.add(m.groupValues[1].trim())
}
}
return out
}
private fun withOnPackLogo4Bmp(imageBytes: ByteArray, qrBmpFileName: String): ByteArray {
// Use ISO-8859-1 one-byte mapping so all original bytes are preserved,
// while replacing only ASCII XML fragment for LOGO_4 filename.
@@ -246,6 +397,95 @@ open class PlasticBagPrinterService(
return replaced.toByteArray(StandardCharsets.ISO_8859_1)
}
/**
* `.image` templates from Windows tools are often UTF-16 LE with BOM; decoding as ISO-8859-1 breaks
* substring/regex matching (`hasNameQr=false` while XML is valid).
*/
private fun decodeOnPackImageTemplateForTextEdit(bytes: ByteArray): Pair<String, (String) -> ByteArray> {
val bomUtf16Le = byteArrayOf(0xFF.toByte(), 0xFE.toByte())
val bomUtf16Be = byteArrayOf(0xFE.toByte(), 0xFF.toByte())
val bomUtf8 = byteArrayOf(0xEF.toByte(), 0xBB.toByte(), 0xBF.toByte())
when {
bytes.size >= 2 && bytes[0] == bomUtf16Le[0] && bytes[1] == bomUtf16Le[1] -> {
val body = bytes.copyOfRange(2, bytes.size)
val text = String(body, StandardCharsets.UTF_16LE)
return text to { s -> bomUtf16Le + s.toByteArray(StandardCharsets.UTF_16LE) }
}
bytes.size >= 2 && bytes[0] == bomUtf16Be[0] && bytes[1] == bomUtf16Be[1] -> {
val body = bytes.copyOfRange(2, bytes.size)
val text = String(body, StandardCharsets.UTF_16BE)
return text to { s -> bomUtf16Be + s.toByteArray(StandardCharsets.UTF_16BE) }
}
bytes.size >= 3 && bytes[0] == bomUtf8[0] && bytes[1] == bomUtf8[1] && bytes[2] == bomUtf8[2] -> {
val body = bytes.copyOfRange(3, bytes.size)
val text = String(body, StandardCharsets.UTF_8)
return text to { s -> bomUtf8 + s.toByteArray(StandardCharsets.UTF_8) }
}
// UTF-16 LE without BOM: "<" == 0x3C 0x00
bytes.size >= 2 && bytes[0] == 0x3C.toByte() && bytes[1] == 0x00.toByte() -> {
val text = String(bytes, StandardCharsets.UTF_16LE)
return text to { s -> s.toByteArray(StandardCharsets.UTF_16LE) }
}
else -> {
val utf8 = String(bytes, StandardCharsets.UTF_8)
return utf8 to { s -> s.toByteArray(StandardCharsets.UTF_8) }
}
}
}
private fun withOnPackStaticQrText(forCode: String, imageBytes: ByteArray, itemId: Long, stockInLineId: Long): ByteArray {
val payload = """{"itemId": $itemId, "stockInLineId": $stockInLineId}"""
val (oneByteText, encodeBack) = decodeOnPackImageTemplateForTextEdit(imageBytes)
// Must NOT use Static[^>]*StaticSrc — [^>]* already eats type='StaticSrc.V1', so StaticSrc can never match.
// Match the attribute form: <Static type='StaticSrc.V1'> or type="StaticSrc.V1"
val staticQrTextOpen =
"""<Static\s+type=(?:'|\u0022)StaticSrc\.V1(?:'|\u0022)[^>]*>\s*<Text>"""
val regexWithComment = Regex(
"""(?s)(<!--\s*2\.\s*QR\s+Code\s*-\s*Change\s+this\s+part\s+daily\s+for\s+dynamic\s+JSON\s*-->[\s\S]*?<Name>QR</Name>[\s\S]*?)($staticQrTextOpen)([^<]*)(</Text>)""",
)
val regexFallbackNameQr = Regex(
"""(?s)(<Name>QR</Name>[\s\S]*?)($staticQrTextOpen)([^<]*)(</Text>)""",
)
val afterComment = oneByteText.replace(regexWithComment, "$1$2$payload$4")
val usedComment = afterComment !== oneByteText
val replaced = if (usedComment) {
afterComment
} else {
oneByteText.replace(regexFallbackNameQr, "$1$2$payload$4")
}
if (replaced === oneByteText) {
logger.warn(
"OnPack text QR: NO regex match for code={} itemId={} stockInLineId={} templateBytes={} hasNameQr={} hasStaticSrc={} staticPatternSample={}",
forCode,
itemId,
stockInLineId,
imageBytes.size,
oneByteText.contains("<Name>QR</Name>"),
oneByteText.contains("StaticSrc.V1"),
previewOneLineForLog(oneByteText, 600),
)
}
val withQrCellWidth = replaceQrV1CellWidth67To50(replaced)
return encodeBack(withQrCellWidth)
}
/** First `<CellWidth>67</CellWidth>` inside `<QR type='QR.V1'>` → 50 (export tuning). */
private fun replaceQrV1CellWidth67To50(xml: String): String {
return Regex(
"""(?s)(<QR\s+type=['"]QR\.V1['"][^>]*>[\s\S]*?<CellWidth>)(67)(</CellWidth>)""",
).replace(xml) { m ->
"${m.groupValues[1]}50${m.groupValues[3]}"
}
}
/** Single-line preview for logs (avoids multiline noise). */
private fun previewOneLineForLog(s: String, maxLen: Int): String {
val collapsed = s.replace(Regex("\\s+"), " ").trim()
return if (collapsed.length <= maxLen) collapsed else collapsed.take(maxLen) + "…"
}
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)