| @@ -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)", | ||||
| @@ -71,6 +71,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 | ||||
| @@ -85,11 +86,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 | ||||
| @@ -142,6 +143,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 { | ||||
| @@ -2931,6 +2946,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") | ||||
| @@ -2944,17 +2960,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 | |||||
| } | } | ||||
| } | } | ||||
| @@ -3021,14 +3058,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>() | ||||
| @@ -3042,18 +3072,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("/", "") | ||||
| @@ -3079,11 +3100,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 ?: "") } | ||||
| @@ -3157,6 +3174,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") | ||||
| @@ -3170,26 +3188,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 | |||||
| } | } | ||||
| } | } | ||||
| @@ -3210,15 +3250,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() | ||||
| @@ -3279,15 +3312,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() | ||||
| @@ -3327,23 +3353,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 | |||||
| } | |||||
| } | } | ||||
| @@ -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 = """ | ||||
| @@ -27,8 +27,8 @@ open class StockLedgerReportService( | |||||
| * - cumBalance = stock_ledger.balance(異動後纍計存量) | * - cumBalance = stock_ledger.balance(異動後纍計存量) | ||||
| * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | ||||
| * | * | ||||
| * 只查 [start, end] 期間列,不掃起日以前全歷史。 | |||||
| * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 | |||||
| * 先用貨品/日期索引切出流水帳,再一對一接批次行。 | |||||
| * 不依 inventoryLotId 展開該批次的全部儲位,否則一筆入庫會變成多列。 | |||||
| * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 | * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 | ||||
| */ | */ | ||||
| fun searchStockLedgerReport( | fun searchStockLedgerReport( | ||||
| @@ -40,6 +40,7 @@ open class StockLedgerReportService( | |||||
| ): List<Map<String, Any>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| val itemPredicate = ledgerItemPredicate(itemCode, args) | |||||
| val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | ||||
| ?: LocalDate.now().format(dateFmt)) | ?: LocalDate.now().format(dateFmt)) | ||||
| @@ -47,17 +48,7 @@ open class StockLedgerReportService( | |||||
| val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | ||||
| reportPeriodStart.replace("/", "-") | reportPeriodStart.replace("/", "-") | ||||
| } else { | } 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) | val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) | ||||
| @@ -71,13 +62,6 @@ open class StockLedgerReportService( | |||||
| args | args | ||||
| ) | ) | ||||
| val itemCodeSql = buildItemCodeFilterClause( | |||||
| itemCode, | |||||
| "sl.itemCode", | |||||
| "itemCode", | |||||
| args | |||||
| ) | |||||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | ||||
| args["storeLocation"] = "%$storeLocation%" | args["storeLocation"] = "%$storeLocation%" | ||||
| "AND wh.code LIKE :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 = """ | 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 | 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' | 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, | 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.type AS stockSubCategory, | ||||
| it.code AS itemNo, | it.code AS itemNo, | ||||
| it.name AS itemName, | it.name AS itemName, | ||||
| uom.unitOfMeasure AS unitOfMeasure, | 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, | wh.code AS storeLocation, | ||||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | ||||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo | 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 | LEFT JOIN stock_in_line sil | ||||
| ON sl.stockInLineId = sil.id | |||||
| ON ledger.stockInLineId = sil.id | |||||
| AND sil.deleted = 0 | 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 | LEFT JOIN stock_out_line sol | ||||
| ON sl.stockOutLineId = sol.id | |||||
| ON ledger.stockOutLineId = sol.id | |||||
| AND sol.deleted = 0 | 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 | 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 | AND wh.deleted = 0 | ||||
| LEFT JOIN items it | LEFT JOIN items it | ||||
| ON sl.itemId = it.id | |||||
| ON ledger.itemId = it.id | |||||
| AND it.deleted = 0 | AND it.deleted = 0 | ||||
| LEFT JOIN ( | LEFT JOIN ( | ||||
| SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure | 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 | JOIN uom_conversion uc ON uc.id = iu.uomId | ||||
| WHERE iu.stockUnit = 1 | WHERE iu.stockUnit = 1 | ||||
| AND iu.deleted = 0 | AND iu.deleted = 0 | ||||
| $uomItemSql | |||||
| GROUP BY iu.itemId | GROUP BY iu.itemId | ||||
| ) uom ON uom.itemId = it.id | ) uom ON uom.itemId = it.id | ||||
| LEFT JOIN stock_out so | LEFT JOIN stock_out so | ||||
| @@ -174,26 +193,97 @@ FROM stock_ledger sl $ledgerIndex | |||||
| LEFT JOIN purchase_order po | LEFT JOIN purchase_order po | ||||
| ON sil.purchaseOrderId = po.id | ON sil.purchaseOrderId = po.id | ||||
| AND po.deleted = 0 | 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 | $stockCategorySql | ||||
| $itemCodeSql | |||||
| $storeLocationSql | $storeLocationSql | ||||
| AND COALESCE(il_in.id, il_out.id) IS NOT NULL | |||||
| ORDER BY | ORDER BY | ||||
| itemNo, | |||||
| trnDate, | |||||
| slId, | |||||
| lotNo | |||||
| $orderBy | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return attachItemTotals(jdbcDao.queryForList(sql, args)) | 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. | * Item totals match the old window: first opening, sum in/out, last balance per itemCode. | ||||
| * Rows stay ordered by item, date, id. | * Rows stay ordered by item, date, id. | ||||
| @@ -326,4 +326,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> | |||||
| } | } | ||||
| @@ -446,6 +446,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 | ||||
| @@ -1252,7 +1254,7 @@ open class StockInLineService( | |||||
| val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) | val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) | ||||
| field["qtyLabel"] = qtyLabel | field["qtyLabel"] = qtyLabel | ||||
| field["qtyDetail"] = qtyDetail | 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["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" | ||||