diff --git a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt index 70af932e..6683cbe6 100644 --- a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt @@ -3,8 +3,11 @@ package com.ffii.core.utils import org.apache.pdfbox.pdmodel.PDDocument import org.apache.pdfbox.rendering.PDFRenderer import java.awt.image.BufferedImage +import java.awt.image.DataBufferByte +import java.awt.image.MultiPixelPackedSampleModel import java.io.ByteArrayOutputStream import java.io.File +import java.net.InetSocketAddress import java.net.Socket import org.slf4j.LoggerFactory @@ -12,6 +15,7 @@ class BrotherPrinterUtil { companion object { private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept + private const val CONNECT_TIMEOUT_MS = 5_000 private const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) @@ -25,11 +29,14 @@ class BrotherPrinterUtil { log.info("Brother print: file=${pdfFile.name} size=${pdfFile.length()} ip=$printerIp port=$printerPort copies=$printQty") + PrinterLockManager.withLock(printerIp, printerPort) { PDDocument.load(pdfFile).use { document -> val renderer = PDFRenderer(document) val totalPages = document.numberOfPages - Socket(printerIp, printerPort).use { socket -> + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), CONNECT_TIMEOUT_MS) socket.soTimeout = SOCKET_TIMEOUT_MS val os = socket.getOutputStream() @@ -52,6 +59,7 @@ class BrotherPrinterUtil { log.info("Brother print job sent successfully") } } + } } private fun getPjlHeader(qty: Int): ByteArray { @@ -67,6 +75,8 @@ class BrotherPrinterUtil { * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. */ private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { + convertPackedBinaryToPcl(image, dpi)?.let { return it } + val out = ByteArrayOutputStream() val width = image.width val height = image.height @@ -75,21 +85,21 @@ class BrotherPrinterUtil { out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset // PCL: Set resolution and start raster out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) - out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) for (y in 0 until height) { val rowBytes = (width + 7) / 8 val data = ByteArray(rowBytes) - + for (x in 0 until width) { // Check if pixel is dark (threshold) val color = image.getRGB(x, y) - val isBlack = (color and 0xFF) < 128 + val isBlack = (color and 0xFF) < 128 if (isBlack) { data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() } } - + // PCL: Transfer Raster Data (Esc * b # W [data]) out.write("\u001B*b${rowBytes}W".toByteArray()) out.write(data) @@ -99,5 +109,64 @@ class BrotherPrinterUtil { out.write("\u001B*rB".toByteArray()) return out.toByteArray() } + + /** + * TYPE_BYTE_BINARY from PDFBox: sample 0 is black, sample 1 is white, MSB first. + * PCL raster uses the opposite polarity (bit 1 = black). getRGB() per pixel is too slow + * for a multi-page A4 note; pack the row from the data buffer instead. + * Returns null when the raster layout does not match, so the caller keeps the slow path. + */ + private fun convertPackedBinaryToPcl(image: BufferedImage, dpi: Int): ByteArray? { + if (image.type != BufferedImage.TYPE_BYTE_BINARY) return null + val raster = image.raster + val sampleModel = raster.sampleModel as? MultiPixelPackedSampleModel ?: return null + if (sampleModel.pixelBitStride != 1 || sampleModel.dataBitOffset != 0) return null + val dataBuffer = raster.dataBuffer as? DataBufferByte ?: return null + if (dataBuffer.numBanks != 1) return null + + val bank = dataBuffer.data + val bankOffset = dataBuffer.offsets[0] + val width = image.width + val height = image.height + if (width <= 0 || height <= 0) return null + val stride = sampleModel.scanlineStride + val rowBytes = (width + 7) / 8 + if (stride < rowBytes) return null + if (bankOffset < 0 || bankOffset + (height - 1) * stride + rowBytes > bank.size) return null + + val check = minOf(8, width) + for (x in 0 until check) { + val srcBit = (bank[bankOffset].toInt() ushr (7 - x)) and 1 + val rgbBlack = (image.getRGB(x, 0) and 0xFF) < 128 + if ((srcBit == 0) != rgbBlack) return null + } + + val out = ByteArrayOutputStream(height * (rowBytes + 12) + 32) + out.write("\u001BE".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + + val fullBytes = width / 8 + val remainder = width % 8 + val remainderMask = if (remainder == 0) 0 else (0xFF shl (8 - remainder)) and 0xFF + val row = ByteArray(rowBytes) + + for (y in 0 until height) { + val rowStart = bankOffset + y * stride + var i = 0 + while (i < fullBytes) { + row[i] = (bank[rowStart + i].toInt().inv() and 0xFF).toByte() + i++ + } + if (remainder != 0) { + val src = bank[rowStart + fullBytes].toInt() and 0xFF + row[fullBytes] = (src.inv() and remainderMask).toByte() + } + out.write("\u001B*b${rowBytes}W".toByteArray(Charsets.US_ASCII)) + out.write(row) + } + out.write("\u001B*rB".toByteArray(Charsets.US_ASCII)) + return out.toByteArray() + } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt index 939d58ec..448bb00e 100644 --- a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt @@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument import org.slf4j.LoggerFactory import java.io.File import java.io.OutputStream +import java.net.InetSocketAddress import java.net.Socket /** @@ -111,7 +112,11 @@ open class CanonPrinterUtil { val copies = printQty ?: 1 repeat(copies) { copyIndex -> val tSend = System.nanoTime() - Socket(printerIp, printerPort).use { socket -> + PrinterLockManager.withLock(printerIp, printerPort) { + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), 5_000) + socket.soTimeout = 120_000 val os: OutputStream = socket.getOutputStream() // Send PJL commands @@ -126,6 +131,7 @@ open class CanonPrinterUtil { os.flush() } + } val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 log.info( "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", diff --git a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt index 11f05544..e7b052f5 100644 --- a/src/main/java/com/ffii/core/utils/QrCodeUtil.kt +++ b/src/main/java/com/ffii/core/utils/QrCodeUtil.kt @@ -1,18 +1,21 @@ package com.ffii.core.utils import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType import java.awt.image.BufferedImage -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; +import com.google.zxing.client.j2se.MatrixToImageWriter +import com.google.zxing.qrcode.QRCodeWriter open class QrCodeUtil { companion object { fun generateQRCodeImage(barcodeText: String?): BufferedImage { - val barcodeWriter = QRCodeWriter() - val bitMatrix: BitMatrix = barcodeWriter.encode(barcodeText, BarcodeFormat.QR_CODE, 250, 250) - val img: BufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix) - return img + return generateQRCodeImage(barcodeText, 250, 4) + } + + fun generateQRCodeImage(barcodeText: String?, size: Int, margin: Int): BufferedImage { + val hints = hashMapOf(EncodeHintType.MARGIN to margin) + val bitMatrix = QRCodeWriter().encode(barcodeText, BarcodeFormat.QR_CODE, size, size, hints) + return MatrixToImageWriter.toBufferedImage(bitMatrix) } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index 71fdf200..8e61d231 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -79,6 +79,7 @@ public class SecurityConfig { * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) + * Route board truck APIs → ADMIN / TRUCK_LANE / TESTING */ @Bean @Order(1) @@ -143,6 +144,18 @@ public class SecurityConfig { .hasAnyAuthority("ADMIN", "M18_SYNC") .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") .hasAnyAuthority("ADMIN", "M18_SYNC") + /* 車線看板 (/settings/shop/board):ADMIN / TRUCK_LANE / TESTING */ + .requestMatchers("/truck/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + .requestMatchers("/truckLaneVersion/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + .requestMatchers("/truckLaneSchedule/**") + .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") + /* 員工發貨績效 (/chart/delivery): PERFORMANCE (績效) */ + .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance") + .hasAuthority("PERFORMANCE") + .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance-handlers") + .hasAuthority("PERFORMANCE") .anyRequest().authenticated()) .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) diff --git a/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt b/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt index 4c4d3340..d0df7074 100644 --- a/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt +++ b/src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt @@ -1,10 +1,20 @@ package com.ffii.fpsms.m18.model -import java.time.LocalDateTime - data class SyncResult( val totalProcessed: Int, val totalSuccess: Int, val totalFail: Int, val query: String? = null -) \ No newline at end of file +) + +/** + * Bounded suffix for [scheduler_sync_log.query]. Listing every skipped document + * overflows VARCHAR(2000) on a multi-day PO window. + */ +fun skippedNotPendingQuerySuffix(entries: List, previewLimit: Int = 20): String { + if (entries.isEmpty()) return "" + val shown = entries.take(previewLimit) + val hidden = entries.size - shown.size + val more = if (hidden > 0) "; ... +$hidden more" else "" + return " | skipped (not pending, only pending may be overwritten) count=${entries.size}: ${shown.joinToString("; ")}$more" +} \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 56f6539e..34c7f81f 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -89,14 +89,8 @@ open class M18DeliveryOrderService( "" } - // Shop PO - val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") - val shopPoSupplier = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), - "venId=equal=", - "=or=" - ) - var shopPoConds = "(${shopPoBuyers})=and=(${shopPoSupplier})" + // Shop PO — same BE/supplier scope as [buildShopPoScopeConds] + var shopPoConds = buildShopPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { shopPoConds += "=and=(${lastDateConds})" @@ -160,9 +154,24 @@ open class M18DeliveryOrderService( ) } + /** + * Shop PO (MTMS DO) BE/supplier scope used by scheduler and by-code sync: + * beId = TOA and venId in [M18Config.SHOP_PO_SUPPLIER] (e.g. P06/P07). + */ + private fun buildShopPoScopeConds(): String { + val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") + val shopPoSupplier = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), + "venId=equal=", + "=or=" + ) + return "(${shopPoBuyers})=and=(${shopPoSupplier})" + } + /** * Sync a single M18 shop PO / delivery order by document [code], same search pattern as - * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. + * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode], including + * the scheduler shop-PO BE/supplier filters (rejects material POs). * * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). * No M18-side "加單" filtering is used. @@ -173,15 +182,16 @@ open class M18DeliveryOrderService( isExtraSync: Boolean = false, newOnly: Boolean = false, ): SyncResult { - if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { + val trimmed = code.trim() + if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) { return SyncResult( totalProcessed = 1, totalSuccess = 0, totalFail = 0, - query = "skipped (newOnly=true): delivery_order.code already exists: $code", + query = "skipped (newOnly=true): delivery_order.code already exists: $trimmed", ) } - val conds = "(code=equal=$code)" + val conds = "(code=equal=$trimmed)=and=${buildShopPoScopeConds()}" val searchRequest = M18PurchaseOrderListRequest( stSearch = "po", params = null, @@ -228,6 +238,7 @@ open class M18DeliveryOrderService( val successList = mutableListOf() val skippedList = mutableListOf() + val skippedNotPendingList = mutableListOf() val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() @@ -291,6 +302,23 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Finding For Latest M18 Data Log...") val latestDeliveryOrderLog = m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) + val existingDeliveryOrderForSync = + latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + if (existingDeliveryOrderForSync != null && + existingDeliveryOrderForSync.deleted != true && + existingDeliveryOrderForSync.status != DeliveryOrderStatus.PENDING + ) { + val statusValue = existingDeliveryOrderForSync.status?.value ?: "unknown" + logger.info( + "${doRefType}: Skipping M18 sync — local DO id=${existingDeliveryOrderForSync.id} " + + "code=${existingDeliveryOrderForSync.code} status=$statusValue " + + "(only pending may be overwritten; receiving=已放單). M18 ID: ${deliveryOrder.id}" + ) + skippedNotPendingList.add( + "code=${existingDeliveryOrderForSync.code} status=$statusValue" + ) + return@forEach + } //logger.info(latestDeliveryOrderLog.toString()) // Save to m18_data_log table // logger.info("${doRefType}: Saving for M18 Data Log...") @@ -312,10 +340,9 @@ open class M18DeliveryOrderService( // logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}") try { - // Find the delivery_order if exist + // Find the delivery_order if exist (re-use lookup from pending guard above) // logger.info("${doRefType}: Finding exising delivery order...") - val existingDeliveryOrder = - latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } + val existingDeliveryOrder = existingDeliveryOrderForSync // logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}") // Save to delivery_order table @@ -621,12 +648,13 @@ open class M18DeliveryOrderService( logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") - val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" + val skippedNotPendingSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) return SyncResult( - totalProcessed = successList.size + failList.size + skippedList.size, + totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, + query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix, ) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index dd488c96..e93bbcd4 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -97,15 +97,8 @@ open class M18PurchaseOrderService( "" } - // Material PO - val materialPoBuyers = - commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") - val materialPoSupplierNot = commonUtils.listToString( - shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), - "venId=unequal=", - "=or=" - ) - var materialPoConds = "(${materialPoBuyers})=and=(${materialPoSupplierNot}))" + // Material PO — same BE/supplier scope as [buildMaterialPoScopeConds] + var materialPoConds = buildMaterialPoScopeConds() if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { materialPoConds += "=and=(${lastDateConds})" @@ -233,11 +226,28 @@ open class M18PurchaseOrderService( return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL } + /** + * Material PO BE/supplier scope used by scheduler and by-code sync: + * beId in (PP, PF) and venId not in [M18Config.MATERIAL_PO_SUPPLIER_NOT] (e.g. P06/P07 shop suppliers). + */ + private fun buildMaterialPoScopeConds(): String { + val materialPoBuyers = + commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") + val materialPoSupplierNot = commonUtils.listToString( + shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), + "venId=unequal=", + "=or=" + ) + return "(${materialPoBuyers})=and=(${materialPoSupplierNot})" + } + open fun savePurchaseOrderByCode(code: String): SyncResult { - // Follow the scheduler's original approach: - // 1) use /search/search to find the PO id by code - // 2) then sync by PO id (this reuses the same create/update logic) - val conds = "(code=equal=$code)" + // Same approach as scheduler list sync, restricted to one document code: + // 1) /search/search with material-PO BE/supplier scope + code + // 2) sync by PO id (reuses create/update logic) + // Rejects shop POs (TOA / P06|P07) that belong to DO sync instead. + val trimmed = code.trim() + val conds = "(code=equal=$trimmed)=and=${buildMaterialPoScopeConds()}" return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds)) } @@ -357,6 +367,7 @@ open class M18PurchaseOrderService( val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() + val skippedNotPendingList = mutableListOf() val affectedItemIds = mutableSetOf() val uomByM18IdCache = mutableMapOf() @@ -398,11 +409,15 @@ open class M18PurchaseOrderService( if (existingPurchaseOrderForSync != null && existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING ) { + val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown" logger.info( "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + - "code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " + + "code=${existingPurchaseOrderForSync.code} status=$statusValue " + "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" ) + skippedNotPendingList.add( + "code=${existingPurchaseOrderForSync.code} status=$statusValue" + ) return@forEach } @@ -729,11 +744,14 @@ open class M18PurchaseOrderService( logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") + val baseQuery = purchaseOrdersWithType?.query ?: "" + val skippedSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) + return SyncResult( - totalProcessed = successList.size + failList.size, + totalProcessed = successList.size + failList.size + skippedNotPendingList.size, totalSuccess = successList.size, totalFail = failList.size, - query = purchaseOrdersWithType?.query ?: "" + query = baseQuery + skippedSuffix ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java index f5e4bbae..88b8dfee 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java +++ b/src/main/java/com/ffii/fpsms/modules/common/SettingNames.java @@ -135,4 +135,7 @@ public abstract class SettingNames { /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; + /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR, 4 = version 3 with the version 1 QR size. */ + public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version"; + } diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index de198a67..7322508c 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -87,6 +87,8 @@ open class SchedulerService( const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" + /** MySQL TEXT is 65535 bytes; 16000 chars stays under that for utf8mb4. */ + const val SYNC_LOG_TEXT_MAX_CHARS: Int = 16_000 } /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ @@ -730,14 +732,21 @@ open class SchedulerService( recordsProcessed = result?.totalProcessed ?: 0, recordsSaved = result?.totalSuccess ?: 0, recordsFailed = result?.totalFail ?: 0, - errorMessage = error, - query = result?.query ?: "", + errorMessage = fitSyncLogText(error), + query = fitSyncLogText(result?.query ?: ""), startTime = start, endTime = LocalDateTime.now() ) schedulerSyncLogRepository.save(log) } + private fun fitSyncLogText(value: String?): String? { + if (value == null || value.length <= SYNC_LOG_TEXT_MAX_CHARS) return value + val marker = "...(truncated, length=${value.length})" + logger.warn("scheduler_sync_log text truncated from {} to {} characters", value.length, SYNC_LOG_TEXT_MAX_CHARS) + return value.take((SYNC_LOG_TEXT_MAX_CHARS - marker.length).coerceAtLeast(0)) + marker + } + open fun getM18Dos2() { logger.info("DO Scheduler 2 - DO") val currentTime = LocalDateTime.now() diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index ac81e3ee..3b869d9f 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -72,6 +72,7 @@ import jakarta.persistence.EntityManager import jakarta.persistence.PersistenceContext import org.springframework.orm.ObjectOptimisticLockingFailureException import com.ffii.core.utils.PdfUtils +import com.ffii.core.utils.PrinterLockManager import com.ffii.core.utils.ZebraPrinterUtil import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry import com.ffii.fpsms.modules.master.service.PrinterService @@ -86,11 +87,11 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintReque import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.CountDownLatch -import java.util.concurrent.Executors import org.springframework.core.io.ClassPathResource import net.sf.jasperreports.engine.JasperCompileManager import net.sf.jasperreports.engine.JasperExportManager import net.sf.jasperreports.engine.JasperPrint +import net.sf.jasperreports.engine.JasperReport import java.io.File import java.io.FileNotFoundException import java.util.Locale @@ -144,6 +145,20 @@ open class DoWorkbenchMainService( companion object { private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) + + private const val DELIVERY_NOTE_JRXML = "DeliveryNote/DeliveryNotePDF.jrxml" + private const val CARTON_LABEL_JRXML = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" + + private val deliveryNoteJasperTemplate: JasperReport by lazy { compileJasper(DELIVERY_NOTE_JRXML) } + private val cartonLabelJasperTemplate: JasperReport by lazy { compileJasper(CARTON_LABEL_JRXML) } + + private fun compileJasper(path: String): JasperReport { + val resource = ClassPathResource(path) + if (!resource.exists()) { + throw FileNotFoundException("Report file not found: $path") + } + return resource.inputStream.use { JasperCompileManager.compileReport(it) } + } } private sealed interface WorkbenchScanIllResolve { @@ -2949,6 +2964,7 @@ return MessageResponse( } @Transactional open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { + val started = System.nanoTime() val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") @@ -2962,17 +2978,38 @@ return MessageResponse( val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - val port = printer.port ?: 9100 - val driver = A4PrintDriverRegistry.getDriver(printer.brand) - driver.print(tempPdfFile, ip, port, printQty) + val ip = printer.ip?.trim().orEmpty() + if (ip.isEmpty()) { + tempPdfFile.delete() + throw IllegalStateException("Printer ${printer.id} has no IP address") } - updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) - } finally { - //tempPdfFile.delete() + val port = printer.port ?: 9100 + val brand = printer.brand + val doPickOrderId = request.doPickOrderId + val cartonQty = request.numOfCarton + sendToPrinter( + tempPdfFile, + ip, + port, + afterPrint = { saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) }, + ) { + A4PrintDriverRegistry.getDriver(brand).print(tempPdfFile, ip, port, printQty) + } + handedToPrinter = true + log.info( + "Workbench print-DN sent buildMs={} doPickOrderId={} printerId={} printerIp={}", + (System.nanoTime() - started) / 1_000_000, + request.doPickOrderId, + request.printerId, + printer.ip ?: "null", + ) + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e } } @@ -3039,14 +3076,7 @@ return MessageResponse( } open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map { - val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml" - val resource = ClassPathResource(DELIVERYNOTE_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF") - } - - val inputStream = resource.inputStream - val deliveryNote = JasperCompileManager.compileReport(inputStream) + val deliveryNote = deliveryNoteJasperTemplate val fields = mutableListOf>() val params = mutableMapOf() @@ -3060,18 +3090,9 @@ return MessageResponse( val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo) - val pickOrderLines = ctx.pickOrderIds.flatMap { pid -> - pickOrderLineRepository.findAllByPickOrderId(pid) - } + val pickOrderLines = loadPickOrderLinesForPrint(ctx.pickOrderIds) val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id } - val stockOutLinesByPickOrderLineId: Map> = - if (allPickOrderLineIds.isNotEmpty()) { - allPickOrderLineIds.associateWith { polId -> - stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) - } - } else { - emptyMap() - } + val stockOutLinesByPickOrderLineId = loadStockOutLinesForPrint(allPickOrderLineIds) val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) ?.replace("/", "") @@ -3097,11 +3118,7 @@ return MessageResponse( val allIllIds = stockOutLinesByPickOrderLineId.values .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } .distinct() - val illById: Map = if (allIllIds.isNotEmpty()) { - inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! } - } else { - emptyMap() - } + val illById = loadInventoryLotLinesWithWarehouse(allIllIds) val deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds) val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } @@ -3175,6 +3192,7 @@ return MessageResponse( } @Transactional open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { + val started = System.nanoTime() val printer = printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") @@ -3188,26 +3206,48 @@ return MessageResponse( val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - printer.port?.let { port -> - ZebraPrinterUtil.printPdfToZebra( - tempPdfFile, - ip, - port, - printQty, - ZebraPrinterUtil.PrintDirection.ROTATED, - printer.dpi, - ) - } + val ip = printer.ip?.trim().orEmpty() + val port = printer.port + if (ip.isEmpty() || port == null) { + tempPdfFile.delete() + throw IllegalStateException("Label printer ${printer.id} has no IP or port") } - if (request.blankCartonNumber != true) { - updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) + val dpi = printer.dpi + val saveCarton = request.blankCartonNumber != true + val doPickOrderId = request.doPickOrderId + val cartonQty = request.numOfCarton + sendToPrinter( + tempPdfFile, + ip, + port, + afterPrint = { + if (saveCarton) saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) + }, + ) { + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + dpi, + ) } - } finally { - //tempPdfFile.delete() + handedToPrinter = true + log.info( + "Workbench print-DNLabels sent buildMs={} doPickOrderId={} cartons={} printerIp={}", + (System.nanoTime() - started) / 1_000_000, + request.doPickOrderId, + request.numOfCarton, + printer.ip ?: "null", + ) + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e } } @@ -3228,15 +3268,8 @@ return MessageResponse( } private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map { - val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" - val resource = ClassPathResource(DNLABELS_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Label file not found: $DNLABELS_PDF") - } - val ctx = getWorkbenchPrintContext(request.doPickOrderId) - val inputStream = resource.inputStream - val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabel = cartonLabelJasperTemplate val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) }.toMutableList() @@ -3297,15 +3330,8 @@ return MessageResponse( totalCartonsOnShipment = request.totalCartonsOnShipment, ) - val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" - val resource = ClassPathResource(DNLABELS_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Label file not found: $DNLABELS_PDF") - } - val ctx = getWorkbenchPrintContext(request.doPickOrderId) - val inputStream = resource.inputStream - val cartonLabel = JasperCompileManager.compileReport(inputStream) + val cartonLabel = cartonLabelJasperTemplate val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) }.toMutableList() @@ -3345,23 +3371,147 @@ return MessageResponse( val pdf = exportDNLabelsReprintWorkbench(request) val jasperPrint = pdf["report"] as JasperPrint val tempPdfFile = File.createTempFile("print_job_", ".pdf") + var handedToPrinter = false try { JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty - printer.ip?.let { ip -> - printer.port?.let { port -> - ZebraPrinterUtil.printPdfToZebra( - tempPdfFile, - ip, - port, - printQty, - ZebraPrinterUtil.PrintDirection.ROTATED, - printer.dpi, - ) - } + val ip = printer.ip?.trim().orEmpty() + val port = printer.port + if (ip.isEmpty() || port == null) { + tempPdfFile.delete() + throw IllegalStateException("Label printer ${printer.id} has no IP or port") + } + val dpi = printer.dpi + sendToPrinter(tempPdfFile, ip, port) { + ZebraPrinterUtil.printPdfToZebra( + tempPdfFile, + ip, + port, + printQty, + ZebraPrinterUtil.PrintDirection.ROTATED, + dpi, + ) } + handedToPrinter = true + } catch (e: Exception) { + if (!handedToPrinter) tempPdfFile.delete() + throw e + } + } + + /** + * Send on this request so a printer timeout is returned to the tablet. + * Carton qty is saved only after the printer accepts the job. + */ + private fun sendToPrinter( + tempPdfFile: File, + ip: String, + port: Int, + afterPrint: (() -> Unit)? = null, + action: () -> Unit, + ) { + try { + PrinterLockManager.withLock(ip, port) { + action() + } + log.info("Workbench print sent printer={}:{} file={}", ip, port, tempPdfFile.name) + afterPrint?.invoke() + } catch (e: Exception) { + log.error("Workbench print failed printer={}:{} file={}", ip, port, tempPdfFile.name, e) + throw e } finally { - // tempPdfFile.delete() + tempPdfFile.delete() } } + + /** Same column as before (`cartonQty` only). Runs only after the printer accepts the job. */ + private fun saveCartonQtyAfterSuccessfulPrint(deliveryOrderPickOrderId: Long, cartonQty: Int) { + if (cartonQty <= 0) return + val tx = TransactionTemplate(transactionManager).apply { + propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW + } + tx.executeWithoutResult { + updateWorkbenchCartonQty(deliveryOrderPickOrderId, cartonQty) + } + } + + private fun loadPickOrderLinesForPrint(pickOrderIds: List): List { + if (pickOrderIds.isEmpty()) return emptyList() + return pickOrderIds.distinct().chunked(400).flatMap { chunk -> + pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk) + } + } + + private fun loadStockOutLinesForPrint(pickOrderLineIds: List): Map> { + if (pickOrderLineIds.isEmpty()) return emptyMap() + val lines = mutableListOf() + pickOrderLineIds.distinct().chunked(400).forEach { chunk -> + val rows = jdbcDao.queryForList( + """ + SELECT + sol.id AS id, + sol.pickOrderLineId AS pickOrderLineId, + sol.qty AS qty, + sol.inventoryLotLineId AS inventoryLotLineId, + sol.status AS status, + il.lotNo AS lotNo + FROM fpsmsdb.stock_out_line sol + LEFT JOIN fpsmsdb.inventory_lot_line ill + ON ill.id = sol.inventoryLotLineId + LEFT JOIN fpsmsdb.inventory_lot il + ON il.id = ill.inventoryLotId + WHERE sol.deleted = 0 + AND sol.pickOrderLineId IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + rows.forEach { row -> + val polId = (rowValue(row, "pickOrderLineId") as? Number)?.toLong() ?: return@forEach + val qtyRaw = rowValue(row, "qty") + lines.add( + PrintStockOutLineInfo( + id = (rowValue(row, "id") as? Number)?.toLong() ?: 0L, + pickOrderLineId = polId, + qty = when (qtyRaw) { + is BigDecimal -> qtyRaw + is Number -> BigDecimal(qtyRaw.toString()) + else -> BigDecimal.ZERO + }, + inventoryLotLineId = (rowValue(row, "inventoryLotLineId") as? Number)?.toLong(), + lotNo = rowValue(row, "lotNo")?.toString()?.takeIf { it.isNotBlank() }, + status = rowValue(row, "status")?.toString() ?: "", + ), + ) + } + } + return lines.groupBy { it.pickOrderLineId!! } + } + + private fun loadInventoryLotLinesWithWarehouse(ids: Collection): Map { + if (ids.isEmpty()) return emptyMap() + val loaded = ids.distinct().chunked(400).flatMap { chunk -> + inventoryLotLineRepository.findAllByIdInWithWarehouse(chunk) + } + return loaded.mapNotNull { line -> line.id?.let { id -> id to line } }.toMap() + } + + private fun rowValue(row: Map, key: String): Any? { + val matched = row.keys.find { it.equals(key, ignoreCase = true) } ?: return null + return row[matched] + } + + private data class PrintStockOutLineInfo( + override val id: Long, + override val pickOrderLineId: Long?, + override val qty: BigDecimal, + override val inventoryLotLineId: Long?, + override val lotNo: String?, + override val status: String, + ) : StockOutLineInfo { + override val itemId: Long = 0L + override val itemName: String? = null + override val itemNo: String = "" + override val stockOutId: Long = 0L + override val pickTime: java.time.LocalDateTime? = null + } } diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt index 8f4bb249..efd9964d 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt @@ -7,7 +7,7 @@ interface BomCombo { val id: Long; @get:Value("#{target.id}") val value: Long; - @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") + @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[stockUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") val label: String; val outputQty: BigDecimal; val outputQtyUom: String?; diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 1ccd30e8..e66eb54a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -104,6 +104,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; + if (sameUomId(purchaseUnit, stockUnit)) { + return purchaseQty.setScale(0, RoundingMode.UP) + } val one = BigDecimal.ONE; // IMPORTANT: @@ -129,6 +132,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty + if (sameUomId(purchaseUnit, stockUnit)) { + return purchaseQty.setScale(0, RoundingMode.DOWN) + } val one = BigDecimal.ONE // Use high precision for intermediate steps, and round down only at the end. @@ -149,6 +155,9 @@ open class ItemUomService( open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty + if (sameUomId(purchaseUnit, stockUnit)) { + return stockQty.setScale(0, RoundingMode.UP) + } val one = BigDecimal.ONE val calcScale = 10 @@ -168,8 +177,11 @@ open class ItemUomService( * Returns source qty when source/purchase unit mapping is not found. */ open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { - val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty + if (sameUomId(uomId, purchaseUnit)) { + return sourceQty.setScale(0, RoundingMode.UP) + } + val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty val one = BigDecimal.ONE val calcScale = 10 @@ -185,9 +197,12 @@ open class ItemUomService( } open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { - val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty; - - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO; + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return sourceQty.setScale(0, RoundingMode.UP) + } + val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE; val calcScale = 10 @@ -208,8 +223,12 @@ open class ItemUomService( * Same as convertQtyToStockQty but rounds down the final stock qty to integer. */ open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return sourceQty.setScale(0, RoundingMode.DOWN) + } val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE val calcScale = 10 @@ -224,6 +243,18 @@ open class ItemUomService( return stockQty.setScale(0, RoundingMode.DOWN) } + /** True when both item UOMs point at the same `uom_conversion` row. */ + private fun sameUomId(left: ItemUom?, right: ItemUom?): Boolean { + val leftId = left?.uom?.id ?: return false + val rightId = right?.uom?.id ?: return false + return leftId == rightId + } + + private fun sameUomId(uomId: Long, unit: ItemUom?): Boolean { + val unitUomId = unit?.uom?.id ?: return false + return unitUomId == uomId + } + /** * Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc. * Weight/volume units (克, 公斤, 升…) keep 2 decimals. @@ -268,6 +299,9 @@ open class ItemUomService( open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty + if (sameUomId(purchaseUnit, stockUnit)) { + return finalizePreciseStockQty(stockUnit, purchaseQty) + } val one = BigDecimal.ONE val calcScale = 10 @@ -287,8 +321,12 @@ open class ItemUomService( * Used for PO (M18 UOM) conversion where round-down is not allowed. */ open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { + val stockUnit = findStockUnitByItemId(itemId) + if (stockUnit != null && sameUomId(uomId, stockUnit)) { + return finalizePreciseStockQty(stockUnit, sourceQty) + } val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty - val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO + if (stockUnit == null) return BigDecimal.ZERO val one = BigDecimal.ONE val calcScale = 10 diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt index bdf8da28..ba35afb9 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.support.AbstractRepository import org.springframework.stereotype.Repository +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -33,6 +34,17 @@ fun findByPickOrderId(pickOrderId: Long): List ) fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List): List + /** Delivery-note print: one query, associations initialized (avoids per-line lazy loads). */ + @EntityGraph(attributePaths = ["item", "pickOrder", "uom"]) + @Query( + """ + SELECT pol FROM PickOrderLine pol + WHERE pol.pickOrder.id IN :pickOrderIds + AND pol.deleted = false + """ + ) + fun findAllForPrintByPickOrderIdIn(@Param("pickOrderIds") pickOrderIds: List): List + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt index 23f9bc7a..a1c20a8d 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt @@ -22,13 +22,13 @@ import java.time.LocalDateTime open class TruckLaneScheduleController @Autowired constructor( private val truckLaneScheduleService: TruckLaneScheduleService, ) { - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { return truckLaneScheduleService.createManual(request) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/planFromRouteExcel") @Throws(ServletRequestBindingException::class) open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity { @@ -66,19 +66,19 @@ open class TruckLaneScheduleController @Autowired constructor( return truckLaneScheduleService.getById(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/cancel") open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { return truckLaneScheduleService.cancel(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/applyNow") open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { return truckLaneScheduleService.applyNow(id) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/retry-failed") open fun retryFailed( @PathVariable id: Long, @@ -87,7 +87,7 @@ open class TruckLaneScheduleController @Autowired constructor( return truckLaneScheduleService.retryFailed(id, request?.executeAt) } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/{id}/reactivate") open fun reactivateCancelled( @PathVariable id: Long, @@ -124,7 +124,7 @@ open class TruckLaneScheduleController @Autowired constructor( } } - @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") + @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") @PostMapping("/importExcel") @Throws(ServletRequestBindingException::class) open fun importExcel( diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt index 80a3e833..039f0228 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt @@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List): List { orderDate = it["orderDate"] as LocalDateTime?, estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, supplierName = it["supplierName"] as String?, - escalated = (it["escalated"] as Boolean?) ?: false, + escalated = it["escalated"] == 1L, ) } } @@ -211,8 +211,19 @@ open fun getPoSummariesByIds(ids: List): List { sql.append(" group by po.id"); sql.append(" order by po.orderDate desc") sql.append(" ) r") - if (args.containsKey("itemDetail")){ - sql.append(" where r.itemDetail like :itemDetail "); + val outerFilters = mutableListOf() + if (args.containsKey("itemDetail")) { + outerFilters.add("r.itemDetail like :itemDetail") + } + if (args.containsKey("itemCode")) { + outerFilters.add("r.itemCode like :itemCode") + } + if (args.containsKey("itemName")) { + outerFilters.add("r.itemName like :itemName") + } + if (outerFilters.isNotEmpty()) { + sql.append(" where ") + sql.append(outerFilters.joinToString(" AND ")) } return sql.toString() } diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt index 6e475304..24671530 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt @@ -47,6 +47,8 @@ class PurchaseOrderController( val criteriaArgs = CriteriaArgsBuilder.withRequest(request) .addStringLike("supplier") .addStringLike("code") + .addStringLike("itemCode") + .addStringLike("itemName") .addString("status") .addBoolean("escalated") .addDate("orderDate") diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index bdcd3603..3d11430d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service import org.springframework.stereotype.Service import com.ffii.core.support.JdbcDao +import java.time.LocalDate @Service class SemiFGProductionAnalysisReportService( @@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService( return jdbcDao.queryForList(sql, args) } + /** + * Same filters as [searchSemiFGProductionAnalysisReport], one row per item and production date. + * Quantity is not pivoted; the Excel builder places each date in the requested range as its own column. + */ + fun searchSemiFGProductionAnalysisDaily( + stockCategory: String?, + stockSubCategory: String?, + itemCode: String?, + year: String?, + rangeStart: LocalDate, + rangeEnd: LocalDate, + ): List> { + val args = mutableMapOf() + + val stockCategorySql = if (!itemCode.isNullOrBlank()) { + "" + } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { + buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args) + } else { + "" + } + val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) + val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args) + + val yearSql = if (!year.isNullOrBlank() && year != "All") { + args["year"] = year + "AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year" + } else { + "" + } + + args["lastOutDateStart"] = rangeStart.toString() + args["lastOutDateEnd"] = rangeEnd.toString() + val lastOutDateStartSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)" + val lastOutDateEndSql = + "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)" + + val sql = """ + WITH qr_agg AS ( + SELECT + qr.stockInLineId, + MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed + FROM qc_result qr + WHERE qr.deleted = 0 + GROUP BY qr.stockInLineId + ), + base AS ( + SELECT + COALESCE(it.code, '') AS itemNo, + COALESCE(it.name, '') AS itemName, + COALESCE(ic.sub, '') AS stockSubCategory, + COALESCE(uc.udfudesc, '') AS unitOfMeasure, + DATE( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) AS prodDate, + si.id AS stockInLineId, + CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 + ELSE COALESCE(si.acceptedQty, 0) + END AS linePutAwayQty + FROM stock_in_line si + INNER JOIN items it ON si.itemId = it.id + INNER JOIN bom b ON b.code = it.code AND b.deleted = false + LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false + LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id + LEFT JOIN item_category ic ON it.categoryId = ic.id + LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true + LEFT JOIN uom_conversion uc ON iu.uomId = uc.id + WHERE si.deleted = false + AND si.productLotNo IS NOT NULL + AND TRIM(si.productLotNo) <> '' + AND ( + CASE + WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate + ELSE si.productionDate + END + ) IS NOT NULL + AND si.jobOrderId IS NOT NULL + AND (si.status IS NULL OR si.status <> 'Pending') + $stockCategorySql + $stockSubCategorySql + $itemCodeSql + $yearSql + $lastOutDateStartSql + $lastOutDateEndSql + ), + dedup AS ( + SELECT + itemNo, + itemName, + stockSubCategory, + unitOfMeasure, + prodDate, + stockInLineId, + MAX(linePutAwayQty) AS linePutAwayQty + FROM base + GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, prodDate, stockInLineId + ) + SELECT + d.itemNo AS itemNo, + MAX(d.itemName) AS itemName, + MAX(d.unitOfMeasure) AS unitOfMeasure, + d.prodDate AS prodDate, + CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS DECIMAL(18,2)) AS qty + FROM dedup d + GROUP BY d.itemNo, d.prodDate + HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0 + ORDER BY d.itemNo, d.prodDate + """.trimIndent() + + return jdbcDao.queryForList(sql, args) + } + /** * Gets list of item codes (bom.code) with names based on stockCategory filter. * Supports multiple categories separated by comma (e.g., "FG,WIP"). diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 7e57c09c..470d720b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -27,8 +27,8 @@ open class StockLedgerReportService( * - cumBalance = stock_ledger.balance(異動後纍計存量) * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) * - * 只查 [start, end] 期間列,不掃起日以前全歷史。 - * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 + * 先用貨品/日期索引切出流水帳,再一對一接批次行。 + * 不依 inventoryLotId 展開該批次的全部儲位,否則一筆入庫會變成多列。 * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 */ fun searchStockLedgerReport( @@ -40,6 +40,7 @@ open class StockLedgerReportService( ): List> { val args = mutableMapOf() + val itemPredicate = ledgerItemPredicate(itemCode, args) val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") ?: LocalDate.now().format(dateFmt)) @@ -47,17 +48,7 @@ open class StockLedgerReportService( val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - val minDateSql = """ - SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - """.trimIndent() - - val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() - (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd + earliestLedgerDate(itemPredicate, args) ?: reportPeriodEnd } val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) @@ -71,13 +62,6 @@ open class StockLedgerReportService( args ) - val itemCodeSql = buildItemCodeFilterClause( - itemCode, - "sl.itemCode", - "itemCode", - args - ) - val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" "AND wh.code LIKE :storeLocation" @@ -85,62 +69,96 @@ open class StockLedgerReportService( "" } - // Wide reports are a date range over the whole ledger. Pin that index so MySQL - // does not start from another stock_ledger index and scan extra rows. - val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else "" + val uomItemSql = if (itemPredicate.itemIdParamNames.isEmpty()) { + "" + } else { + "AND iu.itemId IN (${itemPredicate.itemIdParamNames.joinToString(", ") { ":$it" }})" + } + // One item is already ordered by the (itemId, deleted, date, id) index. + val orderBy = if (itemPredicate.singleItem) { + "ledger.dt, ledger.slId" + } else { + "it.code, ledger.dt, ledger.slId" + } + + // NO_MERGE keeps the date/item slice materialized before the lot joins. val sql = """ -SELECT - sl.id AS slId, - sl.itemCode AS itemCode, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, +SELECT /*+ NO_MERGE(ledger) */ + ledger.slId AS slId, + ledger.itemCode AS itemCode, + DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate, CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ' AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') + ELSE COALESCE(ledger.ledgerType, '') END AS trnRefNoRaw, - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - COALESCE(sl.balance, 0) AS bal, - COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + COALESCE(ledger.inQty, 0) AS inQty, + COALESCE(ledger.outQty, 0) AS outQty, + COALESCE(ledger.bal, 0) AS bal, + COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS cumOpeningBalRaw, it.type AS stockSubCategory, it.code AS itemNo, it.name AS itemName, uom.unitOfMeasure AS unitOfMeasure, - COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo, - COALESCE( - DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'), - DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'), - '' - ) AS expiryDate, + il.lotNo AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, wh.code AS storeLocation, COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, COALESCE(TRIM(jo.code), '') AS jobOrderNo -FROM stock_ledger sl $ledgerIndex +FROM ( + SELECT + sl.id AS slId, + sl.itemCode AS itemCode, + sl.date AS dt, + sl.type AS ledgerType, + sl.inQty AS inQty, + sl.outQty AS outQty, + sl.balance AS bal, + sl.itemId AS itemId, + sl.stockInLineId AS stockInLineId, + sl.stockOutLineId AS stockOutLineId, + sl.inventoryLotLineId AS inventoryLotLineId + FROM stock_ledger sl ${itemPredicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + ${itemPredicate.sql} +) ledger LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id + ON ledger.stockInLineId = sil.id AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id + ON ledger.stockOutLineId = sol.id AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 - LEFT JOIN inventory_lot_line ill_in_loc - ON ill_in_loc.inventoryLotId = il_in.id - AND ill_in_loc.deleted = 0 + LEFT JOIN inventory_lot_line ill + ON ill.id = ledger.inventoryLotLineId + AND ill.deleted = 0 + LEFT JOIN inventory_lot_line ill_sil + ON ill.id IS NULL + AND ill_sil.id = sil.inventoryLotLineId + AND ill_sil.deleted = 0 + LEFT JOIN inventory_lot_line ill_sol + ON ill.id IS NULL + AND ill_sil.id IS NULL + AND ill_sol.id = sol.inventoryLotLineId + AND ill_sol.deleted = 0 + LEFT JOIN inventory_lot il + ON il.id = COALESCE( + ill.inventoryLotId, + ill_sil.inventoryLotId, + ill_sol.inventoryLotId, + sil.inventoryLotId + ) + AND il.deleted = 0 LEFT JOIN warehouse wh - ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId) + ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId) AND wh.deleted = 0 LEFT JOIN items it - ON sl.itemId = it.id + ON ledger.itemId = it.id AND it.deleted = 0 LEFT JOIN ( SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure @@ -148,6 +166,7 @@ FROM stock_ledger sl $ledgerIndex JOIN uom_conversion uc ON uc.id = iu.uomId WHERE iu.stockUnit = 1 AND iu.deleted = 0 + $uomItemSql GROUP BY iu.itemId ) uom ON uom.itemId = it.id LEFT JOIN stock_out so @@ -174,26 +193,97 @@ FROM stock_ledger sl $ledgerIndex LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 -WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND sl.date >= :reportPeriodStart - AND sl.date < :reportPeriodEndExclusive +WHERE il.id IS NOT NULL $stockCategorySql - $itemCodeSql $storeLocationSql - AND COALESCE(il_in.id, il_out.id) IS NOT NULL ORDER BY - itemNo, - trnDate, - slId, - lotNo + $orderBy """.trimIndent() return attachItemTotals(jdbcDao.queryForList(sql, args)) } + /** + * Exact item codes become itemId so MySQL can use + * idx_ledger_item_deleted_date_id (itemId, deleted, date, id). + */ + private fun ledgerItemPredicate(itemCode: String?, args: MutableMap): LedgerItemPredicate { + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode) + if (exact.isEmpty() && fuzzy.isEmpty()) { + return LedgerItemPredicate( + sql = "", + indexHint = "FORCE INDEX (idx_sl_deleted_date)", + singleItem = false, + itemIdParamNames = emptyList(), + ) + } + if (fuzzy.isEmpty()) { + val ids = lookupItemIds(exact) + if (ids.isNotEmpty()) { + val names = ids.mapIndexed { index, id -> + val name = "itemId_$index" + args[name] = id + name + } + return LedgerItemPredicate( + sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})", + indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)", + singleItem = ids.size == 1, + itemIdParamNames = names, + ) + } + } + val oneExactCode = fuzzy.isEmpty() && exact.size == 1 + return LedgerItemPredicate( + sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args), + indexHint = if (exact.size + fuzzy.size == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "", + singleItem = oneExactCode, + itemIdParamNames = emptyList(), + ) + } + + private fun lookupItemIds(codes: List): List { + if (codes.isEmpty()) return emptyList() + val args = HashMap(codes.size) + val names = codes.mapIndexed { index, code -> + val name = "lookupCode_$index" + args[name] = code + ":$name" + } + val sql = """ + SELECT id AS itemId + FROM items + WHERE code IN (${names.joinToString(", ")}) + """.trimIndent() + return jdbcDao.queryForList(sql, args).mapNotNull { row -> + when (val id = row["itemId"]) { + is Number -> id.toLong() + else -> id?.toString()?.toLongOrNull() + } + }.distinct() + } + + /** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */ + private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map): String? { + val sql = """ + SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate + FROM stock_ledger sl ${predicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + ${predicate.sql} + """.trimIndent() + val row = jdbcDao.queryForList(sql, args).firstOrNull() + return row?.get("firstDate") as? String + } + + private data class LedgerItemPredicate( + val sql: String, + val indexHint: String, + val singleItem: Boolean, + val itemIdParamNames: List, + ) + /** * Item totals match the old window: first opening, sum in/out, last balance per itemCode. * Rows stay ordered by item, date, id. diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 4182292e..7089ebd8 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream import java.time.LocalDate import java.time.LocalTime import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService import com.ffii.fpsms.modules.report.service.ReportService import org.apache.poi.ss.usermodel.BorderStyle @@ -173,6 +174,243 @@ class SemiFGProductionAnalysisReportController( return ResponseEntity(excelBytes, headers, HttpStatus.OK) } + @PostMapping("/print-semi-fg-production-analysis-excel-daily") + fun exportSemiFGProductionAnalysisReportExcelDaily( + @RequestBody(required = false) req: ReportItemFilterRequest?, + ): ResponseEntity { + val r = req ?: ReportItemFilterRequest() + val start = parseReportDate(r.lastOutDateStart) + val end = parseReportDate(r.lastOutDateEnd) + if (start == null || end == null) { + return jsonError(HttpStatus.BAD_REQUEST, "請先輸入完成生產日期(由、至)") + } + if (end.isBefore(start)) { + return jsonError(HttpStatus.BAD_REQUEST, "完成生產日期「由」不可晚於「至」") + } + val dayCount = ChronoUnit.DAYS.between(start, end) + 1 + if (dayCount > MAX_DAILY_COLUMNS) { + return jsonError(HttpStatus.BAD_REQUEST, "日期範圍不可超過 $MAX_DAILY_COLUMNS 日") + } + + val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisDaily( + r.stockCategory, + r.stockSubCategory, + r.combinedItemCode(), + r.year, + start, + end, + ) + val dates = generateSequence(start) { previous -> + val next = previous.plusDays(1) + if (next.isAfter(end)) null else next + }.toList() + val excelBytes = createSemiFGProductionAnalysisDailyExcel( + dbData = dbData, + reportTitle = "成品/半成品生產分析報告", + dates = dates, + ) + val headers = HttpHeaders().apply { + contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport_Daily.xlsx") + set("filename", "SemiFGProductionAnalysisReport_Daily.xlsx") + } + return ResponseEntity(excelBytes, headers, HttpStatus.OK) + } + + private fun createSemiFGProductionAnalysisDailyExcel( + dbData: List>, + reportTitle: String, + dates: List, + ): ByteArray { + val workbook = XSSFWorkbook() + val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) + val sheet = workbook.createSheet(safeSheetName) + val dateFormat = DateTimeFormatter.ISO_LOCAL_DATE + val columnDateFormat = DateTimeFormatter.ofPattern( + if (dates.first().year == dates.last().year) "d/M" else "d/M/yy", + ) + val fixedColumns = 3 + val totalColumns = fixedColumns + dates.size + 1 + var rowIndex = 0 + + val titleStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + } + val titleFont = workbook.createFont().apply { + bold = true + fontHeightInPoints = 16 + } + titleStyle.setFont(titleFont) + + val infoStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + } + + val headerStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.CENTER + verticalAlignment = VerticalAlignment.CENTER + fillForegroundColor = IndexedColors.GREY_25_PERCENT.index + fillPattern = FillPatternType.SOLID_FOREGROUND + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + wrapText = true + } + val headerFont = workbook.createFont().apply { bold = true } + headerStyle.setFont(headerFont) + + val textStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.LEFT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val numberStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") + } + + val dashStyle = workbook.createCellStyle().apply { + alignment = HorizontalAlignment.RIGHT + verticalAlignment = VerticalAlignment.CENTER + borderTop = BorderStyle.THIN + borderBottom = BorderStyle.THIN + borderLeft = BorderStyle.THIN + borderRight = BorderStyle.THIN + } + + val titleRow = sheet.createRow(rowIndex++) + titleRow.heightInPoints = 28f + val titleCell = titleRow.createCell(0) + titleCell.setCellValue(reportTitle) + titleCell.cellStyle = titleStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) + } + + val rangeLabel = "${dates.first().format(dateFormat)} 至 ${dates.last().format(dateFormat)}" + val infoRow = sheet.createRow(rowIndex++) + val infoCell = infoRow.createCell(0) + infoCell.setCellValue( + "報告日期:${LocalDate.now().format(dateFormat)} " + + "報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))} " + + "完成生產日期:$rangeLabel" + ) + infoCell.cellStyle = infoStyle + if (totalColumns > 1) { + sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) + } + + rowIndex++ + + val headerRowIndex = rowIndex + val headerRow = sheet.createRow(rowIndex++) + headerRow.heightInPoints = 32f + val headers = buildList { + add("貨品編號") + add("貨品名稱") + add("單位") + dates.forEach { add(it.format(columnDateFormat)) } + add("上架總計") + } + headers.forEachIndexed { index, name -> + val cell = headerRow.createCell(index) + cell.setCellValue(name) + cell.cellStyle = headerStyle + } + + val items = pivotDailyRows(dbData) + if (items.isEmpty()) { + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, "-", textStyle) + setTextCell(dataRow, 1, "-", textStyle) + setTextCell(dataRow, 2, "-", textStyle) + for (col in fixedColumns until totalColumns) { + setDashCell(dataRow, col, dashStyle) + } + } else { + items.forEach { item -> + val dataRow = sheet.createRow(rowIndex++) + setTextCell(dataRow, 0, item.itemNo.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 1, item.itemName.ifBlank { "-" }, textStyle) + setTextCell(dataRow, 2, item.unitOfMeasure.ifBlank { "-" }, textStyle) + var total = 0.0 + dates.forEachIndexed { index, date -> + val qty = item.qtyByDate[date] ?: 0.0 + total += qty + setNumberCell(dataRow, fixedColumns + index, qty, numberStyle, dashStyle) + } + setNumberCell(dataRow, totalColumns - 1, total, numberStyle, dashStyle) + } + } + + sheet.createFreezePane(fixedColumns, headerRowIndex + 1) + sheet.setColumnWidth(0, 18 * 256) + sheet.setColumnWidth(1, 24 * 256) + sheet.setColumnWidth(2, 12 * 256) + for (col in fixedColumns until totalColumns) { + sheet.setColumnWidth(col, 12 * 256) + } + + val output = ByteArrayOutputStream() + workbook.use { it.write(output) } + return output.toByteArray() + } + + private fun pivotDailyRows(dbData: List>): List { + val byItem = linkedMapOf() + dbData.forEach { row -> + val itemNo = row["itemNo"]?.toString()?.trim().orEmpty() + val prodDate = toLocalDate(row["prodDate"]) ?: return@forEach + val qty = parseNumericForExcelChart(row["qty"]) + val item = byItem.getOrPut(itemNo) { + DailyItemQty( + itemNo = itemNo, + itemName = row["itemName"]?.toString().orEmpty(), + unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(), + qtyByDate = linkedMapOf(), + ) + } + item.qtyByDate[prodDate] = (item.qtyByDate[prodDate] ?: 0.0) + qty + } + return byItem.values.toList() + } + + private fun parseReportDate(raw: String?): LocalDate? { + if (raw.isNullOrBlank()) return null + val normalized = raw.trim().replace("/", "-") + return runCatching { LocalDate.parse(normalized) }.getOrNull() + ?: runCatching { LocalDate.parse(normalized, DateTimeFormatter.ofPattern("dd-MM-yyyy")) }.getOrNull() + } + + private fun toLocalDate(value: Any?): LocalDate? = when (value) { + is LocalDate -> value + is java.sql.Date -> value.toLocalDate() + is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() + is java.time.LocalDateTime -> value.toLocalDate() + is java.util.Date -> value.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() + is String -> parseReportDate(value.take(10)) + else -> null + } + + private fun jsonError(status: HttpStatus, message: String): ResponseEntity { + val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"") + return ResponseEntity.status(status) + .contentType(MediaType.APPLICATION_JSON) + .body("""{"message":"$escaped"}""".toByteArray(Charsets.UTF_8)) + } + private fun createSemiFGProductionAnalysisExcel( dbData: List>, reportTitle: String, @@ -517,4 +755,15 @@ class SemiFGProductionAnalysisReportController( val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) } + + private data class DailyItemQty( + val itemNo: String, + val itemName: String, + val unitOfMeasure: String, + val qtyByDate: MutableMap, + ) + + companion object { + private const val MAX_DAILY_COLUMNS = 366 + } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 68911e1c..bc738342 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -326,4 +326,17 @@ WHERE ill.id = :id fun findAllByIdInAndDeletedFalseWithRefs( @Param("ids") ids: Collection ): List + + /** Delivery-note route column: warehouse code without a lazy load per lot line. */ + @EntityGraph( + type = EntityGraph.EntityGraphType.FETCH, + attributePaths = ["warehouse"] + ) + @Query( + """ + SELECT ill FROM InventoryLotLine ill + WHERE ill.id IN :ids + """ + ) + fun findAllByIdInWithWarehouse(@Param("ids") ids: Collection): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt index 72a03b0b..4e4bc56f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt @@ -81,6 +81,10 @@ open class StockTakeRecord : BaseEntity() { @Column(name = "lastSelect") open var lastSelect: Int? = null + /** How many times the picker has changed an already saved first count. Max 2. */ + @Column(name = "pickerReviseCount", nullable = false) + open var pickerReviseCount: Int? = 0 + @Column(name = "approverTime") open var approverTime: LocalDateTime? = null @Column(name = "date", nullable = false) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 8ec1cd18..f28bc51d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.response.RecordsRes import com.ffii.core.utils.PdfUtils -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.master.entity.ItemUomRespository import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.stock.entity.InventoryLotLine @@ -44,6 +43,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse import com.ffii.fpsms.modules.jobOrder.service.JobOrderService import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest import com.ffii.core.utils.ZebraPrinterUtil @@ -60,6 +60,7 @@ open class InventoryLotLineService( private val stockInLineRepository: StockInLineRepository, private val inventoryRepository: InventoryRepository, private val printerService: PrinterService, + private val settingsService: SettingsService, @Lazy private val jobOrderService: JobOrderService, private val stockTakeLocationGuard: StockTakeLocationGuard, @@ -248,7 +249,8 @@ open class InventoryLotLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: LotLineToQrcode): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -288,13 +290,18 @@ open class InventoryLotLineService( field["acceptedQty"] = "%.2f".format(info.acceptedQty) val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo!! field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt new file mode 100644 index 00000000..f4dcf00e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt @@ -0,0 +1,66 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.utils.QrCodeUtil +import com.ffii.fpsms.modules.common.SettingNames +import com.ffii.fpsms.modules.settings.service.SettingsService +import java.awt.image.BufferedImage + +object PoItemLabelTemplate { + const val V1 = "qrCodeLabel/poItemPDF.jrxml" + const val V2 = "qrCodeLabel/poItemPDF_v2.jrxml" + const val V3 = "qrCodeLabel/poItemPDF_v3.jrxml" + const val V4 = "qrCodeLabel/poItemPDF_v4.jrxml" + + fun version(settingsService: SettingsService): Int { + val raw = settingsService.findByName(SettingNames.LABEL_PO_ITEM_VERSION) + .map { it.value } + .orElse("1") + ?.trim() + return when (raw) { + "2" -> 2 + "3" -> 3 + "4" -> 4 + else -> 1 + } + } + + fun path(version: Int): String = when (version) { + 2 -> V2 + 3 -> V3 + 4 -> V4 + else -> V1 + } + + fun qrImage(content: String, version: Int): BufferedImage = + if (version == 1) QrCodeUtil.generateQRCodeImage(content) + else QrCodeUtil.generateQRCodeImage(content, 500, 1) + + /** First line is qty + short unit. Second line is the full unit only when it differs. */ + fun qtyLines(qtyRaw: String?, fullRaw: String?, shortRaw: String?): Pair { + val qty = stripTrailingZeros(qtyRaw) + val full = blankUnit(fullRaw) + val short = blankUnit(shortRaw) + val same = short.isEmpty() || full.isEmpty() || short.equals(full, ignoreCase = true) + if (same) { + val unit = short.ifEmpty { full } + val line = when { + unit.isEmpty() -> qty + unit.first().isDigit() -> listOf(qty, unit).filter { it.isNotEmpty() }.joinToString(" ") + else -> qty + unit + } + return line to "" + } + return (qty + short) to full + } + + private fun blankUnit(raw: String?): String { + val text = raw?.trim().orEmpty() + return if (text.isEmpty() || text.equals("N/A", ignoreCase = true)) "" else text + } + + private fun stripTrailingZeros(raw: String?): String { + val text = raw?.trim().orEmpty() + if (!text.contains('.')) return text + return text.replace(Regex("0+$"), "").removeSuffix(".") + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 7c277952..11ef689d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.core.exception.BadRequestException import com.ffii.core.support.AbstractBaseEntityService import com.ffii.core.support.JdbcDao -import com.ffii.core.utils.QrCodeUtil import com.ffii.fpsms.modules.common.CodeGenerator import com.ffii.fpsms.modules.master.entity.ItemsRepository import com.ffii.fpsms.modules.master.entity.QcItemRepository @@ -33,6 +32,7 @@ import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.master.service.ItemUomService //import com.ffii.fpsms.modules.master.service.BomOutputQtyService import com.ffii.fpsms.modules.master.service.PrinterService +import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository @@ -98,6 +98,7 @@ open class StockInLineService( private val itemUomRepository: ItemUomRespository, private val itemUomService: ItemUomService, private val printerService: PrinterService, + private val settingsService: SettingsService, private val stockTakeLineRepository: StockTakeLineRepository, private val inventoryLotLineService: InventoryLotLineService, private val deliveryOrderRepository: DeliveryOrderRepository, @@ -332,6 +333,33 @@ open class StockInLineService( latestCode = latestCode, ) } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + private fun applyRequestedStockQtyRound( + stockInLine: StockInLine, + request: SaveStockInLineRequest, + isCreate: Boolean, + ) { + if (request.stockQtyRoundMode.isNullOrBlank()) return + if (stockInLine.purchaseOrderLine == null) return + if (!isCreate) { + val status = stockInLine.status?.lowercase()?.trim() + if (status != StockInLineStatus.PENDING.status && status != StockInLineStatus.ESCALATED.status) { + return + } + } + val currentQty = stockInLine.acceptedQty ?: return + val (roundedQty, remarks) = StockQtyRoundSupport.applyIfRequested( + currentQty = currentQty, + currentRemarks = stockInLine.remarks ?: request.remarks, + request = request, + defaultSource = if (isCreate) "CREATE" else "QC", + ) + stockInLine.acceptedQty = roundedQty + stockInLine.remarks = remarks + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ @Throws(IOException::class) @Transactional open fun create(request: SaveStockInLineRequest): MessageResponse { @@ -446,10 +474,13 @@ open class StockInLineService( } dnNo = request.dnNo receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() + // The item label prints this field under 收貨日期. + productionDate = request.productionDate?.atStartOfDay() ?: receiptDate productLotNo = request.productLotNo this.type = "NOR" status = StockInLineStatus.PENDING.status } + applyRequestedStockQtyRound(stockInLine, request, isCreate = true) if (jo != null) { val planStartDate = jo.planStart?.toLocalDate() stockInLine.lotNo = assignLotNoForJo(planStartDate) @@ -650,13 +681,15 @@ open class StockInLineService( open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { val pols = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(po.id!!) - var newStatus = PurchaseOrderStatus.PENDING - if (pols.isNotEmpty()) { - newStatus = if (pols.any { pol-> pol.status != PurchaseOrderLineStatus.COMPLETED }) { - PurchaseOrderStatus.RECEIVING - } else { + // A line that was never received, or whose stock-in rows were all removed before putaway, is pending. + // Receiving only while some line is still receiving or already completed. + val newStatus = when { + pols.isEmpty() || pols.all { it.status == null || it.status == PurchaseOrderLineStatus.PENDING } -> + PurchaseOrderStatus.PENDING + pols.all { it.status == PurchaseOrderLineStatus.COMPLETED } -> PurchaseOrderStatus.COMPLETED - } + else -> + PurchaseOrderStatus.RECEIVING } po.apply { status = newStatus @@ -945,6 +978,7 @@ open class StockInLineService( } } + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ @Throws(IOException::class) @Transactional open fun update(request: SaveStockInLineRequest): MessageResponse { @@ -1031,6 +1065,7 @@ open class StockInLineService( this.expiryDate = stockInLine.expiryDate ?: request.expiryDate this.remarks = stockInLine.remarks ?: request.remarks } + applyRequestedStockQtyRound(stockInLine, request, isCreate = false) _logStep("apply_fields", _tApplyFields) if (request.status == StockInLineStatus.RECEIVED.status) { val held = holdAutoPutawayIfSectionInStockTake(request, stockInLine) @@ -1213,7 +1248,8 @@ open class StockInLineService( @Throws(IOException::class) @Transactional open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map { - val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" + val labelVersion = PoItemLabelTemplate.version(settingsService) + val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) val resource = ClassPathResource(QRCODE_PDF) if (!resource.exists()) { println("here") @@ -1247,12 +1283,17 @@ open class StockInLineService( field["poCode"] = info.poCode ?: "N/A" field["itemType"] = info.itemType ?: "N/A" field["acceptedQty"] = "%.2f".format(acceptedQty) - field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" - field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" + val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" + val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc + field["uom"] = fullUom + val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) + field["qtyLabel"] = qtyLabel + field["qtyDetail"] = qtyDetail + field["productionDate"] = (info.productionDate ?: info.receiptDate)?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" field["lotNo"] = info.lotNo ?: "N/A" field["supplier"] = info.supplier ?: "N/A" - val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) + val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) field["qrCode"] = image fields.add(field) } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt new file mode 100644 index 00000000..d7955c7e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt @@ -0,0 +1,114 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.exception.BadRequestException +import com.ffii.fpsms.modules.common.SecurityUtils +import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest +import java.math.BigDecimal +import java.math.RoundingMode +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +object StockQtyRoundSupport { + private val TAG_REGEX = Regex("""\[QTY_ROUND[^\]]*\]""") + private val AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm") + private val REMARKS_MAX = 500 + + enum class Mode { + HALF_UP, + CEILING, + FLOOR, + CUSTOM, + } + + fun isNotInteger(qty: BigDecimal): Boolean { + return qty.stripTrailingZeros().scale() > 0 + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + fun applyIfRequested( + currentQty: BigDecimal, + currentRemarks: String?, + request: SaveStockInLineRequest, + defaultSource: String, + ): Pair { + val mode = parseMode(request.stockQtyRoundMode) ?: return currentQty to currentRemarks + if (!isNotInteger(currentQty)) { + return currentQty to currentRemarks + } + val after = roundQty(currentQty, mode, request.stockQtyCustomQty) + val source = request.stockQtyRoundSource?.trim()?.ifBlank { null } ?: defaultSource + val tag = buildTag( + mode = mode, + before = currentQty, + after = after, + source = source, + ) + val withReason = if (mode == Mode.CUSTOM) { + val reason = request.stockQtyCustomReason?.trim().orEmpty() + if (reason.isEmpty()) tag else "$tag 原因:$reason" + } else { + tag + } + return after to mergeRemark(currentRemarks, withReason) + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + fun parseMode(raw: String?): Mode? { + val value = raw?.trim()?.uppercase() ?: return null + if (value.isEmpty()) return null + return try { + Mode.valueOf(value) + } catch (_: IllegalArgumentException) { + throw BadRequestException("Invalid stockQtyRoundMode: $raw") + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal { + return when (mode) { + Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP) + Mode.CEILING -> before.setScale(0, RoundingMode.CEILING) + Mode.FLOOR -> before.setScale(0, RoundingMode.FLOOR) + Mode.CUSTOM -> { + val custom = customQty ?: throw BadRequestException("stockQtyCustomQty is required for CUSTOM") + if (custom <= BigDecimal.ZERO) { + throw BadRequestException("stockQtyCustomQty must be greater than 0") + } + if (isNotInteger(custom)) { + throw BadRequestException("stockQtyCustomQty must be an integer") + } + custom.setScale(0, RoundingMode.DOWN) + } + } + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + fun buildTag( + mode: Mode, + before: BigDecimal, + after: BigDecimal, + source: String, + at: LocalDateTime = LocalDateTime.now(), + by: String = currentUsername(), + ): String { + return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]" + } + + /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ + fun mergeRemark(existing: String?, tag: String): String { + val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty() + val merged = if (stripped.isEmpty()) tag else "$tag $stripped" + return if (merged.length <= REMARKS_MAX) merged else merged.take(REMARKS_MAX) + } + + private fun currentUsername(): String { + val user = SecurityUtils.getUser().orElse(null) + return user?.username ?: user?.name ?: "unknown" + } + + private fun plain(qty: BigDecimal): String = qty.stripTrailingZeros().toPlainString() + + private fun sanitize(value: String): String { + return value.replace(Regex("""[\s\[\]]+"""), "_").ifBlank { "unknown" } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index d09c0e6b..37966ce0 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -1615,6 +1615,7 @@ open class StockTakeRecordService( stockTakerName = rec.stockTakerName, stockTakeEndTime = rec.stockTakeEndTime, approverTime = rec.approverTime, + pickerReviseCount = rec.pickerReviseCount ?: 0, ) } @@ -1711,6 +1712,7 @@ open class StockTakeRecordService( stockTakerName = stockTakeRecord?.stockTakerName, stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, approverTime = stockTakeRecord?.approverTime, + pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0, ) } @@ -1757,12 +1759,59 @@ open class StockTakeRecordService( .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) + if (request.reviseFirstQty && request.stockTakeRecordId == null) { + throw IllegalArgumentException("Stock take record ID is required") + } + // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) ?: throw IllegalArgumentException("Stock take record not found: ${request.stockTakeRecordId}") + val headerStatus = stockTake.status + val headerStillOpen = headerStatus == StockTakeStatus.PENDING || + headerStatus == StockTakeStatus.STOCKTAKING || + headerStatus == StockTakeStatus.APPROVING + val revisingOpenPass = existingRecord.pickerFirstStockTakeQty != null && + existingRecord.pickerSecondStockTakeQty == null && + existingRecord.approverStockTakeQty == null && + existingRecord.status.equals("pass", ignoreCase = true) && + headerStillOpen when { + // 已盤點的列在區域「已完成」前可改第一次數量。列狀態已完成、或區域已完成,不能改。 + request.reviseFirstQty || revisingOpenPass -> { + if (!headerStillOpen) { + throw IllegalArgumentException("Cannot change counted qty after the section is completed") + } + if (!existingRecord.status.equals("pass", ignoreCase = true)) { + throw IllegalArgumentException("Only counted records can be changed") + } + if (existingRecord.pickerFirstStockTakeQty == null) { + throw IllegalArgumentException("No first count to change") + } + if (existingRecord.pickerSecondStockTakeQty != null || existingRecord.approverStockTakeQty != null) { + throw IllegalArgumentException("Cannot change the first count after a later count exists") + } + val reviseCount = existingRecord.pickerReviseCount ?: 0 + if (reviseCount >= 2) { + throw IllegalArgumentException("This count can only be changed 2 times") + } + val varianceQty = availableQty - request.qty - request.badQty + existingRecord.apply { + this.pickerFirstStockTakeQty = request.qty + this.pickerFirstBadQty = request.badQty + this.bookQty = availableQty + this.varianceQty = varianceQty + this.stockTakerId = stockTakerId + this.stockTakerName = user?.name + this.status = "pass" + this.pickerSecondStockTakeQty = null + this.pickerSecondBadQty = null + this.stockTakeEndTime = null + this.pickerReviseCount = reviseCount + 1 + } + existingRecord + } existingRecord.pickerFirstStockTakeQty == null -> { val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty val varianceQty = availableQty - request.qty - request.badQty @@ -1782,7 +1831,8 @@ open class StockTakeRecordService( } existingRecord } - existingRecord.pickerSecondStockTakeQty == null -> { + existingRecord.pickerSecondStockTakeQty == null && + existingRecord.status.equals("notMatch", ignoreCase = true) -> { val varianceQty = availableQty - request.qty - request.badQty existingRecord.apply { this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt index 74355227..2c18f136 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt @@ -37,6 +37,7 @@ data class SaveStockInRequest( // val m18 ) +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ data class SaveStockInLineRequest( var id: Long? = null, var stockInId: Long? = null, @@ -66,6 +67,12 @@ data class SaveStockInLineRequest( var inventoryLotLines: List? = null, /** True when closing a stock-take round posts a putaway that was held for that round. */ var releaseHeldPutaway: Boolean = false, + /** HALF_UP / CEILING / FLOOR / CUSTOM. Null keeps current decimal stock qty. */ + var stockQtyRoundMode: String? = null, + var stockQtyCustomQty: BigDecimal? = null, + /** CREATE (PO submit) or QC (old pending line). */ + var stockQtyRoundSource: String? = null, + var stockQtyCustomReason: String? = null, ) data class SaveInventoryLotLineForSil ( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index 4a608c2d..c3dc0888 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -107,6 +107,8 @@ data class InventoryLotDetailResponse( val lastSelect: Int? = null, /** Stock UoM short label (e.g. 包), shown beside the count input. */ val uomShortDesc: String? = null, + /** Times the picker has changed this saved first count. */ + val pickerReviseCount: Int? = 0, ) data class InventoryLotLineListRequest( val warehouseCode: String @@ -117,7 +119,9 @@ data class SaveStockTakeRecordRequest( val qty: BigDecimal, // QTY(第一次或第二次,后端判断) val badQty: BigDecimal, // val stockTakerName: String, - val remark: String? = null + val remark: String? = null, + /** true = 區域仍在盤點中時,覆寫已盤點的第一次數量;不寫入第二次盤點 */ + val reviseFirstQty: Boolean = false, ) /** diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index dcd2d6e5..956ed653 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -3,9 +3,10 @@ fpsms: monitoring: enabled: true -# Session length in production; frontend can call /refresh-token before expiry to stay logged in. +# Session length in production; frontend refreshes via /refresh-token before expiry. +# Floor staff often work ~08:00–22:00 (~14h); 16h access covers one shift without mid-day kickout. jwt: - expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK + expiration-minutes: 960 # 16 hours access token (was 480 / 8h — users logged out ~16:30 after morning login) refresh-expiration-days: 7 # Required for stable JWT across restarts; set JWT_SECRET in the deployment environment (long random string). secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74ce2bc6..ed67e727 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -113,8 +113,9 @@ spring: dialect: storage_engine: innodb -# JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires. +# JWT: access token expiry and refresh token expiry. Frontend calls /refresh-token before access token expires. # Signing key must be STABLE across server restarts (do not use a random key per boot). Override with env JWT_SECRET in production. +# Prod overrides to 16h access (floor shifts ~08:00–22:00); see application-prod.yml. jwt: expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session refresh-expiration-days: 30 # refresh token validity (days) diff --git a/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql b/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql new file mode 100644 index 00000000..b01af9e0 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset fpsms:add_truck_lane_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'TRUCK_LANE' +--comment: Route board (/settings/shop/board): ADMIN or TRUCK_LANE (車線) +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('TRUCK_LANE', '車線', 'SHOP', 'Allow maintaining shop route board / truck lanes'); diff --git a/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql b/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql new file mode 100644 index 00000000..864f978c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql +-- changeset fpsms:20261001-picker-revise-count + +ALTER TABLE `fpsmsdb`.`stocktakerecord` +ADD COLUMN `pickerReviseCount` INT NOT NULL DEFAULT 0 AFTER `lastSelect`; diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql new file mode 100644 index 00000000..8af0d109 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql @@ -0,0 +1,9 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version + +INSERT INTO `settings` (`name`, `value`, `category`, `type`) +SELECT 'LABEL.poItem.version', '1', 'LABEL', '1/2' +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `settings` WHERE `name` = 'LABEL.poItem.version' +); diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql new file mode 100644 index 00000000..c704aa81 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20261001_po_item_label_version_allow_3 + +UPDATE `settings` +SET `type` = '1/2/3' +WHERE `name` = 'LABEL.poItem.version' + AND `type` = '1/2'; diff --git a/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql new file mode 100644 index 00000000..30cafc5c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql @@ -0,0 +1,7 @@ +--liquibase formatted sql +--changeset fpsms:20261002_po_item_label_version_allow_4 + +UPDATE `settings` +SET `type` = '1/2/3/4' +WHERE `name` = 'LABEL.poItem.version' + AND `type` IN ('1/2', '1/2/3'); diff --git a/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql b/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql new file mode 100644 index 00000000..6b682cf6 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql +-- changeset fpsms:20261002-scheduler-sync-log-text + +-- PO sync stores the M18 conds plus skipped PO codes. VARCHAR(2000) / VARCHAR(1024) +-- reject that insert (MySQL 1406) and the scheduled task fails after the sync. +ALTER TABLE `fpsmsdb`.`scheduler_sync_log` + MODIFY COLUMN `query` TEXT NULL, + MODIFY COLUMN `errorMessage` TEXT NULL; diff --git a/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql b/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql new file mode 100644 index 00000000..0f803056 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql @@ -0,0 +1,19 @@ +--liquibase formatted sql + +--changeset fpsms:add_performance_authority +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'PERFORMANCE' +--comment: Staff delivery performance on /chart/delivery requires PERFORMANCE (績效) +INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`) +VALUES ('PERFORMANCE', '績效', 'CHART', 'Allow viewing staff delivery performance on /chart/delivery'); + +--changeset fpsms:grant_performance_authority_to_2fi +--preconditions onFail:MARK_RAN +--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM user_authority ua JOIN `user` u ON u.id = ua.userId JOIN authority a ON a.id = ua.authId WHERE u.username = '2fi' AND a.authority = 'PERFORMANCE' +--comment: Default grant 績效 to user 2fi +INSERT IGNORE INTO `user_authority` (`userId`, `authId`) +SELECT u.id, a.id +FROM `user` u +JOIN `authority` a ON a.authority = 'PERFORMANCE' +WHERE u.username = '2fi' + AND u.deleted = 0; diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml new file mode 100644 index 00000000..d4a57f3d --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml new file mode 100644 index 00000000..47c4aec3 --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml new file mode 100644 index 00000000..31e29cd3 --- /dev/null +++ b/src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt new file mode 100644 index 00000000..944affc2 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt @@ -0,0 +1,82 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.time.LocalDateTime + +class StockQtyRoundSupportTest { + @Test + fun isNotInteger_detects_decimals() { + assertTrue(StockQtyRoundSupport.isNotInteger(BigDecimal("10.50"))) + assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10.00"))) + assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10"))) + } + + @Test + fun roundQty_halfUp_ceiling_floor() { + val before = BigDecimal("10.50") + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.HALF_UP, null).compareTo(BigDecimal("11"))) + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CEILING, null).compareTo(BigDecimal("11"))) + assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.FLOOR, null).compareTo(BigDecimal("10"))) + assertEquals( + 0, + StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CUSTOM, BigDecimal("12")).compareTo(BigDecimal("12")), + ) + } + + @Test + fun mergeRemark_replaces_previous_tag() { + val first = StockQtyRoundSupport.buildTag( + mode = StockQtyRoundSupport.Mode.FLOOR, + before = BigDecimal("10.50"), + after = BigDecimal("10"), + source = "CREATE", + at = LocalDateTime.of(2026, 8, 20, 17, 40), + by = "kw093", + ) + val mergedOnce = StockQtyRoundSupport.mergeRemark("舊備註", first) + val second = StockQtyRoundSupport.buildTag( + mode = StockQtyRoundSupport.Mode.CUSTOM, + before = BigDecimal("10.50"), + after = BigDecimal("12"), + source = "QC", + at = LocalDateTime.of(2026, 8, 20, 17, 45), + by = "kw093", + ) + val mergedTwice = StockQtyRoundSupport.mergeRemark(mergedOnce, second) + assertTrue(mergedTwice.startsWith("[QTY_ROUND mode=CUSTOM")) + assertTrue(mergedTwice.contains("舊備註")) + assertFalse(mergedTwice.contains("mode=FLOOR")) + } + + @Test + fun applyIfRequested_writes_custom_tag() { + val request = SaveStockInLineRequest( + itemId = 1, + acceptedQty = BigDecimal("10.50"), + acceptQty = null, + status = null, + expiryDate = null, + stockQtyRoundMode = "CUSTOM", + stockQtyCustomQty = BigDecimal("12"), + stockQtyRoundSource = "QC", + stockQtyCustomReason = "實收12箱", + ) + val (qty, remarks) = StockQtyRoundSupport.applyIfRequested( + currentQty = BigDecimal("10.50"), + currentRemarks = null, + request = request, + defaultSource = "QC", + ) + assertEquals(0, qty.compareTo(BigDecimal("12"))) + assertTrue(remarks!!.contains("mode=CUSTOM")) + assertTrue(remarks.contains("before=10.5")) + assertTrue(remarks.contains("after=12")) + assertTrue(remarks.contains("source=QC")) + assertTrue(remarks.contains("原因:實收12箱")) + } +}