| @@ -129,7 +129,7 @@ public abstract class SettingNames { | |||||
| /** Laser marking TCP port (default 45678) */ | /** Laser marking TCP port (default 45678) */ | ||||
| public static final String LASER_PRINT_PORT = "LASER_PRINT.port"; | 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"; | 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.) */ | /** 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( | data class EmailAlertProperties( | ||||
| /** Send sync alerts by email (uses DB MAIL.smtp.* — Office 365). */ | /** Send sync alerts by email (uses DB MAIL.smtp.* — Office 365). */ | ||||
| val enabled: Boolean = false, | val enabled: Boolean = false, | ||||
| /** Comma-separated recipients, e.g. [email protected],[email protected] */ | |||||
| /** Comma-separated recipients. */ | |||||
| val toAddresses: String = "", | val toAddresses: String = "", | ||||
| /** Subject prefix; full subject is prefix + job + code in brackets. */ | /** Subject prefix; full subject is prefix + job + code in brackets. */ | ||||
| val subjectPrefix: String = "FPSMS M18 sync alert", | val subjectPrefix: String = "FPSMS M18 sync alert", | ||||
| @@ -424,7 +424,7 @@ open class SchedulerSyncAlertService( | |||||
| .map { it.trim() } | .map { it.trim() } | ||||
| .filter { it.isNotBlank() } | .filter { it.isNotBlank() } | ||||
| if (emailTo.isEmpty()) { | 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()) { | if (!syncAlertEmailSender.isSmtpConfigured()) { | ||||
| return syncAlertEmailSender.smtpConfigIssue() | return syncAlertEmailSender.smtpConfigIssue() | ||||
| @@ -452,7 +452,7 @@ open class SchedulerSyncAlertService( | |||||
| e.message?.contains("not authenticated", ignoreCase = true) == true -> | e.message?.contains("not authenticated", ignoreCase = true) == true -> | ||||
| "\n\nOffice 365 fix checklist:\n" + | "\n\nOffice 365 fix checklist:\n" + | ||||
| "1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\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" + | "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" + | "4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" + | ||||
| "5. Restart backend after changing settings (SMTP client is cached)" | "5. Restart backend after changing settings (SMTP client is cached)" | ||||
| @@ -135,6 +135,7 @@ class DemoSandboxSeeder( | |||||
| refuseIfLiveSchemaReadable() | refuseIfLiveSchemaReadable() | ||||
| transactionTemplate.executeWithoutResult { | transactionTemplate.executeWithoutResult { | ||||
| clearLivePrinterDefaults() | |||||
| ensureAuthorities() | ensureAuthorities() | ||||
| val existing = userRepository.findByUsernameAndDeletedFalse(DEMO_USERNAME) | val existing = userRepository.findByUsernameAndDeletedFalse(DEMO_USERNAME) | ||||
| if (existing.isPresent) { | if (existing.isPresent) { | ||||
| @@ -154,6 +155,26 @@ class DemoSandboxSeeder( | |||||
| * The demo login must not be able to read the live company schema. | * 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. | * 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() { | private fun refuseIfLiveSchemaReadable() { | ||||
| try { | try { | ||||
| jdbcTemplate.queryForObject("SELECT 1 FROM fpsmsdb.`user` LIMIT 1", Int::class.java) | 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_2F = "DO.floor.suppliers.2F" | ||||
| private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F" | 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> { | open fun supplierCodesFromSetting(settingName: String, defaultList: List<String>): List<String> { | ||||
| @@ -33,7 +33,7 @@ open class DoFloorSupplierSettingsService( | |||||
| } | } | ||||
| open fun allowedSupplierCodesForFloor(floor: String?): List<String> { | 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() | val (codes2F, codes4F) = loadDoFloorSupplierLists() | ||||
| return when { | return when { | ||||
| f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct() | f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct() | ||||
| @@ -106,9 +106,9 @@ class PlasticBagPrinterService( | |||||
| private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") | private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong") | ||||
| companion object { | 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_BAG2_PORT = 45678 | ||||
| private const val DEFAULT_LASER_ITEM_CODES = "PP1175" | |||||
| private const val DEFAULT_LASER_ITEM_CODES = "" | |||||
| private const val PACKAGING_PROCESS_NAME = "包裝" | private const val PACKAGING_PROCESS_NAME = "包裝" | ||||
| /** Designer product BMP canvas (LOGO), 1-bit. */ | /** Designer product BMP canvas (LOGO), 1-bit. */ | ||||
| private const val ONPACK_PRODUCT_BMP_WIDTH = 593 | private const val ONPACK_PRODUCT_BMP_WIDTH = 593 | ||||
| @@ -1226,7 +1226,7 @@ class PlasticBagPrinterService( | |||||
| return resource.inputStream.use { it.readBytes() } | 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? { | private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? { | ||||
| onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } | onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it } | ||||
| val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image" | ||||
| @@ -1,7 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.jobOrder.web.model | 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. | * @param lastReceiveSuccess Last job where the laser printer returned a receive ack (from settings JSON); null if never recorded or empty. | ||||
| */ | */ | ||||
| data class LaserBag2SettingsResponse( | data class LaserBag2SettingsResponse( | ||||
| @@ -55,10 +55,36 @@ class StockItemConsumptionTrendReportService( | |||||
| return "AND (${conditions.joinToString(" OR ")})" | 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. | * Queries the database for Stock Item Consumption Trend Report data. | ||||
| * Flow: | * 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 | * 2. Use items.code to match stock_ledger.itemCode | ||||
| * 3. Aggregate stock_ledger.outQty by month based on stock_ledger.modified | * 3. Aggregate stock_ledger.outQty by month based on stock_ledger.modified | ||||
| * Supports comma-separated values for stockCategory, stockSubCategory, and itemCode. | * Supports comma-separated values for stockCategory, stockSubCategory, and itemCode. | ||||
| @@ -72,30 +98,7 @@ class StockItemConsumptionTrendReportService( | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<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 | // itemCode now contains prefixes (first 2 letters), so we use LIKE to match codes starting with those prefixes | ||||
| val itemCodeSql = if (!itemCode.isNullOrBlank()) { | val itemCodeSql = if (!itemCode.isNullOrBlank()) { | ||||
| @@ -176,30 +179,12 @@ class StockItemConsumptionTrendReportService( | |||||
| /** | /** | ||||
| * Gets list of item code prefixes (first 2 letters) based on stockCategory filter. | * 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. | * Returns a list of maps with "prefix" key. | ||||
| */ | */ | ||||
| fun getStockItemCodePrefixes(stockCategory: String?): List<Map<String, String>> { | fun getStockItemCodePrefixes(stockCategory: String?): List<Map<String, String>> { | ||||
| val args = mutableMapOf<String, Any>() | 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 = """ | val sql = """ | ||||
| SELECT DISTINCT LEFT(it.code, 2) as prefix | SELECT DISTINCT LEFT(it.code, 2) as prefix | ||||
| @@ -178,7 +178,11 @@ stock_one AS ( | |||||
| 4 | 4 | ||||
| ) | ) | ||||
| END AS avgUnitPriceRaw, | 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 | FROM inventory_lot_line ill | ||||
| INNER JOIN inventory_lot il | INNER JOIN inventory_lot il | ||||
| ON il.id = ill.inventoryLotId AND il.deleted = 0 | ON il.id = ill.inventoryLotId AND il.deleted = 0 | ||||
| @@ -372,8 +376,8 @@ stock_one AS ( | |||||
| val avg = toDouble(r["avgUnitPriceRaw"]) | val avg = toDouble(r["avgUnitPriceRaw"]) | ||||
| val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" | val key = "${r["itemNo"]}|${r["unitOfMeasure"]}" | ||||
| val last = lastTrnByLot[lotId] | 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) | val row = HashMap<String, Any>(24) | ||||
| row["inventoryLotLineId"] = lotId.toString() | row["inventoryLotLineId"] = lotId.toString() | ||||
| row["remarks"] = r["remarks"] ?: "" | row["remarks"] = r["remarks"] ?: "" | ||||
| @@ -419,14 +423,17 @@ stock_one AS ( | |||||
| return v.toString().toLongOrNull() ?: 0L | 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 { | private fun buildLotOriginFilterSql(lotOrigin: String?): String { | ||||
| val v = lotOrigin?.trim().orEmpty() | val v = lotOrigin?.trim().orEmpty() | ||||
| if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" | if (v.isBlank() || v.equals("All", ignoreCase = true)) return "" | ||||
| return when (v.lowercase()) { | 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 -> "" | else -> "" | ||||
| } | } | ||||
| } | } | ||||
| @@ -79,14 +79,11 @@ lot_root_origin AS ( | |||||
| private const val LOT_ROOT_CATEGORY_SQL = """ | private const val LOT_ROOT_CATEGORY_SQL = """ | ||||
| CASE | 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, ''))) = 'OPEN' THEN '期初存貨' | ||||
| WHEN UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'ADJ' 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""" | END""" | ||||
| private const val LOT_TYPE_DISPLAY_SQL = """ | private const val LOT_TYPE_DISPLAY_SQL = """ | ||||
| @@ -1007,26 +1004,27 @@ ORDER BY | |||||
| * - 篩選用 root_sil / root_po(根來源類型) | * - 篩選用 root_sil / root_po(根來源類型) | ||||
| */ | */ | ||||
| private fun buildLotTypeFilterClause(type: String?): String { | 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 多值工具方法 */ | /** LIKE 多值工具方法 */ | ||||
| @@ -10,7 +10,7 @@ import java.time.LocalDateTime | |||||
| * stockInLineId and itemId are for QR code: {"itemId": xxx, "stockInLineId": xxx} | * stockInLineId and itemId are for QR code: {"itemId": xxx, "stockInLineId": xxx} | ||||
| * lotNo replaces job order no. on the label display. | * lotNo replaces job order no. on the label display. | ||||
| * [itemName] is BOM/item display name plus stock UOM [udfudesc] in parentheses when appropriate; | * [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( | data class PyJobOrderListItem( | ||||
| val id: Long, | val id: Long, | ||||
| @@ -57,9 +57,9 @@ object PyJobOrderListMapper { | |||||
| /** | /** | ||||
| * Item codes where /laserPrint must not append stock UOM to [itemName] (laser field length limit). | * 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). | * Display name for GET /py/laser-job-orders and /plastic/laser-job-orders (/laserPrint page). | ||||
| @@ -61,10 +61,10 @@ scheduler: | |||||
| to-numbers: "" | to-numbers: "" | ||||
| email: | email: | ||||
| enabled: true | enabled: true | ||||
| to-addresses: "[email protected],[email protected]" | |||||
| to-addresses: "" | |||||
| po-line: | po-line: | ||||
| enabled: true | 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 Bag2 (/laserPrint) TCP auto-send; uses LASER_PRINT host/port/itemCodes from DB and sends first matching job only. | ||||
| laser: | laser: | ||||
| @@ -57,7 +57,7 @@ scheduler: | |||||
| content-sid: ${TWILIO_WHATSAPP_CONTENT_SID:} | content-sid: ${TWILIO_WHATSAPP_CONTENT_SID:} | ||||
| email: | email: | ||||
| enabled: ${SYNC_ALERT_EMAIL_ENABLED:true} | enabled: ${SYNC_ALERT_EMAIL_ENABLED:true} | ||||
| to-addresses: "[email protected],[email protected]" | |||||
| to-addresses: "" | |||||
| do1: | do1: | ||||
| min-records-processed: 400 | min-records-processed: 400 | ||||
| grace-minutes-after-schedule: 30 | grace-minutes-after-schedule: 30 | ||||
| @@ -11,20 +11,20 @@ class LaserBag2PayloadTest { | |||||
| @Test | @Test | ||||
| fun payload_without_expiry_keeps_original_three_fields() { | fun payload_without_expiry_keeps_original_three_fields() { | ||||
| assertEquals( | 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( | assertEquals( | ||||
| "0;PP1175;鮮檸檬汁;;", | |||||
| PlasticBagPrinterService.buildLaserBag2Payload(null, null, "PP1175", "鮮檸檬汁"), | |||||
| "0;DEMO-FG-01;鄉村麵包;;", | |||||
| PlasticBagPrinterService.buildLaserBag2Payload(null, null, "DEMO-FG-01", "鄉村麵包"), | |||||
| ) | ) | ||||
| } | } | ||||
| @Test | @Test | ||||
| fun payload_with_iso_expiry_adds_fourth_print_label_field() { | fun payload_with_iso_expiry_adds_fourth_print_label_field() { | ||||
| assertEquals( | 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", | "stockOut" to "0", | ||||
| "cumBalance" to "1,210", | "cumBalance" to "1,210", | ||||
| "orderRefNo" to "DO-20260901-${i % 1000}", | "orderRefNo" to "DO-20260901-${i % 1000}", | ||||
| "storeLocation" to "W201", | |||||
| "storeLocation" to "DEMO-A", | |||||
| "totalStockIn" to "400", | "totalStockIn" to "400", | ||||
| "totalStockOut" to "0", | "totalStockOut" to "0", | ||||
| "totalCumBalance" to "1,210", | "totalCumBalance" to "1,210", | ||||
| @@ -66,14 +66,14 @@ class PyJobOrderListMapperTest { | |||||
| } | } | ||||
| @Test | @Test | ||||
| fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() { | |||||
| fun buildDisplayItemNameForLaser_appends_uom_when_name_has_none() { | |||||
| assertEquals( | assertEquals( | ||||
| "鮮檸檬汁(P+4)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"), | |||||
| "鄉村麵包($stockUom)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("鄉村麵包", stockUom, "DEMO-FG-01"), | |||||
| ) | ) | ||||
| assertEquals( | assertEquals( | ||||
| "咖哩汁(1包X2磅)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"), | |||||
| "奶包(1包X2磅)", | |||||
| PyJobOrderListMapper.buildDisplayItemNameForLaser("奶包", stockUom, "DEMO-FG-02"), | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -92,7 +92,7 @@ class PyJobOrderListMapperTest { | |||||
| @Test | @Test | ||||
| fun shelfLifeForItem_is_null_when_item_missing() { | fun shelfLifeForItem_is_null_when_item_missing() { | ||||
| val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( | val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem( | ||||
| "PP1175", | |||||
| "DEMO-FG-01", | |||||
| emptyMap(), | emptyMap(), | ||||
| LocalDate.of(2026, 8, 20), | LocalDate.of(2026, 8, 20), | ||||
| ) | ) | ||||