| @@ -3,20 +3,7 @@ package com.ffii.fpsms.m18 | |||||
| /** | /** | ||||
| * M18 PO [createUid] is stored on [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18CreatedUId]. | * 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]). | * 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 { | 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) */ | /** 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,14 +135,18 @@ 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) { | ||||
| assignDemoAuthorities(existing.get().id) | assignDemoAuthorities(existing.get().id) | ||||
| backfillDemoPurchaseLinePrices() | |||||
| seedDashboardTrucks() | |||||
| log.info("Demo sandbox already present (user '{}').", DEMO_USERNAME) | log.info("Demo sandbox already present (user '{}').", DEMO_USERNAME) | ||||
| return@executeWithoutResult | return@executeWithoutResult | ||||
| } | } | ||||
| seed() | seed() | ||||
| seedDashboardTrucks() | |||||
| log.info("Demo sandbox seeded. Sign in as {} / {}", DEMO_USERNAME, DEMO_PASSWORD) | 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. | * 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) | ||||
| @@ -171,6 +195,233 @@ class DemoSandboxSeeder( | |||||
| throw IllegalStateException("Demo database login can read the live schema. Refusing to start.") | 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() { | private fun ensureAuthorities() { | ||||
| val roles = listOf( | val roles = listOf( | ||||
| "ADMIN" to "行政", | "ADMIN" to "行政", | ||||
| @@ -481,6 +732,8 @@ class DemoSandboxSeeder( | |||||
| uomM18 = uom | uomM18 = uom | ||||
| this.qty = qty | this.qty = qty | ||||
| qtyM18 = qty | qtyM18 = qty | ||||
| up = BigDecimal("12.50") | |||||
| price = BigDecimal("12.50") | |||||
| this.status = status | this.status = status | ||||
| m18DataLog = dataLog | 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_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() | ||||
| @@ -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.DeliveryOrderRepository | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecordRepository | import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecordRepository | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecord | import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecord | ||||
| import org.springframework.beans.factory.ObjectProvider | |||||
| import org.springframework.context.annotation.Lazy | import org.springframework.context.annotation.Lazy | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | 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.TicketReleaseTableResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.TruckScheduleDashboardResponse | import com.ffii.fpsms.modules.deliveryOrder.web.models.TruckScheduleDashboardResponse | ||||
| import com.ffii.fpsms.modules.deliveryOrder.web.models.SearchDeliveryOrderInfoRequest | import com.ffii.fpsms.modules.deliveryOrder.web.models.SearchDeliveryOrderInfoRequest | ||||
| @@ -64,7 +66,8 @@ open class DoPickOrderService( | |||||
| private val doPickOrderLineRepository: DoPickOrderLineRepository, | private val doPickOrderLineRepository: DoPickOrderLineRepository, | ||||
| @Lazy private val deliveryOrderRepository: DeliveryOrderRepository, | @Lazy private val deliveryOrderRepository: DeliveryOrderRepository, | ||||
| private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository, | ||||
| private val pickOrderLineRepository: PickOrderLineRepository | |||||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||||
| private val demoSandboxSeeder: ObjectProvider<DemoSandboxSeeder>, | |||||
| ) { | ) { | ||||
| open fun findReleasedDoPickOrders(): List<DoPickOrder> { | open fun findReleasedDoPickOrders(): List<DoPickOrder> { | ||||
| return doPickOrderRepository.findByTicketStatusIn( | return doPickOrderRepository.findByTicketStatusIn( | ||||
| @@ -847,6 +850,7 @@ open class DoPickOrderService( | |||||
| * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | * Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics. | ||||
| */ | */ | ||||
| open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> { | ||||
| demoSandboxSeeder.getIfAvailable()?.alignDashboardTrucksToToday() | |||||
| // Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line) | // Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line) | ||||
| // | // | ||||
| // NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途, | // NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途, | ||||
| @@ -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( | ||||
| @@ -8,9 +8,9 @@ import java.time.LocalDateTime | |||||
| interface PurchaseOrderInfo { | interface PurchaseOrderInfo { | ||||
| val id: Long | val id: Long | ||||
| val code: String | val code: String | ||||
| val orderDate: LocalDateTime | |||||
| val estimatedArrivalDate: LocalDateTime | |||||
| val completeDate: LocalDateTime | |||||
| val orderDate: LocalDateTime? | |||||
| val estimatedArrivalDate: LocalDateTime? | |||||
| val completeDate: LocalDateTime? | |||||
| val status: String | val status: String | ||||
| @get:Value("#{target.supplier?.name}") | @get:Value("#{target.supplier?.name}") | ||||
| val supplier: String? | val supplier: String? | ||||
| @@ -268,7 +268,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| return purchaseOrderRepository.findAll() | return purchaseOrderRepository.findAll() | ||||
| } | } | ||||
| open fun getDetailedPo(id: Long): Map<String, Any> { | |||||
| open fun getDetailedPo(id: Long): Map<String, Any?> { | |||||
| // REMINDER: po code have duplication | // REMINDER: po code have duplication | ||||
| val po = purchaseOrderRepository.findPurchaseOrderInfoByIdAndDeletedIsFalse(id) | val po = purchaseOrderRepository.findPurchaseOrderInfoByIdAndDeletedIsFalse(id) | ||||
| val pol = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(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}, | .sumOf { line -> line.putAwayLines?.sumOf { it.qty?.takeIf { qty -> qty > BigDecimal.ZERO } ?: BigDecimal.ZERO } ?: BigDecimal.ZERO}, | ||||
| receivedQty = thisPol.stockInLines.sumOf { it.acceptedQty ?: BigDecimal.ZERO }, | receivedQty = thisPol.stockInLines.sumOf { it.acceptedQty ?: BigDecimal.ZERO }, | ||||
| uom = thisPol.uomM18 ?: thisPol.uom!!, | uom = thisPol.uomM18 ?: thisPol.uom!!, | ||||
| price = thisPol.price!!, | |||||
| price = thisPol.price ?: thisPol.up ?: BigDecimal.ZERO, | |||||
| status = thisPol.status!!.toString(), | status = thisPol.status!!.toString(), | ||||
| stockInLine = inLine, | stockInLine = inLine, | ||||
| qcItems = qcItems, | qcItems = qcItems, | ||||
| @@ -98,7 +98,7 @@ class PurchaseOrderController( | |||||
| } | } | ||||
| @GetMapping("/detail/{id}") // purchaseOrderId | @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 | // 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(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount, | ||||
| MAX(COALESCE(cur.code, '')) AS currencyCode, | MAX(COALESCE(cur.code, '')) AS currencyCode, | ||||
| MAX(grn.grn_code) AS grnCode, | 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 | FROM stock_in_line sil | ||||
| LEFT JOIN items it ON sil.itemId = it.id | LEFT JOIN items it ON sil.itemId = it.id | ||||
| LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | ||||
| @@ -895,14 +894,6 @@ return result | |||||
| "currencyCode" to row["currencyCode"], | "currencyCode" to row["currencyCode"], | ||||
| "grnCode" to row["grnCode"], | "grnCode" to row["grnCode"], | ||||
| "grnId" to row["grnId"], | "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 ")})" | 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 多值工具方法 */ | ||||
| @@ -14,8 +14,10 @@ import org.apache.poi.ss.util.CellRangeAddress | |||||
| import org.apache.poi.ss.util.WorkbookUtil | import org.apache.poi.ss.util.WorkbookUtil | ||||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | import org.apache.poi.xssf.usermodel.XSSFCellStyle | ||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | import org.apache.poi.xssf.usermodel.XSSFWorkbook | ||||
| import org.springframework.beans.factory.annotation.Value | |||||
| import org.springframework.http.* | import org.springframework.http.* | ||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.io.InputStream | import java.io.InputStream | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| @@ -33,7 +35,13 @@ class ReportController( | |||||
| private val reportService: ReportService, | private val reportService: ReportService, | ||||
| private val m18BomShopSyncReportService: M18BomShopSyncReportService, | private val m18BomShopSyncReportService: M18BomShopSyncReportService, | ||||
| private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService, | 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( | private data class ExcelStyles( | ||||
| val title: XSSFCellStyle, | val title: XSSFCellStyle, | ||||
| val subtitle: XSSFCellStyle, | val subtitle: XSSFCellStyle, | ||||
| @@ -1238,18 +1246,21 @@ class ReportController( | |||||
| @RequestParam(required = false) syncDateEnd: String?, | @RequestParam(required = false) syncDateEnd: String?, | ||||
| @RequestParam(required = false) finishedItemCode: String?, | @RequestParam(required = false) finishedItemCode: String?, | ||||
| @RequestParam(required = false) syncStatus: String?, | @RequestParam(required = false) syncStatus: String?, | ||||
| ): Map<String, Any> = | |||||
| m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | |||||
| ): Map<String, Any> { | |||||
| refuseIfDemo() | |||||
| return m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | |||||
| syncDateStart = syncDateStart, | syncDateStart = syncDateStart, | ||||
| syncDateEnd = syncDateEnd, | syncDateEnd = syncDateEnd, | ||||
| finishedItemCode = finishedItemCode, | finishedItemCode = finishedItemCode, | ||||
| syncStatus = syncStatus, | syncStatus = syncStatus, | ||||
| ) | ) | ||||
| } | |||||
| @PostMapping("/bom-shop-sync-history") | @PostMapping("/bom-shop-sync-history") | ||||
| fun getBomShopSyncHistoryReportPost( | fun getBomShopSyncHistoryReportPost( | ||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | @RequestBody(required = false) req: ReportItemFilterRequest?, | ||||
| ): Map<String, Any> { | ): Map<String, Any> { | ||||
| refuseIfDemo() | |||||
| val r = req ?: ReportItemFilterRequest() | val r = req ?: ReportItemFilterRequest() | ||||
| return m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | return m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | ||||
| syncDateStart = r.syncDateStart, | syncDateStart = r.syncDateStart, | ||||
| @@ -1301,6 +1312,7 @@ class ReportController( | |||||
| @RequestParam receiptDate: String, | @RequestParam receiptDate: String, | ||||
| @RequestParam(required = false, defaultValue = "json") format: String, | @RequestParam(required = false, defaultValue = "json") format: String, | ||||
| ): Any { | ): Any { | ||||
| refuseIfDemo() | |||||
| val rows = reportService.searchGrnPreviewM18(receiptDate) | val rows = reportService.searchGrnPreviewM18(receiptDate) | ||||
| if (format.equals("csv", ignoreCase = true)) { | if (format.equals("csv", ignoreCase = true)) { | ||||
| val headers = listOf( | val headers = listOf( | ||||
| @@ -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), | ||||
| ) | ) | ||||