# Conflicts: # src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt # src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt # src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt # src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt # src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt # src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt # src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt # src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt # src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt # src/main/resources/jasper/StockLedgarReport.jrxmlfix負數倉
| @@ -3,8 +3,11 @@ package com.ffii.core.utils | |||||
| import org.apache.pdfbox.pdmodel.PDDocument | import org.apache.pdfbox.pdmodel.PDDocument | ||||
| import org.apache.pdfbox.rendering.PDFRenderer | import org.apache.pdfbox.rendering.PDFRenderer | ||||
| import java.awt.image.BufferedImage | import java.awt.image.BufferedImage | ||||
| import java.awt.image.DataBufferByte | |||||
| import java.awt.image.MultiPixelPackedSampleModel | |||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.io.File | import java.io.File | ||||
| import java.net.InetSocketAddress | |||||
| import java.net.Socket | import java.net.Socket | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| @@ -12,6 +15,7 @@ class BrotherPrinterUtil { | |||||
| companion object { | companion object { | ||||
| private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept | 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 const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 | ||||
| private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) | 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") | 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 -> | PDDocument.load(pdfFile).use { document -> | ||||
| val renderer = PDFRenderer(document) | val renderer = PDFRenderer(document) | ||||
| val totalPages = document.numberOfPages | 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 | socket.soTimeout = SOCKET_TIMEOUT_MS | ||||
| val os = socket.getOutputStream() | val os = socket.getOutputStream() | ||||
| @@ -52,6 +59,7 @@ class BrotherPrinterUtil { | |||||
| log.info("Brother print job sent successfully") | log.info("Brother print job sent successfully") | ||||
| } | } | ||||
| } | } | ||||
| } | |||||
| } | } | ||||
| private fun getPjlHeader(qty: Int): ByteArray { | 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. | * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. | ||||
| */ | */ | ||||
| private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { | private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { | ||||
| convertPackedBinaryToPcl(image, dpi)?.let { return it } | |||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| val width = image.width | val width = image.width | ||||
| val height = image.height | val height = image.height | ||||
| @@ -75,21 +85,21 @@ class BrotherPrinterUtil { | |||||
| out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset | out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset | ||||
| // PCL: Set resolution and start raster | // PCL: Set resolution and start raster | ||||
| out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) | 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) { | for (y in 0 until height) { | ||||
| val rowBytes = (width + 7) / 8 | val rowBytes = (width + 7) / 8 | ||||
| val data = ByteArray(rowBytes) | val data = ByteArray(rowBytes) | ||||
| for (x in 0 until width) { | for (x in 0 until width) { | ||||
| // Check if pixel is dark (threshold) | // Check if pixel is dark (threshold) | ||||
| val color = image.getRGB(x, y) | val color = image.getRGB(x, y) | ||||
| val isBlack = (color and 0xFF) < 128 | |||||
| val isBlack = (color and 0xFF) < 128 | |||||
| if (isBlack) { | if (isBlack) { | ||||
| data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() | data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() | ||||
| } | } | ||||
| } | } | ||||
| // PCL: Transfer Raster Data (Esc * b # W [data]) | // PCL: Transfer Raster Data (Esc * b # W [data]) | ||||
| out.write("\u001B*b${rowBytes}W".toByteArray()) | out.write("\u001B*b${rowBytes}W".toByteArray()) | ||||
| out.write(data) | out.write(data) | ||||
| @@ -99,5 +109,64 @@ class BrotherPrinterUtil { | |||||
| out.write("\u001B*rB".toByteArray()) | out.write("\u001B*rB".toByteArray()) | ||||
| return out.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 org.slf4j.LoggerFactory | ||||
| import java.io.File | import java.io.File | ||||
| import java.io.OutputStream | import java.io.OutputStream | ||||
| import java.net.InetSocketAddress | |||||
| import java.net.Socket | import java.net.Socket | ||||
| /** | /** | ||||
| @@ -111,7 +112,11 @@ open class CanonPrinterUtil { | |||||
| val copies = printQty ?: 1 | val copies = printQty ?: 1 | ||||
| repeat(copies) { copyIndex -> | repeat(copies) { copyIndex -> | ||||
| val tSend = System.nanoTime() | 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() | val os: OutputStream = socket.getOutputStream() | ||||
| // Send PJL commands | // Send PJL commands | ||||
| @@ -126,6 +131,7 @@ open class CanonPrinterUtil { | |||||
| os.flush() | os.flush() | ||||
| } | } | ||||
| } | |||||
| val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 | val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 | ||||
| log.info( | log.info( | ||||
| "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", | "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 | package com.ffii.core.utils | ||||
| import com.google.zxing.BarcodeFormat | import com.google.zxing.BarcodeFormat | ||||
| import com.google.zxing.EncodeHintType | |||||
| import java.awt.image.BufferedImage | 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 { | open class QrCodeUtil { | ||||
| companion object { | companion object { | ||||
| fun generateQRCodeImage(barcodeText: String?): BufferedImage { | 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) | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer { | |||||
| registry.addMapping("/**") | registry.addMapping("/**") | ||||
| .allowedHeaders("*") | .allowedHeaders("*") | ||||
| .allowedOrigins("*") | .allowedOrigins("*") | ||||
| .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry") | |||||
| .exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry", "X-M18-Po-Refresh") | |||||
| .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); | .allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS"); | ||||
| } | } | ||||
| @@ -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. 51 | v1.0.1 | 2026-08-06 | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 | * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 | ||||
| * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) | * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) | ||||
| * Route board truck APIs → ADMIN / TRUCK_LANE / TESTING | |||||
| */ | */ | ||||
| @Bean | @Bean | ||||
| @Order(1) | @Order(1) | ||||
| @@ -140,6 +141,18 @@ public class SecurityConfig { | |||||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | .hasAnyAuthority("ADMIN", "M18_SYNC") | ||||
| .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") | .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") | ||||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | .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()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | ||||
| @@ -1,10 +1,20 @@ | |||||
| package com.ffii.fpsms.m18.model | package com.ffii.fpsms.m18.model | ||||
| import java.time.LocalDateTime | |||||
| data class SyncResult( | data class SyncResult( | ||||
| val totalProcessed: Int, | val totalProcessed: Int, | ||||
| val totalSuccess: Int, | val totalSuccess: Int, | ||||
| val totalFail: Int, | val totalFail: Int, | ||||
| val query: String? = null | 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) { | if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { | ||||
| shopPoConds += "=and=(${lastDateConds})" | 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 | * 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). | * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). | ||||
| * No M18-side "加單" filtering is used. | * No M18-side "加單" filtering is used. | ||||
| @@ -173,15 +182,16 @@ open class M18DeliveryOrderService( | |||||
| isExtraSync: Boolean = false, | isExtraSync: Boolean = false, | ||||
| newOnly: Boolean = false, | newOnly: Boolean = false, | ||||
| ): SyncResult { | ): SyncResult { | ||||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | |||||
| val trimmed = code.trim() | |||||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) { | |||||
| return SyncResult( | return SyncResult( | ||||
| totalProcessed = 1, | totalProcessed = 1, | ||||
| totalSuccess = 0, | totalSuccess = 0, | ||||
| totalFail = 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( | val searchRequest = M18PurchaseOrderListRequest( | ||||
| stSearch = "po", | stSearch = "po", | ||||
| params = null, | params = null, | ||||
| @@ -228,6 +238,7 @@ open class M18DeliveryOrderService( | |||||
| val successList = mutableListOf<Long>() | val successList = mutableListOf<Long>() | ||||
| val skippedList = mutableListOf<Long>() | val skippedList = mutableListOf<Long>() | ||||
| val skippedNotPendingList = mutableListOf<String>() | |||||
| val successDetailList = mutableListOf<Long>() | val successDetailList = mutableListOf<Long>() | ||||
| val failList = mutableListOf<Long>() | val failList = mutableListOf<Long>() | ||||
| val failDetailList = mutableListOf<Long>() | val failDetailList = mutableListOf<Long>() | ||||
| @@ -291,6 +302,23 @@ open class M18DeliveryOrderService( | |||||
| // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | ||||
| val latestDeliveryOrderLog = | val latestDeliveryOrderLog = | ||||
| m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) | 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()) | //logger.info(latestDeliveryOrderLog.toString()) | ||||
| // Save to m18_data_log table | // Save to m18_data_log table | ||||
| // logger.info("${doRefType}: Saving for M18 Data Log...") | // 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}") | // logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}") | ||||
| try { | 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...") | // 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}") | // logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}") | ||||
| // Save to delivery_order table | // Save to delivery_order table | ||||
| @@ -621,12 +648,13 @@ open class M18DeliveryOrderService( | |||||
| logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | 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( | return SyncResult( | ||||
| totalProcessed = successList.size + failList.size + skippedList.size, | |||||
| totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, | |||||
| totalSuccess = successList.size, | totalSuccess = successList.size, | ||||
| totalFail = failList.size, | totalFail = failList.size, | ||||
| query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, | |||||
| query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -15,10 +15,12 @@ import com.ffii.fpsms.modules.master.service.ShopService | |||||
| import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus | import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus | ||||
| import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderStatus | import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderStatus | ||||
| import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType | import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType | ||||
| import com.ffii.fpsms.modules.common.SettingNames | |||||
| import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderLineService | import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderLineService | ||||
| import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService | import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest | import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderRequest | import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderRequest | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| @@ -39,8 +41,16 @@ open class M18PurchaseOrderService( | |||||
| val itemsService: ItemsService, | val itemsService: ItemsService, | ||||
| val shopService: ShopService, | val shopService: ShopService, | ||||
| val itemUomService: ItemUomService, | val itemUomService: ItemUomService, | ||||
| val m18MasterDataService: M18MasterDataService | |||||
| val m18MasterDataService: M18MasterDataService, | |||||
| val settingsService: SettingsService, | |||||
| ) { | ) { | ||||
| companion object { | |||||
| fun isStockInLookupPoCode(code: String?): Boolean { | |||||
| val trimmed = code?.trim().orEmpty() | |||||
| return trimmed.length > 14 && (trimmed.startsWith("PP") || trimmed.startsWith("PF")) | |||||
| } | |||||
| } | |||||
| val commonUtils = CommonUtils() | val commonUtils = CommonUtils() | ||||
| val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java) | ||||
| @@ -87,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) { | if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { | ||||
| materialPoConds += "=and=(${lastDateConds})" | materialPoConds += "=and=(${lastDateConds})" | ||||
| @@ -223,44 +226,129 @@ open class M18PurchaseOrderService( | |||||
| return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL | 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 { | 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 searchRequest = M18PurchaseOrderListRequest( | |||||
| stSearch = "po", | |||||
| params = null, | |||||
| 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)) | |||||
| } | |||||
| /** | |||||
| * Stock-in `/po` lookup. | |||||
| * - Missing local PO: always sync from M18 (same as [savePurchaseOrderByCode]). | |||||
| * - Local pending + setting [SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED] true: | |||||
| * sync from M18 (overwrite pending). Receiving / completed are not refreshed. | |||||
| * - Local PO + setting not true: do not call M18. | |||||
| */ | |||||
| open fun savePurchaseOrderByCodeIfNewer(code: String): SyncResult = | |||||
| refreshForStockInLookup(code).syncResult | |||||
| open fun refreshForStockInLookup(code: String): StockInPoRefreshOutcome { | |||||
| val trimmed = code.trim() | |||||
| val enabled = isStockInRefreshIfNewerEnabled() | |||||
| val localStatus = purchaseOrderService.findActiveStatusByCode(trimmed) | |||||
| logger.info( | |||||
| "Purchase Order: stock-in ifNewer code={} settingEnabled={} localStatus={}", | |||||
| trimmed, | |||||
| enabled, | |||||
| localStatus ?: "<none>", | |||||
| ) | ) | ||||
| val poListResponse = try { | |||||
| apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | |||||
| M18_FETCH_PURCHASE_ORDER_LIST_API, | |||||
| searchRequest | |||||
| ).block() | |||||
| } catch (e: Exception) { | |||||
| logger.error("(Getting Po List By Code) Error on Function - ${e.stackTrace}") | |||||
| logger.error(e.message) | |||||
| null | |||||
| if (localStatus != null && !enabled) { | |||||
| logger.info( | |||||
| "Purchase Order: Skipping M18 sync — setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} " + | |||||
| "is not true. code=$trimmed status=$localStatus" | |||||
| ) | |||||
| return StockInPoRefreshOutcome( | |||||
| action = StockInPoRefreshAction.SKIPPED_DISABLED, | |||||
| syncResult = SyncResult( | |||||
| totalProcessed = 0, | |||||
| totalSuccess = 0, | |||||
| totalFail = 0, | |||||
| query = "skipped (ifNewer): setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} is not true" | |||||
| ), | |||||
| ) | |||||
| } | } | ||||
| if (localStatus != null && | |||||
| !PurchaseOrderStatus.PENDING.value.equals(localStatus, ignoreCase = true) | |||||
| ) { | |||||
| logger.info( | |||||
| "Purchase Order: Skipping M18 sync — local PO code=$trimmed status=$localStatus (only pending may be refreshed)." | |||||
| ) | |||||
| return StockInPoRefreshOutcome( | |||||
| action = StockInPoRefreshAction.SKIPPED_NOT_PENDING, | |||||
| syncResult = SyncResult( | |||||
| totalProcessed = 0, | |||||
| totalSuccess = 0, | |||||
| totalFail = 0, | |||||
| query = "skipped (ifNewer): local PO not pending: $trimmed status=$localStatus" | |||||
| ), | |||||
| ) | |||||
| } | |||||
| return StockInPoRefreshOutcome( | |||||
| action = StockInPoRefreshAction.SYNCED, | |||||
| syncResult = savePurchaseOrderByCode(trimmed), | |||||
| ) | |||||
| } | |||||
| private fun isStockInRefreshIfNewerEnabled(): Boolean = | |||||
| settingsService.isBooleanTrueFromDb(SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED) | |||||
| private fun saveFromPoListSearch( | |||||
| query: String, | |||||
| poListResponse: M18PurchaseOrderListResponse?, | |||||
| ): SyncResult { | |||||
| val poValues = poListResponse?.values | val poValues = poListResponse?.values | ||||
| if (poValues.isNullOrEmpty()) { | if (poValues.isNullOrEmpty()) { | ||||
| return SyncResult( | return SyncResult( | ||||
| totalProcessed = 1, | totalProcessed = 1, | ||||
| totalSuccess = 0, | totalSuccess = 0, | ||||
| totalFail = 1, | totalFail = 1, | ||||
| query = "code=equal=$code" | |||||
| query = query | |||||
| ) | ) | ||||
| } | } | ||||
| val request = M18PurchaseOrderListResponseWithType( | val request = M18PurchaseOrderListResponseWithType( | ||||
| valuesWithType = mutableListOf(Pair(PurchaseOrderType.MATERIAL, poListResponse)), | valuesWithType = mutableListOf(Pair(PurchaseOrderType.MATERIAL, poListResponse)), | ||||
| query = "code=equal=$code" | |||||
| query = query | |||||
| ) | ) | ||||
| return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request) | return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request) | ||||
| } | } | ||||
| private fun searchPurchaseOrdersByConds(conds: String): M18PurchaseOrderListResponse? { | |||||
| val searchRequest = M18PurchaseOrderListRequest( | |||||
| stSearch = "po", | |||||
| params = null, | |||||
| conds = conds | |||||
| ) | |||||
| return try { | |||||
| apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | |||||
| M18_FETCH_PURCHASE_ORDER_LIST_API, | |||||
| searchRequest | |||||
| ).block() | |||||
| } catch (e: Exception) { | |||||
| logger.error("(Getting Po List By Code) Error on Function - ${e.stackTrace}") | |||||
| logger.error(e.message) | |||||
| null | |||||
| } | |||||
| } | |||||
| open fun savePurchaseOrders(request: M18CommonRequest) : SyncResult{ | open fun savePurchaseOrders(request: M18CommonRequest) : SyncResult{ | ||||
| val purchaseOrdersWithType = getPurchaseOrdersWithType(request) | val purchaseOrdersWithType = getPurchaseOrdersWithType(request) | ||||
| return savePurchaseOrdersWithPreparedList( | return savePurchaseOrdersWithPreparedList( | ||||
| @@ -279,6 +367,7 @@ open class M18PurchaseOrderService( | |||||
| val successDetailList = mutableListOf<Long>() | val successDetailList = mutableListOf<Long>() | ||||
| val failList = mutableListOf<Long>() | val failList = mutableListOf<Long>() | ||||
| val failDetailList = mutableListOf<Long>() | val failDetailList = mutableListOf<Long>() | ||||
| val skippedNotPendingList = mutableListOf<String>() | |||||
| val affectedItemIds = mutableSetOf<Long>() | val affectedItemIds = mutableSetOf<Long>() | ||||
| val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>() | val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>() | ||||
| @@ -320,11 +409,15 @@ open class M18PurchaseOrderService( | |||||
| if (existingPurchaseOrderForSync != null && | if (existingPurchaseOrderForSync != null && | ||||
| existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING | existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING | ||||
| ) { | ) { | ||||
| val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown" | |||||
| logger.info( | logger.info( | ||||
| "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + | "${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" | "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" | ||||
| ) | ) | ||||
| skippedNotPendingList.add( | |||||
| "code=${existingPurchaseOrderForSync.code} status=$statusValue" | |||||
| ) | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| @@ -651,11 +744,25 @@ open class M18PurchaseOrderService( | |||||
| logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") | logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") | ||||
| val baseQuery = purchaseOrdersWithType?.query ?: "" | |||||
| val skippedSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) | |||||
| return SyncResult( | return SyncResult( | ||||
| totalProcessed = successList.size + failList.size, | |||||
| totalProcessed = successList.size + failList.size + skippedNotPendingList.size, | |||||
| totalSuccess = successList.size, | totalSuccess = successList.size, | ||||
| totalFail = failList.size, | totalFail = failList.size, | ||||
| query = purchaseOrdersWithType?.query ?: "" | |||||
| query = baseQuery + skippedSuffix | |||||
| ) | ) | ||||
| } | } | ||||
| } | |||||
| } | |||||
| enum class StockInPoRefreshAction(val headerValue: String) { | |||||
| SKIPPED_DISABLED("skipped_disabled"), | |||||
| SKIPPED_NOT_PENDING("skipped_not_pending"), | |||||
| SYNCED("synced"), | |||||
| } | |||||
| data class StockInPoRefreshOutcome( | |||||
| val action: StockInPoRefreshAction, | |||||
| val syncResult: SyncResult, | |||||
| ) | |||||
| @@ -76,8 +76,15 @@ class M18TestController ( | |||||
| } | } | ||||
| @GetMapping("/test/po-by-code") | @GetMapping("/test/po-by-code") | ||||
| fun testSyncPoByCode(@RequestParam code: String): SyncResult { | |||||
| return m18PurchaseOrderService.savePurchaseOrderByCode(code) | |||||
| fun testSyncPoByCode( | |||||
| @RequestParam code: String, | |||||
| @RequestParam(required = false, defaultValue = "false") ifNewer: Boolean, | |||||
| ): SyncResult { | |||||
| return if (ifNewer) { | |||||
| m18PurchaseOrderService.savePurchaseOrderByCodeIfNewer(code) | |||||
| } else { | |||||
| m18PurchaseOrderService.savePurchaseOrderByCode(code) | |||||
| } | |||||
| } | } | ||||
| @GetMapping("/test/do-by-code") | @GetMapping("/test/do-by-code") | ||||
| @@ -52,6 +52,15 @@ public abstract class SettingNames { | |||||
| */ | */ | ||||
| public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled"; | ||||
| /** | |||||
| * When "true", stock-in {@code /po} lookup for an existing pending PO re-syncs from M18. | |||||
| * Read from DB on each request (no server restart). Missing POs still auto-sync. | |||||
| * Receiving / completed POs are never overwritten. | |||||
| */ | |||||
| public static final String M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED = | |||||
| "M18.po.stockIn.refreshIfNewer.enabled"; | |||||
| /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | /** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */ | ||||
| public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn"; | ||||
| @@ -71,6 +80,12 @@ public abstract class SettingNames { | |||||
| * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. | * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. | ||||
| */ | */ | ||||
| public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; | public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; | ||||
| /** | |||||
| * Stock-take count vs on-hand gap (percent) that shows a non-blocking warning. | |||||
| * Either direction. Default 50. | |||||
| */ | |||||
| public static final String STOCK_TAKE_QTY_GAP_WARN_PERCENT = "STOCK_TAKE.qtyGapWarnPercent"; | |||||
| /* | /* | ||||
| * Mail settings | * Mail settings | ||||
| */ | */ | ||||
| @@ -123,4 +138,7 @@ public abstract class SettingNames { | |||||
| /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ | /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ | ||||
| public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; | 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"; | |||||
| } | } | ||||
| @@ -93,6 +93,8 @@ open class SchedulerService( | |||||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | ||||
| /** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */ | /** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */ | ||||
| const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 0 * * *" | const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 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). */ | /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ | ||||
| @@ -811,14 +813,21 @@ open class SchedulerService( | |||||
| recordsProcessed = result?.totalProcessed ?: 0, | recordsProcessed = result?.totalProcessed ?: 0, | ||||
| recordsSaved = result?.totalSuccess ?: 0, | recordsSaved = result?.totalSuccess ?: 0, | ||||
| recordsFailed = result?.totalFail ?: 0, | recordsFailed = result?.totalFail ?: 0, | ||||
| errorMessage = error, | |||||
| query = result?.query ?: "", | |||||
| errorMessage = fitSyncLogText(error), | |||||
| query = fitSyncLogText(result?.query ?: ""), | |||||
| startTime = start, | startTime = start, | ||||
| endTime = LocalDateTime.now() | endTime = LocalDateTime.now() | ||||
| ) | ) | ||||
| schedulerSyncLogRepository.save(log) | 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() { | open fun getM18Dos2() { | ||||
| logger.info("DO Scheduler 2 - DO") | logger.info("DO Scheduler 2 - DO") | ||||
| val currentTime = LocalDateTime.now() | val currentTime = LocalDateTime.now() | ||||
| @@ -72,6 +72,7 @@ import jakarta.persistence.EntityManager | |||||
| import jakarta.persistence.PersistenceContext | import jakarta.persistence.PersistenceContext | ||||
| import org.springframework.orm.ObjectOptimisticLockingFailureException | import org.springframework.orm.ObjectOptimisticLockingFailureException | ||||
| import com.ffii.core.utils.PdfUtils | import com.ffii.core.utils.PdfUtils | ||||
| import com.ffii.core.utils.PrinterLockManager | |||||
| import com.ffii.core.utils.ZebraPrinterUtil | import com.ffii.core.utils.ZebraPrinterUtil | ||||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | ||||
| import com.ffii.fpsms.modules.master.service.PrinterService | 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.ConcurrentHashMap | ||||
| import java.util.concurrent.ConcurrentLinkedQueue | import java.util.concurrent.ConcurrentLinkedQueue | ||||
| import java.util.concurrent.CountDownLatch | import java.util.concurrent.CountDownLatch | ||||
| import java.util.concurrent.Executors | |||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import net.sf.jasperreports.engine.JasperExportManager | import net.sf.jasperreports.engine.JasperExportManager | ||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import net.sf.jasperreports.engine.JasperReport | |||||
| import java.io.File | import java.io.File | ||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.util.Locale | import java.util.Locale | ||||
| @@ -144,6 +145,20 @@ open class DoWorkbenchMainService( | |||||
| companion object { | companion object { | ||||
| private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) | 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 { | private sealed interface WorkbenchScanIllResolve { | ||||
| @@ -2934,6 +2949,7 @@ return MessageResponse( | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { | open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { | ||||
| val started = System.nanoTime() | |||||
| val printer = | val printer = | ||||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | ||||
| @@ -2947,17 +2963,38 @@ return MessageResponse( | |||||
| val jasperPrint = pdf["report"] as JasperPrint | val jasperPrint = pdf["report"] as JasperPrint | ||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | val tempPdfFile = File.createTempFile("print_job_", ".pdf") | ||||
| var handedToPrinter = false | |||||
| try { | try { | ||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | ||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | 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 | |||||
| } | } | ||||
| } | } | ||||
| @@ -3024,14 +3061,7 @@ return MessageResponse( | |||||
| } | } | ||||
| open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map<String, Any> { | 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 fields = mutableListOf<MutableMap<String, Any>>() | ||||
| val params = mutableMapOf<String, Any>() | val params = mutableMapOf<String, Any>() | ||||
| @@ -3045,18 +3075,9 @@ return MessageResponse( | |||||
| val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo) | 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 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) | val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) | ||||
| ?.replace("/", "") | ?.replace("/", "") | ||||
| @@ -3082,11 +3103,7 @@ return MessageResponse( | |||||
| val allIllIds = stockOutLinesByPickOrderLineId.values | val allIllIds = stockOutLinesByPickOrderLineId.values | ||||
| .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } | .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } | ||||
| .distinct() | .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 deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds) | ||||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | ||||
| @@ -3160,6 +3177,7 @@ return MessageResponse( | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { | open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { | ||||
| val started = System.nanoTime() | |||||
| val printer = | val printer = | ||||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | ||||
| @@ -3173,26 +3191,48 @@ return MessageResponse( | |||||
| val jasperPrint = pdf["report"] as JasperPrint | val jasperPrint = pdf["report"] as JasperPrint | ||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | val tempPdfFile = File.createTempFile("print_job_", ".pdf") | ||||
| var handedToPrinter = false | |||||
| try { | try { | ||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | ||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | 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 | |||||
| } | } | ||||
| } | } | ||||
| @@ -3213,15 +3253,8 @@ return MessageResponse( | |||||
| } | } | ||||
| private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map<String, Any> { | 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 ctx = getWorkbenchPrintContext(request.doPickOrderId) | ||||
| val inputStream = resource.inputStream | |||||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||||
| val cartonLabel = cartonLabelJasperTemplate | |||||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | ||||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | ||||
| }.toMutableList() | }.toMutableList() | ||||
| @@ -3282,15 +3315,8 @@ return MessageResponse( | |||||
| totalCartonsOnShipment = request.totalCartonsOnShipment, | 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 ctx = getWorkbenchPrintContext(request.doPickOrderId) | ||||
| val inputStream = resource.inputStream | |||||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||||
| val cartonLabel = cartonLabelJasperTemplate | |||||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | ||||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | ||||
| }.toMutableList() | }.toMutableList() | ||||
| @@ -3330,23 +3356,147 @@ return MessageResponse( | |||||
| val pdf = exportDNLabelsReprintWorkbench(request) | val pdf = exportDNLabelsReprintWorkbench(request) | ||||
| val jasperPrint = pdf["report"] as JasperPrint | val jasperPrint = pdf["report"] as JasperPrint | ||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | val tempPdfFile = File.createTempFile("print_job_", ".pdf") | ||||
| var handedToPrinter = false | |||||
| try { | try { | ||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | ||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | 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 { | } 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 | |||||
| } | |||||
| } | } | ||||
| @@ -226,6 +226,27 @@ open class ItemUomService( | |||||
| return stockQty.setScale(0, RoundingMode.DOWN) | return stockQty.setScale(0, RoundingMode.DOWN) | ||||
| } | } | ||||
| /** | |||||
| * Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc. | |||||
| * Weight/volume units (克, 公斤, 升…) keep 2 decimals. | |||||
| */ | |||||
| open fun isIntegerCountStockUom(stockUnit: ItemUom?): Boolean { | |||||
| if (stockUnit == null) return false | |||||
| val code = stockUnit.uom?.code?.trim().orEmpty() | |||||
| if (code.startsWith("PCS/CTN", ignoreCase = true)) return true | |||||
| val short = stockUnit.uom?.udfShortDesc?.trim().orEmpty() | |||||
| return isIntegerCountUnitToken(short) || isIntegerCountUnitToken(code) | |||||
| } | |||||
| private fun isIntegerCountUnitToken(raw: String): Boolean { | |||||
| val token = raw.trim() | |||||
| if (token.isEmpty()) return false | |||||
| if (token == "包" || token == "箱" || token == "件" || token == "個") return true | |||||
| val upper = token.uppercase() | |||||
| return upper == "PCS" || upper == "PC" || upper == "CTN" || upper == "EA" || | |||||
| upper == "PK" || upper == "PKT" || upper == "BOX" | |||||
| } | |||||
| /** | /** | ||||
| * Stock UOM code [PCS/CTN]* denotes pieces-per-carton; displayed stock qty should be whole cartons (integer). | * Stock UOM code [PCS/CTN]* denotes pieces-per-carton; displayed stock qty should be whole cartons (integer). | ||||
| */ | */ | ||||
| @@ -234,9 +255,9 @@ open class ItemUomService( | |||||
| return code.startsWith("PCS/CTN", ignoreCase = true) | return code.startsWith("PCS/CTN", ignoreCase = true) | ||||
| } | } | ||||
| /** After ratio math: 2 decimals, or integer when stock unit is PCS/CTN… */ | |||||
| /** After ratio math: integer HALF_UP for count units (包/箱/PCS…), else 2 decimals. */ | |||||
| private fun finalizePreciseStockQty(stockUnit: ItemUom?, stockQty: BigDecimal): BigDecimal { | private fun finalizePreciseStockQty(stockUnit: ItemUom?, stockQty: BigDecimal): BigDecimal { | ||||
| if (isPiecesPerCartonStockUomCode(stockUnit)) { | |||||
| if (isIntegerCountStockUom(stockUnit) || isPiecesPerCartonStockUomCode(stockUnit)) { | |||||
| return stockQty.setScale(0, RoundingMode.HALF_UP) | return stockQty.setScale(0, RoundingMode.HALF_UP) | ||||
| } | } | ||||
| return stockQty.setScale(2, RoundingMode.HALF_UP) | return stockQty.setScale(2, RoundingMode.HALF_UP) | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import org.springframework.stereotype.Repository | 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.Modifying | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | 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> | 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) | @Modifying(clearAutomatically = true, flushAutomatically = true) | ||||
| @Query( | @Query( | ||||
| value = """ | value = """ | ||||
| @@ -22,13 +22,13 @@ import java.time.LocalDateTime | |||||
| open class TruckLaneScheduleController @Autowired constructor( | open class TruckLaneScheduleController @Autowired constructor( | ||||
| private val truckLaneScheduleService: TruckLaneScheduleService, | private val truckLaneScheduleService: TruckLaneScheduleService, | ||||
| ) { | ) { | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping | @PostMapping | ||||
| open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | ||||
| return truckLaneScheduleService.createManual(request) | return truckLaneScheduleService.createManual(request) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/planFromRouteExcel") | @PostMapping("/planFromRouteExcel") | ||||
| @Throws(ServletRequestBindingException::class) | @Throws(ServletRequestBindingException::class) | ||||
| open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> { | open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> { | ||||
| @@ -66,19 +66,19 @@ open class TruckLaneScheduleController @Autowired constructor( | |||||
| return truckLaneScheduleService.getById(id) | return truckLaneScheduleService.getById(id) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/cancel") | @PostMapping("/{id}/cancel") | ||||
| open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { | open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { | ||||
| return truckLaneScheduleService.cancel(id) | return truckLaneScheduleService.cancel(id) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/applyNow") | @PostMapping("/{id}/applyNow") | ||||
| open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { | open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { | ||||
| return truckLaneScheduleService.applyNow(id) | return truckLaneScheduleService.applyNow(id) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/retry-failed") | @PostMapping("/{id}/retry-failed") | ||||
| open fun retryFailed( | open fun retryFailed( | ||||
| @PathVariable id: Long, | @PathVariable id: Long, | ||||
| @@ -87,7 +87,7 @@ open class TruckLaneScheduleController @Autowired constructor( | |||||
| return truckLaneScheduleService.retryFailed(id, request?.executeAt) | return truckLaneScheduleService.retryFailed(id, request?.executeAt) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/reactivate") | @PostMapping("/{id}/reactivate") | ||||
| open fun reactivateCancelled( | open fun reactivateCancelled( | ||||
| @PathVariable id: Long, | @PathVariable id: Long, | ||||
| @@ -124,7 +124,7 @@ open class TruckLaneScheduleController @Autowired constructor( | |||||
| } | } | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/importExcel") | @PostMapping("/importExcel") | ||||
| @Throws(ServletRequestBindingException::class) | @Throws(ServletRequestBindingException::class) | ||||
| open fun importExcel( | open fun importExcel( | ||||
| @@ -16,6 +16,8 @@ import org.springframework.data.repository.query.Param | |||||
| interface PurchaseOrderRepository : AbstractRepository<PurchaseOrder, Long> { | interface PurchaseOrderRepository : AbstractRepository<PurchaseOrder, Long> { | ||||
| fun findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18datalogId: Serializable): PurchaseOrder? | fun findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18datalogId: Serializable): PurchaseOrder? | ||||
| fun findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code: String): PurchaseOrder? | |||||
| fun findPurchaseOrderInfoByIdAndDeletedIsFalse(id: Long): PurchaseOrderInfo | fun findPurchaseOrderInfoByIdAndDeletedIsFalse(id: Long): PurchaseOrderInfo | ||||
| fun findByIdAndDeletedFalse(id: Long): Optional<PurchaseOrder> | fun findByIdAndDeletedFalse(id: Long): Optional<PurchaseOrder> | ||||
| @@ -57,9 +57,13 @@ data class StockUomForPoLine ( | |||||
| val id: Long? = null, | val id: Long? = null, | ||||
| val stockUomCode: String? = null, | val stockUomCode: String? = null, | ||||
| val stockUomDesc: String? = null, | val stockUomDesc: String? = null, | ||||
| val stockUomShortDesc: String? = null, | |||||
| val stockQty: BigDecimal? = null, | val stockQty: BigDecimal? = null, | ||||
| val stockRatioN: BigDecimal? = null, | val stockRatioN: BigDecimal? = null, | ||||
| val stockRatioD: BigDecimal? = null, | val stockRatioD: BigDecimal? = null, | ||||
| val purchaseRatioN: BigDecimal? = null, | val purchaseRatioN: BigDecimal? = null, | ||||
| val purchaseRatioD: BigDecimal? = null, | val purchaseRatioD: BigDecimal? = null, | ||||
| val sourceRatioN: BigDecimal? = null, | |||||
| val sourceRatioD: BigDecimal? = null, | |||||
| val stockQtyInteger: Boolean? = null, | |||||
| ) | ) | ||||
| @@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| orderDate = it["orderDate"] as LocalDateTime?, | orderDate = it["orderDate"] as LocalDateTime?, | ||||
| estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, | estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, | ||||
| supplierName = it["supplierName"] as String?, | 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(" group by po.id"); | ||||
| sql.append(" order by po.orderDate desc") | sql.append(" order by po.orderDate desc") | ||||
| sql.append(" ) r") | 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() | return sql.toString() | ||||
| } | } | ||||
| @@ -294,6 +305,12 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| val itemId = thisPol.item?.id | val itemId = thisPol.item?.id | ||||
| val qtyM18 = thisPol.qtyM18 | val qtyM18 = thisPol.qtyM18 | ||||
| val uomM18Id = thisPol.uomM18?.id | val uomM18Id = thisPol.uomM18?.id | ||||
| val sourceUomId = uomM18Id ?: thisPol.uom?.id | |||||
| val sourceItemUom = if (itemId != null && sourceUomId != null) { | |||||
| itemUomService.findFirstByItemIdAndUomId(itemId, sourceUomId) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) { | val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) { | ||||
| itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) | itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18) | ||||
| } else { | } else { | ||||
| @@ -303,6 +320,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| id = iu?.id, | id = iu?.id, | ||||
| stockUomCode = iu?.uom?.code, | stockUomCode = iu?.uom?.code, | ||||
| stockUomDesc = iu?.uom?.udfudesc, | stockUomDesc = iu?.uom?.udfudesc, | ||||
| stockUomShortDesc = iu?.uom?.udfShortDesc, | |||||
| stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { | stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else { | ||||
| // fallback to legacy behavior when M18 fields are missing | // fallback to legacy behavior when M18 fields are missing | ||||
| iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQtyPrecise(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO | iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQtyPrecise(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO | ||||
| @@ -311,6 +329,9 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| stockRatioD = iu?.ratioD, | stockRatioD = iu?.ratioD, | ||||
| purchaseRatioN = purchaseUnit?.ratioN, | purchaseRatioN = purchaseUnit?.ratioN, | ||||
| purchaseRatioD = purchaseUnit?.ratioD, | purchaseRatioD = purchaseUnit?.ratioD, | ||||
| sourceRatioN = sourceItemUom?.ratioN ?: purchaseUnit?.ratioN, | |||||
| sourceRatioD = sourceItemUom?.ratioD ?: purchaseUnit?.ratioD, | |||||
| stockQtyInteger = itemUomService.isIntegerCountStockUom(iu), | |||||
| ) | ) | ||||
| } } ?: StockUomForPoLine() | } } ?: StockUomForPoLine() | ||||
| PoLineWithStockInLine( | PoLineWithStockInLine( | ||||
| @@ -350,6 +371,24 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| return purchaseOrderRepository.findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18DataLogId) | return purchaseOrderRepository.findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18DataLogId) | ||||
| } | } | ||||
| open fun findByCode(code: String): PurchaseOrder? { | |||||
| return purchaseOrderRepository.findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code) | |||||
| } | |||||
| /** JDBC lookup so stock-in skip does not depend on JPA finding the entity. */ | |||||
| open fun findActiveStatusByCode(code: String): String? { | |||||
| val status = jdbcDao.queryForString( | |||||
| """ | |||||
| SELECT status FROM purchase_order | |||||
| WHERE deleted = 0 AND code = :code | |||||
| ORDER BY modified DESC | |||||
| LIMIT 1 | |||||
| """.trimIndent(), | |||||
| mapOf("code" to code), | |||||
| ) | |||||
| return status.takeIf { it.isNotBlank() } | |||||
| } | |||||
| @Throws(IOException::class) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse { | open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse { | ||||
| @@ -10,12 +10,14 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder | import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderDataClass | import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderDataClass | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderInfo | import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderInfo | ||||
| import com.ffii.fpsms.m18.service.M18PurchaseOrderService | |||||
| import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService | import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.PagingRequest | import com.ffii.fpsms.modules.purchaseOrder.web.model.PagingRequest | ||||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | import com.ffii.fpsms.modules.stock.service.StockInLineService | ||||
| import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest | import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest | ||||
| import jakarta.servlet.http.HttpServletRequest | import jakarta.servlet.http.HttpServletRequest | ||||
| import jakarta.servlet.http.HttpServletResponse | |||||
| import net.sf.jasperreports.engine.JasperExportManager | import net.sf.jasperreports.engine.JasperExportManager | ||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||
| @@ -23,15 +25,20 @@ import org.springframework.data.domain.PageRequest | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import java.io.File | import java.io.File | ||||
| import com.ffii.fpsms.modules.purchaseOrder.web.model.PurchaseOrderSummary | import com.ffii.fpsms.modules.purchaseOrder.web.model.PurchaseOrderSummary | ||||
| import org.slf4j.LoggerFactory | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/po") | @RequestMapping("/po") | ||||
| class PurchaseOrderController( | class PurchaseOrderController( | ||||
| private val purchaseOrderService: PurchaseOrderService, | private val purchaseOrderService: PurchaseOrderService, | ||||
| private val stockInLineService: StockInLineService | |||||
| private val stockInLineService: StockInLineService, | |||||
| private val m18PurchaseOrderService: M18PurchaseOrderService, | |||||
| ) { | ) { | ||||
| private val logger = LoggerFactory.getLogger(PurchaseOrderController::class.java) | |||||
| @GetMapping("/list") | @GetMapping("/list") | ||||
| fun getPoList( | fun getPoList( | ||||
| request: HttpServletRequest | |||||
| request: HttpServletRequest, | |||||
| response: HttpServletResponse, | |||||
| // @RequestParam(required = false) pageNum: Int, | // @RequestParam(required = false) pageNum: Int, | ||||
| // @RequestParam(required = false) pageSize: Int | // @RequestParam(required = false) pageSize: Int | ||||
| ): RecordsRes<PurchaseOrderDataClass> { | ): RecordsRes<PurchaseOrderDataClass> { | ||||
| @@ -40,6 +47,8 @@ class PurchaseOrderController( | |||||
| val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | ||||
| .addStringLike("supplier") | .addStringLike("supplier") | ||||
| .addStringLike("code") | .addStringLike("code") | ||||
| .addStringLike("itemCode") | |||||
| .addStringLike("itemName") | |||||
| .addString("status") | .addString("status") | ||||
| .addBoolean("escalated") | .addBoolean("escalated") | ||||
| .addDate("orderDate") | .addDate("orderDate") | ||||
| @@ -50,6 +59,17 @@ class PurchaseOrderController( | |||||
| // println(criteriaArgs) | // println(criteriaArgs) | ||||
| val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10 | val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10 | ||||
| val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 | val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1 | ||||
| val rawCode = request.getParameter("code")?.trim() | |||||
| val refreshFromM18 = request.getParameter("refreshFromM18")?.equals("true", ignoreCase = true) == true | |||||
| if (refreshFromM18 && M18PurchaseOrderService.isStockInLookupPoCode(rawCode)) { | |||||
| try { | |||||
| logger.info("Stock-in /po/list: M18 refresh for code={}", rawCode) | |||||
| val outcome = m18PurchaseOrderService.refreshForStockInLookup(rawCode!!) | |||||
| response.setHeader("X-M18-Po-Refresh", outcome.action.headerValue) | |||||
| } catch (e: Exception) { | |||||
| logger.error("Stock-in /po/list: M18 refresh failed for code={}", rawCode, e) | |||||
| } | |||||
| } | |||||
| val total = purchaseOrderService.getPoListTotalCount(criteriaArgs) | val total = purchaseOrderService.getPoListTotalCount(criteriaArgs) | ||||
| if (total == 0) { | if (total == 0) { | ||||
| @@ -32,11 +32,7 @@ open class M18BomShopSyncReportService( | |||||
| ): Map<String, Any> { | ): Map<String, Any> { | ||||
| val start = parseDateStart(syncDateStart) | val start = parseDateStart(syncDateStart) | ||||
| val end = parseDateEnd(syncDateEnd) | val end = parseDateEnd(syncDateEnd) | ||||
| val itemTokens = finishedItemCode | |||||
| ?.split(",") | |||||
| ?.map { it.trim() } | |||||
| ?.filter { it.isNotEmpty() } | |||||
| .orEmpty() | |||||
| val itemTokens = ReportMultiValueTokens.split(finishedItemCode) | |||||
| val sqlItemFilter = itemTokens.singleOrNull() | val sqlItemFilter = itemTokens.singleOrNull() | ||||
| val statusFilter = syncStatus?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "all" } | val statusFilter = syncStatus?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "all" } | ||||
| @@ -0,0 +1,41 @@ | |||||
| package com.ffii.fpsms.modules.report.service | |||||
| /** | |||||
| * Splits report filter values pasted from Excel or typed in a text field. | |||||
| * Accepts commas, semicolons, Chinese commas, and any whitespace (spaces, tabs, newlines). | |||||
| */ | |||||
| object ReportMultiValueTokens { | |||||
| private val SEPARATORS = Regex("[\\s,;,、\\u00A0\\u3000]+") | |||||
| fun split(paramValue: String?): List<String> { | |||||
| if (paramValue.isNullOrBlank()) return emptyList() | |||||
| return paramValue | |||||
| .split(SEPARATORS) | |||||
| .map { it.trim() } | |||||
| .filter { it.isNotBlank() } | |||||
| .distinctBy { it.uppercase() } | |||||
| } | |||||
| /** | |||||
| * Full item codes (e.g. FA0123) can use an indexed IN-list. | |||||
| * Short / wildcard tokens stay as prefix/LIKE matches. | |||||
| */ | |||||
| fun partitionExactAndFuzzy(paramValue: String?, exactMinLength: Int = 5): Pair<List<String>, List<String>> { | |||||
| val tokens = split(paramValue) | |||||
| val exact = tokens.filter { it.length >= exactMinLength && '%' !in it && '_' !in it } | |||||
| val fuzzy = tokens.filter { token -> exact.none { it.equals(token, ignoreCase = true) } } | |||||
| return exact to fuzzy | |||||
| } | |||||
| fun combine(itemCode: String?, itemCodePaste: String?, itemCodes: List<String>?): String? { | |||||
| val joined = buildString { | |||||
| if (!itemCode.isNullOrBlank()) append(itemCode).append(' ') | |||||
| if (!itemCodePaste.isNullOrBlank()) append(itemCodePaste).append(' ') | |||||
| itemCodes?.forEach { code -> | |||||
| if (code.isNotBlank()) append(code).append(' ') | |||||
| } | |||||
| } | |||||
| val tokens = split(joined) | |||||
| return tokens.takeIf { it.isNotEmpty() }?.joinToString(",") | |||||
| } | |||||
| } | |||||
| @@ -4,7 +4,7 @@ import org.springframework.stereotype.Service | |||||
| import net.sf.jasperreports.engine.* | import net.sf.jasperreports.engine.* | ||||
| import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | ||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.io.InputStream | |||||
| import java.util.concurrent.ConcurrentHashMap | |||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.fpsms.m18.M18GrnRules | import com.ffii.fpsms.m18.M18GrnRules | ||||
| import com.ffii.fpsms.modules.master.entity.ShopRepository | import com.ffii.fpsms.modules.master.entity.ShopRepository | ||||
| @@ -518,8 +518,8 @@ return result | |||||
| return result | return result | ||||
| } | } | ||||
| /** | /** | ||||
| * Helper function to build SQL clause for comma-separated values. | |||||
| * Supports multiple values like "val1, val2, val3" and generates OR conditions with LIKE. | |||||
| * Helper function to build SQL clause for comma/space-separated values. | |||||
| * Supports Excel paste like "FA0123 FA2332" or "FA0123,FA2332, FA23238" (LIKE OR). | |||||
| */ | */ | ||||
| private fun buildMultiValueLikeClause( | private fun buildMultiValueLikeClause( | ||||
| paramValue: String?, | paramValue: String?, | ||||
| @@ -529,7 +529,7 @@ return result | |||||
| ): String { | ): String { | ||||
| if (paramValue.isNullOrBlank()) return "" | if (paramValue.isNullOrBlank()) return "" | ||||
| val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } | |||||
| val values = ReportMultiValueTokens.split(paramValue) | |||||
| if (values.isEmpty()) return "" | if (values.isEmpty()) return "" | ||||
| val conditions = values.mapIndexed { index, value -> | val conditions = values.mapIndexed { index, value -> | ||||
| @@ -541,6 +541,33 @@ return result | |||||
| return "AND (${conditions.joinToString(" OR ")})" | return "AND (${conditions.joinToString(" OR ")})" | ||||
| } | } | ||||
| /** | |||||
| * Item-code filter that prefers indexed equality (`IN`) for pasted/full codes, | |||||
| * and prefix LIKE only for short tokens such as "FA". | |||||
| */ | |||||
| private fun buildItemCodeFilterClause( | |||||
| paramValue: String?, | |||||
| columnName: String, | |||||
| paramPrefix: String, | |||||
| args: MutableMap<String, Any>, | |||||
| ): String { | |||||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue) | |||||
| if (exact.isEmpty() && fuzzy.isEmpty()) return "" | |||||
| val parts = mutableListOf<String>() | |||||
| exact.forEachIndexed { index, value -> | |||||
| val paramName = "${paramPrefix}_eq_$index" | |||||
| args[paramName] = value | |||||
| parts.add("$columnName = :$paramName") | |||||
| } | |||||
| fuzzy.forEachIndexed { index, value -> | |||||
| val paramName = "${paramPrefix}_like_$index" | |||||
| args[paramName] = if ('%' in value || '_' in value) value else "$value%" | |||||
| parts.add("$columnName LIKE :$paramName") | |||||
| } | |||||
| return "AND (${parts.joinToString(" OR ")})" | |||||
| } | |||||
| /** | /** | ||||
| * Helper function to build SQL clause for comma-separated values with exact match. | * Helper function to build SQL clause for comma-separated values with exact match. | ||||
| * Supports multiple values like "val1, val2, val3" and generates OR conditions with =. | * Supports multiple values like "val1, val2, val3" and generates OR conditions with =. | ||||
| @@ -678,7 +705,7 @@ return result | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) | |||||
| val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args) | |||||
| val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateStart.replace("/", "-") | val formattedDate = lastInDateStart.replace("/", "-") | ||||
| @@ -1554,9 +1581,9 @@ return result | |||||
| args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay() | args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay() | ||||
| val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) | val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) | ||||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) | |||||
| val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args) | |||||
| val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args) | |||||
| val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args) | |||||
| val itemCodeSqlDay = buildItemCodeFilterClause(itemCode, "d.itemCode", "itemCodeDay", args) | |||||
| val itemCodeSqlSl = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCodeSl", args) | |||||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | ||||
| args["storeLocation"] = "%$storeLocation%" | args["storeLocation"] = "%$storeLocation%" | ||||
| """ | """ | ||||
| @@ -1842,6 +1869,7 @@ return result | |||||
| ) | ) | ||||
| return rows | return rows | ||||
| } | } | ||||
| /** | /** | ||||
| * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. | * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. | ||||
| */ | */ | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import java.time.LocalDate | |||||
| @Service | @Service | ||||
| class SemiFGProductionAnalysisReportService( | class SemiFGProductionAnalysisReportService( | ||||
| @@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService( | |||||
| return jdbcDao.queryForList(sql, args) | 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. | * Gets list of item codes (bom.code) with names based on stockCategory filter. | ||||
| * Supports multiple categories separated by comma (e.g., "FG,WIP"). | * Supports multiple categories separated by comma (e.g., "FG,WIP"). | ||||
| @@ -99,7 +99,7 @@ class StockItemConsumptionTrendReportService( | |||||
| // itemCode now contains prefixes (first 2 letters), so we use LIKE to match codes starting with those prefixes | // itemCode now contains prefixes (first 2 letters), so we use LIKE to match codes starting with those prefixes | ||||
| val itemCodeSql = if (!itemCode.isNullOrBlank()) { | val itemCodeSql = if (!itemCode.isNullOrBlank()) { | ||||
| val prefixes = itemCode.split(",").map { it.trim() }.filter { it.isNotBlank() } | |||||
| val prefixes = ReportMultiValueTokens.split(itemCode) | |||||
| if (prefixes.isNotEmpty()) { | if (prefixes.isNotEmpty()) { | ||||
| val conditions = prefixes.mapIndexed { index, prefix -> | val conditions = prefixes.mapIndexed { index, prefix -> | ||||
| val paramName = "itemCode_$index" | val paramName = "itemCode_$index" | ||||
| @@ -0,0 +1,258 @@ | |||||
| package com.ffii.fpsms.modules.report.service | |||||
| import org.apache.pdfbox.pdmodel.PDDocument | |||||
| import org.apache.pdfbox.pdmodel.PDPage | |||||
| import org.apache.pdfbox.pdmodel.PDPageContentStream | |||||
| import org.apache.pdfbox.pdmodel.common.PDRectangle | |||||
| import org.apache.pdfbox.pdmodel.font.PDFont | |||||
| import org.apache.pdfbox.pdmodel.font.PDType0Font | |||||
| import org.apache.pdfbox.util.Matrix | |||||
| import java.io.ByteArrayInputStream | |||||
| import java.io.ByteArrayOutputStream | |||||
| /** | |||||
| * Draws 庫存明細報告 directly. Jasper measured each cell and kept whole item groups | |||||
| * together, which is what made large PDFs take tens of seconds. | |||||
| */ | |||||
| object StockLedgerPdf { | |||||
| private const val PAGE_W = 842f | |||||
| private const val PAGE_H = 595f | |||||
| private const val ROW_H = 13f | |||||
| private const val BOTTOM = 22f | |||||
| private const val BODY_SIZE = 8f | |||||
| private const val HEADER_SIZE = 8f | |||||
| private val fontBytes: ByteArray by lazy { | |||||
| StockLedgerPdf::class.java.getResourceAsStream("/fonts/msjh_0.ttf")?.readBytes() | |||||
| ?: error("Font not found: /fonts/msjh_0.ttf") | |||||
| } | |||||
| private data class Col(val x: Float, val w: Float, val title: String, val alignRight: Boolean = false) | |||||
| private val columns = listOf( | |||||
| Col(11f, 78f, "出入賬日期"), | |||||
| Col(91f, 58f, "類型"), | |||||
| Col(151f, 120f, "批號"), | |||||
| Col(273f, 72f, "到期日"), | |||||
| Col(347f, 58f, "纍計期初", alignRight = true), | |||||
| Col(407f, 48f, "入庫", alignRight = true), | |||||
| Col(457f, 48f, "出庫", alignRight = true), | |||||
| Col(507f, 62f, "纍計存量", alignRight = true), | |||||
| Col(571f, 140f, "參考編號"), | |||||
| Col(713f, 112f, "存貨位置"), | |||||
| ) | |||||
| fun write( | |||||
| rows: List<Map<String, Any>>, | |||||
| reportDate: String, | |||||
| reportTime: String, | |||||
| periodStart: String, | |||||
| periodEnd: String, | |||||
| ): ByteArray { | |||||
| PDDocument().use { doc -> | |||||
| val font = PDType0Font.load(doc, ByteArrayInputStream(fontBytes), true) | |||||
| val canvas = Canvas(doc, font, reportDate, reportTime, periodStart, periodEnd) | |||||
| if (rows.isEmpty()) { | |||||
| canvas.ensureRow() | |||||
| canvas.text(columns[0].x, canvas.y, "-", BODY_SIZE) | |||||
| } else { | |||||
| var groupKey: String? = null | |||||
| var count = 0 | |||||
| var totalIn = "" | |||||
| var totalOut = "" | |||||
| var totalBal = "" | |||||
| fun closeGroup() { | |||||
| if (count > 1) { | |||||
| canvas.ensureRow() | |||||
| canvas.text(250f, canvas.y, "貨品總量:", BODY_SIZE, 95f, alignRight = true) | |||||
| canvas.text(columns[5].x, canvas.y, totalIn, BODY_SIZE, columns[5].w, alignRight = true) | |||||
| canvas.text(columns[6].x, canvas.y, totalOut, BODY_SIZE, columns[6].w, alignRight = true) | |||||
| canvas.text(columns[7].x, canvas.y, totalBal, BODY_SIZE, columns[7].w, alignRight = true) | |||||
| canvas.advance() | |||||
| } else if (count == 1) { | |||||
| canvas.advance(4f) | |||||
| } | |||||
| } | |||||
| for (row in rows) { | |||||
| val nextItem = row["itemNo"]?.toString().orEmpty() | |||||
| val nextUom = row["unitOfMeasure"]?.toString().orEmpty() | |||||
| val nextKey = "$nextItem|$nextUom" | |||||
| if (nextKey != groupKey) { | |||||
| closeGroup() | |||||
| groupKey = nextKey | |||||
| count = 0 | |||||
| val name = row["itemName"]?.toString().orEmpty() | |||||
| val itemTitle = listOf(nextItem, name, nextUom).filter { it.isNotBlank() }.joinToString(" ") | |||||
| canvas.ensureRow() | |||||
| canvas.text(columns[0].x, canvas.y, itemTitle, BODY_SIZE + 1f) | |||||
| canvas.advance() | |||||
| } | |||||
| canvas.ensureRow() | |||||
| canvas.cell(0, row["trnDate"]) | |||||
| canvas.cell(1, row["trnRefNo"]) | |||||
| canvas.cell(2, row["lotNo"]) | |||||
| canvas.cell(3, row["expiryDate"]) | |||||
| canvas.cell(4, row["cumOpeningBal"]) | |||||
| canvas.cell(5, row["stockIn"]) | |||||
| canvas.cell(6, row["stockOut"]) | |||||
| canvas.cell(7, row["cumBalance"]) | |||||
| canvas.cell(8, row["orderRefNo"]) | |||||
| canvas.cell(9, row["storeLocation"]) | |||||
| canvas.advance() | |||||
| count++ | |||||
| totalIn = row["totalStockIn"]?.toString().orEmpty() | |||||
| totalOut = row["totalStockOut"]?.toString().orEmpty() | |||||
| totalBal = row["totalCumBalance"]?.toString().orEmpty() | |||||
| } | |||||
| closeGroup() | |||||
| } | |||||
| canvas.closePage() | |||||
| canvas.stampPageNumbers() | |||||
| val out = ByteArrayOutputStream() | |||||
| doc.save(out) | |||||
| return out.toByteArray() | |||||
| } | |||||
| } | |||||
| private class Canvas( | |||||
| private val doc: PDDocument, | |||||
| private val font: PDFont, | |||||
| private val reportDate: String, | |||||
| private val reportTime: String, | |||||
| private val periodStart: String, | |||||
| private val periodEnd: String, | |||||
| ) { | |||||
| private val pages = ArrayList<PDPage>() | |||||
| private var stream: PDPageContentStream? = null | |||||
| private var textOpen = false | |||||
| var y = 0f | |||||
| private set | |||||
| init { | |||||
| newPage() | |||||
| } | |||||
| fun ensureRow() { | |||||
| if (y < BOTTOM + ROW_H) newPage() | |||||
| } | |||||
| fun advance(dy: Float = ROW_H) { | |||||
| y -= dy | |||||
| } | |||||
| fun cell(index: Int, value: Any?) { | |||||
| val col = columns[index] | |||||
| text(col.x, y, value?.toString().orEmpty(), BODY_SIZE, col.w, col.alignRight) | |||||
| } | |||||
| fun text(x: Float, baseline: Float, raw: String, size: Float, width: Float = 700f, alignRight: Boolean = false) { | |||||
| val value = fit(raw, size, width - 2f) | |||||
| if (value.isEmpty()) return | |||||
| openText() | |||||
| val drawX = if (alignRight) { | |||||
| x + width - 2f - widthOf(value, size) | |||||
| } else { | |||||
| x | |||||
| } | |||||
| stream!!.setFont(font, size) | |||||
| stream!!.setTextMatrix(Matrix(1f, 0f, 0f, 1f, drawX, baseline)) | |||||
| stream!!.showText(value) | |||||
| } | |||||
| fun closePage() { | |||||
| endText() | |||||
| stream?.close() | |||||
| stream = null | |||||
| } | |||||
| fun stampPageNumbers() { | |||||
| val total = pages.size | |||||
| pages.forEachIndexed { index, page -> | |||||
| PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs -> | |||||
| val label = "頁數 ${index + 1} / $total" | |||||
| cs.beginText() | |||||
| cs.setFont(font, 9f) | |||||
| val labelX = PAGE_W - 18f - widthOf(label, 9f) | |||||
| cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, labelX, PAGE_H - 28f)) | |||||
| cs.showText(label) | |||||
| cs.endText() | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun newPage() { | |||||
| closePage() | |||||
| val page = PDPage(PDRectangle(PAGE_W, PAGE_H)) | |||||
| doc.addPage(page) | |||||
| pages.add(page) | |||||
| stream = PDPageContentStream(doc, page) | |||||
| y = PAGE_H - 36f | |||||
| openText() | |||||
| text(300f, y, "庫存明細報告", 14f) | |||||
| y -= 18f | |||||
| text(18f, y, "報告日期:$reportDate 報告時間:$reportTime", 9f) | |||||
| val period = if (periodStart.isNotBlank() || periodEnd.isNotBlank()) { | |||||
| "報告期間:${periodStart.ifBlank { "—" }} 至 ${periodEnd.ifBlank { "—" }}" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| if (period.isNotEmpty()) { | |||||
| text(420f, y, period, 9f) | |||||
| } | |||||
| y -= 16f | |||||
| endText() | |||||
| val lineY = y + 4f | |||||
| stream!!.moveTo(11f, lineY) | |||||
| stream!!.lineTo(PAGE_W - 16f, lineY) | |||||
| stream!!.stroke() | |||||
| y -= 12f | |||||
| openText() | |||||
| for (col in columns) { | |||||
| text(col.x, y, col.title, HEADER_SIZE, col.w, col.alignRight) | |||||
| } | |||||
| y -= 4f | |||||
| endText() | |||||
| val lineY2 = y | |||||
| stream!!.moveTo(11f, lineY2) | |||||
| stream!!.lineTo(PAGE_W - 16f, lineY2) | |||||
| stream!!.stroke() | |||||
| y -= 14f | |||||
| } | |||||
| private fun openText() { | |||||
| if (!textOpen) { | |||||
| stream!!.beginText() | |||||
| textOpen = true | |||||
| } | |||||
| } | |||||
| private fun endText() { | |||||
| if (textOpen) { | |||||
| stream!!.endText() | |||||
| textOpen = false | |||||
| } | |||||
| } | |||||
| private fun fit(raw: String, size: Float, maxWidth: Float): String { | |||||
| val cleaned = raw.replace('\n', ' ').replace('\r', ' ') | |||||
| if (cleaned.isEmpty() || fits(cleaned, size, maxWidth)) return cleaned | |||||
| var end = cleaned.length | |||||
| while (end > 0 && widthOf(cleaned.substring(0, end), size) > maxWidth) { | |||||
| end-- | |||||
| } | |||||
| return if (end == cleaned.length) cleaned else cleaned.substring(0, end) | |||||
| } | |||||
| private fun fits(text: String, size: Float, maxWidth: Float): Boolean { | |||||
| var units = 0f | |||||
| for (ch in text) { | |||||
| units += if (ch.code < 128) 0.56f else 1f | |||||
| if (units * size > maxWidth) return false | |||||
| } | |||||
| return true | |||||
| } | |||||
| private fun widthOf(text: String, size: Float): Float = | |||||
| font.getStringWidth(text) / 1000f * size | |||||
| } | |||||
| } | |||||
| @@ -48,20 +48,12 @@ open class StockLedgerReportService( | |||||
| val endDate = (reportPeriodEnd?.replace("/", "-") | val endDate = (reportPeriodEnd?.replace("/", "-") | ||||
| ?: LocalDate.now().format(dateFmt)) | ?: LocalDate.now().format(dateFmt)) | ||||
| val filterArgs = mutableMapOf<String, Any>() | |||||
| val itemPredicate = ledgerItemPredicate(itemCode, filterArgs) | |||||
| val startDate = if (!reportPeriodStart.isNullOrBlank()) { | val startDate = if (!reportPeriodStart.isNullOrBlank()) { | ||||
| reportPeriodStart.replace("/", "-") | reportPeriodStart.replace("/", "-") | ||||
| } else { | } else { | ||||
| val minDateRow = jdbcDao.queryForList( | |||||
| """ | |||||
| 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(), | |||||
| emptyMap<String, Any>(), | |||||
| ).firstOrNull() | |||||
| (minDateRow?.get("firstDate") as? String) ?: endDate | |||||
| earliestLedgerDate(itemPredicate, filterArgs) ?: endDate | |||||
| } | } | ||||
| val endExclusive = LocalDate.parse(endDate).plusDays(1).format(dateFmt) | val endExclusive = LocalDate.parse(endDate).plusDays(1).format(dateFmt) | ||||
| @@ -69,13 +61,8 @@ open class StockLedgerReportService( | |||||
| "reportPeriodStart" to LocalDate.parse(startDate).atStartOfDay(), | "reportPeriodStart" to LocalDate.parse(startDate).atStartOfDay(), | ||||
| "reportPeriodEndExclusive" to LocalDate.parse(endExclusive).atStartOfDay(), | "reportPeriodEndExclusive" to LocalDate.parse(endExclusive).atStartOfDay(), | ||||
| ) | ) | ||||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCode", args) | |||||
| args.putAll(filterArgs) | |||||
| val categorySql = buildCategoryItemFilter(stockCategory, args) | val categorySql = buildCategoryItemFilter(stockCategory, args) | ||||
| val forceLedgerIndex = if (itemCode.isNullOrBlank()) { | |||||
| "FORCE INDEX (idx_sl_deleted_date)" | |||||
| } else { | |||||
| "" | |||||
| } | |||||
| val moves = timed("1.ledger", { rows: List<LedgerMove> -> "rows=${rows.size}" }) { | val moves = timed("1.ledger", { rows: List<LedgerMove> -> "rows=${rows.size}" }) { | ||||
| jdbcDao.queryForList( | jdbcDao.queryForList( | ||||
| @@ -93,13 +80,13 @@ open class StockLedgerReportService( | |||||
| sl.inventoryLotLineId AS inventoryLotLineId, | sl.inventoryLotLineId AS inventoryLotLineId, | ||||
| sl.stockInLineId AS stockInLineId, | sl.stockInLineId AS stockInLineId, | ||||
| sl.stockOutLineId AS stockOutLineId | sl.stockOutLineId AS stockOutLineId | ||||
| FROM stock_ledger sl $forceLedgerIndex | |||||
| FROM stock_ledger sl ${itemPredicate.indexHint} | |||||
| WHERE sl.deleted = 0 | WHERE sl.deleted = 0 | ||||
| AND sl.itemCode IS NOT NULL | AND sl.itemCode IS NOT NULL | ||||
| AND sl.itemCode <> '' | AND sl.itemCode <> '' | ||||
| AND sl.date >= :reportPeriodStart | AND sl.date >= :reportPeriodStart | ||||
| AND sl.date < :reportPeriodEndExclusive | AND sl.date < :reportPeriodEndExclusive | ||||
| $itemCodeSql | |||||
| ${itemPredicate.sql} | |||||
| $categorySql | $categorySql | ||||
| """.trimIndent(), | """.trimIndent(), | ||||
| args, | args, | ||||
| @@ -546,21 +533,101 @@ open class StockLedgerReportService( | |||||
| return out | return out | ||||
| } | } | ||||
| private fun buildMultiValueLikeClause( | |||||
| /** | |||||
| * Exact item codes become itemId so MySQL can use | |||||
| * idx_ledger_item_deleted_date_id (itemId, deleted, date, id). | |||||
| * Short tokens stay as prefix LIKE. Lot / stock-UOM assembly is unchanged. | |||||
| */ | |||||
| 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)", | |||||
| ) | |||||
| } | |||||
| 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)", | |||||
| ) | |||||
| } | |||||
| } | |||||
| val tokenCount = exact.size + fuzzy.size | |||||
| return LedgerItemPredicate( | |||||
| sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args), | |||||
| indexHint = if (tokenCount == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "", | |||||
| ) | |||||
| } | |||||
| 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, | |||||
| ) | |||||
| /** Full codes use equality; short tokens use prefix LIKE. */ | |||||
| private fun buildItemCodeFilterClause( | |||||
| paramValue: String?, | paramValue: String?, | ||||
| columnName: String, | columnName: String, | ||||
| paramPrefix: String, | paramPrefix: String, | ||||
| args: MutableMap<String, Any>, | args: MutableMap<String, Any>, | ||||
| ): String { | ): String { | ||||
| if (paramValue.isNullOrBlank()) return "" | |||||
| val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } | |||||
| if (values.isEmpty()) return "" | |||||
| val conditions = values.mapIndexed { index, value -> | |||||
| val paramName = "${paramPrefix}_$index" | |||||
| args[paramName] = "%$value%" | |||||
| "$columnName LIKE :$paramName" | |||||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue) | |||||
| if (exact.isEmpty() && fuzzy.isEmpty()) return "" | |||||
| val parts = mutableListOf<String>() | |||||
| exact.forEachIndexed { index, value -> | |||||
| val paramName = "${paramPrefix}_eq_$index" | |||||
| args[paramName] = value | |||||
| parts.add("$columnName = :$paramName") | |||||
| } | |||||
| fuzzy.forEachIndexed { index, value -> | |||||
| val paramName = "${paramPrefix}_like_$index" | |||||
| args[paramName] = if ('%' in value || '_' in value) value else "$value%" | |||||
| parts.add("$columnName LIKE :$paramName") | |||||
| } | } | ||||
| return "AND (${conditions.joinToString(" OR ")})" | |||||
| return "AND (${parts.joinToString(" OR ")})" | |||||
| } | } | ||||
| private fun toDouble(v: Any?): Double { | private fun toDouble(v: Any?): Double { | ||||
| @@ -7,6 +7,8 @@ import org.springframework.http.HttpStatus | |||||
| import org.springframework.http.MediaType | import org.springframework.http.MediaType | ||||
| import org.springframework.http.ResponseEntity | import org.springframework.http.ResponseEntity | ||||
| import org.springframework.web.bind.annotation.GetMapping | import org.springframework.web.bind.annotation.GetMapping | ||||
| import org.springframework.web.bind.annotation.PostMapping | |||||
| import org.springframework.web.bind.annotation.RequestBody | |||||
| import org.springframework.web.bind.annotation.RequestMapping | import org.springframework.web.bind.annotation.RequestMapping | ||||
| import org.springframework.web.bind.annotation.RequestParam | import org.springframework.web.bind.annotation.RequestParam | ||||
| import org.springframework.web.bind.annotation.RestController | import org.springframework.web.bind.annotation.RestController | ||||
| @@ -45,6 +47,35 @@ class MaterialStockOutTraceabilityReportController( | |||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String?, | @RequestParam(required = false) lastOutDateEnd: String?, | ||||
| @RequestParam(required = false) handler: String?, | @RequestParam(required = false) handler: String?, | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildMaterialStockOutTraceabilityPdf( | |||||
| stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, handler, | |||||
| ) | |||||
| @PostMapping("/print-material-stock-out-traceability") | |||||
| fun generateMaterialStockOutTraceabilityReportPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildMaterialStockOutTraceabilityPdf( | |||||
| r.stockCategory, | |||||
| r.stockSubCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| r.handler, | |||||
| ) | |||||
| } | |||||
| private fun buildMaterialStockOutTraceabilityPdf( | |||||
| stockCategory: String?, | |||||
| stockSubCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| handler: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -94,6 +125,35 @@ class MaterialStockOutTraceabilityReportController( | |||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String?, | @RequestParam(required = false) lastOutDateEnd: String?, | ||||
| @RequestParam(required = false) handler: String?, | @RequestParam(required = false) handler: String?, | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildMaterialStockOutTraceabilityExcel( | |||||
| stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, handler, | |||||
| ) | |||||
| @PostMapping("/print-material-stock-out-traceability-excel") | |||||
| fun exportMaterialStockOutTraceabilityReportExcelPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildMaterialStockOutTraceabilityExcel( | |||||
| r.stockCategory, | |||||
| r.stockSubCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| r.handler, | |||||
| ) | |||||
| } | |||||
| private fun buildMaterialStockOutTraceabilityExcel( | |||||
| stockCategory: String?, | |||||
| stockSubCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| handler: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -23,6 +23,7 @@ import java.time.LocalTime | |||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.common.SecurityUtils | import com.ffii.fpsms.modules.common.SecurityUtils | ||||
| import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService | import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens | |||||
| import com.ffii.fpsms.modules.report.service.ReportService | import com.ffii.fpsms.modules.report.service.ReportService | ||||
| import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService | import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService | ||||
| @@ -128,6 +129,42 @@ class ReportController( | |||||
| @RequestParam(required = false) slot: String?, | @RequestParam(required = false) slot: String?, | ||||
| @RequestParam(required = false) lotNo: String?, | @RequestParam(required = false) lotNo: String?, | ||||
| @RequestParam(required = false) poPrefix: String?, | @RequestParam(required = false) poPrefix: String?, | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildStockInTraceabilityPdf( | |||||
| stockCategory, itemCode, lastInDateStart, lastInDateEnd, | |||||
| storeId, warehouse, area, slot, lotNo, poPrefix, | |||||
| ) | |||||
| @PostMapping("/print-stock-in-traceability") | |||||
| fun generateStockInTraceabilityReportPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildStockInTraceabilityPdf( | |||||
| r.stockCategory, | |||||
| r.combinedItemCode(), | |||||
| r.lastInDateStart, | |||||
| r.lastInDateEnd, | |||||
| r.storeId, | |||||
| r.warehouse, | |||||
| r.area, | |||||
| r.slot, | |||||
| r.lotNo, | |||||
| r.poPrefix, | |||||
| ) | |||||
| } | |||||
| private fun buildStockInTraceabilityPdf( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| lastInDateStart: String?, | |||||
| lastInDateEnd: String?, | |||||
| storeId: String?, | |||||
| warehouse: String?, | |||||
| area: String?, | |||||
| slot: String?, | |||||
| lotNo: String?, | |||||
| poPrefix: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -179,6 +216,42 @@ class ReportController( | |||||
| @RequestParam(required = false) slot: String?, | @RequestParam(required = false) slot: String?, | ||||
| @RequestParam(required = false) lotNo: String?, | @RequestParam(required = false) lotNo: String?, | ||||
| @RequestParam(required = false) poPrefix: String?, | @RequestParam(required = false) poPrefix: String?, | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildStockInTraceabilityExcel( | |||||
| stockCategory, itemCode, lastInDateStart, lastInDateEnd, | |||||
| storeId, warehouse, area, slot, lotNo, poPrefix, | |||||
| ) | |||||
| @PostMapping("/print-stock-in-traceability-excel") | |||||
| fun exportStockInTraceabilityReportExcelPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildStockInTraceabilityExcel( | |||||
| r.stockCategory, | |||||
| r.combinedItemCode(), | |||||
| r.lastInDateStart, | |||||
| r.lastInDateEnd, | |||||
| r.storeId, | |||||
| r.warehouse, | |||||
| r.area, | |||||
| r.slot, | |||||
| r.lotNo, | |||||
| r.poPrefix, | |||||
| ) | |||||
| } | |||||
| private fun buildStockInTraceabilityExcel( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| lastInDateStart: String?, | |||||
| lastInDateEnd: String?, | |||||
| storeId: String?, | |||||
| warehouse: String?, | |||||
| area: String?, | |||||
| slot: String?, | |||||
| lotNo: String?, | |||||
| poPrefix: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = reportService.searchStockInTraceabilityReport( | val dbData = reportService.searchStockInTraceabilityReport( | ||||
| stockCategory, | stockCategory, | ||||
| @@ -223,6 +296,31 @@ class ReportController( | |||||
| @RequestParam(required = false) year: String?, | @RequestParam(required = false) year: String?, | ||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String? | @RequestParam(required = false) lastOutDateEnd: String? | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildFgDeliveryPdf(stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) | |||||
| @PostMapping("/print-fg-delivery-report") | |||||
| fun generateFGDeliveryReportPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildFgDeliveryPdf( | |||||
| r.stockCategory, | |||||
| r.stockSubCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| ) | |||||
| } | |||||
| private fun buildFgDeliveryPdf( | |||||
| stockCategory: String?, | |||||
| stockSubCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -270,6 +368,31 @@ class ReportController( | |||||
| @RequestParam(required = false) year: String?, | @RequestParam(required = false) year: String?, | ||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String?, | @RequestParam(required = false) lastOutDateEnd: String?, | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildFgDeliveryExcel(stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) | |||||
| @PostMapping("/print-fg-delivery-report-excel") | |||||
| fun exportFGDeliveryReportExcelPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildFgDeliveryExcel( | |||||
| r.stockCategory, | |||||
| r.stockSubCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| ) | |||||
| } | |||||
| private fun buildFgDeliveryExcel( | |||||
| stockCategory: String?, | |||||
| stockSubCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = reportService.searchFGDeliveryReport( | val dbData = reportService.searchFGDeliveryReport( | ||||
| stockCategory, | stockCategory, | ||||
| @@ -309,10 +432,154 @@ class ReportController( | |||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String?, | @RequestParam(required = false) lastOutDateEnd: String?, | ||||
| @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, | @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, | ||||
| ): ResponseEntity<ByteArray> = | |||||
| generateStockBalancePdf( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| stockDate = stockDate, | |||||
| balanceFilterStart = balanceFilterStart, | |||||
| balanceFilterEnd = balanceFilterEnd, | |||||
| storeLocation = storeLocation, | |||||
| lastInDateStart = lastInDateStart, | |||||
| lastInDateEnd = lastInDateEnd, | |||||
| lastOutDateStart = lastOutDateStart, | |||||
| lastOutDateEnd = lastOutDateEnd, | |||||
| stockTakeRoundId = stockTakeRoundId, | |||||
| ) | |||||
| @PostMapping("/print-stock-balance") | |||||
| fun generateStockBalanceReportPost( | |||||
| @RequestBody(required = false) req: StockBalanceReportRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: StockBalanceReportRequest() | |||||
| return generateStockBalancePdf( | |||||
| stockCategory = r.stockCategory, | |||||
| itemCode = combinedItemCode(r.itemCode, r.itemCodePaste, r.itemCodes), | |||||
| stockDate = r.stockDate, | |||||
| balanceFilterStart = r.balanceFilterStart, | |||||
| balanceFilterEnd = r.balanceFilterEnd, | |||||
| storeLocation = r.storeLocation, | |||||
| lastInDateStart = r.lastInDateStart, | |||||
| lastInDateEnd = r.lastInDateEnd, | |||||
| lastOutDateStart = r.lastOutDateStart, | |||||
| lastOutDateEnd = r.lastOutDateEnd, | |||||
| stockTakeRoundId = r.stockTakeRoundId ?: 0L, | |||||
| ) | |||||
| } | |||||
| @GetMapping("/print-stock-balance-excel") | |||||
| fun exportStockBalanceReportExcel( | |||||
| @RequestParam(required = false) stockCategory: String?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) stockDate: String?, | |||||
| @RequestParam(required = false) balanceFilterStart: String?, | |||||
| @RequestParam(required = false) balanceFilterEnd: String?, | |||||
| @RequestParam(required = false) storeLocation: String?, | |||||
| @RequestParam(required = false) lastInDateStart: String?, | |||||
| @RequestParam(required = false) lastInDateEnd: String?, | |||||
| @RequestParam(required = false) lastOutDateStart: String?, | |||||
| @RequestParam(required = false) lastOutDateEnd: String?, | |||||
| @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, | |||||
| ): ResponseEntity<ByteArray> = | |||||
| generateStockBalanceExcel( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| stockDate = stockDate, | |||||
| balanceFilterStart = balanceFilterStart, | |||||
| balanceFilterEnd = balanceFilterEnd, | |||||
| storeLocation = storeLocation, | |||||
| lastInDateStart = lastInDateStart, | |||||
| lastInDateEnd = lastInDateEnd, | |||||
| lastOutDateStart = lastOutDateStart, | |||||
| lastOutDateEnd = lastOutDateEnd, | |||||
| stockTakeRoundId = stockTakeRoundId, | |||||
| ) | |||||
| @PostMapping("/print-stock-balance-excel") | |||||
| fun exportStockBalanceReportExcelPost( | |||||
| @RequestBody(required = false) req: StockBalanceReportRequest?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val r = req ?: StockBalanceReportRequest() | |||||
| return generateStockBalanceExcel( | |||||
| stockCategory = r.stockCategory, | |||||
| itemCode = combinedItemCode(r.itemCode, r.itemCodePaste, r.itemCodes), | |||||
| stockDate = r.stockDate, | |||||
| balanceFilterStart = r.balanceFilterStart, | |||||
| balanceFilterEnd = r.balanceFilterEnd, | |||||
| storeLocation = r.storeLocation, | |||||
| lastInDateStart = r.lastInDateStart, | |||||
| lastInDateEnd = r.lastInDateEnd, | |||||
| lastOutDateStart = r.lastOutDateStart, | |||||
| lastOutDateEnd = r.lastOutDateEnd, | |||||
| stockTakeRoundId = r.stockTakeRoundId ?: 0L, | |||||
| ) | |||||
| } | |||||
| private fun combinedItemCode( | |||||
| itemCode: String?, | |||||
| itemCodePaste: String?, | |||||
| itemCodes: List<String>?, | |||||
| ): String? = ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) | |||||
| private fun loadStockBalanceRows( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| stockDate: String?, | |||||
| balanceFilterStart: String?, | |||||
| balanceFilterEnd: String?, | |||||
| storeLocation: String?, | |||||
| lastInDateStart: String?, | |||||
| lastInDateEnd: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| stockTakeRoundId: Long, | |||||
| ): List<Map<String, Any>> = | |||||
| if (!stockDate.isNullOrBlank()) { | |||||
| reportService.searchStockBalanceReportByDate( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| stockDate = stockDate, | |||||
| balanceFilterStart = balanceFilterStart, | |||||
| balanceFilterEnd = balanceFilterEnd, | |||||
| storeLocation = storeLocation, | |||||
| ) | |||||
| } else { | |||||
| reportService.searchStockBalanceReport( | |||||
| stockCategory, | |||||
| itemCode, | |||||
| balanceFilterStart, | |||||
| balanceFilterEnd, | |||||
| storeLocation, | |||||
| lastInDateStart, | |||||
| lastInDateEnd, | |||||
| lastOutDateStart, | |||||
| lastOutDateEnd, | |||||
| stockTakeRoundId, | |||||
| ) | |||||
| } | |||||
| private fun generateStockBalancePdf( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| stockDate: String?, | |||||
| balanceFilterStart: String?, | |||||
| balanceFilterEnd: String?, | |||||
| storeLocation: String?, | |||||
| lastInDateStart: String?, | |||||
| lastInDateEnd: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| stockTakeRoundId: Long, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val tokenCount = ReportMultiValueTokens.split(itemCode).size | |||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| parameters["stockCategory"] = stockCategory ?: "All" | parameters["stockCategory"] = stockCategory ?: "All" | ||||
| parameters["itemNo"] = itemCode ?: "All" | |||||
| parameters["itemNo"] = | |||||
| when { | |||||
| itemCode.isNullOrBlank() -> "All" | |||||
| tokenCount > 30 -> "$tokenCount items" | |||||
| else -> itemCode | |||||
| } | |||||
| parameters["reportDate"] = (stockDate ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) | parameters["reportDate"] = (stockDate ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))) | ||||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | ||||
| parameters["storeLocation"] = storeLocation ?: "" | parameters["storeLocation"] = storeLocation ?: "" | ||||
| @@ -323,35 +590,24 @@ class ReportController( | |||||
| parameters["lastOutDateStart"] = lastOutDateStart ?: "" | parameters["lastOutDateStart"] = lastOutDateStart ?: "" | ||||
| parameters["lastOutDateEnd"] = lastOutDateEnd ?: "" | parameters["lastOutDateEnd"] = lastOutDateEnd ?: "" | ||||
| val dbData = | |||||
| if (!stockDate.isNullOrBlank()) { | |||||
| reportService.searchStockBalanceReportByDate( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| stockDate = stockDate, | |||||
| balanceFilterStart = balanceFilterStart, | |||||
| balanceFilterEnd = balanceFilterEnd, | |||||
| storeLocation = storeLocation, | |||||
| ) | |||||
| } else { | |||||
| reportService.searchStockBalanceReport( | |||||
| stockCategory, | |||||
| itemCode, | |||||
| balanceFilterStart, | |||||
| balanceFilterEnd, | |||||
| storeLocation, | |||||
| lastInDateStart, | |||||
| lastInDateEnd, | |||||
| lastOutDateStart, | |||||
| lastOutDateEnd, | |||||
| stockTakeRoundId | |||||
| ) | |||||
| } | |||||
| val dbData = loadStockBalanceRows( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| stockDate = stockDate, | |||||
| balanceFilterStart = balanceFilterStart, | |||||
| balanceFilterEnd = balanceFilterEnd, | |||||
| storeLocation = storeLocation, | |||||
| lastInDateStart = lastInDateStart, | |||||
| lastInDateEnd = lastInDateEnd, | |||||
| lastOutDateStart = lastOutDateStart, | |||||
| lastOutDateEnd = lastOutDateEnd, | |||||
| stockTakeRoundId = stockTakeRoundId, | |||||
| ) | |||||
| val pdfBytes = reportService.createPdfResponse( | val pdfBytes = reportService.createPdfResponse( | ||||
| "/jasper/StockBalanceReport.jrxml", | "/jasper/StockBalanceReport.jrxml", | ||||
| parameters, | parameters, | ||||
| dbData | |||||
| dbData, | |||||
| ) | ) | ||||
| val headers = HttpHeaders().apply { | val headers = HttpHeaders().apply { | ||||
| @@ -359,48 +615,35 @@ class ReportController( | |||||
| setContentDispositionFormData("attachment", "StockBalanceReport.pdf") | setContentDispositionFormData("attachment", "StockBalanceReport.pdf") | ||||
| set("filename", "StockBalanceReport.pdf") | set("filename", "StockBalanceReport.pdf") | ||||
| } | } | ||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | ||||
| } | } | ||||
| @GetMapping("/print-stock-balance-excel") | |||||
| fun exportStockBalanceReportExcel( | |||||
| @RequestParam(required = false) stockCategory: String?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) stockDate: String?, | |||||
| @RequestParam(required = false) balanceFilterStart: String?, | |||||
| @RequestParam(required = false) balanceFilterEnd: String?, | |||||
| @RequestParam(required = false) storeLocation: String?, | |||||
| @RequestParam(required = false) lastInDateStart: String?, | |||||
| @RequestParam(required = false) lastInDateEnd: String?, | |||||
| @RequestParam(required = false) lastOutDateStart: String?, | |||||
| @RequestParam(required = false) lastOutDateEnd: String?, | |||||
| @RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long, | |||||
| private fun generateStockBalanceExcel( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| stockDate: String?, | |||||
| balanceFilterStart: String?, | |||||
| balanceFilterEnd: String?, | |||||
| storeLocation: String?, | |||||
| lastInDateStart: String?, | |||||
| lastInDateEnd: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| stockTakeRoundId: Long, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = | |||||
| if (!stockDate.isNullOrBlank()) { | |||||
| reportService.searchStockBalanceReportByDate( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| stockDate = stockDate, | |||||
| balanceFilterStart = balanceFilterStart, | |||||
| balanceFilterEnd = balanceFilterEnd, | |||||
| storeLocation = storeLocation, | |||||
| ) | |||||
| } else { | |||||
| reportService.searchStockBalanceReport( | |||||
| stockCategory, | |||||
| itemCode, | |||||
| balanceFilterStart, | |||||
| balanceFilterEnd, | |||||
| storeLocation, | |||||
| lastInDateStart, | |||||
| lastInDateEnd, | |||||
| lastOutDateStart, | |||||
| lastOutDateEnd, | |||||
| stockTakeRoundId | |||||
| ) | |||||
| } | |||||
| val dbData = loadStockBalanceRows( | |||||
| stockCategory = stockCategory, | |||||
| itemCode = itemCode, | |||||
| stockDate = stockDate, | |||||
| balanceFilterStart = balanceFilterStart, | |||||
| balanceFilterEnd = balanceFilterEnd, | |||||
| storeLocation = storeLocation, | |||||
| lastInDateStart = lastInDateStart, | |||||
| lastInDateEnd = lastInDateEnd, | |||||
| lastOutDateStart = lastOutDateStart, | |||||
| lastOutDateEnd = lastOutDateEnd, | |||||
| stockTakeRoundId = stockTakeRoundId, | |||||
| ) | |||||
| val excelBytes = createStockBalanceExcel( | val excelBytes = createStockBalanceExcel( | ||||
| dbData = dbData, | dbData = dbData, | ||||
| @@ -898,6 +1141,7 @@ class ReportController( | |||||
| return out.toByteArray() | return out.toByteArray() | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 */ | |||||
| private fun createStockBalanceExcel( | private fun createStockBalanceExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportDate: String, | reportDate: String, | ||||
| @@ -974,6 +1218,9 @@ class ReportController( | |||||
| } | } | ||||
| val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14) | val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14) | ||||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | ||||
| // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. | |||||
| sheet.setColumnHidden(10, true) | |||||
| sheet.setColumnHidden(11, true) | |||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| workbook.use { it.write(out) } | workbook.use { it.write(out) } | ||||
| @@ -992,6 +1239,31 @@ class ReportController( | |||||
| @RequestParam(required = false) supplier: String?, | @RequestParam(required = false) supplier: String?, | ||||
| @RequestParam(required = false) poCode: String?, | @RequestParam(required = false) poCode: String?, | ||||
| @RequestParam(required = false) grnCode: String?, | @RequestParam(required = false) grnCode: String?, | ||||
| ): Map<String, Any> = | |||||
| buildGrnReport(receiptDateStart, receiptDateEnd, itemCode, supplier, poCode, grnCode) | |||||
| @PostMapping("/grn-report") | |||||
| fun getGrnReportPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): Map<String, Any> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildGrnReport( | |||||
| r.receiptDateStart, | |||||
| r.receiptDateEnd, | |||||
| r.combinedItemCode(), | |||||
| r.supplier, | |||||
| r.poCode, | |||||
| r.grnCode, | |||||
| ) | |||||
| } | |||||
| private fun buildGrnReport( | |||||
| receiptDateStart: String?, | |||||
| receiptDateEnd: String?, | |||||
| itemCode: String?, | |||||
| supplier: String?, | |||||
| poCode: String?, | |||||
| grnCode: String?, | |||||
| ): Map<String, Any> { | ): Map<String, Any> { | ||||
| val rows = reportService.searchGrnReport( | val rows = reportService.searchGrnReport( | ||||
| receiptDateStart, | receiptDateStart, | ||||
| @@ -1052,6 +1324,19 @@ class ReportController( | |||||
| syncStatus = syncStatus, | syncStatus = syncStatus, | ||||
| ) | ) | ||||
| @PostMapping("/bom-shop-sync-history") | |||||
| fun getBomShopSyncHistoryReportPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): Map<String, Any> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return m18BomShopSyncReportService.searchBomShopSyncHistoryReport( | |||||
| syncDateStart = r.syncDateStart, | |||||
| syncDateEnd = r.syncDateEnd, | |||||
| finishedItemCode = r.combinedFinishedItemCode(), | |||||
| syncStatus = r.syncStatus, | |||||
| ) | |||||
| } | |||||
| /** | /** | ||||
| * Shop Orders Replenishment Records (店鋪訂單補貨記錄). | * Shop Orders Replenishment Records (店鋪訂單補貨記錄). | ||||
| * JSON for Excel export: completed replenishments with linked ticket only. | * JSON for Excel export: completed replenishments with linked ticket only. | ||||
| @@ -1130,4 +1415,20 @@ class ReportController( | |||||
| return mapOf("rows" to rows) | return mapOf("rows" to rows) | ||||
| } | } | ||||
| } | |||||
| } | |||||
| data class StockBalanceReportRequest( | |||||
| val stockDate: String? = null, | |||||
| val itemCode: String? = null, | |||||
| val itemCodePaste: String? = null, | |||||
| val itemCodes: List<String>? = null, | |||||
| val stockCategory: String? = null, | |||||
| val balanceFilterStart: String? = null, | |||||
| val balanceFilterEnd: String? = null, | |||||
| val storeLocation: String? = null, | |||||
| val lastInDateStart: String? = null, | |||||
| val lastInDateEnd: String? = null, | |||||
| val lastOutDateStart: String? = null, | |||||
| val lastOutDateEnd: String? = null, | |||||
| val stockTakeRoundId: Long? = 0L, | |||||
| ) | |||||
| @@ -0,0 +1,39 @@ | |||||
| package com.ffii.fpsms.modules.report.web | |||||
| import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens | |||||
| /** Shared POST body for report filters that accept pasted item codes. */ | |||||
| data class ReportItemFilterRequest( | |||||
| val itemCode: String? = null, | |||||
| val itemCodePaste: String? = null, | |||||
| val itemCodes: List<String>? = null, | |||||
| val finishedItemCode: String? = null, | |||||
| val stockCategory: String? = null, | |||||
| val stockSubCategory: String? = null, | |||||
| val year: String? = null, | |||||
| val lastInDateStart: String? = null, | |||||
| val lastInDateEnd: String? = null, | |||||
| val lastOutDateStart: String? = null, | |||||
| val lastOutDateEnd: String? = null, | |||||
| val receiptDateStart: String? = null, | |||||
| val receiptDateEnd: String? = null, | |||||
| val syncDateStart: String? = null, | |||||
| val syncDateEnd: String? = null, | |||||
| val syncStatus: String? = null, | |||||
| val handler: String? = null, | |||||
| val supplier: String? = null, | |||||
| val poCode: String? = null, | |||||
| val grnCode: String? = null, | |||||
| val storeId: String? = null, | |||||
| val warehouse: String? = null, | |||||
| val area: String? = null, | |||||
| val slot: String? = null, | |||||
| val lotNo: String? = null, | |||||
| val poPrefix: String? = null, | |||||
| ) { | |||||
| fun combinedItemCode(): String? = | |||||
| ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) | |||||
| fun combinedFinishedItemCode(): String? = | |||||
| ReportMultiValueTokens.combine(finishedItemCode, itemCodePaste, itemCodes) | |||||
| } | |||||
| @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import java.time.format.DateTimeFormatter | 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.SemiFGProductionAnalysisReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportService | import com.ffii.fpsms.modules.report.service.ReportService | ||||
| import org.apache.poi.ss.usermodel.BorderStyle | import org.apache.poi.ss.usermodel.BorderStyle | ||||
| @@ -46,6 +47,33 @@ class SemiFGProductionAnalysisReportController( | |||||
| @RequestParam(required = false) year: String?, | @RequestParam(required = false) year: String?, | ||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String? | @RequestParam(required = false) lastOutDateEnd: String? | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildSemiFgProductionAnalysisPdf( | |||||
| stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, | |||||
| ) | |||||
| @PostMapping("/print-semi-fg-production-analysis") | |||||
| fun generateSemiFGProductionAnalysisReportPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildSemiFgProductionAnalysisPdf( | |||||
| r.stockCategory, | |||||
| r.stockSubCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| ) | |||||
| } | |||||
| private fun buildSemiFgProductionAnalysisPdf( | |||||
| stockCategory: String?, | |||||
| stockSubCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -94,6 +122,33 @@ class SemiFGProductionAnalysisReportController( | |||||
| @RequestParam(required = false) year: String?, | @RequestParam(required = false) year: String?, | ||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String? | @RequestParam(required = false) lastOutDateEnd: String? | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildSemiFgProductionAnalysisExcel( | |||||
| stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, | |||||
| ) | |||||
| @PostMapping("/print-semi-fg-production-analysis-excel") | |||||
| fun exportSemiFGProductionAnalysisReportExcelPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildSemiFgProductionAnalysisExcel( | |||||
| r.stockCategory, | |||||
| r.stockSubCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| ) | |||||
| } | |||||
| private fun buildSemiFgProductionAnalysisExcel( | |||||
| stockCategory: String?, | |||||
| stockSubCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport( | val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport( | ||||
| stockCategory, | stockCategory, | ||||
| @@ -119,6 +174,243 @@ class SemiFGProductionAnalysisReportController( | |||||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | 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( | private fun createSemiFGProductionAnalysisExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportTitle: String, | reportTitle: String, | ||||
| @@ -463,4 +755,15 @@ class SemiFGProductionAnalysisReportController( | |||||
| val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) | val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) | ||||
| return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) | 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 | |||||
| } | |||||
| } | } | ||||
| @@ -45,6 +45,29 @@ class StockItemConsumptionTrendReportController( | |||||
| @RequestParam(required = false) year: String?, | @RequestParam(required = false) year: String?, | ||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String? | @RequestParam(required = false) lastOutDateEnd: String? | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildStockItemConsumptionTrendPdf(stockCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) | |||||
| @PostMapping("/print-stock-item-consumption-trend") | |||||
| fun generateStockItemConsumptionTrendReportPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildStockItemConsumptionTrendPdf( | |||||
| r.stockCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| ) | |||||
| } | |||||
| private fun buildStockItemConsumptionTrendPdf( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| try { | try { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -98,6 +121,29 @@ class StockItemConsumptionTrendReportController( | |||||
| @RequestParam(required = false) year: String?, | @RequestParam(required = false) year: String?, | ||||
| @RequestParam(required = false) lastOutDateStart: String?, | @RequestParam(required = false) lastOutDateStart: String?, | ||||
| @RequestParam(required = false) lastOutDateEnd: String? | @RequestParam(required = false) lastOutDateEnd: String? | ||||
| ): ResponseEntity<ByteArray> = | |||||
| buildStockItemConsumptionTrendExcel(stockCategory, itemCode, year, lastOutDateStart, lastOutDateEnd) | |||||
| @PostMapping("/print-stock-item-consumption-trend-excel") | |||||
| fun exportStockItemConsumptionTrendReportExcelPost( | |||||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: ReportItemFilterRequest() | |||||
| return buildStockItemConsumptionTrendExcel( | |||||
| r.stockCategory, | |||||
| r.combinedItemCode(), | |||||
| r.year, | |||||
| r.lastOutDateStart, | |||||
| r.lastOutDateEnd, | |||||
| ) | |||||
| } | |||||
| private fun buildStockItemConsumptionTrendExcel( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| year: String?, | |||||
| lastOutDateStart: String?, | |||||
| lastOutDateEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = stockItemConsumptionTrendReportService.searchStockItemConsumptionTrendReport( | val dbData = stockItemConsumptionTrendReportService.searchStockItemConsumptionTrendReport( | ||||
| stockCategory = stockCategory, | stockCategory = stockCategory, | ||||
| @@ -5,8 +5,9 @@ import org.springframework.http.* | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens | |||||
| import com.ffii.fpsms.modules.report.service.StockLedgerPdf | |||||
| import com.ffii.fpsms.modules.report.service.StockLedgerReportService | import com.ffii.fpsms.modules.report.service.StockLedgerReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportService | |||||
| import org.apache.poi.ss.usermodel.BorderStyle | import org.apache.poi.ss.usermodel.BorderStyle | ||||
| import org.apache.poi.ss.usermodel.CellStyle | import org.apache.poi.ss.usermodel.CellStyle | ||||
| import org.apache.poi.ss.usermodel.DataFormat | import org.apache.poi.ss.usermodel.DataFormat | ||||
| @@ -24,7 +25,6 @@ import java.io.ByteArrayOutputStream | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/report") | @RequestMapping("/report") | ||||
| class StockLedgerReportController( | class StockLedgerReportController( | ||||
| private val reportService: ReportService, | |||||
| private val stockLedgerReportService: StockLedgerReportService, | private val stockLedgerReportService: StockLedgerReportService, | ||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java) | private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java) | ||||
| @@ -50,23 +50,55 @@ class StockLedgerReportController( | |||||
| @RequestParam(required = false) storeLocation: String?, | @RequestParam(required = false) storeLocation: String?, | ||||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | ||||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | ||||
| ): ResponseEntity<ByteArray> = | |||||
| generateStockLedgerPdf(stockCategory, itemCode, storeLocation, reportPeriodStart, reportPeriodEnd) | |||||
| @PostMapping("/print-stock-ledger") | |||||
| fun generateStockLedgerReportPost( | |||||
| @RequestBody(required = false) req: StockLedgerReportRequest?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | |||||
| val r = req ?: StockLedgerReportRequest() | |||||
| return generateStockLedgerPdf( | |||||
| stockCategory = r.stockCategory, | |||||
| itemCode = ReportMultiValueTokens.combine(r.itemCode, r.itemCodePaste, r.itemCodes), | |||||
| storeLocation = r.storeLocation, | |||||
| reportPeriodStart = r.lastInDateStart, | |||||
| reportPeriodEnd = r.lastInDateEnd, | |||||
| ) | |||||
| } | |||||
| parameters["stockCategory"] = stockCategory ?: "All" | |||||
| parameters["stockSubCategory"] = stockCategory ?: "All" | |||||
| parameters["itemNo"] = itemCode ?: "All" | |||||
| parameters["year"] = LocalDate.now().year.toString() | |||||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ | |||||
| @GetMapping("/print-stock-ledger-excel") | |||||
| fun exportStockLedgerReportExcel( | |||||
| @RequestParam(required = false) stockCategory: String?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) storeLocation: String?, | |||||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | |||||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | |||||
| ): ResponseEntity<ByteArray> = | |||||
| generateStockLedgerExcel(stockCategory, itemCode, storeLocation, reportPeriodStart, reportPeriodEnd) | |||||
| parameters["storeLocation"] = storeLocation ?: "" | |||||
| parameters["balanceFilterStart"] = "" | |||||
| parameters["balanceFilterEnd"] = "" | |||||
| parameters["reportPeriodStart"] = reportPeriodStart ?: "" | |||||
| parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | |||||
| @PostMapping("/print-stock-ledger-excel") | |||||
| fun exportStockLedgerReportExcelPost( | |||||
| @RequestBody(required = false) req: StockLedgerReportRequest?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val r = req ?: StockLedgerReportRequest() | |||||
| return generateStockLedgerExcel( | |||||
| stockCategory = r.stockCategory, | |||||
| itemCode = ReportMultiValueTokens.combine(r.itemCode, r.itemCodePaste, r.itemCodes), | |||||
| storeLocation = r.storeLocation, | |||||
| reportPeriodStart = r.lastInDateStart, | |||||
| reportPeriodEnd = r.lastInDateEnd, | |||||
| ) | |||||
| } | |||||
| val tSearch = System.nanoTime() | |||||
| private fun generateStockLedgerPdf( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| storeLocation: String?, | |||||
| reportPeriodStart: String?, | |||||
| reportPeriodEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | |||||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | val dbData = stockLedgerReportService.searchStockLedgerReport( | ||||
| stockCategory = stockCategory, | stockCategory = stockCategory, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| @@ -74,19 +106,13 @@ class StockLedgerReportController( | |||||
| reportPeriodStart = reportPeriodStart, | reportPeriodStart = reportPeriodStart, | ||||
| reportPeriodEnd = reportPeriodEnd, | reportPeriodEnd = reportPeriodEnd, | ||||
| ) | ) | ||||
| val searchMs = (System.nanoTime() - tSearch) / 1_000_000 | |||||
| val tPdf = System.nanoTime() | |||||
| val pdfBytes = reportService.createPdfResponse( | |||||
| "/jasper/StockLedgarReport.jrxml", | |||||
| parameters, | |||||
| dbData, | |||||
| ) | |||||
| log.info( | |||||
| "stock-ledger pdf rows={} search={}ms pdf={}ms bytes={}", | |||||
| dbData.size, | |||||
| searchMs, | |||||
| (System.nanoTime() - tPdf) / 1_000_000, | |||||
| pdfBytes.size, | |||||
| val pdfBytes = StockLedgerPdf.write( | |||||
| rows = dbData, | |||||
| reportDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), | |||||
| reportTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), | |||||
| periodStart = reportPeriodStart?.trim().orEmpty(), | |||||
| periodEnd = reportPeriodEnd?.trim().orEmpty(), | |||||
| ) | ) | ||||
| val headers = HttpHeaders().apply { | val headers = HttpHeaders().apply { | ||||
| @@ -97,14 +123,12 @@ class StockLedgerReportController( | |||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */ | |||||
| @GetMapping("/print-stock-ledger-excel") | |||||
| fun exportStockLedgerReportExcel( | |||||
| @RequestParam(required = false) stockCategory: String?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) storeLocation: String?, | |||||
| @RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?, | |||||
| @RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?, | |||||
| private fun generateStockLedgerExcel( | |||||
| stockCategory: String?, | |||||
| itemCode: String?, | |||||
| storeLocation: String?, | |||||
| reportPeriodStart: String?, | |||||
| reportPeriodEnd: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val tSearch = System.nanoTime() | val tSearch = System.nanoTime() | ||||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | val dbData = stockLedgerReportService.searchStockLedgerReport( | ||||
| @@ -456,3 +480,13 @@ class StockLedgerReportController( | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| data class StockLedgerReportRequest( | |||||
| val lastInDateStart: String? = null, | |||||
| val lastInDateEnd: String? = null, | |||||
| val itemCode: String? = null, | |||||
| val itemCodePaste: String? = null, | |||||
| val itemCodes: List<String>? = null, | |||||
| val stockCategory: String? = null, | |||||
| val storeLocation: String? = null, | |||||
| ) | |||||
| @@ -191,7 +191,7 @@ class StockLotOnhandReportController( | |||||
| return v | return v | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.2 | 2026-09-28 */ | |||||
| private fun createExcel( | private fun createExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportDate: String, | reportDate: String, | ||||
| @@ -310,6 +310,9 @@ class StockLotOnhandReportController( | |||||
| sheet.createFreezePane(0, headerRowIndex + 1) | sheet.createFreezePane(0, headerRowIndex + 1) | ||||
| intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) | intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) | ||||
| .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | ||||
| // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. | |||||
| sheet.setColumnHidden(13, true) | |||||
| sheet.setColumnHidden(14, true) | |||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| workbook.write(out) | workbook.write(out) | ||||
| @@ -30,6 +30,18 @@ public class SettingsService extends AbstractIdEntityService<Settings, Long, Set | |||||
| return this.repository.findByName(name); | return this.repository.findByName(name); | ||||
| } | } | ||||
| /** | |||||
| * Reads {@code settings.value} with JDBC on every call (no JPA persistence-context reuse). | |||||
| * True only when the trimmed value equals {@link Settings#VALUE_BOOLEAN_TRUE} (ignore case). | |||||
| */ | |||||
| public boolean isBooleanTrueFromDb(String name) { | |||||
| String value = this.jdbcDao.queryForString( | |||||
| "SELECT value FROM settings WHERE name = :name LIMIT 1", | |||||
| java.util.Map.of("name", name)); | |||||
| return StringUtils.isNotBlank(value) | |||||
| && Settings.VALUE_BOOLEAN_TRUE.equalsIgnoreCase(value.trim()); | |||||
| } | |||||
| public List<Settings> findAllByCategory(String category) { | public List<Settings> findAllByCategory(String category) { | ||||
| return this.repository.findAllByCategory(category); | return this.repository.findAllByCategory(category); | ||||
| } | } | ||||
| @@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web; | |||||
| import java.util.List; | import java.util.List; | ||||
| import org.springframework.http.HttpStatus; | import org.springframework.http.HttpStatus; | ||||
| import org.springframework.security.access.AccessDeniedException; | |||||
| import org.springframework.security.core.Authentication; | |||||
| import org.springframework.security.core.context.SecurityContextHolder; | |||||
| import org.springframework.web.bind.annotation.GetMapping; | import org.springframework.web.bind.annotation.GetMapping; | ||||
| import org.springframework.web.bind.annotation.PatchMapping; | import org.springframework.web.bind.annotation.PatchMapping; | ||||
| import org.springframework.web.bind.annotation.PostMapping; | import org.springframework.web.bind.annotation.PostMapping; | ||||
| @@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; | |||||
| import com.ffii.core.exception.BadRequestException; | import com.ffii.core.exception.BadRequestException; | ||||
| import com.ffii.core.exception.NotFoundException; | import com.ffii.core.exception.NotFoundException; | ||||
| import com.ffii.fpsms.modules.common.SettingNames; | |||||
| import com.ffii.fpsms.modules.settings.entity.Settings; | import com.ffii.fpsms.modules.settings.entity.Settings; | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService; | import com.ffii.fpsms.modules.settings.service.SettingsService; | ||||
| @@ -53,6 +57,9 @@ public class SettingsController{ | |||||
| } | } | ||||
| private void applyUpdate(String name, UpdateReq body) { | private void applyUpdate(String name, UpdateReq body) { | ||||
| if (SettingNames.STOCK_TAKE_QTY_GAP_WARN_PERCENT.equals(name) && !isAdmin()) { | |||||
| throw new AccessDeniedException("ADMIN only"); | |||||
| } | |||||
| Settings entity = this.settingsService.findByName(name) | Settings entity = this.settingsService.findByName(name) | ||||
| .orElseThrow(NotFoundException::new); | .orElseThrow(NotFoundException::new); | ||||
| if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | ||||
| @@ -63,6 +70,12 @@ public class SettingsController{ | |||||
| this.settingsService.save(entity); | this.settingsService.save(entity); | ||||
| } | } | ||||
| private static boolean isAdmin() { | |||||
| Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | |||||
| if (auth == null) return false; | |||||
| return auth.getAuthorities().stream().anyMatch(a -> "ADMIN".equals(a.getAuthority())); | |||||
| } | |||||
| public static class UpdateReq { | public static class UpdateReq { | ||||
| @NotBlank | @NotBlank | ||||
| private String value; | private String value; | ||||
| @@ -437,4 +437,17 @@ WHERE ill.id = :id | |||||
| fun findAllByIdInAndDeletedFalseWithRefs( | fun findAllByIdInAndDeletedFalseWithRefs( | ||||
| @Param("ids") ids: Collection<Long> | @Param("ids") ids: Collection<Long> | ||||
| ): List<InventoryLotLine> | ): 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") | @Column(name = "lastSelect") | ||||
| open var lastSelect: Int? = null | 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") | @Column(name = "approverTime") | ||||
| open var approverTime: LocalDateTime? = null | open var approverTime: LocalDateTime? = null | ||||
| @Column(name = "date", nullable = false) | @Column(name = "date", nullable = false) | ||||
| @@ -67,12 +67,15 @@ interface StockInLineInfo { | |||||
| interface PutAwayLineForSil { | interface PutAwayLineForSil { | ||||
| val id: Long?; | val id: Long?; | ||||
| /** Stock qty (inventory lot line inQty) */ | |||||
| @get:Value("#{target.inQty}") | |||||
| /** | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 | |||||
| * Origin putaway qty from inventory_lot.stockInLine (lot line → lot → SIL). | |||||
| * Do not use inventory_lot_line.inQty: later ADJ/TKE is added on the same line. | |||||
| */ | |||||
| @get:Value("#{target.inventoryLot?.stockInLine?.acceptedQty}") | |||||
| val stockQty: BigDecimal?; | val stockQty: BigDecimal?; | ||||
| @get:Value("#{target.inQty " + | |||||
| "* ((target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD))" + | |||||
| "/ ((target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioD))}") | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */ | |||||
| @get:Value("#{target.inventoryLot?.stockInLine?.acceptedQtyM18 ?: target.inventoryLot?.stockInLine?.acceptedQty}") | |||||
| val qty: BigDecimal?; | val qty: BigDecimal?; | ||||
| @get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}") | @get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}") | ||||
| val warehouse: String?; | val warehouse: String?; | ||||
| @@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.service | |||||
| import com.ffii.core.response.RecordsRes | import com.ffii.core.response.RecordsRes | ||||
| import com.ffii.core.utils.PdfUtils | 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.ItemUomRespository | ||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | import com.ffii.fpsms.modules.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | ||||
| @@ -45,6 +44,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | |||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | ||||
| import com.ffii.fpsms.modules.master.service.PrinterService | 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.stock.web.model.PrintLabelForInventoryLotLineRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest | ||||
| import com.ffii.core.utils.ZebraPrinterUtil | import com.ffii.core.utils.ZebraPrinterUtil | ||||
| @@ -60,6 +60,7 @@ open class InventoryLotLineService( | |||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val printerService: PrinterService, | private val printerService: PrinterService, | ||||
| private val settingsService: SettingsService, | |||||
| @Lazy | @Lazy | ||||
| private val jobOrderService: JobOrderService | private val jobOrderService: JobOrderService | ||||
| ) { | ) { | ||||
| @@ -257,7 +258,8 @@ open class InventoryLotLineService( | |||||
| @Throws(IOException::class) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun exportStockInLineQrcode(request: LotLineToQrcode): Map<String, Any> { | 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) | val resource = ClassPathResource(QRCODE_PDF) | ||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| println("here") | println("here") | ||||
| @@ -297,13 +299,18 @@ open class InventoryLotLineService( | |||||
| field["acceptedQty"] = "%.2f".format(info.acceptedQty) | field["acceptedQty"] = "%.2f".format(info.acceptedQty) | ||||
| val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) | 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["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | ||||
| field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | ||||
| field["lotNo"] = info.lotNo!! | field["lotNo"] = info.lotNo!! | ||||
| field["supplier"] = info.supplier ?: "N/A" | field["supplier"] = info.supplier ?: "N/A" | ||||
| val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) | |||||
| val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) | |||||
| field["qrCode"] = image | field["qrCode"] = image | ||||
| fields.add(field) | 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.exception.BadRequestException | ||||
| import com.ffii.core.support.AbstractBaseEntityService | import com.ffii.core.support.AbstractBaseEntityService | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.core.utils.QrCodeUtil | |||||
| import com.ffii.fpsms.modules.common.CodeGenerator | import com.ffii.fpsms.modules.common.CodeGenerator | ||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | import com.ffii.fpsms.modules.master.entity.ItemsRepository | ||||
| import com.ffii.fpsms.modules.master.entity.QcItemRepository | import com.ffii.fpsms.modules.master.entity.QcItemRepository | ||||
| @@ -34,6 +33,7 @@ import com.ffii.fpsms.modules.master.entity.WarehouseRepository | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| //import com.ffii.fpsms.modules.master.service.BomOutputQtyService | //import com.ffii.fpsms.modules.master.service.BomOutputQtyService | ||||
| import com.ffii.fpsms.modules.master.service.PrinterService | 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.PurchaseOrder | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository | import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository | ||||
| @@ -99,6 +99,7 @@ open class StockInLineService( | |||||
| private val itemUomRepository: ItemUomRespository, | private val itemUomRepository: ItemUomRespository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val printerService: PrinterService, | private val printerService: PrinterService, | ||||
| private val settingsService: SettingsService, | |||||
| private val stockTakeLineRepository: StockTakeLineRepository, | private val stockTakeLineRepository: StockTakeLineRepository, | ||||
| private val inventoryLotLineService: InventoryLotLineService, | private val inventoryLotLineService: InventoryLotLineService, | ||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | private val deliveryOrderRepository: DeliveryOrderRepository, | ||||
| @@ -447,6 +448,8 @@ open class StockInLineService( | |||||
| } | } | ||||
| dnNo = request.dnNo | dnNo = request.dnNo | ||||
| receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | ||||
| // The item label prints this field under 收貨日期. | |||||
| productionDate = request.productionDate?.atStartOfDay() ?: receiptDate | |||||
| productLotNo = request.productLotNo | productLotNo = request.productLotNo | ||||
| this.type = "NOR" | this.type = "NOR" | ||||
| status = StockInLineStatus.PENDING.status | status = StockInLineStatus.PENDING.status | ||||
| @@ -655,13 +658,15 @@ open class StockInLineService( | |||||
| open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { | open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { | ||||
| val pols = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(po.id!!) | 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 | PurchaseOrderStatus.COMPLETED | ||||
| } | |||||
| else -> | |||||
| PurchaseOrderStatus.RECEIVING | |||||
| } | } | ||||
| po.apply { | po.apply { | ||||
| status = newStatus | status = newStatus | ||||
| @@ -1214,7 +1219,8 @@ open class StockInLineService( | |||||
| @Throws(IOException::class) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map<String, Any> { | 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) | val resource = ClassPathResource(QRCODE_PDF) | ||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| println("here") | println("here") | ||||
| @@ -1248,12 +1254,17 @@ open class StockInLineService( | |||||
| field["poCode"] = info.poCode ?: "N/A" | field["poCode"] = info.poCode ?: "N/A" | ||||
| field["itemType"] = info.itemType ?: "N/A" | field["itemType"] = info.itemType ?: "N/A" | ||||
| field["acceptedQty"] = "%.2f".format(acceptedQty) | 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["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | ||||
| field["lotNo"] = info.lotNo ?: "N/A" | field["lotNo"] = info.lotNo ?: "N/A" | ||||
| field["supplier"] = info.supplier ?: "N/A" | field["supplier"] = info.supplier ?: "N/A" | ||||
| val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) | |||||
| val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) | |||||
| field["qrCode"] = image | field["qrCode"] = image | ||||
| fields.add(field) | fields.add(field) | ||||
| } | } | ||||
| @@ -806,6 +806,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -1066,6 +1067,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -1176,6 +1178,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseArea = warehouse?.area, | warehouseArea = warehouse?.area, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| @@ -1198,6 +1201,7 @@ open class StockTakeRecordService( | |||||
| stockTakerName = rec.stockTakerName, | stockTakerName = rec.stockTakerName, | ||||
| stockTakeEndTime = rec.stockTakeEndTime, | stockTakeEndTime = rec.stockTakeEndTime, | ||||
| approverTime = rec.approverTime, | approverTime = rec.approverTime, | ||||
| pickerReviseCount = rec.pickerReviseCount ?: 0, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -1271,6 +1275,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseArea = warehouse?.area, | warehouseArea = warehouse?.area, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| @@ -1293,6 +1298,7 @@ open class StockTakeRecordService( | |||||
| stockTakerName = stockTakeRecord?.stockTakerName, | stockTakerName = stockTakeRecord?.stockTakerName, | ||||
| stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, | stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, | ||||
| approverTime = stockTakeRecord?.approverTime, | approverTime = stockTakeRecord?.approverTime, | ||||
| pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -1339,12 +1345,59 @@ open class StockTakeRecordService( | |||||
| .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) | .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) | ||||
| //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) | //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) | ||||
| if (request.reviseFirstQty && request.stockTakeRecordId == null) { | |||||
| throw IllegalArgumentException("Stock take record ID is required") | |||||
| } | |||||
| // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 | // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 | ||||
| val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { | val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { | ||||
| val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) | val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) | ||||
| ?: throw IllegalArgumentException("Stock take record not found: ${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 { | 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 -> { | existingRecord.pickerFirstStockTakeQty == null -> { | ||||
| val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty | val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty | ||||
| val varianceQty = availableQty - request.qty - request.badQty | val varianceQty = availableQty - request.qty - request.badQty | ||||
| @@ -1364,7 +1417,8 @@ open class StockTakeRecordService( | |||||
| } | } | ||||
| existingRecord | existingRecord | ||||
| } | } | ||||
| existingRecord.pickerSecondStockTakeQty == null -> { | |||||
| existingRecord.pickerSecondStockTakeQty == null && | |||||
| existingRecord.status.equals("notMatch", ignoreCase = true) -> { | |||||
| val varianceQty = availableQty - request.qty - request.badQty | val varianceQty = availableQty - request.qty - request.badQty | ||||
| existingRecord.apply { | existingRecord.apply { | ||||
| this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) | this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) | ||||
| @@ -2888,6 +2942,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -2988,6 +3043,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -78,6 +78,10 @@ data class InventoryLotDetailResponse( | |||||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | ||||
| val approverTime: LocalDateTime? = null, | val approverTime: LocalDateTime? = null, | ||||
| val lastSelect: Int? = null, | 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( | data class InventoryLotLineListRequest( | ||||
| val warehouseCode: String | val warehouseCode: String | ||||
| @@ -88,7 +92,9 @@ data class SaveStockTakeRecordRequest( | |||||
| val qty: BigDecimal, // QTY(第一次或第二次,后端判断) | val qty: BigDecimal, // QTY(第一次或第二次,后端判断) | ||||
| val badQty: BigDecimal, | val badQty: BigDecimal, | ||||
| // val stockTakerName: String, | // val stockTakerName: String, | ||||
| val remark: String? = null | |||||
| val remark: String? = null, | |||||
| /** true = 區域仍在盤點中時,覆寫已盤點的第一次數量;不寫入第二次盤點 */ | |||||
| val reviseFirstQty: Boolean = false, | |||||
| ) | ) | ||||
| /** | /** | ||||
| @@ -3,9 +3,10 @@ fpsms: | |||||
| monitoring: | monitoring: | ||||
| enabled: true | 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: | 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 | refresh-expiration-days: 7 | ||||
| # Required for stable JWT across restarts; set JWT_SECRET in the deployment environment (long random string). | # 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} | secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET} | ||||
| @@ -117,8 +117,9 @@ spring: | |||||
| dialect: | dialect: | ||||
| storage_engine: innodb | 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. | # 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: | jwt: | ||||
| expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session | expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session | ||||
| refresh-expiration-days: 30 # refresh token validity (days) | refresh-expiration-days: 30 # refresh token validity (days) | ||||
| @@ -0,0 +1,14 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fai:20260918_po_stockin_refresh_if_newer_setting | |||||
| INSERT INTO `settings` (`name`, `value`, `category`, `type`) | |||||
| SELECT v.name, v.value, v.category, v.type | |||||
| FROM ( | |||||
| SELECT 'M18.po.stockIn.refreshIfNewer.enabled' AS name, | |||||
| 'false' AS value, | |||||
| 'M18' AS category, | |||||
| 'boolean' AS type | |||||
| ) v | |||||
| WHERE NOT EXISTS ( | |||||
| SELECT 1 FROM `settings` s WHERE s.name = 'M18.po.stockIn.refreshIfNewer.enabled' | |||||
| ); | |||||
| @@ -0,0 +1,9 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fpsms:20260923_stock_take_qty_gap_warn_percent | |||||
| INSERT INTO `settings` (`name`, `value`, `category`, `type`) | |||||
| SELECT 'STOCK_TAKE.qtyGapWarnPercent', '50', 'STOCK_TAKE', 'integer' | |||||
| FROM DUAL | |||||
| WHERE NOT EXISTS ( | |||||
| SELECT 1 FROM `settings` WHERE `name` = 'STOCK_TAKE.qtyGapWarnPercent' | |||||
| ); | |||||
| @@ -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; | |||||
| @@ -1,5 +1,6 @@ | |||||
| <?xml version="1.0" encoding="UTF-8"?> | <?xml version="1.0" encoding="UTF-8"?> | ||||
| <!-- Created with Jaspersoft Studio version 6.17.0.final using JasperReports Library version 6.17.0-6d93193241dd8cc42629e188b94f9e0bc5722efd --> | <!-- Created with Jaspersoft Studio version 6.17.0.final using JasperReports Library version 6.17.0-6d93193241dd8cc42629e188b94f9e0bc5722efd --> | ||||
| <!-- FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 --> | |||||
| <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="StockBalanceReport" pageWidth="842" pageHeight="595" orientation="Landscape" columnWidth="802" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="b6cc79a8-6025-4490-938c-b90508da62d0"> | <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="StockBalanceReport" pageWidth="842" pageHeight="595" orientation="Landscape" columnWidth="802" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="b6cc79a8-6025-4490-938c-b90508da62d0"> | ||||
| <property name="com.jaspersoft.studio.data.defaultdataadapter" value="One Empty Record"/> | <property name="com.jaspersoft.studio.data.defaultdataadapter" value="One Empty Record"/> | ||||
| <parameter name="stockSubCategory" class="java.lang.String"> | <parameter name="stockSubCategory" class="java.lang.String"> | ||||
| @@ -77,6 +78,7 @@ | |||||
| <textField> | <textField> | ||||
| <reportElement x="669" y="0" width="51" height="20" uuid="7faf8dc0-de0e-49bb-abbc-8c6e4818e6c6"> | <reportElement x="669" y="0" width="51" height="20" uuid="7faf8dc0-de0e-49bb-abbc-8c6e4818e6c6"> | ||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | <property name="com.jaspersoft.studio.unit.height" value="px"/> | ||||
| <printWhenExpression><![CDATA[false]]></printWhenExpression> | |||||
| </reportElement> | </reportElement> | ||||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | <textElement textAlignment="Right" verticalAlignment="Middle"> | ||||
| <font fontName="微軟正黑體"/> | <font fontName="微軟正黑體"/> | ||||
| @@ -163,6 +165,7 @@ | |||||
| <textField> | <textField> | ||||
| <reportElement x="720" y="0" width="78" height="20" uuid="b58a8878-7093-4af2-b1a8-cf44de1fb4a6"> | <reportElement x="720" y="0" width="78" height="20" uuid="b58a8878-7093-4af2-b1a8-cf44de1fb4a6"> | ||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | <property name="com.jaspersoft.studio.unit.height" value="px"/> | ||||
| <printWhenExpression><![CDATA[false]]></printWhenExpression> | |||||
| </reportElement> | </reportElement> | ||||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | <textElement textAlignment="Right" verticalAlignment="Middle"> | ||||
| <font fontName="微軟正黑體"/> | <font fontName="微軟正黑體"/> | ||||
| @@ -405,6 +408,7 @@ | |||||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | <property name="com.jaspersoft.studio.unit.y" value="px"/> | ||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | <property name="com.jaspersoft.studio.unit.height" value="px"/> | ||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | <property name="com.jaspersoft.studio.unit.width" value="px"/> | ||||
| <printWhenExpression><![CDATA[false]]></printWhenExpression> | |||||
| </reportElement> | </reportElement> | ||||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | <textElement textAlignment="Right" verticalAlignment="Middle"> | ||||
| <font fontName="微軟正黑體" size="10"/> | <font fontName="微軟正黑體" size="10"/> | ||||
| @@ -416,6 +420,7 @@ | |||||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | <property name="com.jaspersoft.studio.unit.y" value="px"/> | ||||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | <property name="com.jaspersoft.studio.unit.height" value="px"/> | ||||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | <property name="com.jaspersoft.studio.unit.width" value="px"/> | ||||
| <printWhenExpression><![CDATA[false]]></printWhenExpression> | |||||
| </reportElement> | </reportElement> | ||||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | <textElement textAlignment="Right" verticalAlignment="Middle"> | ||||
| <font fontName="微軟正黑體" size="10"/> | <font fontName="微軟正黑體" size="10"/> | ||||
| @@ -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,59 @@ | |||||
| package com.ffii.fpsms.modules.report.service | |||||
| import org.junit.jupiter.api.Assertions.assertEquals | |||||
| import org.junit.jupiter.api.Test | |||||
| class ReportMultiValueTokensTest { | |||||
| @Test | |||||
| fun `splits space-separated item codes`() { | |||||
| assertEquals( | |||||
| listOf("FA0123", "FA2332"), | |||||
| ReportMultiValueTokens.split("FA0123 FA2332"), | |||||
| ) | |||||
| } | |||||
| @Test | |||||
| fun `splits commas with extra spaces`() { | |||||
| assertEquals( | |||||
| listOf("FA0123", "FA2332", "FA23238"), | |||||
| ReportMultiValueTokens.split("FA0123,FA2332, FA23238"), | |||||
| ) | |||||
| } | |||||
| @Test | |||||
| fun `splits mixed commas spaces and newlines`() { | |||||
| val raw = "FA0123,FA2332, FA23238\nFA9999 FA0001,, FA2332" | |||||
| assertEquals( | |||||
| listOf("FA0123", "FA2332", "FA23238", "FA9999", "FA0001"), | |||||
| ReportMultiValueTokens.split(raw), | |||||
| ) | |||||
| } | |||||
| @Test | |||||
| fun `returns empty for blank input`() { | |||||
| assertEquals(emptyList<String>(), ReportMultiValueTokens.split(null)) | |||||
| assertEquals(emptyList<String>(), ReportMultiValueTokens.split(" , ; ")) | |||||
| } | |||||
| @Test | |||||
| fun `full item codes use exact IN partition`() { | |||||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy("FA0123 FA2332, FA23238") | |||||
| assertEquals(listOf("FA0123", "FA2332", "FA23238"), exact) | |||||
| assertEquals(emptyList<String>(), fuzzy) | |||||
| } | |||||
| @Test | |||||
| fun `short prefix stays fuzzy`() { | |||||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy("FA FA0123") | |||||
| assertEquals(listOf("FA0123"), exact) | |||||
| assertEquals(listOf("FA"), fuzzy) | |||||
| } | |||||
| @Test | |||||
| fun `combine merges select paste and json list`() { | |||||
| assertEquals( | |||||
| "FA0123,FA2332,FA9999", | |||||
| ReportMultiValueTokens.combine("FA0123", "FA2332 FA0123", listOf("FA9999")), | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,40 @@ | |||||
| package com.ffii.fpsms.modules.report.service | |||||
| import org.junit.jupiter.api.Assertions.assertTrue | |||||
| import org.junit.jupiter.api.Test | |||||
| class StockLedgerPdfSpeedTest { | |||||
| @Test | |||||
| fun writesLargeLedgerWithoutJasperLayout() { | |||||
| val rows = ArrayList<Map<String, Any>>(40_000) | |||||
| repeat(40_000) { i -> | |||||
| val item = "PP%04d".format(i / 40) | |||||
| rows.add( | |||||
| linkedMapOf( | |||||
| "itemNo" to item, | |||||
| "itemName" to "測試貨品$item", | |||||
| "unitOfMeasure" to "包", | |||||
| "trnDate" to "2026-09-01", | |||||
| "trnRefNo" to "出入倉", | |||||
| "lotNo" to "LT-$i", | |||||
| "expiryDate" to "2026-12-31", | |||||
| "cumOpeningBal" to "1,200", | |||||
| "stockIn" to "10", | |||||
| "stockOut" to "0", | |||||
| "cumBalance" to "1,210", | |||||
| "orderRefNo" to "DO-20260901-${i % 1000}", | |||||
| "storeLocation" to "W201", | |||||
| "totalStockIn" to "400", | |||||
| "totalStockOut" to "0", | |||||
| "totalCumBalance" to "1,210", | |||||
| ) | |||||
| ) | |||||
| } | |||||
| val started = System.nanoTime() | |||||
| val pdf = StockLedgerPdf.write(rows, "2026-09-23", "23:40:00", "2026-09-01", "2026-09-07") | |||||
| val seconds = (System.nanoTime() - started) / 1_000_000_000.0 | |||||
| assertTrue(pdf.size > 1000, "pdf bytes=${pdf.size}") | |||||
| assertTrue(pdf[0] == '%'.code.toByte()) | |||||
| assertTrue(seconds < 15.0, "pdf took ${"%.2f".format(seconds)}s for 40000 rows") | |||||
| } | |||||
| } | |||||