| @@ -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"); | |||
| } | |||
| @@ -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)) | |||
| } | |||
| } | |||
| @@ -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<OnPackExpiryItemCodeDto> { | |||
| 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<String, String> { | |||
| 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<String> { | |||
| 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<Pair<String, String?>> { | |||
| 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<Pair<String, String?>>): List<OnPackExpiryItemCodeDto> { | |||
| 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<OnPackSupportedItemDto> { | |||
| 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<String> { | |||
| @@ -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<String> = 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<OnPackQrJobOrderRequest>, | |||
| 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<String>() | |||
| 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<String>() | |||
| 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<OnPackQrJobOrderRequest>, | |||
| 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<String>() | |||
| 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) { | |||
| @@ -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<OnPackExpiryItemCodeDto> { | |||
| 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<Void> { | |||
| 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<Map<String, String>> { | |||
| return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("message" to (e.message ?: "Invalid request"))) | |||
| } | |||
| @ExceptionHandler(IllegalStateException::class) | |||
| fun unavailable(e: IllegalStateException): ResponseEntity<Map<String, String>> { | |||
| return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(mapOf("message" to (e.message ?: "Unavailable"))) | |||
| } | |||
| } | |||
| @@ -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() | |||
| } | |||
| } | |||
| @@ -27,3 +27,27 @@ data class OnPackSupportedCatalogDto( | |||
| val juice: List<OnPackSupportedItemDto>, | |||
| val lemon: List<OnPackSupportedItemDto>, | |||
| ) | |||
| 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, | |||
| ) | |||
| @@ -9,4 +9,10 @@ interface ItemDefaultShelfLifeRepository : AbstractRepository<ItemDefaultShelfLi | |||
| fun findByDeletedFalseAndItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? | |||
| fun findByDeletedFalseAndItemCodeIn(itemCodes: Collection<String>): List<ItemDefaultShelfLife> | |||
| fun findByItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? | |||
| fun findByIdAndDeletedFalse(id: Long): ItemDefaultShelfLife? | |||
| fun findAllByDeletedFalseOrderByItemCodeAsc(): List<ItemDefaultShelfLife> | |||
| } | |||
| @@ -14,6 +14,7 @@ interface ItemsRepository : AbstractRepository<Items, Long> { | |||
| fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; | |||
| fun findByCodeAndDeletedFalse(code: String): Items?; | |||
| fun findByDeletedFalseAndCodeIn(codes: Collection<String>): List<Items> | |||
| fun findByNameAndDeletedFalse(name: String): Items?; | |||
| fun findByM18IdAndDeletedIsFalse(m18Id: Long): Items?; | |||
| @@ -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<String?>): Map<String, ItemPrintShelfLife> { | |||
| open fun printShelfLifeByItemCodes(codes: Collection<String?>): Map<String, ItemPrintShelfLife> { | |||
| 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<String?>): Map<String, Int> = | |||
| open fun defaultDaysByItemCodes(codes: Collection<String?>): Map<String, Int> = | |||
| 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<String?>): Map<String, ItemDefaultShelfLife> { | |||
| 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<ItemDefaultShelfLifeRow> { | |||
| 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<ItemDefaultShelfLifeRow> { | |||
| 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<String>): Map<String, String> { | |||
| 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") | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @@ -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<ItemDefaultShelfLifeRow> { | |||
| 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<ItemDefaultShelfLifeRow> { | |||
| 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, | |||
| ) | |||
| @@ -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`) | |||
| ); | |||
| @@ -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'); | |||
| @@ -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`; | |||
| @@ -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 = """<FileName>pp1181Product.bmp</FileName><FileName>pp1181qr.bmp</FileName>""" | |||
| 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( | |||
| "<Job><ImageFileName>PP1181.image</ImageFileName></Job>", | |||
| "pp1041.image", | |||
| ) | |||
| assertEquals("<Job><ImageFileName>pp1041.image</ImageFileName></Job>", 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)) | |||
| } | |||
| } | |||
| @@ -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", | |||
| ) | |||
| } | |||
| } | |||
| } | |||