Procházet zdrojové kódy

Merge branch 'production' into POroundupdown:

item_tracing
CANCERYS\kw093 před 3 týdny
rodič
revize
3b07fcaecd
65 změnil soubory, kde provedl 3525 přidání a 732 odebrání
  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. +1
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt
  14. +23
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  15. +12
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt
  16. +7
    -7
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt
  17. +2
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/PurchaseOrderRepository.kt
  18. +4
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderLineInfo.kt
  19. +42
    -3
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt
  20. +22
    -2
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt
  21. +20
    -5
      src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt
  22. +11
    -3
      src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt
  23. +41
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/ReportMultiValueTokens.kt
  24. +262
    -218
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  25. +117
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  26. +1
    -1
      src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt
  27. +257
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt
  28. +355
    -222
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  29. +60
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt
  30. +336
    -65
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  31. +33
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt
  32. +303
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt
  33. +46
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt
  34. +69
    -26
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
  35. +4
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt
  36. +12
    -0
      src/main/java/com/ffii/fpsms/modules/settings/service/SettingsService.java
  37. +13
    -0
      src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java
  38. +14
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  39. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt
  40. +8
    -5
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt
  41. +12
    -4
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  42. +1
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
  43. +66
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt
  44. +22
    -11
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  45. +57
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  46. +1
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt
  47. +7
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  48. +3
    -2
      src/main/resources/application-prod.yml
  49. +2
    -1
      src/main/resources/application.yml
  50. +14
    -0
      src/main/resources/db/changelog/changes/20260918_po_stockin_refresh_if_newer/01_setting.sql
  51. +9
    -0
      src/main/resources/db/changelog/changes/20260923_stock_take_qty_gap/01_setting.sql
  52. +8
    -0
      src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql
  53. +5
    -0
      src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql
  54. +9
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql
  55. +7
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql
  56. +7
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql
  57. +8
    -0
      src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql
  58. +19
    -0
      src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql
  59. +5
    -0
      src/main/resources/jasper/StockBalanceReport.jrxml
  60. +1
    -1
      src/main/resources/jasper/StockLedgarReport.jrxml
  61. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml
  62. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml
  63. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml
  64. +59
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/ReportMultiValueTokensTest.kt
  65. +40
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt

+ 74
- 5
src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

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

@@ -68,6 +77,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
*/
@@ -120,4 +135,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 Zobrazit soubor

@@ -87,6 +87,8 @@ open class SchedulerService(
const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *"
/** Daily 00:00:15 — process job orders whose planStart was yesterday. */
const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 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). */
@@ -730,14 +732,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 Zobrazit soubor

@@ -71,6 +71,7 @@ import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.orm.ObjectOptimisticLockingFailureException
import com.ffii.core.utils.PdfUtils
import com.ffii.core.utils.PrinterLockManager
import com.ffii.core.utils.ZebraPrinterUtil
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import com.ffii.fpsms.modules.master.service.PrinterService
@@ -85,11 +86,11 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintReque
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import org.springframework.core.io.ClassPathResource
import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperPrint
import net.sf.jasperreports.engine.JasperReport
import java.io.File
import java.io.FileNotFoundException
import java.util.Locale
@@ -142,6 +143,20 @@ open class DoWorkbenchMainService(

companion object {
private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java)

private const val DELIVERY_NOTE_JRXML = "DeliveryNote/DeliveryNotePDF.jrxml"
private const val CARTON_LABEL_JRXML = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml"

private val deliveryNoteJasperTemplate: JasperReport by lazy { compileJasper(DELIVERY_NOTE_JRXML) }
private val cartonLabelJasperTemplate: JasperReport by lazy { compileJasper(CARTON_LABEL_JRXML) }

private fun compileJasper(path: String): JasperReport {
val resource = ClassPathResource(path)
if (!resource.exists()) {
throw FileNotFoundException("Report file not found: $path")
}
return resource.inputStream.use { JasperCompileManager.compileReport(it) }
}
}

private sealed interface WorkbenchScanIllResolve {
@@ -2931,6 +2946,7 @@ return MessageResponse(
}
@Transactional
open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) {
val started = System.nanoTime()
val printer =
printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer")

@@ -2944,17 +2960,38 @@ return MessageResponse(

val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")
var handedToPrinter = false
try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
val port = printer.port ?: 9100
val driver = A4PrintDriverRegistry.getDriver(printer.brand)
driver.print(tempPdfFile, ip, port, printQty)
val ip = printer.ip?.trim().orEmpty()
if (ip.isEmpty()) {
tempPdfFile.delete()
throw IllegalStateException("Printer ${printer.id} has no IP address")
}
updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton)
} finally {
//tempPdfFile.delete()
val port = printer.port ?: 9100
val brand = printer.brand
val doPickOrderId = request.doPickOrderId
val cartonQty = request.numOfCarton
sendToPrinter(
tempPdfFile,
ip,
port,
afterPrint = { saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) },
) {
A4PrintDriverRegistry.getDriver(brand).print(tempPdfFile, ip, port, printQty)
}
handedToPrinter = true
log.info(
"Workbench print-DN sent buildMs={} doPickOrderId={} printerId={} printerIp={}",
(System.nanoTime() - started) / 1_000_000,
request.doPickOrderId,
request.printerId,
printer.ip ?: "null",
)
} catch (e: Exception) {
if (!handedToPrinter) tempPdfFile.delete()
throw e
}
}

@@ -3021,14 +3058,7 @@ return MessageResponse(
}

open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map<String, Any> {
val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml"
val resource = ClassPathResource(DELIVERYNOTE_PDF)
if (!resource.exists()) {
throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF")
}

val inputStream = resource.inputStream
val deliveryNote = JasperCompileManager.compileReport(inputStream)
val deliveryNote = deliveryNoteJasperTemplate
val fields = mutableListOf<MutableMap<String, Any>>()
val params = mutableMapOf<String, Any>()

@@ -3042,18 +3072,9 @@ return MessageResponse(

val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo)

val pickOrderLines = ctx.pickOrderIds.flatMap { pid ->
pickOrderLineRepository.findAllByPickOrderId(pid)
}
val pickOrderLines = loadPickOrderLinesForPrint(ctx.pickOrderIds)
val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id }
val stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>> =
if (allPickOrderLineIds.isNotEmpty()) {
allPickOrderLineIds.associateWith { polId ->
stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId)
}
} else {
emptyMap()
}
val stockOutLinesByPickOrderLineId = loadStockOutLinesForPrint(allPickOrderLineIds)

val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT)
?.replace("/", "")
@@ -3079,11 +3100,7 @@ return MessageResponse(
val allIllIds = stockOutLinesByPickOrderLineId.values
.flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } }
.distinct()
val illById: Map<Long, InventoryLotLine> = if (allIllIds.isNotEmpty()) {
inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! }
} else {
emptyMap()
}
val illById = loadInventoryLotLinesWithWarehouse(allIllIds)

val deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds)
val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") }
@@ -3157,6 +3174,7 @@ return MessageResponse(
}
@Transactional
open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) {
val started = System.nanoTime()
val printer =
printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer")

@@ -3170,26 +3188,48 @@ return MessageResponse(

val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")
var handedToPrinter = false
try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
printer.port?.let { port ->
ZebraPrinterUtil.printPdfToZebra(
tempPdfFile,
ip,
port,
printQty,
ZebraPrinterUtil.PrintDirection.ROTATED,
printer.dpi,
)
}
val ip = printer.ip?.trim().orEmpty()
val port = printer.port
if (ip.isEmpty() || port == null) {
tempPdfFile.delete()
throw IllegalStateException("Label printer ${printer.id} has no IP or port")
}
if (request.blankCartonNumber != true) {
updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton)
val dpi = printer.dpi
val saveCarton = request.blankCartonNumber != true
val doPickOrderId = request.doPickOrderId
val cartonQty = request.numOfCarton
sendToPrinter(
tempPdfFile,
ip,
port,
afterPrint = {
if (saveCarton) saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty)
},
) {
ZebraPrinterUtil.printPdfToZebra(
tempPdfFile,
ip,
port,
printQty,
ZebraPrinterUtil.PrintDirection.ROTATED,
dpi,
)
}
} finally {
//tempPdfFile.delete()
handedToPrinter = true
log.info(
"Workbench print-DNLabels sent buildMs={} doPickOrderId={} cartons={} printerIp={}",
(System.nanoTime() - started) / 1_000_000,
request.doPickOrderId,
request.numOfCarton,
printer.ip ?: "null",
)
} catch (e: Exception) {
if (!handedToPrinter) tempPdfFile.delete()
throw e
}
}

@@ -3210,15 +3250,8 @@ return MessageResponse(
}

private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map<String, Any> {
val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml"
val resource = ClassPathResource(DNLABELS_PDF)
if (!resource.exists()) {
throw FileNotFoundException("Label file not found: $DNLABELS_PDF")
}

val ctx = getWorkbenchPrintContext(request.doPickOrderId)
val inputStream = resource.inputStream
val cartonLabel = JasperCompileManager.compileReport(inputStream)
val cartonLabel = cartonLabelJasperTemplate
val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId ->
deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId)
}.toMutableList()
@@ -3279,15 +3312,8 @@ return MessageResponse(
totalCartonsOnShipment = request.totalCartonsOnShipment,
)

val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml"
val resource = ClassPathResource(DNLABELS_PDF)
if (!resource.exists()) {
throw FileNotFoundException("Label file not found: $DNLABELS_PDF")
}

val ctx = getWorkbenchPrintContext(request.doPickOrderId)
val inputStream = resource.inputStream
val cartonLabel = JasperCompileManager.compileReport(inputStream)
val cartonLabel = cartonLabelJasperTemplate
val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId ->
deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId)
}.toMutableList()
@@ -3327,23 +3353,147 @@ return MessageResponse(
val pdf = exportDNLabelsReprintWorkbench(request)
val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")
var handedToPrinter = false
try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
printer.port?.let { port ->
ZebraPrinterUtil.printPdfToZebra(
tempPdfFile,
ip,
port,
printQty,
ZebraPrinterUtil.PrintDirection.ROTATED,
printer.dpi,
)
}
val ip = printer.ip?.trim().orEmpty()
val port = printer.port
if (ip.isEmpty() || port == null) {
tempPdfFile.delete()
throw IllegalStateException("Label printer ${printer.id} has no IP or port")
}
val dpi = printer.dpi
sendToPrinter(tempPdfFile, ip, port) {
ZebraPrinterUtil.printPdfToZebra(
tempPdfFile,
ip,
port,
printQty,
ZebraPrinterUtil.PrintDirection.ROTATED,
dpi,
)
}
handedToPrinter = true
} catch (e: Exception) {
if (!handedToPrinter) tempPdfFile.delete()
throw e
}
}

/**
* Send on this request so a printer timeout is returned to the tablet.
* Carton qty is saved only after the printer accepts the job.
*/
private fun sendToPrinter(
tempPdfFile: File,
ip: String,
port: Int,
afterPrint: (() -> Unit)? = null,
action: () -> Unit,
) {
try {
PrinterLockManager.withLock(ip, port) {
action()
}
log.info("Workbench print sent printer={}:{} file={}", ip, port, tempPdfFile.name)
afterPrint?.invoke()
} catch (e: Exception) {
log.error("Workbench print failed printer={}:{} file={}", ip, port, tempPdfFile.name, e)
throw e
} finally {
// tempPdfFile.delete()
tempPdfFile.delete()
}
}

/** Same column as before (`cartonQty` only). Runs only after the printer accepts the job. */
private fun saveCartonQtyAfterSuccessfulPrint(deliveryOrderPickOrderId: Long, cartonQty: Int) {
if (cartonQty <= 0) return
val tx = TransactionTemplate(transactionManager).apply {
propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW
}
tx.executeWithoutResult {
updateWorkbenchCartonQty(deliveryOrderPickOrderId, cartonQty)
}
}

private fun loadPickOrderLinesForPrint(pickOrderIds: List<Long>): List<com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine> {
if (pickOrderIds.isEmpty()) return emptyList()
return pickOrderIds.distinct().chunked(400).flatMap { chunk ->
pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk)
}
}

private fun loadStockOutLinesForPrint(pickOrderLineIds: List<Long>): Map<Long, List<StockOutLineInfo>> {
if (pickOrderLineIds.isEmpty()) return emptyMap()
val lines = mutableListOf<PrintStockOutLineInfo>()
pickOrderLineIds.distinct().chunked(400).forEach { chunk ->
val rows = jdbcDao.queryForList(
"""
SELECT
sol.id AS id,
sol.pickOrderLineId AS pickOrderLineId,
sol.qty AS qty,
sol.inventoryLotLineId AS inventoryLotLineId,
sol.status AS status,
il.lotNo AS lotNo
FROM fpsmsdb.stock_out_line sol
LEFT JOIN fpsmsdb.inventory_lot_line ill
ON ill.id = sol.inventoryLotLineId
LEFT JOIN fpsmsdb.inventory_lot il
ON il.id = ill.inventoryLotId
WHERE sol.deleted = 0
AND sol.pickOrderLineId IN (:ids)
""".trimIndent(),
mapOf("ids" to chunk),
)
rows.forEach { row ->
val polId = (rowValue(row, "pickOrderLineId") as? Number)?.toLong() ?: return@forEach
val qtyRaw = rowValue(row, "qty")
lines.add(
PrintStockOutLineInfo(
id = (rowValue(row, "id") as? Number)?.toLong() ?: 0L,
pickOrderLineId = polId,
qty = when (qtyRaw) {
is BigDecimal -> qtyRaw
is Number -> BigDecimal(qtyRaw.toString())
else -> BigDecimal.ZERO
},
inventoryLotLineId = (rowValue(row, "inventoryLotLineId") as? Number)?.toLong(),
lotNo = rowValue(row, "lotNo")?.toString()?.takeIf { it.isNotBlank() },
status = rowValue(row, "status")?.toString() ?: "",
),
)
}
}
return lines.groupBy { it.pickOrderLineId!! }
}

private fun loadInventoryLotLinesWithWarehouse(ids: Collection<Long>): Map<Long, InventoryLotLine> {
if (ids.isEmpty()) return emptyMap()
val loaded = ids.distinct().chunked(400).flatMap { chunk ->
inventoryLotLineRepository.findAllByIdInWithWarehouse(chunk)
}
return loaded.mapNotNull { line -> line.id?.let { id -> id to line } }.toMap()
}

private fun rowValue(row: Map<String, Any?>, key: String): Any? {
val matched = row.keys.find { it.equals(key, ignoreCase = true) } ?: return null
return row[matched]
}

private data class PrintStockOutLineInfo(
override val id: Long,
override val pickOrderLineId: Long?,
override val qty: BigDecimal,
override val inventoryLotLineId: Long?,
override val lotNo: String?,
override val status: String,
) : StockOutLineInfo {
override val itemId: Long = 0L
override val itemName: String? = null
override val itemNo: String = ""
override val stockOutId: Long = 0L
override val pickTime: java.time.LocalDateTime? = null
}
}

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt Zobrazit soubor

@@ -7,7 +7,7 @@ interface BomCombo {
val id: Long;
@get:Value("#{target.id}")
val value: Long;
@get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}")
@get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[stockUnit == true && deleted == false]?.uom?.udfudesc ?: ''}")
val label: String;
val outputQty: BigDecimal;
val outputQtyUom: String?;


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

@@ -255,6 +255,27 @@ open class ItemUomService(
return unitUomId == uomId
}

/**
* 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).
*/
@@ -263,9 +284,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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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) {


+ 20
- 5
src/main/java/com/ffii/fpsms/modules/report/service/DoUserPickAuditReportService.kt Zobrazit soubor

@@ -8,7 +8,7 @@ class DoUserPickAuditReportService(
private val jdbcDao: JdbcDao,
) {
/**
* FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.2 | 2026-08-05
* FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10
* DO workbench user-audit rows (Just issue only).
* issueTypes may combine: 跳過提料(使用「已完成」)+多提+少提+擅自換批/揀錯批.
* 少提/多提 use POL-level SUM(stock_out_line.qty) vs pol.qty (multi-lot split OK if total matches).
@@ -55,10 +55,7 @@ class DoUserPickAuditReportService(
"AND dop.ticketNo LIKE CONCAT('%', :ticketNo, '%')"
} else ""

val itemSql = if (!itemCode.isNullOrBlank()) {
args["itemCode"] = itemCode.trim()
"AND i.code LIKE CONCAT('%', :itemCode, '%')"
} else ""
val itemSql = buildMultiValueLikeClause(itemCode, "i.code", "itemCode", args)

val storeSql = if (!storeId.isNullOrBlank()) {
args["storeId"] = storeId.trim().uppercase().replace("/", "").replace(" ", "")
@@ -201,4 +198,22 @@ class DoUserPickAuditReportService(
.map { (it["handler"]?.toString() ?: "").trim() }
.filter { it.isNotBlank() }
}

/** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */
private fun buildMultiValueLikeClause(
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"
}
return "AND (${conditions.joinToString(" OR ")})"
}
}

+ 11
- 3
src/main/java/com/ffii/fpsms/modules/report/service/M18BomShopSyncReportService.kt Zobrazit soubor

@@ -23,6 +23,7 @@ open class M18BomShopSyncReportService(
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private val versionFromHeaderCode = Regex("V(\\d+)$", RegexOption.IGNORE_CASE)

/** FP-MTMS Version Checklist | Functions Ref. No. 83 | v1.0.0 | 2026-09-10 */
open fun searchBomShopSyncHistoryReport(
syncDateStart: String?,
syncDateEnd: String?,
@@ -31,15 +32,22 @@ open class M18BomShopSyncReportService(
): Map<String, Any> {
val start = parseDateStart(syncDateStart)
val end = parseDateEnd(syncDateEnd)
val itemFilter = finishedItemCode?.trim()?.takeIf { it.isNotEmpty() }
val itemTokens = ReportMultiValueTokens.split(finishedItemCode)
val sqlItemFilter = itemTokens.singleOrNull()
val statusFilter = syncStatus?.trim()?.lowercase()?.takeIf { it.isNotEmpty() && it != "all" }

val logs = m18BomShopSyncLogRepository.searchForReport(
syncDateStart = start,
syncDateEnd = end,
finishedItemCode = itemFilter,
finishedItemCode = sqlItemFilter,
syncStatus = statusFilter,
)
).let { fetched ->
if (itemTokens.size <= 1) fetched
else fetched.filter { log ->
val code = log.finishedItemCode.orEmpty()
itemTokens.any { token -> code.contains(token, ignoreCase = true) }
}
}

val syncRows = mutableListOf<Map<String, Any?>>()
val materialRows = mutableListOf<Map<String, Any?>>()


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

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

+ 262
- 218
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Zobrazit soubor

@@ -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
@@ -12,6 +12,11 @@ import com.ffii.fpsms.modules.master.enums.ShopType
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.time.LocalDate
import java.util.ArrayList
import java.util.Locale
import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter
import net.sf.jasperreports.export.SimpleExporterInput
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput
@@ -23,6 +28,15 @@ open class ReportService(
private val shopRepository: ShopRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
private val compiledReports = ConcurrentHashMap<String, JasperReport>()

private fun compiledReport(templatePath: String): JasperReport =
compiledReports.computeIfAbsent(templatePath) { path ->
val stream = this::class.java.getResourceAsStream(path)
?: throw RuntimeException("Report template not found: $path")
stream.use { JasperCompileManager.compileReport(it) }
}

/**
* Queries the database for inventory data based on dates and optional item type.
*/
@@ -504,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?,
@@ -515,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 ->
@@ -527,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 =.
@@ -1455,12 +1496,12 @@ return result
/**
* Stock Balance Report (date-driven).
*
* Opening / current balance use the latest stock_ledger row per item (index on itemId+date)
* instead of ranking the whole ledger. Item codes prefer `IN` over `LIKE %x%`.
*
* - 期初存量: stockDate - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序)
* - 現存存貨: stockDate 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序)
* - 單位均價: 依 item 判斷是否 BOM
* - BOM: 用 delivery_order_line (prefer up, else price/qty, else price) 算加權平均
* - 非 BOM: 用 purchase_order_line (prefer up, else price/qty, else price) 算加權平均
* - 庫存總價值: 單位均價 * 現存存貨
* - 單位均價 / 庫存總價值: items.AverageUnitPrice * 現存存貨
*/
fun searchStockBalanceReportByDate(
stockCategory: String?,
@@ -1470,228 +1511,236 @@ return result
balanceFilterEnd: String?,
storeLocation: String?,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
val formattedStockDate = stockDate.replace("/", "-")
args["stockDate"] = formattedStockDate
val itemArgs = mutableMapOf<String, Any>()
val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", itemArgs)
val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", itemArgs)
val hasItemCodeFilter = itemCodeSql.isNotBlank()

val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND COALESCE(store_location.storeLocation, '') LIKE :storeLocation"
} else ""
val items = jdbcDao.queryForList(
"""
SELECT
it.id AS itemId,
it.code AS itemNo,
it.name AS itemName,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
FROM items it
WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSql
$stockCategorySql
ORDER BY it.code
""".trimIndent(),
itemArgs,
)
if (items.isEmpty()) return emptyList()

val baseSql = """
WITH params AS (
SELECT
DATE(:stockDate) AS d0,
DATE_SUB(DATE(:stockDate), INTERVAL 1 DAY) AS d1
),
ledger_item_ids AS (
SELECT DISTINCT sl.itemId
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0
),
item_scope AS (
SELECT
it.id AS itemId,
it.code AS itemNo,
it.name AS itemName,
it.type AS itemType,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
FROM items it
INNER JOIN ledger_item_ids li ON li.itemId = it.id
WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSql
$stockCategorySql
),
store_location AS (
SELECT
sl.itemId,
MAX(wh.code) AS storeLocation
FROM stock_ledger sl
LEFT JOIN stock_in_line sil ON sl.stockInLineId = sil.id AND sil.deleted = 0
LEFT JOIN inventory_lot il_in ON sil.inventoryLotId = il_in.id AND il_in.deleted = 0
LEFT JOIN stock_out_line sol ON sl.stockOutLineId = sol.id AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out ON sol.inventoryLotLineId = ill_out.id AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out ON ill_out.inventoryLotId = il_out.id AND il_out.deleted = 0
LEFT JOIN inventory_lot_line ill_any ON ill_any.inventoryLotId = COALESCE(il_in.id, il_out.id) AND ill_any.deleted = 0
LEFT JOIN warehouse wh ON ill_any.warehouseId = wh.id AND wh.deleted = 0
WHERE sl.deleted = 0
GROUP BY sl.itemId
),
opening_ranked AS (
SELECT
sl.itemId,
COALESCE(sl.balance, 0) AS openingBalance,
ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d1
),
opening_per_item AS (
SELECT itemId, openingBalance
FROM opening_ranked
WHERE rn = 1
),
current_ranked AS (
SELECT
sl.itemId,
COALESCE(sl.balance, 0) AS currentBalance,
ROW_NUMBER() OVER (PARTITION BY sl.itemId ORDER BY sl.date DESC, sl.id DESC) AS rn
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0
),
current_per_item AS (
SELECT itemId, currentBalance
FROM current_ranked
WHERE rn = 1
),
last_in_out AS (
SELECT
sl.itemId,
MAX(CASE WHEN COALESCE(sl.inQty, 0) > 0 THEN sl.date END) AS lastInDate,
MAX(CASE WHEN COALESCE(sl.outQty, 0) > 0 THEN sl.date END) AS lastOutDate
val itemIds = items.mapNotNull { jdbcLong(it["itemId"]) }.distinct()
if (itemIds.isEmpty()) return emptyList()

val day = LocalDate.parse(stockDate.replace("/", "-").take(10))
val openingCutoff = day.atStartOfDay()
val currentCutoff = day.plusDays(1).atStartOfDay()

val openingByItem = loadLatestLedgerBalances(itemIds, openingCutoff)
val currentByItem = loadLatestLedgerBalances(itemIds, currentCutoff)
val movesByItem = loadStockDateLedgerMoves(itemIds, openingCutoff, currentCutoff)
val uomByItem = loadStockUnitLabels(itemIds)

val minBalance = balanceFilterStart?.toDoubleOrNull()
val maxBalance = balanceFilterEnd?.toDoubleOrNull()

return items.mapNotNull { item ->
val itemId = jdbcLong(item["itemId"]) ?: return@mapNotNull null
val opening = openingByItem[itemId] ?: 0.0
val current = currentByItem[itemId] ?: 0.0
val moves = movesByItem[itemId]
val hasLedger = openingByItem.containsKey(itemId) ||
currentByItem.containsKey(itemId) ||
movesByItem.containsKey(itemId)
if (!hasItemCodeFilter && !hasLedger) return@mapNotNull null
if (minBalance != null && current < minBalance) return@mapNotNull null
if (maxBalance != null && current > maxBalance) return@mapNotNull null

val avg = jdbcDouble(item["avgUnitPriceRaw"])
val cumIn = moves?.cumStockIn ?: 0.0
val cumOut = moves?.cumStockOut ?: 0.0
val miss = moves?.misInputAndLost ?: 0.0
val variance = moves?.variance ?: 0.0
val defective = moves?.defectiveGoods ?: 0.0
mapOf(
"stockSubCategory" to "",
"itemNo" to (item["itemNo"]?.toString().orEmpty()),
"itemName" to (item["itemName"]?.toString().orEmpty()),
"unitOfMeasure" to (uomByItem[itemId] ?: ""),
"lotNo" to "",
"expiryDate" to "",
"openingBalance" to "",
"cumStockIn" to "",
"cumStockOut" to "",
"currentBalance" to "",
"reOrderQty" to "",
"storeLocation" to "",
"lastInDate" to "",
"lastOutDate" to "",
"openingBalanceRaw" to opening,
"currentBalanceRaw" to current,
"totalOpeningBalance" to formatReportQty(opening),
"totalCumStockIn" to formatReportQty(cumIn),
"totalCumStockOut" to formatReportQty(cumOut),
"totalCurrentBalance" to formatReportQty(current),
"misInputAndLost" to "",
"defectiveGoods" to "",
"variance" to "",
"totalMisInputAndLost" to formatReportQty(miss),
"totalVariance" to formatReportQty(variance),
"totalDefectiveGoods" to formatReportQty(defective),
"avgUnitPrice" to formatReportMoney(avg),
"totalStockBalance" to formatReportMoney(avg * current),
)
}
}

private data class StockBalanceMoves(
val cumStockIn: Double,
val cumStockOut: Double,
val misInputAndLost: Double,
val defectiveGoods: Double,
val variance: Double,
)

private fun loadLatestLedgerBalances(
itemIds: List<Long>,
cutoffExclusive: java.time.LocalDateTime,
): Map<Long, Double> {
val out = HashMap<Long, Double>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>(
"itemIds" to ArrayList(chunk),
"cutoff" to cutoffExclusive,
)
val rows = jdbcDao.queryForList(
"""
SELECT sl.itemId AS itemId, COALESCE(sl.balance, 0) AS balance
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) <= p.d0
GROUP BY sl.itemId
),
ledger_moves AS (
INNER JOIN (
SELECT s.itemId AS itemId, MAX(s.id) AS maxId
FROM stock_ledger s
INNER JOIN (
SELECT itemId, MAX(date) AS maxDate
FROM stock_ledger
WHERE deleted = 0
AND itemId IN (:itemIds)
AND date < :cutoff
GROUP BY itemId
) latest ON latest.itemId = s.itemId AND s.date = latest.maxDate
WHERE s.deleted = 0
AND s.itemId IN (:itemIds)
GROUP BY s.itemId
) t ON sl.id = t.maxId
""".trimIndent(),
args,
)
for (row in rows) {
val id = jdbcLong(row["itemId"]) ?: continue
out[id] = jdbcDouble(row["balance"])
}
}
return out
}

private fun loadStockDateLedgerMoves(
itemIds: List<Long>,
startInclusive: java.time.LocalDateTime,
endExclusive: java.time.LocalDateTime,
): Map<Long, StockBalanceMoves> {
val out = HashMap<Long, StockBalanceMoves>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>(
"itemIds" to ArrayList(chunk),
"startAt" to startInclusive,
"endAt" to endExclusive,
)
val rows = jdbcDao.queryForList(
"""
SELECT
sl.itemId,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
AND COALESCE(sl.inQty, 0) > 0
THEN COALESCE(sl.inQty, 0)
ELSE 0
END
) AS cumStockIn,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS cumStockOut,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS'
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS misInputAndLost,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD'
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS defectiveGoods,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE'
THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)
ELSE 0
END
) AS variance
sl.itemId AS itemId,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.inQty, 0) > 0 THEN COALESCE(sl.inQty, 0) ELSE 0 END) AS cumStockIn,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS cumStockOut,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS misInputAndLost,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS defectiveGoods,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END) AS variance
FROM stock_ledger sl
INNER JOIN params p ON 1=1
WHERE sl.deleted = 0
AND sl.itemId IS NOT NULL
AND DATE(sl.date) > p.d1
AND DATE(sl.date) <= p.d0
AND sl.itemId IN (:itemIds)
AND sl.date >= :startAt
AND sl.date < :endAt
GROUP BY sl.itemId
""".trimIndent(),
args,
)
SELECT
'' AS stockSubCategory,
COALESCE(s.itemNo, '') AS itemNo,
COALESCE(s.itemName, '') AS itemName,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
'' AS lotNo,
'' AS expiryDate,
'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS reOrderQty,
COALESCE(store_location.storeLocation, '') AS storeLocation,
COALESCE(DATE_FORMAT(lio.lastInDate, '%Y-%m-%d'), '') AS lastInDate,
COALESCE(DATE_FORMAT(lio.lastOutDate, '%Y-%m-%d'), '') AS lastOutDate,
COALESCE(op.openingBalance, 0) AS openingBalanceRaw,
COALESCE(cp.currentBalance, 0) AS currentBalanceRaw,
CASE WHEN COALESCE(op.openingBalance, 0) < 0 THEN CONCAT('(', FORMAT(-op.openingBalance, 0), ')') ELSE FORMAT(COALESCE(op.openingBalance, 0), 0) END AS totalOpeningBalance,
CASE WHEN COALESCE(lm.cumStockIn, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockIn, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockIn, 0), 0) END AS totalCumStockIn,
CASE WHEN COALESCE(lm.cumStockOut, 0) < 0 THEN CONCAT('(', FORMAT(-lm.cumStockOut, 0), ')') ELSE FORMAT(COALESCE(lm.cumStockOut, 0), 0) END AS totalCumStockOut,
CASE WHEN COALESCE(cp.currentBalance, 0) < 0 THEN CONCAT('(', FORMAT(-cp.currentBalance, 0), ')') ELSE FORMAT(COALESCE(cp.currentBalance, 0), 0) END AS totalCurrentBalance,
'' AS misInputAndLost,
'' AS defectiveGoods,
'' AS variance,
CASE WHEN COALESCE(lm.misInputAndLost, 0) < 0 THEN CONCAT('(', FORMAT(-lm.misInputAndLost, 0), ')') ELSE FORMAT(COALESCE(lm.misInputAndLost, 0), 0) END AS totalMisInputAndLost,
CASE WHEN COALESCE(lm.variance, 0) < 0 THEN CONCAT('(', FORMAT(-lm.variance, 0), ')') ELSE FORMAT(COALESCE(lm.variance, 0), 0) END AS totalVariance,
CASE WHEN COALESCE(lm.defectiveGoods, 0) < 0 THEN CONCAT('(', FORMAT(-lm.defectiveGoods, 0), ')') ELSE FORMAT(COALESCE(lm.defectiveGoods, 0), 0) END AS totalDefectiveGoods,
FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0), 2), 2) AS avgUnitPrice,
FORMAT(ROUND(COALESCE(s.avgUnitPriceRaw, 0) * COALESCE(cp.currentBalance, 0), 2), 2) AS totalStockBalance
FROM item_scope s
LEFT JOIN opening_per_item op ON op.itemId = s.itemId
LEFT JOIN current_per_item cp ON cp.itemId = s.itemId
LEFT JOIN last_in_out lio ON lio.itemId = s.itemId
LEFT JOIN ledger_moves lm ON lm.itemId = s.itemId
LEFT JOIN item_uom iu ON iu.itemId = s.itemId AND iu.stockUnit = 1 AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN store_location ON store_location.itemId = s.itemId
WHERE 1=1
$storeLocationSql
""".trimIndent()

val filters = mutableListOf<String>()
if (!balanceFilterStart.isNullOrBlank()) {
args["balanceFilterStart"] = balanceFilterStart.toDoubleOrNull() ?: 0.0
filters.add("COALESCE(currentBalanceRaw, 0) >= :balanceFilterStart")
}
if (!balanceFilterEnd.isNullOrBlank()) {
args["balanceFilterEnd"] = balanceFilterEnd.toDoubleOrNull() ?: 0.0
filters.add("COALESCE(currentBalanceRaw, 0) <= :balanceFilterEnd")
for (row in rows) {
val id = jdbcLong(row["itemId"]) ?: continue
out[id] = StockBalanceMoves(
cumStockIn = jdbcDouble(row["cumStockIn"]),
cumStockOut = jdbcDouble(row["cumStockOut"]),
misInputAndLost = jdbcDouble(row["misInputAndLost"]),
defectiveGoods = jdbcDouble(row["defectiveGoods"]),
variance = jdbcDouble(row["variance"]),
)
}
}
return out
}

val finalSql =
if (filters.isEmpty()) {
// no numeric filter: just order by itemNo
baseSql
} else {
// wrap to filter on raw numeric current balance
private fun loadStockUnitLabels(itemIds: List<Long>): Map<Long, String> {
val out = HashMap<Long, String>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>("itemIds" to ArrayList(chunk))
val rows = jdbcDao.queryForList(
"""
SELECT * FROM (
$baseSql
) base
WHERE ${filters.joinToString(" AND ")}
""".trimIndent()
SELECT iu.itemId AS itemId, COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure
FROM item_uom iu
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
WHERE iu.deleted = 0
AND iu.stockUnit = 1
AND iu.itemId IN (:itemIds)
""".trimIndent(),
args,
)
for (row in rows) {
val id = jdbcLong(row["itemId"]) ?: continue
out[id] = row["unitOfMeasure"]?.toString().orEmpty()
}
}
return out
}

return jdbcDao.queryForList("$finalSql ORDER BY itemNo", args)
private fun jdbcLong(value: Any?): Long? = when (value) {
null -> null
is Number -> value.toLong()
else -> value.toString().toLongOrNull()
}

private fun jdbcDouble(value: Any?): Double = when (value) {
null -> 0.0
is Number -> value.toDouble()
else -> value.toString().replace(",", "").toDoubleOrNull() ?: 0.0
}

private fun formatReportQty(value: Double): String {
val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US))
return if (value < 0) "(${fmt.format(-value)})" else fmt.format(value)
}

private fun formatReportMoney(value: Double): String {
val fmt = DecimalFormat("#,##0.00", DecimalFormatSymbols(Locale.US))
return fmt.format(kotlin.math.round(value * 100.0) / 100.0)
}
/**
* Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template.
*/
fun createExcelResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray {
val stream = this::class.java.getResourceAsStream(templatePath)
?: throw RuntimeException("Report template not found: $templatePath")
val jasperReport = JasperCompileManager.compileReport(stream)
val jasperReport = compiledReport(templatePath)
val dataSource = JRMapCollectionDataSource(dataList)
val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource)
val out = ByteArrayOutputStream()
@@ -1706,13 +1755,8 @@ return result
* Compiles and fills a Jasper Report, returning the PDF as a ByteArray.
*/
fun createPdfResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray {
val stream = this::class.java.getResourceAsStream(templatePath)
?: throw RuntimeException("Report template not found: $templatePath")
val jasperReport = JasperCompileManager.compileReport(stream)
val jasperReport = compiledReport(templatePath)
val dataSource = JRMapCollectionDataSource(dataList)
val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource)
return JasperExportManager.exportReportToPdf(jasperPrint)
}


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

@@ -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 Zobrazit soubor

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


+ 257
- 0
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt Zobrazit soubor

@@ -0,0 +1,257 @@
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 itemNo: 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()
if (nextItem != itemNo) {
closeGroup()
itemNo = nextItem
count = 0
val name = row["itemName"]?.toString().orEmpty()
val uom = row["unitOfMeasure"]?.toString().orEmpty()
val itemTitle = listOf(nextItem, name, uom).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
}
}

+ 355
- 222
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt Zobrazit soubor

@@ -2,8 +2,13 @@ package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale

@Service
open class StockLedgerReportService(
@@ -22,7 +27,9 @@ open class StockLedgerReportService(
* - cumBalance = stock_ledger.balance(異動後纍計存量)
* - cumOpeningBal = balance - inQty + outQty(異動前纍計期初)
*
* 只查 [start, end] 期間列,不掃起日以前全歷史。
* 先用貨品/日期索引切出流水帳,再一對一接批次行。
* 不依 inventoryLotId 展開該批次的全部儲位,否則一筆入庫會變成多列。
* 貨品總量在取出明細後計算,避免對寬結果做 window sort。
*/
fun searchStockLedgerReport(
stockCategory: String?,
@@ -33,6 +40,7 @@ open class StockLedgerReportService(
): List<Map<String, Any>> {

val args = mutableMapOf<String, Any>()
val itemPredicate = ledgerItemPredicate(itemCode, args)

val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-")
?: LocalDate.now().format(dateFmt))
@@ -40,17 +48,7 @@ open class StockLedgerReportService(
val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) {
reportPeriodStart.replace("/", "-")
} else {
val minDateSql = """
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
""".trimIndent()

val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull()
(minDateRow?.get("firstDate") as? String)
?: reportPeriodEnd
earliestLedgerDate(itemPredicate, args) ?: reportPeriodEnd
}

val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt)
@@ -64,237 +62,372 @@ open class StockLedgerReportService(
args
)

val itemCodeSql = buildMultiValueLikeClause(
itemCode,
"sl.itemCode",
"itemCode",
args
)

// 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND lot.storeLocation LIKE :storeLocation"
"AND wh.code LIKE :storeLocation"
} else {
""
}

val sql = """
SELECT
stockSubCategory,
itemNo,
itemName,
unitOfMeasure,
lotNo,
expiryDate,
trnDate,
CASE trnRefNoRaw
WHEN 'OPEN' THEN '開倉'
WHEN 'NOR' THEN '出入倉'
WHEN 'TRF' THEN '轉倉'
WHEN 'ADJ' THEN '調整'
WHEN 'MISS' THEN '遺失'
WHEN 'BAD' THEN '不良品'
WHEN 'TKE' THEN '盤點'
ELSE trnRefNoRaw
END AS trnRefNo,
storeLocation,
orderRefNo,
jobOrderNo,

openingBalance,
cumStockIn,
cumStockOut,
currentBalance,
lastInDate,
lastOutDate,
reOrderLevel,
reOrderQty,

CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn,
CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut,

CASE WHEN cumOpeningBalRaw < 0
THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')')
ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal,
CASE WHEN bal < 0
THEN CONCAT('(', FORMAT(-bal, 0), ')')
ELSE FORMAT(bal, 0) END AS cumBalance,

CASE WHEN totalCumOpeningBalRaw < 0
THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')')
ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal,
CASE WHEN totalStockInRaw < 0
THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')')
ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn,
CASE WHEN totalStockOutRaw < 0
THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')')
ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut,
CASE WHEN totalCumBalanceRaw < 0
THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')')
ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance
val uomItemSql = if (itemPredicate.itemIdParamNames.isEmpty()) {
""
} else {
"AND iu.itemId IN (${itemPredicate.itemIdParamNames.joinToString(", ") { ":$it" }})"
}

// One item is already ordered by the (itemId, deleted, date, id) index.
val orderBy = if (itemPredicate.singleItem) {
"ledger.dt, ledger.slId"
} else {
"it.code, ledger.dt, ledger.slId"
}

// NO_MERGE keeps the date/item slice materialized before the lot joins.
val sql = """
SELECT /*+ NO_MERGE(ledger) */
ledger.slId AS slId,
ledger.itemCode AS itemCode,
DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(ledger.ledgerType, '')
END AS trnRefNoRaw,
COALESCE(ledger.inQty, 0) AS inQty,
COALESCE(ledger.outQty, 0) AS outQty,
COALESCE(ledger.bal, 0) AS bal,
COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS cumOpeningBalRaw,
it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uom.unitOfMeasure AS unitOfMeasure,
il.lotNo AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
wh.code AS storeLocation,
COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo
FROM (
SELECT
x.*,
FIRST_VALUE(cumOpeningBalRaw) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw, slId
) AS totalCumOpeningBalRaw,
SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw,
SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw,
FIRST_VALUE(bal) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC
) AS totalCumBalanceRaw
FROM (
SELECT
sl.id AS slId,
DATE(sl.date) AS trnDateRaw,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(sl.type, '')
END AS trnRefNoRaw,

sl.itemCode AS itemCode,
COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
COALESCE(sl.balance, 0) AS bal,
COALESCE(sl.balance, 0)
- COALESCE(sl.inQty, 0)
+ COALESCE(sl.outQty, 0) AS cumOpeningBalRaw,

it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uc.udfudesc AS unitOfMeasure,

lot.lotNo AS lotNo,
COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
lot.storeLocation AS storeLocation,

COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo,

'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS lastInDate,
'' AS lastOutDate,
'' AS reOrderLevel,
'' AS reOrderQty
FROM stock_ledger sl
LEFT JOIN stock_in_line sil
ON sl.stockInLineId = sil.id
AND sil.deleted = 0
LEFT JOIN inventory_lot il_in
ON sil.inventoryLotId = il_in.id
AND il_in.deleted = 0

LEFT JOIN stock_out_line sol
ON sl.stockOutLineId = sol.id
AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out
ON sol.inventoryLotLineId = ill_out.id
AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out
ON ill_out.inventoryLotId = il_out.id
AND il_out.deleted = 0

LEFT JOIN (
SELECT
il.id AS lotId,
il.lotNo AS lotNo,
il.expiryDate AS expiryDate,
MAX(wh.code) AS storeLocation
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.deleted = 0
LEFT JOIN warehouse wh
ON ill.warehouseId = wh.id
AND wh.deleted = 0
WHERE il.deleted = 0
GROUP BY
il.id, il.lotNo, il.expiryDate
) lot
ON lot.lotId = COALESCE(il_in.id, il_out.id)

LEFT JOIN items it
ON sl.itemId = it.id
AND it.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id

LEFT JOIN stock_out so
ON sol.stockOutId = so.id
AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON sol.pickOrderLineId = pol.id
AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON pol.poId = po_out.id
AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON po_out.joId = jo_po.id
AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON po_out.doId = do.id
AND do.deleted = 0
LEFT JOIN stock_in si
ON sil.stockInId = si.id
AND si.deleted = 0
LEFT JOIN job_order jo
ON sil.jobOrderId = jo.id
AND jo.deleted = 0
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE
sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
$stockCategorySql
$itemCodeSql
$storeLocationSql
AND lot.lotId IS NOT NULL
) x
) y
sl.id AS slId,
sl.itemCode AS itemCode,
sl.date AS dt,
sl.type AS ledgerType,
sl.inQty AS inQty,
sl.outQty AS outQty,
sl.balance AS bal,
sl.itemId AS itemId,
sl.stockInLineId AS stockInLineId,
sl.stockOutLineId AS stockOutLineId,
sl.inventoryLotLineId AS inventoryLotLineId
FROM stock_ledger sl ${itemPredicate.indexHint}
WHERE sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
${itemPredicate.sql}
) ledger
LEFT JOIN stock_in_line sil
ON ledger.stockInLineId = sil.id
AND sil.deleted = 0
LEFT JOIN stock_out_line sol
ON ledger.stockOutLineId = sol.id
AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill
ON ill.id = ledger.inventoryLotLineId
AND ill.deleted = 0
LEFT JOIN inventory_lot_line ill_sil
ON ill.id IS NULL
AND ill_sil.id = sil.inventoryLotLineId
AND ill_sil.deleted = 0
LEFT JOIN inventory_lot_line ill_sol
ON ill.id IS NULL
AND ill_sil.id IS NULL
AND ill_sol.id = sol.inventoryLotLineId
AND ill_sol.deleted = 0
LEFT JOIN inventory_lot il
ON il.id = COALESCE(
ill.inventoryLotId,
ill_sil.inventoryLotId,
ill_sol.inventoryLotId,
sil.inventoryLotId
)
AND il.deleted = 0
LEFT JOIN warehouse wh
ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId)
AND wh.deleted = 0
LEFT JOIN items it
ON ledger.itemId = it.id
AND it.deleted = 0
LEFT JOIN (
SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure
FROM item_uom iu
JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.stockUnit = 1
AND iu.deleted = 0
$uomItemSql
GROUP BY iu.itemId
) uom ON uom.itemId = it.id
LEFT JOIN stock_out so
ON sol.stockOutId = so.id
AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON sol.pickOrderLineId = pol.id
AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON pol.poId = po_out.id
AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON po_out.joId = jo_po.id
AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON po_out.doId = do.id
AND do.deleted = 0
LEFT JOIN stock_in si
ON sil.stockInId = si.id
AND si.deleted = 0
LEFT JOIN job_order jo
ON sil.jobOrderId = jo.id
AND jo.deleted = 0
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE il.id IS NOT NULL
$stockCategorySql
$storeLocationSql
ORDER BY
itemNo,
trnDateRaw,
slId,
lotNo
$orderBy
""".trimIndent()

return jdbcDao.queryForList(sql, args)
return attachItemTotals(jdbcDao.queryForList(sql, args))
}

/** LIKE 多值工具方法 */
private fun buildMultiValueLikeClause(
/**
* Exact item codes become itemId so MySQL can use
* idx_ledger_item_deleted_date_id (itemId, deleted, date, id).
*/
private fun ledgerItemPredicate(itemCode: String?, args: MutableMap<String, Any>): LedgerItemPredicate {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode)
if (exact.isEmpty() && fuzzy.isEmpty()) {
return LedgerItemPredicate(
sql = "",
indexHint = "FORCE INDEX (idx_sl_deleted_date)",
singleItem = false,
itemIdParamNames = emptyList(),
)
}
if (fuzzy.isEmpty()) {
val ids = lookupItemIds(exact)
if (ids.isNotEmpty()) {
val names = ids.mapIndexed { index, id ->
val name = "itemId_$index"
args[name] = id
name
}
return LedgerItemPredicate(
sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})",
indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)",
singleItem = ids.size == 1,
itemIdParamNames = names,
)
}
}
val oneExactCode = fuzzy.isEmpty() && exact.size == 1
return LedgerItemPredicate(
sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args),
indexHint = if (exact.size + fuzzy.size == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "",
singleItem = oneExactCode,
itemIdParamNames = emptyList(),
)
}

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

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

private data class LedgerItemPredicate(
val sql: String,
val indexHint: String,
val singleItem: Boolean,
val itemIdParamNames: List<String>,
)

/**
* Item totals match the old window: first opening, sum in/out, last balance per itemCode.
* Rows stay ordered by item, date, id.
*/
private fun attachItemTotals(rows: List<Map<String, Any>>): List<Map<String, Any>> {
if (rows.isEmpty()) return rows
val totals = HashMap<String, LedgerItemTotals>(rows.size.coerceAtMost(4096))
for (row in rows) {
val key = row["itemCode"]?.toString().orEmpty()
val bucket = totals.getOrPut(key) { LedgerItemTotals() }
val inQty = toQty(row["inQty"])
val outQty = toQty(row["outQty"])
val opening = toQty(row["cumOpeningBalRaw"])
val closing = toQty(row["bal"])
val date = row["trnDate"]?.toString().orEmpty()
val id = toLongId(row["slId"])
bucket.inSum = bucket.inSum.add(inQty)
bucket.outSum = bucket.outSum.add(outQty)
if (!bucket.seen || earlier(date, id, bucket.firstDate, bucket.firstId)) {
bucket.opening = opening
bucket.firstDate = date
bucket.firstId = id
}
if (!bucket.seen || later(date, id, bucket.lastDate, bucket.lastId)) {
bucket.closing = closing
bucket.lastDate = date
bucket.lastId = id
}
bucket.seen = true
}

val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US))
val out = ArrayList<Map<String, Any>>(rows.size)
for (row in rows) {
val bucket = totals.getValue(row["itemCode"]?.toString().orEmpty())
val trnRefNoRaw = row["trnRefNoRaw"]?.toString().orEmpty()
out.add(
linkedMapOf(
"stockSubCategory" to text(row["stockSubCategory"]),
"itemNo" to text(row["itemNo"]),
"itemName" to text(row["itemName"]),
"unitOfMeasure" to text(row["unitOfMeasure"]),
"lotNo" to text(row["lotNo"]),
"expiryDate" to text(row["expiryDate"]),
"trnDate" to text(row["trnDate"]),
"trnRefNo" to ledgerTypeLabel(trnRefNoRaw),
"storeLocation" to text(row["storeLocation"]),
"orderRefNo" to text(row["orderRefNo"]),
"jobOrderNo" to text(row["jobOrderNo"]),
"openingBalance" to "",
"cumStockIn" to "",
"cumStockOut" to "",
"currentBalance" to "",
"lastInDate" to "",
"lastOutDate" to "",
"reOrderLevel" to "",
"reOrderQty" to "",
"stockIn" to formatLedgerQty(toQty(row["inQty"]), fmt),
"stockOut" to formatLedgerQty(toQty(row["outQty"]), fmt),
"cumOpeningBal" to formatLedgerQty(toQty(row["cumOpeningBalRaw"]), fmt),
"cumBalance" to formatLedgerQty(toQty(row["bal"]), fmt),
"totalCumOpeningBal" to formatLedgerQty(bucket.opening, fmt),
"totalStockIn" to formatLedgerQty(bucket.inSum, fmt),
"totalStockOut" to formatLedgerQty(bucket.outSum, fmt),
"totalCumBalance" to formatLedgerQty(bucket.closing, fmt),
)
)
}
return out
}

private class LedgerItemTotals {
var inSum: BigDecimal = BigDecimal.ZERO
var outSum: BigDecimal = BigDecimal.ZERO
var opening: BigDecimal = BigDecimal.ZERO
var closing: BigDecimal = BigDecimal.ZERO
var firstDate: String = ""
var firstId: Long = 0
var lastDate: String = ""
var lastId: Long = 0
var seen: Boolean = false
}

private fun earlier(date: String, id: Long, otherDate: String, otherId: Long): Boolean =
date < otherDate || (date == otherDate && id < otherId)

private fun later(date: String, id: Long, otherDate: String, otherId: Long): Boolean =
date > otherDate || (date == otherDate && id > otherId)

private fun ledgerTypeLabel(raw: String): String = when (raw) {
"OPEN" -> "開倉"
"NOR" -> "出入倉"
"TRF" -> "轉倉"
"ADJ" -> "調整"
"MISS" -> "遺失"
"BAD" -> "不良品"
"TKE" -> "盤點"
else -> raw
}

private fun formatLedgerQty(value: BigDecimal, fmt: DecimalFormat): String {
val rounded = value.setScale(0, RoundingMode.HALF_UP)
val text = fmt.format(rounded.abs())
return if (rounded.signum() < 0) "($text)" else text
}

private fun toQty(value: Any?): BigDecimal = when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Long -> BigDecimal.valueOf(value)
is Int -> BigDecimal.valueOf(value.toLong())
is Number -> BigDecimal(value.toString())
else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}

private fun toLongId(value: Any?): Long = when (value) {
is Number -> value.toLong()
else -> value?.toString()?.toLongOrNull() ?: 0L
}

private fun text(value: Any?): String = value?.toString().orEmpty()

/** 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 (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue)
if (exact.isEmpty() && fuzzy.isEmpty()) return ""

val conditions = values.mapIndexed { index, value ->
val paramName = "${paramPrefix}_$index"
args[paramName] = "%$value%"
"$columnName LIKE :$paramName"
val parts = mutableListOf<String>()
exact.forEachIndexed { index, value ->
val paramName = "${paramPrefix}_eq_$index"
args[paramName] = value
parts.add("$columnName = :$paramName")
}
return "AND (${conditions.joinToString(" OR ")})"
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 ")})"
}

/** = 多值工具方法 */


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

@@ -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>()



+ 336
- 65
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Zobrazit soubor

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

@@ -122,6 +123,27 @@ class ReportController(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?
): ResponseEntity<ByteArray> =
buildStockInTraceabilityPdf(stockCategory, itemCode, lastInDateStart, lastInDateEnd)

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

private fun buildStockInTraceabilityPdf(
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
@@ -161,6 +183,27 @@ class ReportController(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?,
): ResponseEntity<ByteArray> =
buildStockInTraceabilityExcel(stockCategory, itemCode, lastInDateStart, lastInDateEnd)

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

private fun buildStockInTraceabilityExcel(
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = reportService.searchStockInTraceabilityReport(
stockCategory,
@@ -192,6 +235,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>()
@@ -239,6 +307,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,
@@ -278,10 +371,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 ?: ""
@@ -292,35 +529,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 {
@@ -328,48 +554,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,
@@ -850,6 +1063,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,
@@ -926,6 +1140,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) }
@@ -944,6 +1161,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,
@@ -1004,6 +1246,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.
@@ -1082,4 +1337,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,
)

+ 33
- 0
src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt Zobrazit soubor

@@ -0,0 +1,33 @@
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,
) {
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 Zobrazit soubor

@@ -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 Zobrazit soubor

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


+ 69
- 26
src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt Zobrazit soubor

@@ -4,8 +4,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
@@ -23,7 +24,6 @@ import java.io.ByteArrayOutputStream
@RestController
@RequestMapping("/report")
class StockLedgerReportController(
private val reportService: ReportService,
private val stockLedgerReportService: StockLedgerReportService,
) {
private data class ExcelStyles(
@@ -48,22 +48,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,
)
}

private fun generateStockLedgerPdf(
stockCategory: String?,
itemCode: String?,
storeLocation: String?,
reportPeriodStart: String?,
reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
@@ -72,10 +105,12 @@ class StockLedgerReportController(
reportPeriodEnd = reportPeriodEnd,
)

val pdfBytes = reportService.createPdfResponse(
"/jasper/StockLedgarReport.jrxml",
parameters,
dbData,
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 {
@@ -86,14 +121,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 dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
@@ -436,3 +469,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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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;


+ 14
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Zobrazit soubor

@@ -56,6 +56,7 @@ WHERE ill.id = :id

fun findInventoryLotLineInfoByInventoryLotItemIdIn(ids: List<Serializable>): List<InventoryLotLineInfo>

/** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.1 | 2026-09-21 */
@Query("select ill from InventoryLotLine ill where :id is null or ill.inventoryLot.item.id = :id order by ill.id desc")
fun findInventoryLotLineInfoByItemId(id: Long?, pageable: Pageable): Page<InventoryLotLineInfo>

@@ -325,4 +326,17 @@ WHERE ill.id = :id
fun findAllByIdInAndDeletedFalseWithRefs(
@Param("ids") ids: Collection<Long>
): List<InventoryLotLine>

/** Delivery-note route column: warehouse code without a lazy load per lot line. */
@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["warehouse"]
)
@Query(
"""
SELECT ill FROM InventoryLotLine ill
WHERE ill.id IN :ids
"""
)
fun findAllByIdInWithWarehouse(@Param("ids") ids: Collection<Long>): List<InventoryLotLine>
}

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

@@ -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 Zobrazit soubor

@@ -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?;


+ 12
- 4
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Zobrazit soubor

@@ -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
@@ -44,6 +43,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 stockInLineRepository: StockInLineRepository,
private val inventoryRepository: InventoryRepository,
private val printerService: PrinterService,
private val settingsService: SettingsService,
@Lazy
private val jobOrderService: JobOrderService
) {
@@ -76,6 +77,7 @@ open class InventoryLotLineService(
return inventoryLotLineRepository.findInventoryLotLineInfoByInventoryLotItemIdIn(itemIds)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.1 | 2026-09-21 */
open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes<InventoryLotLineInfo> {
val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10);

@@ -246,7 +248,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")
@@ -286,13 +289,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)
}


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt Zobrazit soubor

@@ -89,7 +89,7 @@ open class InventoryService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21
* Inventory search page only: no baseUnit/uomId filter;
* one inventory row per item (latest id). Shared getRecordByPage is unchanged.
*/


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

@@ -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 Zobrazit soubor

@@ -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
@@ -33,6 +32,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
@@ -98,6 +98,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,
@@ -470,6 +471,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
@@ -675,13 +678,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
@@ -1235,7 +1240,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")
@@ -1269,12 +1275,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 Zobrazit soubor

@@ -805,6 +805,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,
@@ -1065,6 +1066,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,
@@ -1175,6 +1177,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,
@@ -1197,6 +1200,7 @@ open class StockTakeRecordService(
stockTakerName = rec.stockTakerName,
stockTakeEndTime = rec.stockTakeEndTime,
approverTime = rec.approverTime,
pickerReviseCount = rec.pickerReviseCount ?: 0,
)
}

@@ -1270,6 +1274,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,
@@ -1292,6 +1297,7 @@ open class StockTakeRecordService(
stockTakerName = stockTakeRecord?.stockTakerName,
stockTakeEndTime = stockTakeRecord?.stockTakeEndTime,
approverTime = stockTakeRecord?.approverTime,
pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0,
)
}

@@ -1338,12 +1344,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
@@ -1363,7 +1416,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)
@@ -2854,6 +2908,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,
@@ -2954,6 +3009,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,


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt Zobrazit soubor

@@ -22,7 +22,7 @@ class InventoryController(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21
* Inventory search page only: latest inventory row per item, no baseUnit/uomId filter.
*/
@GetMapping("/searchLatest/getRecordByPage")


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

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -113,8 +113,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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

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


+ 1
- 1
src/main/resources/jasper/StockLedgarReport.jrxml Zobrazit soubor

@@ -73,7 +73,7 @@
<field name="cumBalance" class="java.lang.String"/>
<field name="jobOrderNo" class="java.lang.String"/>
<field name="orderRefNo" class="java.lang.String"/>
<group name="Group1" keepTogether="true">
<group name="Group1" keepTogether="false">
<groupExpression><![CDATA[$F{itemNo}]]></groupExpression>
<groupHeader>
<band height="18">


+ 172
- 0
src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

@@ -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 Zobrazit soubor

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

Načítá se…
Zrušit
Uložit