| @@ -3,8 +3,11 @@ package com.ffii.core.utils | |||
| import org.apache.pdfbox.pdmodel.PDDocument | |||
| import org.apache.pdfbox.rendering.PDFRenderer | |||
| import java.awt.image.BufferedImage | |||
| import java.awt.image.DataBufferByte | |||
| import java.awt.image.MultiPixelPackedSampleModel | |||
| import java.io.ByteArrayOutputStream | |||
| import java.io.File | |||
| import java.net.InetSocketAddress | |||
| import java.net.Socket | |||
| import org.slf4j.LoggerFactory | |||
| @@ -12,6 +15,7 @@ class BrotherPrinterUtil { | |||
| companion object { | |||
| private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept | |||
| private const val CONNECT_TIMEOUT_MS = 5_000 | |||
| private const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 | |||
| private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) | |||
| @@ -25,11 +29,14 @@ class BrotherPrinterUtil { | |||
| log.info("Brother print: file=${pdfFile.name} size=${pdfFile.length()} ip=$printerIp port=$printerPort copies=$printQty") | |||
| PrinterLockManager.withLock(printerIp, printerPort) { | |||
| PDDocument.load(pdfFile).use { document -> | |||
| val renderer = PDFRenderer(document) | |||
| val totalPages = document.numberOfPages | |||
| Socket(printerIp, printerPort).use { socket -> | |||
| Socket().use { socket -> | |||
| socket.tcpNoDelay = true | |||
| socket.connect(InetSocketAddress(printerIp, printerPort), CONNECT_TIMEOUT_MS) | |||
| socket.soTimeout = SOCKET_TIMEOUT_MS | |||
| val os = socket.getOutputStream() | |||
| @@ -52,6 +59,7 @@ class BrotherPrinterUtil { | |||
| log.info("Brother print job sent successfully") | |||
| } | |||
| } | |||
| } | |||
| } | |||
| private fun getPjlHeader(qty: Int): ByteArray { | |||
| @@ -67,6 +75,8 @@ class BrotherPrinterUtil { | |||
| * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. | |||
| */ | |||
| private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { | |||
| convertPackedBinaryToPcl(image, dpi)?.let { return it } | |||
| val out = ByteArrayOutputStream() | |||
| val width = image.width | |||
| val height = image.height | |||
| @@ -75,21 +85,21 @@ class BrotherPrinterUtil { | |||
| out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset | |||
| // PCL: Set resolution and start raster | |||
| out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) | |||
| for (y in 0 until height) { | |||
| val rowBytes = (width + 7) / 8 | |||
| val data = ByteArray(rowBytes) | |||
| for (x in 0 until width) { | |||
| // Check if pixel is dark (threshold) | |||
| val color = image.getRGB(x, y) | |||
| val isBlack = (color and 0xFF) < 128 | |||
| val isBlack = (color and 0xFF) < 128 | |||
| if (isBlack) { | |||
| data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() | |||
| } | |||
| } | |||
| // PCL: Transfer Raster Data (Esc * b # W [data]) | |||
| out.write("\u001B*b${rowBytes}W".toByteArray()) | |||
| out.write(data) | |||
| @@ -99,5 +109,64 @@ class BrotherPrinterUtil { | |||
| out.write("\u001B*rB".toByteArray()) | |||
| return out.toByteArray() | |||
| } | |||
| /** | |||
| * TYPE_BYTE_BINARY from PDFBox: sample 0 is black, sample 1 is white, MSB first. | |||
| * PCL raster uses the opposite polarity (bit 1 = black). getRGB() per pixel is too slow | |||
| * for a multi-page A4 note; pack the row from the data buffer instead. | |||
| * Returns null when the raster layout does not match, so the caller keeps the slow path. | |||
| */ | |||
| private fun convertPackedBinaryToPcl(image: BufferedImage, dpi: Int): ByteArray? { | |||
| if (image.type != BufferedImage.TYPE_BYTE_BINARY) return null | |||
| val raster = image.raster | |||
| val sampleModel = raster.sampleModel as? MultiPixelPackedSampleModel ?: return null | |||
| if (sampleModel.pixelBitStride != 1 || sampleModel.dataBitOffset != 0) return null | |||
| val dataBuffer = raster.dataBuffer as? DataBufferByte ?: return null | |||
| if (dataBuffer.numBanks != 1) return null | |||
| val bank = dataBuffer.data | |||
| val bankOffset = dataBuffer.offsets[0] | |||
| val width = image.width | |||
| val height = image.height | |||
| if (width <= 0 || height <= 0) return null | |||
| val stride = sampleModel.scanlineStride | |||
| val rowBytes = (width + 7) / 8 | |||
| if (stride < rowBytes) return null | |||
| if (bankOffset < 0 || bankOffset + (height - 1) * stride + rowBytes > bank.size) return null | |||
| val check = minOf(8, width) | |||
| for (x in 0 until check) { | |||
| val srcBit = (bank[bankOffset].toInt() ushr (7 - x)) and 1 | |||
| val rgbBlack = (image.getRGB(x, 0) and 0xFF) < 128 | |||
| if ((srcBit == 0) != rgbBlack) return null | |||
| } | |||
| val out = ByteArrayOutputStream(height * (rowBytes + 12) + 32) | |||
| out.write("\u001BE".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) | |||
| val fullBytes = width / 8 | |||
| val remainder = width % 8 | |||
| val remainderMask = if (remainder == 0) 0 else (0xFF shl (8 - remainder)) and 0xFF | |||
| val row = ByteArray(rowBytes) | |||
| for (y in 0 until height) { | |||
| val rowStart = bankOffset + y * stride | |||
| var i = 0 | |||
| while (i < fullBytes) { | |||
| row[i] = (bank[rowStart + i].toInt().inv() and 0xFF).toByte() | |||
| i++ | |||
| } | |||
| if (remainder != 0) { | |||
| val src = bank[rowStart + fullBytes].toInt() and 0xFF | |||
| row[fullBytes] = (src.inv() and remainderMask).toByte() | |||
| } | |||
| out.write("\u001B*b${rowBytes}W".toByteArray(Charsets.US_ASCII)) | |||
| out.write(row) | |||
| } | |||
| out.write("\u001B*rB".toByteArray(Charsets.US_ASCII)) | |||
| return out.toByteArray() | |||
| } | |||
| } | |||
| } | |||
| @@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument | |||
| import org.slf4j.LoggerFactory | |||
| import java.io.File | |||
| import java.io.OutputStream | |||
| import java.net.InetSocketAddress | |||
| import java.net.Socket | |||
| /** | |||
| @@ -111,7 +112,11 @@ open class CanonPrinterUtil { | |||
| val copies = printQty ?: 1 | |||
| repeat(copies) { copyIndex -> | |||
| val tSend = System.nanoTime() | |||
| Socket(printerIp, printerPort).use { socket -> | |||
| PrinterLockManager.withLock(printerIp, printerPort) { | |||
| Socket().use { socket -> | |||
| socket.tcpNoDelay = true | |||
| socket.connect(InetSocketAddress(printerIp, printerPort), 5_000) | |||
| socket.soTimeout = 120_000 | |||
| val os: OutputStream = socket.getOutputStream() | |||
| // Send PJL commands | |||
| @@ -126,6 +131,7 @@ open class CanonPrinterUtil { | |||
| os.flush() | |||
| } | |||
| } | |||
| val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 | |||
| log.info( | |||
| "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", | |||
| @@ -71,6 +71,7 @@ import jakarta.persistence.EntityManager | |||
| import jakarta.persistence.PersistenceContext | |||
| import org.springframework.orm.ObjectOptimisticLockingFailureException | |||
| import com.ffii.core.utils.PdfUtils | |||
| import com.ffii.core.utils.PrinterLockManager | |||
| import com.ffii.core.utils.ZebraPrinterUtil | |||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| @@ -85,11 +86,11 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintReque | |||
| import java.util.concurrent.ConcurrentHashMap | |||
| import java.util.concurrent.ConcurrentLinkedQueue | |||
| import java.util.concurrent.CountDownLatch | |||
| import java.util.concurrent.Executors | |||
| import org.springframework.core.io.ClassPathResource | |||
| import net.sf.jasperreports.engine.JasperCompileManager | |||
| import net.sf.jasperreports.engine.JasperExportManager | |||
| import net.sf.jasperreports.engine.JasperPrint | |||
| import net.sf.jasperreports.engine.JasperReport | |||
| import java.io.File | |||
| import java.io.FileNotFoundException | |||
| import java.util.Locale | |||
| @@ -142,6 +143,20 @@ open class DoWorkbenchMainService( | |||
| companion object { | |||
| private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) | |||
| private const val DELIVERY_NOTE_JRXML = "DeliveryNote/DeliveryNotePDF.jrxml" | |||
| private const val CARTON_LABEL_JRXML = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" | |||
| private val deliveryNoteJasperTemplate: JasperReport by lazy { compileJasper(DELIVERY_NOTE_JRXML) } | |||
| private val cartonLabelJasperTemplate: JasperReport by lazy { compileJasper(CARTON_LABEL_JRXML) } | |||
| private fun compileJasper(path: String): JasperReport { | |||
| val resource = ClassPathResource(path) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Report file not found: $path") | |||
| } | |||
| return resource.inputStream.use { JasperCompileManager.compileReport(it) } | |||
| } | |||
| } | |||
| private sealed interface WorkbenchScanIllResolve { | |||
| @@ -2931,6 +2946,7 @@ return MessageResponse( | |||
| } | |||
| @Transactional | |||
| open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { | |||
| val started = System.nanoTime() | |||
| val printer = | |||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | |||
| @@ -2944,17 +2960,38 @@ return MessageResponse( | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||
| var handedToPrinter = false | |||
| try { | |||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||
| printer.ip?.let { ip -> | |||
| val port = printer.port ?: 9100 | |||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||
| driver.print(tempPdfFile, ip, port, printQty) | |||
| val ip = printer.ip?.trim().orEmpty() | |||
| if (ip.isEmpty()) { | |||
| tempPdfFile.delete() | |||
| throw IllegalStateException("Printer ${printer.id} has no IP address") | |||
| } | |||
| updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) | |||
| } finally { | |||
| //tempPdfFile.delete() | |||
| val port = printer.port ?: 9100 | |||
| val brand = printer.brand | |||
| val doPickOrderId = request.doPickOrderId | |||
| val cartonQty = request.numOfCarton | |||
| sendToPrinter( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| afterPrint = { saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) }, | |||
| ) { | |||
| A4PrintDriverRegistry.getDriver(brand).print(tempPdfFile, ip, port, printQty) | |||
| } | |||
| handedToPrinter = true | |||
| log.info( | |||
| "Workbench print-DN sent buildMs={} doPickOrderId={} printerId={} printerIp={}", | |||
| (System.nanoTime() - started) / 1_000_000, | |||
| request.doPickOrderId, | |||
| request.printerId, | |||
| printer.ip ?: "null", | |||
| ) | |||
| } catch (e: Exception) { | |||
| if (!handedToPrinter) tempPdfFile.delete() | |||
| throw e | |||
| } | |||
| } | |||
| @@ -3021,14 +3058,7 @@ return MessageResponse( | |||
| } | |||
| open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map<String, Any> { | |||
| val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml" | |||
| val resource = ClassPathResource(DELIVERYNOTE_PDF) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF") | |||
| } | |||
| val inputStream = resource.inputStream | |||
| val deliveryNote = JasperCompileManager.compileReport(inputStream) | |||
| val deliveryNote = deliveryNoteJasperTemplate | |||
| val fields = mutableListOf<MutableMap<String, Any>>() | |||
| val params = mutableMapOf<String, Any>() | |||
| @@ -3042,18 +3072,9 @@ return MessageResponse( | |||
| val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo) | |||
| val pickOrderLines = ctx.pickOrderIds.flatMap { pid -> | |||
| pickOrderLineRepository.findAllByPickOrderId(pid) | |||
| } | |||
| val pickOrderLines = loadPickOrderLinesForPrint(ctx.pickOrderIds) | |||
| val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id } | |||
| val stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>> = | |||
| if (allPickOrderLineIds.isNotEmpty()) { | |||
| allPickOrderLineIds.associateWith { polId -> | |||
| stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) | |||
| } | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| val stockOutLinesByPickOrderLineId = loadStockOutLinesForPrint(allPickOrderLineIds) | |||
| val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) | |||
| ?.replace("/", "") | |||
| @@ -3079,11 +3100,7 @@ return MessageResponse( | |||
| val allIllIds = stockOutLinesByPickOrderLineId.values | |||
| .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } | |||
| .distinct() | |||
| val illById: Map<Long, InventoryLotLine> = if (allIllIds.isNotEmpty()) { | |||
| inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! } | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| val illById = loadInventoryLotLinesWithWarehouse(allIllIds) | |||
| val deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds) | |||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | |||
| @@ -3157,6 +3174,7 @@ return MessageResponse( | |||
| } | |||
| @Transactional | |||
| open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { | |||
| val started = System.nanoTime() | |||
| val printer = | |||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | |||
| @@ -3170,26 +3188,48 @@ return MessageResponse( | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||
| var handedToPrinter = false | |||
| try { | |||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||
| printer.ip?.let { ip -> | |||
| printer.port?.let { port -> | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| printer.dpi, | |||
| ) | |||
| } | |||
| val ip = printer.ip?.trim().orEmpty() | |||
| val port = printer.port | |||
| if (ip.isEmpty() || port == null) { | |||
| tempPdfFile.delete() | |||
| throw IllegalStateException("Label printer ${printer.id} has no IP or port") | |||
| } | |||
| if (request.blankCartonNumber != true) { | |||
| updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) | |||
| val dpi = printer.dpi | |||
| val saveCarton = request.blankCartonNumber != true | |||
| val doPickOrderId = request.doPickOrderId | |||
| val cartonQty = request.numOfCarton | |||
| sendToPrinter( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| afterPrint = { | |||
| if (saveCarton) saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) | |||
| }, | |||
| ) { | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| dpi, | |||
| ) | |||
| } | |||
| } finally { | |||
| //tempPdfFile.delete() | |||
| handedToPrinter = true | |||
| log.info( | |||
| "Workbench print-DNLabels sent buildMs={} doPickOrderId={} cartons={} printerIp={}", | |||
| (System.nanoTime() - started) / 1_000_000, | |||
| request.doPickOrderId, | |||
| request.numOfCarton, | |||
| printer.ip ?: "null", | |||
| ) | |||
| } catch (e: Exception) { | |||
| if (!handedToPrinter) tempPdfFile.delete() | |||
| throw e | |||
| } | |||
| } | |||
| @@ -3210,15 +3250,8 @@ return MessageResponse( | |||
| } | |||
| private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map<String, Any> { | |||
| val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" | |||
| val resource = ClassPathResource(DNLABELS_PDF) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Label file not found: $DNLABELS_PDF") | |||
| } | |||
| val ctx = getWorkbenchPrintContext(request.doPickOrderId) | |||
| val inputStream = resource.inputStream | |||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||
| val cartonLabel = cartonLabelJasperTemplate | |||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | |||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | |||
| }.toMutableList() | |||
| @@ -3279,15 +3312,8 @@ return MessageResponse( | |||
| totalCartonsOnShipment = request.totalCartonsOnShipment, | |||
| ) | |||
| val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" | |||
| val resource = ClassPathResource(DNLABELS_PDF) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Label file not found: $DNLABELS_PDF") | |||
| } | |||
| val ctx = getWorkbenchPrintContext(request.doPickOrderId) | |||
| val inputStream = resource.inputStream | |||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||
| val cartonLabel = cartonLabelJasperTemplate | |||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | |||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | |||
| }.toMutableList() | |||
| @@ -3327,23 +3353,147 @@ return MessageResponse( | |||
| val pdf = exportDNLabelsReprintWorkbench(request) | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||
| var handedToPrinter = false | |||
| try { | |||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||
| printer.ip?.let { ip -> | |||
| printer.port?.let { port -> | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| printer.dpi, | |||
| ) | |||
| } | |||
| val ip = printer.ip?.trim().orEmpty() | |||
| val port = printer.port | |||
| if (ip.isEmpty() || port == null) { | |||
| tempPdfFile.delete() | |||
| throw IllegalStateException("Label printer ${printer.id} has no IP or port") | |||
| } | |||
| val dpi = printer.dpi | |||
| sendToPrinter(tempPdfFile, ip, port) { | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| dpi, | |||
| ) | |||
| } | |||
| handedToPrinter = true | |||
| } catch (e: Exception) { | |||
| if (!handedToPrinter) tempPdfFile.delete() | |||
| throw e | |||
| } | |||
| } | |||
| /** | |||
| * Send on this request so a printer timeout is returned to the tablet. | |||
| * Carton qty is saved only after the printer accepts the job. | |||
| */ | |||
| private fun sendToPrinter( | |||
| tempPdfFile: File, | |||
| ip: String, | |||
| port: Int, | |||
| afterPrint: (() -> Unit)? = null, | |||
| action: () -> Unit, | |||
| ) { | |||
| try { | |||
| PrinterLockManager.withLock(ip, port) { | |||
| action() | |||
| } | |||
| log.info("Workbench print sent printer={}:{} file={}", ip, port, tempPdfFile.name) | |||
| afterPrint?.invoke() | |||
| } catch (e: Exception) { | |||
| log.error("Workbench print failed printer={}:{} file={}", ip, port, tempPdfFile.name, e) | |||
| throw e | |||
| } finally { | |||
| // tempPdfFile.delete() | |||
| tempPdfFile.delete() | |||
| } | |||
| } | |||
| /** Same column as before (`cartonQty` only). Runs only after the printer accepts the job. */ | |||
| private fun saveCartonQtyAfterSuccessfulPrint(deliveryOrderPickOrderId: Long, cartonQty: Int) { | |||
| if (cartonQty <= 0) return | |||
| val tx = TransactionTemplate(transactionManager).apply { | |||
| propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW | |||
| } | |||
| tx.executeWithoutResult { | |||
| updateWorkbenchCartonQty(deliveryOrderPickOrderId, cartonQty) | |||
| } | |||
| } | |||
| private fun loadPickOrderLinesForPrint(pickOrderIds: List<Long>): List<com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine> { | |||
| if (pickOrderIds.isEmpty()) return emptyList() | |||
| return pickOrderIds.distinct().chunked(400).flatMap { chunk -> | |||
| pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk) | |||
| } | |||
| } | |||
| private fun loadStockOutLinesForPrint(pickOrderLineIds: List<Long>): Map<Long, List<StockOutLineInfo>> { | |||
| if (pickOrderLineIds.isEmpty()) return emptyMap() | |||
| val lines = mutableListOf<PrintStockOutLineInfo>() | |||
| pickOrderLineIds.distinct().chunked(400).forEach { chunk -> | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| sol.id AS id, | |||
| sol.pickOrderLineId AS pickOrderLineId, | |||
| sol.qty AS qty, | |||
| sol.inventoryLotLineId AS inventoryLotLineId, | |||
| sol.status AS status, | |||
| il.lotNo AS lotNo | |||
| FROM fpsmsdb.stock_out_line sol | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill | |||
| ON ill.id = sol.inventoryLotLineId | |||
| LEFT JOIN fpsmsdb.inventory_lot il | |||
| ON il.id = ill.inventoryLotId | |||
| WHERE sol.deleted = 0 | |||
| AND sol.pickOrderLineId IN (:ids) | |||
| """.trimIndent(), | |||
| mapOf("ids" to chunk), | |||
| ) | |||
| rows.forEach { row -> | |||
| val polId = (rowValue(row, "pickOrderLineId") as? Number)?.toLong() ?: return@forEach | |||
| val qtyRaw = rowValue(row, "qty") | |||
| lines.add( | |||
| PrintStockOutLineInfo( | |||
| id = (rowValue(row, "id") as? Number)?.toLong() ?: 0L, | |||
| pickOrderLineId = polId, | |||
| qty = when (qtyRaw) { | |||
| is BigDecimal -> qtyRaw | |||
| is Number -> BigDecimal(qtyRaw.toString()) | |||
| else -> BigDecimal.ZERO | |||
| }, | |||
| inventoryLotLineId = (rowValue(row, "inventoryLotLineId") as? Number)?.toLong(), | |||
| lotNo = rowValue(row, "lotNo")?.toString()?.takeIf { it.isNotBlank() }, | |||
| status = rowValue(row, "status")?.toString() ?: "", | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| return lines.groupBy { it.pickOrderLineId!! } | |||
| } | |||
| private fun loadInventoryLotLinesWithWarehouse(ids: Collection<Long>): Map<Long, InventoryLotLine> { | |||
| if (ids.isEmpty()) return emptyMap() | |||
| val loaded = ids.distinct().chunked(400).flatMap { chunk -> | |||
| inventoryLotLineRepository.findAllByIdInWithWarehouse(chunk) | |||
| } | |||
| return loaded.mapNotNull { line -> line.id?.let { id -> id to line } }.toMap() | |||
| } | |||
| private fun rowValue(row: Map<String, Any?>, key: String): Any? { | |||
| val matched = row.keys.find { it.equals(key, ignoreCase = true) } ?: return null | |||
| return row[matched] | |||
| } | |||
| private data class PrintStockOutLineInfo( | |||
| override val id: Long, | |||
| override val pickOrderLineId: Long?, | |||
| override val qty: BigDecimal, | |||
| override val inventoryLotLineId: Long?, | |||
| override val lotNo: String?, | |||
| override val status: String, | |||
| ) : StockOutLineInfo { | |||
| override val itemId: Long = 0L | |||
| override val itemName: String? = null | |||
| override val itemNo: String = "" | |||
| override val stockOutId: Long = 0L | |||
| override val pickTime: java.time.LocalDateTime? = null | |||
| } | |||
| } | |||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.stereotype.Repository | |||
| import org.springframework.data.jpa.repository.EntityGraph | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| @@ -33,6 +34,17 @@ fun findByPickOrderId(pickOrderId: Long): List<PickOrderLine> | |||
| ) | |||
| fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickOrderLine> | |||
| /** Delivery-note print: one query, associations initialized (avoids per-line lazy loads). */ | |||
| @EntityGraph(attributePaths = ["item", "pickOrder", "uom"]) | |||
| @Query( | |||
| """ | |||
| SELECT pol FROM PickOrderLine pol | |||
| WHERE pol.pickOrder.id IN :pickOrderIds | |||
| AND pol.deleted = false | |||
| """ | |||
| ) | |||
| fun findAllForPrintByPickOrderIdIn(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickOrderLine> | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| value = """ | |||
| @@ -27,8 +27,8 @@ open class StockLedgerReportService( | |||
| * - cumBalance = stock_ledger.balance(異動後纍計存量) | |||
| * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | |||
| * | |||
| * 只查 [start, end] 期間列,不掃起日以前全歷史。 | |||
| * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 | |||
| * 先用貨品/日期索引切出流水帳,再一對一接批次行。 | |||
| * 不依 inventoryLotId 展開該批次的全部儲位,否則一筆入庫會變成多列。 | |||
| * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 | |||
| */ | |||
| fun searchStockLedgerReport( | |||
| @@ -40,6 +40,7 @@ open class StockLedgerReportService( | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val itemPredicate = ledgerItemPredicate(itemCode, args) | |||
| val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | |||
| ?: LocalDate.now().format(dateFmt)) | |||
| @@ -47,17 +48,7 @@ open class StockLedgerReportService( | |||
| val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | |||
| reportPeriodStart.replace("/", "-") | |||
| } else { | |||
| val minDateSql = """ | |||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | |||
| FROM stock_ledger sl | |||
| WHERE sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| """.trimIndent() | |||
| val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() | |||
| (minDateRow?.get("firstDate") as? String) | |||
| ?: reportPeriodEnd | |||
| earliestLedgerDate(itemPredicate, args) ?: reportPeriodEnd | |||
| } | |||
| val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) | |||
| @@ -71,13 +62,6 @@ open class StockLedgerReportService( | |||
| args | |||
| ) | |||
| val itemCodeSql = buildItemCodeFilterClause( | |||
| itemCode, | |||
| "sl.itemCode", | |||
| "itemCode", | |||
| args | |||
| ) | |||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | |||
| args["storeLocation"] = "%$storeLocation%" | |||
| "AND wh.code LIKE :storeLocation" | |||
| @@ -85,62 +69,96 @@ open class StockLedgerReportService( | |||
| "" | |||
| } | |||
| // Wide reports are a date range over the whole ledger. Pin that index so MySQL | |||
| // does not start from another stock_ledger index and scan extra rows. | |||
| val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else "" | |||
| val uomItemSql = if (itemPredicate.itemIdParamNames.isEmpty()) { | |||
| "" | |||
| } else { | |||
| "AND iu.itemId IN (${itemPredicate.itemIdParamNames.joinToString(", ") { ":$it" }})" | |||
| } | |||
| // One item is already ordered by the (itemId, deleted, date, id) index. | |||
| val orderBy = if (itemPredicate.singleItem) { | |||
| "ledger.dt, ledger.slId" | |||
| } else { | |||
| "it.code, ledger.dt, ledger.slId" | |||
| } | |||
| // NO_MERGE keeps the date/item slice materialized before the lot joins. | |||
| val sql = """ | |||
| SELECT | |||
| sl.id AS slId, | |||
| sl.itemCode AS itemCode, | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, | |||
| SELECT /*+ NO_MERGE(ledger) */ | |||
| ledger.slId AS slId, | |||
| ledger.itemCode AS itemCode, | |||
| DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate, | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' | |||
| WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE' | |||
| WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ' | |||
| AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' | |||
| ELSE COALESCE(sl.type, '') | |||
| ELSE COALESCE(ledger.ledgerType, '') | |||
| END AS trnRefNoRaw, | |||
| COALESCE(sl.inQty, 0) AS inQty, | |||
| COALESCE(sl.outQty, 0) AS outQty, | |||
| COALESCE(sl.balance, 0) AS bal, | |||
| COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, | |||
| COALESCE(ledger.inQty, 0) AS inQty, | |||
| COALESCE(ledger.outQty, 0) AS outQty, | |||
| COALESCE(ledger.bal, 0) AS bal, | |||
| COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS cumOpeningBalRaw, | |||
| it.type AS stockSubCategory, | |||
| it.code AS itemNo, | |||
| it.name AS itemName, | |||
| uom.unitOfMeasure AS unitOfMeasure, | |||
| COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo, | |||
| COALESCE( | |||
| DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'), | |||
| DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'), | |||
| '' | |||
| ) AS expiryDate, | |||
| il.lotNo AS lotNo, | |||
| COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||
| wh.code AS storeLocation, | |||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | |||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo | |||
| FROM stock_ledger sl $ledgerIndex | |||
| FROM ( | |||
| SELECT | |||
| sl.id AS slId, | |||
| sl.itemCode AS itemCode, | |||
| sl.date AS dt, | |||
| sl.type AS ledgerType, | |||
| sl.inQty AS inQty, | |||
| sl.outQty AS outQty, | |||
| sl.balance AS bal, | |||
| sl.itemId AS itemId, | |||
| sl.stockInLineId AS stockInLineId, | |||
| sl.stockOutLineId AS stockOutLineId, | |||
| sl.inventoryLotLineId AS inventoryLotLineId | |||
| FROM stock_ledger sl ${itemPredicate.indexHint} | |||
| WHERE sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| AND sl.date >= :reportPeriodStart | |||
| AND sl.date < :reportPeriodEndExclusive | |||
| ${itemPredicate.sql} | |||
| ) ledger | |||
| LEFT JOIN stock_in_line sil | |||
| ON sl.stockInLineId = sil.id | |||
| ON ledger.stockInLineId = sil.id | |||
| AND sil.deleted = 0 | |||
| LEFT JOIN inventory_lot il_in | |||
| ON sil.inventoryLotId = il_in.id | |||
| AND il_in.deleted = 0 | |||
| LEFT JOIN stock_out_line sol | |||
| ON sl.stockOutLineId = sol.id | |||
| ON ledger.stockOutLineId = sol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_out | |||
| ON sol.inventoryLotLineId = ill_out.id | |||
| AND ill_out.deleted = 0 | |||
| LEFT JOIN inventory_lot il_out | |||
| ON ill_out.inventoryLotId = il_out.id | |||
| AND il_out.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_in_loc | |||
| ON ill_in_loc.inventoryLotId = il_in.id | |||
| AND ill_in_loc.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON ill.id = ledger.inventoryLotLineId | |||
| AND ill.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_sil | |||
| ON ill.id IS NULL | |||
| AND ill_sil.id = sil.inventoryLotLineId | |||
| AND ill_sil.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_sol | |||
| ON ill.id IS NULL | |||
| AND ill_sil.id IS NULL | |||
| AND ill_sol.id = sol.inventoryLotLineId | |||
| AND ill_sol.deleted = 0 | |||
| LEFT JOIN inventory_lot il | |||
| ON il.id = COALESCE( | |||
| ill.inventoryLotId, | |||
| ill_sil.inventoryLotId, | |||
| ill_sol.inventoryLotId, | |||
| sil.inventoryLotId | |||
| ) | |||
| AND il.deleted = 0 | |||
| LEFT JOIN warehouse wh | |||
| ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId) | |||
| ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId) | |||
| AND wh.deleted = 0 | |||
| LEFT JOIN items it | |||
| ON sl.itemId = it.id | |||
| ON ledger.itemId = it.id | |||
| AND it.deleted = 0 | |||
| LEFT JOIN ( | |||
| SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure | |||
| @@ -148,6 +166,7 @@ FROM stock_ledger sl $ledgerIndex | |||
| JOIN uom_conversion uc ON uc.id = iu.uomId | |||
| WHERE iu.stockUnit = 1 | |||
| AND iu.deleted = 0 | |||
| $uomItemSql | |||
| GROUP BY iu.itemId | |||
| ) uom ON uom.itemId = it.id | |||
| LEFT JOIN stock_out so | |||
| @@ -174,26 +193,97 @@ FROM stock_ledger sl $ledgerIndex | |||
| LEFT JOIN purchase_order po | |||
| ON sil.purchaseOrderId = po.id | |||
| AND po.deleted = 0 | |||
| WHERE | |||
| sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| AND sl.date >= :reportPeriodStart | |||
| AND sl.date < :reportPeriodEndExclusive | |||
| WHERE il.id IS NOT NULL | |||
| $stockCategorySql | |||
| $itemCodeSql | |||
| $storeLocationSql | |||
| AND COALESCE(il_in.id, il_out.id) IS NOT NULL | |||
| ORDER BY | |||
| itemNo, | |||
| trnDate, | |||
| slId, | |||
| lotNo | |||
| $orderBy | |||
| """.trimIndent() | |||
| return attachItemTotals(jdbcDao.queryForList(sql, args)) | |||
| } | |||
| /** | |||
| * Exact item codes become itemId so MySQL can use | |||
| * idx_ledger_item_deleted_date_id (itemId, deleted, date, id). | |||
| */ | |||
| private fun ledgerItemPredicate(itemCode: String?, args: MutableMap<String, Any>): LedgerItemPredicate { | |||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode) | |||
| if (exact.isEmpty() && fuzzy.isEmpty()) { | |||
| return LedgerItemPredicate( | |||
| sql = "", | |||
| indexHint = "FORCE INDEX (idx_sl_deleted_date)", | |||
| singleItem = false, | |||
| itemIdParamNames = emptyList(), | |||
| ) | |||
| } | |||
| if (fuzzy.isEmpty()) { | |||
| val ids = lookupItemIds(exact) | |||
| if (ids.isNotEmpty()) { | |||
| val names = ids.mapIndexed { index, id -> | |||
| val name = "itemId_$index" | |||
| args[name] = id | |||
| name | |||
| } | |||
| return LedgerItemPredicate( | |||
| sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})", | |||
| indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)", | |||
| singleItem = ids.size == 1, | |||
| itemIdParamNames = names, | |||
| ) | |||
| } | |||
| } | |||
| val oneExactCode = fuzzy.isEmpty() && exact.size == 1 | |||
| return LedgerItemPredicate( | |||
| sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args), | |||
| indexHint = if (exact.size + fuzzy.size == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "", | |||
| singleItem = oneExactCode, | |||
| itemIdParamNames = emptyList(), | |||
| ) | |||
| } | |||
| private fun lookupItemIds(codes: List<String>): List<Long> { | |||
| if (codes.isEmpty()) return emptyList() | |||
| val args = HashMap<String, Any>(codes.size) | |||
| val names = codes.mapIndexed { index, code -> | |||
| val name = "lookupCode_$index" | |||
| args[name] = code | |||
| ":$name" | |||
| } | |||
| val sql = """ | |||
| SELECT id AS itemId | |||
| FROM items | |||
| WHERE code IN (${names.joinToString(", ")}) | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args).mapNotNull { row -> | |||
| when (val id = row["itemId"]) { | |||
| is Number -> id.toLong() | |||
| else -> id?.toString()?.toLongOrNull() | |||
| } | |||
| }.distinct() | |||
| } | |||
| /** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */ | |||
| private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map<String, Any>): String? { | |||
| val sql = """ | |||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | |||
| FROM stock_ledger sl ${predicate.indexHint} | |||
| WHERE sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| ${predicate.sql} | |||
| """.trimIndent() | |||
| val row = jdbcDao.queryForList(sql, args).firstOrNull() | |||
| return row?.get("firstDate") as? String | |||
| } | |||
| private data class LedgerItemPredicate( | |||
| val sql: String, | |||
| val indexHint: String, | |||
| val singleItem: Boolean, | |||
| val itemIdParamNames: List<String>, | |||
| ) | |||
| /** | |||
| * Item totals match the old window: first opening, sum in/out, last balance per itemCode. | |||
| * Rows stay ordered by item, date, id. | |||
| @@ -326,4 +326,17 @@ WHERE ill.id = :id | |||
| fun findAllByIdInAndDeletedFalseWithRefs( | |||
| @Param("ids") ids: Collection<Long> | |||
| ): List<InventoryLotLine> | |||
| /** Delivery-note route column: warehouse code without a lazy load per lot line. */ | |||
| @EntityGraph( | |||
| type = EntityGraph.EntityGraphType.FETCH, | |||
| attributePaths = ["warehouse"] | |||
| ) | |||
| @Query( | |||
| """ | |||
| SELECT ill FROM InventoryLotLine ill | |||
| WHERE ill.id IN :ids | |||
| """ | |||
| ) | |||
| fun findAllByIdInWithWarehouse(@Param("ids") ids: Collection<Long>): List<InventoryLotLine> | |||
| } | |||
| @@ -446,6 +446,8 @@ open class StockInLineService( | |||
| } | |||
| dnNo = request.dnNo | |||
| receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | |||
| // The item label prints this field under 收貨日期. | |||
| productionDate = request.productionDate?.atStartOfDay() ?: receiptDate | |||
| productLotNo = request.productLotNo | |||
| this.type = "NOR" | |||
| status = StockInLineStatus.PENDING.status | |||
| @@ -1252,7 +1254,7 @@ open class StockInLineService( | |||
| 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 ?: info.receiptDate)?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | |||
| field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | |||
| field["lotNo"] = info.lotNo ?: "N/A" | |||
| field["supplier"] = info.supplier ?: "N/A" | |||