From 76742d5fe7c4c1e76405a2c97de718b6b1847db7 Mon Sep 17 00:00:00 2001 From: Fai Luk Date: Sun, 23 Aug 2026 00:11:02 +0800 Subject: [PATCH] added for the default expiry date --- .../java/com/ffii/fpsms/config/WebConfig.java | 2 +- .../jobOrder/service/OnPackPp1181Master.kt | 34 +++ .../service/OnPackTemplateFileService.kt | 215 +++++++++++++++++- .../service/PlasticBagPrinterService.kt | 181 ++++++++++++--- .../jobOrder/web/OnPackTemplateController.kt | 37 +++ .../web/PlasticBagPrinterController.kt | 58 ++--- .../web/model/OnPackTemplateModels.kt | 24 ++ .../entity/ItemDefaultShelfLifeRepository.kt | 6 + .../modules/master/entity/ItemsRepository.kt | 1 + .../service/ItemDefaultShelfLifeService.kt | 175 +++++++++++++- .../web/ItemDefaultShelfLifeController.kt | 64 ++++++ .../01_create_onpack_expiry_item_code.sql | 18 ++ .../02_seed_onpack_expiry_item_code_pp.sql | 44 ++++ .../03_add_print_name.sql | 6 + .../service/OnPackPp1181MasterTest.kt | 42 ++++ .../ItemDefaultShelfLifeServiceTest.kt | 22 ++ 16 files changed, 845 insertions(+), 84 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt create mode 100644 src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql create mode 100644 src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql create mode 100644 src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql create mode 100644 src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt diff --git a/src/main/java/com/ffii/fpsms/config/WebConfig.java b/src/main/java/com/ffii/fpsms/config/WebConfig.java index dcdf839..5ef870a 100644 --- a/src/main/java/com/ffii/fpsms/config/WebConfig.java +++ b/src/main/java/com/ffii/fpsms/config/WebConfig.java @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { registry.addMapping("/**") .allowedHeaders("*") .allowedOrigins("*") - .exposedHeaders("filename", "Content-Disposition") + .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); } diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt new file mode 100644 index 0000000..029bb6d --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt @@ -0,0 +1,34 @@ +package com.ffii.fpsms.modules.jobOrder.service + +/** + * Clone 汁水機 expiry ZIP templates from classpath [MASTER_IMAGE] / [MASTER_JOB]. + * FileName stems `pp1181*` become `{code}*`; `.job` points at the cloned `.image`. + */ +object OnPackPp1181Master { + const val MASTER_CODE = "pp1181" + const val MASTER_IMAGE = "onpack2030_exp/$MASTER_CODE.image" + const val MASTER_IMAGE_FALLBACK = "onpack2030/$MASTER_CODE.image" + const val MASTER_JOB = "onpack2030/$MASTER_CODE.job" + + fun rewriteImageXml(masterXml: String, itemCode: String): String { + val codeLower = itemCode.trim().lowercase() + require(codeLower.isNotEmpty()) { "itemCode is blank" } + return masterXml.replace(Regex("pp1181", RegexOption.IGNORE_CASE), codeLower) + } + + fun rewriteJobXml(masterXml: String, imageFileName: String): String { + val name = imageFileName.trim() + require(name.isNotEmpty()) { "imageFileName is blank" } + return masterXml.replace(Regex("""(?i)pp1181\.image"""), name) + } + + fun rewriteImageBytes(masterBytes: ByteArray, itemCode: String): ByteArray { + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) + return encodeBack(rewriteImageXml(xml, itemCode)) + } + + fun rewriteJobBytes(masterBytes: ByteArray, imageFileName: String): ByteArray { + val (xml, encodeBack) = OnPackImageTemplateCodec.decode(masterBytes) + return encodeBack(rewriteJobXml(xml, imageFileName)) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt index 49e0bcd..2dc7790 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt @@ -3,10 +3,16 @@ package com.ffii.fpsms.modules.jobOrder.service import com.ffii.core.support.JdbcDao import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFile import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFileRepository +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedItemDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import com.ffii.fpsms.modules.master.service.ItemUomService +import com.ffii.fpsms.py.PyJobOrderListMapper import org.slf4j.LoggerFactory import org.springframework.core.io.support.PathMatchingResourcePatternResolver import org.springframework.dao.DataAccessException @@ -21,6 +27,9 @@ import java.time.format.DateTimeFormatter open class OnPackTemplateFileService( private val repository: OnPackTemplateFileRepository, private val jdbcDao: JdbcDao, + private val itemsRepository: ItemsRepository, + private val itemUomService: ItemUomService, + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, ) { private val logger = LoggerFactory.getLogger(javaClass) private val resourceResolver = PathMatchingResourcePatternResolver() @@ -169,11 +178,215 @@ open class OnPackTemplateFileService( ) } + @Transactional(readOnly = true) + open fun listExpiryItemCodes(machineRaw: String?): List { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val rows = expiryCodeRows(machine) + return enrichExpiryRows(machine, rows) + } + + /** Uppercase item code → non-blank printName override for Product BMP. */ + @Transactional(readOnly = true) + open fun expiryPrintNames(machine: String = MACHINE_JUICE): Map { + val normalized = try { + normalizeMachine(machine) + } catch (_: IllegalArgumentException) { + MACHINE_JUICE + } + return expiryCodeRows(normalized) + .mapNotNull { (code, printName) -> + val name = printName?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null + code to name + } + .toMap() + } + + /** Uppercase item codes allowed in 汁水機 expiry ZIP. Falls back to folder PP* if table is empty/missing. */ + @Transactional(readOnly = true) + open fun expiryItemCodes(machine: String = MACHINE_JUICE): Set { + val normalized = try { + normalizeMachine(machine) + } catch (_: IllegalArgumentException) { + MACHINE_JUICE + } + val fromTable = try { + jdbcDao.queryForStrings( + """ + SELECT DISTINCT UPPER(TRIM(itemCode)) + FROM onpack_expiry_item_code + WHERE deleted = 0 + AND machine = :machine + AND TRIM(itemCode) <> '' + """.trimIndent(), + mapOf("machine" to normalized), + ).mapNotNull { it.trim().uppercase().takeIf { code -> code.isNotEmpty() } }.toSet() + } catch (e: DataAccessException) { + logger.warn("onpack_expiry_item_code is unavailable; using folder PP* list", e) + return if (normalized == MACHINE_JUICE) builtinImageCodes(MACHINE_JUICE) else emptySet() + } + return fromTable + } + + @Transactional + open fun addExpiryItemCode(machineRaw: String?, itemCodeRaw: String): OnPackExpiryItemCodeDto { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(itemCodeRaw) + val existing = try { + jdbcDao.queryForMap( + """ + SELECT id, deleted FROM onpack_expiry_item_code + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code + LIMIT 1 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } catch (e: DataAccessException) { + throw IllegalStateException("無法儲存品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) + } + if (existing.isPresent) { + val row = existing.get() + val deleted = (row["deleted"] as? Number)?.toInt() == 1 || row["deleted"] == true + if (deleted) { + jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET deleted = 0, modified = NOW(), modifiedBy = 'ui' + WHERE id = :id + """.trimIndent(), + mapOf("id" to row["id"]), + ) + } + } else { + jdbcDao.executeUpdate( + """ + INSERT INTO onpack_expiry_item_code + (created, createdBy, version, modified, modifiedBy, deleted, machine, itemCode) + VALUES (NOW(), 'ui', 0, NOW(), 'ui', 0, :machine, :code) + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } + return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() + } + + @Transactional + open fun updateExpiryItemCode(body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { + val machine = normalizeMachine(body.machine ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(body.itemCode) + if (body.printName != null) { + val stored = body.printName.trim().takeIf { it.isNotEmpty() }?.take(255) + try { + val n = jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET printName = :printName, modified = NOW(), modifiedBy = 'ui' + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode, "printName" to stored), + ) + if (n == 0) { + throw IllegalArgumentException("清單中沒有 $itemCode") + } + } catch (e: DataAccessException) { + throw IllegalStateException("無法儲存列印名稱。請重啟後端以執行 Liquibase(printName 欄位)。", e) + } + } + if (body.useMinus18 != null) { + itemDefaultShelfLifeService.setUseMinus18(itemCode, body.useMinus18) + } + return enrichExpiryRows(machine, listOf(itemCode to loadPrintName(machine, itemCode))).first() + } + + @Transactional + open fun removeExpiryItemCode(machineRaw: String?, itemCodeRaw: String) { + val machine = normalizeMachine(machineRaw ?: MACHINE_JUICE) + val itemCode = normalizeItemCode(itemCodeRaw) + try { + jdbcDao.executeUpdate( + """ + UPDATE onpack_expiry_item_code + SET deleted = 1, modified = NOW(), modifiedBy = 'ui' + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ) + } catch (e: DataAccessException) { + throw IllegalStateException("無法刪除品號。請重啟後端以執行 Liquibase(建立 onpack_expiry_item_code)。", e) + } + } + + private fun expiryCodeRows(machine: String): List> { + return try { + jdbcDao.queryForList( + """ + SELECT UPPER(TRIM(itemCode)) AS itemCode, printName + FROM onpack_expiry_item_code + WHERE deleted = 0 + AND machine = :machine + AND TRIM(itemCode) <> '' + ORDER BY itemCode + """.trimIndent(), + mapOf("machine" to machine), + ).mapNotNull { row -> + val code = row["itemCode"]?.toString()?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null + else code to row["printName"]?.toString() + } + } catch (e: DataAccessException) { + logger.warn("onpack_expiry_item_code printName list failed; falling back to codes only", e) + expiryItemCodes(machine).sorted().map { it to null } + } + } + + private fun loadPrintName(machine: String, itemCode: String): String? { + return try { + jdbcDao.queryForString( + """ + SELECT printName FROM onpack_expiry_item_code + WHERE machine = :machine AND UPPER(TRIM(itemCode)) = :code AND deleted = 0 + LIMIT 1 + """.trimIndent(), + mapOf("machine" to machine, "code" to itemCode), + ).trim().takeIf { it.isNotEmpty() } + } catch (_: DataAccessException) { + null + } + } + + private fun enrichExpiryRows(machine: String, rows: List>): List { + val codes = rows.map { it.first } + val items = if (codes.isEmpty()) emptyMap() + else itemsRepository.findByDeletedFalseAndCodeIn((codes + codes.map { it.lowercase() }).distinct()) + .mapNotNull { item -> + val code = item.code?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null else code to item + } + .toMap() + val shelf = itemDefaultShelfLifeService.findRowsByItemCodes(codes) + return rows.map { (code, printName) -> + val item = items[code] + val stockDesc = item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.udfudesc } + val defaultPrintName = PyJobOrderListMapper.buildDisplayItemName(item?.name, stockDesc) + val sl = shelf[code] + OnPackExpiryItemCodeDto( + machine = machine, + itemCode = code, + printName = printName?.trim()?.takeIf { it.isNotEmpty() }, + defaultPrintName = defaultPrintName, + defaultDays = sl?.defaultDays, + minus18Days = sl?.minus18Days, + useMinus18 = sl?.useMinus18 == true, + effectiveDays = sl?.let { ItemDefaultShelfLifeService.effectiveDays(it) }, + ) + } + } + private fun supportedItems(machine: String): List { val registered = registeredCodes(machine) val inDatabase = itemCodesWithImage(machine) val builtin = builtinImageCodes(machine) - return mergeSupported(registered, inDatabase, builtin) + val expiry = if (machine == MACHINE_JUICE) expiryItemCodes(MACHINE_JUICE) else emptySet() + return mergeSupported(registered + expiry, inDatabase, builtin) } private fun registeredCodes(machine: String): Set { 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 0aa946b..42a5cc1 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 @@ -62,6 +62,11 @@ import java.time.format.DateTimeFormatter // Data class to store bitmap bytes + width (for XML) data class BitmapResult(val bytes: ByteArray, val width: Int) +data class OnPackZipResult( + val bytes: ByteArray, + val skippedWithoutExpiry: List = emptyList(), +) + private data class OnPackBmpExportItem( val codeLower: String, val itemId: Long, @@ -652,18 +657,15 @@ class PlasticBagPrinterService( /** * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. * Always swaps LOGO_4 to the generated QR BMP. - * When [includeExpiry] is true, also generates product/code/date/expiry BMPs (`{code}Product.bmp`, - * `{code}Code.bmp`, `{code}Date.bmp` from [printDate] / bagPrint filter, `{code}exp.bmp`). - * When [includeExpiry] is true, writes `{code}exp.bmp` into the template expiry slot - * (`LOGO_5` / `LOGO_EXP` if present, otherwise injects `LOGO_EXP`). Prefers - * `onpack2030_exp/{code}.image` over the main `onpack2030` template. - * Old ZIP callers must pass false; they keep `onpack2030` unchanged. + * When [includeExpiry] is true, clones [OnPackPp1181Master] `.image` / `.job` for each code + * on the UI-managed `onpack_expiry_item_code` list, and generates product/code/date/expiry BMPs. + * Old ZIP callers must pass false; they keep per-code `onpack2030` templates unchanged. */ fun generateOnPackQrZip( jobOrders: List, includeExpiry: Boolean = false, printDate: LocalDate? = null, - ): ByteArray { + ): OnPackZipResult { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -699,6 +701,12 @@ class PlasticBagPrinterService( val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } + val expiryPrintNames = if (includeExpiry) { + onPackTemplateFileService.expiryPrintNames(OnPackTemplateFileService.MACHINE_JUICE) + } else { + emptyMap() + } + val exportItemsRaw = packagingJobOrders .groupBy { it.itemCode.trim().lowercase() } .mapNotNull { (codeLower, orders) -> @@ -710,9 +718,11 @@ class PlasticBagPrinterService( val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) val baseName = jo?.bom?.name ?: stockInLine.item?.name val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc - val productName = PyJobOrderListMapper.buildDisplayItemName(baseName, stockDesc) - ?: codeLower.uppercase() + 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, @@ -726,21 +736,67 @@ class PlasticBagPrinterService( require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } val codesUpper = exportItemsRaw.map { it.itemCode }.toSet() - val allowedBmpCodes = codesOnPackMatchingTemplateType(codesUpper, "bmp") - val exportItems = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) } + val allowedBmpCodes = if (includeExpiry) { + onPackTemplateFileService.expiryItemCodes(OnPackTemplateFileService.MACHINE_JUICE) + } else { + codesOnPackMatchingTemplateType(codesUpper, "bmp") + } + val exportItemsListed = exportItemsRaw.filter { allowedBmpCodes.contains(it.itemCode) } - require(exportItems.isNotEmpty()) { "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" } + require(exportItemsListed.isNotEmpty()) { + if (includeExpiry) { + "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" + } else { + "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" + } + } val effectivePrintDate = printDate - ?: exportItems.firstNotNullOfOrNull { it.planDate } + ?: exportItemsListed.firstNotNullOfOrNull { it.planDate } ?: ItemDefaultShelfLifeService.today() + val skippedWithoutExpiry = mutableListOf() + val exportItems = if (includeExpiry) { + exportItemsListed.filter { item -> + val label = itemDefaultShelfLifeService.expiryDatePrintLabel(item.itemCode, effectivePrintDate) + if (label.isNullOrBlank()) { + skippedWithoutExpiry += item.itemCode + false + } else { + true + } + } + } else { + exportItemsListed + } + + require(exportItems.isNotEmpty()) { + if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { + "當日汁水機清單品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" + } else if (includeExpiry) { + "當日工單沒有在汁水機到期日 ZIP 品號清單中的項目(OnPack 模板可管理此清單)" + } else { + "No OnPack QR (bmp) rows in onpack_qr for the selected job orders, or no matching templates" + } + } + + val expiryMasterImage = if (includeExpiry) loadPp1181ExpiryMasterImage() else null + val expiryMasterJob = if (includeExpiry) loadPp1181MasterJob() else null + if (includeExpiry) { + require(expiryMasterImage != null) { "找不到 PP1181 到期日主模板(onpack2030_exp/pp1181.image)" } + } + val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zos -> val addedEntries = linkedSetOf() exportItems.forEach { item -> val codeLower = item.codeLower - val imageTemplate = loadOnPackImageTemplateOrNull(codeLower, includeExpiry) ?: return@forEach + val imageTemplate = if (includeExpiry) { + val master = expiryMasterImage ?: return@forEach + OnPackPp1181Master.rewriteImageBytes(master, codeLower) + } else { + loadOnPackImageTemplateOrNull(codeLower, forExpiry = false) ?: return@forEach + } val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. @@ -806,21 +862,30 @@ class PlasticBagPrinterService( if (addedEntries.add(imageFileName)) { addToZip(zos, imageFileName, imageContent) } - val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first - extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> - if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach - if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach - if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach - if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach - if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach - if (!addedEntries.add(bmpName)) return@forEach - val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach - addToZip(zos, bmpName, bmpBytes) + if (!includeExpiry) { + val decodedXmlForAssets = decodeOnPackImageTemplateForTextEdit(imageContent).first + extractLogoBmpFileNamesFromOnPackImageXml(decodedXmlForAssets).forEach { bmpName -> + if (bmpName.equals(qrBmpFileName, ignoreCase = true)) return@forEach + if (bmpName.endsWith("exp.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Product.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Code.bmp", ignoreCase = true)) return@forEach + if (bmpName.endsWith("Date.bmp", ignoreCase = true)) return@forEach + if (!addedEntries.add(bmpName)) return@forEach + val bmpBytes = loadJuiceAssetOrNull(bmpName) ?: return@forEach + addToZip(zos, bmpName, bmpBytes) + } } val jobFileName = "${codeLower}.job" - loadJuiceAssetOrNull(jobFileName)?.let { jobBytes -> - if (addedEntries.add(jobFileName)) { - addToZip(zos, jobFileName, jobBytes) + if (includeExpiry) { + val masterJob = expiryMasterJob + if (masterJob != null && addedEntries.add(jobFileName)) { + addToZip(zos, jobFileName, OnPackPp1181Master.rewriteJobBytes(masterJob, imageFileName)) + } + } else { + loadJuiceAssetOrNull(jobFileName)?.let { jobBytes -> + if (addedEntries.add(jobFileName)) { + addToZip(zos, jobFileName, jobBytes) + } } } } @@ -828,7 +893,7 @@ class PlasticBagPrinterService( require(addedEntries.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } } - return baos.toByteArray() + return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) } /** @@ -840,7 +905,7 @@ class PlasticBagPrinterService( jobOrders: List, includeExpiry: Boolean = false, printDate: LocalDate? = null, - ): ByteArray { + ): OnPackZipResult { val normalizedJobOrders = jobOrders .map { OnPackQrJobOrderRequest( @@ -890,9 +955,34 @@ class PlasticBagPrinterService( val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text") - val exportItems = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } + val exportItemsListed = exportItemsRaw.filter { allowedTextCodes.contains(it.first.uppercase()) } + + require(exportItemsListed.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } + + val effectivePrintDate = printDate ?: ItemDefaultShelfLifeService.today() + val skippedWithoutExpiry = mutableListOf() + val exportItems = if (includeExpiry) { + exportItemsListed.filter { (codeLower, _, _) -> + val code = codeLower.uppercase() + val label = itemDefaultShelfLifeService.expiryDatePrintLabel(code, effectivePrintDate) + if (label.isNullOrBlank()) { + skippedWithoutExpiry += code + false + } else { + true + } + } + } else { + exportItemsListed + } - require(exportItems.isNotEmpty()) { "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" } + require(exportItems.isNotEmpty()) { + if (includeExpiry && skippedWithoutExpiry.isNotEmpty()) { + "當日檸檬機品號都沒有預設保質期,無法產生到期日 ZIP。請到設定 → 物品預設保質期新增:${skippedWithoutExpiry.joinToString("、")}" + } else { + "No OnPack QR (text) rows in onpack_qr for the selected job orders, or no matching templates" + } + } val baos = ByteArrayOutputStream() ZipOutputStream(baos).use { zos -> @@ -904,10 +994,7 @@ class PlasticBagPrinterService( } val imageFileName = "${codeLower.uppercase()}.image" val expiryLabel = if (includeExpiry) { - itemDefaultShelfLifeService.expiryDatePrintLabel( - codeLower, - printDate ?: ItemDefaultShelfLifeService.today(), - ) + itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) } else { null } @@ -939,7 +1026,7 @@ class PlasticBagPrinterService( } require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" } } - return baos.toByteArray() + return OnPackZipResult(baos.toByteArray(), skippedWithoutExpiry.distinct()) } /** @@ -955,7 +1042,7 @@ class PlasticBagPrinterService( ) } val zipBytes = try { - generateOnPackQrTextZip(jobOrders) + generateOnPackQrTextZip(jobOrders).bytes } catch (e: Exception) { logger.warn("OnPack text ZIP generation failed before NGPCL push", e) return NgpclPushResponse( @@ -1030,6 +1117,26 @@ class PlasticBagPrinterService( return fromTable + fromDbFiles } + private fun loadPp1181ExpiryMasterImage(): ByteArray? { + listOf(OnPackPp1181Master.MASTER_IMAGE, OnPackPp1181Master.MASTER_IMAGE_FALLBACK).forEach { path -> + val resource = ClassPathResource(path) + if (resource.exists()) { + return resource.inputStream.use { it.readBytes() } + } + } + logger.warn("Missing PP1181 expiry master image on classpath") + return null + } + + private fun loadPp1181MasterJob(): ByteArray? { + val resource = ClassPathResource(OnPackPp1181Master.MASTER_JOB) + if (!resource.exists()) { + logger.warn("Missing PP1181 master job on classpath") + return null + } + return resource.inputStream.use { it.readBytes() } + } + private fun loadOnPackImageTemplateOrNull(codeLower: String, forExpiry: Boolean = false): ByteArray? { onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it } if (forExpiry) { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt index c33197e..9ca5741 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt @@ -1,6 +1,9 @@ package com.ffii.fpsms.modules.jobOrder.web import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeDto +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeRequest +import com.ffii.fpsms.modules.jobOrder.web.model.OnPackExpiryItemCodeUpdateRequest import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedCatalogDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse @@ -11,6 +14,8 @@ import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController @@ -33,6 +38,33 @@ class OnPackTemplateController( return onPackTemplateFileService.supportedCatalog() } + /** Item codes included in 汁水機 OnPack expiry ZIP (dynamic PP1181 template). */ + @GetMapping("/expiry-codes") + fun listExpiryCodes( + @RequestParam(required = false) machine: String?, + ): List { + return onPackTemplateFileService.listExpiryItemCodes(machine) + } + + @PostMapping("/expiry-codes") + fun addExpiryCode(@RequestBody body: OnPackExpiryItemCodeRequest): OnPackExpiryItemCodeDto { + return onPackTemplateFileService.addExpiryItemCode(body.machine, body.itemCode) + } + + @PutMapping("/expiry-codes") + fun updateExpiryCode(@RequestBody body: OnPackExpiryItemCodeUpdateRequest): OnPackExpiryItemCodeDto { + return onPackTemplateFileService.updateExpiryItemCode(body) + } + + @DeleteMapping("/expiry-codes") + fun deleteExpiryCode( + @RequestParam(required = false) machine: String?, + @RequestParam itemCode: String, + ): ResponseEntity { + onPackTemplateFileService.removeExpiryItemCode(machine, itemCode) + return ResponseEntity.noContent().build() + } + @PostMapping fun upload( @RequestParam machine: String, @@ -52,4 +84,9 @@ class OnPackTemplateController( fun badRequest(e: IllegalArgumentException): ResponseEntity> { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("message" to (e.message ?: "Invalid request"))) } + + @ExceptionHandler(IllegalStateException::class) + fun unavailable(e: IllegalStateException): ResponseEntity> { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(mapOf("message" to (e.message ?: "Unavailable"))) + } } 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 fe880c8..9ffd326 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 @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.jobOrder.web import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService +import com.ffii.fpsms.modules.jobOrder.service.OnPackZipResult 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 @@ -119,15 +120,8 @@ class PlasticBagPrinterController( 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() + val zip = plasticBagPrinterService.generateOnPackQrZip(request.jobOrders) + writeOnPackZip(response, "onpack_qr_codes.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -151,7 +145,7 @@ class PlasticBagPrinterController( /** * Same 汁水機 ZIP as [downloadOnPackQr], plus expiry BMP from item_default_shelf_life. - * Uses designer `.image` layout (`LOGO_5` / `LOGO_EXP`) when present; otherwise injects LOGO_EXP. + * Clones PP1181 `.image` / `.job` for each code on `onpack_expiry_item_code`. * Old [downloadOnPackQr] is unchanged. */ @PostMapping("/download-onpack-qr-with-expiry") @@ -160,19 +154,12 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrZip( + val zip = plasticBagPrinterService.generateOnPackQrZip( request.jobOrders, includeExpiry = true, printDate = request.planDate, ) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack_qr_exp.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + writeOnPackZip(response, "onpack_qr_exp.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -201,15 +188,8 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack2023_lemon_qr.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + val zip = plasticBagPrinterService.generateOnPackQrTextZip(request.jobOrders) + writeOnPackZip(response, "onpack2023_lemon_qr.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -250,19 +230,12 @@ class PlasticBagPrinterController( response: HttpServletResponse, ) { try { - val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip( + val zip = plasticBagPrinterService.generateOnPackQrTextZip( request.jobOrders, includeExpiry = true, printDate = request.planDate, ) - response.contentType = "application/zip" - response.setHeader( - HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"onpack2023_lemon_qr_exp.zip\"" - ) - response.setContentLength(zipBytes.size) - response.outputStream.write(zipBytes) - response.outputStream.flush() + writeOnPackZip(response, "onpack2023_lemon_qr_exp.zip", zip) } catch (e: IllegalArgumentException) { response.status = HttpServletResponse.SC_BAD_REQUEST response.contentType = "text/plain;charset=UTF-8" @@ -405,4 +378,15 @@ class PlasticBagPrinterController( } } + private fun writeOnPackZip(response: HttpServletResponse, filename: String, zip: OnPackZipResult) { + if (zip.skippedWithoutExpiry.isNotEmpty()) { + response.setHeader("X-OnPack-Skipped-Expiry", zip.skippedWithoutExpiry.joinToString(",")) + } + response.contentType = "application/zip" + response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$filename\"") + response.setContentLength(zip.bytes.size) + response.outputStream.write(zip.bytes) + response.outputStream.flush() + } + } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt index 9bbeef9..9aebf14 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt @@ -27,3 +27,27 @@ data class OnPackSupportedCatalogDto( val juice: List, val lemon: List, ) + +data class OnPackExpiryItemCodeDto( + val machine: String, + val itemCode: String, + val printName: String? = null, + val defaultPrintName: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean = false, + val effectiveDays: Int? = null, +) + +data class OnPackExpiryItemCodeRequest( + val itemCode: String, + val machine: String? = "juice", +) + +data class OnPackExpiryItemCodeUpdateRequest( + val itemCode: String, + val machine: String? = "juice", + /** Empty string clears the override (use default name + unit). Omitted = leave unchanged. */ + val printName: String? = null, + val useMinus18: Boolean? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt index 5093a11..2223f3e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt @@ -9,4 +9,10 @@ interface ItemDefaultShelfLifeRepository : AbstractRepository): List + + fun findByItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? + + fun findByIdAndDeletedFalse(id: Long): ItemDefaultShelfLife? + + fun findAllByDeletedFalseOrderByItemCodeAsc(): List } diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt index da77c0c..0384041 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt @@ -14,6 +14,7 @@ interface ItemsRepository : AbstractRepository { fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; fun findByCodeAndDeletedFalse(code: String): Items?; + fun findByDeletedFalseAndCodeIn(codes: Collection): List fun findByNameAndDeletedFalse(name: String): Items?; fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?; diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt index fc0b183..5921423 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt @@ -2,7 +2,13 @@ package com.ffii.fpsms.modules.master.service import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLife import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository +import com.ffii.fpsms.modules.master.entity.ItemsRepository +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRow +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.server.ResponseStatusException import java.time.LocalDate import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -17,10 +23,11 @@ data class ItemPrintShelfLife( * Uses [ItemDefaultShelfLife.useMinus18] to pick chilled [defaultDays] vs [minus18Days]. */ @Service -class ItemDefaultShelfLifeService( +open class ItemDefaultShelfLifeService( private val repository: ItemDefaultShelfLifeRepository, + private val itemsRepository: ItemsRepository, ) { - fun printShelfLifeByItemCodes(codes: Collection): Map { + open fun printShelfLifeByItemCodes(codes: Collection): Map { val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } .distinct() if (normalized.isEmpty()) return emptyMap() @@ -33,31 +40,154 @@ class ItemDefaultShelfLifeService( .toMap() } - fun defaultDaysByItemCodes(codes: Collection): Map = + open fun defaultDaysByItemCodes(codes: Collection): Map = printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } - fun defaultDays(itemCode: String?): Int? { + open fun defaultDays(itemCode: String?): Int? { val code = itemCode?.trim()?.uppercase().orEmpty() if (code.isEmpty()) return null val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) ?: return null return effectiveDays(row) } - fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { + open fun findRowsByItemCodes(codes: Collection): Map { + val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } + .distinct() + if (normalized.isEmpty()) return emptyMap() + return repository.findByDeletedFalseAndItemCodeIn(normalized) + .mapNotNull { row -> + val code = row.itemCode?.trim()?.uppercase().orEmpty() + if (code.isEmpty()) null else code to row + } + .toMap() + } + + @Transactional + open fun setUseMinus18(itemCode: String?, useMinus18: Boolean): ItemDefaultShelfLifeRow { + val code = normalizeItemCode(itemCode) + if (code.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") + } + val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) + ?: throw ResponseStatusException( + HttpStatus.BAD_REQUEST, + "找不到 $code 的預設保質期。請先到設定 → 物品預設保質期新增。", + ) + row.useMinus18 = useMinus18 + return toRow(repository.save(row)) + } + + open fun expiryDate(itemCode: String?, printDate: LocalDate = today()): LocalDate? { val days = defaultDays(itemCode) ?: return null return expiryOn(printDate, days) } - fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = + open fun expiryDateIso(itemCode: String?, printDate: LocalDate = today()): String? = expiryDate(itemCode, printDate)?.toString() - fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = + open fun expiryDateCompact(itemCode: String?, printDate: LocalDate = today()): String? = expiryDate(itemCode, printDate)?.format(COMPACT) /** Printed bag wording, e.g. `Expiry Date 20260821`. */ - fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = + open fun expiryDatePrintLabel(itemCode: String?, printDate: LocalDate = today()): String? = expiryDate(itemCode, printDate)?.let { formatPrintLabel(it) } + open fun list(q: String? = null): List { + val rows = repository.findAllByDeletedFalseOrderByItemCodeAsc() + val names = itemNamesByCode(rows.mapNotNull { it.itemCode }) + val needle = q?.trim()?.lowercase().orEmpty() + return rows + .map { toRow(it, names[it.itemCode?.trim()?.uppercase().orEmpty()]) } + .filter { row -> + if (needle.isEmpty()) true + else row.itemCode.lowercase().contains(needle) || + row.itemName.orEmpty().lowercase().contains(needle) || + row.remarks.orEmpty().lowercase().contains(needle) + } + } + + @Transactional + open fun create(request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + val code = normalizeItemCode(request.itemCode) + validateRequest(request, code) + val existing = repository.findByItemCodeIgnoreCase(code) + if (existing != null && existing.deleted != true) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") + } + val row = existing ?: ItemDefaultShelfLife() + applyRequest(row, request, code) + row.deleted = false + return toRow(repository.save(row)) + } + + @Transactional + open fun update(id: Long, request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + val row = repository.findByIdAndDeletedFalse(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") + val code = normalizeItemCode(request.itemCode) + validateRequest(request, code) + val other = repository.findByItemCodeIgnoreCase(code) + if (other != null && other.id != id) { + throw ResponseStatusException(HttpStatus.CONFLICT, "Item code already exists: $code") + } + applyRequest(row, request, code) + return toRow(repository.save(row)) + } + + @Transactional + open fun markDeleted(id: Long): List { + val row = repository.findByIdAndDeletedFalse(id) + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "Shelf life row $id not found") + row.deleted = true + repository.save(row) + return list() + } + + private fun applyRequest(row: ItemDefaultShelfLife, request: ItemDefaultShelfLifeRequest, code: String) { + row.itemCode = code + row.defaultDays = request.defaultDays + row.minus18Days = request.minus18Days + row.useMinus18 = request.useMinus18 == true + row.openedDays = request.openedDays + row.storageC = request.storageC?.trim()?.takeIf { it.isNotEmpty() } + row.remarks = request.remarks?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun toRow(row: ItemDefaultShelfLife, itemName: String? = null): ItemDefaultShelfLifeRow { + val code = row.itemCode?.trim().orEmpty() + val name = itemName ?: itemNameFor(code) + return ItemDefaultShelfLifeRow( + id = row.id ?: 0L, + itemCode = code, + itemName = name, + defaultDays = row.defaultDays, + minus18Days = row.minus18Days, + useMinus18 = row.useMinus18 == true, + openedDays = row.openedDays, + storageC = row.storageC, + remarks = row.remarks, + effectiveDays = effectiveDays(row), + ) + } + + private fun itemNameFor(code: String): String? { + if (code.isEmpty()) return null + return itemsRepository.findByCodeAndDeletedFalse(code)?.name?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun itemNamesByCode(codes: Collection): Map { + val raw = codes.mapNotNull { it.trim().takeIf { c -> c.isNotEmpty() } }.distinct() + if (raw.isEmpty()) return emptyMap() + val lookup = (raw + raw.map { it.uppercase() }).distinct() + return itemsRepository.findByDeletedFalseAndCodeIn(lookup) + .mapNotNull { item -> + val code = item.code?.trim()?.uppercase().orEmpty() + val name = item.name?.trim()?.takeIf { it.isNotEmpty() } + if (code.isEmpty() || name == null) null else code to name + } + .toMap() + } + companion object { val PRINT_ZONE: ZoneId = ZoneId.of("Asia/Hong_Kong") private val COMPACT: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE @@ -79,5 +209,34 @@ class ItemDefaultShelfLifeService( val chosen = if (useMinus18) minus18Days else defaultDays return chosen?.takeIf { it > 0 } } + + fun normalizeItemCode(raw: String?): String = + raw?.trim()?.uppercase().orEmpty() + + fun validateRequest(request: ItemDefaultShelfLifeRequest, code: String) { + if (code.isEmpty()) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code is required") + } + if (code.length > 50) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Item code must be at most 50 characters") + } + requireDays("defaultDays", request.defaultDays) + requireDays("minus18Days", request.minus18Days) + requireDays("openedDays", request.openedDays) + val storage = request.storageC?.trim().orEmpty() + if (storage.length > 20) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "storageC must be at most 20 characters") + } + val remarks = request.remarks?.trim().orEmpty() + if (remarks.length > 255) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "remarks must be at most 255 characters") + } + } + + private fun requireDays(field: String, value: Int?) { + if (value != null && value < 0) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, "$field must be 0 or greater") + } + } } } diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt new file mode 100644 index 0000000..1fa17f4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt @@ -0,0 +1,64 @@ +package com.ffii.fpsms.modules.master.web + +import com.ffii.fpsms.modules.master.service.ItemDefaultShelfLifeService +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RequestMapping("itemDefaultShelfLives") +@RestController +class ItemDefaultShelfLifeController( + private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, +) { + @GetMapping + fun list(@RequestParam(required = false) q: String?): List { + return itemDefaultShelfLifeService.list(q) + } + + @PostMapping + fun create(@RequestBody request: ItemDefaultShelfLifeRequest): ItemDefaultShelfLifeRow { + return itemDefaultShelfLifeService.create(request) + } + + @PutMapping("/{id}") + fun update( + @PathVariable id: Long, + @RequestBody request: ItemDefaultShelfLifeRequest, + ): ItemDefaultShelfLifeRow { + return itemDefaultShelfLifeService.update(id, request) + } + + @DeleteMapping("/{id}") + fun delete(@PathVariable id: Long): List { + return itemDefaultShelfLifeService.markDeleted(id) + } +} + +data class ItemDefaultShelfLifeRequest( + val itemCode: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean? = false, + val openedDays: Int? = null, + val storageC: String? = null, + val remarks: String? = null, +) + +data class ItemDefaultShelfLifeRow( + val id: Long, + val itemCode: String, + val itemName: String? = null, + val defaultDays: Int? = null, + val minus18Days: Int? = null, + val useMinus18: Boolean = false, + val openedDays: Int? = null, + val storageC: String? = null, + val remarks: String? = null, + val effectiveDays: Int? = null, +) diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql new file mode 100644 index 0000000..5290ab3 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql @@ -0,0 +1,18 @@ +--liquibase formatted sql + +--changeset fpsms:create_onpack_expiry_item_code +--comment: UI-managed item codes for 汁水機 OnPack expiry ZIP (dynamic PP1181 template) +CREATE TABLE `onpack_expiry_item_code` +( + `id` INT NOT NULL AUTO_INCREMENT, + `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `createdBy` VARCHAR(30) NULL DEFAULT NULL, + `version` INT NOT NULL DEFAULT '0', + `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `modifiedBy` VARCHAR(30) NULL DEFAULT NULL, + `deleted` TINYINT(1) NOT NULL DEFAULT '0', + `machine` VARCHAR(20) NOT NULL DEFAULT 'juice' COMMENT 'juice=汁水機 expiry ZIP', + `itemCode` VARCHAR(50) NOT NULL, + CONSTRAINT pk_onpack_expiry_item_code PRIMARY KEY (`id`), + UNIQUE KEY uk_onpack_expiry_item_code (`machine`, `itemCode`) +); diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql new file mode 100644 index 0000000..e2b0df2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql @@ -0,0 +1,44 @@ +--liquibase formatted sql + +--changeset fpsms:seed_onpack_expiry_item_code_pp +--comment: Seed juice expiry ZIP list from onpack2030 folder PP*.image +INSERT INTO `onpack_expiry_item_code` +(`created`, `createdBy`, `version`, `modified`, `modifiedBy`, `deleted`, `machine`, `itemCode`) +VALUES +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1041'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1074'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1078'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1080'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1082'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1088'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1117'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1118'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1126'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1136'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1137'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1144'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1148'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1152'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1156'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1178'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1180'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1181'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1185'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1209'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1213'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1214'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1216'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1217'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP1234'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2211'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2214'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2215'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2243'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2248'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2250'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2262'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2282'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2317'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2331'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2341'), +(NOW(), 'system', 0, NOW(), 'system', 0, 'juice', 'PP2404'); diff --git a/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql new file mode 100644 index 0000000..d179594 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +--changeset fpsms:onpack_expiry_item_code_print_name +--comment: Editable OnPack Product BMP name (Chinese name + unit); blank uses items + stock UOM +ALTER TABLE `onpack_expiry_item_code` + ADD COLUMN `printName` VARCHAR(255) NULL COMMENT 'OnPack Product line; overrides items name + unit' AFTER `itemCode`; diff --git a/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt new file mode 100644 index 0000000..8b6c9f6 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt @@ -0,0 +1,42 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.springframework.core.io.ClassPathResource + +class OnPackPp1181MasterTest { + + @Test + fun rewriteImageXml_swaps_pp1181_filenames() { + val xml = """pp1181Product.bmppp1181qr.bmp""" + val out = OnPackPp1181Master.rewriteImageXml(xml, "PP1041") + assertTrue(out.contains("pp1041Product.bmp")) + assertTrue(out.contains("pp1041qr.bmp")) + assertFalse(out.contains("pp1181")) + } + + @Test + fun rewriteJobXml_points_at_cloned_image() { + val out = OnPackPp1181Master.rewriteJobXml( + "PP1181.image", + "pp1041.image", + ) + assertEquals("pp1041.image", out) + } + + @Test + fun classpath_exp_master_rewrites_all_five_slots() { + val resource = ClassPathResource(OnPackPp1181Master.MASTER_IMAGE) + assertTrue(resource.exists(), "missing ${OnPackPp1181Master.MASTER_IMAGE}") + val bytes = OnPackPp1181Master.rewriteImageBytes(resource.inputStream.use { it.readBytes() }, "PP2404") + val (xml, _) = OnPackImageTemplateCodec.decode(bytes) + assertTrue(xml.contains("pp2404Product.bmp")) + assertTrue(xml.contains("pp2404Code.bmp")) + assertTrue(xml.contains("pp2404Date.bmp")) + assertTrue(xml.contains("pp2404qr.bmp")) + assertTrue(xml.contains("pp2404exp.bmp")) + assertFalse(xml.contains("pp1181", ignoreCase = true)) + } +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt index 8accc86..98d2cc0 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt +++ b/src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt @@ -1,7 +1,10 @@ package com.ffii.fpsms.modules.master.service +import com.ffii.fpsms.modules.master.web.ItemDefaultShelfLifeRequest import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test +import org.springframework.web.server.ResponseStatusException import java.time.LocalDate class ItemDefaultShelfLifeServiceTest { @@ -42,4 +45,23 @@ class ItemDefaultShelfLifeServiceTest { ItemDefaultShelfLifeService.formatProductionDatePrintLabel(LocalDate.of(2026, 8, 21)), ) } + + @Test + fun normalizeItemCode_trims_and_uppercases() { + assertEquals("F0013", ItemDefaultShelfLifeService.normalizeItemCode(" f0013 ")) + assertEquals("", ItemDefaultShelfLifeService.normalizeItemCode(" ")) + } + + @Test + fun validateRequest_rejects_blank_code_and_negative_days() { + assertThrows(ResponseStatusException::class.java) { + ItemDefaultShelfLifeService.validateRequest(ItemDefaultShelfLifeRequest(), "") + } + assertThrows(ResponseStatusException::class.java) { + ItemDefaultShelfLifeService.validateRequest( + ItemDefaultShelfLifeRequest(itemCode = "F0013", defaultDays = -1), + "F0013", + ) + } + } }