Преглед на файлове

make the enter box num and print delivery note faster

doTruckUpdate
Fai Luk преди 1 месец
родител
ревизия
0b064e11fb
променени са 7 файла, в които са добавени 493 реда и са изтрити 151 реда
  1. +74
    -5
      src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt
  2. +7
    -1
      src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt
  3. +225
    -75
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  4. +12
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt
  5. +159
    -69
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  6. +13
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  7. +3
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt

+ 74
- 5
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()
}
}
}

+ 7
- 1
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)",


+ 225
- 75
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<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
}
}

+ 12
- 0
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<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 = """


+ 159
- 69
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<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.


+ 13
- 0
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<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>
}

+ 3
- 1
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"


Зареждане…
Отказ
Запис