| @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { | |||||
| registry.addMapping("/**") | registry.addMapping("/**") | ||||
| .allowedHeaders("*") | .allowedHeaders("*") | ||||
| .allowedOrigins("*") | .allowedOrigins("*") | ||||
| .exposedHeaders("filename", "Content-Disposition") | |||||
| .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") | |||||
| .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); | .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.core.support.JdbcDao | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFile | import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFile | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.OnPackTemplateFileRepository | 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.OnPackSupportedCatalogDto | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackSupportedItemDto | 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.OnPackTemplateFileDto | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse | 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.slf4j.LoggerFactory | ||||
| import org.springframework.core.io.support.PathMatchingResourcePatternResolver | import org.springframework.core.io.support.PathMatchingResourcePatternResolver | ||||
| import org.springframework.dao.DataAccessException | import org.springframework.dao.DataAccessException | ||||
| @@ -21,6 +27,9 @@ import java.time.format.DateTimeFormatter | |||||
| open class OnPackTemplateFileService( | open class OnPackTemplateFileService( | ||||
| private val repository: OnPackTemplateFileRepository, | private val repository: OnPackTemplateFileRepository, | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| private val itemsRepository: ItemsRepository, | |||||
| private val itemUomService: ItemUomService, | |||||
| private val itemDefaultShelfLifeService: ItemDefaultShelfLifeService, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(javaClass) | private val logger = LoggerFactory.getLogger(javaClass) | ||||
| private val resourceResolver = PathMatchingResourcePatternResolver() | 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> { | private fun supportedItems(machine: String): List<OnPackSupportedItemDto> { | ||||
| val registered = registeredCodes(machine) | val registered = registeredCodes(machine) | ||||
| val inDatabase = itemCodesWithImage(machine) | val inDatabase = itemCodesWithImage(machine) | ||||
| val builtin = builtinImageCodes(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> { | 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 to store bitmap bytes + width (for XML) | ||||
| data class BitmapResult(val bytes: ByteArray, val width: Int) | data class BitmapResult(val bytes: ByteArray, val width: Int) | ||||
| data class OnPackZipResult( | |||||
| val bytes: ByteArray, | |||||
| val skippedWithoutExpiry: List<String> = emptyList(), | |||||
| ) | |||||
| private data class OnPackBmpExportItem( | private data class OnPackBmpExportItem( | ||||
| val codeLower: String, | val codeLower: String, | ||||
| val itemId: Long, | val itemId: Long, | ||||
| @@ -652,18 +657,15 @@ class PlasticBagPrinterService( | |||||
| /** | /** | ||||
| * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. | * 汁水機 OnPack: templates under classpath `onpack2030/{code}.image`. | ||||
| * Always swaps LOGO_4 to the generated QR BMP. | * 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( | fun generateOnPackQrZip( | ||||
| jobOrders: List<OnPackQrJobOrderRequest>, | jobOrders: List<OnPackQrJobOrderRequest>, | ||||
| includeExpiry: Boolean = false, | includeExpiry: Boolean = false, | ||||
| printDate: LocalDate? = null, | printDate: LocalDate? = null, | ||||
| ): ByteArray { | |||||
| ): OnPackZipResult { | |||||
| val normalizedJobOrders = jobOrders | val normalizedJobOrders = jobOrders | ||||
| .map { | .map { | ||||
| OnPackQrJobOrderRequest( | OnPackQrJobOrderRequest( | ||||
| @@ -699,6 +701,12 @@ class PlasticBagPrinterService( | |||||
| val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } | val packagingJobOrders = normalizedJobOrders.filter { it.jobOrderId in allowedJobOrderIds } | ||||
| require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } | require(packagingJobOrders.isNotEmpty()) { "No 包裝 process job orders found for export" } | ||||
| val expiryPrintNames = if (includeExpiry) { | |||||
| onPackTemplateFileService.expiryPrintNames(OnPackTemplateFileService.MACHINE_JUICE) | |||||
| } else { | |||||
| emptyMap() | |||||
| } | |||||
| val exportItemsRaw = packagingJobOrders | val exportItemsRaw = packagingJobOrders | ||||
| .groupBy { it.itemCode.trim().lowercase() } | .groupBy { it.itemCode.trim().lowercase() } | ||||
| .mapNotNull { (codeLower, orders) -> | .mapNotNull { (codeLower, orders) -> | ||||
| @@ -710,9 +718,11 @@ class PlasticBagPrinterService( | |||||
| val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) | val jo = jobOrderRepository.findById(order.jobOrderId).orElse(null) | ||||
| val baseName = jo?.bom?.name ?: stockInLine.item?.name | val baseName = jo?.bom?.name ?: stockInLine.item?.name | ||||
| val stockDesc = itemUomService.findStockUnitByItemId(itemId)?.uom?.udfudesc | 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 itemCode = (stockInLine.item?.code ?: stockInLine.itemNo ?: codeLower).trim().uppercase() | ||||
| val productName = expiryPrintNames[itemCode]?.trim()?.takeIf { it.isNotEmpty() } | |||||
| ?: computedName | |||||
| ?: itemCode | |||||
| OnPackBmpExportItem( | OnPackBmpExportItem( | ||||
| codeLower, | codeLower, | ||||
| itemId, | itemId, | ||||
| @@ -726,21 +736,67 @@ class PlasticBagPrinterService( | |||||
| require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } | require(exportItemsRaw.isNotEmpty()) { "No OnPack QR files could be generated for the selected date" } | ||||
| val codesUpper = exportItemsRaw.map { it.itemCode }.toSet() | 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 | val effectivePrintDate = printDate | ||||
| ?: exportItems.firstNotNullOfOrNull { it.planDate } | |||||
| ?: exportItemsListed.firstNotNullOfOrNull { it.planDate } | |||||
| ?: ItemDefaultShelfLifeService.today() | ?: 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() | val baos = ByteArrayOutputStream() | ||||
| ZipOutputStream(baos).use { zos -> | ZipOutputStream(baos).use { zos -> | ||||
| val addedEntries = linkedSetOf<String>() | val addedEntries = linkedSetOf<String>() | ||||
| exportItems.forEach { item -> | exportItems.forEach { item -> | ||||
| val codeLower = item.codeLower | 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}}""" | val qrContent = """{"itemId": ${item.itemId}, "stockInLineId": ${item.stockInLineId}}""" | ||||
| // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. | // Target approximately 470x389 BMP, but with larger visible QR and very little vertical whitespace. | ||||
| @@ -806,21 +862,30 @@ class PlasticBagPrinterService( | |||||
| if (addedEntries.add(imageFileName)) { | if (addedEntries.add(imageFileName)) { | ||||
| addToZip(zos, imageFileName, imageContent) | 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" | 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" } | 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>, | jobOrders: List<OnPackQrJobOrderRequest>, | ||||
| includeExpiry: Boolean = false, | includeExpiry: Boolean = false, | ||||
| printDate: LocalDate? = null, | printDate: LocalDate? = null, | ||||
| ): ByteArray { | |||||
| ): OnPackZipResult { | |||||
| val normalizedJobOrders = jobOrders | val normalizedJobOrders = jobOrders | ||||
| .map { | .map { | ||||
| OnPackQrJobOrderRequest( | OnPackQrJobOrderRequest( | ||||
| @@ -890,9 +955,34 @@ class PlasticBagPrinterService( | |||||
| val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() | val codesUpper = exportItemsRaw.map { it.first.uppercase() }.toSet() | ||||
| val allowedTextCodes = codesOnPackMatchingTemplateType(codesUpper, "text") | 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() | val baos = ByteArrayOutputStream() | ||||
| ZipOutputStream(baos).use { zos -> | ZipOutputStream(baos).use { zos -> | ||||
| @@ -904,10 +994,7 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| val imageFileName = "${codeLower.uppercase()}.image" | val imageFileName = "${codeLower.uppercase()}.image" | ||||
| val expiryLabel = if (includeExpiry) { | val expiryLabel = if (includeExpiry) { | ||||
| itemDefaultShelfLifeService.expiryDatePrintLabel( | |||||
| codeLower, | |||||
| printDate ?: ItemDefaultShelfLifeService.today(), | |||||
| ) | |||||
| itemDefaultShelfLifeService.expiryDatePrintLabel(codeLower, effectivePrintDate) | |||||
| } else { | } else { | ||||
| null | null | ||||
| } | } | ||||
| @@ -939,7 +1026,7 @@ class PlasticBagPrinterService( | |||||
| } | } | ||||
| require(addedEntries.isNotEmpty()) { "No OnPack text template files could be generated for the selected date" } | 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 { | val zipBytes = try { | ||||
| generateOnPackQrTextZip(jobOrders) | |||||
| generateOnPackQrTextZip(jobOrders).bytes | |||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| logger.warn("OnPack text ZIP generation failed before NGPCL push", e) | logger.warn("OnPack text ZIP generation failed before NGPCL push", e) | ||||
| return NgpclPushResponse( | return NgpclPushResponse( | ||||
| @@ -1030,6 +1117,26 @@ class PlasticBagPrinterService( | |||||
| return fromTable + fromDbFiles | 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? { | private fun loadOnPackImageTemplateOrNull(codeLower: String, forExpiry: Boolean = false): ByteArray? { | ||||
| onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it } | onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_JUICE, codeLower)?.let { return it } | ||||
| if (forExpiry) { | if (forExpiry) { | ||||
| @@ -1,6 +1,9 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web | package com.ffii.fpsms.modules.jobOrder.web | ||||
| import com.ffii.fpsms.modules.jobOrder.service.OnPackTemplateFileService | 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.OnPackSupportedCatalogDto | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto | import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateFileDto | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.OnPackTemplateUploadResponse | 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.GetMapping | ||||
| import org.springframework.web.bind.annotation.PathVariable | import org.springframework.web.bind.annotation.PathVariable | ||||
| import org.springframework.web.bind.annotation.PostMapping | 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.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| @@ -33,6 +38,33 @@ class OnPackTemplateController( | |||||
| return onPackTemplateFileService.supportedCatalog() | 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 | @PostMapping | ||||
| fun upload( | fun upload( | ||||
| @RequestParam machine: String, | @RequestParam machine: String, | ||||
| @@ -52,4 +84,9 @@ class OnPackTemplateController( | |||||
| fun badRequest(e: IllegalArgumentException): ResponseEntity<Map<String, String>> { | fun badRequest(e: IllegalArgumentException): ResponseEntity<Map<String, String>> { | ||||
| return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(mapOf("message" to (e.message ?: "Invalid request"))) | 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 | package com.ffii.fpsms.modules.jobOrder.web | ||||
| import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService | 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.service.PlasticBagPrinterService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | ||||
| @@ -119,15 +120,8 @@ class PlasticBagPrinterController( | |||||
| response: HttpServletResponse, | response: HttpServletResponse, | ||||
| ) { | ) { | ||||
| try { | 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) { | } catch (e: IllegalArgumentException) { | ||||
| response.status = HttpServletResponse.SC_BAD_REQUEST | response.status = HttpServletResponse.SC_BAD_REQUEST | ||||
| response.contentType = "text/plain;charset=UTF-8" | 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. | * 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. | * Old [downloadOnPackQr] is unchanged. | ||||
| */ | */ | ||||
| @PostMapping("/download-onpack-qr-with-expiry") | @PostMapping("/download-onpack-qr-with-expiry") | ||||
| @@ -160,19 +154,12 @@ class PlasticBagPrinterController( | |||||
| response: HttpServletResponse, | response: HttpServletResponse, | ||||
| ) { | ) { | ||||
| try { | try { | ||||
| val zipBytes = plasticBagPrinterService.generateOnPackQrZip( | |||||
| val zip = plasticBagPrinterService.generateOnPackQrZip( | |||||
| request.jobOrders, | request.jobOrders, | ||||
| includeExpiry = true, | includeExpiry = true, | ||||
| printDate = request.planDate, | 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) { | } catch (e: IllegalArgumentException) { | ||||
| response.status = HttpServletResponse.SC_BAD_REQUEST | response.status = HttpServletResponse.SC_BAD_REQUEST | ||||
| response.contentType = "text/plain;charset=UTF-8" | response.contentType = "text/plain;charset=UTF-8" | ||||
| @@ -201,15 +188,8 @@ class PlasticBagPrinterController( | |||||
| response: HttpServletResponse, | response: HttpServletResponse, | ||||
| ) { | ) { | ||||
| try { | 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) { | } catch (e: IllegalArgumentException) { | ||||
| response.status = HttpServletResponse.SC_BAD_REQUEST | response.status = HttpServletResponse.SC_BAD_REQUEST | ||||
| response.contentType = "text/plain;charset=UTF-8" | response.contentType = "text/plain;charset=UTF-8" | ||||
| @@ -250,19 +230,12 @@ class PlasticBagPrinterController( | |||||
| response: HttpServletResponse, | response: HttpServletResponse, | ||||
| ) { | ) { | ||||
| try { | try { | ||||
| val zipBytes = plasticBagPrinterService.generateOnPackQrTextZip( | |||||
| val zip = plasticBagPrinterService.generateOnPackQrTextZip( | |||||
| request.jobOrders, | request.jobOrders, | ||||
| includeExpiry = true, | includeExpiry = true, | ||||
| printDate = request.planDate, | 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) { | } catch (e: IllegalArgumentException) { | ||||
| response.status = HttpServletResponse.SC_BAD_REQUEST | response.status = HttpServletResponse.SC_BAD_REQUEST | ||||
| response.contentType = "text/plain;charset=UTF-8" | 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 juice: List<OnPackSupportedItemDto>, | ||||
| val lemon: 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 findByDeletedFalseAndItemCodeIgnoreCase(itemCode: String): ItemDefaultShelfLife? | ||||
| fun findByDeletedFalseAndItemCodeIn(itemCodes: Collection<String>): List<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 findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; | ||||
| fun findByCodeAndDeletedFalse(code: String): Items?; | fun findByCodeAndDeletedFalse(code: String): Items?; | ||||
| fun findByDeletedFalseAndCodeIn(codes: Collection<String>): List<Items> | |||||
| fun findByNameAndDeletedFalse(name: String): Items?; | fun findByNameAndDeletedFalse(name: String): Items?; | ||||
| fun findByM18IdAndDeletedIsFalse(m18Id: Long): 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.ItemDefaultShelfLife | ||||
| import com.ffii.fpsms.modules.master.entity.ItemDefaultShelfLifeRepository | 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.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.ZoneId | import java.time.ZoneId | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| @@ -17,10 +23,11 @@ data class ItemPrintShelfLife( | |||||
| * Uses [ItemDefaultShelfLife.useMinus18] to pick chilled [defaultDays] vs [minus18Days]. | * Uses [ItemDefaultShelfLife.useMinus18] to pick chilled [defaultDays] vs [minus18Days]. | ||||
| */ | */ | ||||
| @Service | @Service | ||||
| class ItemDefaultShelfLifeService( | |||||
| open class ItemDefaultShelfLifeService( | |||||
| private val repository: ItemDefaultShelfLifeRepository, | 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() } } | val normalized = codes.mapNotNull { it?.trim()?.uppercase()?.takeIf { code -> code.isNotEmpty() } } | ||||
| .distinct() | .distinct() | ||||
| if (normalized.isEmpty()) return emptyMap() | if (normalized.isEmpty()) return emptyMap() | ||||
| @@ -33,31 +40,154 @@ class ItemDefaultShelfLifeService( | |||||
| .toMap() | .toMap() | ||||
| } | } | ||||
| fun defaultDaysByItemCodes(codes: Collection<String?>): Map<String, Int> = | |||||
| open fun defaultDaysByItemCodes(codes: Collection<String?>): Map<String, Int> = | |||||
| printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } | printShelfLifeByItemCodes(codes).mapValues { it.value.effectiveDays } | ||||
| fun defaultDays(itemCode: String?): Int? { | |||||
| open fun defaultDays(itemCode: String?): Int? { | |||||
| val code = itemCode?.trim()?.uppercase().orEmpty() | val code = itemCode?.trim()?.uppercase().orEmpty() | ||||
| if (code.isEmpty()) return null | if (code.isEmpty()) return null | ||||
| val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) ?: return null | val row = repository.findByDeletedFalseAndItemCodeIgnoreCase(code) ?: return null | ||||
| return effectiveDays(row) | 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 | val days = defaultDays(itemCode) ?: return null | ||||
| return expiryOn(printDate, days) | 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() | 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) | expiryDate(itemCode, printDate)?.format(COMPACT) | ||||
| /** Printed bag wording, e.g. `Expiry Date 20260821`. */ | /** 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) } | 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 { | companion object { | ||||
| val PRINT_ZONE: ZoneId = ZoneId.of("Asia/Hong_Kong") | val PRINT_ZONE: ZoneId = ZoneId.of("Asia/Hong_Kong") | ||||
| private val COMPACT: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE | private val COMPACT: DateTimeFormatter = DateTimeFormatter.BASIC_ISO_DATE | ||||
| @@ -79,5 +209,34 @@ class ItemDefaultShelfLifeService( | |||||
| val chosen = if (useMinus18) minus18Days else defaultDays | val chosen = if (useMinus18) minus18Days else defaultDays | ||||
| return chosen?.takeIf { it > 0 } | 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 | 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.assertEquals | ||||
| import org.junit.jupiter.api.Assertions.assertThrows | |||||
| import org.junit.jupiter.api.Test | import org.junit.jupiter.api.Test | ||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| class ItemDefaultShelfLifeServiceTest { | class ItemDefaultShelfLifeServiceTest { | ||||
| @@ -42,4 +45,23 @@ class ItemDefaultShelfLifeServiceTest { | |||||
| ItemDefaultShelfLifeService.formatProductionDatePrintLabel(LocalDate.of(2026, 8, 21)), | 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", | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | } | ||||