Co-authored-by: Cursor <[email protected]> # Conflicts: # src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.ktstockTakeUpdate
| @@ -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() | |||
| } | |||
| } | |||
| } | |||
| @@ -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)", | |||
| @@ -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, Any>(EncodeHintType.MARGIN to margin) | |||
| val bitMatrix = QRCodeWriter().encode(barcodeText, BarcodeFormat.QR_CODE, size, size, hints) | |||
| return MatrixToImageWriter.toBufferedImage(bitMatrix) | |||
| } | |||
| } | |||
| } | |||
| @@ -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"))) | |||
| @@ -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 | |||
| ) | |||
| ) | |||
| /** | |||
| * Bounded suffix for [scheduler_sync_log.query]. Listing every skipped document | |||
| * overflows VARCHAR(2000) on a multi-day PO window. | |||
| */ | |||
| fun skippedNotPendingQuerySuffix(entries: List<String>, 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" | |||
| } | |||
| @@ -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<Long>() | |||
| val skippedList = mutableListOf<Long>() | |||
| val skippedNotPendingList = mutableListOf<String>() | |||
| val successDetailList = mutableListOf<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| val failDetailList = mutableListOf<Long>() | |||
| @@ -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, | |||
| ) | |||
| } | |||
| } | |||
| @@ -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<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| val failDetailList = mutableListOf<Long>() | |||
| val skippedNotPendingList = mutableListOf<String>() | |||
| val affectedItemIds = mutableSetOf<Long>() | |||
| val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>() | |||
| @@ -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 | |||
| ) | |||
| } | |||
| } | |||
| @@ -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"; | |||
| } | |||
| @@ -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() | |||
| @@ -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<String, Any> { | |||
| 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<MutableMap<String, Any>>() | |||
| val params = mutableMapOf<String, Any>() | |||
| @@ -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<Long, List<StockOutLineInfo>> = | |||
| 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<Long, InventoryLotLine> = 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<String, Any> { | |||
| 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<Long>): List<com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine> { | |||
| if (pickOrderIds.isEmpty()) return emptyList() | |||
| return pickOrderIds.distinct().chunked(400).flatMap { chunk -> | |||
| pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk) | |||
| } | |||
| } | |||
| private fun loadStockOutLinesForPrint(pickOrderLineIds: List<Long>): Map<Long, List<StockOutLineInfo>> { | |||
| if (pickOrderLineIds.isEmpty()) return emptyMap() | |||
| val lines = mutableListOf<PrintStockOutLineInfo>() | |||
| 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<Long>): Map<Long, InventoryLotLine> { | |||
| 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<String, Any?>, 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 | |||
| } | |||
| } | |||
| @@ -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?; | |||
| @@ -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 | |||
| @@ -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<PickOrderLine> | |||
| ) | |||
| fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickOrderLine> | |||
| /** 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<Long>): List<PickOrderLine> | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| value = """ | |||
| @@ -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<RouteExcelSchedulePlanResponse> { | |||
| @@ -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( | |||
| @@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| 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<Long>): List<PurchaseOrderSummary> { | |||
| 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<String>() | |||
| 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() | |||
| } | |||
| @@ -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") | |||
| @@ -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<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| 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"). | |||
| @@ -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<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| 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<String, Any>()).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<String, Any>): 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<String>): List<Long> { | |||
| if (codes.isEmpty()) return emptyList() | |||
| val args = HashMap<String, Any>(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, Any>): 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<String>, | |||
| ) | |||
| /** | |||
| * Item totals match the old window: first opening, sum in/out, last balance per itemCode. | |||
| * Rows stay ordered by item, date, id. | |||
| @@ -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<ByteArray> { | |||
| 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<Map<String, Any>>, | |||
| reportTitle: String, | |||
| dates: List<LocalDate>, | |||
| ): 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<Map<String, Any>>): List<DailyItemQty> { | |||
| val byItem = linkedMapOf<String, DailyItemQty>() | |||
| 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<ByteArray> { | |||
| 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<Map<String, Any>>, | |||
| 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<LocalDate, Double>, | |||
| ) | |||
| companion object { | |||
| private const val MAX_DAILY_COLUMNS = 366 | |||
| } | |||
| } | |||
| @@ -326,4 +326,17 @@ WHERE ill.id = :id | |||
| fun findAllByIdInAndDeletedFalseWithRefs( | |||
| @Param("ids") ids: Collection<Long> | |||
| ): List<InventoryLotLine> | |||
| /** 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<Long>): List<InventoryLotLine> | |||
| } | |||
| @@ -81,6 +81,10 @@ open class StockTakeRecord : BaseEntity<Long>() { | |||
| @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) | |||
| @@ -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<String, Any> { | |||
| 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) | |||
| } | |||
| @@ -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<String, String> { | |||
| 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(".") | |||
| } | |||
| } | |||
| @@ -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<String, Any> { | |||
| 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) | |||
| } | |||
| @@ -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<BigDecimal, String?> { | |||
| 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" } | |||
| } | |||
| } | |||
| @@ -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) | |||
| @@ -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<SaveInventoryLotLineForSil>? = 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 ( | |||
| @@ -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, | |||
| ) | |||
| /** | |||
| @@ -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} | |||
| @@ -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) | |||
| @@ -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'); | |||
| @@ -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`; | |||
| @@ -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' | |||
| ); | |||
| @@ -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'; | |||
| @@ -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'); | |||
| @@ -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; | |||
| @@ -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; | |||
| @@ -0,0 +1,172 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf --> | |||
| <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="poItemPDF" printOrder="Horizontal" pageWidth="425" pageHeight="283" orientation="Landscape" columnWidth="425" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0" uuid="6cb85eb9-3573-42f3-a293-099db250aec0"> | |||
| <property name="com.jaspersoft.studio.unit." value="pixel"/> | |||
| <parameter name="lotNo" class="java.lang.String"/> | |||
| <parameter name="itemName" class="java.lang.String"/> | |||
| <parameter name="itemNo" class="java.lang.String"/> | |||
| <parameter name="poCode" class="java.lang.String"/> | |||
| <parameter name="qrCode" class="java.awt.Image"/> | |||
| <parameter name="acceptedQty" class="java.math.BigDecimal"/> | |||
| <parameter name="uom" class="java.lang.String"/> | |||
| <parameter name="isTransfer" class="java.lang.String"/> | |||
| <queryString> | |||
| <![CDATA[]]> | |||
| </queryString> | |||
| <field name="lotNo" class="java.lang.String"/> | |||
| <field name="itemName" class="java.lang.String"/> | |||
| <field name="itemNo" class="java.lang.String"/> | |||
| <field name="acceptedQty" class="java.lang.String"/> | |||
| <field name="uom" class="java.lang.String"/> | |||
| <field name="qtyLabel" class="java.lang.String"/> | |||
| <field name="qtyDetail" class="java.lang.String"/> | |||
| <field name="qrCode" class="java.awt.Image"/> | |||
| <field name="poCode" class="java.lang.String"/> | |||
| <field name="supplier" class="java.lang.String"/> | |||
| <field name="productionDate" class="java.lang.String"/> | |||
| <field name="expiryDate" class="java.lang.String"/> | |||
| <background> | |||
| <band splitType="Stretch"/> | |||
| </background> | |||
| <detail> | |||
| <band height="279" splitType="Prevent"> | |||
| <staticText> | |||
| <reportElement x="8" y="0" width="108" height="56" uuid="c62ac00a-c495-462c-93bc-97bdd3cdb395"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="18" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[收貨日期:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="116" y="0" width="301" height="56" uuid="74a5cfba-046d-42f4-92d7-113729514a6f"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="44" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{productionDate}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="56" width="108" height="56" uuid="32edcad1-23c1-4d41-8c9a-352d9e6ef377"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="18" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[到期日:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="116" y="56" width="301" height="56" uuid="97e4a9bb-fc84-4074-b523-ddf9eb9ed2d2"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="44" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{expiryDate}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="114" width="230" height="18" uuid="f5f145f1-123f-4fe5-90fa-8550b3d2eb63"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[批次編號:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="132" width="230" height="20" uuid="402f35fc-b11a-4bf1-982d-e12c622f38ea"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="18" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{lotNo}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="152" width="108" height="18" uuid="96d46fb1-939d-4f3e-b8f0-172b69b85e9f"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[物料編號:]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement x="120" y="152" width="118" height="18" uuid="53a2d7cf-bd50-4264-abee-346823cd49dd"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[數量:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="170" width="108" height="28" uuid="1f289946-b945-4f79-8515-212368b8be3b"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="24" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="120" y="170" width="120" height="18" uuid="629d9af8-c73f-4614-ba9d-a582e2ed4a29"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="14" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{qtyLabel}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="120" y="188" width="120" height="12" uuid="7c1a0e44-6b2d-4a91-9f10-2d8e5b6c4a11"> | |||
| <printWhenExpression><![CDATA[$F{qtyDetail} != null && !$F{qtyDetail}.trim().isEmpty()]]></printWhenExpression> | |||
| </reportElement> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{qtyDetail}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="200" width="230" height="16" uuid="5c3f9b03-222c-4c94-ab58-6bc86d44c5ce"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[物料名稱:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="216" width="230" height="30" uuid="e04b094d-2d16-4060-b6d9-2e6033a673ab"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="32" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression> | |||
| </textField> | |||
| <image scaleImage="FillFrame" hAlign="Center" vAlign="Middle"> | |||
| <reportElement x="248" y="112" width="166" height="166" uuid="5dd35c73-973b-4f0d-9cad-13305a29a9fd"/> | |||
| <imageExpression><![CDATA[$F{qrCode}]]></imageExpression> | |||
| </image> | |||
| <staticText> | |||
| <reportElement x="8" y="248" width="42" height="14" uuid="c2041205-51f8-45bb-a1d1-e3de37eebed7"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <text><![CDATA[供應商:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="50" y="248" width="188" height="14" uuid="ab82ca39-8840-4471-b7c0-cdeec9cb5693"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{supplier}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="262" width="76" height="14" uuid="1b1efafd-049a-432e-93c4-9662273011f7"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <text><![CDATA[採購訂單編號:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="84" y="262" width="154" height="14" uuid="b805e20a-9a36-45c8-8255-23b6cc58f47c"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{poCode}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="276" y="183" width="110" height="22" uuid="d66a64b0-3335-4846-bcb0-a67594520055"> | |||
| <printWhenExpression><![CDATA[$P{isTransfer} != null && !$P{isTransfer}.toString().trim().isEmpty()]]></printWhenExpression> | |||
| </reportElement> | |||
| <box> | |||
| <pen lineWidth="2.0"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="11" isBold="true" isUnderline="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{isTransfer}]]></textFieldExpression> | |||
| </textField> | |||
| </band> | |||
| </detail> | |||
| </jasperReport> | |||
| @@ -0,0 +1,172 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf --> | |||
| <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="poItemPDF_v3" printOrder="Horizontal" pageWidth="425" pageHeight="283" orientation="Landscape" columnWidth="425" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0" uuid="6cb85eb9-3573-42f3-a293-099db250aec0"> | |||
| <property name="com.jaspersoft.studio.unit." value="pixel"/> | |||
| <parameter name="lotNo" class="java.lang.String"/> | |||
| <parameter name="itemName" class="java.lang.String"/> | |||
| <parameter name="itemNo" class="java.lang.String"/> | |||
| <parameter name="poCode" class="java.lang.String"/> | |||
| <parameter name="qrCode" class="java.awt.Image"/> | |||
| <parameter name="acceptedQty" class="java.math.BigDecimal"/> | |||
| <parameter name="uom" class="java.lang.String"/> | |||
| <parameter name="isTransfer" class="java.lang.String"/> | |||
| <queryString> | |||
| <![CDATA[]]> | |||
| </queryString> | |||
| <field name="lotNo" class="java.lang.String"/> | |||
| <field name="itemName" class="java.lang.String"/> | |||
| <field name="itemNo" class="java.lang.String"/> | |||
| <field name="acceptedQty" class="java.lang.String"/> | |||
| <field name="uom" class="java.lang.String"/> | |||
| <field name="qtyLabel" class="java.lang.String"/> | |||
| <field name="qtyDetail" class="java.lang.String"/> | |||
| <field name="qrCode" class="java.awt.Image"/> | |||
| <field name="poCode" class="java.lang.String"/> | |||
| <field name="supplier" class="java.lang.String"/> | |||
| <field name="productionDate" class="java.lang.String"/> | |||
| <field name="expiryDate" class="java.lang.String"/> | |||
| <background> | |||
| <band splitType="Stretch"/> | |||
| </background> | |||
| <detail> | |||
| <band height="279" splitType="Prevent"> | |||
| <staticText> | |||
| <reportElement x="8" y="0" width="108" height="56" uuid="c62ac00a-c495-462c-93bc-97bdd3cdb395"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="18" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[收貨日期:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="116" y="0" width="301" height="56" uuid="74a5cfba-046d-42f4-92d7-113729514a6f"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="44" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{productionDate}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="56" width="108" height="56" uuid="32edcad1-23c1-4d41-8c9a-352d9e6ef377"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="18" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[到期日:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="116" y="56" width="301" height="56" uuid="97e4a9bb-fc84-4074-b523-ddf9eb9ed2d2"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="44" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{expiryDate}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="114" width="286" height="16" uuid="f5f145f1-123f-4fe5-90fa-8550b3d2eb63"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[批次編號:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="130" width="286" height="20" uuid="402f35fc-b11a-4bf1-982d-e12c622f38ea"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="18" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{lotNo}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="152" width="140" height="16" uuid="96d46fb1-939d-4f3e-b8f0-172b69b85e9f"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[物料編號:]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement x="152" y="152" width="142" height="16" uuid="53a2d7cf-bd50-4264-abee-346823cd49dd"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[數量:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="168" width="140" height="26" uuid="1f289946-b945-4f79-8515-212368b8be3b"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="22" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="152" y="168" width="142" height="16" uuid="629d9af8-c73f-4614-ba9d-a582e2ed4a29"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="14" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{qtyLabel}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="152" y="184" width="142" height="12" uuid="7c1a0e44-6b2d-4a91-9f10-2d8e5b6c4a11"> | |||
| <printWhenExpression><![CDATA[$F{qtyDetail} != null && !$F{qtyDetail}.trim().isEmpty()]]></printWhenExpression> | |||
| </reportElement> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{qtyDetail}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="200" width="290" height="18" uuid="5c3f9b03-222c-4c94-ab58-6bc86d44c5ce"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[物料名稱:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="218" width="290" height="58" uuid="e04b094d-2d16-4060-b6d9-2e6033a673ab"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="48" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression> | |||
| </textField> | |||
| <image scaleImage="FillFrame" hAlign="Center" vAlign="Middle"> | |||
| <reportElement x="308" y="116" width="108" height="108" uuid="5dd35c73-973b-4f0d-9cad-13305a29a9fd"/> | |||
| <imageExpression><![CDATA[$F{qrCode}]]></imageExpression> | |||
| </image> | |||
| <staticText> | |||
| <reportElement x="308" y="226" width="108" height="12" uuid="c2041205-51f8-45bb-a1d1-e3de37eebed7"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <text><![CDATA[供應商:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="308" y="238" width="108" height="14" uuid="ab82ca39-8840-4471-b7c0-cdeec9cb5693"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{supplier}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="308" y="252" width="108" height="12" uuid="1b1efafd-049a-432e-93c4-9662273011f7"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <text><![CDATA[採購訂單編號:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="308" y="264" width="108" height="14" uuid="b805e20a-9a36-45c8-8255-23b6cc58f47c"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{poCode}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="322" y="156" width="80" height="20" uuid="d66a64b0-3335-4846-bcb0-a67594520055"> | |||
| <printWhenExpression><![CDATA[$P{isTransfer} != null && !$P{isTransfer}.toString().trim().isEmpty()]]></printWhenExpression> | |||
| </reportElement> | |||
| <box> | |||
| <pen lineWidth="2.0"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="11" isBold="true" isUnderline="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{isTransfer}]]></textFieldExpression> | |||
| </textField> | |||
| </band> | |||
| </detail> | |||
| </jasperReport> | |||
| @@ -0,0 +1,172 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf --> | |||
| <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="poItemPDF_v4" printOrder="Horizontal" pageWidth="425" pageHeight="283" orientation="Landscape" columnWidth="425" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0" uuid="6cb85eb9-3573-42f3-a293-099db250aec0"> | |||
| <property name="com.jaspersoft.studio.unit." value="pixel"/> | |||
| <parameter name="lotNo" class="java.lang.String"/> | |||
| <parameter name="itemName" class="java.lang.String"/> | |||
| <parameter name="itemNo" class="java.lang.String"/> | |||
| <parameter name="poCode" class="java.lang.String"/> | |||
| <parameter name="qrCode" class="java.awt.Image"/> | |||
| <parameter name="acceptedQty" class="java.math.BigDecimal"/> | |||
| <parameter name="uom" class="java.lang.String"/> | |||
| <parameter name="isTransfer" class="java.lang.String"/> | |||
| <queryString> | |||
| <![CDATA[]]> | |||
| </queryString> | |||
| <field name="lotNo" class="java.lang.String"/> | |||
| <field name="itemName" class="java.lang.String"/> | |||
| <field name="itemNo" class="java.lang.String"/> | |||
| <field name="acceptedQty" class="java.lang.String"/> | |||
| <field name="uom" class="java.lang.String"/> | |||
| <field name="qtyLabel" class="java.lang.String"/> | |||
| <field name="qtyDetail" class="java.lang.String"/> | |||
| <field name="qrCode" class="java.awt.Image"/> | |||
| <field name="poCode" class="java.lang.String"/> | |||
| <field name="supplier" class="java.lang.String"/> | |||
| <field name="productionDate" class="java.lang.String"/> | |||
| <field name="expiryDate" class="java.lang.String"/> | |||
| <background> | |||
| <band splitType="Stretch"/> | |||
| </background> | |||
| <detail> | |||
| <band height="279" splitType="Prevent"> | |||
| <staticText> | |||
| <reportElement x="4" y="0" width="222" height="16" uuid="c62ac00a-c495-462c-93bc-97bdd3cdb395"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="11" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[收貨日期:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="4" y="16" width="222" height="52" uuid="74a5cfba-046d-42f4-92d7-113729514a6f"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="48" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{productionDate}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="4" y="70" width="222" height="16" uuid="32edcad1-23c1-4d41-8c9a-352d9e6ef377"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="11" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[到期日:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="4" y="86" width="222" height="52" uuid="97e4a9bb-fc84-4074-b523-ddf9eb9ed2d2"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="48" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{expiryDate}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="142" width="104" height="16" uuid="96d46fb1-939d-4f3e-b8f0-172b69b85e9f"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[物料編號:]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement x="130" y="142" width="96" height="16" uuid="53a2d7cf-bd50-4264-abee-346823cd49dd"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[數量:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="158" width="118" height="34" uuid="1f289946-b945-4f79-8515-212368b8be3b"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="30" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="130" y="158" width="96" height="18" uuid="629d9af8-c73f-4614-ba9d-a582e2ed4a29"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="14" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{qtyLabel}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="130" y="178" width="96" height="14" uuid="7c1a0e44-6b2d-4a91-9f10-2d8e5b6c4a11"> | |||
| <printWhenExpression><![CDATA[$F{qtyDetail} != null && !$F{qtyDetail}.trim().isEmpty()]]></printWhenExpression> | |||
| </reportElement> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{qtyDetail}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="8" y="196" width="218" height="16" uuid="5c3f9b03-222c-4c94-ab58-6bc86d44c5ce"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[物料名稱:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="8" y="212" width="218" height="64" uuid="e04b094d-2d16-4060-b6d9-2e6033a673ab"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="48" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression> | |||
| </textField> | |||
| <image scaleImage="FillFrame" hAlign="Center" vAlign="Middle"> | |||
| <reportElement x="230" y="4" width="190" height="190" uuid="5dd35c73-973b-4f0d-9cad-13305a29a9fd"/> | |||
| <imageExpression><![CDATA[$F{qrCode}]]></imageExpression> | |||
| </image> | |||
| <staticText> | |||
| <reportElement x="230" y="196" width="190" height="16" uuid="f5f145f1-123f-4fe5-90fa-8550b3d2eb63"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[批次編號:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="230" y="212" width="190" height="26" uuid="402f35fc-b11a-4bf1-982d-e12c622f38ea"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="18" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{lotNo}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="230" y="240" width="42" height="16" uuid="c2041205-51f8-45bb-a1d1-e3de37eebed7"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <text><![CDATA[供應商:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="272" y="240" width="148" height="16" uuid="ab82ca39-8840-4471-b7c0-cdeec9cb5693"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{supplier}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <reportElement x="230" y="258" width="72" height="16" uuid="1b1efafd-049a-432e-93c4-9662273011f7"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <text><![CDATA[採購訂單編號:]]></text> | |||
| </staticText> | |||
| <textField textAdjust="ScaleFont" isBlankWhenNull="true"> | |||
| <reportElement x="302" y="258" width="118" height="16" uuid="b805e20a-9a36-45c8-8255-23b6cc58f47c"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="8"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{poCode}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="265" y="87" width="120" height="24" uuid="d66a64b0-3335-4846-bcb0-a67594520055"> | |||
| <printWhenExpression><![CDATA[$P{isTransfer} != null && !$P{isTransfer}.toString().trim().isEmpty()]]></printWhenExpression> | |||
| </reportElement> | |||
| <box> | |||
| <pen lineWidth="2.0"/> | |||
| </box> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="11" isBold="true" isUnderline="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{isTransfer}]]></textFieldExpression> | |||
| </textField> | |||
| </band> | |||
| </detail> | |||
| </jasperReport> | |||
| @@ -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箱")) | |||
| } | |||
| } | |||