| Upphovsman | SHA1 | Meddelande | Datum |
|---|---|---|---|
|
|
44907651da | Disable hardcoded reports | 1 månad sedan |
|
|
79fa957896 | Remove hard-coded placeholders and data | 1 månad sedan |
|
|
90f833aedb | Fix purchase order crash, add demo trucks | 1 månad sedan |
| @@ -3,20 +3,7 @@ package com.ffii.fpsms.m18 | |||
| /** | |||
| * M18 PO [createUid] is stored on [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18CreatedUId]. | |||
| * For these M18 user ids, FPSMS does not post GRN to M18 (see [com.ffii.fpsms.modules.stock.service.StockInLineService]). | |||
| * Remarks are shown on PO stock-in traceability report ([com.ffii.fpsms.modules.report.service.ReportService.searchStockInTraceabilityReport]). | |||
| */ | |||
| object M18GrnRules { | |||
| private val REMARKS_FOR_M18_CREATED_UID: Map<Long, String> = mapOf( | |||
| 2569L to "legato", | |||
| 2676L to "xtech", | |||
| ) | |||
| val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = REMARKS_FOR_M18_CREATED_UID.keys | |||
| /** Display string for PDF/Excel: `2569 (legato)`, or plain id when unknown. */ | |||
| fun formatM18CreatedUidForReport(uid: Long?): String { | |||
| if (uid == null) return "" | |||
| val remark = REMARKS_FOR_M18_CREATED_UID[uid] | |||
| return if (remark != null) "$uid ($remark)" else uid.toString() | |||
| } | |||
| val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = setOf(2569L, 2676L) | |||
| } | |||
| @@ -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,14 +135,18 @@ class DemoSandboxSeeder( | |||
| refuseIfLiveSchemaReadable() | |||
| transactionTemplate.executeWithoutResult { | |||
| clearLivePrinterDefaults() | |||
| ensureAuthorities() | |||
| val existing = userRepository.findByUsernameAndDeletedFalse(DEMO_USERNAME) | |||
| if (existing.isPresent) { | |||
| assignDemoAuthorities(existing.get().id) | |||
| backfillDemoPurchaseLinePrices() | |||
| seedDashboardTrucks() | |||
| log.info("Demo sandbox already present (user '{}').", DEMO_USERNAME) | |||
| return@executeWithoutResult | |||
| } | |||
| seed() | |||
| seedDashboardTrucks() | |||
| log.info("Demo sandbox seeded. Sign in as {} / {}", DEMO_USERNAME, DEMO_PASSWORD) | |||
| } | |||
| } | |||
| @@ -151,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) | |||
| @@ -171,6 +195,233 @@ class DemoSandboxSeeder( | |||
| throw IllegalStateException("Demo database login can read the live schema. Refusing to start.") | |||
| } | |||
| /** | |||
| * Truck board reads delivery_order_pick_order for today, tomorrow, and the day after. | |||
| * If those rows are still on an earlier day, shift them to the current date. | |||
| * Opening the dashboard does this, so a demo on a later day still has trucks to show. | |||
| */ | |||
| @Synchronized | |||
| fun alignDashboardTrucksToToday() { | |||
| val anchored = jdbcTemplate.query( | |||
| """ | |||
| SELECT requiredDeliveryDate | |||
| FROM delivery_order_pick_order | |||
| WHERE ticketNo = 'DEMO-DASH-0-2F-AM-A' AND deleted = 0 | |||
| LIMIT 1 | |||
| """.trimIndent(), | |||
| ) { rs, _ -> rs.getDate("requiredDeliveryDate")?.toLocalDate() }.firstOrNull() | |||
| if (anchored == LocalDate.now()) return | |||
| seedDashboardTrucks() | |||
| } | |||
| private fun seedDashboardTrucks() { | |||
| val loafId = demoItemId("DEMO-FG-01") | |||
| val bunId = demoItemId("DEMO-FG-02") | |||
| val pcsId = jdbcTemplate.query( | |||
| "SELECT id FROM uom_conversion WHERE code = 'PCS' AND deleted = 0 ORDER BY id LIMIT 1", | |||
| ) { rs, _ -> rs.getLong("id") }.firstOrNull() | |||
| for (truck in dashboardTrucks()) { | |||
| val inserted = jdbcTemplate.update( | |||
| """ | |||
| INSERT INTO delivery_order_pick_order ( | |||
| storeId, requiredDeliveryDate, truckDepartureTime, truckLanceCode, | |||
| shopCode, shopName, loadingSequence, ticketNo, ticketStatus, | |||
| handlerName, createdBy, modifiedBy, deleted | |||
| ) | |||
| SELECT ?, DATE_ADD(CURDATE(), INTERVAL ? DAY), ?, ?, ?, ?, ?, ?, ?, '示範倉務', 'demo', 'demo', 0 | |||
| FROM DUAL | |||
| WHERE NOT EXISTS ( | |||
| SELECT 1 FROM delivery_order_pick_order WHERE ticketNo = ? | |||
| ) | |||
| """.trimIndent(), | |||
| truck.storeId, | |||
| truck.dayOffset, | |||
| truck.departure, | |||
| truck.lane, | |||
| truck.shopCode, | |||
| truck.shopName, | |||
| truck.sequence, | |||
| truck.ticketNo, | |||
| truck.status, | |||
| truck.ticketNo, | |||
| ) | |||
| jdbcTemplate.update( | |||
| """ | |||
| UPDATE delivery_order_pick_order | |||
| SET storeId = ?, | |||
| requiredDeliveryDate = DATE_ADD(CURDATE(), INTERVAL ? DAY), | |||
| truckDepartureTime = ?, | |||
| truckLanceCode = ?, | |||
| shopCode = ?, | |||
| shopName = ?, | |||
| loadingSequence = ?, | |||
| ticketStatus = ?, | |||
| handlerName = '示範倉務', | |||
| ticketReleaseTime = CASE | |||
| WHEN ? = 'pending' THEN NULL | |||
| ELSE TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?) | |||
| END, | |||
| ticketCompleteDateTime = CASE | |||
| WHEN ? = 'completed' THEN TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?) | |||
| ELSE NULL | |||
| END, | |||
| deleted = 0 | |||
| WHERE ticketNo = ? | |||
| """.trimIndent(), | |||
| truck.storeId, | |||
| truck.dayOffset, | |||
| truck.departure, | |||
| truck.lane, | |||
| truck.shopCode, | |||
| truck.shopName, | |||
| truck.sequence, | |||
| truck.status, | |||
| truck.status, | |||
| truck.dayOffset, | |||
| truck.releaseTime, | |||
| truck.status, | |||
| truck.dayOffset, | |||
| truck.completeTime, | |||
| truck.ticketNo, | |||
| ) | |||
| jdbcTemplate.update( | |||
| """ | |||
| INSERT INTO truck ( | |||
| TruckLanceCode, DepartureTime, ShopCode, ShopName, LoadingSequence, | |||
| Store_id, districtReference, remark, deleted | |||
| ) | |||
| SELECT ?, ?, ?, ?, ?, ?, '示範區', '示範車線', 0 | |||
| FROM DUAL | |||
| WHERE NOT EXISTS ( | |||
| SELECT 1 FROM truck | |||
| WHERE deleted = 0 | |||
| AND TruckLanceCode = ? | |||
| AND Store_id = ? | |||
| AND DepartureTime = ? | |||
| ) | |||
| """.trimIndent(), | |||
| truck.lane, | |||
| truck.departure, | |||
| truck.shopCode, | |||
| truck.shopName, | |||
| truck.sequence, | |||
| truck.storeId, | |||
| truck.lane, | |||
| truck.storeId, | |||
| truck.departure, | |||
| ) | |||
| if (loafId != null && bunId != null && pcsId != null) { | |||
| seedDashboardPickLines(truck, loafId, bunId, pcsId) | |||
| } | |||
| if (inserted > 0) { | |||
| log.info("Added demo truck ticket {}.", truck.ticketNo) | |||
| } | |||
| } | |||
| } | |||
| private fun seedDashboardPickLines(truck: DemoDashTruck, loafId: Long, bunId: Long, pcsId: Long) { | |||
| val pickCode = "DEMO-DASH-PK-${truck.ticketNo}" | |||
| jdbcTemplate.update( | |||
| """ | |||
| INSERT INTO pick_order (code, status, type, targetDate, deliveryOrderPickOrderId, deleted) | |||
| SELECT ?, 'released', 'do', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?), d.id, 0 | |||
| FROM delivery_order_pick_order d | |||
| WHERE d.ticketNo = ? | |||
| AND NOT EXISTS (SELECT 1 FROM pick_order p WHERE p.code = ?) | |||
| """.trimIndent(), | |||
| pickCode, | |||
| truck.dayOffset, | |||
| truck.departure, | |||
| truck.ticketNo, | |||
| pickCode, | |||
| ) | |||
| jdbcTemplate.update( | |||
| """ | |||
| UPDATE pick_order p | |||
| JOIN delivery_order_pick_order d ON d.ticketNo = ? | |||
| SET p.deliveryOrderPickOrderId = d.id, | |||
| p.targetDate = TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?), | |||
| p.deleted = 0 | |||
| WHERE p.code = ? | |||
| """.trimIndent(), | |||
| truck.ticketNo, | |||
| truck.dayOffset, | |||
| truck.departure, | |||
| pickCode, | |||
| ) | |||
| for ((itemId, qty) in listOf(loafId to "6", bunId to "4")) { | |||
| jdbcTemplate.update( | |||
| """ | |||
| INSERT INTO pick_order_line (poId, itemId, qty, uomId, status, deleted) | |||
| SELECT p.id, ?, ?, ?, 'pending', 0 | |||
| FROM pick_order p | |||
| WHERE p.code = ? | |||
| AND NOT EXISTS ( | |||
| SELECT 1 FROM pick_order_line l | |||
| WHERE l.poId = p.id AND l.itemId = ? AND l.deleted = 0 | |||
| ) | |||
| """.trimIndent(), | |||
| itemId, | |||
| qty, | |||
| pcsId, | |||
| pickCode, | |||
| itemId, | |||
| ) | |||
| } | |||
| } | |||
| private fun demoItemId(code: String): Long? { | |||
| return jdbcTemplate.query( | |||
| "SELECT id FROM items WHERE code = ? AND deleted = 0 ORDER BY id LIMIT 1", | |||
| { rs, _ -> rs.getLong("id") }, | |||
| code, | |||
| ).firstOrNull() | |||
| } | |||
| private fun dashboardTrucks(): List<DemoDashTruck> = listOf( | |||
| DemoDashTruck("DEMO-DASH-0-2F-AM-A", 0, "2/F", "示範-早班", "08:30:00", "DEMO-SHP", "海港咖啡店", 1, "completed", "07:10:00", "07:48:00"), | |||
| DemoDashTruck("DEMO-DASH-0-2F-AM-B", 0, "2/F", "示範-早班", "08:30:00", "DEMO-BAK", "山景餅店", 2, "completed", "07:20:00", "07:55:00"), | |||
| DemoDashTruck("DEMO-DASH-0-2F-PM-A", 0, "2/F", "示範-午班", "14:30:00", "DEMO-BAY", "灣畔餐廳", 1, "released", "12:05:00", null), | |||
| DemoDashTruck("DEMO-DASH-0-2F-PM-B", 0, "2/F", "示範-午班", "14:30:00", "DEMO-CAMP", "校園小食", 2, "pending", null, null), | |||
| DemoDashTruck("DEMO-DASH-0-4F-PM-A", 0, "4/F", "示範-晚班", "17:00:00", "DEMO-SHP", "海港咖啡店", 1, "pending", null, null), | |||
| DemoDashTruck("DEMO-DASH-1-2F-AM-A", 1, "2/F", "示範-早班", "09:00:00", "DEMO-SHP", "海港咖啡店", 1, "pending", null, null), | |||
| DemoDashTruck("DEMO-DASH-1-2F-AM-B", 1, "2/F", "示範-早班", "09:00:00", "DEMO-BAK", "山景餅店", 2, "pending", null, null), | |||
| DemoDashTruck("DEMO-DASH-1-4F-NOON-A", 1, "4/F", "示範-午班", "13:30:00", "DEMO-BAY", "灣畔餐廳", 1, "pending", null, null), | |||
| DemoDashTruck("DEMO-DASH-2-2F-AM-A", 2, "2/F", "示範-早班", "09:15:00", "DEMO-SHP", "海港咖啡店", 1, "pending", null, null), | |||
| DemoDashTruck("DEMO-DASH-2-4F-EVE-A", 2, "4/F", "示範-晚班", "16:00:00", "DEMO-CAMP", "校園小食", 1, "pending", null, null), | |||
| ) | |||
| private data class DemoDashTruck( | |||
| val ticketNo: String, | |||
| val dayOffset: Int, | |||
| val storeId: String, | |||
| val lane: String, | |||
| val departure: String, | |||
| val shopCode: String, | |||
| val shopName: String, | |||
| val sequence: Int, | |||
| val status: String, | |||
| val releaseTime: String?, | |||
| val completeTime: String?, | |||
| ) | |||
| /** Existing sandboxes were seeded without a unit price, which made PO detail fail. */ | |||
| private fun backfillDemoPurchaseLinePrices() { | |||
| val updated = jdbcTemplate.update( | |||
| """ | |||
| UPDATE purchase_order_line pol | |||
| INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId | |||
| SET pol.price = 12.50, pol.up = 12.50 | |||
| WHERE po.code LIKE 'DEMO-PO-%' | |||
| AND (pol.price IS NULL OR pol.up IS NULL) | |||
| """.trimIndent(), | |||
| ) | |||
| if (updated > 0) { | |||
| log.info("Filled unit price on {} demo purchase order line(s).", updated) | |||
| } | |||
| } | |||
| private fun ensureAuthorities() { | |||
| val roles = listOf( | |||
| "ADMIN" to "行政", | |||
| @@ -481,6 +732,8 @@ class DemoSandboxSeeder( | |||
| uomM18 = uom | |||
| this.qty = qty | |||
| qtyM18 = qty | |||
| up = BigDecimal("12.50") | |||
| price = BigDecimal("12.50") | |||
| this.status = status | |||
| m18DataLog = dataLog | |||
| }) | |||
| @@ -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() | |||
| @@ -45,8 +45,10 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecordRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecord | |||
| import org.springframework.beans.factory.ObjectProvider | |||
| import org.springframework.context.annotation.Lazy | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | |||
| import com.ffii.fpsms.modules.common.demo.DemoSandboxSeeder | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.TicketReleaseTableResponse | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.TruckScheduleDashboardResponse | |||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SearchDeliveryOrderInfoRequest | |||
| @@ -64,7 +66,8 @@ open class DoPickOrderService( | |||
| private val doPickOrderLineRepository: DoPickOrderLineRepository, | |||
| @Lazy private val deliveryOrderRepository: DeliveryOrderRepository, | |||
| private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | |||
| private val pickOrderLineRepository: PickOrderLineRepository | |||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||
| private val demoSandboxSeeder: ObjectProvider<DemoSandboxSeeder>, | |||
| ) { | |||
| open fun findReleasedDoPickOrders(): List<DoPickOrder> { | |||
| return doPickOrderRepository.findByTicketStatusIn( | |||
| @@ -847,6 +850,7 @@ open class DoPickOrderService( | |||
| * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | |||
| */ | |||
| open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | |||
| demoSandboxSeeder.getIfAvailable()?.alignDashboardTrucksToToday() | |||
| // Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line) | |||
| // | |||
| // NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途, | |||
| @@ -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( | |||
| @@ -8,9 +8,9 @@ import java.time.LocalDateTime | |||
| interface PurchaseOrderInfo { | |||
| val id: Long | |||
| val code: String | |||
| val orderDate: LocalDateTime | |||
| val estimatedArrivalDate: LocalDateTime | |||
| val completeDate: LocalDateTime | |||
| val orderDate: LocalDateTime? | |||
| val estimatedArrivalDate: LocalDateTime? | |||
| val completeDate: LocalDateTime? | |||
| val status: String | |||
| @get:Value("#{target.supplier?.name}") | |||
| val supplier: String? | |||
| @@ -268,7 +268,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| return purchaseOrderRepository.findAll() | |||
| } | |||
| open fun getDetailedPo(id: Long): Map<String, Any> { | |||
| open fun getDetailedPo(id: Long): Map<String, Any?> { | |||
| // REMINDER: po code have duplication | |||
| val po = purchaseOrderRepository.findPurchaseOrderInfoByIdAndDeletedIsFalse(id) | |||
| val pol = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(id) | |||
| @@ -336,7 +336,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| .sumOf { line -> line.putAwayLines?.sumOf { it.qty?.takeIf { qty -> qty > BigDecimal.ZERO } ?: BigDecimal.ZERO } ?: BigDecimal.ZERO}, | |||
| receivedQty = thisPol.stockInLines.sumOf { it.acceptedQty ?: BigDecimal.ZERO }, | |||
| uom = thisPol.uomM18 ?: thisPol.uom!!, | |||
| price = thisPol.price!!, | |||
| price = thisPol.price ?: thisPol.up ?: BigDecimal.ZERO, | |||
| status = thisPol.status!!.toString(), | |||
| stockInLine = inLine, | |||
| qcItems = qcItems, | |||
| @@ -98,7 +98,7 @@ class PurchaseOrderController( | |||
| } | |||
| @GetMapping("/detail/{id}") // purchaseOrderId | |||
| fun getDetailedPo(@PathVariable id: Long): Map<String, Any> { | |||
| fun getDetailedPo(@PathVariable id: Long): Map<String, Any?> { | |||
| /* | |||
| // tested the sample printing function by a pdf file | |||
| @@ -831,8 +831,7 @@ return result | |||
| MAX(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount, | |||
| MAX(COALESCE(cur.code, '')) AS currencyCode, | |||
| MAX(grn.grn_code) AS grnCode, | |||
| MAX(grn.m18_record_id) AS grnId, | |||
| MAX(po.m18CreatedUId) AS poM18CreatedUId | |||
| MAX(grn.m18_record_id) AS grnId | |||
| FROM stock_in_line sil | |||
| LEFT JOIN items it ON sil.itemId = it.id | |||
| LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | |||
| @@ -895,14 +894,6 @@ return result | |||
| "currencyCode" to row["currencyCode"], | |||
| "grnCode" to row["grnCode"], | |||
| "grnId" to row["grnId"], | |||
| "poM18CreatorDisplay" to M18GrnRules.formatM18CreatedUidForReport( | |||
| when (val v = row["poM18CreatedUId"]) { | |||
| null -> null | |||
| is Number -> v.toLong() | |||
| is BigDecimal -> v.toLong() | |||
| else -> v.toString().toLongOrNull() | |||
| }, | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| @@ -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 多值工具方法 */ | |||
| @@ -14,8 +14,10 @@ import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.WorkbookUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.springframework.beans.factory.annotation.Value | |||
| import org.springframework.http.* | |||
| import org.springframework.web.bind.annotation.* | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import java.io.ByteArrayOutputStream | |||
| import java.io.InputStream | |||
| import java.time.LocalDate | |||
| @@ -33,7 +35,13 @@ class ReportController( | |||
| private val reportService: ReportService, | |||
| private val m18BomShopSyncReportService: M18BomShopSyncReportService, | |||
| private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService, | |||
| @Value("\${fpsms.demo-mode:false}") private val demoMode: Boolean, | |||
| ) { | |||
| private fun refuseIfDemo() { | |||
| if (demoMode) { | |||
| throw ResponseStatusException(HttpStatus.FORBIDDEN, "Unavailable in demo mode") | |||
| } | |||
| } | |||
| private data class ExcelStyles( | |||
| val title: XSSFCellStyle, | |||
| val subtitle: XSSFCellStyle, | |||
| @@ -1238,18 +1246,21 @@ class ReportController( | |||
| @RequestParam(required = false) syncDateEnd: String?, | |||
| @RequestParam(required = false) finishedItemCode: String?, | |||
| @RequestParam(required = false) syncStatus: String?, | |||
| ): Map<String, Any> = | |||
| m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | |||
| ): Map<String, Any> { | |||
| refuseIfDemo() | |||
| return m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | |||
| syncDateStart = syncDateStart, | |||
| syncDateEnd = syncDateEnd, | |||
| finishedItemCode = finishedItemCode, | |||
| syncStatus = syncStatus, | |||
| ) | |||
| } | |||
| @PostMapping("/bom-shop-sync-history") | |||
| fun getBomShopSyncHistoryReportPost( | |||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||
| ): Map<String, Any> { | |||
| refuseIfDemo() | |||
| val r = req ?: ReportItemFilterRequest() | |||
| return m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | |||
| syncDateStart = r.syncDateStart, | |||
| @@ -1301,6 +1312,7 @@ class ReportController( | |||
| @RequestParam receiptDate: String, | |||
| @RequestParam(required = false, defaultValue = "json") format: String, | |||
| ): Any { | |||
| refuseIfDemo() | |||
| val rows = reportService.searchGrnPreviewM18(receiptDate) | |||
| if (format.equals("csv", ignoreCase = true)) { | |||
| val headers = listOf( | |||
| @@ -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), | |||
| ) | |||