Bladeren bron

Merge branch 'production' into fix負數倉

# Conflicts:
#	src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
#	src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
#	src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
#	src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
#	src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
#	src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
#	src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt
#	src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
#	src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt
#	src/main/resources/jasper/StockLedgarReport.jrxml
fix負數倉
CANCERYS\kw093 3 weken geleden
bovenliggende
commit
b76b1148b4
60 gewijzigde bestanden met toevoegingen van 3041 en 331 verwijderingen
  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. +10
    -7
      src/main/java/com/ffii/core/utils/QrCodeUtil.kt
  4. +1
    -1
      src/main/java/com/ffii/fpsms/config/WebConfig.java
  5. +13
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  6. +13
    -3
      src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt
  7. +46
    -18
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  8. +141
    -34
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  9. +9
    -2
      src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt
  10. +18
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  11. +11
    -2
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  12. +225
    -75
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  13. +23
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  14. +12
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt
  15. +7
    -7
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt
  16. +2
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderRepository.kt
  17. +4
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt
  18. +42
    -3
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt
  19. +22
    -2
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt
  20. +1
    -5
      src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt
  21. +41
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt
  22. +36
    -8
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  23. +117
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  24. +1
    -1
      src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt
  25. +258
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt
  26. +95
    -28
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  27. +60
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt
  28. +366
    -65
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  29. +39
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt
  30. +303
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt
  31. +46
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt
  32. +70
    -36
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
  33. +4
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt
  34. +12
    -0
      src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java
  35. +13
    -0
      src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java
  36. +13
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  37. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt
  38. +8
    -5
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt
  39. +11
    -4
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  40. +66
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt
  41. +22
    -11
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  42. +57
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  43. +7
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  44. +3
    -2
      src/main/resources/application-prod.yml
  45. +2
    -1
      src/main/resources/application.yml
  46. +14
    -0
      src/main/resources/db/changelog/changes/20260918_po_stockin_refresh_if_newer/01_setting.sql
  47. +9
    -0
      src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql
  48. +8
    -0
      src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql
  49. +5
    -0
      src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql
  50. +9
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql
  51. +7
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql
  52. +7
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql
  53. +8
    -0
      src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql
  54. +19
    -0
      src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql
  55. +5
    -0
      src/main/resources/jasper/StockBalanceReport.jrxml
  56. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml
  57. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml
  58. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml
  59. +59
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt
  60. +40
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt

+ 74
- 5
src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt Bestand weergeven

@@ -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 Bestand weergeven

@@ -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)",


+ 10
- 7
src/main/java/com/ffii/core/utils/QrCodeUtil.kt Bestand weergeven

@@ -1,18 +1,21 @@
package com.ffii.core.utils

import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import java.awt.image.BufferedImage
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.client.j2se.MatrixToImageWriter
import com.google.zxing.qrcode.QRCodeWriter

open class QrCodeUtil {
companion object {
fun generateQRCodeImage(barcodeText: String?): BufferedImage {
val barcodeWriter = QRCodeWriter()
val bitMatrix: BitMatrix = barcodeWriter.encode(barcodeText, BarcodeFormat.QR_CODE, 250, 250)
val img: BufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix)
return img
return generateQRCodeImage(barcodeText, 250, 4)
}

fun generateQRCodeImage(barcodeText: String?, size: Int, margin: Int): BufferedImage {
val hints = hashMapOf<EncodeHintType, Any>(EncodeHintType.MARGIN to margin)
val bitMatrix = QRCodeWriter().encode(barcodeText, BarcodeFormat.QR_CODE, size, size, hints)
return MatrixToImageWriter.toBufferedImage(bitMatrix)
}
}
}

+ 1
- 1
src/main/java/com/ffii/fpsms/config/WebConfig.java Bestand weergeven

@@ -16,7 +16,7 @@ public class WebConfig implements WebMvcConfigurer {
registry.addMapping("/**")
.allowedHeaders("*")
.allowedOrigins("*")
.exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry")
.exposedHeaders("filename", "Content-Disposition", "X-OnPack-Skipped-Expiry", "X-M18-Po-Refresh")
.allowedMethods("GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS");

}


+ 13
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Bestand weergeven

@@ -79,6 +79,7 @@ public class SecurityConfig {
* FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06
* FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08
* (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST)
* Route board truck APIs → ADMIN / TRUCK_LANE / TESTING
*/
@Bean
@Order(1)
@@ -140,6 +141,18 @@ public class SecurityConfig {
.hasAnyAuthority("ADMIN", "M18_SYNC")
.requestMatchers(HttpMethod.GET, "/m18/test/product-by-code")
.hasAnyAuthority("ADMIN", "M18_SYNC")
/* 車線看板 (/settings/shop/board):ADMIN / TRUCK_LANE / TESTING */
.requestMatchers("/truck/**")
.hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING")
.requestMatchers("/truckLaneVersion/**")
.hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING")
.requestMatchers("/truckLaneSchedule/**")
.hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING")
/* 員工發貨績效 (/chart/delivery): PERFORMANCE (績效) */
.requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance")
.hasAuthority("PERFORMANCE")
.requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance-handlers")
.hasAuthority("PERFORMANCE")
.anyRequest().authenticated())
.httpBasic(httpBasic -> httpBasic.authenticationEntryPoint(
(request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED")))


+ 13
- 3
src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt Bestand weergeven

@@ -1,10 +1,20 @@
package com.ffii.fpsms.m18.model

import java.time.LocalDateTime

data class SyncResult(
val totalProcessed: Int,
val totalSuccess: Int,
val totalFail: Int,
val query: String? = null
)
)

/**
* Bounded suffix for [scheduler_sync_log.query]. Listing every skipped document
* overflows VARCHAR(2000) on a multi-day PO window.
*/
fun skippedNotPendingQuerySuffix(entries: List<String>, previewLimit: Int = 20): String {
if (entries.isEmpty()) return ""
val shown = entries.take(previewLimit)
val hidden = entries.size - shown.size
val more = if (hidden > 0) "; ... +$hidden more" else ""
return " | skipped (not pending, only pending may be overwritten) count=${entries.size}: ${shown.joinToString("; ")}$more"
}

+ 46
- 18
src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt Bestand weergeven

@@ -89,14 +89,8 @@ open class M18DeliveryOrderService(
""
}
// Shop PO
val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=")
val shopPoSupplier = commonUtils.listToString(
shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER),
"venId=equal=",
"=or="
)
var shopPoConds = "(${shopPoBuyers})=and=(${shopPoSupplier})"
// Shop PO — same BE/supplier scope as [buildShopPoScopeConds]
var shopPoConds = buildShopPoScopeConds()

if (request.modifiedDateFrom != null && request.modifiedDateTo != null) {
shopPoConds += "=and=(${lastDateConds})"
@@ -160,9 +154,24 @@ open class M18DeliveryOrderService(
)
}

/**
* Shop PO (MTMS DO) BE/supplier scope used by scheduler and by-code sync:
* beId = TOA and venId in [M18Config.SHOP_PO_SUPPLIER] (e.g. P06/P07).
*/
private fun buildShopPoScopeConds(): String {
val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=")
val shopPoSupplier = commonUtils.listToString(
shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER),
"venId=equal=",
"=or="
)
return "(${shopPoBuyers})=and=(${shopPoSupplier})"
}

/**
* Sync a single M18 shop PO / delivery order by document [code], same search pattern as
* [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode].
* [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode], including
* the scheduler shop-PO BE/supplier filters (rejects material POs).
*
* @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync).
* No M18-side "加單" filtering is used.
@@ -173,15 +182,16 @@ open class M18DeliveryOrderService(
isExtraSync: Boolean = false,
newOnly: Boolean = false,
): SyncResult {
if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) {
val trimmed = code.trim()
if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) {
return SyncResult(
totalProcessed = 1,
totalSuccess = 0,
totalFail = 0,
query = "skipped (newOnly=true): delivery_order.code already exists: $code",
query = "skipped (newOnly=true): delivery_order.code already exists: $trimmed",
)
}
val conds = "(code=equal=$code)"
val conds = "(code=equal=$trimmed)=and=${buildShopPoScopeConds()}"
val searchRequest = M18PurchaseOrderListRequest(
stSearch = "po",
params = null,
@@ -228,6 +238,7 @@ open class M18DeliveryOrderService(

val successList = mutableListOf<Long>()
val skippedList = mutableListOf<Long>()
val skippedNotPendingList = mutableListOf<String>()
val successDetailList = mutableListOf<Long>()
val failList = mutableListOf<Long>()
val failDetailList = mutableListOf<Long>()
@@ -291,6 +302,23 @@ open class M18DeliveryOrderService(
// logger.info("${doRefType}: Finding For Latest M18 Data Log...")
val latestDeliveryOrderLog =
m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType)
val existingDeliveryOrderForSync =
latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) }
if (existingDeliveryOrderForSync != null &&
existingDeliveryOrderForSync.deleted != true &&
existingDeliveryOrderForSync.status != DeliveryOrderStatus.PENDING
) {
val statusValue = existingDeliveryOrderForSync.status?.value ?: "unknown"
logger.info(
"${doRefType}: Skipping M18 sync — local DO id=${existingDeliveryOrderForSync.id} " +
"code=${existingDeliveryOrderForSync.code} status=$statusValue " +
"(only pending may be overwritten; receiving=已放單). M18 ID: ${deliveryOrder.id}"
)
skippedNotPendingList.add(
"code=${existingDeliveryOrderForSync.code} status=$statusValue"
)
return@forEach
}
//logger.info(latestDeliveryOrderLog.toString())
// Save to m18_data_log table
// logger.info("${doRefType}: Saving for M18 Data Log...")
@@ -312,10 +340,9 @@ open class M18DeliveryOrderService(
// logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}")

try {
// Find the delivery_order if exist
// Find the delivery_order if exist (re-use lookup from pending guard above)
// logger.info("${doRefType}: Finding exising delivery order...")
val existingDeliveryOrder =
latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) }
val existingDeliveryOrder = existingDeliveryOrderForSync
// logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}")

// Save to delivery_order table
@@ -621,12 +648,13 @@ open class M18DeliveryOrderService(

logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------")

val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else ""
val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else ""
val skippedNotPendingSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList)
return SyncResult(
totalProcessed = successList.size + failList.size + skippedList.size,
totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size,
totalSuccess = successList.size,
totalFail = failList.size,
query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix,
query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix,
)
}
}

+ 141
- 34
src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt Bestand weergeven

@@ -15,10 +15,12 @@ import com.ffii.fpsms.modules.master.service.ShopService
import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderLineStatus
import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderStatus
import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderLineService
import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService
import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderLineRequest
import com.ffii.fpsms.modules.purchaseOrder.web.model.SavePurchaseOrderRequest
import com.ffii.fpsms.modules.settings.service.SettingsService
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
@@ -39,8 +41,16 @@ open class M18PurchaseOrderService(
val itemsService: ItemsService,
val shopService: ShopService,
val itemUomService: ItemUomService,
val m18MasterDataService: M18MasterDataService
val m18MasterDataService: M18MasterDataService,
val settingsService: SettingsService,
) {
companion object {
fun isStockInLookupPoCode(code: String?): Boolean {
val trimmed = code?.trim().orEmpty()
return trimmed.length > 14 && (trimmed.startsWith("PP") || trimmed.startsWith("PF"))
}
}

val commonUtils = CommonUtils()
val logger: Logger = LoggerFactory.getLogger(JwtTokenUtil::class.java)

@@ -87,15 +97,8 @@ open class M18PurchaseOrderService(
""
}

// Material PO
val materialPoBuyers =
commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=")
val materialPoSupplierNot = commonUtils.listToString(
shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT),
"venId=unequal=",
"=or="
)
var materialPoConds = "(${materialPoBuyers})=and=(${materialPoSupplierNot}))"
// Material PO — same BE/supplier scope as [buildMaterialPoScopeConds]
var materialPoConds = buildMaterialPoScopeConds()
if (request.modifiedDateFrom != null && request.modifiedDateTo != null) {
materialPoConds += "=and=(${lastDateConds})"
@@ -223,44 +226,129 @@ open class M18PurchaseOrderService(
return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL
}

/**
* Material PO BE/supplier scope used by scheduler and by-code sync:
* beId in (PP, PF) and venId not in [M18Config.MATERIAL_PO_SUPPLIER_NOT] (e.g. P06/P07 shop suppliers).
*/
private fun buildMaterialPoScopeConds(): String {
val materialPoBuyers =
commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=")
val materialPoSupplierNot = commonUtils.listToString(
shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT),
"venId=unequal=",
"=or="
)
return "(${materialPoBuyers})=and=(${materialPoSupplierNot})"
}

open fun savePurchaseOrderByCode(code: String): SyncResult {
// Follow the scheduler's original approach:
// 1) use /search/search to find the PO id by code
// 2) then sync by PO id (this reuses the same create/update logic)
val searchRequest = M18PurchaseOrderListRequest(
stSearch = "po",
params = null,
conds = "(code=equal=$code)"
// Same approach as scheduler list sync, restricted to one document code:
// 1) /search/search with material-PO BE/supplier scope + code
// 2) sync by PO id (reuses create/update logic)
// Rejects shop POs (TOA / P06|P07) that belong to DO sync instead.
val trimmed = code.trim()
val conds = "(code=equal=$trimmed)=and=${buildMaterialPoScopeConds()}"
return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds))
}

/**
* Stock-in `/po` lookup.
* - Missing local PO: always sync from M18 (same as [savePurchaseOrderByCode]).
* - Local pending + setting [SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED] true:
* sync from M18 (overwrite pending). Receiving / completed are not refreshed.
* - Local PO + setting not true: do not call M18.
*/
open fun savePurchaseOrderByCodeIfNewer(code: String): SyncResult =
refreshForStockInLookup(code).syncResult

open fun refreshForStockInLookup(code: String): StockInPoRefreshOutcome {
val trimmed = code.trim()
val enabled = isStockInRefreshIfNewerEnabled()
val localStatus = purchaseOrderService.findActiveStatusByCode(trimmed)
logger.info(
"Purchase Order: stock-in ifNewer code={} settingEnabled={} localStatus={}",
trimmed,
enabled,
localStatus ?: "<none>",
)
val poListResponse = try {
apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>(
M18_FETCH_PURCHASE_ORDER_LIST_API,
searchRequest
).block()
} catch (e: Exception) {
logger.error("(Getting Po List By Code) Error on Function - ${e.stackTrace}")
logger.error(e.message)
null
if (localStatus != null && !enabled) {
logger.info(
"Purchase Order: Skipping M18 sync — setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} " +
"is not true. code=$trimmed status=$localStatus"
)
return StockInPoRefreshOutcome(
action = StockInPoRefreshAction.SKIPPED_DISABLED,
syncResult = SyncResult(
totalProcessed = 0,
totalSuccess = 0,
totalFail = 0,
query = "skipped (ifNewer): setting ${SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED} is not true"
),
)
}
if (localStatus != null &&
!PurchaseOrderStatus.PENDING.value.equals(localStatus, ignoreCase = true)
) {
logger.info(
"Purchase Order: Skipping M18 sync — local PO code=$trimmed status=$localStatus (only pending may be refreshed)."
)
return StockInPoRefreshOutcome(
action = StockInPoRefreshAction.SKIPPED_NOT_PENDING,
syncResult = SyncResult(
totalProcessed = 0,
totalSuccess = 0,
totalFail = 0,
query = "skipped (ifNewer): local PO not pending: $trimmed status=$localStatus"
),
)
}
return StockInPoRefreshOutcome(
action = StockInPoRefreshAction.SYNCED,
syncResult = savePurchaseOrderByCode(trimmed),
)
}

private fun isStockInRefreshIfNewerEnabled(): Boolean =
settingsService.isBooleanTrueFromDb(SettingNames.M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED)

private fun saveFromPoListSearch(
query: String,
poListResponse: M18PurchaseOrderListResponse?,
): SyncResult {
val poValues = poListResponse?.values
if (poValues.isNullOrEmpty()) {
return SyncResult(
totalProcessed = 1,
totalSuccess = 0,
totalFail = 1,
query = "code=equal=$code"
query = query
)
}

val request = M18PurchaseOrderListResponseWithType(
valuesWithType = mutableListOf(Pair(PurchaseOrderType.MATERIAL, poListResponse)),
query = "code=equal=$code"
query = query
)

return savePurchaseOrdersWithPreparedList(purchaseOrdersWithType = request)
}

private fun searchPurchaseOrdersByConds(conds: String): M18PurchaseOrderListResponse? {
val searchRequest = M18PurchaseOrderListRequest(
stSearch = "po",
params = null,
conds = conds
)
return try {
apiCallerService.get<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>(
M18_FETCH_PURCHASE_ORDER_LIST_API,
searchRequest
).block()
} catch (e: Exception) {
logger.error("(Getting Po List By Code) Error on Function - ${e.stackTrace}")
logger.error(e.message)
null
}
}

open fun savePurchaseOrders(request: M18CommonRequest) : SyncResult{
val purchaseOrdersWithType = getPurchaseOrdersWithType(request)
return savePurchaseOrdersWithPreparedList(
@@ -279,6 +367,7 @@ open class M18PurchaseOrderService(
val successDetailList = mutableListOf<Long>()
val failList = mutableListOf<Long>()
val failDetailList = mutableListOf<Long>()
val skippedNotPendingList = mutableListOf<String>()
val affectedItemIds = mutableSetOf<Long>()
val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>()

@@ -320,11 +409,15 @@ open class M18PurchaseOrderService(
if (existingPurchaseOrderForSync != null &&
existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING
) {
val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown"
logger.info(
"${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " +
"code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " +
"code=${existingPurchaseOrderForSync.code} status=$statusValue " +
"(only pending may be overwritten). M18 ID: $m18PurchaseOrderId"
)
skippedNotPendingList.add(
"code=${existingPurchaseOrderForSync.code} status=$statusValue"
)
return@forEach
}

@@ -651,11 +744,25 @@ open class M18PurchaseOrderService(

logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------")

val baseQuery = purchaseOrdersWithType?.query ?: ""
val skippedSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList)

return SyncResult(
totalProcessed = successList.size + failList.size,
totalProcessed = successList.size + failList.size + skippedNotPendingList.size,
totalSuccess = successList.size,
totalFail = failList.size,
query = purchaseOrdersWithType?.query ?: ""
query = baseQuery + skippedSuffix
)
}
}
}

enum class StockInPoRefreshAction(val headerValue: String) {
SKIPPED_DISABLED("skipped_disabled"),
SKIPPED_NOT_PENDING("skipped_not_pending"),
SYNCED("synced"),
}

data class StockInPoRefreshOutcome(
val action: StockInPoRefreshAction,
val syncResult: SyncResult,
)

+ 9
- 2
src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt Bestand weergeven

@@ -76,8 +76,15 @@ class M18TestController (
}

@GetMapping("/test/po-by-code")
fun testSyncPoByCode(@RequestParam code: String): SyncResult {
return m18PurchaseOrderService.savePurchaseOrderByCode(code)
fun testSyncPoByCode(
@RequestParam code: String,
@RequestParam(required = false, defaultValue = "false") ifNewer: Boolean,
): SyncResult {
return if (ifNewer) {
m18PurchaseOrderService.savePurchaseOrderByCodeIfNewer(code)
} else {
m18PurchaseOrderService.savePurchaseOrderByCode(code)
}
}

@GetMapping("/test/do-by-code")


+ 18
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Bestand weergeven

@@ -52,6 +52,15 @@ public abstract class SettingNames {
*/
public static final String M18_BOM_SHOP_SYNC_ENABLED = "M18.bom.shop.sync.enabled";

/**
* When "true", stock-in {@code /po} lookup for an existing pending PO re-syncs from M18.
* Read from DB on each request (no server restart). Missing POs still auto-sync.
* Receiving / completed POs are never overwritten.
*/
public static final String M18_PO_STOCK_IN_REFRESH_IF_NEWER_ENABLED =
"M18.po.stockIn.refreshIfNewer.enabled";


/** Post completed DN and process M18 GRN (cron, e.g. "0 40 23 * * *" for 23:40 daily) */
public static final String SCHEDULE_POST_COMPLETED_DN_GRN = "SCHEDULE.postCompletedDn.grn";

@@ -71,6 +80,12 @@ public abstract class SettingNames {
* Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday.
*/
public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart";

/**
* Stock-take count vs on-hand gap (percent) that shows a non-blocking warning.
* Either direction. Default 50.
*/
public static final String STOCK_TAKE_QTY_GAP_WARN_PERCENT = "STOCK_TAKE.qtyGapWarnPercent";
/*
* Mail settings
*/
@@ -123,4 +138,7 @@ public abstract class SettingNames {
/** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */
public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess";

/** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR, 4 = version 3 with the version 1 QR size. */
public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version";

}

+ 11
- 2
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Bestand weergeven

@@ -93,6 +93,8 @@ open class SchedulerService(
const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *"
/** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */
const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 0 * * *"
/** MySQL TEXT is 65535 bytes; 16000 chars stays under that for utf8mb4. */
const val SYNC_LOG_TEXT_MAX_CHARS: Int = 16_000
}

/** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */
@@ -811,14 +813,21 @@ open class SchedulerService(
recordsProcessed = result?.totalProcessed ?: 0,
recordsSaved = result?.totalSuccess ?: 0,
recordsFailed = result?.totalFail ?: 0,
errorMessage = error,
query = result?.query ?: "",
errorMessage = fitSyncLogText(error),
query = fitSyncLogText(result?.query ?: ""),
startTime = start,
endTime = LocalDateTime.now()
)
schedulerSyncLogRepository.save(log)
}

private fun fitSyncLogText(value: String?): String? {
if (value == null || value.length <= SYNC_LOG_TEXT_MAX_CHARS) return value
val marker = "...(truncated, length=${value.length})"
logger.warn("scheduler_sync_log text truncated from {} to {} characters", value.length, SYNC_LOG_TEXT_MAX_CHARS)
return value.take((SYNC_LOG_TEXT_MAX_CHARS - marker.length).coerceAtLeast(0)) + marker
}

open fun getM18Dos2() {
logger.info("DO Scheduler 2 - DO")
val currentTime = LocalDateTime.now()


+ 225
- 75
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Bestand weergeven

@@ -72,6 +72,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
@@ -86,11 +87,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
@@ -144,6 +145,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 {
@@ -2934,6 +2949,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")

@@ -2947,17 +2963,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
}
}

@@ -3024,14 +3061,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>()

@@ -3045,18 +3075,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("/", "")
@@ -3082,11 +3103,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 ?: "") }
@@ -3160,6 +3177,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")

@@ -3173,26 +3191,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
}
}

@@ -3213,15 +3253,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()
@@ -3282,15 +3315,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()
@@ -3330,23 +3356,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
}
}

+ 23
- 2
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt Bestand weergeven

@@ -226,6 +226,27 @@ open class ItemUomService(
return stockQty.setScale(0, RoundingMode.DOWN)
}

/**
* Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc.
* Weight/volume units (克, 公斤, 升…) keep 2 decimals.
*/
open fun isIntegerCountStockUom(stockUnit: ItemUom?): Boolean {
if (stockUnit == null) return false
val code = stockUnit.uom?.code?.trim().orEmpty()
if (code.startsWith("PCS/CTN", ignoreCase = true)) return true
val short = stockUnit.uom?.udfShortDesc?.trim().orEmpty()
return isIntegerCountUnitToken(short) || isIntegerCountUnitToken(code)
}

private fun isIntegerCountUnitToken(raw: String): Boolean {
val token = raw.trim()
if (token.isEmpty()) return false
if (token == "包" || token == "箱" || token == "件" || token == "個") return true
val upper = token.uppercase()
return upper == "PCS" || upper == "PC" || upper == "CTN" || upper == "EA" ||
upper == "PK" || upper == "PKT" || upper == "BOX"
}

/**
* Stock UOM code [PCS/CTN]* denotes pieces-per-carton; displayed stock qty should be whole cartons (integer).
*/
@@ -234,9 +255,9 @@ open class ItemUomService(
return code.startsWith("PCS/CTN", ignoreCase = true)
}

/** After ratio math: 2 decimals, or integer when stock unit is PCS/CTN… */
/** After ratio math: integer HALF_UP for count units (包/箱/PCS…), else 2 decimals. */
private fun finalizePreciseStockQty(stockUnit: ItemUom?, stockQty: BigDecimal): BigDecimal {
if (isPiecesPerCartonStockUomCode(stockUnit)) {
if (isIntegerCountStockUom(stockUnit) || isPiecesPerCartonStockUomCode(stockUnit)) {
return stockQty.setScale(0, RoundingMode.HALF_UP)
}
return stockQty.setScale(2, RoundingMode.HALF_UP)


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt Bestand weergeven

@@ -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 = """


+ 7
- 7
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt Bestand weergeven

@@ -22,13 +22,13 @@ import java.time.LocalDateTime
open class TruckLaneScheduleController @Autowired constructor(
private val truckLaneScheduleService: TruckLaneScheduleService,
) {
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')")
@PostMapping
open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse {
return truckLaneScheduleService.createManual(request)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')")
@PostMapping("/planFromRouteExcel")
@Throws(ServletRequestBindingException::class)
open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> {
@@ -66,19 +66,19 @@ open class TruckLaneScheduleController @Autowired constructor(
return truckLaneScheduleService.getById(id)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')")
@PostMapping("/{id}/cancel")
open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse {
return truckLaneScheduleService.cancel(id)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')")
@PostMapping("/{id}/applyNow")
open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse {
return truckLaneScheduleService.applyNow(id)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')")
@PostMapping("/{id}/retry-failed")
open fun retryFailed(
@PathVariable id: Long,
@@ -87,7 +87,7 @@ open class TruckLaneScheduleController @Autowired constructor(
return truckLaneScheduleService.retryFailed(id, request?.executeAt)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')")
@PostMapping("/{id}/reactivate")
open fun reactivateCancelled(
@PathVariable id: Long,
@@ -124,7 +124,7 @@ open class TruckLaneScheduleController @Autowired constructor(
}
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')")
@PostMapping("/importExcel")
@Throws(ServletRequestBindingException::class)
open fun importExcel(


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderRepository.kt Bestand weergeven

@@ -16,6 +16,8 @@ import org.springframework.data.repository.query.Param
interface PurchaseOrderRepository : AbstractRepository<PurchaseOrder, Long> {
fun findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18datalogId: Serializable): PurchaseOrder?

fun findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code: String): PurchaseOrder?

fun findPurchaseOrderInfoByIdAndDeletedIsFalse(id: Long): PurchaseOrderInfo

fun findByIdAndDeletedFalse(id: Long): Optional<PurchaseOrder>


+ 4
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt Bestand weergeven

@@ -57,9 +57,13 @@ data class StockUomForPoLine (
val id: Long? = null,
val stockUomCode: String? = null,
val stockUomDesc: String? = null,
val stockUomShortDesc: String? = null,
val stockQty: BigDecimal? = null,
val stockRatioN: BigDecimal? = null,
val stockRatioD: BigDecimal? = null,
val purchaseRatioN: BigDecimal? = null,
val purchaseRatioD: BigDecimal? = null,
val sourceRatioN: BigDecimal? = null,
val sourceRatioD: BigDecimal? = null,
val stockQtyInteger: Boolean? = null,
)

+ 42
- 3
src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt Bestand weergeven

@@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
orderDate = it["orderDate"] as LocalDateTime?,
estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?,
supplierName = it["supplierName"] as String?,
escalated = (it["escalated"] as Boolean?) ?: false,
escalated = it["escalated"] == 1L,
)
}
}
@@ -211,8 +211,19 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
sql.append(" group by po.id");
sql.append(" order by po.orderDate desc")
sql.append(" ) r")
if (args.containsKey("itemDetail")){
sql.append(" where r.itemDetail like :itemDetail ");
val outerFilters = mutableListOf<String>()
if (args.containsKey("itemDetail")) {
outerFilters.add("r.itemDetail like :itemDetail")
}
if (args.containsKey("itemCode")) {
outerFilters.add("r.itemCode like :itemCode")
}
if (args.containsKey("itemName")) {
outerFilters.add("r.itemName like :itemName")
}
if (outerFilters.isNotEmpty()) {
sql.append(" where ")
sql.append(outerFilters.joinToString(" AND "))
}
return sql.toString()
}
@@ -294,6 +305,12 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
val itemId = thisPol.item?.id
val qtyM18 = thisPol.qtyM18
val uomM18Id = thisPol.uomM18?.id
val sourceUomId = uomM18Id ?: thisPol.uom?.id
val sourceItemUom = if (itemId != null && sourceUomId != null) {
itemUomService.findFirstByItemIdAndUomId(itemId, sourceUomId)
} else {
null
}
val stockQtyFromM18 = if (itemId != null && qtyM18 != null && uomM18Id != null) {
itemUomService.convertQtyToStockQtyPrecise(itemId, uomM18Id, qtyM18)
} else {
@@ -303,6 +320,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
id = iu?.id,
stockUomCode = iu?.uom?.code,
stockUomDesc = iu?.uom?.udfudesc,
stockUomShortDesc = iu?.uom?.udfShortDesc,
stockQty = if (stockQtyFromM18 > BigDecimal.ZERO) stockQtyFromM18 else {
// fallback to legacy behavior when M18 fields are missing
iu?.item?.id?.let { iId -> itemUomService.convertPurchaseQtyToStockQtyPrecise(iId, (thisPol.qty ?: BigDecimal.ZERO)) } ?: BigDecimal.ZERO
@@ -311,6 +329,9 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
stockRatioD = iu?.ratioD,
purchaseRatioN = purchaseUnit?.ratioN,
purchaseRatioD = purchaseUnit?.ratioD,
sourceRatioN = sourceItemUom?.ratioN ?: purchaseUnit?.ratioN,
sourceRatioD = sourceItemUom?.ratioD ?: purchaseUnit?.ratioD,
stockQtyInteger = itemUomService.isIntegerCountStockUom(iu),
)
} } ?: StockUomForPoLine()
PoLineWithStockInLine(
@@ -350,6 +371,24 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
return purchaseOrderRepository.findTopByM18DataLogIdAndDeletedIsFalseOrderByModifiedDesc(m18DataLogId)
}

open fun findByCode(code: String): PurchaseOrder? {
return purchaseOrderRepository.findTopByCodeAndDeletedIsFalseOrderByModifiedDesc(code)
}

/** JDBC lookup so stock-in skip does not depend on JPA finding the entity. */
open fun findActiveStatusByCode(code: String): String? {
val status = jdbcDao.queryForString(
"""
SELECT status FROM purchase_order
WHERE deleted = 0 AND code = :code
ORDER BY modified DESC
LIMIT 1
""".trimIndent(),
mapOf("code" to code),
)
return status.takeIf { it.isNotBlank() }
}

@Throws(IOException::class)
@Transactional
open fun savePurchaseOrder(request: SavePurchaseOrderRequest): SavePurchaseOrderResponse {


+ 22
- 2
src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt Bestand weergeven

@@ -10,12 +10,14 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder
import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderDataClass
import com.ffii.fpsms.modules.purchaseOrder.entity.projections.PurchaseOrderInfo
import com.ffii.fpsms.m18.service.M18PurchaseOrderService
import com.ffii.fpsms.modules.purchaseOrder.service.PurchaseOrderService
import com.ffii.fpsms.modules.purchaseOrder.web.model.PagingRequest
import com.ffii.fpsms.modules.stock.service.StockInLineService
import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest
import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperPrint
import org.springframework.data.domain.Page
@@ -23,15 +25,20 @@ import org.springframework.data.domain.PageRequest
import org.springframework.web.bind.annotation.*
import java.io.File
import com.ffii.fpsms.modules.purchaseOrder.web.model.PurchaseOrderSummary
import org.slf4j.LoggerFactory

@RestController
@RequestMapping("/po")
class PurchaseOrderController(
private val purchaseOrderService: PurchaseOrderService,
private val stockInLineService: StockInLineService
private val stockInLineService: StockInLineService,
private val m18PurchaseOrderService: M18PurchaseOrderService,
) {
private val logger = LoggerFactory.getLogger(PurchaseOrderController::class.java)
@GetMapping("/list")
fun getPoList(
request: HttpServletRequest
request: HttpServletRequest,
response: HttpServletResponse,
// @RequestParam(required = false) pageNum: Int,
// @RequestParam(required = false) pageSize: Int
): RecordsRes<PurchaseOrderDataClass> {
@@ -40,6 +47,8 @@ class PurchaseOrderController(
val criteriaArgs = CriteriaArgsBuilder.withRequest(request)
.addStringLike("supplier")
.addStringLike("code")
.addStringLike("itemCode")
.addStringLike("itemName")
.addString("status")
.addBoolean("escalated")
.addDate("orderDate")
@@ -50,6 +59,17 @@ class PurchaseOrderController(
// println(criteriaArgs)
val pageSize = request.getParameter("pageSize")?.toIntOrNull()?.coerceAtLeast(1) ?: 10
val pageNum = request.getParameter("pageNum")?.toIntOrNull()?.coerceAtLeast(1) ?: 1
val rawCode = request.getParameter("code")?.trim()
val refreshFromM18 = request.getParameter("refreshFromM18")?.equals("true", ignoreCase = true) == true
if (refreshFromM18 && M18PurchaseOrderService.isStockInLookupPoCode(rawCode)) {
try {
logger.info("Stock-in /po/list: M18 refresh for code={}", rawCode)
val outcome = m18PurchaseOrderService.refreshForStockInLookup(rawCode!!)
response.setHeader("X-M18-Po-Refresh", outcome.action.headerValue)
} catch (e: Exception) {
logger.error("Stock-in /po/list: M18 refresh failed for code={}", rawCode, e)
}
}

val total = purchaseOrderService.getPoListTotalCount(criteriaArgs)
if (total == 0) {


+ 1
- 5
src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt Bestand weergeven

@@ -32,11 +32,7 @@ open class M18BomShopSyncReportService(
): Map<String, Any> {
val start = parseDateStart(syncDateStart)
val end = parseDateEnd(syncDateEnd)
val itemTokens = finishedItemCode
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
.orEmpty()
val itemTokens = ReportMultiValueTokens.split(finishedItemCode)
val sqlItemFilter = itemTokens.singleOrNull()
val statusFilter = syncStatus?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "all" }



+ 41
- 0
src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt Bestand weergeven

@@ -0,0 +1,41 @@
package com.ffii.fpsms.modules.report.service

/**
* Splits report filter values pasted from Excel or typed in a text field.
* Accepts commas, semicolons, Chinese commas, and any whitespace (spaces, tabs, newlines).
*/
object ReportMultiValueTokens {
private val SEPARATORS = Regex("[\\s,;,、\\u00A0\\u3000]+")

fun split(paramValue: String?): List<String> {
if (paramValue.isNullOrBlank()) return emptyList()
return paramValue
.split(SEPARATORS)
.map { it.trim() }
.filter { it.isNotBlank() }
.distinctBy { it.uppercase() }
}

/**
* Full item codes (e.g. FA0123) can use an indexed IN-list.
* Short / wildcard tokens stay as prefix/LIKE matches.
*/
fun partitionExactAndFuzzy(paramValue: String?, exactMinLength: Int = 5): Pair<List<String>, List<String>> {
val tokens = split(paramValue)
val exact = tokens.filter { it.length >= exactMinLength && '%' !in it && '_' !in it }
val fuzzy = tokens.filter { token -> exact.none { it.equals(token, ignoreCase = true) } }
return exact to fuzzy
}

fun combine(itemCode: String?, itemCodePaste: String?, itemCodes: List<String>?): String? {
val joined = buildString {
if (!itemCode.isNullOrBlank()) append(itemCode).append(' ')
if (!itemCodePaste.isNullOrBlank()) append(itemCodePaste).append(' ')
itemCodes?.forEach { code ->
if (code.isNotBlank()) append(code).append(' ')
}
}
val tokens = split(joined)
return tokens.takeIf { it.isNotEmpty() }?.joinToString(",")
}
}

+ 36
- 8
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Bestand weergeven

@@ -4,7 +4,7 @@ import org.springframework.stereotype.Service
import net.sf.jasperreports.engine.*
import net.sf.jasperreports.engine.data.JRMapCollectionDataSource
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.m18.M18GrnRules
import com.ffii.fpsms.modules.master.entity.ShopRepository
@@ -518,8 +518,8 @@ return result
return result
}
/**
* Helper function to build SQL clause for comma-separated values.
* Supports multiple values like "val1, val2, val3" and generates OR conditions with LIKE.
* Helper function to build SQL clause for comma/space-separated values.
* Supports Excel paste like "FA0123 FA2332" or "FA0123,FA2332, FA23238" (LIKE OR).
*/
private fun buildMultiValueLikeClause(
paramValue: String?,
@@ -529,7 +529,7 @@ return result
): String {
if (paramValue.isNullOrBlank()) return ""
val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() }
val values = ReportMultiValueTokens.split(paramValue)
if (values.isEmpty()) return ""
val conditions = values.mapIndexed { index, value ->
@@ -541,6 +541,33 @@ return result
return "AND (${conditions.joinToString(" OR ")})"
}

/**
* Item-code filter that prefers indexed equality (`IN`) for pasted/full codes,
* and prefix LIKE only for short tokens such as "FA".
*/
private fun buildItemCodeFilterClause(
paramValue: String?,
columnName: String,
paramPrefix: String,
args: MutableMap<String, Any>,
): String {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue)
if (exact.isEmpty() && fuzzy.isEmpty()) return ""

val parts = mutableListOf<String>()
exact.forEachIndexed { index, value ->
val paramName = "${paramPrefix}_eq_$index"
args[paramName] = value
parts.add("$columnName = :$paramName")
}
fuzzy.forEachIndexed { index, value ->
val paramName = "${paramPrefix}_like_$index"
args[paramName] = if ('%' in value || '_' in value) value else "$value%"
parts.add("$columnName LIKE :$paramName")
}
return "AND (${parts.joinToString(" OR ")})"
}

/**
* Helper function to build SQL clause for comma-separated values with exact match.
* Supports multiple values like "val1, val2, val3" and generates OR conditions with =.
@@ -678,7 +705,7 @@ return result
} else {
""
}
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args)
val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) {
val formattedDate = lastInDateStart.replace("/", "-")
@@ -1554,9 +1581,9 @@ return result
args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay()

val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args)
val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args)
val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args)
val itemCodeSqlDay = buildItemCodeFilterClause(itemCode, "d.itemCode", "itemCodeDay", args)
val itemCodeSqlSl = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCodeSl", args)
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"""
@@ -1842,6 +1869,7 @@ return result
)
return rows
}

/**
* Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template.
*/


+ 117
- 0
src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt Bestand weergeven

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service

import org.springframework.stereotype.Service
import com.ffii.core.support.JdbcDao
import java.time.LocalDate

@Service
class SemiFGProductionAnalysisReportService(
@@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService(
return jdbcDao.queryForList(sql, args)
}

/**
* Same filters as [searchSemiFGProductionAnalysisReport], one row per item and production date.
* Quantity is not pivoted; the Excel builder places each date in the requested range as its own column.
*/
fun searchSemiFGProductionAnalysisDaily(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
rangeStart: LocalDate,
rangeEnd: LocalDate,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()

val stockCategorySql = if (!itemCode.isNullOrBlank()) {
""
} else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args)
} else {
""
}
val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args)

val yearSql = if (!year.isNullOrBlank() && year != "All") {
args["year"] = year
"AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year"
} else {
""
}

args["lastOutDateStart"] = rangeStart.toString()
args["lastOutDateEnd"] = rangeEnd.toString()
val lastOutDateStartSql =
"AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)"
val lastOutDateEndSql =
"AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)"

val sql = """
WITH qr_agg AS (
SELECT
qr.stockInLineId,
MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed
FROM qc_result qr
WHERE qr.deleted = 0
GROUP BY qr.stockInLineId
),
base AS (
SELECT
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName,
COALESCE(ic.sub, '') AS stockSubCategory,
COALESCE(uc.udfudesc, '') AS unitOfMeasure,
DATE(
CASE
WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate
ELSE si.productionDate
END
) AS prodDate,
si.id AS stockInLineId,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0
ELSE COALESCE(si.acceptedQty, 0)
END AS linePutAwayQty
FROM stock_in_line si
INNER JOIN items it ON si.itemId = it.id
INNER JOIN bom b ON b.code = it.code AND b.deleted = false
LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false
LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id
LEFT JOIN item_category ic ON it.categoryId = ic.id
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
WHERE si.deleted = false
AND si.productLotNo IS NOT NULL
AND TRIM(si.productLotNo) <> ''
AND (
CASE
WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate
ELSE si.productionDate
END
) IS NOT NULL
AND si.jobOrderId IS NOT NULL
AND (si.status IS NULL OR si.status <> 'Pending')
$stockCategorySql
$stockSubCategorySql
$itemCodeSql
$yearSql
$lastOutDateStartSql
$lastOutDateEndSql
),
dedup AS (
SELECT
itemNo,
itemName,
stockSubCategory,
unitOfMeasure,
prodDate,
stockInLineId,
MAX(linePutAwayQty) AS linePutAwayQty
FROM base
GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, prodDate, stockInLineId
)
SELECT
d.itemNo AS itemNo,
MAX(d.itemName) AS itemName,
MAX(d.unitOfMeasure) AS unitOfMeasure,
d.prodDate AS prodDate,
CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS DECIMAL(18,2)) AS qty
FROM dedup d
GROUP BY d.itemNo, d.prodDate
HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0
ORDER BY d.itemNo, d.prodDate
""".trimIndent()

return jdbcDao.queryForList(sql, args)
}

/**
* Gets list of item codes (bom.code) with names based on stockCategory filter.
* Supports multiple categories separated by comma (e.g., "FG,WIP").


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt Bestand weergeven

@@ -99,7 +99,7 @@ class StockItemConsumptionTrendReportService(
// itemCode now contains prefixes (first 2 letters), so we use LIKE to match codes starting with those prefixes
val itemCodeSql = if (!itemCode.isNullOrBlank()) {
val prefixes = itemCode.split(",").map { it.trim() }.filter { it.isNotBlank() }
val prefixes = ReportMultiValueTokens.split(itemCode)
if (prefixes.isNotEmpty()) {
val conditions = prefixes.mapIndexed { index, prefix ->
val paramName = "itemCode_$index"


+ 258
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt Bestand weergeven

@@ -0,0 +1,258 @@
package com.ffii.fpsms.modules.report.service

import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.pdmodel.PDPage
import org.apache.pdfbox.pdmodel.PDPageContentStream
import org.apache.pdfbox.pdmodel.common.PDRectangle
import org.apache.pdfbox.pdmodel.font.PDFont
import org.apache.pdfbox.pdmodel.font.PDType0Font
import org.apache.pdfbox.util.Matrix
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream

/**
* Draws 庫存明細報告 directly. Jasper measured each cell and kept whole item groups
* together, which is what made large PDFs take tens of seconds.
*/
object StockLedgerPdf {
private const val PAGE_W = 842f
private const val PAGE_H = 595f
private const val ROW_H = 13f
private const val BOTTOM = 22f
private const val BODY_SIZE = 8f
private const val HEADER_SIZE = 8f

private val fontBytes: ByteArray by lazy {
StockLedgerPdf::class.java.getResourceAsStream("/fonts/msjh_0.ttf")?.readBytes()
?: error("Font not found: /fonts/msjh_0.ttf")
}

private data class Col(val x: Float, val w: Float, val title: String, val alignRight: Boolean = false)

private val columns = listOf(
Col(11f, 78f, "出入賬日期"),
Col(91f, 58f, "類型"),
Col(151f, 120f, "批號"),
Col(273f, 72f, "到期日"),
Col(347f, 58f, "纍計期初", alignRight = true),
Col(407f, 48f, "入庫", alignRight = true),
Col(457f, 48f, "出庫", alignRight = true),
Col(507f, 62f, "纍計存量", alignRight = true),
Col(571f, 140f, "參考編號"),
Col(713f, 112f, "存貨位置"),
)

fun write(
rows: List<Map<String, Any>>,
reportDate: String,
reportTime: String,
periodStart: String,
periodEnd: String,
): ByteArray {
PDDocument().use { doc ->
val font = PDType0Font.load(doc, ByteArrayInputStream(fontBytes), true)
val canvas = Canvas(doc, font, reportDate, reportTime, periodStart, periodEnd)
if (rows.isEmpty()) {
canvas.ensureRow()
canvas.text(columns[0].x, canvas.y, "-", BODY_SIZE)
} else {
var groupKey: String? = null
var count = 0
var totalIn = ""
var totalOut = ""
var totalBal = ""
fun closeGroup() {
if (count > 1) {
canvas.ensureRow()
canvas.text(250f, canvas.y, "貨品總量:", BODY_SIZE, 95f, alignRight = true)
canvas.text(columns[5].x, canvas.y, totalIn, BODY_SIZE, columns[5].w, alignRight = true)
canvas.text(columns[6].x, canvas.y, totalOut, BODY_SIZE, columns[6].w, alignRight = true)
canvas.text(columns[7].x, canvas.y, totalBal, BODY_SIZE, columns[7].w, alignRight = true)
canvas.advance()
} else if (count == 1) {
canvas.advance(4f)
}
}
for (row in rows) {
val nextItem = row["itemNo"]?.toString().orEmpty()
val nextUom = row["unitOfMeasure"]?.toString().orEmpty()
val nextKey = "$nextItem|$nextUom"
if (nextKey != groupKey) {
closeGroup()
groupKey = nextKey
count = 0
val name = row["itemName"]?.toString().orEmpty()
val itemTitle = listOf(nextItem, name, nextUom).filter { it.isNotBlank() }.joinToString(" ")
canvas.ensureRow()
canvas.text(columns[0].x, canvas.y, itemTitle, BODY_SIZE + 1f)
canvas.advance()
}
canvas.ensureRow()
canvas.cell(0, row["trnDate"])
canvas.cell(1, row["trnRefNo"])
canvas.cell(2, row["lotNo"])
canvas.cell(3, row["expiryDate"])
canvas.cell(4, row["cumOpeningBal"])
canvas.cell(5, row["stockIn"])
canvas.cell(6, row["stockOut"])
canvas.cell(7, row["cumBalance"])
canvas.cell(8, row["orderRefNo"])
canvas.cell(9, row["storeLocation"])
canvas.advance()
count++
totalIn = row["totalStockIn"]?.toString().orEmpty()
totalOut = row["totalStockOut"]?.toString().orEmpty()
totalBal = row["totalCumBalance"]?.toString().orEmpty()
}
closeGroup()
}
canvas.closePage()
canvas.stampPageNumbers()
val out = ByteArrayOutputStream()
doc.save(out)
return out.toByteArray()
}
}

private class Canvas(
private val doc: PDDocument,
private val font: PDFont,
private val reportDate: String,
private val reportTime: String,
private val periodStart: String,
private val periodEnd: String,
) {
private val pages = ArrayList<PDPage>()
private var stream: PDPageContentStream? = null
private var textOpen = false
var y = 0f
private set

init {
newPage()
}

fun ensureRow() {
if (y < BOTTOM + ROW_H) newPage()
}

fun advance(dy: Float = ROW_H) {
y -= dy
}

fun cell(index: Int, value: Any?) {
val col = columns[index]
text(col.x, y, value?.toString().orEmpty(), BODY_SIZE, col.w, col.alignRight)
}

fun text(x: Float, baseline: Float, raw: String, size: Float, width: Float = 700f, alignRight: Boolean = false) {
val value = fit(raw, size, width - 2f)
if (value.isEmpty()) return
openText()
val drawX = if (alignRight) {
x + width - 2f - widthOf(value, size)
} else {
x
}
stream!!.setFont(font, size)
stream!!.setTextMatrix(Matrix(1f, 0f, 0f, 1f, drawX, baseline))
stream!!.showText(value)
}

fun closePage() {
endText()
stream?.close()
stream = null
}

fun stampPageNumbers() {
val total = pages.size
pages.forEachIndexed { index, page ->
PDPageContentStream(doc, page, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
val label = "頁數 ${index + 1} / $total"
cs.beginText()
cs.setFont(font, 9f)
val labelX = PAGE_W - 18f - widthOf(label, 9f)
cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, labelX, PAGE_H - 28f))
cs.showText(label)
cs.endText()
}
}
}

private fun newPage() {
closePage()
val page = PDPage(PDRectangle(PAGE_W, PAGE_H))
doc.addPage(page)
pages.add(page)
stream = PDPageContentStream(doc, page)
y = PAGE_H - 36f
openText()
text(300f, y, "庫存明細報告", 14f)
y -= 18f
text(18f, y, "報告日期:$reportDate 報告時間:$reportTime", 9f)
val period = if (periodStart.isNotBlank() || periodEnd.isNotBlank()) {
"報告期間:${periodStart.ifBlank { "—" }} 至 ${periodEnd.ifBlank { "—" }}"
} else {
""
}
if (period.isNotEmpty()) {
text(420f, y, period, 9f)
}
y -= 16f
endText()
val lineY = y + 4f
stream!!.moveTo(11f, lineY)
stream!!.lineTo(PAGE_W - 16f, lineY)
stream!!.stroke()
y -= 12f
openText()
for (col in columns) {
text(col.x, y, col.title, HEADER_SIZE, col.w, col.alignRight)
}
y -= 4f
endText()
val lineY2 = y
stream!!.moveTo(11f, lineY2)
stream!!.lineTo(PAGE_W - 16f, lineY2)
stream!!.stroke()
y -= 14f
}

private fun openText() {
if (!textOpen) {
stream!!.beginText()
textOpen = true
}
}

private fun endText() {
if (textOpen) {
stream!!.endText()
textOpen = false
}
}

private fun fit(raw: String, size: Float, maxWidth: Float): String {
val cleaned = raw.replace('\n', ' ').replace('\r', ' ')
if (cleaned.isEmpty() || fits(cleaned, size, maxWidth)) return cleaned
var end = cleaned.length
while (end > 0 && widthOf(cleaned.substring(0, end), size) > maxWidth) {
end--
}
return if (end == cleaned.length) cleaned else cleaned.substring(0, end)
}

private fun fits(text: String, size: Float, maxWidth: Float): Boolean {
var units = 0f
for (ch in text) {
units += if (ch.code < 128) 0.56f else 1f
if (units * size > maxWidth) return false
}
return true
}

private fun widthOf(text: String, size: Float): Float =
font.getStringWidth(text) / 1000f * size
}
}

+ 95
- 28
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt Bestand weergeven

@@ -48,20 +48,12 @@ open class StockLedgerReportService(

val endDate = (reportPeriodEnd?.replace("/", "-")
?: LocalDate.now().format(dateFmt))
val filterArgs = mutableMapOf<String, Any>()
val itemPredicate = ledgerItemPredicate(itemCode, filterArgs)
val startDate = if (!reportPeriodStart.isNullOrBlank()) {
reportPeriodStart.replace("/", "-")
} else {
val minDateRow = jdbcDao.queryForList(
"""
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
""".trimIndent(),
emptyMap<String, Any>(),
).firstOrNull()
(minDateRow?.get("firstDate") as? String) ?: endDate
earliestLedgerDate(itemPredicate, filterArgs) ?: endDate
}
val endExclusive = LocalDate.parse(endDate).plusDays(1).format(dateFmt)

@@ -69,13 +61,8 @@ open class StockLedgerReportService(
"reportPeriodStart" to LocalDate.parse(startDate).atStartOfDay(),
"reportPeriodEndExclusive" to LocalDate.parse(endExclusive).atStartOfDay(),
)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCode", args)
args.putAll(filterArgs)
val categorySql = buildCategoryItemFilter(stockCategory, args)
val forceLedgerIndex = if (itemCode.isNullOrBlank()) {
"FORCE INDEX (idx_sl_deleted_date)"
} else {
""
}

val moves = timed("1.ledger", { rows: List<LedgerMove> -> "rows=${rows.size}" }) {
jdbcDao.queryForList(
@@ -93,13 +80,13 @@ open class StockLedgerReportService(
sl.inventoryLotLineId AS inventoryLotLineId,
sl.stockInLineId AS stockInLineId,
sl.stockOutLineId AS stockOutLineId
FROM stock_ledger sl $forceLedgerIndex
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
$itemCodeSql
${itemPredicate.sql}
$categorySql
""".trimIndent(),
args,
@@ -546,21 +533,101 @@ open class StockLedgerReportService(
return out
}

private fun buildMultiValueLikeClause(
/**
* Exact item codes become itemId so MySQL can use
* idx_ledger_item_deleted_date_id (itemId, deleted, date, id).
* Short tokens stay as prefix LIKE. Lot / stock-UOM assembly is unchanged.
*/
private fun ledgerItemPredicate(itemCode: String?, args: MutableMap<String, Any>): LedgerItemPredicate {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode)
if (exact.isEmpty() && fuzzy.isEmpty()) {
return LedgerItemPredicate(
sql = "",
indexHint = "FORCE INDEX (idx_sl_deleted_date)",
)
}
if (fuzzy.isEmpty()) {
val ids = lookupItemIds(exact)
if (ids.isNotEmpty()) {
val names = ids.mapIndexed { index, id ->
val name = "itemId_$index"
args[name] = id
name
}
return LedgerItemPredicate(
sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})",
indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)",
)
}
}
val tokenCount = exact.size + fuzzy.size
return LedgerItemPredicate(
sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args),
indexHint = if (tokenCount == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "",
)
}

private fun lookupItemIds(codes: List<String>): List<Long> {
if (codes.isEmpty()) return emptyList()
val args = HashMap<String, Any>(codes.size)
val names = codes.mapIndexed { index, code ->
val name = "lookupCode_$index"
args[name] = code
":$name"
}
val sql = """
SELECT id AS itemId
FROM items
WHERE code IN (${names.joinToString(", ")})
""".trimIndent()
return jdbcDao.queryForList(sql, args).mapNotNull { row ->
when (val id = row["itemId"]) {
is Number -> id.toLong()
else -> id?.toString()?.toLongOrNull()
}
}.distinct()
}

/** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */
private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map<String, Any>): String? {
val sql = """
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl ${predicate.indexHint}
WHERE sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
${predicate.sql}
""".trimIndent()
val row = jdbcDao.queryForList(sql, args).firstOrNull()
return row?.get("firstDate") as? String
}

private data class LedgerItemPredicate(
val sql: String,
val indexHint: String,
)

/** Full codes use equality; short tokens use prefix LIKE. */
private fun buildItemCodeFilterClause(
paramValue: String?,
columnName: String,
paramPrefix: String,
args: MutableMap<String, Any>,
): String {
if (paramValue.isNullOrBlank()) return ""
val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() }
if (values.isEmpty()) return ""
val conditions = values.mapIndexed { index, value ->
val paramName = "${paramPrefix}_$index"
args[paramName] = "%$value%"
"$columnName LIKE :$paramName"
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue)
if (exact.isEmpty() && fuzzy.isEmpty()) return ""
val parts = mutableListOf<String>()
exact.forEachIndexed { index, value ->
val paramName = "${paramPrefix}_eq_$index"
args[paramName] = value
parts.add("$columnName = :$paramName")
}
fuzzy.forEachIndexed { index, value ->
val paramName = "${paramPrefix}_like_$index"
args[paramName] = if ('%' in value || '_' in value) value else "$value%"
parts.add("$columnName LIKE :$paramName")
}
return "AND (${conditions.joinToString(" OR ")})"
return "AND (${parts.joinToString(" OR ")})"
}

private fun toDouble(v: Any?): Double {


+ 60
- 0
src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt Bestand weergeven

@@ -7,6 +7,8 @@ import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
@@ -45,6 +47,35 @@ class MaterialStockOutTraceabilityReportController(
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false) handler: String?,
): ResponseEntity<ByteArray> =
buildMaterialStockOutTraceabilityPdf(
stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, handler,
)

@PostMapping("/print-material-stock-out-traceability")
fun generateMaterialStockOutTraceabilityReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildMaterialStockOutTraceabilityPdf(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
r.handler,
)
}

private fun buildMaterialStockOutTraceabilityPdf(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
handler: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

@@ -94,6 +125,35 @@ class MaterialStockOutTraceabilityReportController(
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false) handler: String?,
): ResponseEntity<ByteArray> =
buildMaterialStockOutTraceabilityExcel(
stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, handler,
)

@PostMapping("/print-material-stock-out-traceability-excel")
fun exportMaterialStockOutTraceabilityReportExcelPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildMaterialStockOutTraceabilityExcel(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
r.handler,
)
}

private fun buildMaterialStockOutTraceabilityExcel(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
handler: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()



+ 366
- 65
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Bestand weergeven

@@ -23,6 +23,7 @@ import java.time.LocalTime
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.report.service.M18BomShopSyncReportService
import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens
import com.ffii.fpsms.modules.report.service.ReportService
import com.ffii.fpsms.modules.report.service.ShopOrderReplenishmentReportService

@@ -128,6 +129,42 @@ class ReportController(
@RequestParam(required = false) slot: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) poPrefix: String?,
): ResponseEntity<ByteArray> =
buildStockInTraceabilityPdf(
stockCategory, itemCode, lastInDateStart, lastInDateEnd,
storeId, warehouse, area, slot, lotNo, poPrefix,
)

@PostMapping("/print-stock-in-traceability")
fun generateStockInTraceabilityReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildStockInTraceabilityPdf(
r.stockCategory,
r.combinedItemCode(),
r.lastInDateStart,
r.lastInDateEnd,
r.storeId,
r.warehouse,
r.area,
r.slot,
r.lotNo,
r.poPrefix,
)
}

private fun buildStockInTraceabilityPdf(
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
storeId: String?,
warehouse: String?,
area: String?,
slot: String?,
lotNo: String?,
poPrefix: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
@@ -179,6 +216,42 @@ class ReportController(
@RequestParam(required = false) slot: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) poPrefix: String?,
): ResponseEntity<ByteArray> =
buildStockInTraceabilityExcel(
stockCategory, itemCode, lastInDateStart, lastInDateEnd,
storeId, warehouse, area, slot, lotNo, poPrefix,
)

@PostMapping("/print-stock-in-traceability-excel")
fun exportStockInTraceabilityReportExcelPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildStockInTraceabilityExcel(
r.stockCategory,
r.combinedItemCode(),
r.lastInDateStart,
r.lastInDateEnd,
r.storeId,
r.warehouse,
r.area,
r.slot,
r.lotNo,
r.poPrefix,
)
}

private fun buildStockInTraceabilityExcel(
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
storeId: String?,
warehouse: String?,
area: String?,
slot: String?,
lotNo: String?,
poPrefix: String?,
): ResponseEntity<ByteArray> {
val dbData = reportService.searchStockInTraceabilityReport(
stockCategory,
@@ -223,6 +296,31 @@ class ReportController(
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?
): ResponseEntity<ByteArray> =
buildFgDeliveryPdf(stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd)

@PostMapping("/print-fg-delivery-report")
fun generateFGDeliveryReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildFgDeliveryPdf(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildFgDeliveryPdf(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
@@ -270,6 +368,31 @@ class ReportController(
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
): ResponseEntity<ByteArray> =
buildFgDeliveryExcel(stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd)

@PostMapping("/print-fg-delivery-report-excel")
fun exportFGDeliveryReportExcelPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildFgDeliveryExcel(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildFgDeliveryExcel(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = reportService.searchFGDeliveryReport(
stockCategory,
@@ -309,10 +432,154 @@ class ReportController(
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long,
): ResponseEntity<ByteArray> =
generateStockBalancePdf(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
lastInDateStart = lastInDateStart,
lastInDateEnd = lastInDateEnd,
lastOutDateStart = lastOutDateStart,
lastOutDateEnd = lastOutDateEnd,
stockTakeRoundId = stockTakeRoundId,
)

@PostMapping("/print-stock-balance")
fun generateStockBalanceReportPost(
@RequestBody(required = false) req: StockBalanceReportRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: StockBalanceReportRequest()
return generateStockBalancePdf(
stockCategory = r.stockCategory,
itemCode = combinedItemCode(r.itemCode, r.itemCodePaste, r.itemCodes),
stockDate = r.stockDate,
balanceFilterStart = r.balanceFilterStart,
balanceFilterEnd = r.balanceFilterEnd,
storeLocation = r.storeLocation,
lastInDateStart = r.lastInDateStart,
lastInDateEnd = r.lastInDateEnd,
lastOutDateStart = r.lastOutDateStart,
lastOutDateEnd = r.lastOutDateEnd,
stockTakeRoundId = r.stockTakeRoundId ?: 0L,
)
}

@GetMapping("/print-stock-balance-excel")
fun exportStockBalanceReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) stockDate: String?,
@RequestParam(required = false) balanceFilterStart: String?,
@RequestParam(required = false) balanceFilterEnd: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long,
): ResponseEntity<ByteArray> =
generateStockBalanceExcel(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
lastInDateStart = lastInDateStart,
lastInDateEnd = lastInDateEnd,
lastOutDateStart = lastOutDateStart,
lastOutDateEnd = lastOutDateEnd,
stockTakeRoundId = stockTakeRoundId,
)

@PostMapping("/print-stock-balance-excel")
fun exportStockBalanceReportExcelPost(
@RequestBody(required = false) req: StockBalanceReportRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: StockBalanceReportRequest()
return generateStockBalanceExcel(
stockCategory = r.stockCategory,
itemCode = combinedItemCode(r.itemCode, r.itemCodePaste, r.itemCodes),
stockDate = r.stockDate,
balanceFilterStart = r.balanceFilterStart,
balanceFilterEnd = r.balanceFilterEnd,
storeLocation = r.storeLocation,
lastInDateStart = r.lastInDateStart,
lastInDateEnd = r.lastInDateEnd,
lastOutDateStart = r.lastOutDateStart,
lastOutDateEnd = r.lastOutDateEnd,
stockTakeRoundId = r.stockTakeRoundId ?: 0L,
)
}

private fun combinedItemCode(
itemCode: String?,
itemCodePaste: String?,
itemCodes: List<String>?,
): String? = ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes)

private fun loadStockBalanceRows(
stockCategory: String?,
itemCode: String?,
stockDate: String?,
balanceFilterStart: String?,
balanceFilterEnd: String?,
storeLocation: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
stockTakeRoundId: Long,
): List<Map<String, Any>> =
if (!stockDate.isNullOrBlank()) {
reportService.searchStockBalanceReportByDate(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
)
} else {
reportService.searchStockBalanceReport(
stockCategory,
itemCode,
balanceFilterStart,
balanceFilterEnd,
storeLocation,
lastInDateStart,
lastInDateEnd,
lastOutDateStart,
lastOutDateEnd,
stockTakeRoundId,
)
}

private fun generateStockBalancePdf(
stockCategory: String?,
itemCode: String?,
stockDate: String?,
balanceFilterStart: String?,
balanceFilterEnd: String?,
storeLocation: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
stockTakeRoundId: Long,
): ResponseEntity<ByteArray> {
val tokenCount = ReportMultiValueTokens.split(itemCode).size
val parameters = mutableMapOf<String, Any>()
parameters["stockCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["itemNo"] =
when {
itemCode.isNullOrBlank() -> "All"
tokenCount > 30 -> "$tokenCount items"
else -> itemCode
}
parameters["reportDate"] = (stockDate ?: LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
parameters["storeLocation"] = storeLocation ?: ""
@@ -323,35 +590,24 @@ class ReportController(
parameters["lastOutDateStart"] = lastOutDateStart ?: ""
parameters["lastOutDateEnd"] = lastOutDateEnd ?: ""

val dbData =
if (!stockDate.isNullOrBlank()) {
reportService.searchStockBalanceReportByDate(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
)
} else {
reportService.searchStockBalanceReport(
stockCategory,
itemCode,
balanceFilterStart,
balanceFilterEnd,
storeLocation,
lastInDateStart,
lastInDateEnd,
lastOutDateStart,
lastOutDateEnd,
stockTakeRoundId
)
}
val dbData = loadStockBalanceRows(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
lastInDateStart = lastInDateStart,
lastInDateEnd = lastInDateEnd,
lastOutDateStart = lastOutDateStart,
lastOutDateEnd = lastOutDateEnd,
stockTakeRoundId = stockTakeRoundId,
)

val pdfBytes = reportService.createPdfResponse(
"/jasper/StockBalanceReport.jrxml",
parameters,
dbData
dbData,
)

val headers = HttpHeaders().apply {
@@ -359,48 +615,35 @@ class ReportController(
setContentDispositionFormData("attachment", "StockBalanceReport.pdf")
set("filename", "StockBalanceReport.pdf")
}

return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

@GetMapping("/print-stock-balance-excel")
fun exportStockBalanceReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) stockDate: String?,
@RequestParam(required = false) balanceFilterStart: String?,
@RequestParam(required = false) balanceFilterEnd: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false, defaultValue = "0") stockTakeRoundId: Long,
private fun generateStockBalanceExcel(
stockCategory: String?,
itemCode: String?,
stockDate: String?,
balanceFilterStart: String?,
balanceFilterEnd: String?,
storeLocation: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
stockTakeRoundId: Long,
): ResponseEntity<ByteArray> {
val dbData =
if (!stockDate.isNullOrBlank()) {
reportService.searchStockBalanceReportByDate(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
)
} else {
reportService.searchStockBalanceReport(
stockCategory,
itemCode,
balanceFilterStart,
balanceFilterEnd,
storeLocation,
lastInDateStart,
lastInDateEnd,
lastOutDateStart,
lastOutDateEnd,
stockTakeRoundId
)
}
val dbData = loadStockBalanceRows(
stockCategory = stockCategory,
itemCode = itemCode,
stockDate = stockDate,
balanceFilterStart = balanceFilterStart,
balanceFilterEnd = balanceFilterEnd,
storeLocation = storeLocation,
lastInDateStart = lastInDateStart,
lastInDateEnd = lastInDateEnd,
lastOutDateStart = lastOutDateStart,
lastOutDateEnd = lastOutDateEnd,
stockTakeRoundId = stockTakeRoundId,
)

val excelBytes = createStockBalanceExcel(
dbData = dbData,
@@ -898,6 +1141,7 @@ class ReportController(
return out.toByteArray()
}

/** FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 */
private fun createStockBalanceExcel(
dbData: List<Map<String, Any>>,
reportDate: String,
@@ -974,6 +1218,9 @@ class ReportController(
}
val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }
// Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now.
sheet.setColumnHidden(10, true)
sheet.setColumnHidden(11, true)

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }
@@ -992,6 +1239,31 @@ class ReportController(
@RequestParam(required = false) supplier: String?,
@RequestParam(required = false) poCode: String?,
@RequestParam(required = false) grnCode: String?,
): Map<String, Any> =
buildGrnReport(receiptDateStart, receiptDateEnd, itemCode, supplier, poCode, grnCode)

@PostMapping("/grn-report")
fun getGrnReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): Map<String, Any> {
val r = req ?: ReportItemFilterRequest()
return buildGrnReport(
r.receiptDateStart,
r.receiptDateEnd,
r.combinedItemCode(),
r.supplier,
r.poCode,
r.grnCode,
)
}

private fun buildGrnReport(
receiptDateStart: String?,
receiptDateEnd: String?,
itemCode: String?,
supplier: String?,
poCode: String?,
grnCode: String?,
): Map<String, Any> {
val rows = reportService.searchGrnReport(
receiptDateStart,
@@ -1052,6 +1324,19 @@ class ReportController(
syncStatus = syncStatus,
)

@PostMapping("/bom-shop-sync-history")
fun getBomShopSyncHistoryReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): Map<String, Any> {
val r = req ?: ReportItemFilterRequest()
return m18BomShopSyncReportService.searchBomShopSyncHistoryReport(
syncDateStart = r.syncDateStart,
syncDateEnd = r.syncDateEnd,
finishedItemCode = r.combinedFinishedItemCode(),
syncStatus = r.syncStatus,
)
}

/**
* Shop Orders Replenishment Records (店鋪訂單補貨記錄).
* JSON for Excel export: completed replenishments with linked ticket only.
@@ -1130,4 +1415,20 @@ class ReportController(
return mapOf("rows" to rows)
}

}
}

data class StockBalanceReportRequest(
val stockDate: String? = null,
val itemCode: String? = null,
val itemCodePaste: String? = null,
val itemCodes: List<String>? = null,
val stockCategory: String? = null,
val balanceFilterStart: String? = null,
val balanceFilterEnd: String? = null,
val storeLocation: String? = null,
val lastInDateStart: String? = null,
val lastInDateEnd: String? = null,
val lastOutDateStart: String? = null,
val lastOutDateEnd: String? = null,
val stockTakeRoundId: Long? = 0L,
)

+ 39
- 0
src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt Bestand weergeven

@@ -0,0 +1,39 @@
package com.ffii.fpsms.modules.report.web

import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens

/** Shared POST body for report filters that accept pasted item codes. */
data class ReportItemFilterRequest(
val itemCode: String? = null,
val itemCodePaste: String? = null,
val itemCodes: List<String>? = null,
val finishedItemCode: String? = null,
val stockCategory: String? = null,
val stockSubCategory: String? = null,
val year: String? = null,
val lastInDateStart: String? = null,
val lastInDateEnd: String? = null,
val lastOutDateStart: String? = null,
val lastOutDateEnd: String? = null,
val receiptDateStart: String? = null,
val receiptDateEnd: String? = null,
val syncDateStart: String? = null,
val syncDateEnd: String? = null,
val syncStatus: String? = null,
val handler: String? = null,
val supplier: String? = null,
val poCode: String? = null,
val grnCode: String? = null,
val storeId: String? = null,
val warehouse: String? = null,
val area: String? = null,
val slot: String? = null,
val lotNo: String? = null,
val poPrefix: String? = null,
) {
fun combinedItemCode(): String? =
ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes)

fun combinedFinishedItemCode(): String? =
ReportMultiValueTokens.combine(finishedItemCode, itemCodePaste, itemCodes)
}

+ 303
- 0
src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt Bestand weergeven

@@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService
import com.ffii.fpsms.modules.report.service.ReportService
import org.apache.poi.ss.usermodel.BorderStyle
@@ -46,6 +47,33 @@ class SemiFGProductionAnalysisReportController(
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?
): ResponseEntity<ByteArray> =
buildSemiFgProductionAnalysisPdf(
stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd,
)

@PostMapping("/print-semi-fg-production-analysis")
fun generateSemiFGProductionAnalysisReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildSemiFgProductionAnalysisPdf(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildSemiFgProductionAnalysisPdf(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
@@ -94,6 +122,33 @@ class SemiFGProductionAnalysisReportController(
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?
): ResponseEntity<ByteArray> =
buildSemiFgProductionAnalysisExcel(
stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd,
)

@PostMapping("/print-semi-fg-production-analysis-excel")
fun exportSemiFGProductionAnalysisReportExcelPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildSemiFgProductionAnalysisExcel(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildSemiFgProductionAnalysisExcel(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport(
stockCategory,
@@ -119,6 +174,243 @@ class SemiFGProductionAnalysisReportController(
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

@PostMapping("/print-semi-fg-production-analysis-excel-daily")
fun exportSemiFGProductionAnalysisReportExcelDaily(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
val start = parseReportDate(r.lastOutDateStart)
val end = parseReportDate(r.lastOutDateEnd)
if (start == null || end == null) {
return jsonError(HttpStatus.BAD_REQUEST, "請先輸入完成生產日期(由、至)")
}
if (end.isBefore(start)) {
return jsonError(HttpStatus.BAD_REQUEST, "完成生產日期「由」不可晚於「至」")
}
val dayCount = ChronoUnit.DAYS.between(start, end) + 1
if (dayCount > MAX_DAILY_COLUMNS) {
return jsonError(HttpStatus.BAD_REQUEST, "日期範圍不可超過 $MAX_DAILY_COLUMNS 日")
}

val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisDaily(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
start,
end,
)
val dates = generateSequence(start) { previous ->
val next = previous.plusDays(1)
if (next.isAfter(end)) null else next
}.toList()
val excelBytes = createSemiFGProductionAnalysisDailyExcel(
dbData = dbData,
reportTitle = "成品/半成品生產分析報告",
dates = dates,
)
val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport_Daily.xlsx")
set("filename", "SemiFGProductionAnalysisReport_Daily.xlsx")
}
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

private fun createSemiFGProductionAnalysisDailyExcel(
dbData: List<Map<String, Any>>,
reportTitle: String,
dates: List<LocalDate>,
): ByteArray {
val workbook = XSSFWorkbook()
val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle)
val sheet = workbook.createSheet(safeSheetName)
val dateFormat = DateTimeFormatter.ISO_LOCAL_DATE
val columnDateFormat = DateTimeFormatter.ofPattern(
if (dates.first().year == dates.last().year) "d/M" else "d/M/yy",
)
val fixedColumns = 3
val totalColumns = fixedColumns + dates.size + 1
var rowIndex = 0

val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
}
val titleFont = workbook.createFont().apply {
bold = true
fontHeightInPoints = 16
}
titleStyle.setFont(titleFont)

val infoStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
}

val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
wrapText = true
}
val headerFont = workbook.createFont().apply { bold = true }
headerStyle.setFont(headerFont)

val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

val numberStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0")
}

val dashStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

val titleRow = sheet.createRow(rowIndex++)
titleRow.heightInPoints = 28f
val titleCell = titleRow.createCell(0)
titleCell.setCellValue(reportTitle)
titleCell.cellStyle = titleStyle
if (totalColumns > 1) {
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))
}

val rangeLabel = "${dates.first().format(dateFormat)} 至 ${dates.last().format(dateFormat)}"
val infoRow = sheet.createRow(rowIndex++)
val infoCell = infoRow.createCell(0)
infoCell.setCellValue(
"報告日期:${LocalDate.now().format(dateFormat)} " +
"報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))} " +
"完成生產日期:$rangeLabel"
)
infoCell.cellStyle = infoStyle
if (totalColumns > 1) {
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1))
}

rowIndex++

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headerRow.heightInPoints = 32f
val headers = buildList {
add("貨品編號")
add("貨品名稱")
add("單位")
dates.forEach { add(it.format(columnDateFormat)) }
add("上架總計")
}
headers.forEachIndexed { index, name ->
val cell = headerRow.createCell(index)
cell.setCellValue(name)
cell.cellStyle = headerStyle
}

val items = pivotDailyRows(dbData)
if (items.isEmpty()) {
val dataRow = sheet.createRow(rowIndex++)
setTextCell(dataRow, 0, "-", textStyle)
setTextCell(dataRow, 1, "-", textStyle)
setTextCell(dataRow, 2, "-", textStyle)
for (col in fixedColumns until totalColumns) {
setDashCell(dataRow, col, dashStyle)
}
} else {
items.forEach { item ->
val dataRow = sheet.createRow(rowIndex++)
setTextCell(dataRow, 0, item.itemNo.ifBlank { "-" }, textStyle)
setTextCell(dataRow, 1, item.itemName.ifBlank { "-" }, textStyle)
setTextCell(dataRow, 2, item.unitOfMeasure.ifBlank { "-" }, textStyle)
var total = 0.0
dates.forEachIndexed { index, date ->
val qty = item.qtyByDate[date] ?: 0.0
total += qty
setNumberCell(dataRow, fixedColumns + index, qty, numberStyle, dashStyle)
}
setNumberCell(dataRow, totalColumns - 1, total, numberStyle, dashStyle)
}
}

sheet.createFreezePane(fixedColumns, headerRowIndex + 1)
sheet.setColumnWidth(0, 18 * 256)
sheet.setColumnWidth(1, 24 * 256)
sheet.setColumnWidth(2, 12 * 256)
for (col in fixedColumns until totalColumns) {
sheet.setColumnWidth(col, 12 * 256)
}

val output = ByteArrayOutputStream()
workbook.use { it.write(output) }
return output.toByteArray()
}

private fun pivotDailyRows(dbData: List<Map<String, Any>>): List<DailyItemQty> {
val byItem = linkedMapOf<String, DailyItemQty>()
dbData.forEach { row ->
val itemNo = row["itemNo"]?.toString()?.trim().orEmpty()
val prodDate = toLocalDate(row["prodDate"]) ?: return@forEach
val qty = parseNumericForExcelChart(row["qty"])
val item = byItem.getOrPut(itemNo) {
DailyItemQty(
itemNo = itemNo,
itemName = row["itemName"]?.toString().orEmpty(),
unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(),
qtyByDate = linkedMapOf(),
)
}
item.qtyByDate[prodDate] = (item.qtyByDate[prodDate] ?: 0.0) + qty
}
return byItem.values.toList()
}

private fun parseReportDate(raw: String?): LocalDate? {
if (raw.isNullOrBlank()) return null
val normalized = raw.trim().replace("/", "-")
return runCatching { LocalDate.parse(normalized) }.getOrNull()
?: runCatching { LocalDate.parse(normalized, DateTimeFormatter.ofPattern("dd-MM-yyyy")) }.getOrNull()
}

private fun toLocalDate(value: Any?): LocalDate? = when (value) {
is LocalDate -> value
is java.sql.Date -> value.toLocalDate()
is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate()
is java.time.LocalDateTime -> value.toLocalDate()
is java.util.Date -> value.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate()
is String -> parseReportDate(value.take(10))
else -> null
}

private fun jsonError(status: HttpStatus, message: String): ResponseEntity<ByteArray> {
val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"")
return ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body("""{"message":"$escaped"}""".toByteArray(Charsets.UTF_8))
}

private fun createSemiFGProductionAnalysisExcel(
dbData: List<Map<String, Any>>,
reportTitle: String,
@@ -463,4 +755,15 @@ class SemiFGProductionAnalysisReportController(
val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory)
return ResponseEntity(itemCodesWithCategory, HttpStatus.OK)
}

private data class DailyItemQty(
val itemNo: String,
val itemName: String,
val unitOfMeasure: String,
val qtyByDate: MutableMap<LocalDate, Double>,
)

companion object {
private const val MAX_DAILY_COLUMNS = 366
}
}

+ 46
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt Bestand weergeven

@@ -45,6 +45,29 @@ class StockItemConsumptionTrendReportController(
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?
): ResponseEntity<ByteArray> =
buildStockItemConsumptionTrendPdf(stockCategory, itemCode, year, lastOutDateStart, lastOutDateEnd)

@PostMapping("/print-stock-item-consumption-trend")
fun generateStockItemConsumptionTrendReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildStockItemConsumptionTrendPdf(
r.stockCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildStockItemConsumptionTrendPdf(
stockCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
try {
val parameters = mutableMapOf<String, Any>()
@@ -98,6 +121,29 @@ class StockItemConsumptionTrendReportController(
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?
): ResponseEntity<ByteArray> =
buildStockItemConsumptionTrendExcel(stockCategory, itemCode, year, lastOutDateStart, lastOutDateEnd)

@PostMapping("/print-stock-item-consumption-trend-excel")
fun exportStockItemConsumptionTrendReportExcelPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildStockItemConsumptionTrendExcel(
r.stockCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildStockItemConsumptionTrendExcel(
stockCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = stockItemConsumptionTrendReportService.searchStockItemConsumptionTrendReport(
stockCategory = stockCategory,


+ 70
- 36
src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt Bestand weergeven

@@ -5,8 +5,9 @@ import org.springframework.http.*
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens
import com.ffii.fpsms.modules.report.service.StockLedgerPdf
import com.ffii.fpsms.modules.report.service.StockLedgerReportService
import com.ffii.fpsms.modules.report.service.ReportService
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.DataFormat
@@ -24,7 +25,6 @@ import java.io.ByteArrayOutputStream
@RestController
@RequestMapping("/report")
class StockLedgerReportController(
private val reportService: ReportService,
private val stockLedgerReportService: StockLedgerReportService,
) {
private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java)
@@ -50,23 +50,55 @@ class StockLedgerReportController(
@RequestParam(required = false) storeLocation: String?,
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> =
generateStockLedgerPdf(stockCategory, itemCode, storeLocation, reportPeriodStart, reportPeriodEnd)

@PostMapping("/print-stock-ledger")
fun generateStockLedgerReportPost(
@RequestBody(required = false) req: StockLedgerReportRequest?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
val r = req ?: StockLedgerReportRequest()
return generateStockLedgerPdf(
stockCategory = r.stockCategory,
itemCode = ReportMultiValueTokens.combine(r.itemCode, r.itemCodePaste, r.itemCodes),
storeLocation = r.storeLocation,
reportPeriodStart = r.lastInDateStart,
reportPeriodEnd = r.lastInDateEnd,
)
}

parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
/** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-stock-ledger-excel")
fun exportStockLedgerReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> =
generateStockLedgerExcel(stockCategory, itemCode, storeLocation, reportPeriodStart, reportPeriodEnd)

parameters["storeLocation"] = storeLocation ?: ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["reportPeriodStart"] = reportPeriodStart ?: ""
parameters["reportPeriodEnd"] = reportPeriodEnd ?: ""
@PostMapping("/print-stock-ledger-excel")
fun exportStockLedgerReportExcelPost(
@RequestBody(required = false) req: StockLedgerReportRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: StockLedgerReportRequest()
return generateStockLedgerExcel(
stockCategory = r.stockCategory,
itemCode = ReportMultiValueTokens.combine(r.itemCode, r.itemCodePaste, r.itemCodes),
storeLocation = r.storeLocation,
reportPeriodStart = r.lastInDateStart,
reportPeriodEnd = r.lastInDateEnd,
)
}

val tSearch = System.nanoTime()
private fun generateStockLedgerPdf(
stockCategory: String?,
itemCode: String?,
storeLocation: String?,
reportPeriodStart: String?,
reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
@@ -74,19 +106,13 @@ class StockLedgerReportController(
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)
val searchMs = (System.nanoTime() - tSearch) / 1_000_000
val tPdf = System.nanoTime()
val pdfBytes = reportService.createPdfResponse(
"/jasper/StockLedgarReport.jrxml",
parameters,
dbData,
)
log.info(
"stock-ledger pdf rows={} search={}ms pdf={}ms bytes={}",
dbData.size,
searchMs,
(System.nanoTime() - tPdf) / 1_000_000,
pdfBytes.size,

val pdfBytes = StockLedgerPdf.write(
rows = dbData,
reportDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
reportTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),
periodStart = reportPeriodStart?.trim().orEmpty(),
periodEnd = reportPeriodEnd?.trim().orEmpty(),
)

val headers = HttpHeaders().apply {
@@ -97,14 +123,12 @@ class StockLedgerReportController(
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 65 | v1.0.0 | 2026-08-11 */
@GetMapping("/print-stock-ledger-excel")
fun exportStockLedgerReportExcel(
@RequestParam(required = false) stockCategory: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) storeLocation: String?,
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
private fun generateStockLedgerExcel(
stockCategory: String?,
itemCode: String?,
storeLocation: String?,
reportPeriodStart: String?,
reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val tSearch = System.nanoTime()
val dbData = stockLedgerReportService.searchStockLedgerReport(
@@ -456,3 +480,13 @@ class StockLedgerReportController(
}
}
}

data class StockLedgerReportRequest(
val lastInDateStart: String? = null,
val lastInDateEnd: String? = null,
val itemCode: String? = null,
val itemCodePaste: String? = null,
val itemCodes: List<String>? = null,
val stockCategory: String? = null,
val storeLocation: String? = null,
)

+ 4
- 1
src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt Bestand weergeven

@@ -191,7 +191,7 @@ class StockLotOnhandReportController(
return v
}

/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */
/** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.2 | 2026-09-28 */
private fun createExcel(
dbData: List<Map<String, Any>>,
reportDate: String,
@@ -310,6 +310,9 @@ class StockLotOnhandReportController(
sheet.createFreezePane(0, headerRowIndex + 1)
intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14)
.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }
// Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now.
sheet.setColumnHidden(13, true)
sheet.setColumnHidden(14, true)

val out = ByteArrayOutputStream()
workbook.write(out)


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java Bestand weergeven

@@ -30,6 +30,18 @@ public class SettingsService extends AbstractIdEntityService<Settings, Long, Set
return this.repository.findByName(name);
}

/**
* Reads {@code settings.value} with JDBC on every call (no JPA persistence-context reuse).
* True only when the trimmed value equals {@link Settings#VALUE_BOOLEAN_TRUE} (ignore case).
*/
public boolean isBooleanTrueFromDb(String name) {
String value = this.jdbcDao.queryForString(
"SELECT value FROM settings WHERE name = :name LIMIT 1",
java.util.Map.of("name", name));
return StringUtils.isNotBlank(value)
&& Settings.VALUE_BOOLEAN_TRUE.equalsIgnoreCase(value.trim());
}

public List<Settings> findAllByCategory(String category) {
return this.repository.findAllByCategory(category);
}


+ 13
- 0
src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java Bestand weergeven

@@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web;
import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController;

import com.ffii.core.exception.BadRequestException;
import com.ffii.core.exception.NotFoundException;
import com.ffii.fpsms.modules.common.SettingNames;
import com.ffii.fpsms.modules.settings.entity.Settings;
import com.ffii.fpsms.modules.settings.service.SettingsService;

@@ -53,6 +57,9 @@ public class SettingsController{
}

private void applyUpdate(String name, UpdateReq body) {
if (SettingNames.STOCK_TAKE_QTY_GAP_WARN_PERCENT.equals(name) && !isAdmin()) {
throw new AccessDeniedException("ADMIN only");
}
Settings entity = this.settingsService.findByName(name)
.orElseThrow(NotFoundException::new);
if (!this.settingsService.validateType(entity.getType(), body.getValue())) {
@@ -63,6 +70,12 @@ public class SettingsController{
this.settingsService.save(entity);
}

private static boolean isAdmin() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) return false;
return auth.getAuthorities().stream().anyMatch(a -> "ADMIN".equals(a.getAuthority()));
}

public static class UpdateReq {
@NotBlank
private String value;


+ 13
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Bestand weergeven

@@ -437,4 +437,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>
}

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt Bestand weergeven

@@ -81,6 +81,10 @@ open class StockTakeRecord : BaseEntity<Long>() {
@Column(name = "lastSelect")
open var lastSelect: Int? = null

/** How many times the picker has changed an already saved first count. Max 2. */
@Column(name = "pickerReviseCount", nullable = false)
open var pickerReviseCount: Int? = 0

@Column(name = "approverTime")
open var approverTime: LocalDateTime? = null
@Column(name = "date", nullable = false)


+ 8
- 5
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt Bestand weergeven

@@ -67,12 +67,15 @@ interface StockInLineInfo {

interface PutAwayLineForSil {
val id: Long?;
/** Stock qty (inventory lot line inQty) */
@get:Value("#{target.inQty}")
/**
* FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18
* Origin putaway qty from inventory_lot.stockInLine (lot line → lot → SIL).
* Do not use inventory_lot_line.inQty: later ADJ/TKE is added on the same line.
*/
@get:Value("#{target.inventoryLot?.stockInLine?.acceptedQty}")
val stockQty: BigDecimal?;
@get:Value("#{target.inQty " +
"* ((target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD))" +
"/ ((target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioN / target.inventoryLot.item.itemUoms.^[purchaseUnit == true && deleted == false]?.ratioD))}")
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */
@get:Value("#{target.inventoryLot?.stockInLine?.acceptedQtyM18 ?: target.inventoryLot?.stockInLine?.acceptedQty}")
val qty: BigDecimal?;
@get:Value("#{target.warehouse?.code} - #{target.warehouse?.name}")
val warehouse: String?;


+ 11
- 4
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Bestand weergeven

@@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.service

import com.ffii.core.response.RecordsRes
import com.ffii.core.utils.PdfUtils
import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.master.entity.WarehouseRepository
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
@@ -45,6 +44,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse
import com.ffii.fpsms.modules.jobOrder.service.JobOrderService
import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest
import com.ffii.fpsms.modules.master.service.PrinterService
import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest
import com.ffii.core.utils.ZebraPrinterUtil
@@ -60,6 +60,7 @@ open class InventoryLotLineService(
private val itemUomRespository: ItemUomRespository,
private val stockInLineRepository: StockInLineRepository,
private val printerService: PrinterService,
private val settingsService: SettingsService,
@Lazy
private val jobOrderService: JobOrderService
) {
@@ -257,7 +258,8 @@ open class InventoryLotLineService(
@Throws(IOException::class)
@Transactional
open fun exportStockInLineQrcode(request: LotLineToQrcode): Map<String, Any> {
val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml"
val labelVersion = PoItemLabelTemplate.version(settingsService)
val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion)
val resource = ClassPathResource(QRCODE_PDF)
if (!resource.exists()) {
println("here")
@@ -297,13 +299,18 @@ open class InventoryLotLineService(
field["acceptedQty"] = "%.2f".format(info.acceptedQty)

val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId)
field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A"
val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A"
val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc
field["uom"] = fullUom
val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom)
field["qtyLabel"] = qtyLabel
field["qtyDetail"] = qtyDetail

field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: ""
field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: ""
field["lotNo"] = info.lotNo!!
field["supplier"] = info.supplier ?: "N/A"
val image = QrCodeUtil.generateQRCodeImage(qrCodeContent)
val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion)
field["qrCode"] = image
fields.add(field)
}


+ 66
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt Bestand weergeven

@@ -0,0 +1,66 @@
package com.ffii.fpsms.modules.stock.service

import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.common.SettingNames
import com.ffii.fpsms.modules.settings.service.SettingsService
import java.awt.image.BufferedImage

object PoItemLabelTemplate {
const val V1 = "qrCodeLabel/poItemPDF.jrxml"
const val V2 = "qrCodeLabel/poItemPDF_v2.jrxml"
const val V3 = "qrCodeLabel/poItemPDF_v3.jrxml"
const val V4 = "qrCodeLabel/poItemPDF_v4.jrxml"

fun version(settingsService: SettingsService): Int {
val raw = settingsService.findByName(SettingNames.LABEL_PO_ITEM_VERSION)
.map { it.value }
.orElse("1")
?.trim()
return when (raw) {
"2" -> 2
"3" -> 3
"4" -> 4
else -> 1
}
}

fun path(version: Int): String = when (version) {
2 -> V2
3 -> V3
4 -> V4
else -> V1
}

fun qrImage(content: String, version: Int): BufferedImage =
if (version == 1) QrCodeUtil.generateQRCodeImage(content)
else QrCodeUtil.generateQRCodeImage(content, 500, 1)

/** First line is qty + short unit. Second line is the full unit only when it differs. */
fun qtyLines(qtyRaw: String?, fullRaw: String?, shortRaw: String?): Pair<String, String> {
val qty = stripTrailingZeros(qtyRaw)
val full = blankUnit(fullRaw)
val short = blankUnit(shortRaw)
val same = short.isEmpty() || full.isEmpty() || short.equals(full, ignoreCase = true)
if (same) {
val unit = short.ifEmpty { full }
val line = when {
unit.isEmpty() -> qty
unit.first().isDigit() -> listOf(qty, unit).filter { it.isNotEmpty() }.joinToString(" ")
else -> qty + unit
}
return line to ""
}
return (qty + short) to full
}

private fun blankUnit(raw: String?): String {
val text = raw?.trim().orEmpty()
return if (text.isEmpty() || text.equals("N/A", ignoreCase = true)) "" else text
}

private fun stripTrailingZeros(raw: String?): String {
val text = raw?.trim().orEmpty()
if (!text.contains('.')) return text
return text.replace(Regex("0+$"), "").removeSuffix(".")
}
}

+ 22
- 11
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Bestand weergeven

@@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.service
import com.ffii.core.exception.BadRequestException
import com.ffii.core.support.AbstractBaseEntityService
import com.ffii.core.support.JdbcDao
import com.ffii.core.utils.QrCodeUtil
import com.ffii.fpsms.modules.common.CodeGenerator
import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.entity.QcItemRepository
@@ -34,6 +33,7 @@ import com.ffii.fpsms.modules.master.entity.WarehouseRepository
import com.ffii.fpsms.modules.master.service.ItemUomService
//import com.ffii.fpsms.modules.master.service.BomOutputQtyService
import com.ffii.fpsms.modules.master.service.PrinterService
import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository
@@ -99,6 +99,7 @@ open class StockInLineService(
private val itemUomRepository: ItemUomRespository,
private val itemUomService: ItemUomService,
private val printerService: PrinterService,
private val settingsService: SettingsService,
private val stockTakeLineRepository: StockTakeLineRepository,
private val inventoryLotLineService: InventoryLotLineService,
private val deliveryOrderRepository: DeliveryOrderRepository,
@@ -447,6 +448,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
@@ -655,13 +658,15 @@ open class StockInLineService(
open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder {
val pols = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(po.id!!)

var newStatus = PurchaseOrderStatus.PENDING
if (pols.isNotEmpty()) {
newStatus = if (pols.any { pol-> pol.status != PurchaseOrderLineStatus.COMPLETED }) {
PurchaseOrderStatus.RECEIVING
} else {
// A line that was never received, or whose stock-in rows were all removed before putaway, is pending.
// Receiving only while some line is still receiving or already completed.
val newStatus = when {
pols.isEmpty() || pols.all { it.status == null || it.status == PurchaseOrderLineStatus.PENDING } ->
PurchaseOrderStatus.PENDING
pols.all { it.status == PurchaseOrderLineStatus.COMPLETED } ->
PurchaseOrderStatus.COMPLETED
}
else ->
PurchaseOrderStatus.RECEIVING
}
po.apply {
status = newStatus
@@ -1214,7 +1219,8 @@ open class StockInLineService(
@Throws(IOException::class)
@Transactional
open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map<String, Any> {
val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml"
val labelVersion = PoItemLabelTemplate.version(settingsService)
val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion)
val resource = ClassPathResource(QRCODE_PDF)
if (!resource.exists()) {
println("here")
@@ -1248,12 +1254,17 @@ open class StockInLineService(
field["poCode"] = info.poCode ?: "N/A"
field["itemType"] = info.itemType ?: "N/A"
field["acceptedQty"] = "%.2f".format(acceptedQty)
field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A"
field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: ""
val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A"
val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc
field["uom"] = fullUom
val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom)
field["qtyLabel"] = qtyLabel
field["qtyDetail"] = qtyDetail
field["productionDate"] = (info.productionDate ?: info.receiptDate)?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: ""
field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: ""
field["lotNo"] = info.lotNo ?: "N/A"
field["supplier"] = info.supplier ?: "N/A"
val image = QrCodeUtil.generateQRCodeImage(qrCodeContent)
val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion)
field["qrCode"] = image
fields.add(field)
}


+ 57
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Bestand weergeven

@@ -806,6 +806,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -1066,6 +1067,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -1176,6 +1178,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseArea = warehouse?.area,
warehouseName = warehouse?.name,
@@ -1198,6 +1201,7 @@ open class StockTakeRecordService(
stockTakerName = rec.stockTakerName,
stockTakeEndTime = rec.stockTakeEndTime,
approverTime = rec.approverTime,
pickerReviseCount = rec.pickerReviseCount ?: 0,
)
}

@@ -1271,6 +1275,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseArea = warehouse?.area,
warehouseName = warehouse?.name,
@@ -1293,6 +1298,7 @@ open class StockTakeRecordService(
stockTakerName = stockTakeRecord?.stockTakerName,
stockTakeEndTime = stockTakeRecord?.stockTakeEndTime,
approverTime = stockTakeRecord?.approverTime,
pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0,
)
}

@@ -1339,12 +1345,59 @@ open class StockTakeRecordService(
.subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO)
//.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO)

if (request.reviseFirstQty && request.stockTakeRecordId == null) {
throw IllegalArgumentException("Stock take record ID is required")
}

// 3. 新建、更新預建記錄之第一次盤點、或第二次盤點
val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) {
val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId)
?: throw IllegalArgumentException("Stock take record not found: ${request.stockTakeRecordId}")

val headerStatus = stockTake.status
val headerStillOpen = headerStatus == StockTakeStatus.PENDING ||
headerStatus == StockTakeStatus.STOCKTAKING ||
headerStatus == StockTakeStatus.APPROVING
val revisingOpenPass = existingRecord.pickerFirstStockTakeQty != null &&
existingRecord.pickerSecondStockTakeQty == null &&
existingRecord.approverStockTakeQty == null &&
existingRecord.status.equals("pass", ignoreCase = true) &&
headerStillOpen
when {
// 已盤點的列在區域「已完成」前可改第一次數量。列狀態已完成、或區域已完成,不能改。
request.reviseFirstQty || revisingOpenPass -> {
if (!headerStillOpen) {
throw IllegalArgumentException("Cannot change counted qty after the section is completed")
}
if (!existingRecord.status.equals("pass", ignoreCase = true)) {
throw IllegalArgumentException("Only counted records can be changed")
}
if (existingRecord.pickerFirstStockTakeQty == null) {
throw IllegalArgumentException("No first count to change")
}
if (existingRecord.pickerSecondStockTakeQty != null || existingRecord.approverStockTakeQty != null) {
throw IllegalArgumentException("Cannot change the first count after a later count exists")
}
val reviseCount = existingRecord.pickerReviseCount ?: 0
if (reviseCount >= 2) {
throw IllegalArgumentException("This count can only be changed 2 times")
}
val varianceQty = availableQty - request.qty - request.badQty
existingRecord.apply {
this.pickerFirstStockTakeQty = request.qty
this.pickerFirstBadQty = request.badQty
this.bookQty = availableQty
this.varianceQty = varianceQty
this.stockTakerId = stockTakerId
this.stockTakerName = user?.name
this.status = "pass"
this.pickerSecondStockTakeQty = null
this.pickerSecondBadQty = null
this.stockTakeEndTime = null
this.pickerReviseCount = reviseCount + 1
}
existingRecord
}
existingRecord.pickerFirstStockTakeQty == null -> {
val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty
val varianceQty = availableQty - request.qty - request.badQty
@@ -1364,7 +1417,8 @@ open class StockTakeRecordService(
}
existingRecord
}
existingRecord.pickerSecondStockTakeQty == null -> {
existingRecord.pickerSecondStockTakeQty == null &&
existingRecord.status.equals("notMatch", ignoreCase = true) -> {
val varianceQty = availableQty - request.qty - request.badQty
existingRecord.apply {
this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id)
@@ -2888,6 +2942,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -2988,6 +3043,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,


+ 7
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt Bestand weergeven

@@ -78,6 +78,10 @@ data class InventoryLotDetailResponse(
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
val approverTime: LocalDateTime? = null,
val lastSelect: Int? = null,
/** Stock UoM short label (e.g. 包), shown beside the count input. */
val uomShortDesc: String? = null,
/** Times the picker has changed this saved first count. */
val pickerReviseCount: Int? = 0,
)
data class InventoryLotLineListRequest(
val warehouseCode: String
@@ -88,7 +92,9 @@ data class SaveStockTakeRecordRequest(
val qty: BigDecimal, // QTY(第一次或第二次,后端判断)
val badQty: BigDecimal,
// val stockTakerName: String,
val remark: String? = null
val remark: String? = null,
/** true = 區域仍在盤點中時,覆寫已盤點的第一次數量;不寫入第二次盤點 */
val reviseFirstQty: Boolean = false,
)

/**


+ 3
- 2
src/main/resources/application-prod.yml Bestand weergeven

@@ -3,9 +3,10 @@ fpsms:
monitoring:
enabled: true

# Session length in production; frontend can call /refresh-token before expiry to stay logged in.
# Session length in production; frontend refreshes via /refresh-token before expiry.
# Floor staff often work ~08:00–22:00 (~14h); 16h access covers one shift without mid-day kickout.
jwt:
expiration-minutes: 480 # 8 hours access token (was 30 min); increase if users need longer AFK
expiration-minutes: 960 # 16 hours access token (was 480 / 8h — users logged out ~16:30 after morning login)
refresh-expiration-days: 7
# Required for stable JWT across restarts; set JWT_SECRET in the deployment environment (long random string).
secret: ${JWT_SECRET:fpsms-dev-jwt-signing-secret-change-for-production-use-long-random-JWT_SECRET}


+ 2
- 1
src/main/resources/application.yml Bestand weergeven

@@ -117,8 +117,9 @@ spring:
dialect:
storage_engine: innodb

# JWT: access token expiry and refresh token expiry. Frontend should call /refresh-token before access token expires.
# JWT: access token expiry and refresh token expiry. Frontend calls /refresh-token before access token expires.
# Signing key must be STABLE across server restarts (do not use a random key per boot). Override with env JWT_SECRET in production.
# Prod overrides to 16h access (floor shifts ~08:00–22:00); see application-prod.yml.
jwt:
expiration-minutes: 14400 # access token: 10 days (default); override in application-prod for shorter session
refresh-expiration-days: 30 # refresh token validity (days)


+ 14
- 0
src/main/resources/db/changelog/changes/20260918_po_stockin_refresh_if_newer/01_setting.sql Bestand weergeven

@@ -0,0 +1,14 @@
--liquibase formatted sql
--changeset fai:20260918_po_stockin_refresh_if_newer_setting

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT v.name, v.value, v.category, v.type
FROM (
SELECT 'M18.po.stockIn.refreshIfNewer.enabled' AS name,
'false' AS value,
'M18' AS category,
'boolean' AS type
) v
WHERE NOT EXISTS (
SELECT 1 FROM `settings` s WHERE s.name = 'M18.po.stockIn.refreshIfNewer.enabled'
);

+ 9
- 0
src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql Bestand weergeven

@@ -0,0 +1,9 @@
--liquibase formatted sql
--changeset fpsms:20260923_stock_take_qty_gap_warn_percent

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT 'STOCK_TAKE.qtyGapWarnPercent', '50', 'STOCK_TAKE', 'integer'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `settings` WHERE `name` = 'STOCK_TAKE.qtyGapWarnPercent'
);

+ 8
- 0
src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql Bestand weergeven

@@ -0,0 +1,8 @@
--liquibase formatted sql

--changeset fpsms:add_truck_lane_authority
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'TRUCK_LANE'
--comment: Route board (/settings/shop/board): ADMIN or TRUCK_LANE (車線)
INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`)
VALUES ('TRUCK_LANE', '車線', 'SHOP', 'Allow maintaining shop route board / truck lanes');

+ 5
- 0
src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql Bestand weergeven

@@ -0,0 +1,5 @@
-- liquibase formatted sql
-- changeset fpsms:20261001-picker-revise-count

ALTER TABLE `fpsmsdb`.`stocktakerecord`
ADD COLUMN `pickerReviseCount` INT NOT NULL DEFAULT 0 AFTER `lastSelect`;

+ 9
- 0
src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql Bestand weergeven

@@ -0,0 +1,9 @@
--liquibase formatted sql
--changeset fpsms:20261001_po_item_label_version

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT 'LABEL.poItem.version', '1', 'LABEL', '1/2'
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM `settings` WHERE `name` = 'LABEL.poItem.version'
);

+ 7
- 0
src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql Bestand weergeven

@@ -0,0 +1,7 @@
--liquibase formatted sql
--changeset fpsms:20261001_po_item_label_version_allow_3

UPDATE `settings`
SET `type` = '1/2/3'
WHERE `name` = 'LABEL.poItem.version'
AND `type` = '1/2';

+ 7
- 0
src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql Bestand weergeven

@@ -0,0 +1,7 @@
--liquibase formatted sql
--changeset fpsms:20261002_po_item_label_version_allow_4

UPDATE `settings`
SET `type` = '1/2/3/4'
WHERE `name` = 'LABEL.poItem.version'
AND `type` IN ('1/2', '1/2/3');

+ 8
- 0
src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql Bestand weergeven

@@ -0,0 +1,8 @@
-- liquibase formatted sql
-- changeset fpsms:20261002-scheduler-sync-log-text

-- PO sync stores the M18 conds plus skipped PO codes. VARCHAR(2000) / VARCHAR(1024)
-- reject that insert (MySQL 1406) and the scheduled task fails after the sync.
ALTER TABLE `fpsmsdb`.`scheduler_sync_log`
MODIFY COLUMN `query` TEXT NULL,
MODIFY COLUMN `errorMessage` TEXT NULL;

+ 19
- 0
src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql Bestand weergeven

@@ -0,0 +1,19 @@
--liquibase formatted sql

--changeset fpsms:add_performance_authority
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM authority WHERE authority = 'PERFORMANCE'
--comment: Staff delivery performance on /chart/delivery requires PERFORMANCE (績效)
INSERT IGNORE INTO `authority` (`authority`, `name`, `module`, `description`)
VALUES ('PERFORMANCE', '績效', 'CHART', 'Allow viewing staff delivery performance on /chart/delivery');

--changeset fpsms:grant_performance_authority_to_2fi
--preconditions onFail:MARK_RAN
--precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM user_authority ua JOIN `user` u ON u.id = ua.userId JOIN authority a ON a.id = ua.authId WHERE u.username = '2fi' AND a.authority = 'PERFORMANCE'
--comment: Default grant 績效 to user 2fi
INSERT IGNORE INTO `user_authority` (`userId`, `authId`)
SELECT u.id, a.id
FROM `user` u
JOIN `authority` a ON a.authority = 'PERFORMANCE'
WHERE u.username = '2fi'
AND u.deleted = 0;

+ 5
- 0
src/main/resources/jasper/StockBalanceReport.jrxml Bestand weergeven

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Jaspersoft Studio version 6.17.0.final using JasperReports Library version 6.17.0-6d93193241dd8cc42629e188b94f9e0bc5722efd -->
<!-- FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 -->
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="StockBalanceReport" pageWidth="842" pageHeight="595" orientation="Landscape" columnWidth="802" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="b6cc79a8-6025-4490-938c-b90508da62d0">
<property name="com.jaspersoft.studio.data.defaultdataadapter" value="One Empty Record"/>
<parameter name="stockSubCategory" class="java.lang.String">
@@ -77,6 +78,7 @@
<textField>
<reportElement x="669" y="0" width="51" height="20" uuid="7faf8dc0-de0e-49bb-abbc-8c6e4818e6c6">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<printWhenExpression><![CDATA[false]]></printWhenExpression>
</reportElement>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體"/>
@@ -163,6 +165,7 @@
<textField>
<reportElement x="720" y="0" width="78" height="20" uuid="b58a8878-7093-4af2-b1a8-cf44de1fb4a6">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<printWhenExpression><![CDATA[false]]></printWhenExpression>
</reportElement>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體"/>
@@ -405,6 +408,7 @@
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<printWhenExpression><![CDATA[false]]></printWhenExpression>
</reportElement>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
@@ -416,6 +420,7 @@
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<printWhenExpression><![CDATA[false]]></printWhenExpression>
</reportElement>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>


+ 172
- 0
src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml Bestand weergeven

@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf -->
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="poItemPDF" printOrder="Horizontal" pageWidth="425" pageHeight="283" orientation="Landscape" columnWidth="425" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0" uuid="6cb85eb9-3573-42f3-a293-099db250aec0">
<property name="com.jaspersoft.studio.unit." value="pixel"/>
<parameter name="lotNo" class="java.lang.String"/>
<parameter name="itemName" class="java.lang.String"/>
<parameter name="itemNo" class="java.lang.String"/>
<parameter name="poCode" class="java.lang.String"/>
<parameter name="qrCode" class="java.awt.Image"/>
<parameter name="acceptedQty" class="java.math.BigDecimal"/>
<parameter name="uom" class="java.lang.String"/>
<parameter name="isTransfer" class="java.lang.String"/>
<queryString>
<![CDATA[]]>
</queryString>
<field name="lotNo" class="java.lang.String"/>
<field name="itemName" class="java.lang.String"/>
<field name="itemNo" class="java.lang.String"/>
<field name="acceptedQty" class="java.lang.String"/>
<field name="uom" class="java.lang.String"/>
<field name="qtyLabel" class="java.lang.String"/>
<field name="qtyDetail" class="java.lang.String"/>
<field name="qrCode" class="java.awt.Image"/>
<field name="poCode" class="java.lang.String"/>
<field name="supplier" class="java.lang.String"/>
<field name="productionDate" class="java.lang.String"/>
<field name="expiryDate" class="java.lang.String"/>
<background>
<band splitType="Stretch"/>
</background>
<detail>
<band height="279" splitType="Prevent">
<staticText>
<reportElement x="8" y="0" width="108" height="56" uuid="c62ac00a-c495-462c-93bc-97bdd3cdb395"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true"/>
</textElement>
<text><![CDATA[收貨日期:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="116" y="0" width="301" height="56" uuid="74a5cfba-046d-42f4-92d7-113729514a6f"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="44" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{productionDate}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="56" width="108" height="56" uuid="32edcad1-23c1-4d41-8c9a-352d9e6ef377"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true"/>
</textElement>
<text><![CDATA[到期日:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="116" y="56" width="301" height="56" uuid="97e4a9bb-fc84-4074-b523-ddf9eb9ed2d2"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="44" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{expiryDate}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="114" width="230" height="18" uuid="f5f145f1-123f-4fe5-90fa-8550b3d2eb63"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[批次編號:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="132" width="230" height="20" uuid="402f35fc-b11a-4bf1-982d-e12c622f38ea"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{lotNo}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="152" width="108" height="18" uuid="96d46fb1-939d-4f3e-b8f0-172b69b85e9f"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[物料編號:]]></text>
</staticText>
<staticText>
<reportElement x="120" y="152" width="118" height="18" uuid="53a2d7cf-bd50-4264-abee-346823cd49dd"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[數量:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="170" width="108" height="28" uuid="1f289946-b945-4f79-8515-212368b8be3b"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="24" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="120" y="170" width="120" height="18" uuid="629d9af8-c73f-4614-ba9d-a582e2ed4a29"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="14" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{qtyLabel}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="120" y="188" width="120" height="12" uuid="7c1a0e44-6b2d-4a91-9f10-2d8e5b6c4a11">
<printWhenExpression><![CDATA[$F{qtyDetail} != null && !$F{qtyDetail}.trim().isEmpty()]]></printWhenExpression>
</reportElement>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$F{qtyDetail}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="200" width="230" height="16" uuid="5c3f9b03-222c-4c94-ab58-6bc86d44c5ce"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[物料名稱:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="216" width="230" height="30" uuid="e04b094d-2d16-4060-b6d9-2e6033a673ab"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="32" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression>
</textField>
<image scaleImage="FillFrame" hAlign="Center" vAlign="Middle">
<reportElement x="248" y="112" width="166" height="166" uuid="5dd35c73-973b-4f0d-9cad-13305a29a9fd"/>
<imageExpression><![CDATA[$F{qrCode}]]></imageExpression>
</image>
<staticText>
<reportElement x="8" y="248" width="42" height="14" uuid="c2041205-51f8-45bb-a1d1-e3de37eebed7"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<text><![CDATA[供應商:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="50" y="248" width="188" height="14" uuid="ab82ca39-8840-4471-b7c0-cdeec9cb5693"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$F{supplier}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="262" width="76" height="14" uuid="1b1efafd-049a-432e-93c4-9662273011f7"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<text><![CDATA[採購訂單編號:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="84" y="262" width="154" height="14" uuid="b805e20a-9a36-45c8-8255-23b6cc58f47c"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$P{poCode}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="276" y="183" width="110" height="22" uuid="d66a64b0-3335-4846-bcb0-a67594520055">
<printWhenExpression><![CDATA[$P{isTransfer} != null && !$P{isTransfer}.toString().trim().isEmpty()]]></printWhenExpression>
</reportElement>
<box>
<pen lineWidth="2.0"/>
</box>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="11" isBold="true" isUnderline="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{isTransfer}]]></textFieldExpression>
</textField>
</band>
</detail>
</jasperReport>

+ 172
- 0
src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml Bestand weergeven

@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf -->
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="poItemPDF_v3" printOrder="Horizontal" pageWidth="425" pageHeight="283" orientation="Landscape" columnWidth="425" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0" uuid="6cb85eb9-3573-42f3-a293-099db250aec0">
<property name="com.jaspersoft.studio.unit." value="pixel"/>
<parameter name="lotNo" class="java.lang.String"/>
<parameter name="itemName" class="java.lang.String"/>
<parameter name="itemNo" class="java.lang.String"/>
<parameter name="poCode" class="java.lang.String"/>
<parameter name="qrCode" class="java.awt.Image"/>
<parameter name="acceptedQty" class="java.math.BigDecimal"/>
<parameter name="uom" class="java.lang.String"/>
<parameter name="isTransfer" class="java.lang.String"/>
<queryString>
<![CDATA[]]>
</queryString>
<field name="lotNo" class="java.lang.String"/>
<field name="itemName" class="java.lang.String"/>
<field name="itemNo" class="java.lang.String"/>
<field name="acceptedQty" class="java.lang.String"/>
<field name="uom" class="java.lang.String"/>
<field name="qtyLabel" class="java.lang.String"/>
<field name="qtyDetail" class="java.lang.String"/>
<field name="qrCode" class="java.awt.Image"/>
<field name="poCode" class="java.lang.String"/>
<field name="supplier" class="java.lang.String"/>
<field name="productionDate" class="java.lang.String"/>
<field name="expiryDate" class="java.lang.String"/>
<background>
<band splitType="Stretch"/>
</background>
<detail>
<band height="279" splitType="Prevent">
<staticText>
<reportElement x="8" y="0" width="108" height="56" uuid="c62ac00a-c495-462c-93bc-97bdd3cdb395"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true"/>
</textElement>
<text><![CDATA[收貨日期:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="116" y="0" width="301" height="56" uuid="74a5cfba-046d-42f4-92d7-113729514a6f"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="44" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{productionDate}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="56" width="108" height="56" uuid="32edcad1-23c1-4d41-8c9a-352d9e6ef377"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true"/>
</textElement>
<text><![CDATA[到期日:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="116" y="56" width="301" height="56" uuid="97e4a9bb-fc84-4074-b523-ddf9eb9ed2d2"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="44" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{expiryDate}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="114" width="286" height="16" uuid="f5f145f1-123f-4fe5-90fa-8550b3d2eb63"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[批次編號:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="130" width="286" height="20" uuid="402f35fc-b11a-4bf1-982d-e12c622f38ea"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{lotNo}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="152" width="140" height="16" uuid="96d46fb1-939d-4f3e-b8f0-172b69b85e9f"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[物料編號:]]></text>
</staticText>
<staticText>
<reportElement x="152" y="152" width="142" height="16" uuid="53a2d7cf-bd50-4264-abee-346823cd49dd"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[數量:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="168" width="140" height="26" uuid="1f289946-b945-4f79-8515-212368b8be3b"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="22" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="152" y="168" width="142" height="16" uuid="629d9af8-c73f-4614-ba9d-a582e2ed4a29"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="14" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{qtyLabel}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="152" y="184" width="142" height="12" uuid="7c1a0e44-6b2d-4a91-9f10-2d8e5b6c4a11">
<printWhenExpression><![CDATA[$F{qtyDetail} != null && !$F{qtyDetail}.trim().isEmpty()]]></printWhenExpression>
</reportElement>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$F{qtyDetail}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="200" width="290" height="18" uuid="5c3f9b03-222c-4c94-ab58-6bc86d44c5ce"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[物料名稱:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="218" width="290" height="58" uuid="e04b094d-2d16-4060-b6d9-2e6033a673ab"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="48" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression>
</textField>
<image scaleImage="FillFrame" hAlign="Center" vAlign="Middle">
<reportElement x="308" y="116" width="108" height="108" uuid="5dd35c73-973b-4f0d-9cad-13305a29a9fd"/>
<imageExpression><![CDATA[$F{qrCode}]]></imageExpression>
</image>
<staticText>
<reportElement x="308" y="226" width="108" height="12" uuid="c2041205-51f8-45bb-a1d1-e3de37eebed7"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<text><![CDATA[供應商:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="308" y="238" width="108" height="14" uuid="ab82ca39-8840-4471-b7c0-cdeec9cb5693"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$F{supplier}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="308" y="252" width="108" height="12" uuid="1b1efafd-049a-432e-93c4-9662273011f7"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<text><![CDATA[採購訂單編號:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="308" y="264" width="108" height="14" uuid="b805e20a-9a36-45c8-8255-23b6cc58f47c"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$P{poCode}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="322" y="156" width="80" height="20" uuid="d66a64b0-3335-4846-bcb0-a67594520055">
<printWhenExpression><![CDATA[$P{isTransfer} != null && !$P{isTransfer}.toString().trim().isEmpty()]]></printWhenExpression>
</reportElement>
<box>
<pen lineWidth="2.0"/>
</box>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="11" isBold="true" isUnderline="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{isTransfer}]]></textFieldExpression>
</textField>
</band>
</detail>
</jasperReport>

+ 172
- 0
src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml Bestand weergeven

@@ -0,0 +1,172 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf -->
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="poItemPDF_v4" printOrder="Horizontal" pageWidth="425" pageHeight="283" orientation="Landscape" columnWidth="425" leftMargin="0" rightMargin="0" topMargin="0" bottomMargin="0" uuid="6cb85eb9-3573-42f3-a293-099db250aec0">
<property name="com.jaspersoft.studio.unit." value="pixel"/>
<parameter name="lotNo" class="java.lang.String"/>
<parameter name="itemName" class="java.lang.String"/>
<parameter name="itemNo" class="java.lang.String"/>
<parameter name="poCode" class="java.lang.String"/>
<parameter name="qrCode" class="java.awt.Image"/>
<parameter name="acceptedQty" class="java.math.BigDecimal"/>
<parameter name="uom" class="java.lang.String"/>
<parameter name="isTransfer" class="java.lang.String"/>
<queryString>
<![CDATA[]]>
</queryString>
<field name="lotNo" class="java.lang.String"/>
<field name="itemName" class="java.lang.String"/>
<field name="itemNo" class="java.lang.String"/>
<field name="acceptedQty" class="java.lang.String"/>
<field name="uom" class="java.lang.String"/>
<field name="qtyLabel" class="java.lang.String"/>
<field name="qtyDetail" class="java.lang.String"/>
<field name="qrCode" class="java.awt.Image"/>
<field name="poCode" class="java.lang.String"/>
<field name="supplier" class="java.lang.String"/>
<field name="productionDate" class="java.lang.String"/>
<field name="expiryDate" class="java.lang.String"/>
<background>
<band splitType="Stretch"/>
</background>
<detail>
<band height="279" splitType="Prevent">
<staticText>
<reportElement x="4" y="0" width="222" height="16" uuid="c62ac00a-c495-462c-93bc-97bdd3cdb395"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="11" isBold="true"/>
</textElement>
<text><![CDATA[收貨日期:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="4" y="16" width="222" height="52" uuid="74a5cfba-046d-42f4-92d7-113729514a6f"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="48" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{productionDate}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="4" y="70" width="222" height="16" uuid="32edcad1-23c1-4d41-8c9a-352d9e6ef377"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="11" isBold="true"/>
</textElement>
<text><![CDATA[到期日:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="4" y="86" width="222" height="52" uuid="97e4a9bb-fc84-4074-b523-ddf9eb9ed2d2"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="48" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{expiryDate}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="142" width="104" height="16" uuid="96d46fb1-939d-4f3e-b8f0-172b69b85e9f"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[物料編號:]]></text>
</staticText>
<staticText>
<reportElement x="130" y="142" width="96" height="16" uuid="53a2d7cf-bd50-4264-abee-346823cd49dd"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[數量:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="158" width="118" height="34" uuid="1f289946-b945-4f79-8515-212368b8be3b"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="30" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="130" y="158" width="96" height="18" uuid="629d9af8-c73f-4614-ba9d-a582e2ed4a29"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="14" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{qtyLabel}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="130" y="178" width="96" height="14" uuid="7c1a0e44-6b2d-4a91-9f10-2d8e5b6c4a11">
<printWhenExpression><![CDATA[$F{qtyDetail} != null && !$F{qtyDetail}.trim().isEmpty()]]></printWhenExpression>
</reportElement>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$F{qtyDetail}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="8" y="196" width="218" height="16" uuid="5c3f9b03-222c-4c94-ab58-6bc86d44c5ce"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[物料名稱:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="8" y="212" width="218" height="64" uuid="e04b094d-2d16-4060-b6d9-2e6033a673ab"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="48" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression>
</textField>
<image scaleImage="FillFrame" hAlign="Center" vAlign="Middle">
<reportElement x="230" y="4" width="190" height="190" uuid="5dd35c73-973b-4f0d-9cad-13305a29a9fd"/>
<imageExpression><![CDATA[$F{qrCode}]]></imageExpression>
</image>
<staticText>
<reportElement x="230" y="196" width="190" height="16" uuid="f5f145f1-123f-4fe5-90fa-8550b3d2eb63"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[批次編號:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="230" y="212" width="190" height="26" uuid="402f35fc-b11a-4bf1-982d-e12c622f38ea"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$F{lotNo}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="230" y="240" width="42" height="16" uuid="c2041205-51f8-45bb-a1d1-e3de37eebed7"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<text><![CDATA[供應商:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="272" y="240" width="148" height="16" uuid="ab82ca39-8840-4471-b7c0-cdeec9cb5693"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$F{supplier}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="230" y="258" width="72" height="16" uuid="1b1efafd-049a-432e-93c4-9662273011f7"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<text><![CDATA[採購訂單編號:]]></text>
</staticText>
<textField textAdjust="ScaleFont" isBlankWhenNull="true">
<reportElement x="302" y="258" width="118" height="16" uuid="b805e20a-9a36-45c8-8255-23b6cc58f47c"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="8"/>
</textElement>
<textFieldExpression><![CDATA[$P{poCode}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="265" y="87" width="120" height="24" uuid="d66a64b0-3335-4846-bcb0-a67594520055">
<printWhenExpression><![CDATA[$P{isTransfer} != null && !$P{isTransfer}.toString().trim().isEmpty()]]></printWhenExpression>
</reportElement>
<box>
<pen lineWidth="2.0"/>
</box>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="11" isBold="true" isUnderline="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{isTransfer}]]></textFieldExpression>
</textField>
</band>
</detail>
</jasperReport>

+ 59
- 0
src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt Bestand weergeven

@@ -0,0 +1,59 @@
package com.ffii.fpsms.modules.report.service

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class ReportMultiValueTokensTest {
@Test
fun `splits space-separated item codes`() {
assertEquals(
listOf("FA0123", "FA2332"),
ReportMultiValueTokens.split("FA0123 FA2332"),
)
}

@Test
fun `splits commas with extra spaces`() {
assertEquals(
listOf("FA0123", "FA2332", "FA23238"),
ReportMultiValueTokens.split("FA0123,FA2332, FA23238"),
)
}

@Test
fun `splits mixed commas spaces and newlines`() {
val raw = "FA0123,FA2332, FA23238\nFA9999 FA0001,, FA2332"
assertEquals(
listOf("FA0123", "FA2332", "FA23238", "FA9999", "FA0001"),
ReportMultiValueTokens.split(raw),
)
}

@Test
fun `returns empty for blank input`() {
assertEquals(emptyList<String>(), ReportMultiValueTokens.split(null))
assertEquals(emptyList<String>(), ReportMultiValueTokens.split(" , ; "))
}

@Test
fun `full item codes use exact IN partition`() {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy("FA0123 FA2332, FA23238")
assertEquals(listOf("FA0123", "FA2332", "FA23238"), exact)
assertEquals(emptyList<String>(), fuzzy)
}

@Test
fun `short prefix stays fuzzy`() {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy("FA FA0123")
assertEquals(listOf("FA0123"), exact)
assertEquals(listOf("FA"), fuzzy)
}

@Test
fun `combine merges select paste and json list`() {
assertEquals(
"FA0123,FA2332,FA9999",
ReportMultiValueTokens.combine("FA0123", "FA2332 FA0123", listOf("FA9999")),
)
}
}

+ 40
- 0
src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt Bestand weergeven

@@ -0,0 +1,40 @@
package com.ffii.fpsms.modules.report.service

import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class StockLedgerPdfSpeedTest {
@Test
fun writesLargeLedgerWithoutJasperLayout() {
val rows = ArrayList<Map<String, Any>>(40_000)
repeat(40_000) { i ->
val item = "PP%04d".format(i / 40)
rows.add(
linkedMapOf(
"itemNo" to item,
"itemName" to "測試貨品$item",
"unitOfMeasure" to "包",
"trnDate" to "2026-09-01",
"trnRefNo" to "出入倉",
"lotNo" to "LT-$i",
"expiryDate" to "2026-12-31",
"cumOpeningBal" to "1,200",
"stockIn" to "10",
"stockOut" to "0",
"cumBalance" to "1,210",
"orderRefNo" to "DO-20260901-${i % 1000}",
"storeLocation" to "W201",
"totalStockIn" to "400",
"totalStockOut" to "0",
"totalCumBalance" to "1,210",
)
)
}
val started = System.nanoTime()
val pdf = StockLedgerPdf.write(rows, "2026-09-23", "23:40:00", "2026-09-01", "2026-09-07")
val seconds = (System.nanoTime() - started) / 1_000_000_000.0
assertTrue(pdf.size > 1000, "pdf bytes=${pdf.size}")
assertTrue(pdf[0] == '%'.code.toByte())
assertTrue(seconds < 15.0, "pdf took ${"%.2f".format(seconds)}s for 40000 rows")
}
}

Laden…
Annuleren
Opslaan