Procházet zdrojové kódy

added for the default expiry date

fix負數倉
Fai Luk před 4 týdny
rodič
revize
76742d5fe7
16 změnil soubory, kde provedl 845 přidání a 84 odebrání
  1. +1
    -1
      src/main/java/com/ffii/fpsms/config/WebConfig.java
  2. +34
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt
  3. +214
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt
  4. +144
    -37
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  5. +37
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt
  6. +21
    -37
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt
  7. +24
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt
  8. +6
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt
  9. +1
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt
  10. +167
    -8
      src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt
  11. +64
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt
  12. +18
    -0
      src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql
  13. +44
    -0
      src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql
  14. +6
    -0
      src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql
  15. +42
    -0
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt
  16. +22
    -0
      src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt

+ 1
- 1
src/main/java/com/ffii/fpsms/config/WebConfig.java Zobrazit soubor

@@ -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");

}


+ 34
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181Master.kt Zobrazit soubor

@@ -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))
}
}

+ 214
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/service/OnPackTemplateFileService.kt Zobrazit soubor

@@ -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> {


+ 144
- 37
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt Zobrazit soubor

@@ -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) {


+ 37
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/OnPackTemplateController.kt Zobrazit soubor

@@ -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")))
}
}

+ 21
- 37
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt Zobrazit soubor

@@ -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()
}

}

+ 24
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/OnPackTemplateModels.kt Zobrazit soubor

@@ -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,
)

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemDefaultShelfLifeRepository.kt Zobrazit soubor

@@ -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>
}

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt Zobrazit soubor

@@ -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?;


+ 167
- 8
src/main/java/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeService.kt Zobrazit soubor

@@ -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")
}
}
}
}

+ 64
- 0
src/main/java/com/ffii/fpsms/modules/master/web/ItemDefaultShelfLifeController.kt Zobrazit soubor

@@ -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,
)

+ 18
- 0
src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/01_create_onpack_expiry_item_code.sql Zobrazit soubor

@@ -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`)
);

+ 44
- 0
src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/02_seed_onpack_expiry_item_code_pp.sql Zobrazit soubor

@@ -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');

+ 6
- 0
src/main/resources/db/changelog/changes/20260822_onpack_expiry_item_code/03_add_print_name.sql Zobrazit soubor

@@ -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`;

+ 42
- 0
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/OnPackPp1181MasterTest.kt Zobrazit soubor

@@ -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))
}
}

+ 22
- 0
src/test/kotlin/com/ffii/fpsms/modules/master/service/ItemDefaultShelfLifeServiceTest.kt Zobrazit soubor

@@ -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",
)
}
}
}

Načítá se…
Zrušit
Uložit