diff --git a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt index 70af932e..6683cbe6 100644 --- a/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt @@ -3,8 +3,11 @@ package com.ffii.core.utils import org.apache.pdfbox.pdmodel.PDDocument import org.apache.pdfbox.rendering.PDFRenderer import java.awt.image.BufferedImage +import java.awt.image.DataBufferByte +import java.awt.image.MultiPixelPackedSampleModel import java.io.ByteArrayOutputStream import java.io.File +import java.net.InetSocketAddress import java.net.Socket import org.slf4j.LoggerFactory @@ -12,6 +15,7 @@ class BrotherPrinterUtil { companion object { private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept + private const val CONNECT_TIMEOUT_MS = 5_000 private const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) @@ -25,11 +29,14 @@ class BrotherPrinterUtil { log.info("Brother print: file=${pdfFile.name} size=${pdfFile.length()} ip=$printerIp port=$printerPort copies=$printQty") + PrinterLockManager.withLock(printerIp, printerPort) { PDDocument.load(pdfFile).use { document -> val renderer = PDFRenderer(document) val totalPages = document.numberOfPages - Socket(printerIp, printerPort).use { socket -> + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), CONNECT_TIMEOUT_MS) socket.soTimeout = SOCKET_TIMEOUT_MS val os = socket.getOutputStream() @@ -52,6 +59,7 @@ class BrotherPrinterUtil { log.info("Brother print job sent successfully") } } + } } private fun getPjlHeader(qty: Int): ByteArray { @@ -67,6 +75,8 @@ class BrotherPrinterUtil { * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. */ private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { + convertPackedBinaryToPcl(image, dpi)?.let { return it } + val out = ByteArrayOutputStream() val width = image.width val height = image.height @@ -75,21 +85,21 @@ class BrotherPrinterUtil { out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset // PCL: Set resolution and start raster out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) - out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) for (y in 0 until height) { val rowBytes = (width + 7) / 8 val data = ByteArray(rowBytes) - + for (x in 0 until width) { // Check if pixel is dark (threshold) val color = image.getRGB(x, y) - val isBlack = (color and 0xFF) < 128 + val isBlack = (color and 0xFF) < 128 if (isBlack) { data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() } } - + // PCL: Transfer Raster Data (Esc * b # W [data]) out.write("\u001B*b${rowBytes}W".toByteArray()) out.write(data) @@ -99,5 +109,64 @@ class BrotherPrinterUtil { out.write("\u001B*rB".toByteArray()) return out.toByteArray() } + + /** + * TYPE_BYTE_BINARY from PDFBox: sample 0 is black, sample 1 is white, MSB first. + * PCL raster uses the opposite polarity (bit 1 = black). getRGB() per pixel is too slow + * for a multi-page A4 note; pack the row from the data buffer instead. + * Returns null when the raster layout does not match, so the caller keeps the slow path. + */ + private fun convertPackedBinaryToPcl(image: BufferedImage, dpi: Int): ByteArray? { + if (image.type != BufferedImage.TYPE_BYTE_BINARY) return null + val raster = image.raster + val sampleModel = raster.sampleModel as? MultiPixelPackedSampleModel ?: return null + if (sampleModel.pixelBitStride != 1 || sampleModel.dataBitOffset != 0) return null + val dataBuffer = raster.dataBuffer as? DataBufferByte ?: return null + if (dataBuffer.numBanks != 1) return null + + val bank = dataBuffer.data + val bankOffset = dataBuffer.offsets[0] + val width = image.width + val height = image.height + if (width <= 0 || height <= 0) return null + val stride = sampleModel.scanlineStride + val rowBytes = (width + 7) / 8 + if (stride < rowBytes) return null + if (bankOffset < 0 || bankOffset + (height - 1) * stride + rowBytes > bank.size) return null + + val check = minOf(8, width) + for (x in 0 until check) { + val srcBit = (bank[bankOffset].toInt() ushr (7 - x)) and 1 + val rgbBlack = (image.getRGB(x, 0) and 0xFF) < 128 + if ((srcBit == 0) != rgbBlack) return null + } + + val out = ByteArrayOutputStream(height * (rowBytes + 12) + 32) + out.write("\u001BE".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) + out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) + + val fullBytes = width / 8 + val remainder = width % 8 + val remainderMask = if (remainder == 0) 0 else (0xFF shl (8 - remainder)) and 0xFF + val row = ByteArray(rowBytes) + + for (y in 0 until height) { + val rowStart = bankOffset + y * stride + var i = 0 + while (i < fullBytes) { + row[i] = (bank[rowStart + i].toInt().inv() and 0xFF).toByte() + i++ + } + if (remainder != 0) { + val src = bank[rowStart + fullBytes].toInt() and 0xFF + row[fullBytes] = (src.inv() and remainderMask).toByte() + } + out.write("\u001B*b${rowBytes}W".toByteArray(Charsets.US_ASCII)) + out.write(row) + } + out.write("\u001B*rB".toByteArray(Charsets.US_ASCII)) + return out.toByteArray() + } } } \ No newline at end of file diff --git a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt index 939d58ec..448bb00e 100644 --- a/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt +++ b/src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt @@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument import org.slf4j.LoggerFactory import java.io.File import java.io.OutputStream +import java.net.InetSocketAddress import java.net.Socket /** @@ -111,7 +112,11 @@ open class CanonPrinterUtil { val copies = printQty ?: 1 repeat(copies) { copyIndex -> val tSend = System.nanoTime() - Socket(printerIp, printerPort).use { socket -> + PrinterLockManager.withLock(printerIp, printerPort) { + Socket().use { socket -> + socket.tcpNoDelay = true + socket.connect(InetSocketAddress(printerIp, printerPort), 5_000) + socket.soTimeout = 120_000 val os: OutputStream = socket.getOutputStream() // Send PJL commands @@ -126,6 +131,7 @@ open class CanonPrinterUtil { os.flush() } + } val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 log.info( "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt index e1563c53..50b2ec58 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt @@ -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 { - val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml" - val resource = ClassPathResource(DELIVERYNOTE_PDF) - if (!resource.exists()) { - throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF") - } - - val inputStream = resource.inputStream - val deliveryNote = JasperCompileManager.compileReport(inputStream) + val deliveryNote = deliveryNoteJasperTemplate val fields = mutableListOf>() val params = mutableMapOf() @@ -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> = - 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 = 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 { - 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): List { + if (pickOrderIds.isEmpty()) return emptyList() + return pickOrderIds.distinct().chunked(400).flatMap { chunk -> + pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk) + } + } + + private fun loadStockOutLinesForPrint(pickOrderLineIds: List): Map> { + if (pickOrderLineIds.isEmpty()) return emptyMap() + val lines = mutableListOf() + pickOrderLineIds.distinct().chunked(400).forEach { chunk -> + val rows = jdbcDao.queryForList( + """ + SELECT + sol.id AS id, + sol.pickOrderLineId AS pickOrderLineId, + sol.qty AS qty, + sol.inventoryLotLineId AS inventoryLotLineId, + sol.status AS status, + il.lotNo AS lotNo + FROM fpsmsdb.stock_out_line sol + LEFT JOIN fpsmsdb.inventory_lot_line ill + ON ill.id = sol.inventoryLotLineId + LEFT JOIN fpsmsdb.inventory_lot il + ON il.id = ill.inventoryLotId + WHERE sol.deleted = 0 + AND sol.pickOrderLineId IN (:ids) + """.trimIndent(), + mapOf("ids" to chunk), + ) + rows.forEach { row -> + val polId = (rowValue(row, "pickOrderLineId") as? Number)?.toLong() ?: return@forEach + val qtyRaw = rowValue(row, "qty") + lines.add( + PrintStockOutLineInfo( + id = (rowValue(row, "id") as? Number)?.toLong() ?: 0L, + pickOrderLineId = polId, + qty = when (qtyRaw) { + is BigDecimal -> qtyRaw + is Number -> BigDecimal(qtyRaw.toString()) + else -> BigDecimal.ZERO + }, + inventoryLotLineId = (rowValue(row, "inventoryLotLineId") as? Number)?.toLong(), + lotNo = rowValue(row, "lotNo")?.toString()?.takeIf { it.isNotBlank() }, + status = rowValue(row, "status")?.toString() ?: "", + ), + ) + } + } + return lines.groupBy { it.pickOrderLineId!! } + } + + private fun loadInventoryLotLinesWithWarehouse(ids: Collection): Map { + if (ids.isEmpty()) return emptyMap() + val loaded = ids.distinct().chunked(400).flatMap { chunk -> + inventoryLotLineRepository.findAllByIdInWithWarehouse(chunk) + } + return loaded.mapNotNull { line -> line.id?.let { id -> id to line } }.toMap() + } + + private fun rowValue(row: Map, key: String): Any? { + val matched = row.keys.find { it.equals(key, ignoreCase = true) } ?: return null + return row[matched] + } + + private data class PrintStockOutLineInfo( + override val id: Long, + override val pickOrderLineId: Long?, + override val qty: BigDecimal, + override val inventoryLotLineId: Long?, + override val lotNo: String?, + override val status: String, + ) : StockOutLineInfo { + override val itemId: Long = 0L + override val itemName: String? = null + override val itemNo: String = "" + override val stockOutId: Long = 0L + override val pickTime: java.time.LocalDateTime? = null + } } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt index bdf8da28..ba35afb9 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity import com.ffii.core.support.AbstractRepository import org.springframework.stereotype.Repository +import org.springframework.data.jpa.repository.EntityGraph import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param @@ -33,6 +34,17 @@ fun findByPickOrderId(pickOrderId: Long): List ) fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List): List + /** Delivery-note print: one query, associations initialized (avoids per-line lazy loads). */ + @EntityGraph(attributePaths = ["item", "pickOrder", "uom"]) + @Query( + """ + SELECT pol FROM PickOrderLine pol + WHERE pol.pickOrder.id IN :pickOrderIds + AND pol.deleted = false + """ + ) + fun findAllForPrintByPickOrderIdIn(@Param("pickOrderIds") pickOrderIds: List): List + @Modifying(clearAutomatically = true, flushAutomatically = true) @Query( value = """ diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt index 7e57c09c..470d720b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt @@ -27,8 +27,8 @@ open class StockLedgerReportService( * - cumBalance = stock_ledger.balance(異動後纍計存量) * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) * - * 只查 [start, end] 期間列,不掃起日以前全歷史。 - * 批號/位置直接跟這筆出入庫的批次,不再先彙整全庫批次。 + * 先用貨品/日期索引切出流水帳,再一對一接批次行。 + * 不依 inventoryLotId 展開該批次的全部儲位,否則一筆入庫會變成多列。 * 貨品總量在取出明細後計算,避免對寬結果做 window sort。 */ fun searchStockLedgerReport( @@ -40,6 +40,7 @@ open class StockLedgerReportService( ): List> { val args = mutableMapOf() + val itemPredicate = ledgerItemPredicate(itemCode, args) val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") ?: LocalDate.now().format(dateFmt)) @@ -47,17 +48,7 @@ open class StockLedgerReportService( val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { reportPeriodStart.replace("/", "-") } else { - val minDateSql = """ - SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate - FROM stock_ledger sl - WHERE sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - """.trimIndent() - - val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap()).firstOrNull() - (minDateRow?.get("firstDate") as? String) - ?: reportPeriodEnd + earliestLedgerDate(itemPredicate, args) ?: reportPeriodEnd } val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) @@ -71,13 +62,6 @@ open class StockLedgerReportService( args ) - val itemCodeSql = buildItemCodeFilterClause( - itemCode, - "sl.itemCode", - "itemCode", - args - ) - val storeLocationSql = if (!storeLocation.isNullOrBlank()) { args["storeLocation"] = "%$storeLocation%" "AND wh.code LIKE :storeLocation" @@ -85,62 +69,96 @@ open class StockLedgerReportService( "" } - // Wide reports are a date range over the whole ledger. Pin that index so MySQL - // does not start from another stock_ledger index and scan extra rows. - val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else "" + val uomItemSql = if (itemPredicate.itemIdParamNames.isEmpty()) { + "" + } else { + "AND iu.itemId IN (${itemPredicate.itemIdParamNames.joinToString(", ") { ":$it" }})" + } + // One item is already ordered by the (itemId, deleted, date, id) index. + val orderBy = if (itemPredicate.singleItem) { + "ledger.dt, ledger.slId" + } else { + "it.code, ledger.dt, ledger.slId" + } + + // NO_MERGE keeps the date/item slice materialized before the lot joins. val sql = """ -SELECT - sl.id AS slId, - sl.itemCode AS itemCode, - DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, +SELECT /*+ NO_MERGE(ledger) */ + ledger.slId AS slId, + ledger.itemCode AS itemCode, + DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate, CASE - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' - WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE' + WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ' AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' - ELSE COALESCE(sl.type, '') + ELSE COALESCE(ledger.ledgerType, '') END AS trnRefNoRaw, - COALESCE(sl.inQty, 0) AS inQty, - COALESCE(sl.outQty, 0) AS outQty, - COALESCE(sl.balance, 0) AS bal, - COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, + COALESCE(ledger.inQty, 0) AS inQty, + COALESCE(ledger.outQty, 0) AS outQty, + COALESCE(ledger.bal, 0) AS bal, + COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS cumOpeningBalRaw, it.type AS stockSubCategory, it.code AS itemNo, it.name AS itemName, uom.unitOfMeasure AS unitOfMeasure, - COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo, - COALESCE( - DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'), - DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'), - '' - ) AS expiryDate, + il.lotNo AS lotNo, + COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, wh.code AS storeLocation, COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, COALESCE(TRIM(jo.code), '') AS jobOrderNo -FROM stock_ledger sl $ledgerIndex +FROM ( + SELECT + sl.id AS slId, + sl.itemCode AS itemCode, + sl.date AS dt, + sl.type AS ledgerType, + sl.inQty AS inQty, + sl.outQty AS outQty, + sl.balance AS bal, + sl.itemId AS itemId, + sl.stockInLineId AS stockInLineId, + sl.stockOutLineId AS stockOutLineId, + sl.inventoryLotLineId AS inventoryLotLineId + FROM stock_ledger sl ${itemPredicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + AND sl.date >= :reportPeriodStart + AND sl.date < :reportPeriodEndExclusive + ${itemPredicate.sql} +) ledger LEFT JOIN stock_in_line sil - ON sl.stockInLineId = sil.id + ON ledger.stockInLineId = sil.id AND sil.deleted = 0 - LEFT JOIN inventory_lot il_in - ON sil.inventoryLotId = il_in.id - AND il_in.deleted = 0 LEFT JOIN stock_out_line sol - ON sl.stockOutLineId = sol.id + ON ledger.stockOutLineId = sol.id AND sol.deleted = 0 - LEFT JOIN inventory_lot_line ill_out - ON sol.inventoryLotLineId = ill_out.id - AND ill_out.deleted = 0 - LEFT JOIN inventory_lot il_out - ON ill_out.inventoryLotId = il_out.id - AND il_out.deleted = 0 - LEFT JOIN inventory_lot_line ill_in_loc - ON ill_in_loc.inventoryLotId = il_in.id - AND ill_in_loc.deleted = 0 + LEFT JOIN inventory_lot_line ill + ON ill.id = ledger.inventoryLotLineId + AND ill.deleted = 0 + LEFT JOIN inventory_lot_line ill_sil + ON ill.id IS NULL + AND ill_sil.id = sil.inventoryLotLineId + AND ill_sil.deleted = 0 + LEFT JOIN inventory_lot_line ill_sol + ON ill.id IS NULL + AND ill_sil.id IS NULL + AND ill_sol.id = sol.inventoryLotLineId + AND ill_sol.deleted = 0 + LEFT JOIN inventory_lot il + ON il.id = COALESCE( + ill.inventoryLotId, + ill_sil.inventoryLotId, + ill_sol.inventoryLotId, + sil.inventoryLotId + ) + AND il.deleted = 0 LEFT JOIN warehouse wh - ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId) + ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId) AND wh.deleted = 0 LEFT JOIN items it - ON sl.itemId = it.id + ON ledger.itemId = it.id AND it.deleted = 0 LEFT JOIN ( SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure @@ -148,6 +166,7 @@ FROM stock_ledger sl $ledgerIndex JOIN uom_conversion uc ON uc.id = iu.uomId WHERE iu.stockUnit = 1 AND iu.deleted = 0 + $uomItemSql GROUP BY iu.itemId ) uom ON uom.itemId = it.id LEFT JOIN stock_out so @@ -174,26 +193,97 @@ FROM stock_ledger sl $ledgerIndex LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id AND po.deleted = 0 -WHERE - sl.deleted = 0 - AND sl.itemCode IS NOT NULL - AND sl.itemCode <> '' - AND sl.date >= :reportPeriodStart - AND sl.date < :reportPeriodEndExclusive +WHERE il.id IS NOT NULL $stockCategorySql - $itemCodeSql $storeLocationSql - AND COALESCE(il_in.id, il_out.id) IS NOT NULL ORDER BY - itemNo, - trnDate, - slId, - lotNo + $orderBy """.trimIndent() return attachItemTotals(jdbcDao.queryForList(sql, args)) } + /** + * Exact item codes become itemId so MySQL can use + * idx_ledger_item_deleted_date_id (itemId, deleted, date, id). + */ + private fun ledgerItemPredicate(itemCode: String?, args: MutableMap): LedgerItemPredicate { + val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode) + if (exact.isEmpty() && fuzzy.isEmpty()) { + return LedgerItemPredicate( + sql = "", + indexHint = "FORCE INDEX (idx_sl_deleted_date)", + singleItem = false, + itemIdParamNames = emptyList(), + ) + } + if (fuzzy.isEmpty()) { + val ids = lookupItemIds(exact) + if (ids.isNotEmpty()) { + val names = ids.mapIndexed { index, id -> + val name = "itemId_$index" + args[name] = id + name + } + return LedgerItemPredicate( + sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})", + indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)", + singleItem = ids.size == 1, + itemIdParamNames = names, + ) + } + } + val oneExactCode = fuzzy.isEmpty() && exact.size == 1 + return LedgerItemPredicate( + sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args), + indexHint = if (exact.size + fuzzy.size == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "", + singleItem = oneExactCode, + itemIdParamNames = emptyList(), + ) + } + + private fun lookupItemIds(codes: List): List { + if (codes.isEmpty()) return emptyList() + val args = HashMap(codes.size) + val names = codes.mapIndexed { index, code -> + val name = "lookupCode_$index" + args[name] = code + ":$name" + } + val sql = """ + SELECT id AS itemId + FROM items + WHERE code IN (${names.joinToString(", ")}) + """.trimIndent() + return jdbcDao.queryForList(sql, args).mapNotNull { row -> + when (val id = row["itemId"]) { + is Number -> id.toLong() + else -> id?.toString()?.toLongOrNull() + } + }.distinct() + } + + /** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */ + private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map): String? { + val sql = """ + SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate + FROM stock_ledger sl ${predicate.indexHint} + WHERE sl.deleted = 0 + AND sl.itemCode IS NOT NULL + AND sl.itemCode <> '' + ${predicate.sql} + """.trimIndent() + val row = jdbcDao.queryForList(sql, args).firstOrNull() + return row?.get("firstDate") as? String + } + + private data class LedgerItemPredicate( + val sql: String, + val indexHint: String, + val singleItem: Boolean, + val itemIdParamNames: List, + ) + /** * Item totals match the old window: first opening, sum in/out, last balance per itemCode. * Rows stay ordered by item, date, id. diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index 68911e1c..bc738342 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -326,4 +326,17 @@ WHERE ill.id = :id fun findAllByIdInAndDeletedFalseWithRefs( @Param("ids") ids: Collection ): List + + /** Delivery-note route column: warehouse code without a lazy load per lot line. */ + @EntityGraph( + type = EntityGraph.EntityGraphType.FETCH, + attributePaths = ["warehouse"] + ) + @Query( + """ + SELECT ill FROM InventoryLotLine ill + WHERE ill.id IN :ids + """ + ) + fun findAllByIdInWithWarehouse(@Param("ids") ids: Collection): List } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 446fa208..b86bf0a1 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -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"