| @@ -129,7 +129,7 @@ public abstract class SettingNames { | |||
| /** Laser marking TCP port (default 45678) */ | |||
| public static final String LASER_PRINT_PORT = "LASER_PRINT.port"; | |||
| /** Comma-separated BOM item codes shown on /laserPrint job list (e.g. PP1175); blank = no filter (all packaging JOs) */ | |||
| /** Comma-separated BOM item codes shown on /laserPrint job list; blank = no filter (all packaging JOs) */ | |||
| public static final String LASER_PRINT_ITEM_CODES = "LASER_PRINT.itemCodes"; | |||
| /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ | |||
| @@ -46,7 +46,7 @@ data class SmsProperties( | |||
| data class EmailAlertProperties( | |||
| /** Send sync alerts by email (uses DB MAIL.smtp.* — Office 365). */ | |||
| val enabled: Boolean = false, | |||
| /** Comma-separated recipients, e.g. [email protected],[email protected] */ | |||
| /** Comma-separated recipients. */ | |||
| val toAddresses: String = "", | |||
| /** Subject prefix; full subject is prefix + job + code in brackets. */ | |||
| val subjectPrefix: String = "FPSMS M18 sync alert", | |||
| @@ -424,7 +424,7 @@ open class SchedulerSyncAlertService( | |||
| .map { it.trim() } | |||
| .filter { it.isNotBlank() } | |||
| if (emailTo.isEmpty()) { | |||
| return "Set scheduler.sync-alert.email.to-addresses (e.g. [email protected])" | |||
| return "Set scheduler.sync-alert.email.to-addresses" | |||
| } | |||
| if (!syncAlertEmailSender.isSmtpConfigured()) { | |||
| return syncAlertEmailSender.smtpConfigIssue() | |||
| @@ -452,7 +452,7 @@ open class SchedulerSyncAlertService( | |||
| e.message?.contains("not authenticated", ignoreCase = true) == true -> | |||
| "\n\nOffice 365 fix checklist:\n" + | |||
| "1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\n" + | |||
| "2. MAIL.smtp.username must match the mailbox ([email protected])\n" + | |||
| "2. MAIL.smtp.username must match the sending mailbox\n" + | |||
| "3. Use an App password if MFA is on (not your normal login password)\n" + | |||
| "4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" + | |||
| "5. Restart backend after changing settings (SMTP client is cached)" | |||
| @@ -135,6 +135,7 @@ class DemoSandboxSeeder( | |||
| refuseIfLiveSchemaReadable() | |||
| transactionTemplate.executeWithoutResult { | |||
| clearLivePrinterDefaults() | |||
| ensureAuthorities() | |||
| val existing = userRepository.findByUsernameAndDeletedFalse(DEMO_USERNAME) | |||
| if (existing.isPresent) { | |||
| @@ -154,6 +155,26 @@ class DemoSandboxSeeder( | |||
| * The demo login must not be able to read the live company schema. | |||
| * MySQL error 1142/1044 means access denied; 1049 means that schema is not visible. | |||
| */ | |||
| /** Drop live printer host and item-code defaults copied in with the schema. */ | |||
| private fun clearLivePrinterDefaults() { | |||
| jdbcTemplate.update( | |||
| """ | |||
| UPDATE settings | |||
| SET value = '' | |||
| WHERE name = 'LASER_PRINT.host' | |||
| AND value IN ('192.168.18.77', '192.168.17.10') | |||
| """.trimIndent(), | |||
| ) | |||
| jdbcTemplate.update( | |||
| """ | |||
| UPDATE settings | |||
| SET value = '' | |||
| WHERE name = 'LASER_PRINT.itemCodes' | |||
| AND value IN ('PP1175', 'PP2211') | |||
| """.trimIndent(), | |||
| ) | |||
| } | |||
| private fun refuseIfLiveSchemaReadable() { | |||
| try { | |||
| jdbcTemplate.queryForObject("SELECT 1 FROM fpsmsdb.`user` LIMIT 1", Int::class.java) | |||
| @@ -13,8 +13,8 @@ open class DoFloorSupplierSettingsService( | |||
| private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F" | |||
| private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F" | |||
| private val DEFAULT_SUPPLIERS_2F = listOf("P07", "P06D", "P06Y") | |||
| private val DEFAULT_SUPPLIERS_4F = listOf("P06B") | |||
| private val DEFAULT_SUPPLIERS_2F = listOf("DEMO-SUP") | |||
| private val DEFAULT_SUPPLIERS_4F = listOf("DEMO-SUP") | |||
| } | |||
| open fun supplierCodesFromSetting(settingName: String, defaultList: List<String>): List<String> { | |||
| @@ -33,7 +33,7 @@ open class DoFloorSupplierSettingsService( | |||
| } | |||
| open fun allowedSupplierCodesForFloor(floor: String?): List<String> { | |||
| val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty() | |||
| val f = floor?.trim()?.replace("/", "")?.uppercase(Locale.ROOT).orEmpty() | |||
| val (codes2F, codes4F) = loadDoFloorSupplierLists() | |||
| return when { | |||
| f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct() | |||
| @@ -106,9 +106,9 @@ class PlasticBagPrinterService( | |||
| private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") | |||
| companion object { | |||
| private const val DEFAULT_LASER_BAG2_HOST = "192.168.18.77" | |||
| private const val DEFAULT_LASER_BAG2_HOST = "" | |||
| private const val DEFAULT_LASER_BAG2_PORT = 45678 | |||
| private const val DEFAULT_LASER_ITEM_CODES = "PP1175" | |||
| private const val DEFAULT_LASER_ITEM_CODES = "" | |||
| private const val PACKAGING_PROCESS_NAME = "包裝" | |||
| /** Designer product BMP canvas (LOGO), 1-bit. */ | |||
| private const val ONPACK_PRODUCT_BMP_WIDTH = 593 | |||
| @@ -1226,7 +1226,7 @@ class PlasticBagPrinterService( | |||
| return resource.inputStream.use { it.readBytes() } | |||
| } | |||
| /** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */ | |||
| /** Template files on classpath use uppercase code, e.g. `onpack2030_2/DEMO-FG-01.image`. */ | |||
| private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { | |||
| onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } | |||
| val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | |||
| @@ -1,7 +1,7 @@ | |||
| package com.ffii.fpsms.modules.jobOrder.web.model | |||
| /** | |||
| * @param itemCodes Comma-separated item codes for the laser job list filter (e.g. `PP1175` or `PP1175,AB123`). Empty string means no filter (show all packaging job orders). | |||
| * @param itemCodes Comma-separated item codes for the laser job list filter. Empty string means no filter (show all packaging job orders). | |||
| * @param lastReceiveSuccess Last job where the laser printer returned a receive ack (from settings JSON); null if never recorded or empty. | |||
| */ | |||
| data class LaserBag2SettingsResponse( | |||
| @@ -55,10 +55,36 @@ class StockItemConsumptionTrendReportService( | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| private val itemTypes = listOf("mat", "sfg", "fg") | |||
| /** Item types used by the demo catalogue. "All" includes every one of them. */ | |||
| private fun itemTypeFilterSql( | |||
| stockCategory: String?, | |||
| args: MutableMap<String, Any>, | |||
| paramPrefix: String, | |||
| ): String { | |||
| val selected = if ( | |||
| stockCategory.isNullOrBlank() || | |||
| stockCategory == "All" || | |||
| stockCategory.contains("All") | |||
| ) { | |||
| itemTypes | |||
| } else { | |||
| stockCategory.split(",").map { it.trim() }.filter { it in itemTypes } | |||
| } | |||
| val categories = selected.ifEmpty { itemTypes } | |||
| val conditions = categories.mapIndexed { index, cat -> | |||
| val paramName = "${paramPrefix}_$index" | |||
| args[paramName] = cat | |||
| "it.type = :$paramName" | |||
| } | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| /** | |||
| * Queries the database for Stock Item Consumption Trend Report data. | |||
| * Flow: | |||
| * 1. Filter items by type (MAT, WIP) - this is the stockCategory filter | |||
| * 1. Filter items by type (mat, sfg, fg) - this is the stockCategory filter | |||
| * 2. Use items.code to match stock_ledger.itemCode | |||
| * 3. Aggregate stock_ledger.outQty by month based on stock_ledger.modified | |||
| * Supports comma-separated values for stockCategory, stockSubCategory, and itemCode. | |||
| @@ -72,30 +98,7 @@ class StockItemConsumptionTrendReportService( | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| // Filter by stockCategory from items.type (MAT/WIP/NM/FG/CMB) | |||
| // Supports multiple categories separated by comma (e.g., "MAT,WIP,NM") | |||
| // Valid categories: MAT, WIP, NM, FG, CMB | |||
| // If "All" is selected or empty, show all valid categories | |||
| // If specific categories are selected, show only those | |||
| val validCategories = setOf("MAT", "WIP", "NM", "FG", "CMB") | |||
| val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | |||
| // Handle multiple categories (comma-separated) | |||
| val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" && validCategories.contains(it) } | |||
| if (categories.isNotEmpty()) { | |||
| val conditions = categories.mapIndexed { index, cat -> | |||
| val paramName = "stockCategory_$index" | |||
| args[paramName] = cat | |||
| "it.type = :$paramName" | |||
| } | |||
| "AND (${conditions.joinToString(" OR ")})" | |||
| } else { | |||
| // If no valid categories, default to all valid categories | |||
| "AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')" | |||
| } | |||
| } else { | |||
| // If "All" or empty, filter to all valid categories | |||
| "AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')" | |||
| } | |||
| val stockCategorySql = itemTypeFilterSql(stockCategory, args, "stockCategory") | |||
| // itemCode now contains prefixes (first 2 letters), so we use LIKE to match codes starting with those prefixes | |||
| val itemCodeSql = if (!itemCode.isNullOrBlank()) { | |||
| @@ -176,30 +179,12 @@ class StockItemConsumptionTrendReportService( | |||
| /** | |||
| * Gets list of item code prefixes (first 2 letters) based on stockCategory filter. | |||
| * Supports multiple categories separated by comma (e.g., "MAT,WIP,NM"). | |||
| * If stockCategory is "All" or null, returns prefixes for all valid categories (MAT, WIP, NM, FG, CMB). | |||
| * If stockCategory is "All" or null, returns prefixes for mat, sfg, and fg. | |||
| * Returns a list of maps with "prefix" key. | |||
| */ | |||
| fun getStockItemCodePrefixes(stockCategory: String?): List<Map<String, String>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val validCategories = setOf("MAT", "WIP", "NM", "FG", "CMB") | |||
| val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | |||
| // Handle multiple categories (comma-separated) | |||
| val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" && validCategories.contains(it) } | |||
| if (categories.isNotEmpty()) { | |||
| val conditions = categories.mapIndexed { index, cat -> | |||
| val paramName = "stockCategory_$index" | |||
| args[paramName] = cat | |||
| "it.type = :$paramName" | |||
| } | |||
| "AND (${conditions.joinToString(" OR ")})" | |||
| } else { | |||
| "AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')" | |||
| } | |||
| } else { | |||
| "AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')" | |||
| } | |||
| val stockCategorySql = itemTypeFilterSql(stockCategory, args, "stockCategory") | |||
| val sql = """ | |||
| SELECT DISTINCT LEFT(it.code, 2) as prefix | |||
| @@ -178,7 +178,11 @@ stock_one AS ( | |||
| 4 | |||
| ) | |||
| END AS avgUnitPriceRaw, | |||
| UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix | |||
| CASE | |||
| WHEN root_sil.jobOrderId IS NOT NULL THEN 'production' | |||
| WHEN root_sil.purchaseOrderId IS NOT NULL THEN 'purchase' | |||
| ELSE 'other' | |||
| END AS lotOriginKey | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il | |||
| ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||
| @@ -372,8 +376,8 @@ stock_one AS ( | |||
| val avg = toDouble(r["avgUnitPriceRaw"]) | |||
| val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" | |||
| val last = lastTrnByLot[lotId] | |||
| val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase() | |||
| val showPrice = origin == "PP" || origin == "PF" | |||
| val origin = r["lotOriginKey"]?.toString().orEmpty() | |||
| val showPrice = origin == "purchase" | |||
| val row = HashMap<String, Any>(24) | |||
| row["inventoryLotLineId"] = lotId.toString() | |||
| row["remarks"] = r["remarks"] ?: "" | |||
| @@ -419,14 +423,17 @@ stock_one AS ( | |||
| return v.toString().toLongOrNull() ?: 0L | |||
| } | |||
| /** PP/PF = root PO code prefix after TRF walk; other = not PP/PF (ADJ, TRF of other origin, JO, OPEN, …). */ | |||
| /** purchase = root stock-in from a PO; production = job order; other = neither. */ | |||
| private fun buildLotOriginFilterSql(lotOrigin: String?): String { | |||
| val v = lotOrigin?.trim().orEmpty() | |||
| if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" | |||
| return when (v.lowercase()) { | |||
| "pp" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'" | |||
| "pf" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'" | |||
| "other" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF')" | |||
| "purchase" -> "AND root_sil.purchaseOrderId IS NOT NULL AND root_sil.jobOrderId IS NULL" | |||
| "production" -> "AND root_sil.jobOrderId IS NOT NULL" | |||
| "other" -> """ | |||
| AND (root_sil.purchaseOrderId IS NULL OR root_sil.purchaseOrderId = 0) | |||
| AND root_sil.jobOrderId IS NULL | |||
| """.trimIndent() | |||
| else -> "" | |||
| } | |||
| } | |||
| @@ -79,14 +79,11 @@ lot_root_origin AS ( | |||
| private const val LOT_ROOT_CATEGORY_SQL = """ | |||
| CASE | |||
| WHEN root_sil.jobOrderId IS NOT NULL THEN '工廠生產' | |||
| WHEN root_sil.jobOrderId IS NOT NULL THEN '生產' | |||
| WHEN UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'OPEN' THEN '期初存貨' | |||
| WHEN UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'ADJ' THEN '倉存調整' | |||
| WHEN UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP' THEN 'PP' | |||
| WHEN UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF' THEN 'PF' | |||
| WHEN UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'TOA' THEN 'TOA' | |||
| WHEN root_sil.purchaseOrderId IS NOT NULL THEN '採購入倉' | |||
| ELSE '其他入倉' | |||
| WHEN root_sil.purchaseOrderId IS NOT NULL THEN '採購' | |||
| ELSE '其他' | |||
| END""" | |||
| private const val LOT_TYPE_DISPLAY_SQL = """ | |||
| @@ -1007,26 +1004,27 @@ ORDER BY | |||
| * - 篩選用 root_sil / root_po(根來源類型) | |||
| */ | |||
| private fun buildLotTypeFilterClause(type: String?): String { | |||
| val normalized = type?.trim().orEmpty() | |||
| if (normalized.isBlank() || normalized.equals("all", ignoreCase = true)) return "" | |||
| return when (normalized) { | |||
| "PP" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'" | |||
| "PF" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'" | |||
| "TOA" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'TOA'" | |||
| "工廠生產" -> "AND root_sil.jobOrderId IS NOT NULL" | |||
| "倉存調整" -> "AND UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'ADJ'" | |||
| "期初存貨" -> "AND UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'OPEN'" | |||
| "採購入倉" -> """ | |||
| AND root_sil.purchaseOrderId IS NOT NULL | |||
| AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF', 'TOA') | |||
| """.trimIndent() | |||
| "其他入倉" -> """ | |||
| AND (root_sil.purchaseOrderId IS NULL OR root_sil.purchaseOrderId = 0) | |||
| AND root_sil.jobOrderId IS NULL | |||
| AND UPPER(TRIM(COALESCE(root_sil.type, ''))) NOT IN ('OPEN', 'ADJ') | |||
| """.trimIndent() | |||
| else -> "" | |||
| } | |||
| val parts = type?.split(",") | |||
| ?.map { it.trim() } | |||
| ?.filter { it.isNotBlank() && !it.equals("all", ignoreCase = true) } | |||
| .orEmpty() | |||
| if (parts.isEmpty()) return "" | |||
| val predicates = parts.mapNotNull { lotTypePredicate(it) } | |||
| if (predicates.isEmpty()) return "" | |||
| return "AND (" + predicates.joinToString(" OR ") { "($it)" } + ")" | |||
| } | |||
| private fun lotTypePredicate(type: String): String? = when (type) { | |||
| "purchase" -> "root_sil.purchaseOrderId IS NOT NULL AND root_sil.jobOrderId IS NULL" | |||
| "production" -> "root_sil.jobOrderId IS NOT NULL" | |||
| "adjustment" -> "UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'ADJ'" | |||
| "opening" -> "UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'OPEN'" | |||
| "other" -> """ | |||
| (root_sil.purchaseOrderId IS NULL OR root_sil.purchaseOrderId = 0) | |||
| AND root_sil.jobOrderId IS NULL | |||
| AND UPPER(TRIM(COALESCE(root_sil.type, ''))) NOT IN ('OPEN', 'ADJ') | |||
| """.trimIndent() | |||
| else -> null | |||
| } | |||
| /** LIKE 多值工具方法 */ | |||
| @@ -10,7 +10,7 @@ import java.time.LocalDateTime | |||
| * stockInLineId and itemId are for QR code: {"itemId": xxx, "stockInLineId": xxx} | |||
| * lotNo replaces job order no. on the label display. | |||
| * [itemName] is BOM/item display name plus stock UOM [udfudesc] in parentheses when appropriate; | |||
| * GET /py/laser-job-orders (/laserPrint) may omit that suffix for specific item codes (e.g. PP1175) due to laser name length. | |||
| * GET /py/laser-job-orders (/laserPrint) may omit that suffix for item codes listed for the laser name-length limit. | |||
| */ | |||
| data class PyJobOrderListItem( | |||
| val id: Long, | |||
| @@ -57,9 +57,9 @@ object PyJobOrderListMapper { | |||
| /** | |||
| * Item codes where /laserPrint must not append stock UOM to [itemName] (laser field length limit). | |||
| * BOM base name only, e.g. PP1175 "鮮檸檬汁(P+4)" without "(1包X2磅)" suffix. | |||
| * Empty on the demo branch so no live item code is special-cased. | |||
| */ | |||
| private val LASER_OMIT_APPENDED_STOCK_UOM_ITEM_CODES = setOf("PP1175") | |||
| private val LASER_OMIT_APPENDED_STOCK_UOM_ITEM_CODES = emptySet<String>() | |||
| /** | |||
| * Display name for GET /py/laser-job-orders and /plastic/laser-job-orders (/laserPrint page). | |||
| @@ -61,10 +61,10 @@ scheduler: | |||
| to-numbers: "" | |||
| email: | |||
| enabled: true | |||
| to-addresses: "[email protected],[email protected]" | |||
| to-addresses: "" | |||
| po-line: | |||
| enabled: true | |||
| # From = MAIL.smtp.username in DB (e.g. [email protected] + Gmail app password) | |||
| # From = MAIL.smtp.username in DB settings. | |||
| # Laser Bag2 (/laserPrint) TCP auto-send; uses LASER_PRINT host/port/itemCodes from DB and sends first matching job only. | |||
| laser: | |||
| @@ -57,7 +57,7 @@ scheduler: | |||
| content-sid: ${TWILIO_WHATSAPP_CONTENT_SID:} | |||
| email: | |||
| enabled: ${SYNC_ALERT_EMAIL_ENABLED:true} | |||
| to-addresses: "[email protected],[email protected]" | |||
| to-addresses: "" | |||
| do1: | |||
| min-records-processed: 400 | |||
| grace-minutes-after-schedule: 30 | |||
| @@ -11,20 +11,20 @@ class LaserBag2PayloadTest { | |||
| @Test | |||
| fun payload_without_expiry_keeps_original_three_fields() { | |||
| assertEquals( | |||
| "{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;;", | |||
| PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", null), | |||
| "{\"itemId\":1,\"stockInLineId\":2};DEMO-FG-01;鄉村麵包;;", | |||
| PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "DEMO-FG-01", "鄉村麵包", null), | |||
| ) | |||
| assertEquals( | |||
| "0;PP1175;鮮檸檬汁;;", | |||
| PlasticBagPrinterService.buildLaserBag2Payload(null, null, "PP1175", "鮮檸檬汁"), | |||
| "0;DEMO-FG-01;鄉村麵包;;", | |||
| PlasticBagPrinterService.buildLaserBag2Payload(null, null, "DEMO-FG-01", "鄉村麵包"), | |||
| ) | |||
| } | |||
| @Test | |||
| fun payload_with_iso_expiry_adds_fourth_print_label_field() { | |||
| assertEquals( | |||
| "{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;Expiry Date 20260821;;", | |||
| PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", "2026-08-21"), | |||
| "{\"itemId\":1,\"stockInLineId\":2};DEMO-FG-01;鄉村麵包;Expiry Date 20260821;;", | |||
| PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "DEMO-FG-01", "鄉村麵包", "2026-08-21"), | |||
| ) | |||
| } | |||
| @@ -23,7 +23,7 @@ class StockLedgerPdfSpeedTest { | |||
| "stockOut" to "0", | |||
| "cumBalance" to "1,210", | |||
| "orderRefNo" to "DO-20260901-${i % 1000}", | |||
| "storeLocation" to "W201", | |||
| "storeLocation" to "DEMO-A", | |||
| "totalStockIn" to "400", | |||
| "totalStockOut" to "0", | |||
| "totalCumBalance" to "1,210", | |||
| @@ -66,14 +66,14 @@ class PyJobOrderListMapperTest { | |||
| } | |||
| @Test | |||
| fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { | |||
| fun buildDisplayItemNameForLaser_appends_uom_when_name_has_none() { | |||
| assertEquals( | |||
| "鮮檸檬汁(P+4)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), | |||
| "鄉村麵包($stockUom)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鄉村麵包", stockUom, "DEMO-FG-01"), | |||
| ) | |||
| assertEquals( | |||
| "咖哩汁(1包X2磅)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), | |||
| "奶包(1包X2磅)", | |||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("奶包", stockUom, "DEMO-FG-02"), | |||
| ) | |||
| } | |||
| @@ -92,7 +92,7 @@ class PyJobOrderListMapperTest { | |||
| @Test | |||
| fun shelfLifeForItem_is_null_when_item_missing() { | |||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( | |||
| "PP1175", | |||
| "DEMO-FG-01", | |||
| emptyMap(), | |||
| LocalDate.of(2026, 8, 20), | |||
| ) | |||