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 862c8be..33b6d49 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 @@ -69,6 +69,8 @@ data class OnPackZipResult( private data class OnPackBmpExportItem( val codeLower: String, + /** ZIP / template file stem (e.g. `pp2211` or `pp2211-2` for expiry multi-JO). */ + val fileStem: String, val itemId: Long, val stockInLineId: Long, val itemCode: String, @@ -149,6 +151,19 @@ class PlasticBagPrinterService( fun laserAckLooksInvalid(ack: String?): Boolean = ack?.contains("invalid", ignoreCase = true) == true + /** Expiry 汁水機 ZIP: one file set per job order, ordered by job order no then id. */ + fun sortOnPackExportJobOrders( + orders: List, + jobOrdersById: Map, + ): List = + orders.sortedWith( + compareBy { jobOrdersById[it.jobOrderId]?.code?.trim().orEmpty() } + .thenBy { it.jobOrderId }, + ) + + fun onPackExpiryFileStem(itemCodeLower: String, indexOneBased: Int): String = + "$itemCodeLower-$indexOneBased" + /** ISO `yyyy-MM-dd`, compact `yyyyMMdd`, or already `Expiry Date yyyyMMdd`. */ fun formatLaserExpiryParam(expiryDate: String?): String { val raw = expiryDate?.trim().orEmpty() @@ -285,6 +300,36 @@ class PlasticBagPrinterService( .toSet() } + private fun toOnPackBmpExportItem( + codeLower: String, + fileStem: String, + order: OnPackQrJobOrderRequest, + expiryPrintNames: Map, + jobOrder: JobOrder?, + ): OnPackBmpExportItem? { + val stockInLine = stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(order.jobOrderId) + ?: return null + val itemId = stockInLine.item?.id ?: return null + val stockInLineId = stockInLine.id ?: return null + val jo = jobOrder ?: jobOrderRepository.findById(order.jobOrderId).orElse(null) + val baseName = jo?.bom?.name ?: stockInLine.item?.name + val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc + val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) + val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() + val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } + ?: computedName + ?: itemCode + return OnPackBmpExportItem( + codeLower = codeLower, + fileStem = fileStem, + itemId = itemId, + stockInLineId = stockInLineId, + itemCode = itemCode, + productName = productName, + planDate = jo?.planStart?.toLocalDate(), + ) + } + /** * Bag2.py [send_job_to_laser] / [send_job_to_laser_with_retry]: UTF-8 TCP payload and optional ack read. */ @@ -725,30 +770,23 @@ class PlasticBagPrinterService( emptyMap() } + val jobOrdersById = jobOrderRepository.findAllById( + packagingJobOrders.map { it.jobOrderId }.distinct(), + ).associateBy { it.id!! } 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 - val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) - val baseName = jo?.bom?.name ?: stockInLine.item?.name - val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc - val computedName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) - val itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() - val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } - ?: computedName - ?: itemCode - OnPackBmpExportItem( - codeLower, - itemId, - stockInLineId, - itemCode, - productName, - jo?.planStart?.toLocalDate(), - ) + .flatMap { (codeLower, orders) -> + val sorted = sortOnPackExportJobOrders(orders, jobOrdersById) + val selected = if (includeExpiry) sorted else listOfNotNull(sorted.firstOrNull()) + selected.mapIndexedNotNull { index, order -> + toOnPackBmpExportItem( + codeLower = codeLower, + fileStem = if (includeExpiry) onPackExpiryFileStem(codeLower, index + 1) else codeLower, + order = order, + expiryPrintNames = expiryPrintNames, + jobOrder = jobOrdersById[order.jobOrderId], + ) + } } require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } @@ -808,12 +846,12 @@ class PlasticBagPrinterService( ZipOutputStream(baos).use { zos -> val addedEntries = linkedSetOf() exportItems.forEach { item -> - val codeLower = item.codeLower + val fileStem = item.fileStem val imageTemplate = if (includeExpiry) { val master = expiryMasterImage ?: return@forEach - OnPackPp1181Master.rewriteImageBytes(master, codeLower) + OnPackPp1181Master.rewriteImageBytes(master, fileStem) } else { - loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach + loadOnPackImageTemplateOrNull(item.codeLower, forExpiry = false) ?: return@forEach } val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" @@ -821,13 +859,13 @@ class PlasticBagPrinterService( // Width = 386 + (42 * 2) = 470 // Height = 386 + (1 * 2) = 388 (~389) val bmp = createQrCodeBitmap(qrContent, contentSize = 386, horizontalPadding = 42, verticalPadding = 1) - val qrBmpFileName = "${codeLower}qr.bmp" - val imageFileName = "$codeLower.image" + val qrBmpFileName = "${fileStem}qr.bmp" + val imageFileName = "$fileStem.image" var imageContent = withOnPackLogo4Bmp(imageTemplate, qrBmpFileName) if (includeExpiry) { - val productFile = "${codeLower}Product.bmp" - val codeFile = "${codeLower}Code.bmp" - val dateFile = "${codeLower}Date.bmp" + val productFile = "${fileStem}Product.bmp" + val codeFile = "${fileStem}Code.bmp" + val dateFile = "${fileStem}Date.bmp" val productBmp = createMonochromeBitmapFixed( item.productName, ONPACK_PRODUCT_BMP_WIDTH, @@ -857,7 +895,7 @@ class PlasticBagPrinterService( } } val expiryLabel = if (includeExpiry) { - itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) + itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate) } else { null } @@ -867,7 +905,7 @@ class PlasticBagPrinterService( ONPACK_EXPIRY_BMP_WIDTH, ONPACK_EXPIRY_BMP_HEIGHT, ) - val expBmpFileName = "${codeLower}exp.bmp" + val expBmpFileName = "${fileStem}exp.bmp" imageContent = withOnPackExpiryLogo(imageContent, expBmpFileName, expBmp.width) if (addedEntries.add(expBmpFileName)) { addToZip(zos, expBmpFileName, expBmp.bytes) @@ -893,7 +931,7 @@ class PlasticBagPrinterService( addToZip(zos, bmpName, bmpBytes) } } - val jobFileName = "${codeLower}.job" + val jobFileName = "$fileStem.job" if (includeExpiry) { val masterJob = expiryMasterJob if (masterJob != null && addedEntries.add(jobFileName)) { diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt new file mode 100644 index 0000000..0ec0a81 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackExpiryZipStemTest.kt @@ -0,0 +1,32 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.fpsms.modules.jobOrder.entity.JobOrder +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class OnPackExpiryZipStemTest { + + @Test + fun expiry_file_stem_appends_one_based_index() { + assertEquals("pp2211-1", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 1)) + assertEquals("pp2211-2", PlasticBagPrinterService.onPackExpiryFileStem("pp2211", 2)) + } + + @Test + fun export_job_orders_sort_by_job_order_code_then_id() { + val jo10 = JobOrder().apply { id = 10L; code = "JO-002" } + val jo20 = JobOrder().apply { id = 20L; code = "JO-001" } + val jo30 = JobOrder().apply { id = 30L; code = "JO-001" } + val byId = mapOf(10L to jo10, 20L to jo20, 30L to jo30) + val sorted = PlasticBagPrinterService.sortOnPackExportJobOrders( + listOf( + OnPackQrJobOrderRequest(10, "PP2211"), + OnPackQrJobOrderRequest(30, "PP2211"), + OnPackQrJobOrderRequest(20, "PP2211"), + ), + byId, + ) + assertEquals(listOf(20L, 30L, 10L), sorted.map { it.jobOrderId }) + } +}