瀏覽代碼

Merge branch 'production' into stockTakeUpdate

Co-authored-by: Cursor <[email protected]>

# Conflicts:
#	src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt
stockTakeUpdate
CANCERYS\kw093 2 週之前
父節點
當前提交
3e14e62e7e
共有 41 個文件被更改,包括 2030 次插入 和 233 次删除
  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. +13
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  5. +13
    -3
      src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt
  6. +46
    -18
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  7. +34
    -16
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  8. +3
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  9. +11
    -2
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  10. +225
    -75
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  11. +1
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt
  12. +44
    -6
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  13. +12
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt
  14. +7
    -7
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt
  15. +14
    -3
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt
  16. +2
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt
  17. +117
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  18. +159
    -69
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  19. +249
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt
  20. +13
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  21. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt
  22. +11
    -4
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  23. +66
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt
  24. +52
    -11
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  25. +114
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt
  26. +51
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  27. +7
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt
  28. +5
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  29. +3
    -2
      src/main/resources/application-prod.yml
  30. +2
    -1
      src/main/resources/application.yml
  31. +8
    -0
      src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql
  32. +5
    -0
      src/main/resources/db/changelog/changes/20261001_picker_revise_count/01_add_picker_revise_count.sql
  33. +9
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/01_setting.sql
  34. +7
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/02_allow_version_3.sql
  35. +7
    -0
      src/main/resources/db/changelog/changes/20261001_po_item_label_version/03_allow_version_4.sql
  36. +8
    -0
      src/main/resources/db/changelog/changes/20261002_scheduler_sync_log_text/01_widen_query_and_error_message.sql
  37. +19
    -0
      src/main/resources/db/changelog/changes/20261004_performance_authority/01_add_performance_authority.sql
  38. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml
  39. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v3.jrxml
  40. +172
    -0
      src/main/resources/qrCodeLabel/poItemPDF_v4.jrxml
  41. +82
    -0
      src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt

+ 74
- 5
src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt 查看文件

@@ -3,8 +3,11 @@ package com.ffii.core.utils
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.rendering.PDFRenderer
import java.awt.image.BufferedImage
import java.awt.image.DataBufferByte
import java.awt.image.MultiPixelPackedSampleModel
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.InetSocketAddress
import java.net.Socket
import org.slf4j.LoggerFactory

@@ -12,6 +15,7 @@ class BrotherPrinterUtil {

companion object {
private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept
private const val CONNECT_TIMEOUT_MS = 5_000
private const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200

private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java)
@@ -25,11 +29,14 @@ class BrotherPrinterUtil {

log.info("Brother print: file=${pdfFile.name} size=${pdfFile.length()} ip=$printerIp port=$printerPort copies=$printQty")

PrinterLockManager.withLock(printerIp, printerPort) {
PDDocument.load(pdfFile).use { document ->
val renderer = PDFRenderer(document)
val totalPages = document.numberOfPages

Socket(printerIp, printerPort).use { socket ->
Socket().use { socket ->
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(printerIp, printerPort), CONNECT_TIMEOUT_MS)
socket.soTimeout = SOCKET_TIMEOUT_MS
val os = socket.getOutputStream()

@@ -52,6 +59,7 @@ class BrotherPrinterUtil {
log.info("Brother print job sent successfully")
}
}
}
}

private fun getPjlHeader(qty: Int): ByteArray {
@@ -67,6 +75,8 @@ class BrotherPrinterUtil {
* Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer.
*/
private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray {
convertPackedBinaryToPcl(image, dpi)?.let { return it }

val out = ByteArrayOutputStream()
val width = image.width
val height = image.height
@@ -75,21 +85,21 @@ class BrotherPrinterUtil {
out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset
// PCL: Set resolution and start raster
out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII))
out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII))
out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII))

for (y in 0 until height) {
val rowBytes = (width + 7) / 8
val data = ByteArray(rowBytes)
for (x in 0 until width) {
// Check if pixel is dark (threshold)
val color = image.getRGB(x, y)
val isBlack = (color and 0xFF) < 128
val isBlack = (color and 0xFF) < 128
if (isBlack) {
data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte()
}
}
// PCL: Transfer Raster Data (Esc * b # W [data])
out.write("\u001B*b${rowBytes}W".toByteArray())
out.write(data)
@@ -99,5 +109,64 @@ class BrotherPrinterUtil {
out.write("\u001B*rB".toByteArray())
return out.toByteArray()
}

/**
* TYPE_BYTE_BINARY from PDFBox: sample 0 is black, sample 1 is white, MSB first.
* PCL raster uses the opposite polarity (bit 1 = black). getRGB() per pixel is too slow
* for a multi-page A4 note; pack the row from the data buffer instead.
* Returns null when the raster layout does not match, so the caller keeps the slow path.
*/
private fun convertPackedBinaryToPcl(image: BufferedImage, dpi: Int): ByteArray? {
if (image.type != BufferedImage.TYPE_BYTE_BINARY) return null
val raster = image.raster
val sampleModel = raster.sampleModel as? MultiPixelPackedSampleModel ?: return null
if (sampleModel.pixelBitStride != 1 || sampleModel.dataBitOffset != 0) return null
val dataBuffer = raster.dataBuffer as? DataBufferByte ?: return null
if (dataBuffer.numBanks != 1) return null

val bank = dataBuffer.data
val bankOffset = dataBuffer.offsets[0]
val width = image.width
val height = image.height
if (width <= 0 || height <= 0) return null
val stride = sampleModel.scanlineStride
val rowBytes = (width + 7) / 8
if (stride < rowBytes) return null
if (bankOffset < 0 || bankOffset + (height - 1) * stride + rowBytes > bank.size) return null

val check = minOf(8, width)
for (x in 0 until check) {
val srcBit = (bank[bankOffset].toInt() ushr (7 - x)) and 1
val rgbBlack = (image.getRGB(x, 0) and 0xFF) < 128
if ((srcBit == 0) != rgbBlack) return null
}

val out = ByteArrayOutputStream(height * (rowBytes + 12) + 32)
out.write("\u001BE".toByteArray(Charsets.US_ASCII))
out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII))
out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII))

val fullBytes = width / 8
val remainder = width % 8
val remainderMask = if (remainder == 0) 0 else (0xFF shl (8 - remainder)) and 0xFF
val row = ByteArray(rowBytes)

for (y in 0 until height) {
val rowStart = bankOffset + y * stride
var i = 0
while (i < fullBytes) {
row[i] = (bank[rowStart + i].toInt().inv() and 0xFF).toByte()
i++
}
if (remainder != 0) {
val src = bank[rowStart + fullBytes].toInt() and 0xFF
row[fullBytes] = (src.inv() and remainderMask).toByte()
}
out.write("\u001B*b${rowBytes}W".toByteArray(Charsets.US_ASCII))
out.write(row)
}
out.write("\u001B*rB".toByteArray(Charsets.US_ASCII))
return out.toByteArray()
}
}
}

+ 7
- 1
src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt 查看文件

@@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument
import org.slf4j.LoggerFactory
import java.io.File
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket

/**
@@ -111,7 +112,11 @@ open class CanonPrinterUtil {
val copies = printQty ?: 1
repeat(copies) { copyIndex ->
val tSend = System.nanoTime()
Socket(printerIp, printerPort).use { socket ->
PrinterLockManager.withLock(printerIp, printerPort) {
Socket().use { socket ->
socket.tcpNoDelay = true
socket.connect(InetSocketAddress(printerIp, printerPort), 5_000)
socket.soTimeout = 120_000
val os: OutputStream = socket.getOutputStream()

// Send PJL commands
@@ -126,6 +131,7 @@ open class CanonPrinterUtil {

os.flush()
}
}
val sendMs = (System.nanoTime() - tSend) / 1_000_000.0
log.info(
"Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)",


+ 10
- 7
src/main/java/com/ffii/core/utils/QrCodeUtil.kt 查看文件

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

+ 13
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java 查看文件

@@ -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)
@@ -143,6 +144,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 查看文件

@@ -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 查看文件

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

+ 34
- 16
src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt 查看文件

@@ -97,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})"
@@ -233,11 +226,28 @@ 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 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))
}

@@ -357,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?>()

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

@@ -729,11 +744,14 @@ 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
)
}
}


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java 查看文件

@@ -135,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 查看文件

@@ -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 查看文件

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

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

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

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

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

private sealed interface WorkbenchScanIllResolve {
@@ -2949,6 +2964,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")

@@ -2962,17 +2978,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
}
}

@@ -3039,14 +3076,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>()

@@ -3060,18 +3090,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("/", "")
@@ -3097,11 +3118,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 ?: "") }
@@ -3175,6 +3192,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")

@@ -3188,26 +3206,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
}
}

@@ -3228,15 +3268,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()
@@ -3297,15 +3330,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()
@@ -3345,23 +3371,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 查看文件

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


+ 44
- 6
src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt 查看文件

@@ -104,6 +104,9 @@ open class ItemUomService(
open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty;
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty;
if (sameUomId(purchaseUnit, stockUnit)) {
return purchaseQty.setScale(0, RoundingMode.UP)
}
val one = BigDecimal.ONE;

// IMPORTANT:
@@ -129,6 +132,9 @@ open class ItemUomService(
open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty
if (sameUomId(purchaseUnit, stockUnit)) {
return purchaseQty.setScale(0, RoundingMode.DOWN)
}
val one = BigDecimal.ONE

// Use high precision for intermediate steps, and round down only at the end.
@@ -149,6 +155,9 @@ open class ItemUomService(
open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty
val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty
if (sameUomId(purchaseUnit, stockUnit)) {
return stockQty.setScale(0, RoundingMode.UP)
}
val one = BigDecimal.ONE
val calcScale = 10

@@ -168,8 +177,11 @@ open class ItemUomService(
* Returns source qty when source/purchase unit mapping is not found.
*/
open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty
if (sameUomId(uomId, purchaseUnit)) {
return sourceQty.setScale(0, RoundingMode.UP)
}
val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val one = BigDecimal.ONE
val calcScale = 10

@@ -185,9 +197,12 @@ open class ItemUomService(
}

open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty;

val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO;
val stockUnit = findStockUnitByItemId(itemId)
if (stockUnit != null && sameUomId(uomId, stockUnit)) {
return sourceQty.setScale(0, RoundingMode.UP)
}
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
if (stockUnit == null) return BigDecimal.ZERO
val one = BigDecimal.ONE;

val calcScale = 10
@@ -208,8 +223,12 @@ open class ItemUomService(
* Same as convertQtyToStockQty but rounds down the final stock qty to integer.
*/
open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val stockUnit = findStockUnitByItemId(itemId)
if (stockUnit != null && sameUomId(uomId, stockUnit)) {
return sourceQty.setScale(0, RoundingMode.DOWN)
}
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (stockUnit == null) return BigDecimal.ZERO
val one = BigDecimal.ONE
val calcScale = 10

@@ -224,6 +243,18 @@ open class ItemUomService(
return stockQty.setScale(0, RoundingMode.DOWN)
}

/** True when both item UOMs point at the same `uom_conversion` row. */
private fun sameUomId(left: ItemUom?, right: ItemUom?): Boolean {
val leftId = left?.uom?.id ?: return false
val rightId = right?.uom?.id ?: return false
return leftId == rightId
}

private fun sameUomId(uomId: Long, unit: ItemUom?): Boolean {
val unitUomId = unit?.uom?.id ?: return false
return unitUomId == uomId
}

/**
* Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc.
* Weight/volume units (克, 公斤, 升…) keep 2 decimals.
@@ -268,6 +299,9 @@ open class ItemUomService(
open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal {
val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty
val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty
if (sameUomId(purchaseUnit, stockUnit)) {
return finalizePreciseStockQty(stockUnit, purchaseQty)
}
val one = BigDecimal.ONE
val calcScale = 10

@@ -287,8 +321,12 @@ open class ItemUomService(
* Used for PO (M18 UOM) conversion where round-down is not allowed.
*/
open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal {
val stockUnit = findStockUnitByItemId(itemId)
if (stockUnit != null && sameUomId(uomId, stockUnit)) {
return finalizePreciseStockQty(stockUnit, sourceQty)
}
val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty
val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO
if (stockUnit == null) return BigDecimal.ZERO
val one = BigDecimal.ONE
val calcScale = 10



+ 12
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt 查看文件

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository
import org.springframework.data.jpa.repository.EntityGraph
import org.springframework.data.jpa.repository.Modifying
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
@@ -33,6 +34,17 @@ fun findByPickOrderId(pickOrderId: Long): List<PickOrderLine>
)
fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickOrderLine>

/** Delivery-note print: one query, associations initialized (avoids per-line lazy loads). */
@EntityGraph(attributePaths = ["item", "pickOrder", "uom"])
@Query(
"""
SELECT pol FROM PickOrderLine pol
WHERE pol.pickOrder.id IN :pickOrderIds
AND pol.deleted = false
"""
)
fun findAllForPrintByPickOrderIdIn(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickOrderLine>

@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query(
value = """


+ 7
- 7
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt 查看文件

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


+ 14
- 3
src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt 查看文件

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


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt 查看文件

@@ -47,6 +47,8 @@ class PurchaseOrderController(
val criteriaArgs = CriteriaArgsBuilder.withRequest(request)
.addStringLike("supplier")
.addStringLike("code")
.addStringLike("itemCode")
.addStringLike("itemName")
.addString("status")
.addBoolean("escalated")
.addDate("orderDate")


+ 117
- 0
src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt 查看文件

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


+ 159
- 69
src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt 查看文件

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

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

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

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

val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt)
@@ -71,13 +62,6 @@ open class StockLedgerReportService(
args
)

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

val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND wh.code LIKE :storeLocation"
@@ -85,62 +69,96 @@ open class StockLedgerReportService(
""
}

// Wide reports are a date range over the whole ledger. Pin that index so MySQL
// does not start from another stock_ledger index and scan extra rows.
val ledgerIndex = if (itemCode.isNullOrBlank()) "FORCE INDEX (idx_sl_deleted_date)" else ""
val uomItemSql = if (itemPredicate.itemIdParamNames.isEmpty()) {
""
} else {
"AND iu.itemId IN (${itemPredicate.itemIdParamNames.joinToString(", ") { ":$it" }})"
}

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

// NO_MERGE keeps the date/item slice materialized before the lot joins.
val sql = """
SELECT
sl.id AS slId,
sl.itemCode AS itemCode,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
SELECT /*+ NO_MERGE(ledger) */
ledger.slId AS slId,
ledger.itemCode AS itemCode,
DATE_FORMAT(ledger.dt, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ'
WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(ledger.ledgerType, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(sl.type, '')
ELSE COALESCE(ledger.ledgerType, '')
END AS trnRefNoRaw,
COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
COALESCE(sl.balance, 0) AS bal,
COALESCE(sl.balance, 0) - COALESCE(sl.inQty, 0) + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw,
COALESCE(ledger.inQty, 0) AS inQty,
COALESCE(ledger.outQty, 0) AS outQty,
COALESCE(ledger.bal, 0) AS bal,
COALESCE(ledger.bal, 0) - COALESCE(ledger.inQty, 0) + COALESCE(ledger.outQty, 0) AS cumOpeningBalRaw,
it.type AS stockSubCategory,
it.code AS itemNo,
it.name AS itemName,
uom.unitOfMeasure AS unitOfMeasure,
COALESCE(il_in.lotNo, il_out.lotNo) AS lotNo,
COALESCE(
DATE_FORMAT(il_in.expiryDate, '%Y-%m-%d'),
DATE_FORMAT(il_out.expiryDate, '%Y-%m-%d'),
''
) AS expiryDate,
il.lotNo AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
wh.code AS storeLocation,
COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo
FROM stock_ledger sl $ledgerIndex
FROM (
SELECT
sl.id AS slId,
sl.itemCode AS itemCode,
sl.date AS dt,
sl.type AS ledgerType,
sl.inQty AS inQty,
sl.outQty AS outQty,
sl.balance AS bal,
sl.itemId AS itemId,
sl.stockInLineId AS stockInLineId,
sl.stockOutLineId AS stockOutLineId,
sl.inventoryLotLineId AS inventoryLotLineId
FROM stock_ledger sl ${itemPredicate.indexHint}
WHERE sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
${itemPredicate.sql}
) ledger
LEFT JOIN stock_in_line sil
ON sl.stockInLineId = sil.id
ON ledger.stockInLineId = sil.id
AND sil.deleted = 0
LEFT JOIN inventory_lot il_in
ON sil.inventoryLotId = il_in.id
AND il_in.deleted = 0
LEFT JOIN stock_out_line sol
ON sl.stockOutLineId = sol.id
ON ledger.stockOutLineId = sol.id
AND sol.deleted = 0
LEFT JOIN inventory_lot_line ill_out
ON sol.inventoryLotLineId = ill_out.id
AND ill_out.deleted = 0
LEFT JOIN inventory_lot il_out
ON ill_out.inventoryLotId = il_out.id
AND il_out.deleted = 0
LEFT JOIN inventory_lot_line ill_in_loc
ON ill_in_loc.inventoryLotId = il_in.id
AND ill_in_loc.deleted = 0
LEFT JOIN inventory_lot_line ill
ON ill.id = ledger.inventoryLotLineId
AND ill.deleted = 0
LEFT JOIN inventory_lot_line ill_sil
ON ill.id IS NULL
AND ill_sil.id = sil.inventoryLotLineId
AND ill_sil.deleted = 0
LEFT JOIN inventory_lot_line ill_sol
ON ill.id IS NULL
AND ill_sil.id IS NULL
AND ill_sol.id = sol.inventoryLotLineId
AND ill_sol.deleted = 0
LEFT JOIN inventory_lot il
ON il.id = COALESCE(
ill.inventoryLotId,
ill_sil.inventoryLotId,
ill_sol.inventoryLotId,
sil.inventoryLotId
)
AND il.deleted = 0
LEFT JOIN warehouse wh
ON wh.id = COALESCE(ill_in_loc.warehouseId, ill_out.warehouseId)
ON wh.id = COALESCE(ill.warehouseId, ill_sil.warehouseId, ill_sol.warehouseId)
AND wh.deleted = 0
LEFT JOIN items it
ON sl.itemId = it.id
ON ledger.itemId = it.id
AND it.deleted = 0
LEFT JOIN (
SELECT iu.itemId, MIN(uc.udfudesc) AS unitOfMeasure
@@ -148,6 +166,7 @@ FROM stock_ledger sl $ledgerIndex
JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.stockUnit = 1
AND iu.deleted = 0
$uomItemSql
GROUP BY iu.itemId
) uom ON uom.itemId = it.id
LEFT JOIN stock_out so
@@ -174,26 +193,97 @@ FROM stock_ledger sl $ledgerIndex
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE
sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
WHERE il.id IS NOT NULL
$stockCategorySql
$itemCodeSql
$storeLocationSql
AND COALESCE(il_in.id, il_out.id) IS NOT NULL
ORDER BY
itemNo,
trnDate,
slId,
lotNo
$orderBy
""".trimIndent()

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

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

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

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

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

/**
* Item totals match the old window: first opening, sum in/out, last balance per itemCode.
* Rows stay ordered by item, date, id.


+ 249
- 0
src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt 查看文件

@@ -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
@@ -173,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,
@@ -517,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
}
}

+ 13
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt 查看文件

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

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

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt 查看文件

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


+ 11
- 4
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt 查看文件

@@ -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,
private val stockTakeLocationGuard: StockTakeLocationGuard,
@@ -248,7 +249,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")
@@ -288,13 +290,18 @@ open class InventoryLotLineService(
field["acceptedQty"] = "%.2f".format(info.acceptedQty)

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

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


+ 66
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt 查看文件

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

+ 52
- 11
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt 查看文件

@@ -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,
@@ -332,6 +333,33 @@ open class StockInLineService(
latestCode = latestCode,
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
private fun applyRequestedStockQtyRound(
stockInLine: StockInLine,
request: SaveStockInLineRequest,
isCreate: Boolean,
) {
if (request.stockQtyRoundMode.isNullOrBlank()) return
if (stockInLine.purchaseOrderLine == null) return
if (!isCreate) {
val status = stockInLine.status?.lowercase()?.trim()
if (status != StockInLineStatus.PENDING.status && status != StockInLineStatus.ESCALATED.status) {
return
}
}
val currentQty = stockInLine.acceptedQty ?: return
val (roundedQty, remarks) = StockQtyRoundSupport.applyIfRequested(
currentQty = currentQty,
currentRemarks = stockInLine.remarks ?: request.remarks,
request = request,
defaultSource = if (isCreate) "CREATE" else "QC",
)
stockInLine.acceptedQty = roundedQty
stockInLine.remarks = remarks
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
@Throws(IOException::class)
@Transactional
open fun create(request: SaveStockInLineRequest): MessageResponse {
@@ -446,10 +474,13 @@ 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
}
applyRequestedStockQtyRound(stockInLine, request, isCreate = true)
if (jo != null) {
val planStartDate = jo.planStart?.toLocalDate()
stockInLine.lotNo = assignLotNoForJo(planStartDate)
@@ -650,13 +681,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
@@ -945,6 +978,7 @@ open class StockInLineService(
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
@Throws(IOException::class)
@Transactional
open fun update(request: SaveStockInLineRequest): MessageResponse {
@@ -1031,6 +1065,7 @@ open class StockInLineService(
this.expiryDate = stockInLine.expiryDate ?: request.expiryDate
this.remarks = stockInLine.remarks ?: request.remarks
}
applyRequestedStockQtyRound(stockInLine, request, isCreate = false)
_logStep("apply_fields", _tApplyFields)
if (request.status == StockInLineStatus.RECEIVED.status) {
val held = holdAutoPutawayIfSectionInStockTake(request, stockInLine)
@@ -1213,7 +1248,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")
@@ -1247,12 +1283,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)
}


+ 114
- 0
src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt 查看文件

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

import com.ffii.core.exception.BadRequestException
import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

object StockQtyRoundSupport {
private val TAG_REGEX = Regex("""\[QTY_ROUND[^\]]*\]""")
private val AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm")
private val REMARKS_MAX = 500

enum class Mode {
HALF_UP,
CEILING,
FLOOR,
CUSTOM,
}

fun isNotInteger(qty: BigDecimal): Boolean {
return qty.stripTrailingZeros().scale() > 0
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun applyIfRequested(
currentQty: BigDecimal,
currentRemarks: String?,
request: SaveStockInLineRequest,
defaultSource: String,
): Pair<BigDecimal, String?> {
val mode = parseMode(request.stockQtyRoundMode) ?: return currentQty to currentRemarks
if (!isNotInteger(currentQty)) {
return currentQty to currentRemarks
}
val after = roundQty(currentQty, mode, request.stockQtyCustomQty)
val source = request.stockQtyRoundSource?.trim()?.ifBlank { null } ?: defaultSource
val tag = buildTag(
mode = mode,
before = currentQty,
after = after,
source = source,
)
val withReason = if (mode == Mode.CUSTOM) {
val reason = request.stockQtyCustomReason?.trim().orEmpty()
if (reason.isEmpty()) tag else "$tag 原因:$reason"
} else {
tag
}
return after to mergeRemark(currentRemarks, withReason)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun parseMode(raw: String?): Mode? {
val value = raw?.trim()?.uppercase() ?: return null
if (value.isEmpty()) return null
return try {
Mode.valueOf(value)
} catch (_: IllegalArgumentException) {
throw BadRequestException("Invalid stockQtyRoundMode: $raw")
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal {
return when (mode) {
Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP)
Mode.CEILING -> before.setScale(0, RoundingMode.CEILING)
Mode.FLOOR -> before.setScale(0, RoundingMode.FLOOR)
Mode.CUSTOM -> {
val custom = customQty ?: throw BadRequestException("stockQtyCustomQty is required for CUSTOM")
if (custom <= BigDecimal.ZERO) {
throw BadRequestException("stockQtyCustomQty must be greater than 0")
}
if (isNotInteger(custom)) {
throw BadRequestException("stockQtyCustomQty must be an integer")
}
custom.setScale(0, RoundingMode.DOWN)
}
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun buildTag(
mode: Mode,
before: BigDecimal,
after: BigDecimal,
source: String,
at: LocalDateTime = LocalDateTime.now(),
by: String = currentUsername(),
): String {
return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]"
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
fun mergeRemark(existing: String?, tag: String): String {
val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty()
val merged = if (stripped.isEmpty()) tag else "$tag $stripped"
return if (merged.length <= REMARKS_MAX) merged else merged.take(REMARKS_MAX)
}

private fun currentUsername(): String {
val user = SecurityUtils.getUser().orElse(null)
return user?.username ?: user?.name ?: "unknown"
}

private fun plain(qty: BigDecimal): String = qty.stripTrailingZeros().toPlainString()

private fun sanitize(value: String): String {
return value.replace(Regex("""[\s\[\]]+"""), "_").ifBlank { "unknown" }
}
}

+ 51
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt 查看文件

@@ -1615,6 +1615,7 @@ open class StockTakeRecordService(
stockTakerName = rec.stockTakerName,
stockTakeEndTime = rec.stockTakeEndTime,
approverTime = rec.approverTime,
pickerReviseCount = rec.pickerReviseCount ?: 0,
)
}

@@ -1711,6 +1712,7 @@ open class StockTakeRecordService(
stockTakerName = stockTakeRecord?.stockTakerName,
stockTakeEndTime = stockTakeRecord?.stockTakeEndTime,
approverTime = stockTakeRecord?.approverTime,
pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0,
)
}

@@ -1757,12 +1759,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
@@ -1782,7 +1831,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)


+ 7
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockInRequest.kt 查看文件

@@ -37,6 +37,7 @@ data class SaveStockInRequest(
// val m18
)

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */
data class SaveStockInLineRequest(
var id: Long? = null,
var stockInId: Long? = null,
@@ -66,6 +67,12 @@ data class SaveStockInLineRequest(
var inventoryLotLines: List<SaveInventoryLotLineForSil>? = null,
/** True when closing a stock-take round posts a putaway that was held for that round. */
var releaseHeldPutaway: Boolean = false,
/** HALF_UP / CEILING / FLOOR / CUSTOM. Null keeps current decimal stock qty. */
var stockQtyRoundMode: String? = null,
var stockQtyCustomQty: BigDecimal? = null,
/** CREATE (PO submit) or QC (old pending line). */
var stockQtyRoundSource: String? = null,
var stockQtyCustomReason: String? = null,
)

data class SaveInventoryLotLineForSil (


+ 5
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt 查看文件

@@ -107,6 +107,8 @@ data class InventoryLotDetailResponse(
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
@@ -117,7 +119,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 查看文件

@@ -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 查看文件

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


+ 8
- 0
src/main/resources/db/changelog/changes/20260929_truck_lane_authority/01_add_truck_lane_authority.sql 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 172
- 0
src/main/resources/qrCodeLabel/poItemPDF_v2.jrxml 查看文件

@@ -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 查看文件

@@ -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 查看文件

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

+ 82
- 0
src/test/kotlin/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupportTest.kt 查看文件

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

import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
import java.time.LocalDateTime

class StockQtyRoundSupportTest {
@Test
fun isNotInteger_detects_decimals() {
assertTrue(StockQtyRoundSupport.isNotInteger(BigDecimal("10.50")))
assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10.00")))
assertFalse(StockQtyRoundSupport.isNotInteger(BigDecimal("10")))
}

@Test
fun roundQty_halfUp_ceiling_floor() {
val before = BigDecimal("10.50")
assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.HALF_UP, null).compareTo(BigDecimal("11")))
assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CEILING, null).compareTo(BigDecimal("11")))
assertEquals(0, StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.FLOOR, null).compareTo(BigDecimal("10")))
assertEquals(
0,
StockQtyRoundSupport.roundQty(before, StockQtyRoundSupport.Mode.CUSTOM, BigDecimal("12")).compareTo(BigDecimal("12")),
)
}

@Test
fun mergeRemark_replaces_previous_tag() {
val first = StockQtyRoundSupport.buildTag(
mode = StockQtyRoundSupport.Mode.FLOOR,
before = BigDecimal("10.50"),
after = BigDecimal("10"),
source = "CREATE",
at = LocalDateTime.of(2026, 8, 20, 17, 40),
by = "kw093",
)
val mergedOnce = StockQtyRoundSupport.mergeRemark("舊備註", first)
val second = StockQtyRoundSupport.buildTag(
mode = StockQtyRoundSupport.Mode.CUSTOM,
before = BigDecimal("10.50"),
after = BigDecimal("12"),
source = "QC",
at = LocalDateTime.of(2026, 8, 20, 17, 45),
by = "kw093",
)
val mergedTwice = StockQtyRoundSupport.mergeRemark(mergedOnce, second)
assertTrue(mergedTwice.startsWith("[QTY_ROUND mode=CUSTOM"))
assertTrue(mergedTwice.contains("舊備註"))
assertFalse(mergedTwice.contains("mode=FLOOR"))
}

@Test
fun applyIfRequested_writes_custom_tag() {
val request = SaveStockInLineRequest(
itemId = 1,
acceptedQty = BigDecimal("10.50"),
acceptQty = null,
status = null,
expiryDate = null,
stockQtyRoundMode = "CUSTOM",
stockQtyCustomQty = BigDecimal("12"),
stockQtyRoundSource = "QC",
stockQtyCustomReason = "實收12箱",
)
val (qty, remarks) = StockQtyRoundSupport.applyIfRequested(
currentQty = BigDecimal("10.50"),
currentRemarks = null,
request = request,
defaultSource = "QC",
)
assertEquals(0, qty.compareTo(BigDecimal("12")))
assertTrue(remarks!!.contains("mode=CUSTOM"))
assertTrue(remarks.contains("before=10.5"))
assertTrue(remarks.contains("after=12"))
assertTrue(remarks.contains("source=QC"))
assertTrue(remarks.contains("原因:實收12箱"))
}
}

Loading…
取消
儲存