浏览代码

Merge branch 'production' into controllQRcodescanWarehouse.

Keep pick-exclude rules on the settings page. Same expiry date still suggests 3F before 2F.

Co-authored-by: Cursor <[email protected]>
controllQRcodescanWarehouse
CANCERYS\kw093 1 天前
父节点
当前提交
e31671509e
共有 100 个文件被更改,包括 11718 次插入 和 1557 次删除
  1. 二进制
      docs/deploy/20260831_stock_ledger_fix_timeline.xlsx
  2. +35
    -0
      src/main/java/com/ffii/core/support/ErrorHandler.java
  3. +74
    -5
      src/main/java/com/ffii/core/utils/BrotherPrinterUtil.kt
  4. +7
    -1
      src/main/java/com/ffii/core/utils/CanonPrinterUtil.kt
  5. +10
    -7
      src/main/java/com/ffii/core/utils/QrCodeUtil.kt
  6. +13
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  7. +13
    -3
      src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt
  8. +46
    -18
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  9. +34
    -16
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  10. +12
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  11. +93
    -3
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  12. +11
    -0
      src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt
  13. +1
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt
  14. +317
    -95
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  15. +3
    -0
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt
  16. +242
    -83
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  17. +12
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/JobOrderRepository.kt
  18. +2
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt
  19. +29
    -33
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt
  20. +38
    -13
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt
  21. +13
    -2
      src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleLineRepository.kt
  22. +7
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleRepository.kt
  23. +17
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt
  24. +1
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt
  25. +60
    -20
      src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt
  26. +5
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  27. +61
    -13
      src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt
  28. +79
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt
  29. +696
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt
  30. +1690
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt
  31. +853
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt
  32. +258
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt
  33. +775
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt
  34. +190
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt
  35. +7
    -7
      src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
  36. +129
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt
  37. +161
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt
  38. +12
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/PickOrderLineRepository.kt
  39. +36
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLog.kt
  40. +9
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/entity/TruckLaneRenameLogRepository.kt
  41. +39
    -93
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  42. +59
    -87
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt
  43. +61
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt
  44. +299
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt
  45. +26
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt
  46. +15
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt
  47. +7
    -7
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckLaneScheduleController.kt
  48. +44
    -0
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt
  49. +1
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt
  50. +1
    -0
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  51. +14
    -3
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt
  52. +2
    -0
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt
  53. +17
    -7
      src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt
  54. +15
    -3
      src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt
  55. +16
    -6
      src/main/java/com/ffii/fpsms/modules/report/service/MaterialStockOutTraceabilityReportService.kt
  56. +390
    -246
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  57. +117
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  58. +258
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerPdf.kt
  59. +619
    -249
      src/main/java/com/ffii/fpsms/modules/report/service/StockLedgerReportService.kt
  60. +372
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotBalanceReportService.kt
  61. +508
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotLedgerReportService.kt
  62. +24
    -27
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  63. +6
    -3
      src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt
  64. +2
    -2
      src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt
  65. +6
    -3
      src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt
  66. +98
    -16
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  67. +6
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt
  68. +250
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt
  69. +1
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt
  70. +23
    -32
      src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt
  71. +330
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotBalanceReportController.kt
  72. +332
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotLedgerReportController.kt
  73. +4
    -1
      src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt
  74. +4
    -5
      src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt
  75. +13
    -0
      src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java
  76. +9
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/Inventory.kt
  77. +127
    -3
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  78. +50
    -41
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
  79. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockLedgerRepository.kt
  80. +4
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecord.kt
  81. +10
    -23
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt
  82. +28
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfoDto.kt
  83. +31
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt
  84. +28
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRowDto.kt
  85. +56
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryBucketResolver.kt
  86. +26
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLocationSearchQty.kt
  87. +76
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineQtyGuard.kt
  88. +69
    -52
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  89. +344
    -57
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt
  90. +66
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/PoItemLabelTemplate.kt
  91. +3
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt
  92. +83
    -25
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  93. +310
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockLotDayCloseService.kt
  94. +43
    -108
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  95. +7
    -5
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt
  96. +114
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockQtyRoundSupport.kt
  97. +147
    -26
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  98. +1
    -87
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt
  99. +49
    -11
      src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotWorkbenchService.kt
  100. +6
    -4
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt

二进制
docs/deploy/20260831_stock_ledger_fix_timeline.xlsx 查看文件


+ 35
- 0
src/main/java/com/ffii/core/support/ErrorHandler.java 查看文件

@@ -1,9 +1,11 @@
package com.ffii.core.support;

import java.sql.SQLException;
import java.util.UUID;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
@@ -33,6 +35,39 @@ public class ErrorHandler extends ResponseEntityExceptionHandler {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}

/** MySQL SIGNAL SQLSTATE 45000 is a rejected business write, not an unknown crash. */
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<?> sqlSignal(final DataAccessException ex) {
String signal = sqlSignalMessage(ex);
if (signal != null) {
logger.warn("SQLSTATE 45000: " + signal);
return new ResponseEntity<>(new FailureRes(signal), HttpStatus.BAD_REQUEST);
}
return error500(ex);
}

private String sqlSignalMessage(Throwable ex) {
String shortest = null;
Throwable current = ex;
while (current != null) {
if (current instanceof SQLException sql && "45000".equals(sql.getSQLState())) {
String message = sql.getMessage();
if (message != null && !message.isBlank()) {
String trimmed = message.trim();
if (shortest == null || trimmed.length() < shortest.length()) {
shortest = trimmed;
}
}
}
Throwable next = current.getCause();
if (next == current) {
break;
}
current = next;
}
return shortest;
}

@ExceptionHandler({ InternalServerErrorException.class, Exception.class })
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ResponseEntity<ErrorRes> error500(final Exception ex) {


+ 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)
@@ -140,6 +141,18 @@ public class SecurityConfig {
.hasAnyAuthority("ADMIN", "M18_SYNC")
.requestMatchers(HttpMethod.GET, "/m18/test/product-by-code")
.hasAnyAuthority("ADMIN", "M18_SYNC")
/* 車線看板 (/settings/shop/board):ADMIN / TRUCK_LANE / TESTING */
.requestMatchers("/truck/**")
.hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING")
.requestMatchers("/truckLaneVersion/**")
.hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING")
.requestMatchers("/truckLaneSchedule/**")
.hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING")
/* 員工發貨績效 (/chart/delivery): PERFORMANCE (績效) */
.requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance")
.hasAuthority("PERFORMANCE")
.requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance-handlers")
.hasAuthority("PERFORMANCE")
.anyRequest().authenticated())
.httpBasic(httpBasic -> httpBasic.authenticationEntryPoint(
(request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED")))


+ 13
- 3
src/main/java/com/ffii/fpsms/m18/model/SyncResult.kt 查看文件

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


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

@@ -69,6 +69,9 @@ public abstract class SettingNames {
/** Mark expired inventory lot lines as unavailable (default 00:05 daily) */
public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry";

/** Daily stock_lot_day close for yesterday (default 00:15; moves + carry) */
public static final String SCHEDULE_STOCK_LOT_DAY_CLOSE = "SCHEDULE.stockLotDay.close";

public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough";

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

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

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

}

+ 93
- 3
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt 查看文件

@@ -13,8 +13,9 @@ import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService
import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService
import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService
import com.ffii.fpsms.modules.master.service.ProductionScheduleService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService
import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService
import com.ffii.fpsms.modules.settings.entity.Settings
import com.ffii.fpsms.modules.settings.service.SettingsService
import jakarta.annotation.PostConstruct
@@ -43,6 +44,8 @@ open class SchedulerService(
@Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean,
@Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int,
@Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean,
@Value("\${scheduler.stockLotDayClose.enabled:true}") val stockLotDayCloseEnabled: Boolean,
@Value("\${scheduler.stockLotDayClose.catchUpDays:7}") val stockLotDayCloseCatchUpDays: Int,
/** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */
@Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean,
@Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean,
@@ -71,6 +74,7 @@ open class SchedulerService(
val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService,
private val bomM18ShopBulkPushService: BomM18ShopBulkPushService,
private val schedulerSyncAlertService: SchedulerSyncAlertService,
private val stockLotDayCloseService: StockLotDayCloseService,
@Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String,
@Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean,
) {
@@ -87,6 +91,10 @@ 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 * * *"
/** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */
const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 0 * * *"
/** MySQL TEXT is 65535 bytes; 16000 chars stays under that for utf8mb4. */
const val SYNC_LOG_TEXT_MAX_CHARS: Int = 16_000
}

/** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */
@@ -116,6 +124,8 @@ open class SchedulerService(
var scheduledGrnCodeSync: ScheduledFuture<*>? = null
var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null

var scheduledStockLotDayClose: ScheduledFuture<*>? = null

var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null

var scheduledDo1CatchUp: ScheduledFuture<*>? = null
@@ -213,6 +223,7 @@ open class SchedulerService(
schedulePostCompletedDnGrn();
scheduleGrnCodeSync();
scheduleInventoryLotExpiry();
scheduleStockLotDayClose();
scheduleJobOrderPlanStartAuto();
scheduleDo1CatchUpOnce();
scheduleSyncAlertWatchdog();
@@ -586,6 +597,78 @@ open class SchedulerService(
}
}

/**
* Daily stock_lot_day close at 00:15 (yesterday + catch-up).
* Set scheduler.stockLotDayClose.enabled=false to disable.
*/
fun scheduleStockLotDayClose() {
if (!stockLotDayCloseEnabled) {
scheduledStockLotDayClose?.cancel(false)
scheduledStockLotDayClose = null
logger.info("stock_lot_day close scheduler disabled (scheduler.stockLotDayClose.enabled=false)")
return
}
scheduledStockLotDayClose = commonSchedule(
scheduledStockLotDayClose,
SettingNames.SCHEDULE_STOCK_LOT_DAY_CLOSE,
STOCK_LOT_DAY_CLOSE_DEFAULT_CRON,
{ runStockLotDayClose() },
)
logger.info(
"Scheduled stock_lot_day close (default cron={}, catchUpDays={})",
STOCK_LOT_DAY_CLOSE_DEFAULT_CRON,
stockLotDayCloseCatchUpDays,
)
}

/**
* Close through [date] (default yesterday). Catch-up from last closed day, capped by catchUpDays.
* Rejects date >= today.
*/
open fun runStockLotDayClose(date: LocalDate? = null): String {
val currentTime = LocalDateTime.now()
return try {
val result = stockLotDayCloseService.closeThrough(
target = date,
catchUpDays = stockLotDayCloseCatchUpDays,
)
if (result.skipped) {
val msg = "stock_lot_day close skipped: ${result.skipReason}"
logger.warn("Scheduler - {}", msg)
saveSyncLog(
type = "STOCK_LOT_DAY",
status = "SKIPPED",
result = SyncResult(0, 0, 0, msg),
start = currentTime,
)
return msg
}
val query = "from=${result.dateFrom} to=${result.dateTo} days=${result.days.size} catchUpDays=$stockLotDayCloseCatchUpDays"
saveSyncLog(
type = "STOCK_LOT_DAY",
status = "SUCCESS",
result = SyncResult(
totalProcessed = result.days.size,
totalSuccess = result.totalRows,
totalFail = 0,
query = query,
),
start = currentTime,
)
logger.info("Scheduler - stock_lot_day close done {}", query)
"stock_lot_day close ok $query rows=${result.totalRows}"
} catch (e: Exception) {
logger.error("Scheduler - stock_lot_day close failed: ${e.message}", e)
saveSyncLog(
type = "STOCK_LOT_DAY",
status = "FAILED",
error = e.message,
start = currentTime,
)
throw e
}
}

// Function for schedule
// --------------------------- FP-MTMS --------------------------- //
@@ -730,14 +813,21 @@ open class SchedulerService(
recordsProcessed = result?.totalProcessed ?: 0,
recordsSaved = result?.totalSuccess ?: 0,
recordsFailed = result?.totalFail ?: 0,
errorMessage = error,
query = result?.query ?: "",
errorMessage = fitSyncLogText(error),
query = fitSyncLogText(result?.query ?: ""),
startTime = start,
endTime = LocalDateTime.now()
)
schedulerSyncLogRepository.save(log)
}

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

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


+ 11
- 0
src/main/java/com/ffii/fpsms/modules/common/scheduler/web/SchedulerController.kt 查看文件

@@ -96,6 +96,17 @@ class SchedulerController(
return "Inventory lot expiry status update triggered"
}

/**
* Manual stock_lot_day close (moves + carry). Omit [date] → through yesterday (with catch-up).
* [date] must be before today. Example: GET /scheduler/trigger/stock-lot-day-close?date=2026-08-20
*/
@GetMapping("/trigger/stock-lot-day-close")
fun triggerStockLotDayClose(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate? = null,
): String {
return schedulerService.runStockLotDayClose(date)
}

@GetMapping("/trigger/post-completed-dn-grn")
fun triggerPostCompletedDnGrn(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null,


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderLineInfo.kt 查看文件

@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value
import java.math.BigDecimal

interface DeliveryOrderLineInfo {
val id: Long
@get:Value("#{target.item?.name}")
val itemName: String?
val itemNo: String


+ 317
- 95
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt 查看文件

@@ -43,6 +43,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import java.time.LocalTime
import com.ffii.fpsms.modules.pickOrder.entity.Truck
import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import com.ffii.fpsms.modules.stock.entity.StockOutRepository
import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository
import com.ffii.fpsms.modules.stock.entity.StockOut
@@ -91,7 +92,6 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderLineInfo
import com.ffii.fpsms.modules.master.entity.Items
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo
import java.util.Locale
import org.slf4j.Logger
@@ -110,6 +110,7 @@ open class DeliveryOrderService(
private val pickOrderRepository: PickOrderRepository,
private val suggestedPickLotService: SuggestedPickLotService,
private val inventoryLotLineRepository: InventoryLotLineRepository,
private val inventoryRepository: InventoryRepository,
private val stockOutRepository: StockOutRepository,
private val stockOutLineRepository: StockOutLIneRepository,
private val pickOrderLineRepository: PickOrderLineRepository,
@@ -159,6 +160,7 @@ open class DeliveryOrderService(
}
}

/** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */
open fun searchDoLiteByPage(
code: String?,
shopName: String?,
@@ -203,10 +205,14 @@ open class DeliveryOrderService(

val preFilteredContent = allResult.content
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id },
)

// ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录)
// 未放單才依供應商樓層查 truck;已放單用票據車線
val shopIdAndDatePairs = preFilteredContent.mapNotNull { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
if (isReleasedDeliveryOrder(deliveryOrder)) return@mapNotNull null
val shopId = deliveryOrder?.shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate
if (shopId != null && estimatedArrivalDate != null) {
@@ -234,23 +240,16 @@ open class DeliveryOrderService(
// 处理预过滤后的记录:计算 truckLanceCode 并过滤
val processedRecords = preFilteredContent.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop
val shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate

val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) {
val targetDate = estimatedArrivalDate.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)

// 从缓存中获取 Truck
val matchedTruck = truckCache[Triple(shopId, preferredFloor, dayAbbr)]
matchedTruck?.truckLanceCode
} else {
null
}
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = truckCache,
ticketLaneByDoId = ticketLaneByDoId,
)

DeliveryOrderInfoLiteDto(
id = info.id,
@@ -299,22 +298,22 @@ open class DeliveryOrderService(
)

val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val loadedOrders = result.content.associate { info ->
info.id to deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id)
}
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
loadedOrders.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it?.id },
)
val records = result.content.map { info ->
val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id)
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop
val shopId = shop?.id
val estimatedArrivalDate = info.estimatedArrivalDate

val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) {
val targetDate = estimatedArrivalDate.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
} else {
null
}
val deliveryOrder = loadedOrders[info.id]
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = info.estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = null,
ticketLaneByDoId = ticketLaneByDoId,
)

DeliveryOrderInfoLiteDto(
id = info.id,
@@ -391,8 +390,9 @@ open class DeliveryOrderService(
}

/**
* 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。
* 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 僅回傳顯示車線為 null 或空白的送貨單(畫面上對應「車線-X」)。
* 已放單(receiving/completed)用票據車線;未放單才依店鋪/ETA 從 truck 排程推算。
*/
open fun searchDoLiteUnassignedTruckByPage(
code: String?,
@@ -425,23 +425,20 @@ open class DeliveryOrderService(
val deliveryOrderIds = allResult.content.mapNotNull { it.id }
val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id }
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id },
)

val processedRecords = allResult.content.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shop = deliveryOrder?.shop
val shopId = shop?.id
val infoEta = info.estimatedArrivalDate

val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && infoEta != null) {
val targetDate = infoEta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
} else {
null
}
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = info.estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = null,
ticketLaneByDoId = ticketLaneByDoId,
)

DeliveryOrderInfoLiteDto(
id = info.id,
@@ -481,14 +478,18 @@ open class DeliveryOrderService(
return deliveryOrderRepository.findByIdAndDeletedIsFalse(id);
}
*/
private fun computeAvailableStockQtyForItem(itemId: Long): BigDecimal {
val availableLots = inventoryLotLineRepository
.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE)
return availableLots.sumOf { lot ->
(lot.inQty ?: BigDecimal.ZERO)
.minus(lot.outQty ?: BigDecimal.ZERO)
.minus(lot.holdQty ?: BigDecimal.ZERO)
}
/**
* DO line stock figure: one inventory bucket `(itemId, stockUomId)` where
* [stockUomId] is the line's UOM (`uom_conversion.id`).
* `available = onHand − unavailable` (hold not subtracted), same as Item Search.
* Must not sum lot lines across all stock UOMs of the item.
*/
private fun computeAvailableStockQtyForItem(itemId: Long, stockUomId: Long?): BigDecimal {
if (stockUomId == null) return BigDecimal.ZERO
val bucket = inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUomId)
?: return BigDecimal.ZERO
return (bucket.onHandQty ?: BigDecimal.ZERO)
.minus(bucket.unavailableQty ?: BigDecimal.ZERO)
}

/**
@@ -603,11 +604,13 @@ open class DeliveryOrderService(
null
}

val itemIds = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { it.item?.id }.distinct()
val stockQtyByItemId = itemIds.associateWith { itemId ->
computeAvailableStockQtyForItem(itemId)
}
val fallbackQtyByItemId = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { line ->
val activeLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }
val stockQtyByBucket = activeLines.mapNotNull { line ->
val itemId = line.item?.id ?: return@mapNotNull null
val stockUomId = line.uom?.id ?: return@mapNotNull null
(itemId to stockUomId) to computeAvailableStockQtyForItem(itemId, stockUomId)
}.toMap()
val fallbackQtyByItemId = activeLines.mapNotNull { line ->
val itemId = line.item?.id ?: return@mapNotNull null
itemId to (line.qty ?: BigDecimal.ZERO)
}.toMap()
@@ -632,9 +635,14 @@ open class DeliveryOrderService(
isExtra = deliveryOrder.isExtra,
handlerName = handlerName,
truckLaneCode = truckLaneCode,
deliveryOrderLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.map { line ->
deliveryOrderLines = activeLines.map { line ->
val itemId = line.item?.id
val stockQty = itemId?.let { stockQtyByItemId[it] } ?: BigDecimal.ZERO
val stockUomId = line.uom?.id
val stockQty = if (itemId != null && stockUomId != null) {
stockQtyByBucket[itemId to stockUomId] ?: BigDecimal.ZERO
} else {
BigDecimal.ZERO
}
val requiredQty = line.qty ?: BigDecimal.ZERO
val availableStatus =
if (stockQty >= requiredQty) "available" else "insufficient"
@@ -1387,6 +1395,7 @@ open class DeliveryOrderService(
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
private fun exportDeliveryNoteFromRecord(
request: ExportDeliveryNoteRequest,
deliveryNote: net.sf.jasperreports.engine.JasperReport,
@@ -1480,15 +1489,19 @@ open class DeliveryOrderService(
val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") }
val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra }
val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds)
val pickOrderLineIdByDeliveryLine = matchDeliveryNotePickOrderLines(
exportLines = exportLines,
pickOrderIdByDoId = pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(),
)

sortedLines.forEach { row ->
fields.add(
buildDeliveryNotePdfLineField(
deliveryOrderId = row.deliveryOrderId,
line = row.line,
sequenceNumber = fields.size + 1,
pickOrderIdByDoId = pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId,
illById = illById,
itemsById = itemsById,
@@ -1497,6 +1510,7 @@ open class DeliveryOrderService(
headerTicketNo = null,
headerIsMerge = false,
replenishPdfIndex = replenishPdfIndex,
pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine,
),
)
}
@@ -1557,17 +1571,47 @@ open class DeliveryOrderService(
info.deliveryOrderLines.filter { it.deleted != true }.map { line -> DeliveryNoteExportLine(info.id, line) }
}

fun resolvePickOrderLineIdForDeliveryNoteLine(
deliveryOrderId: Long,
itemId: Long?,
/**
* FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28
* Pair each delivery-order line with its own pick line.
* Same item on one pick order is matched by qty first (4↔4, 1↔1).
* Equal qty is paired in id order. Replenishment pick lines are left out;
* those rows still print from do_replenishment.pickOrderLineId.
*/
fun matchDeliveryNotePickOrderLines(
exportLines: List<DeliveryNoteExportLine>,
pickOrderIdByDoId: Map<Long, Long>,
pickOrderLines: List<PickOrderLine>,
): Long? {
if (itemId == null) return null
val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: return null
return pickOrderLines
.firstOrNull { pol -> pol.pickOrder?.id == pickOrderId && pol.item?.id == itemId }
?.id
replenishmentPickOrderLineIds: Set<Long> = emptySet(),
): Map<Pair<Long, Long>, Long> {
val keys = exportLines.mapNotNull { row ->
val itemId = row.line.itemId ?: return@mapNotNull null
if (row.line.deleted == true) return@mapNotNull null
DeliveryNoteLineMatchKey(
deliveryOrderId = row.deliveryOrderId,
lineId = row.line.id,
itemId = itemId,
qty = row.line.qty,
)
}
val candidates = pickOrderLines.mapNotNull { pol ->
val polId = pol.id ?: return@mapNotNull null
val pickOrderId = pol.pickOrder?.id ?: return@mapNotNull null
val itemId = pol.item?.id ?: return@mapNotNull null
val qty = pol.qty ?: return@mapNotNull null
DeliveryNotePickLineCandidate(
id = polId,
pickOrderId = pickOrderId,
itemId = itemId,
qty = qty,
)
}
return DeliveryNotePickLineMatcher.match(
lines = keys,
pickOrderIdByDoId = pickOrderIdByDoId,
pickLines = candidates,
replenishmentPickOrderLineIds = replenishmentPickOrderLineIds,
)
}

fun parseTicketTypeLetter(ticketNo: String?): String? {
@@ -1679,12 +1723,11 @@ open class DeliveryOrderService(
return field
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
fun buildDeliveryNotePdfLineField(
deliveryOrderId: Long,
line: DeliveryOrderLineInfo,
sequenceNumber: Int,
pickOrderIdByDoId: Map<Long, Long>,
pickOrderLines: List<PickOrderLine>,
stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>,
illById: Map<Long, InventoryLotLine>,
itemsById: Map<Long, Items>,
@@ -1693,6 +1736,7 @@ open class DeliveryOrderService(
headerTicketNo: String? = null,
headerIsMerge: Boolean = false,
replenishPdfIndex: DoReplenishmentService.ReplenishPdfIndex = DoReplenishmentService.ReplenishPdfIndex.EMPTY,
pickOrderLineIdByDeliveryLine: Map<Pair<Long, Long>, Long> = emptyMap(),
): MutableMap<String, Any> {
val field = mutableMapOf<String, Any>()
val isExtra = isExtraDeliveryTicket(
@@ -1701,12 +1745,7 @@ open class DeliveryOrderService(
headerIsMerge = headerIsMerge,
)

val polId = resolvePickOrderLineIdForDeliveryNoteLine(
deliveryOrderId = deliveryOrderId,
itemId = line.itemId,
pickOrderIdByDoId = pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
)
val polId = pickOrderLineIdByDeliveryLine[deliveryOrderId to line.id]
val isReplenish = replenishPdfIndex.matches(deliveryOrderId, line.itemId, polId)

field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish)
@@ -2502,6 +2541,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
return out
}

/** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */
private fun buildResolvedTruckDtosForLiteRows(
rows: List<DeliveryOrderInfoLite>,
): List<DeliveryOrderInfoLiteDto> {
@@ -2509,9 +2549,13 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
if (ids.isEmpty()) return emptyList()
val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id }
val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists()
val ticketLaneByDoId = loadTicketTruckLanesByDoId(
deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id },
)

val shopIdAndDatePairs = rows.mapNotNull { info ->
val d = deliveryOrdersMap[info.id]
if (isReleasedDeliveryOrder(d)) return@mapNotNull null
val shopId = d?.shop?.id
val eta = info.estimatedArrivalDate
if (shopId != null && eta != null) {
@@ -2533,18 +2577,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }

return rows.map { info ->
val deliveryOrder = deliveryOrdersMap[info.id]
val supplierCode = deliveryOrder?.supplier?.code
val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F)
val shopId = deliveryOrder?.shop?.id
val infoEta = info.estimatedArrivalDate
val calculatedTruckLanceCode =
if (deliveryOrder != null && shopId != null && infoEta != null) {
val targetDate = infoEta.toLocalDate()
val dayAbbr = getDayOfWeekAbbr(targetDate)
truckCache[Triple(shopId, preferredFloor, dayAbbr)]?.truckLanceCode
} else {
null
}
val calculatedTruckLanceCode = displayedTruckLanceCode(
deliveryOrder = deliveryOrder,
estimatedArrivalDate = info.estimatedArrivalDate,
floorSuppliers2F = floorSuppliers2F,
floorSuppliers4F = floorSuppliers4F,
truckCache = truckCache,
ticketLaneByDoId = ticketLaneByDoId,
)
DeliveryOrderInfoLiteDto(
id = info.id,
code = info.code,
@@ -2560,6 +2600,139 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
}
}

private class TicketLaneHit(
val lane: String?,
val ticketId: Long,
val srcRank: Int,
)

/**
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 放單後狀態:receiving 表示已發布,completed 表示已完成。
*/
private fun isReleasedDeliveryOrder(deliveryOrder: DeliveryOrder?): Boolean =
deliveryOrder?.status == DeliveryOrderStatus.RECEIVING ||
deliveryOrder?.status == DeliveryOrderStatus.COMPLETED

/**
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 已放單讀票據車線(工作台 `delivery_order_pick_order`,否則 `do_pick_order`/record)。
* 未放單才依供應商樓層 + 店鋪 + 星期從 truck 推算。
* [truckCache] 為 null 時,未放單即時查 truck。
*/
private fun displayedTruckLanceCode(
deliveryOrder: DeliveryOrder?,
estimatedArrivalDate: LocalDateTime?,
floorSuppliers2F: List<String>,
floorSuppliers4F: List<String>,
truckCache: Map<Triple<Long, String, String>, Truck?>?,
ticketLaneByDoId: Map<Long, String?>,
): String? {
if (isReleasedDeliveryOrder(deliveryOrder)) {
return deliveryOrder?.id?.let { ticketLaneByDoId[it] }
}
val shopId = deliveryOrder?.shop?.id ?: return null
val eta = estimatedArrivalDate ?: return null
val preferredFloor = preferredStoreFloorForSupplier(
deliveryOrder.supplier?.code,
floorSuppliers2F,
floorSuppliers4F,
)
val dayAbbr = getDayOfWeekAbbr(eta.toLocalDate())
val key = Triple(shopId, preferredFloor, dayAbbr)
if (truckCache != null) {
return truckCache[key]?.truckLanceCode
}
return resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28
* 一批送貨單對應的票據車線。同一張單有多張票時,工作台票優先,同來源取 id 最大的一張。
* 票上的車線為空白時仍回傳 null,呼叫端不再改查 truck。
*/
private fun loadTicketTruckLanesByDoId(doIds: Collection<Long>): Map<Long, String?> {
val ids = doIds.distinct()
if (ids.isEmpty()) return emptyMap()
val best = HashMap<Long, TicketLaneHit>()
val sql = """
SELECT do_id, truck_lance_code, ticket_id, src_rank
FROM (
SELECT po.doId AS do_id,
NULLIF(TRIM(dop.truckLanceCode), '') AS truck_lance_code,
dop.id AS ticket_id,
1 AS src_rank
FROM fpsmsdb.delivery_order_pick_order dop
INNER JOIN fpsmsdb.pick_order po
ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0
WHERE dop.deleted = 0
AND po.doId IN (:doIds)

UNION ALL

SELECT dpo.do_order_id AS do_id,
NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code,
dpo.id AS ticket_id,
2 AS src_rank
FROM fpsmsdb.do_pick_order dpo
WHERE dpo.deleted = 0
AND dpo.do_order_id IN (:doIds)

UNION ALL

SELECT dpol.do_order_id AS do_id,
NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code,
dpo.id AS ticket_id,
2 AS src_rank
FROM fpsmsdb.do_pick_order dpo
INNER JOIN fpsmsdb.do_pick_order_line dpol
ON dpol.do_pick_order_id = dpo.id AND dpol.deleted = 0
WHERE dpo.deleted = 0
AND dpol.do_order_id IN (:doIds)

UNION ALL

SELECT rec.do_order_id AS do_id,
NULLIF(TRIM(rec.TruckLanceCode), '') AS truck_lance_code,
rec.id AS ticket_id,
3 AS src_rank
FROM fpsmsdb.do_pick_order_record rec
WHERE rec.deleted = 0
AND rec.do_order_id IN (:doIds)
) lanes
""".trimIndent()

ids.chunked(400).forEach { chunk ->
val rows = jdbcDao.queryForList(sql, mapOf("doIds" to chunk))
for (row in rows) {
val doId = jdbcNumber(row, "do_id")?.toLong() ?: continue
val ticketId = jdbcNumber(row, "ticket_id")?.toLong() ?: 0L
val srcRank = jdbcNumber(row, "src_rank")?.toInt() ?: 9
val lane = jdbcText(row, "truck_lance_code")
val hit = TicketLaneHit(lane, ticketId, srcRank)
val current = best[doId]
if (
current == null ||
hit.srcRank < current.srcRank ||
(hit.srcRank == current.srcRank && hit.ticketId > current.ticketId)
) {
best[doId] = hit
}
}
}
return best.mapValues { it.value.lane }
}

private fun jdbcNumber(row: Map<*, *>, name: String): Number? {
val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null
return value as? Number
}

private fun jdbcText(row: Map<*, *>, name: String): String? {
val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null
return value.toString().trim().takeIf { it.isNotEmpty() }
}

/**
* 依店鋪 + 揀貨樓層解析當日應顯示之車線。
* - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。
@@ -2676,3 +2849,52 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] }
}
}
}

internal data class DeliveryNoteLineMatchKey(
val deliveryOrderId: Long,
val lineId: Long,
val itemId: Long,
val qty: BigDecimal,
)

internal data class DeliveryNotePickLineCandidate(
val id: Long,
val pickOrderId: Long,
val itemId: Long,
val qty: BigDecimal,
)

internal object DeliveryNotePickLineMatcher {
/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
fun match(
lines: List<DeliveryNoteLineMatchKey>,
pickOrderIdByDoId: Map<Long, Long>,
pickLines: List<DeliveryNotePickLineCandidate>,
replenishmentPickOrderLineIds: Set<Long> = emptySet(),
): Map<Pair<Long, Long>, Long> {
val result = mutableMapOf<Pair<Long, Long>, Long>()
val rowsByDoAndItem = lines.groupBy { it.deliveryOrderId to it.itemId }
for ((key, rows) in rowsByDoAndItem) {
val (deliveryOrderId, itemId) = key
val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: continue
val available = pickLines
.filter { pol ->
pol.pickOrderId == pickOrderId &&
pol.itemId == itemId &&
pol.id !in replenishmentPickOrderLineIds
}
.sortedBy { it.id }
.toMutableList()
for (row in rows.sortedBy { it.lineId }) {
val qtyIndex = available.indexOfFirst { it.qty.compareTo(row.qty) == 0 }
val chosen = when {
qtyIndex >= 0 -> available.removeAt(qtyIndex)
available.isNotEmpty() -> available.removeAt(0)
else -> null
} ?: continue
result[row.deliveryOrderId to row.lineId] = chosen.id
}
}
return result
}
}

+ 3
- 0
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt 查看文件

@@ -162,6 +162,9 @@ open class DoReplenishmentService(
return deliveryOrderId to resolvedItemId in targetDoItemKeys
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
fun replenishmentPickOrderLineIds(): Set<Long> = pickOrderLineIds

companion object {
val EMPTY = ReplenishPdfIndex(emptySet(), emptySet())
}


+ 242
- 83
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt 查看文件

@@ -46,6 +46,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo
import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService
import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot
import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver
import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress
import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus
import org.springframework.stereotype.Service
@@ -70,6 +71,7 @@ import jakarta.persistence.EntityManager
import jakarta.persistence.PersistenceContext
import org.springframework.orm.ObjectOptimisticLockingFailureException
import com.ffii.core.utils.PdfUtils
import com.ffii.core.utils.PrinterLockManager
import com.ffii.core.utils.ZebraPrinterUtil
import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry
import com.ffii.fpsms.modules.master.service.PrinterService
@@ -84,11 +86,11 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintReque
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import org.springframework.core.io.ClassPathResource
import net.sf.jasperreports.engine.JasperCompileManager
import net.sf.jasperreports.engine.JasperExportManager
import net.sf.jasperreports.engine.JasperPrint
import net.sf.jasperreports.engine.JasperReport
import java.io.File
import java.io.FileNotFoundException
import java.util.Locale
@@ -114,6 +116,7 @@ open class DoWorkbenchMainService(
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockLedgerRepository: StockLedgerRepository,
private val itemUomService: ItemUomService,
private val inventoryBucketResolver: InventoryBucketResolver,
private val itemUomRespository: ItemUomRespository,
private val bagRepository: BagRepository,
private val bagService: BagService,
@@ -141,6 +144,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 {
@@ -243,7 +260,7 @@ open class DoWorkbenchMainService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.3 | 2026-11-02
* Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split).
* - JO: setting `JO.pick.excludeWarehouses` (fallback is JoWorkbenchPickConstants when the row is missing).
* - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit).
@@ -2639,15 +2656,15 @@ return MessageResponse(
private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) {
if (deltaQty <= BigDecimal.ZERO) return
val solItem = sol.item ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return
// Fast path (batch-submit style): avoid querying latest ledger.
// At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change.
// So previousBalance can be reconstructed as (onHandAfter + deltaQty).
val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = onHandAfter + deltaQty.toDouble()
val newBalance = previousBalance - deltaQty.toDouble()
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = sol
@@ -2707,12 +2724,13 @@ return MessageResponse(
}

private fun resolveWorkbenchPreviousBalance(
itemId: Long,
inventory: Inventory,
onHandQtyBeforeUpdate: Double?,
): Double {
if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId)
val latestLedger = inventory.id?.let {
stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it)
}
return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}

@@ -2975,6 +2993,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")

@@ -2988,17 +3007,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
}
}

@@ -3064,15 +3104,9 @@ return MessageResponse(
return deliveryOrderService.formatDoCodesPlain(codes)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */
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>()

@@ -3086,18 +3120,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("/", "")
@@ -3123,11 +3148,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 ?: "") }
@@ -3135,6 +3156,12 @@ return MessageResponse(
val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true
val headerTicketNo = ctx.header.ticketNo
val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(ctx.deliveryOrderIds)
val pickOrderLineIdByDeliveryLine = deliveryOrderService.matchDeliveryNotePickOrderLines(
exportLines = exportLines,
pickOrderIdByDoId = ctx.pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(),
)

sortedLines.forEach { row ->
fields.add(
@@ -3142,8 +3169,6 @@ return MessageResponse(
deliveryOrderId = row.deliveryOrderId,
line = row.line,
sequenceNumber = fields.size + 1,
pickOrderIdByDoId = ctx.pickOrderIdByDoId,
pickOrderLines = pickOrderLines,
stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId,
illById = illById,
itemsById = itemsById,
@@ -3152,6 +3177,7 @@ return MessageResponse(
headerTicketNo = headerTicketNo,
headerIsMerge = headerIsMerge,
replenishPdfIndex = replenishPdfIndex,
pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine,
),
)
}
@@ -3201,6 +3227,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")

@@ -3214,26 +3241,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
}
}

@@ -3254,15 +3303,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()
@@ -3323,15 +3365,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()
@@ -3371,23 +3406,147 @@ return MessageResponse(
val pdf = exportDNLabelsReprintWorkbench(request)
val jasperPrint = pdf["report"] as JasperPrint
val tempPdfFile = File.createTempFile("print_job_", ".pdf")
var handedToPrinter = false
try {
JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath)
val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty
printer.ip?.let { ip ->
printer.port?.let { port ->
ZebraPrinterUtil.printPdfToZebra(
tempPdfFile,
ip,
port,
printQty,
ZebraPrinterUtil.PrintDirection.ROTATED,
printer.dpi,
)
}
val ip = printer.ip?.trim().orEmpty()
val port = printer.port
if (ip.isEmpty() || port == null) {
tempPdfFile.delete()
throw IllegalStateException("Label printer ${printer.id} has no IP or port")
}
val dpi = printer.dpi
sendToPrinter(tempPdfFile, ip, port) {
ZebraPrinterUtil.printPdfToZebra(
tempPdfFile,
ip,
port,
printQty,
ZebraPrinterUtil.PrintDirection.ROTATED,
dpi,
)
}
handedToPrinter = true
} catch (e: Exception) {
if (!handedToPrinter) tempPdfFile.delete()
throw e
}
}

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

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

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

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

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

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

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

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

@@ -54,8 +54,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> {
json_arrayagg(
json_object(
'id', jobm.id,
'itemId', jobm.itemId,
'uomId', jobm.uomId,
'code', i.code,
'name', i.name,
'type', i.type,
'pickedLotNo', pln.pickedLotNo,
'reqQty', jobm.reqQty,
'uom', uc.udfudesc,
@@ -93,8 +96,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> {
json_arrayagg(
json_object(
'id', jobm.id,
'itemId', jobm.itemId,
'uomId', jobm.uomId,
'code', i.code,
'name', i.name,
'type', i.type,
'lotNo', il.lotNo,
'reqQty', jobm.reqQty,
'uom', uc.udfudesc,
@@ -272,8 +278,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> {
json_arrayagg(
json_object(
'id', jobm.id,
'itemId', jobm.itemId,
'uomId', jobm.uomId,
'code', i.code,
'name', i.name,
'type', i.type,
'lotNo', il.lotNo,
'reqQty', jobm.reqQty,
'uom', uc.udfudesc,
@@ -312,8 +321,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> {
json_arrayagg(
json_object(
'id', jobm.id,
'itemId', jobm.itemId,
'uomId', jobm.uomId,
'code', i.code,
'name', i.name,
'type', i.type,
'lotNo', il.lotNo,
'reqQty', jobm.reqQty,
'uom', uc.udfudesc,


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/entity/projections/JobOrderInfo.kt 查看文件

@@ -126,6 +126,8 @@ data class JobOrderDetail(

data class JobOrderDetailPickLine(
val id: Long?,
val itemId: Long?,
val uomId: Long?,
val code: String?,
val name: String?,
val pickedLotNo: List<JobOrderDetailPickedLotNo>?,


+ 29
- 33
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt 查看文件

@@ -166,7 +166,12 @@ open class JobOrderService(
val inventoriesMap = if (allItemIds.isNotEmpty()) {
inventoryRepository.findInventoryInfoByItemIdInAndDeletedIsFalse(allItemIds)
.associateBy { it.itemId }
.mapNotNull { info ->
val itemId = info.itemId ?: return@mapNotNull null
val stockUomId = info.stockUomId ?: return@mapNotNull null
(itemId to stockUomId) to info
}
.toMap()
} else {
emptyMap()
}
@@ -228,7 +233,7 @@ open class JobOrderService(
private fun calculateStockCounts(
jobOrder: JobOrder,
inventoriesMap: Map<Long?, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo>
inventoriesMap: Map<Pair<Long, Long>, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo>
): Pair<Int, Int> {
// 过滤掉 consumables 和 CMB 类型的物料
val nonConsumablesJobms = jobOrder.jobms.filter { jobm ->
@@ -243,36 +248,22 @@ open class JobOrderService(
var sufficientCount = 0
var insufficientCount = 0
//println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===")
nonConsumablesJobms.forEach { jobm ->
val itemId = jobm.item?.id
val itemCode = jobm.item?.code ?: "N/A"
val itemName = jobm.item?.name ?: "N/A"
if (itemId != null) {
val inventory = inventoriesMap[itemId]
val availableQty = if (inventory != null) {
inventory.availableQty ?: (
(inventory.onHandQty ?: BigDecimal.ZERO) -
(inventory.onHoldQty ?: BigDecimal.ZERO) -
(inventory.unavailableQty ?: BigDecimal.ZERO)
)
} else {
BigDecimal.ZERO
}
val jobmUomId = jobm.uom?.id
val inventory = resolveInventoryForJobm(itemId, jobmUomId, inventoriesMap)
val availableQty = inventory?.availableQty
?: (inventory?.let {
(it.onHandQty ?: BigDecimal.ZERO) - (it.unavailableQty ?: BigDecimal.ZERO)
} ?: BigDecimal.ZERO)
// ✅ 获取 reqQty 和 availableQty 的单位信息
val reqQty = jobm.reqQty ?: BigDecimal.ZERO
val reqUomId = jobm.uom?.id ?: 0L
val reqUomName = jobm.uom?.udfudesc ?: "N/A"
// ✅ 修复:使用 itemUomService 获取 stockUomId(与 ProductProcessService 保持一致)
val stockUnitItemUom = itemUomService.findStockUnitByItemId(itemId)
val stockUomId = stockUnitItemUom?.uom?.id ?: 0L
val stockUomName = stockUnitItemUom?.uom?.udfudesc ?: "N/A"
val availableUomId = inventory?.stockUomId ?: stockUnitItemUom?.uom?.id ?: 0L
// ✅ 转换为 base unit 进行比较(与 ProductProcessService 保持一致)
val baseReqQtyResult = if (reqUomId > 0 && reqQty > BigDecimal.ZERO) {
try {
itemUomService.convertUomByItem(
@@ -291,13 +282,13 @@ open class JobOrderService(
null
}
val baseAvailableQtyResult = if (stockUomId > 0 && availableQty > BigDecimal.ZERO) {
val baseAvailableQtyResult = if (availableUomId > 0 && availableQty > BigDecimal.ZERO) {
try {
itemUomService.convertUomByItem(
ConvertUomByItemRequest(
itemId = itemId,
qty = availableQty,
uomId = stockUomId,
uomId = availableUomId,
targetUnit = "baseUnit"
)
)
@@ -311,27 +302,32 @@ open class JobOrderService(
val baseReqQty = baseReqQtyResult?.newQty ?: BigDecimal.ZERO
val baseAvailableQty = baseAvailableQtyResult?.newQty ?: BigDecimal.ZERO
val baseUomName = baseReqQtyResult?.udfudesc ?: baseAvailableQtyResult?.udfudesc ?: "N/A"
// ✅ 使用 base unit 进行比较
if (baseAvailableQty >= baseReqQty) {
sufficientCount++
//println("✅ SUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)")
} else {
insufficientCount++
//println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)")
}
} else {
// 如果没有 itemId,视为不足
insufficientCount++
//println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - No itemId")
}
}
//println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===")
return Pair(sufficientCount, insufficientCount)
}

private fun resolveInventoryForJobm(
itemId: Long,
jobmUomId: Long?,
inventoriesMap: Map<Pair<Long, Long>, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo>,
): com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo? {
if (jobmUomId != null) {
inventoriesMap[itemId to jobmUomId]?.let { return it }
}
val stockUnitUomId = itemUomService.findStockUnitByItemId(itemId)?.uom?.id ?: return null
return inventoriesMap[itemId to stockUnitUomId]
}

open fun jobOrderDetail(id: Long): JobOrderDetail {
val sqlResult = jobOrderRepository.findJobOrderDetailById(id) ?: throw NoSuchElementException();



+ 38
- 13
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt 查看文件

@@ -77,10 +77,31 @@ open class PSService(
FROM
bom
LEFT JOIN items ON bom.itemId = items.id
LEFT JOIN inventory ON items.id = inventory.itemId
LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode
LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1
LEFT JOIN item_uom iu
ON iu.itemId = items.id
AND iu.stockUnit = 1
AND IFNULL(iu.deleted, 0) = 0
AND iu.id = (
SELECT MIN(iu2.id)
FROM item_uom iu2
WHERE iu2.itemId = items.id
AND iu2.stockUnit = 1
AND IFNULL(iu2.deleted, 0) = 0
)
LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId
LEFT JOIN inventory ON inventory.id = (
SELECT i2.id
FROM inventory i2
WHERE i2.itemId = items.id
AND IFNULL(i2.deleted, 0) = 0
AND (
i2.stockUomId = iu.uomId
OR (i2.stockUomId IS NULL AND i2.uomId = iu.uomId)
)
ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id
LIMIT 1
)
LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode
WHERE bom.deleted = 0 and bom.description = 'FG'
""".trimIndent()
return jdbcDao.queryForList(sql, args)
@@ -168,8 +189,8 @@ open class PSService(

/** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */
/**
* Recalculate [inventory.onHandQty] / hold / unavailable from [inventory_lot_line] for FG BOM items.
* Same aggregation as pick-issue manual inventory sync.
* Recalculate [inventory.onHandQty] / unavailable from [inventory_lot_line] for FG BOM items.
* Per (itemId, stockUomId) bucket. Does not write onHoldQty.
*/
fun refreshInventoryOnHandForFgBomItems(): Int {
val sql = """
@@ -182,24 +203,28 @@ open class PSService(
LEFT JOIN (
SELECT
il.itemId,
iu.uomId AS stockUomId,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty,
SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty,
SUM(CASE
WHEN ill.status = 'unavailable'
THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)
WHEN LOWER(ill.status) = 'unavailable'
THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)
ELSE 0
END) AS totalUnavailableQty
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0
WHERE ill.deleted = 0
GROUP BY il.itemId
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0
WHERE IFNULL(ill.deleted, 0) = 0
GROUP BY il.itemId, iu.uomId
) calc ON calc.itemId = i.itemId
AND (
i.stockUomId = calc.stockUomId
OR (i.stockUomId IS NULL AND i.uomId = calc.stockUomId)
)
SET
i.onHandQty = COALESCE(calc.totalOnHandQty, 0),
i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0),
i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0),
i.status = IF(
COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalOnHoldQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0,
COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0,
'available',
'unavailable'
),


+ 13
- 2
src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleLineRepository.kt 查看文件

@@ -27,14 +27,25 @@ interface ProductionScheduleLineRepository : AbstractRepository<ProductionSchedu
bmi.code,
bmi.name,
bmi.`type`,
coalesce(i.onHandQty, 0) - coalesce(i.onHoldQty, 0) - coalesce(i.unavailableQty, 0) as availableQty,
coalesce(i.onHandQty, 0) - coalesce(i.unavailableQty, 0) as availableQty,
ceil(coalesce(bm.qty, 0) * pp.proportion) as demandQty
from production_schedule_line psl
left join prod_prop pp on pp.pslId = psl.id
left join bom b on b.itemId = psl.itemId
left join bom_material bm on bm.bomId = b.id
left join items bmi on bmi.id = bm.itemId
left join inventory i on i.itemId = bmi.id
left join inventory i on i.id = (
select i2.id
from inventory i2
where i2.itemId = bmi.id
and ifnull(i2.deleted, 0) = 0
and (
i2.stockUomId = coalesce(bm.stockUnit, bm.uomId)
or (i2.stockUomId is null and i2.uomId = coalesce(bm.stockUnit, bm.uomId))
)
order by case when i2.stockUomId is null then 1 else 0 end, i2.id
limit 1
)
where psl.id = :id and bmi.id is not null
group by psl.id, bm.id, i.id, pp.proportion
""")


+ 7
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/ProductionScheduleRepository.kt 查看文件

@@ -183,7 +183,7 @@ interface ProductionScheduleRepository : AbstractRepository<ProductionSchedule,
bmi.code,
bmi.name,
bmi.`type`,
coalesce(i.onHandQty, 0) - coalesce(i.onHoldQty, 0) - coalesce(i.unavailableQty, 0) as availableQty,
coalesce(i.onHandQty, 0) - coalesce(i.unavailableQty, 0) as availableQty,
-- ceil(coalesce(bm.qty, 0) * pp.proportion) as demandQty
coalesce(bm.qty, 0) * pp.proportion as demandQty,
uc.udfudesc as uomName
@@ -195,7 +195,13 @@ interface ProductionScheduleRepository : AbstractRepository<ProductionSchedule,
left join items bmi on bmi.id = bm.itemId
left join item_uom iu on iu.itemId = bmi.id and iu.salesUnit = true
left join uom_conversion uc on uc.id = iu.uomId
left join item_uom iu_stock on iu_stock.itemId = bmi.id and iu_stock.stockUnit = 1 and ifnull(iu_stock.deleted, 0) = 0
left join inventory i on i.itemId = bmi.id
and ifnull(i.deleted, 0) = 0
and (
i.stockUomId = coalesce(nullif(bm.stockUnit, 0), iu_stock.uomId)
or (i.stockUomId is null and i.uomId = coalesce(nullif(bm.stockUnit, 0), iu_stock.uomId))
)
where ps.id = :id and bmi.id is not null
group by ps.id, psl.id, bm.id, i.id, pp.proportion, uc.udfudesc
) r2


+ 17
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/WarehouseRepository.kt 查看文件

@@ -5,6 +5,7 @@ import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param
import org.springframework.stereotype.Repository
import java.io.Serializable

@@ -22,6 +23,22 @@ interface WarehouseRepository : AbstractRepository<Warehouse, Long> {
fun findAllByIdIn(ids: List<Long>): List<Warehouse>;
fun findAllByCodeAndDeletedIsFalse(code: String): List<Warehouse>

/** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */
@Query(
"""
SELECT w FROM Warehouse w
WHERE w.deleted = false
AND (:storeId IS NULL OR :storeId = '' OR w.store_id = :storeId)
AND (:warehouse IS NULL OR :warehouse = '' OR w.warehouse = :warehouse)
AND (:area IS NULL OR :area = '' OR w.area = :area)
"""
)
fun findByLocationFilters(
@Param("storeId") storeId: String?,
@Param("warehouse") warehouse: String?,
@Param("area") area: String?,
): List<Warehouse>

@Query(
"""
SELECT COUNT(w) FROM Warehouse w


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


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

@@ -13,7 +13,8 @@ import org.slf4j.Logger
import org.slf4j.LoggerFactory
import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.stock.entity.Inventory
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver

@Service
open class ItemUomService(
@@ -22,7 +23,7 @@ open class ItemUomService(
val itemUomRespository: ItemUomRespository,
val currencyService: CurrencyService,
val itemsRepository: ItemsRepository,
private val inventoryRepository: InventoryRepository,
private val inventoryBucketResolver: InventoryBucketResolver,
) {
val logger = org.slf4j.LoggerFactory.getLogger(this::class.java)

@@ -70,20 +71,21 @@ open class ItemUomService(
}

/**
* 同一 [itemId] 可能對應多筆 [Inventory](不同 uom)。
* 優先取 `inventory.uomId` 與 `item_uom` 中 `baseUnit = true` 之列的 `uomId` 一致的那一筆;
* 若無 base 設定或無匹配列,則回退為 `findFirstByItemIdAndDeletedIsFalseOrderByIdAsc`。
* @deprecated ChangeList #4 — do not use. Multi stock-UOM buckets cannot be keyed by base UOM.
* Use [com.ffii.fpsms.modules.stock.service.InventoryBucketResolver.findInventoryBucket] with
* lot-line stockItemUomId / stockUomId instead.
*
* Temporary fallback: looks up by the item's current stockUnit uomId (not base).
*/
@Deprecated(
message = "Use InventoryBucketResolver.findInventoryBucket(itemId, lotLine)",
replaceWith = ReplaceWith(
"inventoryBucketResolver.findInventoryBucket(itemId, lotLine)",
"com.ffii.fpsms.modules.stock.service.InventoryBucketResolver",
),
)
open fun findInventoryForItemBaseUom(itemId: Long): Inventory? {
val base = findBaseUnitByItemId(itemId)
val uomId = base?.uom?.id
if (uomId != null) {
val match = inventoryRepository.findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId, uomId)
if (match != null) {
return match
}
}
return inventoryRepository.findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId)
return inventoryBucketResolver.findInventoryBucket(itemId, null as InventoryLotLine?)
}

open fun findPickingUnitByItemId(itemId: Long): ItemUom? {
@@ -104,6 +106,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 +134,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 +157,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 +179,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 +199,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 +225,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 +245,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 +301,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 +323,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



+ 5
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt 查看文件

@@ -410,8 +410,13 @@ open class ItemsService(
"i.AverageUnitPrice as averageUnitPrice, " +
"i.LatestMarketUnitPrice as latestMarketUnitPrice, " +
"DATE_FORMAT(i.latestMupUpdatedDate, '%Y-%m-%d %H:%i') as latestMupUpdatedDate, " +
"uc_s.id as uomId, " +
"uc_s.code as uom, " +
"uc_s.udfudesc as uomDesc, " +
"uc_p.udfudesc as purchaseUnit " +
"FROM items i " +
"LEFT JOIN item_uom iu_s ON iu_s.itemId = i.id AND iu_s.deleted = false AND iu_s.stockUnit = true " +
"LEFT JOIN uom_conversion uc_s ON uc_s.id = iu_s.uomId " +
"LEFT JOIN item_uom iu_p ON iu_p.itemId = i.id AND iu_p.deleted = false AND iu_p.purchaseUnit = true " +
"LEFT JOIN uom_conversion uc_p ON uc_p.id = iu_p.uomId " +
"WHERE i.deleted = false"


+ 61
- 13
src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt 查看文件

@@ -171,29 +171,29 @@ open class ProductionScheduleService(
val proportion = BigDecimal.ONE // BigDecimal(line.prodQty).divide(bm.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP)
val demandQty = bm.qty?.times(proportion) ?: zero
val saleUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
val materialStockUomId = bm.stockUnit?.toLong()
?: bm.uom?.id
?: bm.item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id }

RoughProdScheduleLineBomMaterialInfoByFg(
id = bm.id,
code = bm.item?.code,
name = bm.item?.name,
type = bm.item?.type,
availableQty = bm.item?.inventories?.sumOf {
(it.onHandQty ?: zero) - (it.onHoldQty ?: zero) - (it.unavailableQty ?: zero)
},
availableQty = availableQtyWithoutHold(bm.item?.inventories, materialStockUomId, zero),
demandQty = demandQty,
uomName = saleUnit?.uom?.udfudesc
)
}

val saleUnit = line.item.id?.let { itemUomService.findSalesUnitByItemId(it) }
val fgStockUomId = line.item.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id }
RoughProdScheduleLineInfoByFg(
id = line.id,
code = line.item.code,
name = line.item.name,
type = line.item.type,
availableQty = line.item.inventories.sumOf {
(it.onHandQty ?: zero) - (it.onHoldQty ?: zero) - (it.unavailableQty ?: zero)
},
availableQty = availableQtyWithoutHold(line.item.inventories, fgStockUomId, zero),
prodQty = BigDecimal(line.prodQty),
lastMonthAvgSales = BigDecimal(line.lastMonthAvgSales),
estCloseBal = BigDecimal(line.estCloseBal),
@@ -258,15 +258,16 @@ open class ProductionScheduleService(
val proportion = BigDecimal.ONE //BigDecimal(line.prodQty).divide(bm.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP)
val demandQty = bm.qty?.times(proportion) ?: zero
val saleUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
val materialStockUomId = bm.stockUnit?.toLong()
?: bm.uom?.id
?: bm.item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id }

RoughProdScheduleLineInfoByBomByDate(
id = bm.item?.id,
code = bm.item?.code,
name = bm.item?.name,
type = bm.item?.type,
availableQty = bm.item?.inventories?.sumOf {
(it.onHandQty ?: zero) - (it.onHoldQty ?: zero) - (it.unavailableQty ?: zero)
} ?: zero,
availableQty = availableQtyWithoutHold(bm.item?.inventories, materialStockUomId, zero),
demandQty = demandQty,
assignDate = line.assignDate,
uomName = saleUnit?.uom?.udfudesc
@@ -724,7 +725,21 @@ open class ProductionScheduleService(
FROM
bom
LEFT JOIN items ON bom.itemId = items.id
LEFT JOIN inventory ON items.id = inventory.itemId
LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id
AND iu_stock.stockUnit = 1
AND IFNULL(iu_stock.deleted, 0) = 0
LEFT JOIN inventory ON inventory.id = (
SELECT i2.id
FROM inventory i2
WHERE i2.itemId = items.id
AND IFNULL(i2.deleted, 0) = 0
AND (
i2.stockUomId = iu_stock.uomId
OR (i2.stockUomId IS NULL AND i2.uomId = iu_stock.uomId)
)
ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id
LIMIT 1
)
left join item_fake_onhand on items.code = item_fake_onhand.itemCode
WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active'
-- and bom.itemId != 16771
@@ -1821,7 +1836,18 @@ open class ProductionScheduleService(
left join bom on it.id = bom.itemId
left join bom_material bm on bom.id = bm.bomId
left join items itm on bm.itemId = itm.id
left join inventory iv on itm.id = iv.itemId
left join inventory iv on iv.id = (
select i2.id
from inventory i2
where i2.itemId = itm.id
and ifnull(i2.deleted, 0) = 0
and (
i2.stockUomId = ius.uomId
or (i2.stockUomId is null and i2.uomId = ius.uomId)
)
order by case when i2.stockUomId is null then 1 else 0 end, i2.id
limit 1
)
left join item_uom ius on itm.id = ius.itemId and ius.stockUnit = 1
left join uom_conversion ucs on ius.uomId = ucs.id
@@ -1893,7 +1919,18 @@ open class ProductionScheduleService(
LEFT JOIN item_uom ium18 ON ium18.itemId = itm.id AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0
LEFT JOIN uom_conversion uomM18 ON uomM18.id = ium18.uomId
JOIN item_uom itsm ON itm.id = itsm.itemId and itsm.stockUnit = 1
LEFT JOIN inventory iv ON itm.id = iv.itemId
LEFT JOIN inventory iv ON iv.id = (
SELECT i2.id
FROM inventory i2
WHERE i2.itemId = itm.id
AND IFNULL(i2.deleted, 0) = 0
AND (
i2.stockUomId = itsm.uomId
OR (i2.stockUomId IS NULL AND i2.uomId = itsm.uomId)
)
ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id
LIMIT 1
)
WHERE DATE(ps.produceAt) >= DATE_ADD(:fromDate, INTERVAL 1 DAY)
AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY)
AND ps.id = (
@@ -1968,5 +2005,16 @@ open class ProductionScheduleService(
// Optional: log the action (if you have logging setup)
// logger.info("Cleared all production schedules with produceAt >= today")
}

private fun availableQtyWithoutHold(
inventories: List<Inventory>?,
stockUomId: Long?,
zero: BigDecimal,
): BigDecimal {
if (inventories.isNullOrEmpty() || stockUomId == null) return zero
val bucket = inventories.firstOrNull { inv ->
inv.stockUom?.id == stockUomId || (inv.stockUom == null && inv.uom?.id == stockUomId)
} ?: return zero
return (bucket.onHandQty ?: zero) - (bucket.unavailableQty ?: zero)
}
}

+ 79
- 0
src/main/java/com/ffii/fpsms/modules/master/service/StockLedgerFixService.kt 查看文件

@@ -0,0 +1,79 @@
package com.ffii.fpsms.modules.master.service

import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixAdjService
import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixBackfillService
import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixExportService
import com.ffii.fpsms.modules.master.service.ledgerfix.StockLedgerFixQueryService
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit
import org.springframework.stereotype.Service
import java.io.OutputStream
import java.time.LocalDate

/**
* Facade for stock-ledger-fix. Work is split across query, backfill, ADJ, and SQL export.
* Inventory 1.0 then per-day stock_ledger backfill (2.1–2.6) and stock_lot_day.
*/
@Service
open class StockLedgerFixService(
private val query: StockLedgerFixQueryService,
private val backfill: StockLedgerFixBackfillService,
private val adj: StockLedgerFixAdjService,
private val export: StockLedgerFixExportService,
) {
open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse =
query.calendar(from, to)

open fun inventoryPreview(): StockLedgerFixInventoryPreview = query.inventoryPreview()

open fun fixInventory10(): StockLedgerFixInventoryResponse = backfill.fixInventory10()

open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail = query.dayDetail(day)

open fun searchInventory(qRaw: String): List<StockLedgerFixSearchInventoryHit> =
query.searchInventory(qRaw)

open fun searchLot(qRaw: String): List<StockLedgerFixSearchLotHit> = query.searchLot(qRaw)

open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail =
query.inventoryScopeDetail(inventoryId)

open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail =
query.lotScopeDetail(lotLineId)

open fun fixInventoryScope(inventoryId: Long): StockLedgerFixRunResponse =
backfill.fixInventoryScope(inventoryId)

open fun fixLotScope(lotLineId: Long): StockLedgerFixRunResponse =
backfill.fixLotScope(lotLineId)

open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview =
adj.adjPreview(rowLimit, adjDateRaw)

open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse =
adj.applyAdjAlign(adjDateRaw)

open fun applyAdjLotLineIn(adjDateRaw: String? = null): StockLedgerFixAdjResponse =
adj.applyAdjLotLineIn(adjDateRaw)

open fun fixDay(dateRaw: String, stepsRaw: List<String>? = null): StockLedgerFixRunResponse =
backfill.fixDay(dateRaw, stepsRaw)

open fun fixRange(fromRaw: String, toRaw: String, stepsRaw: List<String>? = null): StockLedgerFixRunResponse =
backfill.fixRange(fromRaw, toRaw, stepsRaw)

open fun writeExportSql(
from: LocalDate,
to: LocalDate,
out: OutputStream,
partsRaw: List<String>? = null,
) = export.writeExportSql(from, to, out, partsRaw)
}

+ 696
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixAdjService.kt 查看文件

@@ -0,0 +1,696 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjPreview
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRow
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.support.GeneratedKeyHolder
import org.springframework.stereotype.Service
import org.springframework.transaction.support.TransactionTemplate
import java.math.BigDecimal
import java.time.LocalDate

@Service
open class StockLedgerFixAdjService(
private val support: StockLedgerFixSupport,
private val backfill: StockLedgerFixBackfillService,
) {
private val log = LoggerFactory.getLogger(StockLedgerFixAdjService::class.java)

/**
* 2.7b. One ADJ per lot line whose remain (in − out) differs from its ledger net.
* Quantity is that lot's own gap. Out greater than in is left to 2.7a.
* Lot lines and inventory are not updated. After the ADJ, the bucket balance is
* rebuilt from live in/out so a stale balance is not copied forward.
* A lot that already matches is skipped. A second run inserts no new ADJ.
* Ledger net uses `date < adjDate+1`.
*/
open fun adjPreview(rowLimit: Int = 20, adjDateRaw: String? = null): StockLedgerFixAdjPreview {
val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw)
val t0 = System.nanoTime()
val plans = planLotAdjs(asOfExclusive)
val sumIn = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missIn }
val sumOut = plans.fold(BigDecimal.ZERO) { a, g -> a + g.missOut }
val limit = rowLimit.coerceIn(0, 50)
log.info(
"stock-ledger-fix 2.7b preview lots={} in={} out={} {}ms",
plans.size,
plans.count { it.missIn > BigDecimal.ZERO },
plans.count { it.missOut > BigDecimal.ZERO },
(System.nanoTime() - t0) / 1_000_000,
)
return StockLedgerFixAdjPreview(
adjDate = adjDate.toString(),
lotCount = plans.size,
adjInCount = plans.count { it.missIn > BigDecimal.ZERO },
adjOutCount = plans.count { it.missOut > BigDecimal.ZERO },
skippedNegCount = 0,
sumMissIn = sumIn.stripTrailingZeros().toPlainString(),
sumMissOut = sumOut.stripTrailingZeros().toPlainString(),
skuNet = (sumIn - sumOut).stripTrailingZeros().toPlainString(),
overIssueCount = 0,
sumOverIssue = "0",
rows = plans.take(limit).map { it.toRow() },
)
}

open fun applyAdjAlign(adjDateRaw: String? = null): StockLedgerFixAdjResponse {
if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) {
throw IllegalStateException("fix already running")
}
val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw)
val t0 = System.nanoTime()
try {
support.stampFixActor()
val plans = planLotAdjs(asOfExclusive)
var insertedIn = 0
var insertedOut = 0
val overIssuePatched = 0
if (plans.isNotEmpty()) {
val insTx = TransactionTemplate(support.transactionManager)
insTx.timeout = 300
insTx.execute {
var stockInId: Long? = null
var stockOutId: Long? = null
fun inHeader(): Long =
stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it }
fun outHeader(): Long =
stockOutId ?: ensureAdjStockOutHeader(adjDate).also { stockOutId = it }
plans.forEach { g ->
when {
g.missIn > BigDecimal.ZERO -> {
insertAdjLedger(
g, adjDate, inQty = g.missIn, outQty = null, stockInId = inHeader(),
)
insertedIn++
}
g.missOut > BigDecimal.ZERO -> {
insertAdjLedger(
g, adjDate, inQty = null, outQty = g.missOut, stockOutId = outHeader(),
)
insertedOut++
}
}
}
}
}
val adjustmentRecords = TransactionTemplate(support.transactionManager).let { tx ->
tx.timeout = 300
tx.execute { insertMissingAdjustmentRecords() } ?: 0
}
val lotIds = linkedSetOf<Long>()
fun addLot(lotId: Long, invId: Long?) {
if (lotId > 0) lotIds.add(lotId)
}
plans.forEach { addLot(it.lotLineId, it.inventoryId) }
support.jdbcDao.queryForList(
"""
SELECT DISTINCT inventoryLotLineId AS lotId, inventoryId AS invId
FROM stock_ledger
WHERE deleted = 0
AND date >= :adjDate AND date < :asOf
AND type = 'ADJ'
AND createdBy IN ('DNP', 'stock-ledger-fix')
""".trimIndent(),
mapOf("adjDate" to adjDate, "asOf" to asOfExclusive),
).forEach { addLot(support.toLong(it["lotId"]), support.toLongOrNull(it["invId"])) }
val filledLotQty = support.timed("adj 2.4") { backfill.fillLotQtyForLotIds(lotIds.toList(), asOfExclusive) }
val filledBalance = support.timed("adj 2.5") {
backfill.fillBalanceForInventoryIds(
plans.mapNotNull { it.inventoryId }.distinct(),
asOfExclusive,
)
}
val range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter(
"inventoryLotLineId IN (:lotIds)",
mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()),
)
val dayRows = if (range != null && lotIds.isNotEmpty()) {
support.timed("adj 2.6") { backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second) }
} else 0
log.info(
"stock-ledger-fix 2.7b {} lots={} in={} out={} adjRecord={} lotQty={} bal={} day={} total={}ms",
adjDate, plans.size, insertedIn, insertedOut, adjustmentRecords,
filledLotQty, filledBalance, dayRows,
(System.nanoTime() - t0) / 1_000_000,
)
return StockLedgerFixAdjResponse(
adjDate = adjDate.toString(),
insertedIn = insertedIn,
insertedOut = insertedOut,
overIssuePatched = overIssuePatched,
filledLotQty = filledLotQty,
filledBalance = filledBalance,
dayRowsWritten = dayRows,
adjustmentRecords = adjustmentRecords,
)
} catch (e: Exception) {
log.warn("stock-ledger-fix adj FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message)
throw e
} finally {
support.unlock(StockLedgerFixSupport.RUN_LOCK)
}
}

/**
* 2.7a. Lot lines with outQty greater than inQty get inQty raised to outQty,
* and one inbound ADJ on that same lot for the gap. A second run finds no gap.
* The lot-line update fires the inventory trigger, so the ledger in must be the same qty.
*/
open fun applyAdjLotLineIn(adjDateRaw: String? = null): StockLedgerFixAdjResponse {
if (!support.tryLock(StockLedgerFixSupport.RUN_LOCK)) {
throw IllegalStateException("fix already running")
}
val (adjDate, asOfExclusive) = resolveAdjWindow(adjDateRaw)
val t0 = System.nanoTime()
try {
support.stampFixActor()
val gaps = loadLotLineInGaps()
var insertedIn = 0
val lotIds = linkedSetOf<Long>()
if (gaps.isNotEmpty()) {
val insTx = TransactionTemplate(support.transactionManager)
insTx.timeout = 300
insTx.execute {
var stockInId: Long? = null
fun inHeader(): Long =
stockInId ?: ensureAdjStockInHeader(adjDate).also { stockInId = it }
gaps.forEach { g ->
val raised = support.jdbcDao.executeUpdate(
"""
UPDATE inventory_lot_line
SET inQty = outQty,
modified = NOW(),
modifiedBy = 'DNP'
WHERE id = :id
AND IFNULL(deleted, 0) = 0
AND COALESCE(outQty, 0) > COALESCE(inQty, 0)
""".trimIndent(),
mapOf("id" to g.lotLineId),
)
if (raised == 0) return@forEach
insertAdjLedger(
g, adjDate, inQty = g.missIn, outQty = null,
remarks = ADJ_LOT_LINE_REMARKS, stockInId = inHeader(),
)
insertedIn++
lotIds.add(g.lotLineId)
}
}
}
val adjustmentRecords = TransactionTemplate(support.transactionManager).let { tx ->
tx.timeout = 300
tx.execute { insertMissingAdjustmentRecords() } ?: 0
}
val filledLotQty = support.timed("adj 2.7a 2.4") {
backfill.fillLotQtyForLotIds(lotIds.toList(), asOfExclusive)
}
val filledBalance = support.timed("adj 2.7a 2.5") {
backfill.fillBalanceOnNewAdjRows(adjDate, asOfExclusive)
}
val range = if (lotIds.isEmpty()) null else backfill.dateRangeForFilter(
"inventoryLotLineId IN (:lotIds)",
mapOf("today" to asOfExclusive, "lotIds" to lotIds.toList()),
)
val dayRows = if (range != null && lotIds.isNotEmpty()) {
support.timed("adj 2.7a 2.6") {
backfill.upsertStockLotDayRange(lotIds.toList(), range.first, range.second)
}
} else 0
log.info(
"stock-ledger-fix 2.7a {} lines={} adjRecord={} lotQty={} bal={} day={} total={}ms",
adjDate, insertedIn, adjustmentRecords, filledLotQty, filledBalance, dayRows,
(System.nanoTime() - t0) / 1_000_000,
)
return StockLedgerFixAdjResponse(
adjDate = adjDate.toString(),
insertedIn = insertedIn,
insertedOut = 0,
overIssuePatched = insertedIn,
filledLotQty = filledLotQty,
filledBalance = filledBalance,
dayRowsWritten = dayRows,
adjustmentRecords = adjustmentRecords,
)
} catch (e: Exception) {
log.warn("stock-ledger-fix 2.7a FAILED after {}ms: {}", (System.nanoTime() - t0) / 1_000_000, e.message)
throw e
} finally {
support.unlock(StockLedgerFixSupport.RUN_LOCK)
}
}

private fun loadLotLineInGaps(): List<AdjGap> {
return support.jdbcDao.queryForList(
"""
SELECT ill.id AS lotLineId,
il.itemId AS itemId,
it.code AS itemCode,
iu.uomId AS uomId,
i.id AS inventoryId,
CAST(COALESCE(ill.outQty, 0) - COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS gap
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN items it ON it.id = il.itemId
INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId
LEFT JOIN inventory i
ON i.itemId = il.itemId
AND i.stockUomId = iu.uomId
AND IFNULL(i.deleted, 0) = 0
WHERE IFNULL(ill.deleted, 0) = 0
AND COALESCE(ill.outQty, 0) > COALESCE(ill.inQty, 0)
""".trimIndent(),
).mapNotNull { row ->
val gap = support.toDecimal(row["gap"])
if (gap.compareTo(ADJ_TOLERANCE) <= 0) return@mapNotNull null
AdjGap(
lotLineId = support.toLong(row["lotLineId"]),
inventoryId = support.toLongOrNull(row["inventoryId"]),
itemId = support.toLongOrNull(row["itemId"]),
itemCode = row["itemCode"]?.toString(),
uomId = support.toLongOrNull(row["uomId"]),
lineIn = BigDecimal.ZERO,
lineOut = BigDecimal.ZERO,
ledgerIn = BigDecimal.ZERO,
ledgerOut = BigDecimal.ZERO,
missIn = gap,
missOut = BigDecimal.ZERO,
)
}
}

/**
* ADJ window: ledger date = [adjDate], gap sums use `date < adjDate+1`.
* Default adjDate = yesterday. Pass today for freeze-night dump (e.g. 31/8).
*/
private fun resolveAdjWindow(adjDateRaw: String?): Pair<LocalDate, LocalDate> {
val today = LocalDate.now()
val adjDate = if (adjDateRaw.isNullOrBlank()) {
today.minusDays(1)
} else {
support.parseDay(adjDateRaw)
}
if (adjDate.isAfter(today)) {
throw IllegalArgumentException("adjDate cannot be in the future")
}
return adjDate to adjDate.plusDays(1)
}

/**
* One ADJ per lot whose remain differs from its own ledger net.
* Gap = (line in − line out) − (ledger in − ledger out). Positive gap is an inbound ADJ.
* Lots with out greater than in are skipped; 2.7a owns those.
*/
private fun planLotAdjs(today: LocalDate): List<AdjGap> {
val lines = support.jdbcDao.queryForList(
"""
SELECT ill.id AS lotLineId,
CAST(COALESCE(ill.inQty, 0) AS DECIMAL(14,2)) AS lineIn,
CAST(COALESCE(ill.outQty, 0) AS DECIMAL(14,2)) AS lineOut,
il.itemId AS itemId,
it.code AS itemCode,
iu.uomId AS uomId,
i.id AS inventoryId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN items it ON it.id = il.itemId
INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId
LEFT JOIN inventory i
ON i.itemId = il.itemId
AND i.stockUomId = iu.uomId
AND IFNULL(i.deleted, 0) = 0
WHERE IFNULL(ill.deleted, 0) = 0
AND COALESCE(ill.outQty, 0) <= COALESCE(ill.inQty, 0)
""".trimIndent(),
)
if (lines.isEmpty()) return emptyList()
val byId = HashMap<Long, Map<String, Any?>>(lines.size)
lines.forEach { byId[support.toLong(it["lotLineId"])] = it }
val gaps = ArrayList<AdjGap>()
byId.keys.chunked(400).forEach { chunk ->
val netByLot = HashMap<Long, Map<String, Any?>>(chunk.size)
support.jdbcDao.queryForList(
"""
SELECT sl.inventoryLotLineId AS lotLineId,
CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS ledgerIn,
CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS ledgerOut
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date < :today
AND sl.inventoryLotLineId IN (:ids)
GROUP BY sl.inventoryLotLineId
""".trimIndent(),
mapOf("today" to today, "ids" to chunk),
).forEach { netByLot[support.toLong(it["lotLineId"])] = it }
chunk.forEach lotLoop@{ lotId ->
val line = byId.getValue(lotId)
val lineIn = support.toDecimal(line["lineIn"])
val lineOut = support.toDecimal(line["lineOut"])
val net = netByLot[lotId]
val ledgerIn = support.toDecimal(net?.get("ledgerIn"))
val ledgerOut = support.toDecimal(net?.get("ledgerOut"))
val gap = lineIn.subtract(lineOut).subtract(ledgerIn.subtract(ledgerOut))
if (gap.abs().compareTo(ADJ_TOLERANCE) <= 0) return@lotLoop
val needIn = gap.signum() > 0
gaps.add(
AdjGap(
lotLineId = lotId,
inventoryId = support.toLongOrNull(line["inventoryId"]),
itemId = support.toLongOrNull(line["itemId"]),
itemCode = line["itemCode"]?.toString(),
uomId = support.toLongOrNull(line["uomId"]),
lineIn = lineIn,
lineOut = lineOut,
ledgerIn = ledgerIn,
ledgerOut = ledgerOut,
missIn = if (needIn) gap else BigDecimal.ZERO,
missOut = if (needIn) BigDecimal.ZERO else gap.negate(),
),
)
}
}
return gaps
}


private fun insertAdjLedger(
g: AdjGap,
adjDate: LocalDate,
inQty: BigDecimal?,
outQty: BigDecimal?,
remarks: String = ADJ_DOC_REMARKS,
stockInId: Long? = null,
stockOutId: Long? = null,
) {
val silId = if (inQty != null) {
insertAdjStockInLine(g, adjDate, inQty, remarks, stockInId ?: error("stockInId required for ADJ in"))
} else {
null
}
val solId = if (outQty != null) {
insertAdjStockOutLine(g, adjDate, outQty, remarks, stockOutId ?: error("stockOutId required for ADJ out"))
} else {
null
}
val args = hashMapOf<String, Any?>(
"adjDate" to adjDate,
"silId" to silId,
"solId" to solId,
"lotLineId" to g.lotLineId,
"inventoryId" to g.inventoryId,
"itemId" to g.itemId,
"itemCode" to g.itemCode,
"inQty" to inQty,
"outQty" to outQty,
"uomId" to g.uomId,
)
support.jdbcDao.executeUpdate(
"""
INSERT INTO stock_ledger (
created, createdBy, version, modified, modifiedBy, deleted,
date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,
itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,
uomId, type
) VALUES (
NOW(), 'DNP', 0, NOW(), 'DNP', 0,
:adjDate, :silId, :solId, :lotLineId, :inventoryId,
:itemId, :itemCode, :inQty, :outQty, NULL, NULL, NULL,
:uomId, 'ADJ'
)
""".trimIndent(),
args,
)
}


private fun adjStockInCode(adjDate: LocalDate): String = "SLF-I-$adjDate"

private fun adjStockOutRemarks(adjDate: LocalDate): String = "$ADJ_HEADER_REMARKS $adjDate"

/** One stock_in per ADJ date (reused if Apply runs again). */
private fun ensureAdjStockInHeader(adjDate: LocalDate): Long {
val code = adjStockInCode(adjDate)
val existing = support.jdbcDao.queryForMap(
"""
SELECT id FROM stock_in
WHERE code = :code AND IFNULL(deleted, 0) = 0
LIMIT 1
""".trimIndent(),
mapOf("code" to code),
).orElse(null)
if (existing != null) return support.toLong(existing["id"])
val at = adjDate.atTime(12, 0)
return insertReturningId(
"""
INSERT INTO stock_in (
created, createdBy, version, modified, modifiedBy, deleted,
code, status, orderDate, completeDate
) VALUES (
NOW(), 'DNP', 0, NOW(), 'DNP', 0,
:code, 'completed', :at, :at
)
""".trimIndent(),
mapOf("code" to code, "at" to at),
)
}

/** One stock_out per ADJ date (reused if Apply runs again). */
private fun ensureAdjStockOutHeader(adjDate: LocalDate): Long {
val remarks = adjStockOutRemarks(adjDate)
val existing = support.jdbcDao.queryForMap(
"""
SELECT id FROM stock_out
WHERE createdBy IN ('DNP', 'stock-ledger-fix')
AND type = 'ADJ'
AND remarks = :remarks
AND IFNULL(deleted, 0) = 0
ORDER BY id
LIMIT 1
""".trimIndent(),
mapOf("remarks" to remarks),
).orElse(null)
if (existing != null) return support.toLong(existing["id"])
val at = adjDate.atTime(12, 0)
val handlerId = SecurityUtils.getUser().orElse(null)?.id
?: throw IllegalStateException("stock-ledger-fix: login user required for stock_out.handlerId")
return insertReturningId(
"""
INSERT INTO stock_out (
created, createdBy, version, modified, modifiedBy, deleted,
type, completeDate, status, remarks, handlerId
) VALUES (
NOW(), 'DNP', 0, NOW(), 'DNP', 0,
'ADJ', :at, 'completed', :remarks, :handlerId
)
""".trimIndent(),
mapOf("at" to at, "remarks" to remarks, "handlerId" to handlerId),
)
}

/** One remarks row per stock-ledger-fix ADJ line that does not already have one. */
private fun insertMissingAdjustmentRecords(): Int {
return support.jdbcDao.executeUpdate(
"""
INSERT INTO stock_adjustment_record (
created, createdBy, version, modified, modifiedBy, deleted,
itemId, itemCode, itemName, lotNo, inQty, outQty, remarks,
stockInLineId, stockOutLineId
)
SELECT
NOW(), 'DNP', 0, NOW(), 'DNP', 0,
sl.itemId,
LEFT(COALESCE(sl.itemCode, it.code), 50),
LEFT(it.name, 200),
LEFT(il.lotNo, 512),
CASE WHEN sl.stockInLineId IS NOT NULL THEN sl.inQty ELSE NULL END,
CASE WHEN sl.stockOutLineId IS NOT NULL THEN sl.outQty ELSE NULL END,
'DNP',
sl.stockInLineId,
sl.stockOutLineId
FROM stock_ledger sl
INNER JOIN items it ON it.id = sl.itemId
LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId
LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN stock_in_line sil ON sil.id = sl.stockInLineId
LEFT JOIN stock_out_line sol ON sol.id = sl.stockOutLineId
LEFT JOIN stock_out so ON so.id = sol.stockOutId
WHERE sl.deleted = 0
AND sl.type = 'ADJ'
AND sl.createdBy IN ('DNP', 'stock-ledger-fix')
AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL)
AND NOT EXISTS (
SELECT 1
FROM stock_adjustment_record r
WHERE IFNULL(r.deleted, 0) = 0
AND (
(sl.stockInLineId IS NOT NULL AND r.stockInLineId = sl.stockInLineId)
OR (sl.stockOutLineId IS NOT NULL AND r.stockOutLineId = sl.stockOutLineId)
)
)
""".trimIndent(),
)
}

/** Line on shared stock_in; does not UPDATE inventory_lot_line or inventory. */
private fun insertAdjStockInLine(
g: AdjGap,
adjDate: LocalDate,
qty: BigDecimal,
remarks: String,
stockInId: Long,
): Long? {
val itemId = g.itemId
if (itemId == null) {
log.warn("stock-ledger-fix adj skip SIL: lotLineId={} has no itemId", g.lotLineId)
return null
}
val lot = loadAdjLotLink(g.lotLineId) ?: return null
val at = adjDate.atTime(12, 0)
return insertReturningId(
"""
INSERT INTO stock_in_line (
created, createdBy, version, modified, modifiedBy, deleted,
itemId, itemNo, stockInId, acceptedQty, receiptDate, status,
inventoryLotId, inventoryLotLineId, lotNo, remarks, type
) VALUES (
NOW(), 'DNP', 0, NOW(), 'DNP', 0,
:itemId, :itemNo, :stockInId, :qty, :at, 'completed',
:inventoryLotId, :lotLineId, :lotNo, :remarks, 'ADJ'
)
""".trimIndent(),
mapOf(
"itemId" to itemId,
"itemNo" to adjItemNo(g.itemCode),
"stockInId" to stockInId,
"qty" to qty,
"at" to at,
"inventoryLotId" to lot.inventoryLotId,
"lotLineId" to g.lotLineId,
"lotNo" to lot.lotNo,
"remarks" to remarks,
),
)
}

/** Line on shared stock_out; does not UPDATE inventory_lot_line or inventory. */
private fun insertAdjStockOutLine(
g: AdjGap,
adjDate: LocalDate,
qty: BigDecimal,
remarks: String,
stockOutId: Long,
): Long? {
val itemId = g.itemId
if (itemId == null) {
log.warn("stock-ledger-fix adj skip SOL: lotLineId={} has no itemId", g.lotLineId)
return null
}
if (loadAdjLotLink(g.lotLineId) == null) return null
val at = adjDate.atTime(12, 0)
return insertReturningId(
"""
INSERT INTO stock_out_line (
created, createdBy, version, modified, modifiedBy, deleted,
itemId, qty, stockOutId, inventoryLotLineId, status, pickTime, type
) VALUES (
NOW(), 'DNP', 0, NOW(), 'DNP', 0,
:itemId, :qty, :stockOutId, :lotLineId, 'completed', :at, 'ADJ'
)
""".trimIndent(),
mapOf(
"itemId" to itemId,
"qty" to qty,
"stockOutId" to stockOutId,
"lotLineId" to g.lotLineId,
"at" to at,
),
)
}

private fun loadAdjLotLink(lotLineId: Long): AdjLotLink? {
val row = support.jdbcDao.queryForMap(
"""
SELECT ill.inventoryLotId AS inventoryLotId, il.lotNo AS lotNo
FROM inventory_lot_line ill
LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId
WHERE ill.id = :lotLineId
AND IFNULL(ill.deleted, 0) = 0
""".trimIndent(),
mapOf("lotLineId" to lotLineId),
).orElse(null)
if (row == null) {
log.warn("stock-ledger-fix adj skip SIL/SOL: lotLineId={} not found", lotLineId)
return null
}
return AdjLotLink(
inventoryLotId = support.toLongOrNull(row["inventoryLotId"]),
lotNo = row["lotNo"]?.toString(),
)
}

private fun insertReturningId(sql: String, args: Map<String, Any?>): Long {
val keyHolder = GeneratedKeyHolder()
support.namedJdbc.update(sql, MapSqlParameterSource(args), keyHolder, arrayOf("id"))
val key = keyHolder.key?.toLong()
?: keyHolder.keyList.firstOrNull()?.values?.firstOrNull()?.let { (it as Number).toLong() }
return key ?: error("stock-ledger-fix: insert did not return id")
}

private fun adjItemNo(itemCode: String?): String {
val raw = itemCode?.trim().orEmpty()
return if (raw.isEmpty()) "SLF" else raw.take(20)
}

private data class AdjLotLink(
val inventoryLotId: Long?,
val lotNo: String?,
)

private data class AdjGap(
val lotLineId: Long,
val inventoryId: Long?,
val itemId: Long?,
val itemCode: String?,
val uomId: Long?,
val lineIn: BigDecimal,
val lineOut: BigDecimal,
val ledgerIn: BigDecimal,
val ledgerOut: BigDecimal,
val missIn: BigDecimal,
val missOut: BigDecimal,
) {
val overIssue: BigDecimal =
if (lineOut.compareTo(lineIn) > 0) lineOut.subtract(lineIn) else BigDecimal.ZERO

fun hasOverIssue(): Boolean = overIssue.compareTo(BigDecimal.ZERO) > 0

fun hasMiss(): Boolean =
missIn.compareTo(BigDecimal.ZERO) != 0 || missOut.compareTo(BigDecimal.ZERO) != 0

fun hasWork(): Boolean = hasOverIssue() || hasMiss()

fun toRow() = StockLedgerFixAdjRow(
lotLineId = lotLineId,
inventoryId = inventoryId,
itemCode = itemCode,
lineIn = lineIn.stripTrailingZeros().toPlainString(),
lineOut = lineOut.stripTrailingZeros().toPlainString(),
ledgerIn = ledgerIn.stripTrailingZeros().toPlainString(),
ledgerOut = ledgerOut.stripTrailingZeros().toPlainString(),
missIn = missIn.stripTrailingZeros().toPlainString(),
missOut = missOut.stripTrailingZeros().toPlainString(),
overIssue = overIssue.stripTrailingZeros().toPlainString(),
)
}

companion object {
private val ADJ_TOLERANCE = BigDecimal("0.01")
private const val ADJ_DOC_REMARKS = "stock-ledger-fix 2.7b ADJ"
private const val ADJ_LOT_LINE_REMARKS = "stock-ledger-fix 2.7a ADJ"
private const val ADJ_HEADER_REMARKS = "stock-ledger-fix 2.7 ADJ"
}
}

+ 1690
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixBackfillService.kt
文件差异内容过多而无法显示
查看文件


+ 853
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixExportService.kt 查看文件

@@ -0,0 +1,853 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.RowCallbackHandler
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Service
import java.io.BufferedWriter
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.math.BigDecimal
import java.nio.charset.StandardCharsets
import java.sql.ResultSet
import java.time.LocalDate

@Service
open class StockLedgerFixExportService(
private val support: StockLedgerFixSupport,
) {
private val log = LoggerFactory.getLogger(StockLedgerFixExportService::class.java)

/**
* Export SQL patch for target DB (same dump lineage / freeze).
* [partsRaw] omit / empty / all = ledger + 2.6 (legacy). Else subset:
* 1.0 inventory, 2.1 soft-deleted ledger + stock-out lot, 2.3 inventoryId,
* ledger (lot/uom/lotQty/balance), 2.6 day, 2.7 SIL/SOL + adjustment record + ADJ INSERT.
* Apply order in file: 1.0 → 2.1 → ledger(+2.3) → 2.7 docs → 2.7 ADJ → 2.6.
*/
open fun writeExportSql(
from: LocalDate,
to: LocalDate,
out: OutputStream,
partsRaw: List<String>? = null,
) {
if (to.isBefore(from)) throw IllegalArgumentException("to must be on or after from")
if (from.plusDays(400).isBefore(to)) {
throw IllegalArgumentException("date range must be at most 400 days")
}
val today = LocalDate.now()
if (to.isAfter(today)) {
throw IllegalArgumentException("cannot export future dates")
}
val toExclusive = to.plusDays(1)
if (!from.isBefore(toExclusive)) {
throw IllegalArgumentException("from must be before toExclusive")
}
support.stampFixActor()
val parts = parseExportParts(partsRaw)
val want10 = "1.0" in parts
val want21 = "2.1" in parts
val want23 = "2.3" in parts
val wantLedger = "ledger" in parts
val want26 = "2.6" in parts
val want27 = "2.7" in parts
if (!want10 && !want21 && !want23 && !wantLedger && !want26 && !want27) {
throw IllegalArgumentException("parts must include at least one of: 1.0, 2.1, 2.3, ledger, 2.6, 2.7")
}
val args = mapOf("from" to from, "toExclusive" to toExclusive)
val ledgerCnt = if (wantLedger || want23 || want27) {
support.toInt(
support.jdbcDao.queryForList(
"""
SELECT COUNT(*) AS c FROM stock_ledger
WHERE deleted = 0 AND date >= :from AND date < :toExclusive
""".trimIndent(),
args,
).firstOrNull()?.get("c"),
)
} else {
0
}
val dayCnt = if (want26) {
support.toInt(
support.jdbcDao.queryForList(
"""
SELECT COUNT(*) AS c FROM stock_lot_day
WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive
""".trimIndent(),
args,
).firstOrNull()?.get("c"),
)
} else {
0
}
val adjCnt = if (want27) {
support.toInt(
support.jdbcDao.queryForList(
"""
SELECT COUNT(*) AS c FROM stock_ledger
WHERE deleted = 0
AND date >= :from AND date < :toExclusive
AND type = 'ADJ'
AND createdBy IN ('DNP', 'stock-ledger-fix')
""".trimIndent(),
args,
).firstOrNull()?.get("c"),
)
} else {
0
}
val invCnt = if (want10) {
support.toInt(
support.jdbcDao.queryForList(
"SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0",
).firstOrNull()?.get("c"),
)
} else {
0
}
val writer = BufferedWriter(OutputStreamWriter(out, StandardCharsets.UTF_8), 65536)
val jdbc = NamedParameterJdbcTemplate(support.dataSource)
jdbc.jdbcTemplate.fetchSize = 2000
try {
writer.write("-- stock-ledger-fix patch\n")
writer.write("-- from $from inclusive to ${toExclusive.minusDays(1)} inclusive (date < $toExclusive)\n")
writer.write("-- parts=${parts.joinToString(",")}\n")
writer.write("-- apply order: 1.0 inventory (stockUomId) then lot outQty NULL→0 → 2.1 delete ledger + stock_out lot → ledger(+2.3) UPDATE → 2.7a lot inQty + SIL/SOL/adjustment record then ADJ INSERT → 2.6 stock_lot_day\n")
writer.write("-- INNER JOIN id: target rows without matching id are skipped on UPDATE\n")
if (want23 && !want10) {
writer.write("-- WARN: 2.3 without 1.0 — new inventory ids may be missing on target; prefer include 1.0\n")
}
writer.write("-- please run in the target DB/schema, e.g. USE fpsmsdb;\n")
writer.write(
"-- expected inventoryRows=$invCnt ledgerRows=$ledgerCnt adjRows=$adjCnt stock_lot_dayRows=$dayCnt\n",
)
writer.write("-- SET NAMES utf8mb4;\n\n")

if (want10) {
writeExportInventory10(writer, jdbc)
}
if (want21) {
writeExport21(writer, jdbc, args)
}
if (wantLedger || want23) {
writeExportLedgerPatch(writer, jdbc, args, wantLedger, want23)
}
if (want27) {
writeExportAdjDocs(writer, jdbc, args)
writeExportAdjInsert(writer, jdbc, args)
}
if (want26) {
writeExportStockLotDay(writer, jdbc, args, from, toExclusive)
}

writer.write("\n-- done\n")
writer.flush()
log.info(
"stock-ledger-fix export {}..{} parts={} inv={} ledger={} adj={} day={}",
from, toExclusive.minusDays(1), parts.joinToString(","), invCnt, ledgerCnt, adjCnt, dayCnt,
)
} finally {
writer.flush()
}
}

private fun writeExportInventory10(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) {
writer.write("-- === 1.0 inventory (stockUomId first, so the later outQty trigger updates this row) ===\n")
writeInventoryUpsert(writer, jdbc)
writeTempUpdate(
writer,
jdbc,
emptyMap(),
title = "-- === 1.0 soft-delete orphan inventory (stockUomId still null) ===\n",
selectSql = """
SELECT id, modified, modifiedBy
FROM inventory
WHERE deleted = 1
AND modifiedBy IN ('DNP', 'stock-ledger-fix')
ORDER BY id
""".trimIndent(),
tempName = "tmp_sl_fix_inv_del",
columnDefs = "modified DATETIME NULL, modifiedBy VARCHAR(255) NULL",
columns = listOf("id", "modified", "modifiedBy"),
updateSql = """
UPDATE inventory i
INNER JOIN tmp_sl_fix_inv_del t ON t.id = i.id
SET
i.deleted = 1,
i.modified = t.modified,
i.modifiedBy = t.modifiedBy;
""".trimIndent(),
emptyComment = "-- (no stock-ledger-fix deleted inventory rows)",
)
writer.write("-- === 1.0 lot line outQty NULL → 0 (after stockUomId; trigger updates the existing bucket) ===\n")
writer.write(StockLedgerFixInventorySql.ZERO_NULL_OUT_QTY_SQL.trim())
writer.write(";\n\n")
writer.write("-- === 1.0 inventory qty again (trigger above replaced onHandQty) ===\n")
writeInventoryUpsert(writer, jdbc)
}

private fun writeInventoryUpsert(writer: BufferedWriter, jdbc: NamedParameterJdbcTemplate) {
var batch = 0
jdbc.query(
"""
SELECT id, created, createdBy, version, modified, modifiedBy, deleted,
itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,
price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status
FROM inventory
WHERE IFNULL(deleted, 0) = 0
ORDER BY id
""".trimIndent(),
emptyMap<String, Any>(),
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write(
"INSERT INTO inventory (\n" +
" id, created, createdBy, version, modified, modifiedBy, deleted,\n" +
" itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,\n" +
" price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status\n" +
") VALUES\n",
)
} else {
writer.write(",\n")
}
writer.write(
"(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " +
"${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " +
"${rsSql(rs, "itemId")}, ${rsSql(rs, "uomId")}, ${rsSql(rs, "stockUomId")}, ${rsSql(rs, "onHandQty")}, " +
"${rsSql(rs, "onHoldQty")}, ${rsSql(rs, "unavailableQty")}, ${rsSql(rs, "price")}, " +
"${rsSql(rs, "currencyId")}, ${rsSql(rs, "cpu")}, ${rsSql(rs, "cpuUnit")}, " +
"${rsSql(rs, "cpm")}, ${rsSql(rs, "cpmUnit")}, ${rsSql(rs, "status")})",
)
batch++
if (batch >= 200) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " +
"unavailableQty=VALUES(unavailableQty), status=VALUES(status), " +
"uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " +
"price=VALUES(price), currencyId=VALUES(currencyId), " +
"cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=inventory.version+1, deleted=0;\n\n",
)
batch = 0
}
},
)
if (batch > 0) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"onHandQty=VALUES(onHandQty), onHoldQty=VALUES(onHoldQty), " +
"unavailableQty=VALUES(unavailableQty), status=VALUES(status), " +
"uomId=VALUES(uomId), stockUomId=VALUES(stockUomId), " +
"price=VALUES(price), currencyId=VALUES(currencyId), " +
"cpu=VALUES(cpu), cpuUnit=VALUES(cpuUnit), cpm=VALUES(cpm), cpmUnit=VALUES(cpmUnit), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=inventory.version+1, deleted=0;\n\n",
)
} else {
writer.write("-- (no inventory rows)\n\n")
}
}

/**
* 2.1 results the live-row ledger patch cannot carry:
* soft-deleted ledger rows (including lot cleared when the out line has no lot),
* and stock_out_line.inventoryLotLineId set to the ledger lot.
*/
private fun writeExport21(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
) {
writeTempUpdate(
writer,
jdbc,
args,
title = "-- === 2.1 soft-delete stock_ledger (id missing on target is skipped) ===\n",
selectSql = """
SELECT id, inventoryLotLineId, modified, modifiedBy
FROM stock_ledger
WHERE deleted = 1
AND modifiedBy IN ('DNP', 'stock-ledger-fix')
AND date >= :from AND date < :toExclusive
ORDER BY id
""".trimIndent(),
tempName = "tmp_sl_fix_ledger_del",
columnDefs = "inventoryLotLineId BIGINT NULL, modified DATETIME NULL, modifiedBy VARCHAR(255) NULL",
columns = listOf("id", "inventoryLotLineId", "modified", "modifiedBy"),
updateSql = """
UPDATE stock_ledger sl
INNER JOIN tmp_sl_fix_ledger_del t ON t.id = sl.id
SET
sl.deleted = 1,
sl.inventoryLotLineId = t.inventoryLotLineId,
sl.modified = t.modified,
sl.modifiedBy = t.modifiedBy;
""".trimIndent(),
emptyComment = "-- (no stock-ledger-fix deleted ledger rows in range)",
)
writeTempUpdate(
writer,
jdbc,
args,
title = "-- === 2.1 stock_out_line lot = ledger lot (existing lines only) ===\n",
selectSql = """
SELECT sol.id, sol.inventoryLotLineId, sol.modified, sol.modifiedBy
FROM stock_out_line sol
WHERE sol.modifiedBy IN ('DNP', 'stock-ledger-fix')
AND IFNULL(sol.createdBy, '') NOT IN ('DNP', 'stock-ledger-fix')
AND EXISTS (
SELECT 1
FROM stock_ledger sl
WHERE sl.stockOutLineId = sol.id
AND sl.date >= :from AND sl.date < :toExclusive
)
ORDER BY sol.id
""".trimIndent(),
tempName = "tmp_sl_fix_sol_lot",
columnDefs = "inventoryLotLineId BIGINT NULL, modified DATETIME NULL, modifiedBy VARCHAR(255) NULL",
columns = listOf("id", "inventoryLotLineId", "modified", "modifiedBy"),
updateSql = """
UPDATE stock_out_line sol
INNER JOIN tmp_sl_fix_sol_lot t ON t.id = sol.id
SET
sol.inventoryLotLineId = t.inventoryLotLineId,
sol.modified = t.modified,
sol.modifiedBy = t.modifiedBy;
""".trimIndent(),
emptyComment = "-- (no stock-ledger-fix stock_out_line lot updates in range)",
)
}

private fun writeTempUpdate(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
title: String,
selectSql: String,
tempName: String,
columnDefs: String,
columns: List<String>,
updateSql: String,
emptyComment: String,
) {
writer.write(title)
writer.write("DROP TEMPORARY TABLE IF EXISTS $tempName;\n")
writer.write("CREATE TEMPORARY TABLE $tempName (\n")
writer.write(" id INT NOT NULL PRIMARY KEY,\n $columnDefs\n);\n\n")
var batch = 0
var any = false
jdbc.query(
selectSql,
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write("INSERT INTO $tempName (${columns.joinToString(", ")}) VALUES\n")
} else {
writer.write(",\n")
}
writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")")
any = true
batch++
if (batch >= 500) {
writer.write(";\n\n")
batch = 0
}
},
)
if (batch > 0) writer.write(";\n\n")
if (any) {
val sql = updateSql.trim().let { if (it.endsWith(";")) it else "$it;" }
writer.write("$sql\n\n")
} else {
writer.write("$emptyComment\n\n")
}
writer.write("DROP TEMPORARY TABLE IF EXISTS $tempName;\n\n")
}

private fun writeExportLedgerPatch(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
wantLedger: Boolean,
want23: Boolean,
) {
writer.write("-- === ledger UPDATE (JOIN id)")
if (wantLedger) writer.write(" lot/uom/lotQty/balance")
if (want23) writer.write(" inventoryId")
writer.write(" ===\n")
val cols = mutableListOf("id")
if (wantLedger) {
cols += listOf("inventoryLotLineId", "uomId", "lotQtyBefore", "lotQtyAfter", "balance")
}
if (want23) cols += "inventoryId"
writer.write("CREATE TEMPORARY TABLE tmp_sl_fix_patch (\n")
writer.write(" id INT NOT NULL PRIMARY KEY")
if (wantLedger) {
writer.write(",\n inventoryLotLineId BIGINT NULL,\n uomId INT NULL,\n")
writer.write(" lotQtyBefore DECIMAL(14,2) NULL,\n lotQtyAfter DECIMAL(14,2) NULL,\n")
writer.write(" balance DECIMAL(14,2) NULL")
}
if (want23) writer.write(",\n inventoryId INT NULL")
writer.write("\n);\n\n")
var batch = 0
val selectCols = cols.joinToString(", ")
jdbc.query(
"""
SELECT $selectCols
FROM stock_ledger
WHERE deleted = 0 AND date >= :from AND date < :toExclusive
""".trimIndent(),
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write("INSERT INTO tmp_sl_fix_patch (${cols.joinToString(", ")}) VALUES\n")
} else {
writer.write(",\n")
}
writer.write("(" + cols.joinToString(", ") { rsSql(rs, it) } + ")")
batch++
if (batch >= 500) {
writer.write(";\n\n")
batch = 0
}
},
)
if (batch > 0) writer.write(";\n\n")
val setParts = mutableListOf<String>()
if (wantLedger) {
setParts += listOf(
"sl.inventoryLotLineId = t.inventoryLotLineId",
"sl.uomId = t.uomId",
"sl.lotQtyBefore = t.lotQtyBefore",
"sl.lotQtyAfter = t.lotQtyAfter",
"sl.balance = t.balance",
)
}
if (want23) setParts += "sl.inventoryId = t.inventoryId"
writer.write(
"""
UPDATE stock_ledger sl
INNER JOIN tmp_sl_fix_patch t ON t.id = sl.id
SET
${setParts.joinToString(",\n ")}
WHERE sl.deleted = 0;

DROP TEMPORARY TABLE IF EXISTS tmp_sl_fix_patch;

""".trimIndent(),
)
writer.write("\n")
}

private fun writeExportAdjDocs(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
) {
writeTempUpdate(
writer,
jdbc,
args,
title = "-- === 2.7a lot line inQty (out was greater; trigger refreshes onHand) ===\n",
selectSql = """
SELECT ill.id, ill.inQty, ill.modified, ill.modifiedBy
FROM inventory_lot_line ill
WHERE ill.modifiedBy IN ('DNP', 'stock-ledger-fix')
AND IFNULL(ill.deleted, 0) = 0
AND EXISTS (
SELECT 1
FROM stock_ledger sl
INNER JOIN stock_in_line sil ON sil.id = sl.stockInLineId
WHERE sl.inventoryLotLineId = ill.id
AND sl.deleted = 0
AND sl.type = 'ADJ'
AND sl.createdBy IN ('DNP', 'stock-ledger-fix')
AND sl.date >= :from AND sl.date < :toExclusive
AND sil.remarks = 'stock-ledger-fix 2.7a ADJ'
)
ORDER BY ill.id
""".trimIndent(),
tempName = "tmp_slf_ill_in",
columnDefs = "inQty DECIMAL(14,2) NULL, modified DATETIME NULL, modifiedBy VARCHAR(50) NULL",
columns = listOf("id", "inQty", "modified", "modifiedBy"),
updateSql = """
UPDATE inventory_lot_line ill
INNER JOIN tmp_slf_ill_in t ON t.id = ill.id
SET ill.inQty = t.inQty,
ill.modified = t.modified,
ill.modifiedBy = t.modifiedBy;
""".trimIndent(),
emptyComment = "-- (no 2.7a lot-line inQty in range)",
)
writer.write("-- === 2.7 ADJ stock_in / stock_out (1 header + many lines; apply before ledger ADJ FKs) ===\n")
writeExportRows(
writer,
jdbc,
"""
SELECT DISTINCT si.id, si.created, si.createdBy, si.version, si.modified, si.modifiedBy, si.deleted,
si.code, si.status, si.orderDate, si.completeDate
FROM stock_in si
INNER JOIN stock_in_line sil ON sil.stockInId = si.id AND IFNULL(sil.deleted, 0) = 0
INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy IN ('DNP', 'stock-ledger-fix')
ORDER BY si.id
""".trimIndent(),
args,
table = "stock_in",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"code", "status", "orderDate", "completeDate",
),
onDup = "code=VALUES(code), status=VALUES(status), orderDate=VALUES(orderDate), " +
"completeDate=VALUES(completeDate), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_in.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_in headers in range)",
)
writeExportRows(
writer,
jdbc,
"""
SELECT sil.id, sil.created, sil.createdBy, sil.version, sil.modified, sil.modifiedBy, sil.deleted,
sil.itemId, sil.itemNo, sil.stockInId, sil.acceptedQty, sil.receiptDate, sil.status,
sil.inventoryLotId, sil.inventoryLotLineId, sil.lotNo, sil.remarks, sil.type
FROM stock_in_line sil
INNER JOIN stock_ledger sl ON sl.stockInLineId = sil.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy IN ('DNP', 'stock-ledger-fix')
ORDER BY sil.id
""".trimIndent(),
args,
table = "stock_in_line",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"itemId", "itemNo", "stockInId", "acceptedQty", "receiptDate", "status",
"inventoryLotId", "inventoryLotLineId", "lotNo", "remarks", "type",
),
onDup = "acceptedQty=VALUES(acceptedQty), inventoryLotId=VALUES(inventoryLotId), " +
"inventoryLotLineId=VALUES(inventoryLotLineId), lotNo=VALUES(lotNo), status=VALUES(status), " +
"type=VALUES(type), remarks=VALUES(remarks), modified=VALUES(modified), " +
"modifiedBy=VALUES(modifiedBy), version=stock_in_line.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_in_line in range)",
)
writeExportRows(
writer,
jdbc,
"""
SELECT DISTINCT so.id, so.created, so.createdBy, so.version, so.modified, so.modifiedBy, so.deleted,
so.type, so.completeDate, so.status, so.remarks, so.handlerId
FROM stock_out so
INNER JOIN stock_out_line sol ON sol.stockOutId = so.id AND IFNULL(sol.deleted, 0) = 0
INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy IN ('DNP', 'stock-ledger-fix')
ORDER BY so.id
""".trimIndent(),
args,
table = "stock_out",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"type", "completeDate", "status", "remarks", "handlerId",
),
onDup = "type=VALUES(type), completeDate=VALUES(completeDate), status=VALUES(status), " +
"remarks=VALUES(remarks), handlerId=VALUES(handlerId), modified=VALUES(modified), " +
"modifiedBy=VALUES(modifiedBy), version=stock_out.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_out headers in range)",
)
writeExportRows(
writer,
jdbc,
"""
SELECT sol.id, sol.created, sol.createdBy, sol.version, sol.modified, sol.modifiedBy, sol.deleted,
sol.itemId, sol.qty, sol.stockOutId, sol.inventoryLotLineId, sol.status, sol.pickTime, sol.type
FROM stock_out_line sol
INNER JOIN stock_ledger sl ON sl.stockOutLineId = sol.id
WHERE sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy IN ('DNP', 'stock-ledger-fix')
ORDER BY sol.id
""".trimIndent(),
args,
table = "stock_out_line",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"itemId", "qty", "stockOutId", "inventoryLotLineId", "status", "pickTime", "type",
),
onDup = "qty=VALUES(qty), inventoryLotLineId=VALUES(inventoryLotLineId), status=VALUES(status), " +
"pickTime=VALUES(pickTime), type=VALUES(type), modified=VALUES(modified), " +
"modifiedBy=VALUES(modifiedBy), version=stock_out_line.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_out_line in range)",
)
writer.write("-- === 2.7 stock_adjustment_record (after SIL/SOL; FK to those lines) ===\n")
writeExportRows(
writer,
jdbc,
"""
SELECT DISTINCT r.id, r.created, r.createdBy, r.version, r.modified, r.modifiedBy, r.deleted,
r.itemId, r.itemCode, r.itemName, r.lotNo, r.inQty, r.outQty, r.remarks,
r.stockInLineId, r.stockOutLineId
FROM stock_adjustment_record r
INNER JOIN stock_ledger sl
ON (
(r.stockInLineId IS NOT NULL AND sl.stockInLineId = r.stockInLineId)
OR (r.stockOutLineId IS NOT NULL AND sl.stockOutLineId = r.stockOutLineId)
)
WHERE IFNULL(r.deleted, 0) = 0
AND r.createdBy IN ('DNP', 'stock-ledger-fix')
AND sl.deleted = 0
AND sl.date >= :from AND sl.date < :toExclusive
AND sl.type = 'ADJ'
AND sl.createdBy IN ('DNP', 'stock-ledger-fix')
ORDER BY r.id
""".trimIndent(),
args,
table = "stock_adjustment_record",
columns = listOf(
"id", "created", "createdBy", "version", "modified", "modifiedBy", "deleted",
"itemId", "itemCode", "itemName", "lotNo", "inQty", "outQty", "remarks",
"stockInLineId", "stockOutLineId",
),
onDup = "itemCode=VALUES(itemCode), itemName=VALUES(itemName), lotNo=VALUES(lotNo), " +
"inQty=VALUES(inQty), outQty=VALUES(outQty), remarks=VALUES(remarks), " +
"stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_adjustment_record.version+1, deleted=0",
emptyComment = "-- (no ADJ stock_adjustment_record in range)",
)
}

private fun writeExportAdjInsert(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
) {
writer.write("-- === 2.7 ADJ INSERT (fixed id; rows missing on target are created) ===\n")
var batch = 0
jdbc.query(
"""
SELECT id, created, createdBy, version, modified, modifiedBy, deleted,
date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,
itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,
uomId, type
FROM stock_ledger
WHERE deleted = 0
AND date >= :from AND date < :toExclusive
AND type = 'ADJ'
AND createdBy IN ('DNP', 'stock-ledger-fix')
ORDER BY id
""".trimIndent(),
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write(
"INSERT INTO stock_ledger (\n" +
" id, created, createdBy, version, modified, modifiedBy, deleted,\n" +
" date, stockInLineId, stockOutLineId, inventoryLotLineId, inventoryId,\n" +
" itemId, itemCode, inQty, outQty, balance, lotQtyBefore, lotQtyAfter,\n" +
" uomId, type\n) VALUES\n",
)
} else {
writer.write(",\n")
}
writer.write(
"(${rsSql(rs, "id")}, ${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " +
"${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " +
"${rsSql(rs, "date")}, ${rsSql(rs, "stockInLineId")}, ${rsSql(rs, "stockOutLineId")}, " +
"${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "inventoryId")}, " +
"${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " +
"${rsSql(rs, "balance")}, ${rsSql(rs, "lotQtyBefore")}, ${rsSql(rs, "lotQtyAfter")}, " +
"${rsSql(rs, "uomId")}, ${rsSql(rs, "type")})",
)
batch++
if (batch >= 200) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " +
"stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " +
"inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " +
"lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " +
"uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_ledger.version+1, deleted=0;\n\n",
)
batch = 0
}
},
)
if (batch > 0) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"inventoryLotLineId=VALUES(inventoryLotLineId), inventoryId=VALUES(inventoryId), " +
"stockInLineId=VALUES(stockInLineId), stockOutLineId=VALUES(stockOutLineId), " +
"inQty=VALUES(inQty), outQty=VALUES(outQty), balance=VALUES(balance), " +
"lotQtyBefore=VALUES(lotQtyBefore), lotQtyAfter=VALUES(lotQtyAfter), " +
"uomId=VALUES(uomId), modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_ledger.version+1, deleted=0;\n\n",
)
} else {
writer.write("-- (no ADJ rows from stock-ledger-fix in range)\n\n")
}
}

private fun writeExportStockLotDay(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
args: Map<String, Any>,
from: LocalDate,
toExclusive: LocalDate,
) {
writer.write("-- === 2.6 stock_lot_day ===\n")
writer.write(
"""
DELETE FROM stock_lot_day
WHERE `date` >= '$from' AND `date` < '$toExclusive';

""".trimIndent(),
)
writer.write("\n")
var batch = 0
jdbc.query(
"""
SELECT created, createdBy, version, modified, modifiedBy, deleted,
inventoryLotLineId, itemId, itemCode, uomId, lotNo, date,
opening, inQty, outQty, closing
FROM stock_lot_day
WHERE IFNULL(deleted, 0) = 0 AND date >= :from AND date < :toExclusive
ORDER BY date, inventoryLotLineId
""".trimIndent(),
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write(
"INSERT INTO stock_lot_day (\n" +
" created, createdBy, version, modified, modifiedBy, deleted,\n" +
" inventoryLotLineId, itemId, itemCode, uomId, lotNo, `date`,\n" +
" opening, inQty, outQty, closing\n) VALUES\n",
)
} else {
writer.write(",\n")
}
writer.write(
"(${rsSql(rs, "created")}, ${rsSql(rs, "createdBy")}, ${rsSql(rs, "version")}, " +
"${rsSql(rs, "modified")}, ${rsSql(rs, "modifiedBy")}, ${rsSql(rs, "deleted")}, " +
"${rsSql(rs, "inventoryLotLineId")}, ${rsSql(rs, "itemId")}, ${rsSql(rs, "itemCode")}, " +
"${rsSql(rs, "uomId")}, ${rsSql(rs, "lotNo")}, ${rsSql(rs, "date")}, " +
"${rsSql(rs, "opening")}, ${rsSql(rs, "inQty")}, ${rsSql(rs, "outQty")}, " +
"${rsSql(rs, "closing")})",
)
batch++
if (batch >= 500) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " +
"lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " +
"outQty=VALUES(outQty), closing=VALUES(closing), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_lot_day.version+1, deleted=0;\n\n",
)
batch = 0
}
},
)
if (batch > 0) {
writer.write(
"\nON DUPLICATE KEY UPDATE " +
"itemId=VALUES(itemId), itemCode=VALUES(itemCode), uomId=VALUES(uomId), " +
"lotNo=VALUES(lotNo), opening=VALUES(opening), inQty=VALUES(inQty), " +
"outQty=VALUES(outQty), closing=VALUES(closing), " +
"modified=VALUES(modified), modifiedBy=VALUES(modifiedBy), " +
"version=stock_lot_day.version+1, deleted=0;\n",
)
}
}

private fun parseExportParts(raw: List<String>?): Set<String> {
if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) {
return EXPORT_PARTS_DEFAULT
}
val flat = raw.flatMap { it.split(",") }.map { it.trim() }.filter { it.isNotEmpty() }
if (flat.isEmpty() || flat.any { it.equals("all", ignoreCase = true) }) {
return EXPORT_PARTS_DEFAULT
}
val out = linkedSetOf<String>()
flat.forEach { token ->
val t = token.lowercase()
val key = when (t) {
"1.0", "10", "inventory" -> "1.0"
"2.3", "23", "inventoryid" -> "2.3"
"2.1", "21" -> "2.1"
"ledger", "2.2", "2.4", "2.5" -> "ledger"
"2.6", "26", "day", "stock_lot_day" -> "2.6"
"2.7", "27", "adj" -> "2.7"
else -> throw IllegalArgumentException(
"unknown export part '$token' (use 1.0, 2.1, 2.3, ledger, 2.6, 2.7, or all)",
)
}
out.add(key)
}
if (out.isEmpty()) throw IllegalArgumentException("parts must not be empty")
return out
}

private fun writeExportRows(
writer: BufferedWriter,
jdbc: NamedParameterJdbcTemplate,
sql: String,
args: Map<String, Any>,
table: String,
columns: List<String>,
onDup: String,
emptyComment: String,
) {
var batch = 0
jdbc.query(
sql,
args,
RowCallbackHandler { rs ->
if (batch == 0) {
writer.write("INSERT INTO $table (\n ${columns.joinToString(", ")}\n) VALUES\n")
} else {
writer.write(",\n")
}
writer.write("(" + columns.joinToString(", ") { rsSql(rs, it) } + ")")
batch++
if (batch >= 200) {
writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n")
batch = 0
}
},
)
if (batch > 0) {
writer.write("\nON DUPLICATE KEY UPDATE $onDup;\n\n")
} else {
writer.write("$emptyComment\n\n")
}
}

private fun rsSql(rs: ResultSet, col: String): String {
val v = rs.getObject(col)
if (v == null || rs.wasNull()) return "NULL"
return when (v) {
is BigDecimal -> v.stripTrailingZeros().toPlainString()
is Number -> v.toString()
is java.sql.Date -> "'${v.toLocalDate()}'"
is java.sql.Timestamp -> "'${v.toLocalDateTime().toString().replace('T', ' ')}'"
is Boolean -> if (v) "1" else "0"
else -> "'" + v.toString().replace("\\", "\\\\").replace("'", "''") + "'"
}
}

companion object {
/** Legacy default export: ledger calc fields + stock_lot_day (no inventory / inventoryId / ADJ). */
private val EXPORT_PARTS_DEFAULT = linkedSetOf("ledger", "2.6")
}
}

+ 258
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixInventorySql.kt 查看文件

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

internal object StockLedgerFixInventorySql {
internal const val LOT_UOM_PAIR_COUNT_SQL = """
SELECT COUNT(*) AS c FROM (
SELECT il.itemId, iu.uomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId, iu.uomId
) x
"""

internal const val MISSING_UOM_PAIR_COUNT_SQL = """
SELECT COUNT(*) AS c FROM (
SELECT il.itemId, iu.uomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
LEFT JOIN inventory inv
ON inv.itemId = il.itemId
AND COALESCE(inv.stockUomId, inv.uomId) = iu.uomId
AND IFNULL(inv.deleted, 0) = 0
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
AND inv.id IS NULL
GROUP BY il.itemId, iu.uomId
) x
"""

internal const val NULL_STOCK_UOM_COUNT_SQL = """
SELECT COUNT(*) AS c
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NULL
"""

/**
* Old row: uomId already equals a lot stock UOM (stock, or base when base=stock).
* Fill stockUomId from that lot stock; move uomId to item base.
* One keeper per (itemId, uomId); skip if that stockUomId is already taken
* (including soft-deleted rows that still occupy the unique key).
*/
internal const val BACKFILL_STOCK_UOM_WHEN_UOM_MATCHES_LOT_SQL = """
UPDATE inventory i
INNER JOIN (
SELECT DISTINCT il.itemId, iu.uomId AS stockUomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
) b ON b.itemId = i.itemId AND b.stockUomId = i.uomId
INNER JOIN (
SELECT itemId, uomId, MIN(id) AS keepId
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NULL
GROUP BY itemId, uomId
) keep ON keep.keepId = i.id
LEFT JOIN item_uom baseIu
ON baseIu.itemId = i.itemId
AND baseIu.baseUnit = 1
AND IFNULL(baseIu.deleted, 0) = 0
LEFT JOIN inventory taken
ON taken.itemId = i.itemId
AND taken.stockUomId = b.stockUomId
AND taken.id <> i.id
SET
i.stockUomId = b.stockUomId,
i.uomId = COALESCE(baseIu.uomId, i.uomId),
i.modified = NOW(),
i.modifiedBy = 'DNP'
WHERE IFNULL(i.deleted, 0) = 0
AND i.stockUomId IS NULL
AND taken.id IS NULL
"""

/**
* Remaining NULL stockUomId: item has exactly one lot stock UOM
* (uomId already base, stock may differ). Keep MIN(id) only.
*/
internal const val BACKFILL_STOCK_UOM_SINGLE_LOT_SQL = """
UPDATE inventory i
INNER JOIN (
SELECT il.itemId, MIN(iu.uomId) AS stockUomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId
HAVING COUNT(DISTINCT iu.uomId) = 1
) b ON b.itemId = i.itemId
INNER JOIN (
SELECT itemId, MIN(id) AS keepId
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NULL
GROUP BY itemId
) keep ON keep.keepId = i.id
LEFT JOIN item_uom baseIu
ON baseIu.itemId = i.itemId
AND baseIu.baseUnit = 1
AND IFNULL(baseIu.deleted, 0) = 0
LEFT JOIN inventory taken
ON taken.itemId = i.itemId
AND taken.stockUomId = b.stockUomId
AND taken.id <> i.id
SET
i.stockUomId = b.stockUomId,
i.uomId = COALESCE(baseIu.uomId, i.uomId),
i.modified = NOW(),
i.modifiedBy = 'DNP'
WHERE IFNULL(i.deleted, 0) = 0
AND i.stockUomId IS NULL
AND taken.id IS NULL
"""

internal const val INSERT_MISSING_INVENTORY_SQL = """
INSERT INTO inventory (
itemId, uomId, stockUomId, onHandQty, onHoldQty, unavailableQty,
price, currencyId, cpu, cpuUnit, cpm, cpmUnit, status,
created, createdBy, modified, modifiedBy, version, deleted
)
SELECT
src.itemId,
COALESCE(baseIu.uomId, src.uomId),
src.uomId,
0, 0, 0,
COALESCE(tpl.price, 0),
tpl.currencyId,
COALESCE(tpl.cpu, 0),
COALESCE(tpl.cpuUnit, 'HKD'),
COALESCE(tpl.cpm, 0),
COALESCE(tpl.cpmUnit, 'HKD'),
'unavailable',
NOW(), 'DNP', NOW(), 'DNP', 0, 0
FROM (
SELECT il.itemId, iu.uomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId, iu.uomId
) src
LEFT JOIN item_uom baseIu
ON baseIu.itemId = src.itemId
AND baseIu.baseUnit = 1
AND IFNULL(baseIu.deleted, 0) = 0
LEFT JOIN inventory existing
ON existing.itemId = src.itemId
AND IFNULL(existing.deleted, 0) = 0
AND (
existing.stockUomId = src.uomId
OR (existing.stockUomId IS NULL AND existing.uomId = src.uomId)
)
LEFT JOIN inventory occupied
ON occupied.itemId = src.itemId
AND occupied.stockUomId = src.uomId
LEFT JOIN inventory tpl
ON tpl.id = (
SELECT MIN(i.id)
FROM inventory i
WHERE i.itemId = src.itemId
AND IFNULL(i.deleted, 0) = 0
)
WHERE existing.id IS NULL
AND occupied.id IS NULL
"""

/**
* Extra old UOM rows left NULL after a keeper bucket exists for the item
* (e.g. 18918 id 5261). Soft-delete so search / COALESCE fallbacks ignore them.
*/
internal const val SOFT_DELETE_ORPHAN_NULL_STOCK_UOM_SQL = """
UPDATE inventory i
INNER JOIN (
SELECT DISTINCT itemId
FROM inventory
WHERE IFNULL(deleted, 0) = 0
AND stockUomId IS NOT NULL
) keeper ON keeper.itemId = i.itemId
SET
i.deleted = 1,
i.modified = NOW(),
i.modifiedBy = 'DNP'
WHERE IFNULL(i.deleted, 0) = 0
AND i.stockUomId IS NULL
"""

/**
* Putaway used to insert outQty as NULL. The lot-line trigger sums
* `inQty - outQty`, and NULL drops the row. Zero those lines before
* [UPDATE_INVENTORY_QTY_SQL] rewrites the buckets.
*/
internal const val ZERO_NULL_OUT_QTY_SQL = """
UPDATE inventory_lot_line
SET outQty = 0
WHERE outQty IS NULL
AND IFNULL(deleted, 0) = 0
"""

internal const val UPDATE_INVENTORY_QTY_SQL = """
UPDATE inventory i
LEFT JOIN (
SELECT
il.itemId,
iu.uomId,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHand,
SUM(
CASE
WHEN LOWER(ill.status) = 'unavailable'
THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)
ELSE 0
END
) AS unavailable
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0
INNER JOIN item_uom iu
ON iu.id = ill.stockItemUomId
WHERE IFNULL(ill.deleted, 0) = 0
AND il.itemId IS NOT NULL
AND iu.uomId IS NOT NULL
GROUP BY il.itemId, iu.uomId
) agg ON agg.itemId = i.itemId AND agg.uomId = COALESCE(i.stockUomId, i.uomId)
SET
i.onHandQty = COALESCE(agg.onHand, 0),
i.unavailableQty = COALESCE(agg.unavailable, 0),
i.status = IF(
COALESCE(agg.onHand, 0) - COALESCE(agg.unavailable, 0) > 0,
'available',
'unavailable'
),
i.modified = NOW(),
i.modifiedBy = 'DNP'
WHERE IFNULL(i.deleted, 0) = 0
"""
}

+ 775
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixQueryService.kt 查看文件

@@ -0,0 +1,775 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCheckPart
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayStatus
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit
import org.springframework.stereotype.Service
import java.time.LocalDate

@Service
open class StockLedgerFixQueryService(
private val support: StockLedgerFixSupport,
) {
open fun calendar(from: LocalDate, to: LocalDate): StockLedgerFixCalendarResponse {
if (to.isBefore(from)) {
throw IllegalArgumentException("to must be on or after from")
}
if (from.plusDays(62).isBefore(to)) {
throw IllegalArgumentException("date range must be at most 62 days")
}
val args = mapOf("from" to from, "toNext" to to.plusDays(1))
val rows = support.jdbcDao.queryForList(
"""
SELECT
DATE_FORMAT(sl.date, '%Y-%m-%d') AS d,
COUNT(*) AS cnt,
SUM(sl.inventoryLotLineId IS NULL) AS missLot,
SUM(sl.uomId IS NULL) AS missUom,
SUM(sl.inventoryId IS NULL) AS missInventoryId,
SUM(sl.lotQtyAfter IS NULL) AS missLotQty
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :from
AND sl.date < :toNext
GROUP BY DATE_FORMAT(sl.date, '%Y-%m-%d')
ORDER BY d
""".trimIndent(),
args,
)
val dayLots = support.jdbcDao.queryForList(
"""
SELECT DATE_FORMAT(date, '%Y-%m-%d') AS d, COUNT(*) AS lots
FROM stock_lot_day
WHERE deleted = 0
AND date >= :from
AND date < :toNext
GROUP BY DATE_FORMAT(date, '%Y-%m-%d')
""".trimIndent(),
args,
).associate { row ->
row["d"].toString() to support.toInt(row["lots"])
}
val days = rows.map { row ->
val d = row["d"].toString()
StockLedgerFixDayStatus(
date = d,
cnt = support.toInt(row["cnt"]),
missLot = support.toInt(row["missLot"]),
missUom = support.toInt(row["missUom"]),
missInventoryId = support.toInt(row["missInventoryId"]),
missLotQty = support.toInt(row["missLotQty"]),
dayTableLots = dayLots[d] ?: 0,
)
}
return StockLedgerFixCalendarResponse(
from = from.toString(),
to = to.toString(),
days = days,
)
}

/**
* A: keep existing inventory ids;
* backfill stockUomId from lot stock UOM (and set uomId to base) — one row per
* (itemId, stockUomId) so uk_inventory_item_stock_uom is not violated;
* INSERT missing (itemId, stockUomId);
* soft-delete leftover NULL stockUomId rows on items that already have a bucket;
* recalc onHand/unavailable from Σ lot qty by item+stock UOM (0 if no lots).
* Does not write inventory.onHoldQty (hold stays on lot line). Does not UPDATE inventory_lot_line.
*/
open fun inventoryPreview(): StockLedgerFixInventoryPreview {
val inventoryRows = support.toInt(
support.jdbcDao.queryForList(
"SELECT COUNT(*) AS c FROM inventory WHERE IFNULL(deleted, 0) = 0",
).firstOrNull()?.get("c"),
)
val lotUomPairs = support.toInt(
support.jdbcDao.queryForList(StockLedgerFixInventorySql.LOT_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"),
)
val missingUomPairs = support.toInt(
support.jdbcDao.queryForList(StockLedgerFixInventorySql.MISSING_UOM_PAIR_COUNT_SQL).firstOrNull()?.get("c"),
)
val nullStockUomId = support.toInt(
support.jdbcDao.queryForList(StockLedgerFixInventorySql.NULL_STOCK_UOM_COUNT_SQL).firstOrNull()?.get("c"),
)
return StockLedgerFixInventoryPreview(
inventoryRows = inventoryRows,
lotUomPairs = lotUomPairs,
missingUomPairs = missingUomPairs,
nullStockUomId = nullStockUomId,
)
}

open fun dayDetail(day: LocalDate): StockLedgerFixDayDetail {
val args = support.dayArgs(day)
val ledger = support.jdbcDao.queryForList(
"""
SELECT
COUNT(*) AS cnt,
SUM(sl.inventoryLotLineId IS NULL) AS lotMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL
AND sl.inventoryLotLineId <> COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId)
) AS lotIncorrect,
SUM(sl.uomId IS NULL) AS uomMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND sl.uomId IS NOT NULL
AND iu.uomId IS NOT NULL
AND sl.uomId <> iu.uomId
) AS uomIncorrect,
SUM(sl.inventoryId IS NULL) AS invMiss,
SUM(
sl.inventoryId IS NOT NULL
AND sl.itemId IS NOT NULL
AND sl.uomId IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM inventory inv
WHERE inv.id = sl.inventoryId
AND IFNULL(inv.deleted, 0) = 0
AND inv.itemId = sl.itemId
AND COALESCE(inv.stockUomId, inv.uomId) = sl.uomId
)
) AS invIncorrect,
SUM(sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL) AS lotQtyMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL)
) AS lotQtyMissWithLot,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND (
sl.lotQtyAfter - sl.lotQtyBefore
<> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
)
) AS lotQtyIncorrect,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
= CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0)
) AS overIssue,
SUM(sl.balance IS NULL) AS balanceMiss,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL
) AS fixable21a,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) = 1
) AS fixable21b,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) > 1
) AS cannotMultiLine,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0)
) AS cannotNoSource,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL)
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId
) AS existingLotMatch,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL)
AND (
COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId
)
) AS existingLotMismatch,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND sol.id IS NOT NULL
AND sol.inventoryLotLineId IS NULL
) AS outLineBlank,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND sol.inventoryLotLineId IS NOT NULL
AND sol.inventoryLotLineId <> sl.inventoryLotLineId
) AS outLineDiffers
FROM stock_ledger sl
LEFT JOIN stock_out_line sol
ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0
LEFT JOIN stock_in_line sil
ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0
LEFT JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId
LEFT JOIN (
SELECT inventoryLotId, COUNT(*) AS lineCnt
FROM inventory_lot_line
WHERE IFNULL(deleted, 0) = 0
GROUP BY inventoryLotId
) lc ON lc.inventoryLotId = sil.inventoryLotId
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()

val cnt = support.toInt(ledger["cnt"])
val lotMiss = support.toInt(ledger["lotMiss"])
val lotIncorrect = support.toInt(ledger["lotIncorrect"])
val uomMiss = support.toInt(ledger["uomMiss"])
val uomIncorrect = support.toInt(ledger["uomIncorrect"])
val invMiss = support.toInt(ledger["invMiss"])
val invIncorrect = support.toInt(ledger["invIncorrect"])
val lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"])
val lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"])
val overIssue = support.toInt(ledger["overIssue"])
val balanceMiss = support.toInt(ledger["balanceMiss"])

val dayTable = support.jdbcDao.queryForList(
"""
SELECT
COUNT(*) AS expected,
SUM(d.id IS NULL) AS miss,
SUM(
d.id IS NOT NULL AND (
(x.kind = 'move' AND (
d.closing <> lastSl.lotQtyAfter
OR d.opening <> firstSl.lotQtyBefore
OR d.inQty <> x.inQty
OR d.outQty <> x.outQty
))
OR (x.kind = 'carry' AND (
d.inQty <> 0
OR d.outQty <> 0
OR d.opening <> x.prevClosing
OR d.closing <> x.prevClosing
))
)
) AS incorrect
FROM (
SELECT
sl.inventoryLotLineId,
'move' AS kind,
MIN(sl.id) AS firstId,
MAX(sl.id) AS lastId,
CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty,
CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty,
CAST(NULL AS DECIMAL(14,2)) AS prevClosing
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
AND sl.inventoryLotLineId IS NOT NULL
GROUP BY sl.inventoryLotLineId

UNION ALL

SELECT
prev.inventoryLotLineId,
'carry' AS kind,
NULL AS firstId,
NULL AS lastId,
CAST(0 AS DECIMAL(14,2)) AS inQty,
CAST(0 AS DECIMAL(14,2)) AS outQty,
prev.closing AS prevClosing
FROM stock_lot_day prev
WHERE prev.date = :prevDay
AND IFNULL(prev.deleted, 0) = 0
AND prev.closing > 0
AND NOT EXISTS (
SELECT 1 FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
AND sl.inventoryLotLineId = prev.inventoryLotLineId
)
) x
LEFT JOIN stock_ledger firstSl ON firstSl.id = x.firstId
LEFT JOIN stock_ledger lastSl ON lastSl.id = x.lastId
LEFT JOIN stock_lot_day d
ON d.inventoryLotLineId = x.inventoryLotLineId
AND d.date = :day
AND IFNULL(d.deleted, 0) = 0
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()

val dayLots = support.toInt(dayTable["expected"])
val dayMiss = support.toInt(dayTable["miss"])
val dayIncorrect = support.toInt(dayTable["incorrect"])
// No lot ids yet → SQL finds 0 lots, 0/0/0 would look "correct". Ledger exists but day table cannot be built.
val dayTablePending = cnt > 0 && dayLots == 0
val dayTableMiss = if (dayTablePending) 1 else dayMiss
val dayTableTotal = if (dayTablePending) 1 else dayLots

fun part(key: String, label: String, miss: Int, incorrect: Int, total: Int) =
StockLedgerFixCheckPart(
key = key,
label = label,
ok = (total - miss - incorrect).coerceAtLeast(0),
miss = miss,
incorrect = incorrect,
)

return StockLedgerFixDayDetail(
date = day.toString(),
cnt = cnt,
parts = fieldAndReasonParts(
cnt = cnt,
lotMiss = lotMiss,
lotIncorrect = lotIncorrect,
uomMiss = uomMiss,
uomIncorrect = uomIncorrect,
invMiss = invMiss,
invIncorrect = invIncorrect,
lotQtyMissWithLot = lotQtyMissWithLot,
lotQtyIncorrect = lotQtyIncorrect,
overIssue = overIssue,
balanceMiss = balanceMiss,
fixable21a = support.toInt(ledger["fixable21a"]),
fixable21b = support.toInt(ledger["fixable21b"]),
cannotMultiLine = support.toInt(ledger["cannotMultiLine"]),
cannotNoSource = support.toInt(ledger["cannotNoSource"]),
earlyTkeOver = 0,
otherOver = 0,
existingLotMatch = support.toInt(ledger["existingLotMatch"]),
existingLotMismatch = support.toInt(ledger["existingLotMismatch"]),
outLineBlank = support.toInt(ledger["outLineBlank"]),
outLineDiffers = support.toInt(ledger["outLineDiffers"]),
extra = listOf(
part("dayTable", "stock_lot_day", dayTableMiss, dayIncorrect, dayTableTotal),
),
),
)
}

open fun searchInventory(qRaw: String): List<StockLedgerFixSearchInventoryHit> {
val q = qRaw.trim()
if (q.isEmpty()) throw IllegalArgumentException("q is required")
val args = mapOf("q" to q, "like" to "%$q%")
return support.jdbcDao.queryForList(
"""
SELECT
i.id AS inventoryId,
i.itemId AS itemId,
it.code AS itemCode,
i.uomId AS uomId,
(
SELECT COUNT(*) FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.inventoryId = i.id
) AS ledgerCnt
FROM inventory i
LEFT JOIN items it ON it.id = i.itemId
WHERE IFNULL(i.deleted, 0) = 0
AND (
CAST(i.id AS CHAR) = :q
OR CAST(i.itemId AS CHAR) = :q
OR it.code LIKE :like
)
ORDER BY ledgerCnt DESC, i.id
LIMIT 20
""".trimIndent(),
args,
).map { row ->
StockLedgerFixSearchInventoryHit(
inventoryId = support.toLong(row["inventoryId"]),
itemId = support.toLongOrNull(row["itemId"]),
itemCode = row["itemCode"]?.toString(),
uomId = support.toLongOrNull(row["uomId"]),
ledgerCnt = support.toInt(row["ledgerCnt"]),
)
}
}

open fun searchLot(qRaw: String): List<StockLedgerFixSearchLotHit> {
val q = qRaw.trim()
if (q.isEmpty()) throw IllegalArgumentException("q is required")
val args = mapOf("q" to q, "like" to "%$q%")
return support.jdbcDao.queryForList(
"""
SELECT
ill.id AS inventoryLotLineId,
il.lotNo AS lotNo,
it.code AS itemCode,
(
SELECT sl.inventoryId FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id
AND sl.inventoryId IS NOT NULL
ORDER BY sl.id DESC LIMIT 1
) AS inventoryId,
(
SELECT COUNT(*) FROM stock_ledger sl
WHERE sl.deleted = 0 AND sl.inventoryLotLineId = ill.id
) AS ledgerCnt
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN items it ON it.id = il.itemId
WHERE IFNULL(ill.deleted, 0) = 0
AND (
CAST(ill.id AS CHAR) = :q
OR il.lotNo LIKE :like
)
ORDER BY ledgerCnt DESC, ill.id
LIMIT 20
""".trimIndent(),
args,
).map { row ->
StockLedgerFixSearchLotHit(
inventoryLotLineId = support.toLong(row["inventoryLotLineId"]),
lotNo = row["lotNo"]?.toString(),
itemCode = row["itemCode"]?.toString(),
inventoryId = support.toLongOrNull(row["inventoryId"]),
ledgerCnt = support.toInt(row["ledgerCnt"]),
)
}
}

open fun inventoryScopeDetail(inventoryId: Long): StockLedgerFixScopeDetail {
val args = mapOf("inventoryId" to inventoryId, "today" to support.inventoryScopeToExclusive())
val head = support.jdbcDao.queryForList(
"""
SELECT
i.id AS inventoryId,
i.itemId AS itemId,
it.code AS itemCode,
i.uomId AS uomId
FROM inventory i
LEFT JOIN items it ON it.id = i.itemId
WHERE i.id = :inventoryId
""".trimIndent(),
args,
).firstOrNull() ?: throw IllegalArgumentException("inventory not found: $inventoryId")
val stats = ledgerScopeStats(
"sl.inventoryId = :inventoryId",
args,
itemId = support.toLongOrNull(head["itemId"]),
)
return StockLedgerFixScopeDetail(
kind = "inventory",
id = inventoryId,
itemCode = head["itemCode"]?.toString(),
lotNo = null,
inventoryId = inventoryId,
uomId = support.toLongOrNull(head["uomId"]),
firstDate = stats.firstDate,
lastDate = stats.lastDate,
cnt = stats.cnt,
lastBalance = stats.lastBalance,
lastLotQtyAfter = stats.lastLotQtyAfter,
parts = stats.parts,
)
}

open fun lotScopeDetail(lotLineId: Long): StockLedgerFixScopeDetail {
val today = LocalDate.now()
val args = mapOf("lotId" to lotLineId, "today" to today)
val head = support.jdbcDao.queryForList(
"""
SELECT
ill.id AS inventoryLotLineId,
il.lotNo AS lotNo,
il.itemId AS itemId,
it.code AS itemCode
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN items it ON it.id = il.itemId
WHERE ill.id = :lotId
""".trimIndent(),
args,
).firstOrNull() ?: throw IllegalArgumentException("lot line not found: $lotLineId")
val stats = ledgerScopeStats(
"sl.inventoryLotLineId = :lotId",
args,
itemId = support.toLongOrNull(head["itemId"]),
)
return StockLedgerFixScopeDetail(
kind = "lot",
id = lotLineId,
itemCode = head["itemCode"]?.toString(),
lotNo = head["lotNo"]?.toString(),
inventoryId = support.toLongOrNull(stats.lastInventoryId),
uomId = null,
firstDate = stats.firstDate,
lastDate = stats.lastDate,
cnt = stats.cnt,
lastBalance = stats.lastBalance,
lastLotQtyAfter = stats.lastLotQtyAfter,
parts = stats.parts,
)
}

private data class ScopeStats(
val cnt: Int,
val firstDate: String?,
val lastDate: String?,
val lastBalance: String?,
val lastLotQtyAfter: String?,
val lastInventoryId: Any?,
val parts: List<StockLedgerFixCheckPart>,
)

private fun ledgerScopeStats(
filter: String,
args: Map<String, Any>,
itemId: Long? = null,
): ScopeStats {
val ledger = support.jdbcDao.queryForList(
"""
SELECT
COUNT(*) AS cnt,
DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate,
DATE_FORMAT(MAX(sl.date), '%Y-%m-%d') AS lastDate,
SUM(sl.inventoryLotLineId IS NULL) AS lotMiss,
SUM(sl.uomId IS NULL) AS uomMiss,
SUM(sl.inventoryId IS NULL) AS invMiss,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.lotQtyAfter IS NULL OR sl.lotQtyBefore IS NULL)
) AS lotQtyMissWithLot,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
<> CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
) AS lotQtyIncorrect,
SUM(
sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
= CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0)
) AS overIssue,
SUM(sl.balance IS NULL) AS balanceMiss,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NOT NULL
) AS fixable21a,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) = 1
) AS fixable21b,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND sil.inventoryLotId IS NOT NULL
AND IFNULL(lc.lineCnt, 0) > 1
) AS cannotMultiLine,
SUM(
sl.inventoryLotLineId IS NULL
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
AND (sil.inventoryLotId IS NULL OR IFNULL(lc.lineCnt, 0) = 0)
) AS cannotNoSource,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL)
AND COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) = sl.inventoryLotLineId
) AS existingLotMatch,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND (sl.stockInLineId IS NOT NULL OR sl.stockOutLineId IS NOT NULL)
AND (
COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) IS NULL
OR COALESCE(sol.inventoryLotLineId, sil.inventoryLotLineId) <> sl.inventoryLotLineId
)
) AS existingLotMismatch,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND sol.id IS NOT NULL
AND sol.inventoryLotLineId IS NULL
) AS outLineBlank,
SUM(
sl.inventoryLotLineId IS NOT NULL
AND sol.inventoryLotLineId IS NOT NULL
AND sol.inventoryLotLineId <> sl.inventoryLotLineId
) AS outLineDiffers
FROM stock_ledger sl
LEFT JOIN stock_out_line sol
ON sol.id = sl.stockOutLineId AND IFNULL(sol.deleted, 0) = 0
LEFT JOIN stock_in_line sil
ON sil.id = sl.stockInLineId AND IFNULL(sil.deleted, 0) = 0
LEFT JOIN (
SELECT inventoryLotId, COUNT(*) AS lineCnt
FROM inventory_lot_line
WHERE IFNULL(deleted, 0) = 0
GROUP BY inventoryLotId
) lc ON lc.inventoryLotId = sil.inventoryLotId
WHERE sl.deleted = 0
AND sl.date < :today
AND $filter
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()
val last = support.jdbcDao.queryForList(
"""
SELECT sl.balance, sl.lotQtyAfter, sl.inventoryId
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date < :today
AND $filter
ORDER BY sl.id DESC
LIMIT 1
""".trimIndent(),
args,
).firstOrNull() ?: emptyMap()
val cnt = support.toInt(ledger["cnt"])
val overIssue = support.toInt(ledger["overIssue"])
val earlyTke = if (overIssue > 0) overIssueEarlyTkeRows(filter, args, itemId) else 0
return ScopeStats(
cnt = cnt,
firstDate = ledger["firstDate"]?.toString(),
lastDate = ledger["lastDate"]?.toString(),
lastBalance = last["balance"]?.toString(),
lastLotQtyAfter = last["lotQtyAfter"]?.toString(),
lastInventoryId = last["inventoryId"],
parts = fieldAndReasonParts(
cnt = cnt,
lotMiss = support.toInt(ledger["lotMiss"]),
lotIncorrect = 0,
uomMiss = support.toInt(ledger["uomMiss"]),
uomIncorrect = 0,
invMiss = support.toInt(ledger["invMiss"]),
invIncorrect = 0,
lotQtyMissWithLot = support.toInt(ledger["lotQtyMissWithLot"]),
lotQtyIncorrect = support.toInt(ledger["lotQtyIncorrect"]),
overIssue = overIssue,
balanceMiss = support.toInt(ledger["balanceMiss"]),
fixable21a = support.toInt(ledger["fixable21a"]),
fixable21b = support.toInt(ledger["fixable21b"]),
cannotMultiLine = support.toInt(ledger["cannotMultiLine"]),
cannotNoSource = support.toInt(ledger["cannotNoSource"]),
earlyTkeOver = earlyTke,
otherOver = (overIssue - earlyTke).coerceAtLeast(0),
existingLotMatch = support.toInt(ledger["existingLotMatch"]),
existingLotMismatch = support.toInt(ledger["existingLotMismatch"]),
outLineBlank = support.toInt(ledger["outLineBlank"]),
outLineDiffers = support.toInt(ledger["outLineDiffers"]),
),
)
}

private fun fieldAndReasonParts(
cnt: Int,
lotMiss: Int,
lotIncorrect: Int,
uomMiss: Int,
uomIncorrect: Int,
invMiss: Int,
invIncorrect: Int,
lotQtyMissWithLot: Int,
lotQtyIncorrect: Int,
overIssue: Int,
balanceMiss: Int,
fixable21a: Int,
fixable21b: Int,
cannotMultiLine: Int,
cannotNoSource: Int,
earlyTkeOver: Int,
otherOver: Int,
existingLotMatch: Int = 0,
existingLotMismatch: Int = 0,
outLineBlank: Int = 0,
outLineDiffers: Int = 0,
extra: List<StockLedgerFixCheckPart> = emptyList(),
): List<StockLedgerFixCheckPart> {
fun field(key: String, label: String, miss: Int, incorrect: Int) =
StockLedgerFixCheckPart(
key = key,
label = label,
ok = (cnt - miss - incorrect).coerceAtLeast(0),
miss = miss,
incorrect = incorrect,
group = "field",
)
fun canFix(key: String, label: String, n: Int) =
StockLedgerFixCheckPart(key, label, ok = 0, miss = n, incorrect = 0, group = "canFix")
fun cannotFix(key: String, label: String, n: Int) =
StockLedgerFixCheckPart(key, label, ok = 0, miss = 0, incorrect = n, group = "cannotFix")
return buildList {
add(field("lot", "inventoryLotLineId", lotMiss, lotIncorrect))
add(field("uom", "uomId", uomMiss, uomIncorrect))
add(field("inventory", "inventoryId", invMiss, invIncorrect))
add(field("lotQty", "lotQtyBefore/After(lot 已有)", lotQtyMissWithLot, lotQtyIncorrect))
add(field("overIssue", "over-issue (lotQty < 0)", 0, overIssue))
add(field("balance", "balance", balanceMiss, 0))
add(
StockLedgerFixCheckPart(
key = "existingLotLine",
label = "已有 lot line:入出倉列是否同一條",
ok = existingLotMatch,
miss = 0,
incorrect = existingLotMismatch,
group = "field",
),
)
addAll(extra)
add(canFix("outLineBlank", "2.1:出倉列批號是空,流水帳改空並軟刪", outLineBlank))
add(canFix("outLineDiffers", "2.1:出倉列批號不同,改出倉列跟流水帳", outLineDiffers))
add(canFix("fixable21a", "可修 2.1a:SIL/SOL 已有 lot line", fixable21a))
add(canFix("fixable21b", "可修 2.1b:inventoryLotId 僅一條 line", fixable21b))
add(canFix("fixableLotQty", "可修:lot 已有但缺 lotQty", lotQtyMissWithLot))
add(cannotFix("cannotNoSource", "2.1 軟刪:對不到批號列(含沒有入出倉列)", cannotNoSource))
add(cannotFix("cannotMultiLine", "2.1 軟刪:同一 lot 有多條 warehouse line", cannotMultiLine))
add(cannotFix("earlyTke", "不能修:舊 TKE 開新批(sibling 盤盈)", earlyTkeOver))
add(cannotFix("realOver", "不能修:over-issue 非舊 TKE(真超發/帳不一致)", otherOver))
}
}

private fun overIssueEarlyTkeRows(filter: String, args: Map<String, Any>, itemId: Long?): Int {
if (itemId == null || itemId <= 0) return 0
val q = args.toMutableMap()
q["itemId"] = itemId
return support.toInt(
support.jdbcDao.queryForList(
"""
SELECT SUM(ok) AS c FROM (
SELECT EXISTS (
SELECT 1
FROM inventory_lot_line ill2
INNER JOIN inventory_lot il2
ON il2.id = ill2.inventoryLotId AND il2.itemId = :itemId
INNER JOIN (
SELECT sl3.inventoryLotLineId
FROM stock_ledger sl3
INNER JOIN inventory_lot_line ill3 ON ill3.id = sl3.inventoryLotLineId
INNER JOIN inventory_lot il3 ON il3.id = ill3.inventoryLotId AND il3.itemId = :itemId
WHERE sl3.deleted = 0
AND sl3.date < :today
GROUP BY sl3.inventoryLotLineId
HAVING SUM(COALESCE(sl3.inQty, 0)) > 0
AND SUM(CASE WHEN sl3.type = 'TKE' AND sl3.inQty > 0 THEN sl3.inQty ELSE 0 END)
>= SUM(COALESCE(sl3.inQty, 0)) * 0.99
) tke ON tke.inventoryLotLineId = ill2.id
WHERE IFNULL(ill2.deleted, 0) = 0
AND ill2.warehouseId = ill.warehouseId
AND ill2.id <> sl.inventoryLotLineId
) AS ok
FROM stock_ledger sl
INNER JOIN inventory_lot_line ill ON ill.id = sl.inventoryLotLineId
WHERE sl.deleted = 0
AND sl.date < :today
AND $filter
AND sl.lotQtyAfter IS NOT NULL
AND sl.lotQtyBefore IS NOT NULL
AND sl.lotQtyAfter - sl.lotQtyBefore
= CAST(COALESCE(sl.inQty, 0) AS DECIMAL(14,2))
- CAST(COALESCE(sl.outQty, 0) AS DECIMAL(14,2))
AND (sl.lotQtyAfter < 0 OR sl.lotQtyBefore < 0)
) x
""".trimIndent(),
q,
).firstOrNull()?.get("c"),
)
}
}

+ 190
- 0
src/main/java/com/ffii/fpsms/modules/master/service/ledgerfix/StockLedgerFixSupport.kt 查看文件

@@ -0,0 +1,190 @@
package com.ffii.fpsms.modules.master.service.ledgerfix

import com.ffii.core.support.JdbcDao
import org.slf4j.LoggerFactory
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Component
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import java.math.BigDecimal
import java.time.LocalDate
import java.time.format.DateTimeParseException
import java.util.concurrent.ConcurrentHashMap
import javax.sql.DataSource

/** Shared JDBC, conversions, and the single in-flight lock for stock-ledger-fix. */
@Component
open class StockLedgerFixSupport(
val jdbcDao: JdbcDao,
val transactionManager: PlatformTransactionManager,
val dataSource: DataSource,
) {
private val log = LoggerFactory.getLogger(StockLedgerFixSupport::class.java)
val namedJdbc = NamedParameterJdbcTemplate(dataSource)
private val inFlight = ConcurrentHashMap.newKeySet<String>()

fun tryLock(key: String): Boolean = inFlight.add(key)

fun unlock(key: String) {
inFlight.remove(key)
}

/** Exclusive bound for inventory-scope inspect/fix: include calendar today. */
fun inventoryScopeToExclusive(): LocalDate = LocalDate.now().plusDays(1)

fun dayArgs(day: LocalDate): Map<String, Any> =
mapOf(
"day" to day,
"dayNext" to day.plusDays(1),
"prevDay" to day.minusDays(1),
)

/** Inclusive [from]..[to]; same keys as [dayArgs] (used by stillMiss over the whole range). */
fun rangeArgs(from: LocalDate, to: LocalDate): Map<String, Any> =
mapOf(
"day" to from,
"dayNext" to to.plusDays(1),
"prevDay" to from.minusDays(1),
)

/** Same JDBC connection so TEMPORARY TABLEs survive across statements. */
fun withTempTx(block: () -> Int): Int {
return TransactionTemplate(transactionManager).execute { block() } ?: 0
}

fun timed(name: String, block: () -> Int): Int {
val t0 = System.nanoTime()
return try {
val n = block()
log.info("stock-ledger-fix step {} rows={} {}ms", name, n, (System.nanoTime() - t0) / 1_000_000)
n
} catch (e: Exception) {
log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message)
throw e
}
}

fun <T> timedValue(name: String, block: () -> T): T {
val t0 = System.nanoTime()
return try {
val value = block()
log.info("stock-ledger-fix step {} {}ms", name, (System.nanoTime() - t0) / 1_000_000)
value
} catch (e: Exception) {
log.warn("stock-ledger-fix step {} FAILED {}ms: {}", name, (System.nanoTime() - t0) / 1_000_000, e.message)
throw e
}
}

fun parseDay(raw: String): LocalDate {
val trimmed = raw.trim()
if (trimmed.isEmpty()) throw IllegalArgumentException("date is required (yyyy-MM-dd)")
return try {
LocalDate.parse(trimmed)
} catch (_: DateTimeParseException) {
throw IllegalArgumentException("date must be yyyy-MM-dd")
}
}


fun parseDaySteps(raw: List<String>?): Set<String> {
if (raw.isNullOrEmpty() || raw.any { it.trim().equals("all", ignoreCase = true) }) {
return DAY_STEPS
}
val out = linkedSetOf<String>()
raw.forEach { token ->
val t = token.trim().lowercase()
val key = when (t) {
"2.1", "21" -> "2.1"
"2.2", "22" -> "2.2"
"2.3", "23" -> "2.3"
"2.4", "24" -> "2.4"
"2.5", "25" -> "2.5"
"2.6", "26" -> "2.6"
else -> throw IllegalArgumentException("unknown step '$token' (use 2.1–2.6 or all)")
}
out.add(key)
}
if (out.isEmpty()) throw IllegalArgumentException("steps must not be empty")
return out
}

/**
* Rename rows already stamped by this fix to [FIX_ACTOR].
* Adjustment-record remarks written by the fix become [FIX_ACTOR] as well.
*/
fun stampFixActor() {
jdbcDao.executeUpdate(
"""
UPDATE stock_adjustment_record
SET remarks = '$FIX_ACTOR',
modified = NOW(),
modifiedBy = '$FIX_ACTOR'
WHERE IFNULL(remarks, '') LIKE 'stock-ledger-fix%'
""".trimIndent(),
)
FIX_ACTOR_TABLES.forEach { table ->
jdbcDao.executeUpdate(
"UPDATE $table SET createdBy = '$FIX_ACTOR' WHERE createdBy = 'stock-ledger-fix'",
)
jdbcDao.executeUpdate(
"UPDATE $table SET modifiedBy = '$FIX_ACTOR' WHERE modifiedBy = 'stock-ledger-fix'",
)
}
jdbcDao.executeUpdate(
"UPDATE stock_lot_day SET createdBy = '$FIX_ACTOR' WHERE createdBy = 'ledger-fix-api'",
)
jdbcDao.executeUpdate(
"UPDATE stock_lot_day SET modifiedBy = '$FIX_ACTOR' WHERE modifiedBy = 'ledger-fix-api'",
)
}

fun toInt(value: Any?): Int = when (value) {
null -> 0
is Number -> value.toInt()
else -> value.toString().toIntOrNull() ?: 0
}

fun toLong(value: Any?): Long = toLongOrNull(value) ?: 0L

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

fun toDecimal(value: Any?): BigDecimal = when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Number -> BigDecimal(value.toString())
else -> value.toString().toBigDecimalOrNull() ?: BigDecimal.ZERO
}

fun toLocalDate(value: Any?): LocalDate? = when (value) {
null -> null
is LocalDate -> value
is java.sql.Date -> value.toLocalDate()
is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate()
else -> try {
LocalDate.parse(value.toString().take(10))
} catch (_: Exception) {
null
}
}
companion object {
const val INVENTORY_LOCK = "inventory-1.0"
const val RUN_LOCK = "ledger-fix-run"
const val FIX_ACTOR = "DNP"
private val DAY_STEPS = linkedSetOf("2.1", "2.2", "2.3", "2.4", "2.5", "2.6")
private val FIX_ACTOR_TABLES = listOf(
"stock_ledger",
"inventory",
"inventory_lot_line",
"stock_in",
"stock_in_line",
"stock_out",
"stock_out_line",
"stock_adjustment_record",
)
}
}

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

@@ -89,8 +89,8 @@ class ItemsController(
@GetMapping("/pickOrderItems")
fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> {
try {
println("=== Debug: getPickOrderItemsByPage ===")
println("Request parameters: ${request.parameterMap}")
//println("=== Debug: getPickOrderItemsByPage ===")
//println("Request parameters: ${request.parameterMap}")
val criteriaArgs = CriteriaArgsBuilder.withRequest(request)
.addStringLike("itemCode")
@@ -100,18 +100,18 @@ class ItemsController(
.addString("targetDateTo")
.build()
println("Criteria args: $criteriaArgs")
//println("Criteria args: $criteriaArgs")
val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10
val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1
println("Page size: $pageSize, Page num: $pageNum")
//println("Page size: $pageSize, Page num: $pageNum")
val fullList = itemsService.getPickOrderItemsByPage(criteriaArgs)
println("Full list size: ${fullList.size}")
//println("Full list size: ${fullList.size}")
val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum)
println("Paginated list size: ${paginatedList.size}")
//println("Paginated list size: ${paginatedList.size}")
return RecordsRes(paginatedList, fullList.size)
} catch (e: Exception) {


+ 129
- 0
src/main/java/com/ffii/fpsms/modules/master/web/StockLedgerFixController.kt 查看文件

@@ -0,0 +1,129 @@
package com.ffii.fpsms.modules.master.web

import com.ffii.fpsms.modules.master.service.StockLedgerFixService
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixAdjRequest
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixCalendarResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixDayDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryPreview
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixInventoryResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunRequest
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixRunResponse
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixScopeDetail
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchInventoryHit
import com.ffii.fpsms.modules.master.web.models.StockLedgerFixSearchLotHit
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.HttpHeaders
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import jakarta.servlet.http.HttpServletResponse
import java.time.LocalDate

/**
* Admin data-fix: inventory 1.0 (A) then ledger days (2.1–2.6), including date-range mode.
*/
@RestController
@RequestMapping("/stock-ledger-fix")
open class StockLedgerFixController(
private val stockLedgerFixService: StockLedgerFixService,
) {
@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/calendar")
open fun calendar(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate,
): StockLedgerFixCalendarResponse = stockLedgerFixService.calendar(from, to)

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/day")
open fun day(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate,
): StockLedgerFixDayDetail = stockLedgerFixService.dayDetail(date)

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/inventory")
open fun inventoryPreview(): StockLedgerFixInventoryPreview =
stockLedgerFixService.inventoryPreview()

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/inventory")
open fun inventory(): StockLedgerFixInventoryResponse =
stockLedgerFixService.fixInventory10()

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/lookup/inventory")
open fun lookupInventory(@RequestParam q: String): List<StockLedgerFixSearchInventoryHit> =
stockLedgerFixService.searchInventory(q)

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/lookup/lot")
open fun lookupLot(@RequestParam q: String): List<StockLedgerFixSearchLotHit> =
stockLedgerFixService.searchLot(q)

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/scope/inventory")
open fun inventoryScope(@RequestParam id: Long): StockLedgerFixScopeDetail =
stockLedgerFixService.inventoryScopeDetail(id)

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/scope/lot")
open fun lotScope(@RequestParam id: Long): StockLedgerFixScopeDetail =
stockLedgerFixService.lotScopeDetail(id)

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/export")
open fun export(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) from: LocalDate,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) to: LocalDate,
@RequestParam(required = false) parts: List<String>?,
response: HttpServletResponse,
) {
val name = "stock_ledger_fix_${from.toString().replace("-", "")}_${to.toString().replace("-", "")}.sql"
response.contentType = "application/sql;charset=UTF-8"
response.setHeader(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$name\"")
stockLedgerFixService.writeExportSql(from, to, response.outputStream, parts)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@GetMapping("/adj")
open fun adjPreview(
@RequestParam(defaultValue = "20") rowLimit: Int,
@RequestParam(required = false) adjDate: String?,
) = stockLedgerFixService.adjPreview(rowLimit, adjDate)

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/adj")
open fun adjApply(@RequestBody(required = false) body: StockLedgerFixAdjRequest?) =
when (body?.step?.trim()?.lowercase()) {
"2.7a", "27a" -> stockLedgerFixService.applyAdjLotLineIn(body?.adjDate)
else -> stockLedgerFixService.applyAdjAlign(body?.adjDate)
}

@PreAuthorize("hasAnyAuthority('ADMIN','TESTING')")
@PostMapping("/run")
open fun run(@RequestBody request: StockLedgerFixRunRequest): StockLedgerFixRunResponse {
val mode = request.mode?.trim()?.lowercase().orEmpty().ifEmpty { "day" }
return when (mode) {
"day" -> stockLedgerFixService.fixDay(
request.date ?: throw IllegalArgumentException("date is required"),
request.steps,
)
"range" -> stockLedgerFixService.fixRange(
request.from ?: throw IllegalArgumentException("from is required"),
request.to ?: throw IllegalArgumentException("to is required"),
request.steps,
)
"inventory" -> stockLedgerFixService.fixInventoryScope(
request.inventoryId ?: throw IllegalArgumentException("inventoryId is required"),
)
"lot" -> stockLedgerFixService.fixLotScope(
request.lotLineId ?: throw IllegalArgumentException("lotLineId is required"),
)
else -> throw IllegalArgumentException("mode must be day, range, inventory, or lot")
}
}
}

+ 161
- 0
src/main/java/com/ffii/fpsms/modules/master/web/models/StockLedgerFixModels.kt 查看文件

@@ -0,0 +1,161 @@
package com.ffii.fpsms.modules.master.web.models

data class StockLedgerFixRunRequest(
val date: String? = null,
val from: String? = null,
val to: String? = null,
val mode: String? = null,
val inventoryId: Long? = null,
val lotLineId: Long? = null,
/** Day / range. Omit / empty / ["all"] = 2.1–2.6. Else subset e.g. ["2.3"]. */
val steps: List<String>? = null,
)

data class StockLedgerFixDayStatus(
val date: String,
val cnt: Int,
val missLot: Int,
val missUom: Int,
val missInventoryId: Int,
val missLotQty: Int,
val dayTableLots: Int,
)

data class StockLedgerFixCalendarResponse(
val from: String,
val to: String,
val days: List<StockLedgerFixDayStatus>,
)

data class StockLedgerFixRunResponse(
val date: String,
val filledLotLineId: Int,
val filledUomId: Int,
val filledInventoryId: Int,
val filledLotQty: Int,
val filledBalance: Int,
val dayRowsWritten: Int,
val stillMissLot: Int,
val stillMissUom: Int,
val stillMissInventoryId: Int,
val stillMissLotQty: Int,
/** 2.1: related SIL/SOL cannot point at a lot line, so the ledger row is soft-deleted. */
val deletedUnlinkedLot: Int = 0,
/** Ledger already had inventoryLotLineId and the live SIL/SOL lot line is the same. */
val existingLotMatch: Int = 0,
/** Ledger already had inventoryLotLineId; the live SIL/SOL lot line is missing or different. Not deleted. */
val existingLotMismatch: Int = 0,
/** Ledger had a lot line but the live stock-out line lot was null: lot cleared and ledger soft-deleted. */
val deletedBlankOutLine: Int = 0,
/** Live stock-out line lot differed; the line was set to the ledger lot. Ledger lot was not changed. */
val alignedOutLine: Int = 0,
)

data class StockLedgerFixCheckPart(
val key: String,
val label: String,
val ok: Int,
val miss: Int,
val incorrect: Int,
val group: String = "field",
)

data class StockLedgerFixDayDetail(
val date: String,
val cnt: Int,
val parts: List<StockLedgerFixCheckPart>,
)

data class StockLedgerFixInventoryPreview(
val inventoryRows: Int,
val lotUomPairs: Int,
val missingUomPairs: Int,
val nullStockUomId: Int,
)

data class StockLedgerFixInventoryResponse(
val inserted: Int,
val updated: Int,
val missingUomPairsAfter: Int,
val patchedStockUomId: Int,
val nullStockUomIdAfter: Int,
val orphansDeleted: Int = 0,
val outQtyNulledToZero: Int = 0,
)

data class StockLedgerFixSearchInventoryHit(
val inventoryId: Long,
val itemId: Long?,
val itemCode: String?,
val uomId: Long?,
val ledgerCnt: Int,
)

data class StockLedgerFixSearchLotHit(
val inventoryLotLineId: Long,
val lotNo: String?,
val itemCode: String?,
val inventoryId: Long?,
val ledgerCnt: Int,
)

data class StockLedgerFixAdjRequest(
/** Optional ADJ ledger date (yyyy-MM-dd). Default = yesterday. Use today on freeze-night dump. */
val adjDate: String? = null,
/** `2.7a` raises lot-line inQty where outQty is greater. Empty = 2.7b bucket align. */
val step: String? = null,
)

data class StockLedgerFixAdjRow(
val lotLineId: Long,
val inventoryId: Long?,
val itemCode: String?,
val lineIn: String,
val lineOut: String,
val ledgerIn: String,
val ledgerOut: String,
val missIn: String,
val missOut: String,
val overIssue: String,
)

data class StockLedgerFixAdjPreview(
val adjDate: String,
val lotCount: Int,
val adjInCount: Int,
val adjOutCount: Int,
val skippedNegCount: Int,
val sumMissIn: String,
val sumMissOut: String,
val skuNet: String,
val overIssueCount: Int,
val sumOverIssue: String,
val rows: List<StockLedgerFixAdjRow>,
)

data class StockLedgerFixAdjResponse(
val adjDate: String,
val insertedIn: Int,
val insertedOut: Int,
val overIssuePatched: Int,
val filledLotQty: Int,
val filledBalance: Int,
val dayRowsWritten: Int,
/** Rows inserted into stock_adjustment_record for stock-ledger-fix ADJ lines, including ones already on the ledger. */
val adjustmentRecords: Int = 0,
)

data class StockLedgerFixScopeDetail(
val kind: String,
val id: Long,
val itemCode: String?,
val lotNo: String?,
val inventoryId: Long?,
val uomId: Long?,
val firstDate: String?,
val lastDate: String?,
val cnt: Int,
val lastBalance: String?,
val lastLotQtyAfter: String?,
val parts: List<StockLedgerFixCheckPart>,
)

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


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

@@ -0,0 +1,36 @@
package com.ffii.fpsms.modules.pickOrder.entity

import com.ffii.core.entity.BaseEntity
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.Table

@Entity
@Table(name = "truck_lane_rename_log")
open class TruckLaneRenameLog : BaseEntity<Long>() {
@Column(name = "oldCode", nullable = false, length = 50)
open var oldCode: String = ""

@Column(name = "newCode", nullable = false, length = 50)
open var newCode: String = ""

@Column(name = "storeId", nullable = false, length = 10)
open var storeId: String = ""

@Column(name = "remark", length = 255)
open var remark: String? = null

/** A clock time, or `mixed` when rows in the bucket did not share one time. */
@Column(name = "oldDepartureTime", length = 20)
open var oldDepartureTime: String? = null

@Column(name = "newDepartureTime", length = 20)
open var newDepartureTime: String? = null

@Column(name = "ticketCount", nullable = false)
open var ticketCount: Int = 0

/** `truck.id` of each shop row in the renamed lane, comma-separated, ascending. */
@Column(name = "truckIds", columnDefinition = "TEXT")
open var truckIds: String? = null
}

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

@@ -0,0 +1,9 @@
package com.ffii.fpsms.modules.pickOrder.entity

import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface TruckLaneRenameLogRepository : JpaRepository<TruckLaneRenameLog, Long> {
fun findTop50ByDeletedFalseOrderByIdDesc(): List<TruckLaneRenameLog>
}

+ 39
- 93
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt 查看文件

@@ -10,6 +10,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.HandleStatus
import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository
import com.ffii.fpsms.modules.stock.entity.Inventory
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import com.ffii.fpsms.modules.stock.service.StockOutLineService

@@ -46,10 +47,13 @@ import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult
import com.ffii.fpsms.modules.stock.entity.StockLedger
import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository
import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot
import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus
import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest
@Service
@@ -72,7 +76,8 @@ open class PickExecutionIssueService(
private val stockLedgerRepository: StockLedgerRepository,
private val deliveryOrderRepository: DeliveryOrderRepository,
private val suggestedPickLotRepository: SuggestPickLotRepository,
private val doPickOrderRepository: DoPickOrderRepository
private val doPickOrderRepository: DoPickOrderRepository,
private val inventoryBucketResolver: InventoryBucketResolver,

) {
// 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议)
@@ -858,13 +863,8 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac
println("Miss Qty: ${missQty}")
val lotId = request.lotId ?: return
val itemId = request.itemId ?: return
val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null)
// ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
if (inventoryLotLine != null) {
// ✅ 修改:更新 outQty 为 actualPickQty
val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO
@@ -927,9 +927,7 @@ private fun handleMissItemOnly(request: PickExecutionIssueRequest, missQty: BigD
val itemId = request.itemId ?: return
val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null)
// ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine)
if (inventoryLotLine != null) {
// ✅ 修改:释放 holdQty(减少 holdQty)
@@ -1128,10 +1126,9 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty
val lotId = request.lotId ?: return
val itemId = request.itemId ?: return
val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null)
// ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine)
// ✅ 修复:更新 stock_out_line,但不要累积 qty
val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse(
@@ -1163,7 +1160,6 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty
}
// ✅ 修复:更新 inventory_lot_line 的 outQty
val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null)
if (inventoryLotLine != null) {
// ✅ 修复:计算新的 outQty,考虑之前的 outQty
val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO
@@ -2313,71 +2309,6 @@ private fun updateLotLineAfterIssue(lotLineId: Long, qty: BigDecimal, isMissItem
lotLine.modifiedBy = "system"
// 修复:使用 saveAndFlush 确保立即提交到数据库,触发触发器
inventoryLotLineRepository.saveAndFlush(lotLine)
updateInventoryAfterLotLineChange(lotLine)
}
}
private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) {
try {
val item = lotLine.inventoryLot?.item ?: return
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
// 使用 SQL 查询计算所有相关 lot lines 的总和
val sql = """
SELECT
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) as totalOnHandQty,
SUM(COALESCE(ill.holdQty, 0)) as totalOnHoldQty,
SUM(CASE
WHEN ill.status = 'unavailable'
THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0)
ELSE 0
END) as totalUnavailableQty
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId
WHERE il.itemId = :itemId
AND ill.deleted = 0
""".trimIndent()
val result = jdbcDao.queryForMap(sql, mapOf("itemId" to item.id!!)).orElse(null)
if (result != null) {
val totalOnHandQty = (result["totalOnHandQty"] as? Number)?.let {
BigDecimal(it.toString())
} ?: BigDecimal.ZERO
val totalOnHoldQty = (result["totalOnHoldQty"] as? Number)?.let {
BigDecimal(it.toString())
} ?: BigDecimal.ZERO
val totalUnavailableQty = (result["totalUnavailableQty"] as? Number)?.let {
BigDecimal(it.toString())
} ?: BigDecimal.ZERO
// 更新 inventory
inventory.onHandQty = totalOnHandQty
inventory.onHoldQty = totalOnHoldQty
inventory.unavailableQty = totalUnavailableQty
inventory.status = if (totalOnHandQty.subtract(totalOnHoldQty).subtract(totalUnavailableQty) > BigDecimal.ZERO) {
"available"
} else {
"unavailable"
}
inventory.modified = LocalDateTime.now()
inventory.modifiedBy = "system"
inventoryRepository.saveAndFlush(inventory)
println("=== MANUALLY UPDATED INVENTORY ===")
println("Item ID: ${item.id}")
println("Total OnHandQty: ${totalOnHandQty}")
println("Total OnHoldQty: ${totalOnHoldQty}")
println("Total UnavailableQty: ${totalUnavailableQty}")
println("Status: ${inventory.status}")
println("==================================")
}
} catch (e: Exception) {
println("Error updating inventory manually: ${e.message}")
e.printStackTrace()
}
}
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */
@@ -2389,23 +2320,19 @@ private fun createStockLedgerForStockOut(
val item = stockOutLine.item ?: return
val outQty = stockOutLine.qty?.toDouble() ?: 0.0
// ✅ 修复:如果传入了 balance,直接使用;否则重新查询 inventory
val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return
val finalBalance = if (balance != null) {
if (balance < 0) 0.0 else balance
} else {
// 重新查询 inventory 以获取触发器更新后的最新 onHandQty
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val currentOnHandQty = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val newBalance = currentOnHandQty - outQty
val previous = previousBalanceForInventory(inventory)
val newBalance = previous - outQty
if (newBalance < 0) 0.0 else newBalance
}
// 使用传入的 ledgerType,如果没有则使用 stockOutLine.type
val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor"
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
@@ -2768,7 +2695,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse
lotLine.modified = LocalDateTime.now()
lotLine.modifiedBy = "system"
inventoryLotLineRepository.saveAndFlush(lotLine)
updateInventoryAfterLotLineChange(lotLine)
println("✅ Reset lot ${request.lotId}: issueQty=0, status=AVAILABLE")
}
val rejectedLines = stockOutLineRepository
@@ -2802,9 +2728,17 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse
)
}
// ✅ 修复:在创建和保存 stockOutLine 之前获取 inventory 的当前 onHandQty
val inventoryBeforeUpdate = inventoryRepository.findByItemId(request.itemId).orElse(null)
val lotLine = request.lotId.let {
inventoryLotLineRepository.findById(it).orElse(null)
}
val inventoryBeforeUpdate = inventoryBucketResolver.findInventoryBucket(request.itemId, lotLine)
val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
if (lotLine != null && inventoryBeforeUpdate == null) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"此批次的庫存單位與貨品不符,無法提交貨品遺失或不良貨品。",
)
}
println("=== submitIssueWithQty: Before update ===")
println("Item ID: ${request.itemId}")
@@ -2812,9 +2746,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse
println("OnHandQty Before Update: ${onHandQtyBeforeUpdate}")
println("==========================================")
val lotLine = request.lotId.let {
inventoryLotLineRepository.findById(it).orElse(null)
}
println("Lot line: id=${lotLine?.id}, lotNo=${lotLine?.inventoryLot?.lotNo}")
val item = itemsRepository.findById(request.itemId).orElse(null)
@@ -2901,7 +2832,7 @@ private fun createStockLedgerForStockOutWithBalance(
// Use传入的 ledgerType,如果没有则使用 stockOutLine.type
val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor"
val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return
val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return
val ill = stockOutLine.inventoryLotLine
val outQtyBd = BigDecimal.valueOf(outQty)
@@ -3013,4 +2944,19 @@ private fun resolveLotUomInfo(lotId: Long?): Pair<String?, String?> {

return Pair(uomCode, uomDesc)
}

private fun bucketOnHandQty(itemId: Long, lotLine: InventoryLotLine?): Double {
val inventory = resolveLedgerInventory(itemId, lotLine)
return (inventory?.onHandQty ?: BigDecimal.ZERO).toDouble()
}

private fun resolveLedgerInventory(itemId: Long, lotLine: InventoryLotLine?): Inventory? =
inventoryBucketResolver.findInventoryBucket(itemId, lotLine)

private fun previousBalanceForInventory(inventory: Inventory): Double {
val latest = inventory.id?.let {
stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it)
}
return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}
}

+ 59
- 87
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt 查看文件

@@ -31,6 +31,7 @@ import com.ffii.fpsms.modules.stock.entity.StockOutRepository
import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo
import com.ffii.fpsms.modules.stock.service.InventoryService
import com.ffii.fpsms.modules.stock.service.StockOutLineService
import com.ffii.fpsms.modules.stock.service.SuggestedPickLotService
@@ -215,28 +216,17 @@ open class PickOrderService(
val today = LocalDate.now()
val zero = BigDecimal.ZERO

val inventories = if (itemIds.isNotEmpty()) {
inventoryLotLineService
.allInventoryLotLinesByItemIdIn(itemIds)
.filter { it.status == InventoryLotLineStatus.AVAILABLE.value }
.filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero }
.filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) }
.groupBy { it.item?.id }
} else {
emptyMap()
}
val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero)

// Build response (removed suggestions)
val pickOrderInfos = fullPickOrders.map { po ->
val pickOrderLineInfos = po.pickOrderLines.map { pol ->
val inventory = pol.item?.id?.let { inventories[it] }

GetPickOrderLineInfo(
id = pol.id,
itemId = pol.item?.id,
itemCode = pol.item?.code,
itemName = pol.item?.name,
availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) },
availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero),
requiredQty = pol.qty,
uomCode = pol.uom?.code,
uomDesc = pol.uom?.udfudesc,
@@ -660,7 +650,7 @@ open class PickOrderService(
// Get Inventory Data
val requiredItems = pos
.flatMap { it.pickOrderLines }
.groupBy { it.item?.id }
.groupBy { it.item?.id to it.uom?.id }
.map { (key, value) ->
key to object : CurrentInventoryItemInfo {
override val id: Long? = value[0].item?.id
@@ -671,42 +661,25 @@ open class PickOrderService(
override val requiredQty: BigDecimal = value.sumOf { it.qty ?: zero }

}
} // itemId - requiredQty
} // itemId+uom - requiredQty

val itemIds = requiredItems.mapNotNull { it.first }
// val inventories = inventoryLotLineRepository.findCurrentInventoryByItems(itemIds)
// val inventories = inventoryService.allInventoriesByItemIds(itemIds)
val inventories = inventoryLotLineService
.allInventoryLotLinesByItemIdIn(itemIds)
.filter { it.status == InventoryLotLineStatus.AVAILABLE.value }
.filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero }
.filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) }
.sortedBy { it.expiryDate }
.groupBy { it.item?.id }
val itemIds = requiredItems.mapNotNull { it.first.first }
val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero)

// Pick Orders
val releasePickOrderInfos = pos
.map { po ->
val releasePickOrderLineInfos = po.pickOrderLines.map { pol ->
// if (pol.item?.id != null && pol.item!!.id!! > 0) {
val inventory = pol.item?.id?.let { inventories[it] }
val itemUom = pol.item?.id?.let { itemUomService.findSalesUnitByItemId(it) }
// val inventory = inventories.find { it.itemId == pol.item?.id }

// Return
ReleasePickOrderLineInfo(
id = pol.id,
itemId = pol.item?.id,
itemCode = pol.item?.code,
itemName = pol.item?.name,
// availableQty = inventory?.availableQty,
availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) },
// availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) * (itemUom?.ratioN ?: one) * (itemUom?.ratioD ?: one) },
availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero),
requiredQty = pol.qty,
uomCode = pol.uom?.code,
uomDesc = pol.uom?.udfudesc,
)
// }
}

// Return
@@ -725,16 +698,12 @@ open class PickOrderService(
// val inventory = inventories
// .find { it.itemId == item.first }

val inventory = item.first?.let { inventories[it] }
val itemUom = item.first?.let { itemUomService.findSalesUnitByItemId(it) }
val inventory = item.first.let { (itemId, uomId) ->
if (itemId != null && uomId != null) inventories[itemId to uomId] else null
}

item.second.let {
// it.availableQty = inventory?.availableQty

it.availableQty = inventory?.sumOf { i -> i.availableQty ?: zero }
// it.availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) * (itemUom?.ratioN ?: one) * (itemUom?.ratioD ?: one) }

// return
it.availableQty = inventory?.sumOf { i -> (i.inQty ?: zero) - (i.outQty ?: zero) }
it
}
}
@@ -759,7 +728,7 @@ open class PickOrderService(
// Get Inventory Data
val requiredItems = pos
.flatMap { it.pickOrderLines }
.groupBy { it.item?.id }
.groupBy { it.item?.id to it.uom?.id }
.map { (key, value) ->
key to object : CurrentInventoryItemInfo {
override val id: Long? = value[0].item?.id
@@ -769,16 +738,10 @@ open class PickOrderService(
override var availableQty: BigDecimal? = zero
override val requiredQty: BigDecimal = value.sumOf { it.qty ?: zero }
}
} // itemId - requiredQty
}

val itemIds = requiredItems.mapNotNull { it.first }
val inventories = inventoryLotLineService
.allInventoryLotLinesByItemIdIn(itemIds)
.filter { it.status == InventoryLotLineStatus.AVAILABLE.value }
.filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero }
.filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) }
.sortedBy { it.expiryDate }
.groupBy { it.item?.id }
val itemIds = requiredItems.mapNotNull { it.first.first }
val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero)

val suggestions =
suggestedPickLotService.suggestionForPickOrders(SuggestedPickLotForPoRequest(pickOrders = pos))
@@ -793,24 +756,10 @@ open class PickOrderService(
emptyMap()
}

// Pre-calculate available quantities for each item ONCE
val itemAvailableQtyMap = mutableMapOf<Long, BigDecimal>()
itemIds.forEach { itemId ->
val inventory = inventories[itemId]
val totalAvailableQty = inventory?.sumOf { i ->
val inQty = i.inQty ?: zero
val outQty = i.outQty ?: zero
val holdQty = i.holdQty ?: zero
inQty.minus(outQty).minus(holdQty)
} ?: zero
itemAvailableQtyMap[itemId] = totalAvailableQty
}

val releasePickOrderLineInfos = pos
.map { po ->
val releasePickOrderLineInfos = po.pickOrderLines.map { pol ->
val itemId = pol.item?.id
val availableQty = itemId?.let { itemAvailableQtyMap[it] } ?: zero
val availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero)
// 取得這一行的所有 stock_out_line
val stockOutLines = stockOutLinesByPickOrderLineId[pol.id] ?: emptyList()
@@ -871,16 +820,11 @@ open class PickOrderService(
}
// Items
val currentInventoryInfos = requiredItems.map { item ->
val inventory = item.first?.let { inventories[it] }
val itemUom = item.first?.let { itemUomService.findSalesUnitByItemId(it) }
val inventory = item.first.let { (itemId, uomId) ->
if (itemId != null && uomId != null) inventories[itemId to uomId] else null
}
item.second.let {
val convertedAvailableQty = inventory?.sumOf { i ->
val baseQty = (i.availableQty ?: zero)
baseQty
}

it.availableQty = convertedAvailableQty
it.availableQty = inventory?.sumOf { i -> (i.inQty ?: zero) - (i.outQty ?: zero) }
it
}
}
@@ -2344,19 +2288,16 @@ open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageRespons
-- Calculate available quantity from inventory
COALESCE((
SELECT SUM(
COALESCE(inv.onHandQty, 0)
- COALESCE(inv.onHoldQty, 0)
- COALESCE(inv.unavailableQty, 0)
)
SELECT COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0)
FROM fpsmsdb.inventory inv
JOIN fpsmsdb.item_uom iu
ON iu.itemId = inv.itemId
AND iu.uomId = inv.uomId
AND iu.baseUnit = 1
AND iu.deleted = false
WHERE inv.itemId = i.id
AND inv.deleted = false
AND (
inv.stockUomId = pol.uomId
OR (inv.stockUomId IS NULL AND inv.uomId = pol.uomId)
)
ORDER BY CASE WHEN inv.stockUomId IS NULL THEN 1 ELSE 0 END, inv.id
LIMIT 1
), 0) as availableQty,
-- Check if all stock out lines for this pick order line are completed
@@ -5201,5 +5142,36 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A
)
}

private fun pickableLotLinesByItemAndStockUom(
itemIds: List<Long>,
today: LocalDate,
zero: BigDecimal,
): Map<Pair<Long, Long>, List<InventoryLotLineInfo>> {
if (itemIds.isEmpty()) return emptyMap()
return inventoryLotLineService
.allInventoryLotLinesByItemIdIn(itemIds)
.filter { it.status == InventoryLotLineStatus.AVAILABLE.value }
.filter { ((it.inQty ?: zero) - (it.outQty ?: zero)) > zero }
.filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) }
.mapNotNull { line ->
val itemId = line.item?.id ?: return@mapNotNull null
val stockUomId = line.uomId ?: return@mapNotNull null
(itemId to stockUomId) to line
}
.groupBy({ it.first }, { it.second })
}

private fun pickLineAvailableQty(
itemId: Long?,
polUomId: Long?,
lotsByBucket: Map<Pair<Long, Long>, List<InventoryLotLineInfo>>,
zero: BigDecimal,
): BigDecimal {
if (itemId == null || polUomId == null) return zero
return lotsByBucket[itemId to polUomId]
?.sumOf { (it.inQty ?: zero) - (it.outQty ?: zero) }
?: zero
}

}


+ 61
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameRules.kt 查看文件

@@ -0,0 +1,61 @@
package com.ffii.fpsms.modules.pickOrder.service

/**
* Bucket identity shared by the route board and the rename transaction.
* Remark is part of the key only on 4F. Comparison trims, and does not collapse inner spaces.
*/
object TruckLaneRenameRules {
const val RESERVED = "車線-X"
const val MAX_LEN = 50

data class BucketRef(
val code: String,
val floor: String,
val remarkKey: String,
)

fun floorOf(storeId: String?): String {
val compact = storeId?.trim()?.uppercase()?.replace(" ", "")?.replace("/", "") ?: ""
return if (compact == "4F") "4F" else "2F"
}

/** Blank, null, and whitespace-only remarks share the empty key. 2F ignores remark. */
fun remarkKey(floor: String, remark: String?): String {
if (floor != "4F") return ""
return remark?.trim().orEmpty()
}

/** Null when the name is acceptable. Otherwise a stable error code. */
fun validateNames(oldName: String, newName: String): String? {
val oldTrim = oldName.trim()
val newTrim = newName.trim()
if (oldTrim.isEmpty() || newTrim.isEmpty()) return "LANE_NAME_EMPTY"
if (newTrim.length > MAX_LEN) return "LANE_NAME_TOO_LONG"
if (oldTrim == RESERVED || newTrim == RESERVED) return "LANE_NAME_RESERVED"
return null
}

/**
* Same-remark collision ignores case and excludes the source bucket.
* A different remark refuses only when the trimmed new name is exactly the same,
* because tickets have no remark and could not tell the buckets apart.
*/
fun collisionCode(source: BucketRef, newCode: String, live: List<BucketRef>): String? {
val next = newCode.trim()
for (other in live) {
if (other.floor != source.floor) continue
val sameSource = other.remarkKey == source.remarkKey &&
other.code.trim().equals(source.code.trim(), ignoreCase = true)
if (sameSource) continue
if (other.remarkKey == source.remarkKey &&
other.code.trim().equals(next, ignoreCase = true)
) {
return "LANE_NAME_TAKEN"
}
if (other.remarkKey != source.remarkKey && other.code.trim() == next) {
return "LANE_NAME_REMARK_AMBIGUOUS"
}
}
return null
}
}

+ 299
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneRenameService.kt 查看文件

@@ -0,0 +1,299 @@
package com.ffii.fpsms.modules.pickOrder.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.pickOrder.entity.Truck
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLog
import com.ffii.fpsms.modules.pickOrder.entity.TruckLaneRenameLogRepository
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest
import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameResponse
import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest
import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneResponse
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneRenameLogResponse
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import java.time.LocalTime
import java.time.format.DateTimeParseException

@Service
open class TruckLaneRenameService(
private val jdbcDao: JdbcDao,
private val truckRepository: TruckRepository,
private val renameLogRepository: TruckLaneRenameLogRepository,
) {
@Transactional(readOnly = true)
open fun preview(req: PreviewTruckLaneRenameRequest): PreviewTruckLaneRenameResponse {
val code = req.truckLanceCode.trim()
if (code.isEmpty()) throw badRequest("LANE_NAME_EMPTY")
val floor = TruckLaneRenameRules.floorOf(req.storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark)
val params = ticketParams(code, floor, remarkKey)
return PreviewTruckLaneRenameResponse(
unfinishedCount = count(UNFINISHED_COUNT_SQL, params),
completedCount = count(COMPLETED_COUNT_SQL, params),
handlerNames = jdbcDao.queryForStrings(HANDLER_SQL, params)
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct(),
openScheduleCount = count(OPEN_SCHEDULE_COUNT_SQL, params),
)
}

@Transactional
open fun rename(req: RenameTruckLaneRequest): RenameTruckLaneResponse {
val oldCode = req.truckLanceCode.trim()
val newCode = req.replacementTruckLanceCode.trim()
val nameError = TruckLaneRenameRules.validateNames(oldCode, newCode)
if (nameError != null) throw badRequest(nameError)
val floor = TruckLaneRenameRules.floorOf(req.storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, req.remark)
val newTime = if (req.departureTimeChanged) parseTime(req.newDepartureTime) else null
if (req.departureTimeChanged && newTime == null) throw badRequest("LANE_TIME_INVALID")

jdbcDao.queryForList(
"SELECT id FROM truck WHERE deleted = 0 FOR UPDATE",
emptyMap<String, Any>(),
)

val live = truckRepository.findAllForRouteBoard().filter { it.deleted != true }
val sourceRows = live.filter { inBucket(it, oldCode, floor, remarkKey) }
if (sourceRows.isEmpty()) throw badRequest("LANE_NOT_FOUND")

val refs = liveBuckets(live)
val collision = TruckLaneRenameRules.collisionCode(
TruckLaneRenameRules.BucketRef(oldCode, floor, remarkKey),
newCode,
refs,
)
if (collision != null) throw conflict(collision)

val oldTimeLabel = describeTimes(sourceRows.map { it.departureTime })
val casings = sourceRows.map { it.truckLanceCode?.trim().orEmpty() }.filter { it.isNotEmpty() }.distinct()
// Existing delivery tickets (pending / released / assigned / completed) keep the old name.
// Later releases copy the new master name at insert time.
var scheduleCount = 0
for (casing in casings) {
scheduleCount += applyOpenScheduleRename(casing, newCode, floor, remarkKey)
}
for (row in sourceRows) {
row.truckLanceCode = newCode
if (req.departureTimeChanged) row.departureTime = newTime
}
truckRepository.saveAll(sourceRows)

val log = TruckLaneRenameLog()
log.oldCode = oldCode
log.newCode = newCode
log.storeId = floor
log.remark = remarkKey.ifEmpty { null }
log.oldDepartureTime = oldTimeLabel
log.newDepartureTime = if (req.departureTimeChanged) newTime.toString() else oldTimeLabel
log.ticketCount = 0
log.truckIds = sourceRows.mapNotNull { it.id }.distinct().sorted().joinToString(",")
renameLogRepository.save(log)

return RenameTruckLaneResponse(
truckLanceCode = newCode,
ticketCount = 0,
scheduleLineCount = scheduleCount,
)
}

/**
* Restore writes master rows back to the snapshot name and does not move unfinished tickets.
* Open schedule from/to that still point at the current name are moved back here.
*/
@Transactional
open fun rewriteOpenScheduleNames(
oldCode: String,
newCode: String,
storeId: String?,
remark: String?,
): Int {
val floor = TruckLaneRenameRules.floorOf(storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, remark)
return applyOpenScheduleRename(oldCode.trim(), newCode.trim(), floor, remarkKey)
}

@Transactional(readOnly = true)
open fun recentLogs(): List<TruckLaneRenameLogResponse> =
renameLogRepository.findTop50ByDeletedFalseOrderByIdDesc().map { row ->
TruckLaneRenameLogResponse(
id = row.id ?: 0,
created = row.created?.toString(),
createdBy = row.createdBy,
oldCode = row.oldCode,
newCode = row.newCode,
storeId = row.storeId,
remark = row.remark,
oldDepartureTime = row.oldDepartureTime,
newDepartureTime = row.newDepartureTime,
ticketCount = row.ticketCount,
truckIds = row.truckIds,
)
}

private fun applyOpenScheduleRename(
oldCode: String,
newCode: String,
floor: String,
remarkKey: String,
): Int {
if (oldCode.isEmpty() || newCode.isEmpty() || oldCode == newCode) return 0
val params = ticketParams(oldCode, floor, remarkKey).toMutableMap()
params["newCode"] = newCode
val matched = count(OPEN_SCHEDULE_COUNT_SQL, params)
jdbcDao.executeUpdate(SCHEDULE_FROM_SQL, params)
jdbcDao.executeUpdate(SCHEDULE_TO_SQL, params)
return matched
}

private fun liveBuckets(live: List<Truck>): List<TruckLaneRenameRules.BucketRef> {
val grouped = linkedMapOf<Triple<String, String, String>, String>()
for (row in live) {
val code = row.truckLanceCode?.trim().orEmpty()
if (code.isEmpty()) continue
val floor = TruckLaneRenameRules.floorOf(row.storeId)
val remarkKey = TruckLaneRenameRules.remarkKey(floor, row.remark)
val key = Triple(code.lowercase(), floor, remarkKey)
grouped.putIfAbsent(key, code)
}
return grouped.map { (key, code) ->
TruckLaneRenameRules.BucketRef(code, key.second, key.third)
}
}

private fun inBucket(row: Truck, code: String, floor: String, remarkKey: String): Boolean {
val rowFloor = TruckLaneRenameRules.floorOf(row.storeId)
val rowRemark = TruckLaneRenameRules.remarkKey(rowFloor, row.remark)
return rowFloor == floor &&
rowRemark == remarkKey &&
row.truckLanceCode?.trim().orEmpty().equals(code, ignoreCase = true)
}

private fun describeTimes(times: List<LocalTime?>): String {
val labels = times.map { it?.withNano(0)?.toString() ?: "" }.distinct()
return if (labels.size <= 1) labels.firstOrNull().orEmpty() else "mixed"
}

private fun parseTime(raw: String?): LocalTime? {
val text = raw?.trim().orEmpty()
if (text.isEmpty()) return null
val normalized = if (text.length == 5) "$text:00" else text
return try {
LocalTime.parse(normalized)
} catch (_: DateTimeParseException) {
null
}
}

private fun ticketParams(code: String, floor: String, remarkKey: String): Map<String, Any?> = mapOf(
"oldCode" to code,
"floor" to floor,
"remark" to remarkKey,
"blankRemark" to if (remarkKey.isEmpty()) 1 else 0,
)

private fun count(sql: String, params: Map<String, Any?>): Int {
val row = jdbcDao.queryForList(sql, params).firstOrNull() ?: return 0
val value = row.values.firstOrNull()
return (value as? Number)?.toInt() ?: 0
}

private fun badRequest(code: String) = ResponseStatusException(HttpStatus.BAD_REQUEST, code)

private fun conflict(code: String) = ResponseStatusException(HttpStatus.CONFLICT, code)

companion object {
private const val FLOOR_MATCH = """
(
(:floor = '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('4F', '4/F'))
OR (:floor <> '4F' AND UPPER(REPLACE(TRIM(storeCol), ' ', '')) IN ('2F', '2/F'))
)
"""

private const val REMARK_MATCH_4F = """
(
:floor <> '4F'
OR (
(:blankRemark = 1 AND (remarkCol IS NULL OR TRIM(remarkCol) = ''))
OR (:blankRemark = 0 AND TRIM(remarkCol) = :remark)
)
)
"""

private val UNFINISHED_COUNT_SQL = """
SELECT COUNT(*) AS cnt
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus IN ('pending', 'released')
AND TRIM(truckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "storeId")}
""".trimIndent()

private val COMPLETED_COUNT_SQL = """
SELECT COUNT(*) AS cnt
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus = 'completed'
AND TRIM(truckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "storeId")}
""".trimIndent()

private val HANDLER_SQL = """
SELECT DISTINCT TRIM(handlerName)
FROM delivery_order_pick_order
WHERE deleted = 0
AND ticketStatus IN ('pending', 'released')
AND handledBy IS NOT NULL
AND handlerName IS NOT NULL
AND TRIM(handlerName) <> ''
AND TRIM(truckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "storeId")}
""".trimIndent()

private val OPEN_SCHEDULE_COUNT_SQL = """
SELECT COUNT(*) AS cnt
FROM truck_lane_schedule_line l
JOIN truck_lane_schedule s ON s.id = l.scheduleId
WHERE l.deleted = 0 AND s.deleted = 0
AND s.status NOT IN ('APPLIED', 'CANCELLED')
AND (
(
TRIM(l.fromTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")}
)
OR (
TRIM(l.toTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")}
)
)
""".trimIndent()

private val SCHEDULE_FROM_SQL = """
UPDATE truck_lane_schedule_line l
JOIN truck_lane_schedule s ON s.id = l.scheduleId
SET l.fromTruckLanceCode = :newCode, l.version = l.version + 1
WHERE l.deleted = 0 AND s.deleted = 0
AND s.status NOT IN ('APPLIED', 'CANCELLED')
AND TRIM(l.fromTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.fromStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.fromRemark")}
""".trimIndent()

private val SCHEDULE_TO_SQL = """
UPDATE truck_lane_schedule_line l
JOIN truck_lane_schedule s ON s.id = l.scheduleId
SET l.toTruckLanceCode = :newCode, l.version = l.version + 1
WHERE l.deleted = 0 AND s.deleted = 0
AND s.status NOT IN ('APPLIED', 'CANCELLED')
AND TRIM(l.toTruckLanceCode) = :oldCode
AND ${FLOOR_MATCH.replace("storeCol", "l.toStoreId")}
AND ${REMARK_MATCH_4F.replace("remarkCol", "l.toRemark")}
""".trimIndent()
}
}

+ 26
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/service/TruckLaneVersionService.kt 查看文件

@@ -16,6 +16,7 @@ open class TruckLaneVersionService(
private val truckLaneVersionRepository: TruckLaneVersionRepository,
private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository,
private val logisticRepository: LogisticRepository,
private val truckLaneRenameService: TruckLaneRenameService,
) {
private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse =
TruckLaneVersionResponse(
@@ -271,6 +272,7 @@ open class TruckLaneVersionService(
}
}

rewriteOpenSchedulesBackToSnapshot(version, lines)
restoreRowsFromLines(version, lines)

if (!skipPostSnapshot) {
@@ -316,6 +318,7 @@ open class TruckLaneVersionService(
return "No snapshot lines for affected lanes (versionId=$versionId)"
}

rewriteOpenSchedulesBackToSnapshot(version, scopedLines)
val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet()
for (lane in codes) {
val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane)
@@ -340,6 +343,29 @@ open class TruckLaneVersionService(
return "Restored ${scopedLines.size} rows in ${codes.size} lane(s) from versionId=$versionId"
}

/**
* Open schedule from/to that still use the live name are pointed back at the snapshot name.
* Unfinished delivery tickets are left on the current name.
*/
private fun rewriteOpenSchedulesBackToSnapshot(
version: TruckLaneVersion,
lines: List<TruckLaneVersionLine>,
) {
val ids = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.distinct()
if (ids.isEmpty()) return
val trucks = truckRepository.findAllById(ids).associateBy { it.id }
val seen = HashSet<String>()
for (line in lines) {
val truck = trucks[line.truckRowId] ?: continue
val before = truck.truckLanceCode?.trim().orEmpty()
val after = (line.truckLanceCode ?: version.truckLanceCode)?.trim().orEmpty()
if (before.isEmpty() || after.isEmpty() || before == after) continue
val key = "$before|$after|${truck.storeId}|${truck.remark}"
if (!seen.add(key)) continue
truckLaneRenameService.rewriteOpenScheduleNames(before, after, truck.storeId, truck.remark)
}
}

private fun restoreRowsFromLines(
version: TruckLaneVersion,
lines: List<TruckLaneVersionLine>,


+ 15
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/TruckController.kt 查看文件

@@ -18,8 +18,11 @@ import org.springframework.web.multipart.MultipartHttpServletRequest
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest
import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest
import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest
import com.ffii.fpsms.modules.pickOrder.service.TruckLaneRenameService
import com.ffii.fpsms.modules.pickOrder.service.TruckService
import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository
import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest
import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest
import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane
import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse
import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest
@@ -37,6 +40,7 @@ import jakarta.validation.Valid
open class TruckController(
private val truckService: TruckService,
private val truckRepository: TruckRepository,
private val truckLaneRenameService: TruckLaneRenameService,
) {

@@ -469,4 +473,15 @@ open class TruckController(
}
}

@PostMapping("/previewTruckLaneRename")
open fun previewTruckLaneRename(@RequestBody req: PreviewTruckLaneRenameRequest) =
truckLaneRenameService.preview(req)

@PostMapping("/renameTruckLane")
open fun renameTruckLane(@RequestBody req: RenameTruckLaneRequest) =
truckLaneRenameService.rename(req)

@GetMapping("/truckLaneRenameLog")
open fun truckLaneRenameLog() = truckLaneRenameService.recentLogs()

}

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


+ 44
- 0
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/TruckLaneRenameModels.kt 查看文件

@@ -0,0 +1,44 @@
package com.ffii.fpsms.modules.pickOrder.web.models

data class PreviewTruckLaneRenameRequest(
val truckLanceCode: String = "",
val storeId: String = "2F",
val remark: String? = null,
)

data class PreviewTruckLaneRenameResponse(
val unfinishedCount: Int = 0,
val completedCount: Int = 0,
val handlerNames: List<String> = emptyList(),
val openScheduleCount: Int = 0,
)

data class RenameTruckLaneRequest(
val truckLanceCode: String = "",
val storeId: String = "2F",
val remark: String? = null,
@param:com.fasterxml.jackson.annotation.JsonProperty("newTruckLanceCode")
val replacementTruckLanceCode: String = "",
val departureTimeChanged: Boolean = false,
val newDepartureTime: String? = null,
)

data class RenameTruckLaneResponse(
val truckLanceCode: String = "",
val ticketCount: Int = 0,
val scheduleLineCount: Int = 0,
)

data class TruckLaneRenameLogResponse(
val id: Long = 0,
val created: String? = null,
val createdBy: String? = null,
val oldCode: String = "",
val newCode: String = "",
val storeId: String = "",
val remark: String? = null,
val oldDepartureTime: String? = null,
val newDepartureTime: String? = null,
val ticketCount: Int = 0,
val truckIds: String? = null,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt 查看文件

@@ -109,6 +109,7 @@ data class jobOrderLineInfo(

val stockUom: String?,
val stockBaseUom: String?,
val stockUomId: Long?,
val availableStatus: String?,
val bomProcessId: Long?,


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt 查看文件

@@ -915,6 +915,7 @@ open class ProductProcessService(
reqBaseUom = baseUomName ?: "",
stockUom = stockUomName ?: "",
stockBaseUom = baseUomName ?: "",
stockUomId = stockUomId,

type = line.item?.type ?: "",
availableStatus = availableStatus,


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


+ 17
- 7
src/main/java/com/ffii/fpsms/modules/report/service/FGStockOutTraceabilityReportService.kt 查看文件

@@ -105,7 +105,7 @@ class FGStockOutTraceabilityReportService(
CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId,
IFNULL(sp.name, '') AS customerName,
FORMAT(
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0
) AS qtyNumeric,
FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty,
'' AS truckNo,
@@ -131,7 +131,7 @@ class FGStockOutTraceabilityReportService(
GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation,
'' AS pickRemark,
FORMAT(
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0
) AS totalStockOutQty,
0 AS stockSubCategory
FROM delivery_order_pick_order dopo
@@ -154,11 +154,6 @@ class FGStockOutTraceabilityReportService(
ON dol.deliveryOrderId = do.id
AND dol.itemId = pol.itemId
AND dol.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
LEFT JOIN stock_out_line sol
ON pol.id = sol.pickOrderLineId
AND sol.itemId = it.id
@@ -170,6 +165,20 @@ class FGStockOutTraceabilityReportService(
LEFT JOIN inventory_lot_line ill
ON sol.inventoryLotLineId = ill.id
AND ill.deleted = 0
LEFT JOIN item_uom iu
ON iu.deleted = 0
AND iu.id = COALESCE(
ill.stockItemUomId,
(
SELECT MIN(iu2.id)
FROM item_uom iu2
WHERE iu2.itemId = it.id
AND iu2.deleted = 0
AND iu2.stockUnit = 1
)
)
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
LEFT JOIN inventory_lot il
ON ill.inventoryLotId = il.id
AND il.deleted = 0
@@ -213,6 +222,7 @@ class FGStockOutTraceabilityReportService(
il.expiryDate
ORDER BY
it.code,
uc.udfudesc,
deliveryDate,
il.lotNo
""".trimIndent()


+ 15
- 3
src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt 查看文件

@@ -109,12 +109,23 @@ open class ItemQcFailReportService(
LEFT JOIN qc_result_measurement qrm
ON qrm.qcResultId = qr.id
AND qrm.deleted = 0
LEFT JOIN inventory_lot_line ill_uom
ON ill_uom.deleted = 0
AND ill_uom.id = COALESCE(
sil.inventoryLotLineId,
(
SELECT MIN(illx.id)
FROM inventory_lot_line illx
WHERE illx.inventoryLotId = sil.inventoryLotId
AND illx.deleted = 0
AND illx.stockItemUomId IS NOT NULL
)
)
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
ON iu.id = ill_uom.stockItemUomId
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
ON uc.id = iu.uomId
LEFT JOIN item_uom iu_purchase
ON it.id = iu_purchase.itemId
AND iu_purchase.purchaseUnit = 1
@@ -164,6 +175,7 @@ open class ItemQcFailReportService(
qr.qcPassed DESC,
it.type,
it.code,
COALESCE(uc.udfudesc, uc.code, ''),
COALESCE(sil.lotNo, il.lotNo, ''),
CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END,
COALESCE(qic.`order`, 9999),


+ 16
- 6
src/main/java/com/ffii/fpsms/modules/report/service/MaterialStockOutTraceabilityReportService.kt 查看文件

@@ -105,7 +105,7 @@ class MaterialStockOutTraceabilityReportService(
COALESCE(wh.code, '') AS storeLocation,
'' AS pickRemark,
FORMAT(
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0
) AS totalStockOutQty
FROM stock_out_line sol
INNER JOIN stock_out so
@@ -126,14 +126,23 @@ class MaterialStockOutTraceabilityReportService(
INNER JOIN items it
ON sol.itemId = it.id
AND it.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
LEFT JOIN inventory_lot_line ill
ON sol.inventoryLotLineId = ill.id
AND ill.deleted = 0
LEFT JOIN item_uom iu
ON iu.deleted = 0
AND iu.id = COALESCE(
ill.stockItemUomId,
(
SELECT MIN(iu2.id)
FROM item_uom iu2
WHERE iu2.itemId = it.id
AND iu2.deleted = 0
AND iu2.stockUnit = 1
)
)
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
LEFT JOIN inventory_lot il
ON ill.inventoryLotId = il.id
AND il.deleted = 0
@@ -169,6 +178,7 @@ class MaterialStockOutTraceabilityReportService(
$handlerSql
ORDER BY
it.code,
uc.udfudesc,
il.lotNo,
sol.endTime
""".trimIndent()


+ 390
- 246
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt 查看文件

@@ -4,7 +4,7 @@ import org.springframework.stereotype.Service
import net.sf.jasperreports.engine.*
import net.sf.jasperreports.engine.data.JRMapCollectionDataSource
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.util.concurrent.ConcurrentHashMap
import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.m18.M18GrnRules
import com.ffii.fpsms.modules.master.entity.ShopRepository
@@ -12,14 +12,11 @@ import com.ffii.fpsms.modules.master.enums.ShopType
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
import java.math.BigDecimal
import java.text.DecimalFormat
import java.text.DecimalFormatSymbols
import java.time.LocalDate
import java.util.ArrayList
import java.util.Locale
import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter
import net.sf.jasperreports.export.SimpleExporterInput
import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput
import org.slf4j.LoggerFactory

@Service
open class ReportService(
@@ -28,6 +25,8 @@ open class ReportService(
private val shopRepository: ShopRepository,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
private val log = LoggerFactory.getLogger(javaClass)
private val compiledReports = java.util.concurrent.ConcurrentHashMap<String, JasperReport>()
/**
* Queries the database for inventory data based on dates and optional item type.
*/
@@ -373,7 +372,7 @@ return result
CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId,
IFNULL(sp.name, '') AS customerName,
FORMAT(
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0
) AS qtyNumeric,
FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty,
'' AS truckNo,
@@ -397,7 +396,7 @@ return result
GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation,
'' AS pickRemark,
FORMAT(
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0
ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0
) AS totalStockOutQty,
0 AS stockSubCategory
FROM do_pick_order_line_record dpolr
@@ -417,11 +416,6 @@ return result
LEFT JOIN items it
ON dol.itemId = it.id
AND it.deleted = 0
LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
LEFT JOIN pick_order_line pol
ON dpolr.pick_order_id = pol.poId
AND pol.itemId = it.id
@@ -440,6 +434,20 @@ return result
LEFT JOIN inventory_lot_line ill
ON sol.inventoryLotLineId = ill.id
AND ill.deleted = 0
LEFT JOIN item_uom iu
ON iu.deleted = 0
AND iu.id = COALESCE(
ill.stockItemUomId,
(
SELECT MIN(iu2.id)
FROM item_uom iu2
WHERE iu2.itemId = it.id
AND iu2.deleted = 0
AND iu2.stockUnit = 1
)
)
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
LEFT JOIN inventory_lot il
ON ill.inventoryLotId = il.id
AND il.deleted = 0
@@ -480,6 +488,7 @@ return result
il.expiryDate
ORDER BY
it.code,
uc.udfudesc,
deliveryDate,
il.lotNo
""".trimIndent()
@@ -673,12 +682,19 @@ return result
* Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables.
* Supports comma-separated values for stockCategory (items.type) and itemCode.
* Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告.
* Always limited to NOR stock-in (excludes TRF). PP/PF uses this line's PO code prefix, not lot-origin TRF walk.
*/
fun searchStockInTraceabilityReport(
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?
lastInDateEnd: String?,
storeId: String? = null,
warehouse: String? = null,
area: String? = null,
slot: String? = null,
lotNo: String? = null,
poPrefix: String? = null,
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
@@ -689,7 +705,7 @@ return result
} else {
""
}
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args)
val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) {
val formattedDate = lastInDateStart.replace("/", "-")
@@ -703,6 +719,34 @@ return result
"AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)"
} else ""

val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") {
args["storeId"] = storeId.trim()
"AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')"
} else {
""
}
val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") {
buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", args)
} else {
""
}
val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") {
buildMultiValueLikeClause(area, "wh.area", "areaPart", args)
} else {
""
}
val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") {
buildMultiValueLikeClause(slot, "wh.slot", "slotPart", args)
} else {
""
}
val lotNoSql = buildMultiValueLikeClause(lotNo, "COALESCE(sil.lotNo, il.lotNo)", "lotNo", args)
val poPrefixSql = when (poPrefix?.trim()?.uppercase()) {
"PP" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PP'"
"PF" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PF'"
else -> ""
}

val sql = """
SELECT
COALESCE(it.code, '') as itemNo,
@@ -723,19 +767,33 @@ return result
END, 2))) as iqcDefectPercentage,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '不合格' ELSE '已接受' END as iqcResult,
COALESCE(qr_agg.remarksConcat, '') as iqcRemarks,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(qr_agg.failQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcDefectQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(qr_agg.failQtySum, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalIqcDefectQty,
COALESCE(wh.code, '') as storeLocation,
COALESCE(sp_si.code, sp_po.code, '') as supplierID,
COALESCE(sp_si.name, sp_po.name, '') as supplierName,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalStockInQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalIqcSampleQty
FROM stock_in_line sil
LEFT JOIN stock_in si ON sil.stockInId = si.id
LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id
LEFT JOIN items it ON sil.itemId = it.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
LEFT JOIN inventory_lot il ON sil.inventoryLotId = il.id
LEFT JOIN inventory_lot_line ill_uom
ON ill_uom.deleted = 0
AND ill_uom.id = COALESCE(
sil.inventoryLotLineId,
(
SELECT MIN(illx.id)
FROM inventory_lot_line illx
WHERE illx.inventoryLotId = il.id
AND illx.deleted = 0
AND illx.stockItemUomId IS NOT NULL
)
)
LEFT JOIN item_uom iu
ON iu.id = ill_uom.stockItemUomId
AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId
LEFT JOIN (
SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum
FROM inventory_lot_line
@@ -760,11 +818,18 @@ return result
GROUP BY qr.stockInLineId
) qr_agg ON qr_agg.stockInLineId = sil.id
WHERE sil.deleted = false
AND UPPER(TRIM(COALESCE(sil.type, ''))) = 'NOR'
$stockCategorySql
$itemCodeSql
$lastInDateStartSql
$lastInDateEndSql
ORDER BY it.code, sil.lotNo
$storeIdSql
$warehouseSql
$areaSql
$slotSql
$lotNoSql
$poPrefixSql
ORDER BY it.code, uc.udfudesc, sil.lotNo
""".trimIndent()

return jdbcDao.queryForList(sql, args)
@@ -1485,14 +1550,17 @@ return result
}

/**
* Stock Balance Report (date-driven).
* FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.2 | 2026-09-21
* Stock Balance Report (date-driven). One summarized row per item + stock UOM.
*
* Opening / current balance use the latest stock_ledger row per item (index on itemId+date)
* instead of ranking the whole ledger. Item codes prefer `IN` over `LIKE %x%`.
*
* - 期初存量: stockDate - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序)
* - 現存存貨: stockDate 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序)
* - 單位均價 / 庫存總價值: items.AverageUnitPrice * 現存存貨
* Qty comes from [stock_lot_day] (daily lot close) instead of scanning all stock_ledger:
* - 期初存量: historical = that day's opening; today = yesterday's closing
* - 現存存貨: historical = that day's closing; today = inventory.onHandQty
* (today is not closed yet). If the day row is missing, fall back to previous
* close + that day's ledger net.
* - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only
* - 單位均價: items.AverageUnitPrice (HKD / stock UOM), converted by item_uom ratio
* - 庫存總價值: 該 UOM 均價 * 該 UOM 現存存貨
*/
fun searchStockBalanceReportByDate(
stockCategory: String?,
@@ -1502,238 +1570,311 @@ return result
balanceFilterEnd: String?,
storeLocation: String?,
): List<Map<String, Any>> {
val itemArgs = mutableMapOf<String, Any>()
val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", itemArgs)
val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", itemArgs)
val hasItemCodeFilter = itemCodeSql.isNotBlank()
val args = mutableMapOf<String, Any>()
val formattedStockDate = stockDate.replace("/", "-")
val stockLocalDate = LocalDate.parse(formattedStockDate)
val useLiveInventory = !stockLocalDate.isBefore(LocalDate.now())
val liveFlag = if (useLiveInventory) "1" else "0"
args["d0"] = stockLocalDate
args["d1"] = stockLocalDate.minusDays(1)
args["d0Start"] = stockLocalDate.atStartOfDay()
args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay()

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

val itemIds = items.mapNotNull { jdbcLong(it["itemId"]) }.distinct()
if (itemIds.isEmpty()) return emptyList()

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

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

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

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

val avg = jdbcDouble(item["avgUnitPriceRaw"])
val cumIn = moves?.cumStockIn ?: 0.0
val cumOut = moves?.cumStockOut ?: 0.0
val miss = moves?.misInputAndLost ?: 0.0
val variance = moves?.variance ?: 0.0
val defective = moves?.defectiveGoods ?: 0.0
mapOf(
"stockSubCategory" to "",
"itemNo" to (item["itemNo"]?.toString().orEmpty()),
"itemName" to (item["itemName"]?.toString().orEmpty()),
"unitOfMeasure" to (uomByItem[itemId] ?: ""),
"lotNo" to "",
"expiryDate" to "",
"openingBalance" to "",
"cumStockIn" to "",
"cumStockOut" to "",
"currentBalance" to "",
"reOrderQty" to "",
"storeLocation" to "",
"lastInDate" to "",
"lastOutDate" to "",
"openingBalanceRaw" to opening,
"currentBalanceRaw" to current,
"totalOpeningBalance" to formatReportQty(opening),
"totalCumStockIn" to formatReportQty(cumIn),
"totalCumStockOut" to formatReportQty(cumOut),
"totalCurrentBalance" to formatReportQty(current),
"misInputAndLost" to "",
"defectiveGoods" to "",
"variance" to "",
"totalMisInputAndLost" to formatReportQty(miss),
"totalVariance" to formatReportQty(variance),
"totalDefectiveGoods" to formatReportQty(defective),
"avgUnitPrice" to formatReportMoney(avg),
"totalStockBalance" to formatReportMoney(avg * current),
)
AND EXISTS (
SELECT 1
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0
INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0
LEFT JOIN item_uom iu_loc ON iu_loc.id = ill.stockItemUomId AND iu_loc.deleted = 0
WHERE ill.deleted = 0
AND il.itemId = q.itemId
AND iu_loc.uomId <=> q.uomId
AND wh.code LIKE :storeLocation
)
""".trimIndent()
} else ""
val forceLedgerIndex = if (itemCode.isNullOrBlank()) {
"FORCE INDEX (idx_sl_deleted_date)"
} else {
""
}
}

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

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

private fun loadStockDateLedgerMoves(
itemIds: List<Long>,
startInclusive: java.time.LocalDateTime,
endExclusive: java.time.LocalDateTime,
): Map<Long, StockBalanceMoves> {
val out = HashMap<Long, StockBalanceMoves>(itemIds.size)
itemIds.chunked(400).forEach { chunk ->
val args = mutableMapOf<String, Any>(
"itemIds" to ArrayList(chunk),
"startAt" to startInclusive,
"endAt" to endExclusive,
)
val rows = jdbcDao.queryForList(
"""
val baseSql = """
WITH day0 AS (
SELECT
sl.itemId AS itemId,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.inQty, 0) > 0 THEN COALESCE(sl.inQty, 0) ELSE 0 END) AS cumStockIn,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS cumStockOut,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS misInputAndLost,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS defectiveGoods,
SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END) AS variance
FROM stock_ledger sl
d.itemId,
d.uomId,
SUM(COALESCE(d.opening, 0)) AS openingBalance,
SUM(COALESCE(d.closing, 0)) AS currentBalance
FROM stock_lot_day d
WHERE IFNULL(d.deleted, 0) = 0
AND d.date = :d0
AND d.itemId IS NOT NULL
$itemCodeSqlDay
GROUP BY d.itemId, d.uomId
),
day1 AS (
SELECT
d.itemId,
d.uomId,
SUM(COALESCE(d.closing, 0)) AS openingBalance
FROM stock_lot_day d
WHERE IFNULL(d.deleted, 0) = 0
AND d.date = :d1
AND d.itemId IS NOT NULL
$itemCodeSqlDay
GROUP BY d.itemId, d.uomId
),
inventory_per_item AS (
SELECT
iv.itemId,
iv.stockUomId AS uomId,
SUM(COALESCE(iv.onHandQty, 0)) AS currentBalance
FROM inventory iv
INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0
WHERE iv.deleted = 0
$itemCodeSql
$stockCategorySql
GROUP BY iv.itemId, iv.stockUomId
),
ledger_moves AS (
SELECT
sl.itemId,
sl.uomId,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
AND COALESCE(sl.inQty, 0) > 0
THEN COALESCE(sl.inQty, 0)
ELSE 0
END
) AS cumStockIn,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ')
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS cumStockOut,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS'
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS misInputAndLost,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD'
AND COALESCE(sl.outQty, 0) > 0
THEN COALESCE(sl.outQty, 0)
ELSE 0
END
) AS defectiveGoods,
SUM(
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE'
THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)
ELSE 0
END
) AS variance,
SUM(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS dayNet
FROM stock_ledger sl $forceLedgerIndex
WHERE sl.deleted = 0
AND sl.itemId IN (:itemIds)
AND sl.date >= :startAt
AND sl.date < :endAt
GROUP BY sl.itemId
""".trimIndent(),
args,
AND sl.itemId IS NOT NULL
AND sl.date >= :d0Start
AND sl.date < :d0EndExclusive
$itemCodeSqlSl
GROUP BY sl.itemId, sl.uomId
),
item_ids AS (
SELECT itemId, uomId FROM day0
UNION
SELECT itemId, uomId FROM day1
UNION
SELECT itemId, uomId FROM ledger_moves
$inventoryScopeUnion
),
item_scope AS (
SELECT
it.id AS itemId,
it.code AS itemNo,
it.name AS itemName,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
FROM items it
WHERE it.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
AND it.id IN (SELECT DISTINCT itemId FROM item_ids)
$itemCodeSql
$stockCategorySql
),
stock_one AS (
SELECT iu.*
FROM item_uom iu
INNER JOIN (
SELECT itemId, MIN(id) AS id
FROM item_uom
WHERE deleted = 0 AND stockUnit = 1
GROUP BY itemId
) x ON x.id = iu.id
),
qty_per_item AS (
SELECT
ids.itemId,
ids.uomId,
COALESCE(
CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END,
d1.openingBalance,
0
) AS openingBalance,
COALESCE(
CASE WHEN $liveFlag = 1 THEN invc.currentBalance ELSE d0.currentBalance END,
COALESCE(
CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END,
d1.openingBalance,
0
) + COALESCE(lm.dayNet, 0),
0
) AS currentBalance,
COALESCE(lm.cumStockIn, 0) AS cumStockIn,
COALESCE(lm.cumStockOut, 0) AS cumStockOut,
COALESCE(lm.misInputAndLost, 0) AS misInputAndLost,
COALESCE(lm.variance, 0) AS variance,
COALESCE(lm.defectiveGoods, 0) AS defectiveGoods
FROM item_ids ids
LEFT JOIN day0 d0 ON d0.itemId = ids.itemId AND d0.uomId <=> ids.uomId
LEFT JOIN day1 d1 ON d1.itemId = ids.itemId AND d1.uomId <=> ids.uomId
LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId AND invc.uomId <=> ids.uomId
LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId AND lm.uomId <=> ids.uomId
)
for (row in rows) {
val id = jdbcLong(row["itemId"]) ?: continue
out[id] = StockBalanceMoves(
cumStockIn = jdbcDouble(row["cumStockIn"]),
cumStockOut = jdbcDouble(row["cumStockOut"]),
misInputAndLost = jdbcDouble(row["misInputAndLost"]),
defectiveGoods = jdbcDouble(row["defectiveGoods"]),
variance = jdbcDouble(row["variance"]),
)
}
SELECT
'' AS stockSubCategory,
COALESCE(s.itemNo, '') AS itemNo,
COALESCE(s.itemName, '') AS itemName,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
'' AS lotNo,
'' AS expiryDate,
'' AS openingBalance,
'' AS cumStockIn,
'' AS cumStockOut,
'' AS currentBalance,
'' AS reOrderQty,
'' AS storeLocation,
'' AS lastInDate,
'' AS lastOutDate,
q.openingBalance AS openingBalanceRaw,
q.currentBalance AS currentBalanceRaw,
CASE WHEN q.openingBalance < 0 THEN CONCAT('(', FORMAT(-q.openingBalance, 0), ')') ELSE FORMAT(q.openingBalance, 0) END AS totalOpeningBalance,
CASE WHEN q.cumStockIn < 0 THEN CONCAT('(', FORMAT(-q.cumStockIn, 0), ')') ELSE FORMAT(q.cumStockIn, 0) END AS totalCumStockIn,
CASE WHEN q.cumStockOut < 0 THEN CONCAT('(', FORMAT(-q.cumStockOut, 0), ')') ELSE FORMAT(q.cumStockOut, 0) END AS totalCumStockOut,
CASE WHEN q.currentBalance < 0 THEN CONCAT('(', FORMAT(-q.currentBalance, 0), ')') ELSE FORMAT(q.currentBalance, 0) END AS totalCurrentBalance,
'' AS misInputAndLost,
'' AS defectiveGoods,
'' AS variance,
CASE WHEN q.misInputAndLost < 0 THEN CONCAT('(', FORMAT(-q.misInputAndLost, 0), ')') ELSE FORMAT(q.misInputAndLost, 0) END AS totalMisInputAndLost,
CASE WHEN q.variance < 0 THEN CONCAT('(', FORMAT(-q.variance, 0), ')') ELSE FORMAT(q.variance, 0) END AS totalVariance,
CASE WHEN q.defectiveGoods < 0 THEN CONCAT('(', FORMAT(-q.defectiveGoods, 0), ')') ELSE FORMAT(q.defectiveGoods, 0) END AS totalDefectiveGoods,
FORMAT(ROUND(
CASE
WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0)
WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0)
ELSE ROUND(
COALESCE(s.avgUnitPriceRaw, 0)
* (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)),
4
)
END
, 2), 2) AS avgUnitPrice,
FORMAT(ROUND(
CASE
WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0)
WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0)
ELSE ROUND(
COALESCE(s.avgUnitPriceRaw, 0)
* (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1))
* (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)),
4
)
END
* COALESCE(q.currentBalance, 0)
, 2), 2) AS totalStockBalance
FROM qty_per_item q
INNER JOIN item_scope s ON s.itemId = q.itemId
LEFT JOIN stock_one so ON so.itemId = q.itemId
LEFT JOIN item_uom iu
ON iu.itemId = q.itemId
AND iu.deleted = 0
AND iu.uomId <=> q.uomId
AND iu.id = (
SELECT MIN(iu2.id)
FROM item_uom iu2
WHERE iu2.itemId = q.itemId
AND iu2.deleted = 0
AND iu2.uomId <=> q.uomId
)
LEFT JOIN uom_conversion uc ON uc.id = COALESCE(iu.uomId, q.uomId)
WHERE 1=1
$storeLocationSql
""".trimIndent()

val filters = mutableListOf<String>()
if (!balanceFilterStart.isNullOrBlank()) {
args["balanceFilterStart"] = balanceFilterStart.toDoubleOrNull() ?: 0.0
filters.add("COALESCE(currentBalanceRaw, 0) >= :balanceFilterStart")
}
if (!balanceFilterEnd.isNullOrBlank()) {
args["balanceFilterEnd"] = balanceFilterEnd.toDoubleOrNull() ?: 0.0
filters.add("COALESCE(currentBalanceRaw, 0) <= :balanceFilterEnd")
}
return out
}

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

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

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

private fun formatReportQty(value: Double): String {
val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US))
return if (value < 0) "(${fmt.format(-value)})" else fmt.format(value)
val t0 = System.nanoTime()
val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo, unitOfMeasure", args)
log.info(
"stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms",
stockLocalDate,
useLiveInventory,
itemCode ?: "",
rows.size,
(System.nanoTime() - t0) / 1_000_000,
)
return rows
}

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

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

fun mergePdfBytes(parts: List<ByteArray>): ByteArray {
if (parts.isEmpty()) return ByteArray(0)
if (parts.size == 1) return parts[0]


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


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

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

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

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

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

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

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

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

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

init {
newPage()
}

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -1,7 +1,10 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDate
import java.time.format.DateTimeFormatter

@@ -9,7 +12,7 @@ import java.time.format.DateTimeFormatter
open class StockLedgerReportService(
private val jdbcDao: JdbcDao,
) {
private val log = LoggerFactory.getLogger(StockLedgerReportService::class.java)
private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd")

/**
@@ -18,11 +21,12 @@ open class StockLedgerReportService(
*
* - stockSubCategory = items.type
* - trnDate = stock_ledger.date
* - trnRefNo = stock_ledger.type
* - cumBalance = stock_ledger.balance(異動後纍計存量)
* - cumOpeningBal = balance - inQty + outQty(異動前纍計期初)
* - trnRefNo = stock_ledger.type(ADJ 且關聯盤點單時改為盤點)
* - cumBalance = stock_ledger.balance(異動後貨品纍計存量)
* - cumOpeningBal = balance - inQty + outQty(異動前貨品纍計)
*
* 只查 [start, end] 期間列,不掃起日以前全歷史。
* 帳本先依日期區間撈窄欄,批號/單據再依這段出現的 id 分批補。
* 餘額用帳本上的貨品纍計,排序為貨品 → 時間 → id。
*/
fun searchStockLedgerReport(
stockCategory: String?,
@@ -31,263 +35,587 @@ open class StockLedgerReportService(
reportPeriodStart: String?,
reportPeriodEnd: String?,
): List<Map<String, Any>> {
val tAll = System.nanoTime()
val timings = mutableListOf<String>()
fun <T> timed(name: String, extra: (T) -> String, block: () -> T): T {
val t0 = System.nanoTime()
val result = block()
val line = "$name ${extra(result)} ${(System.nanoTime() - t0) / 1_000_000}ms"
timings.add(line)
log.info("stock-ledger {}", line)
return result
}

val args = mutableMapOf<String, Any>()

val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-")
val endDate = (reportPeriodEnd?.replace("/", "-")
?: LocalDate.now().format(dateFmt))

val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) {
val filterArgs = mutableMapOf<String, Any>()
val itemPredicate = ledgerItemPredicate(itemCode, filterArgs)
val startDate = if (!reportPeriodStart.isNullOrBlank()) {
reportPeriodStart.replace("/", "-")
} else {
val minDateSql = """
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl
earliestLedgerDate(itemPredicate, filterArgs) ?: endDate
}
val endExclusive = LocalDate.parse(endDate).plusDays(1).format(dateFmt)

val args = mutableMapOf<String, Any>(
"reportPeriodStart" to LocalDate.parse(startDate).atStartOfDay(),
"reportPeriodEndExclusive" to LocalDate.parse(endExclusive).atStartOfDay(),
)
args.putAll(filterArgs)
val categorySql = buildCategoryItemFilter(stockCategory, args)

val moves = timed("1.ledger", { rows: List<LedgerMove> -> "rows=${rows.size}" }) {
jdbcDao.queryForList(
"""
SELECT
sl.id AS slId,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
TRIM(COALESCE(sl.type, '')) AS rawType,
sl.itemId AS itemId,
sl.itemCode AS itemCode,
COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
COALESCE(sl.balance, 0) AS bal,
sl.uomId AS ledgerUomId,
sl.inventoryLotLineId AS inventoryLotLineId,
sl.stockInLineId AS stockInLineId,
sl.stockOutLineId AS stockOutLineId
FROM stock_ledger sl ${itemPredicate.indexHint}
WHERE sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
""".trimIndent()
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
${itemPredicate.sql}
$categorySql
""".trimIndent(),
args,
).map { r ->
LedgerMove(
slId = toLong(r["slId"]),
trnDate = r["trnDate"]?.toString().orEmpty(),
rawType = r["rawType"]?.toString().orEmpty(),
itemId = toLong(r["itemId"]),
itemCode = r["itemCode"]?.toString().orEmpty(),
inQty = toDouble(r["inQty"]),
outQty = toDouble(r["outQty"]),
bal = toDouble(r["bal"]),
ledgerUomId = toLong(r["ledgerUomId"]),
lotLineId = toLong(r["inventoryLotLineId"]),
stockInLineId = toLong(r["stockInLineId"]),
stockOutLineId = toLong(r["stockOutLineId"]),
)
}
}
if (moves.isEmpty()) {
log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | "))
return emptyList()
}

val lineByIn = HashMap<Long, Pair<Long, Long>>()
val lineByOut = HashMap<Long, Long>()
timed("2.lot_keys", { _: Unit ->
"in=${lineByIn.size} out=${lineByOut.size}"
}) {
val missingIn = moves.filter { it.lotLineId <= 0 && it.stockInLineId > 0 }
.map { it.stockInLineId }
val missingOut = moves.filter { it.lotLineId <= 0 && it.stockOutLineId > 0 }
.map { it.stockOutLineId }
for (r in queryChunks(
missingIn,
"""
SELECT id, inventoryLotLineId, inventoryLotId
FROM stock_in_line
WHERE deleted = 0 AND id IN (:ids)
""".trimIndent(),
)) {
lineByIn[toLong(r["id"])] = toLong(r["inventoryLotLineId"]) to toLong(r["inventoryLotId"])
}
for (r in queryChunks(
missingOut,
"""
SELECT id, inventoryLotLineId
FROM stock_out_line
WHERE deleted = 0 AND id IN (:ids)
""".trimIndent(),
)) {
lineByOut[toLong(r["id"])] = toLong(r["inventoryLotLineId"])
}
}

data class LotKey(val lineId: Long, val lotId: Long)
val lotKeyBySl = HashMap<Long, LotKey>(moves.size * 2)
for (m in moves) {
val fromIn = lineByIn[m.stockInLineId]
val inLine = fromIn?.first ?: 0L
val inLot = fromIn?.second ?: 0L
val outLine = lineByOut[m.stockOutLineId] ?: 0L
val lineId = when {
m.lotLineId > 0 -> m.lotLineId
inLine > 0 -> inLine
inLot > 0 -> 0L
else -> outLine
}
val lotId = if (lineId <= 0 && inLot > 0) inLot else 0L
if (lineId > 0 || lotId > 0) {
lotKeyBySl[m.slId] = LotKey(lineId, lotId)
}
}

val lotByLine = HashMap<Long, LotInfo>()
val lotByHeader = HashMap<Long, LotInfo>()
timed("3.lots", { _: Unit -> "lines=${lotByLine.size} headers=${lotByHeader.size}" }) {
for (r in queryChunks(
lotKeyBySl.values.map { it.lineId },
"""
SELECT
ill.id AS lineId,
COALESCE(il.lotNo, '') AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
COALESCE(wh.code, '') AS storeLocation,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND il.deleted = 0
LEFT JOIN warehouse wh
ON wh.id = ill.warehouseId AND wh.deleted = 0
LEFT JOIN item_uom iu
ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
WHERE ill.deleted = 0
AND ill.id IN (:ids)
""".trimIndent(),
)) {
lotByLine[toLong(r["lineId"])] = LotInfo(
lotNo = r["lotNo"]?.toString().orEmpty(),
expiryDate = r["expiryDate"]?.toString().orEmpty(),
storeLocation = r["storeLocation"]?.toString().orEmpty(),
unitOfMeasure = r["unitOfMeasure"]?.toString().orEmpty(),
)
}
val headerIds = lotKeyBySl.values
.filter { it.lineId <= 0 && it.lotId > 0 }
.map { it.lotId }
for (r in queryChunks(
headerIds,
"""
SELECT
il.id AS lotId,
COALESCE(il.lotNo, '') AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
COALESCE(MAX(wh.code), '') AS storeLocation,
COALESCE(MAX(uc.udfudesc), MAX(uc.code), '') AS unitOfMeasure
FROM inventory_lot il
LEFT JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id AND ill.deleted = 0
LEFT JOIN warehouse wh
ON wh.id = ill.warehouseId AND wh.deleted = 0
LEFT JOIN item_uom iu
ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
WHERE il.deleted = 0
AND il.id IN (:ids)
GROUP BY il.id, il.lotNo, il.expiryDate
""".trimIndent(),
)) {
lotByHeader[toLong(r["lotId"])] = LotInfo(
lotNo = r["lotNo"]?.toString().orEmpty(),
expiryDate = r["expiryDate"]?.toString().orEmpty(),
storeLocation = r["storeLocation"]?.toString().orEmpty(),
unitOfMeasure = r["unitOfMeasure"]?.toString().orEmpty(),
)
}
}

val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull()
(minDateRow?.get("firstDate") as? String)
?: reportPeriodEnd
val uomDescById = HashMap<Long, String>()
val missingUomIds = ArrayList<Long>()
for (m in moves) {
val key = lotKeyBySl[m.slId] ?: continue
val lot = if (key.lineId > 0) lotByLine[key.lineId] else lotByHeader[key.lotId]
if (lot != null && lot.unitOfMeasure.isBlank() && m.ledgerUomId > 0) {
missingUomIds.add(m.ledgerUomId)
}
}
for (r in queryChunks(
missingUomIds,
"""
SELECT id AS uomId, COALESCE(udfudesc, code, '') AS unitOfMeasure
FROM uom_conversion
WHERE id IN (:ids)
""".trimIndent(),
)) {
uomDescById[toLong(r["uomId"])] = r["unitOfMeasure"]?.toString().orEmpty()
}

val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt)
args["reportPeriodStart"] = reportPeriodStart
args["reportPeriodEndExclusive"] = endExclusive
val locationFilter = storeLocation?.trim().orEmpty()
val withLot = ArrayList<MoveWithLot>(moves.size)
for (m in moves) {
val key = lotKeyBySl[m.slId] ?: continue
val lot = if (key.lineId > 0) lotByLine[key.lineId] else lotByHeader[key.lotId]
if (lot == null) continue
if (locationFilter.isNotEmpty() &&
!lot.storeLocation.contains(locationFilter, ignoreCase = true)
) {
continue
}
val uom = lot.unitOfMeasure.ifBlank { uomDescById[m.ledgerUomId].orEmpty() }
withLot.add(MoveWithLot(m, lot, uom))
}
timings.add("4.filter rows=${withLot.size}")
if (withLot.isEmpty()) {
log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | "))
return emptyList()
}

val stockCategorySql = buildMultiValueExactClause(
stockCategory,
"it.type",
"stockCategory",
args
data class ItemInfo(
val type: String,
val code: String,
val name: String,
)
val items = HashMap<Long, ItemInfo>()
timed("5.items", { _: Unit -> "items=${items.size}" }) {
for (r in queryChunks(
withLot.map { it.move.itemId },
"""
SELECT
it.id AS itemId,
COALESCE(it.type, '') AS stockSubCategory,
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName
FROM items it
WHERE it.deleted = 0
AND it.id IN (:ids)
""".trimIndent(),
)) {
items[toLong(r["itemId"])] = ItemInfo(
type = r["stockSubCategory"]?.toString().orEmpty(),
code = r["itemNo"]?.toString().orEmpty(),
name = r["itemName"]?.toString().orEmpty(),
)
}
}

data class InRef(val orderRefNo: String?, val jobOrderNo: String, val stockTake: Boolean)
data class OutRef(val orderRefNo: String?, val stockTake: Boolean)
val inRefs = HashMap<Long, InRef>()
val outRefs = HashMap<Long, OutRef>()
timed("6.refs", { _: Unit -> "in=${inRefs.size} out=${outRefs.size}" }) {
for (r in queryChunks(
withLot.map { it.move.stockOutLineId },
"""
SELECT
sol.id AS lineId,
CASE
WHEN po_out.type = 'consumable' THEN TRIM(po_out.code)
ELSE COALESCE(TRIM(do.code), TRIM(jo_po.code))
END AS orderRefNo,
CASE
WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1
ELSE 0
END AS isStockTake
FROM stock_out_line sol
LEFT JOIN stock_out so
ON so.id = sol.stockOutId AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON pol.id = sol.pickOrderLineId AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON po_out.id = pol.poId AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON jo_po.id = po_out.joId AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON do.id = po_out.doId AND do.deleted = 0
WHERE sol.deleted = 0
AND sol.id IN (:ids)
""".trimIndent(),
)) {
outRefs[toLong(r["lineId"])] = OutRef(
orderRefNo = r["orderRefNo"]?.toString(),
stockTake = toLong(r["isStockTake"]) == 1L,
)
}
for (r in queryChunks(
withLot.map { it.move.stockInLineId },
"""
SELECT
sil.id AS lineId,
COALESCE(TRIM(jo.code), TRIM(po.code)) AS orderRefNo,
COALESCE(TRIM(jo.code), '') AS jobOrderNo,
CASE WHEN si.stockTakeId IS NOT NULL THEN 1 ELSE 0 END AS isStockTake
FROM stock_in_line sil
LEFT JOIN stock_in si
ON si.id = sil.stockInId AND si.deleted = 0
LEFT JOIN job_order jo
ON jo.id = sil.jobOrderId AND jo.deleted = 0
LEFT JOIN purchase_order po
ON po.id = sil.purchaseOrderId AND po.deleted = 0
WHERE sil.deleted = 0
AND sil.id IN (:ids)
""".trimIndent(),
)) {
inRefs[toLong(r["lineId"])] = InRef(
orderRefNo = r["orderRefNo"]?.toString(),
jobOrderNo = r["jobOrderNo"]?.toString().orEmpty(),
stockTake = toLong(r["isStockTake"]) == 1L,
)
}
}

val totals = HashMap<String, ItemTotal>()
for (row in withLot) {
val m = row.move
val total = totals.getOrPut("${m.itemCode}|${row.unitOfMeasure}") { ItemTotal() }
total.inSum += m.inQty
total.outSum += m.outQty
val opening = m.bal - m.inQty + m.outQty
if (total.isBeforeFirst(m.trnDate, m.slId)) {
total.firstDate = m.trnDate
total.firstId = m.slId
total.opening = opening
}
if (total.isAfterLast(m.trnDate, m.slId)) {
total.lastDate = m.trnDate
total.lastId = m.slId
total.closing = m.bal
}
}

val rows = timed("7.assemble", { list: List<Map<String, Any>> -> "rows=${list.size}" }) {
val sorted = withLot.sortedWith(
compareBy<MoveWithLot> { items[it.move.itemId]?.code.orEmpty() }
.thenBy { it.unitOfMeasure }
.thenBy { it.move.trnDate }
.thenBy { it.move.slId }
.thenBy { it.lot.lotNo },
)
sorted.map { row ->
val m = row.move
val item = items[m.itemId]
val inRef = inRefs[m.stockInLineId]
val outRef = outRefs[m.stockOutLineId]
val norm = m.rawType.uppercase()
val refType = when {
norm == "TKE" -> "TKE"
norm == "ADJ" && (outRef?.stockTake == true || inRef?.stockTake == true) -> "TKE"
else -> m.rawType
}
val total = totals["${m.itemCode}|${row.unitOfMeasure}"]
reportRow(
stockSubCategory = item?.type.orEmpty(),
itemNo = item?.code.orEmpty(),
itemName = item?.name.orEmpty(),
unitOfMeasure = row.unitOfMeasure,
lotNo = row.lot.lotNo,
expiryDate = row.lot.expiryDate,
trnDate = m.trnDate,
trnRefNo = typeLabel(refType),
storeLocation = row.lot.storeLocation,
orderRefNo = outRef?.orderRefNo ?: inRef?.orderRefNo ?: "",
jobOrderNo = inRef?.jobOrderNo.orEmpty(),
inQty = m.inQty,
outQty = m.outQty,
opening = m.bal - m.inQty + m.outQty,
balance = m.bal,
totalOpening = total?.opening ?: 0.0,
totalIn = total?.inSum ?: 0.0,
totalOut = total?.outSum ?: 0.0,
totalBalance = total?.closing ?: 0.0,
)
}
}

val totalMs = (System.nanoTime() - tAll) / 1_000_000
timings.add("search.total ${totalMs}ms")
log.info("stock-ledger DONE {}", timings.joinToString(" | "))
return rows
}

private fun reportRow(
stockSubCategory: String,
itemNo: String,
itemName: String,
unitOfMeasure: String,
lotNo: String,
expiryDate: String,
trnDate: String,
trnRefNo: String,
storeLocation: String,
orderRefNo: String,
jobOrderNo: String,
inQty: Double,
outQty: Double,
opening: Double,
balance: Double,
totalOpening: Double,
totalIn: Double,
totalOut: Double,
totalBalance: Double,
): Map<String, Any> {
val row = HashMap<String, Any>(32)
row["stockSubCategory"] = stockSubCategory
row["itemNo"] = itemNo
row["itemName"] = itemName
row["unitOfMeasure"] = unitOfMeasure
row["lotNo"] = lotNo
row["expiryDate"] = expiryDate
row["trnDate"] = trnDate
row["trnRefNo"] = trnRefNo
row["storeLocation"] = storeLocation
row["orderRefNo"] = orderRefNo
row["jobOrderNo"] = jobOrderNo
row["openingBalance"] = ""
row["cumStockIn"] = ""
row["cumStockOut"] = ""
row["currentBalance"] = ""
row["lastInDate"] = ""
row["lastOutDate"] = ""
row["reOrderLevel"] = ""
row["reOrderQty"] = ""
row["stockIn"] = formatQty(inQty)
row["stockOut"] = formatQty(outQty)
row["cumOpeningBal"] = formatQty(opening)
row["cumBalance"] = formatQty(balance)
row["totalCumOpeningBal"] = formatQty(totalOpening)
row["totalStockIn"] = formatQty(totalIn)
row["totalStockOut"] = formatQty(totalOut)
row["totalCumBalance"] = formatQty(totalBalance)
return row
}

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

/** Same shape as MySQL FORMAT(n, 0), negatives in parentheses. */
private fun formatQty(value: Double): String {
val rounded = BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP)
val negative = rounded.signum() < 0
val digits = rounded.abs().toPlainString()
val grouped = digits.reversed().chunked(3).joinToString(",").reversed()
return if (negative) "($grouped)" else grouped
}

private fun buildCategoryItemFilter(
stockCategory: String?,
args: MutableMap<String, Any>,
): String {
if (stockCategory.isNullOrBlank()) return ""
val values = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() }
if (values.isEmpty()) return ""
val ors = values.mapIndexed { index, value ->
val name = "stockCategory_$index"
args[name] = value
"it.type = :$name"
}
return """
AND sl.itemId IN (
SELECT it.id FROM items it
WHERE it.deleted = 0 AND (${ors.joinToString(" OR ")})
)
""".trimIndent()
}

val itemCodeSql = buildItemCodeFilterClause(
itemCode,
"sl.itemCode",
"itemCode",
args
private fun queryChunks(ids: Collection<Long>, sql: String): List<Map<String, Any>> {
val distinct = ids.filter { it > 0 }.distinct()
if (distinct.isEmpty()) return emptyList()
val out = ArrayList<Map<String, Any>>()
for (chunk in distinct.chunked(800)) {
out.addAll(jdbcDao.queryForList(sql, mapOf("ids" to chunk)))
}
return out
}

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

// 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數
val storeLocationSql = if (!storeLocation.isNullOrBlank()) {
args["storeLocation"] = "%$storeLocation%"
"AND lot.storeLocation LIKE :storeLocation"
} else {
""
private fun lookupItemIds(codes: List<String>): List<Long> {
if (codes.isEmpty()) return emptyList()
val args = HashMap<String, Any>(codes.size)
val names = codes.mapIndexed { index, code ->
val name = "lookupCode_$index"
args[name] = code
":$name"
}
val sql = """
SELECT id AS itemId
FROM items
WHERE code IN (${names.joinToString(", ")})
""".trimIndent()
return jdbcDao.queryForList(sql, args).mapNotNull { row ->
when (val id = row["itemId"]) {
is Number -> id.toLong()
else -> id?.toString()?.toLongOrNull()
}
}.distinct()
}

/** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */
private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map<String, Any>): String? {
val sql = """
SELECT
stockSubCategory,
itemNo,
itemName,
unitOfMeasure,
lotNo,
expiryDate,
trnDate,
CASE trnRefNoRaw
WHEN 'OPEN' THEN '開倉'
WHEN 'NOR' THEN '出入倉'
WHEN 'TRF' THEN '轉倉'
WHEN 'ADJ' THEN '調整'
WHEN 'MISS' THEN '遺失'
WHEN 'BAD' THEN '不良品'
WHEN 'TKE' THEN '盤點'
ELSE trnRefNoRaw
END AS trnRefNo,
storeLocation,
orderRefNo,
jobOrderNo,

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

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

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

CASE WHEN totalCumOpeningBalRaw < 0
THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')')
ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal,
CASE WHEN totalStockInRaw < 0
THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')')
ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn,
CASE WHEN totalStockOutRaw < 0
THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')')
ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut,
CASE WHEN totalCumBalanceRaw < 0
THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')')
ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance

FROM (
SELECT
x.*,
FIRST_VALUE(cumOpeningBalRaw) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw, slId
) AS totalCumOpeningBalRaw,
SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw,
SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw,
FIRST_VALUE(bal) OVER (
PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC
) AS totalCumBalanceRaw
FROM (
SELECT
sl.id AS slId,
DATE(sl.date) AS trnDateRaw,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
CASE
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE'
WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ'
AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE'
ELSE COALESCE(sl.type, '')
END AS trnRefNoRaw,

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

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

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

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

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

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

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

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

LEFT JOIN stock_out so
ON sol.stockOutId = so.id
AND so.deleted = 0
LEFT JOIN pick_order_line pol
ON sol.pickOrderLineId = pol.id
AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON pol.poId = po_out.id
AND po_out.deleted = 0
LEFT JOIN job_order jo_po
ON po_out.joId = jo_po.id
AND jo_po.deleted = 0
LEFT JOIN delivery_order do
ON po_out.doId = do.id
AND do.deleted = 0
LEFT JOIN stock_in si
ON sil.stockInId = si.id
AND si.deleted = 0
LEFT JOIN job_order jo
ON sil.jobOrderId = jo.id
AND jo.deleted = 0
LEFT JOIN purchase_order po
ON sil.purchaseOrderId = po.id
AND po.deleted = 0
WHERE
sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
$stockCategorySql
$itemCodeSql
$storeLocationSql
AND lot.lotId IS NOT NULL
) x
) y
ORDER BY
itemNo,
trnDateRaw,
slId,
lotNo
""".trimIndent()

return jdbcDao.queryForList(sql, args)
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl ${predicate.indexHint}
WHERE sl.deleted = 0
AND sl.itemCode IS NOT NULL
AND sl.itemCode <> ''
${predicate.sql}
""".trimIndent()
val row = jdbcDao.queryForList(sql, args).firstOrNull()
return row?.get("firstDate") as? String
}

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

/** Full codes use equality; short tokens use prefix LIKE. */
private fun buildItemCodeFilterClause(
paramValue: String?,
columnName: String,
paramPrefix: String,
args: MutableMap<String, Any>
args: MutableMap<String, Any>,
): String {
val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue)
if (exact.isEmpty() && fuzzy.isEmpty()) return ""

val parts = mutableListOf<String>()
exact.forEachIndexed { index, value ->
val paramName = "${paramPrefix}_eq_$index"
@@ -302,22 +630,64 @@ ORDER BY
return "AND (${parts.joinToString(" OR ")})"
}

/** = 多值工具方法 */
private fun buildMultiValueExactClause(
paramValue: String?,
columnName: String,
paramPrefix: String,
args: MutableMap<String, Any>
): String {
if (paramValue.isNullOrBlank()) return ""
val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() }
if (values.isEmpty()) return ""
private fun toDouble(v: Any?): Double {
if (v == null) return 0.0
if (v is Number) return v.toDouble()
return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0
}

val conditions = values.mapIndexed { index, value ->
val paramName = "${paramPrefix}_$index"
args[paramName] = value
"$columnName = :$paramName"
private fun toLong(v: Any?): Long {
if (v == null) return 0L
if (v is Number) return v.toLong()
return v.toString().toLongOrNull() ?: 0L
}

private data class LedgerMove(
val slId: Long,
val trnDate: String,
val rawType: String,
val itemId: Long,
val itemCode: String,
val inQty: Double,
val outQty: Double,
val bal: Double,
val ledgerUomId: Long,
val lotLineId: Long,
val stockInLineId: Long,
val stockOutLineId: Long,
)

private data class MoveWithLot(
val move: LedgerMove,
val lot: LotInfo,
val unitOfMeasure: String,
)

private data class LotInfo(
val lotNo: String,
val expiryDate: String,
val storeLocation: String,
val unitOfMeasure: String,
)

private class ItemTotal {
var firstDate: String? = null
var firstId: Long = 0L
var lastDate: String? = null
var lastId: Long = 0L
var opening: Double = 0.0
var closing: Double = 0.0
var inSum: Double = 0.0
var outSum: Double = 0.0

fun isBeforeFirst(date: String, id: Long): Boolean {
val current = firstDate ?: return true
return date < current || (date == current && id < firstId)
}

fun isAfterLast(date: String, id: Long): Boolean {
val current = lastDate ?: return true
return date > current || (date == current && id > lastId)
}
return "AND (${conditions.joinToString(" OR ")})"
}
}

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

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

import com.ffii.core.support.JdbcDao
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.util.Locale

/**
* 庫存批次結餘:永遠今天。現存讀 [inventory_lot_line](inQty-outQty),
* 當天入/出/MISS/BAD/過期/盤盈虧讀當天 [stock_ledger]。
*/
@Service
open class StockLotBalanceReportService(
private val jdbcDao: JdbcDao,
) {
private val log = LoggerFactory.getLogger(StockLotBalanceReportService::class.java)
private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd")

data class TimedResult(
val rows: List<Map<String, Any>>,
val timings: List<String>,
val stockDate: String,
)

fun searchStockLotBalanceReport(
stockDate: String?,
itemCode: String?,
warehouseCode: String?,
storeId: String?,
lotNo: String?,
): List<Map<String, Any>> = searchStockLotBalanceReportTimed(
stockDate, itemCode, warehouseCode, storeId, lotNo,
).rows

fun searchStockLotBalanceReportTimed(
stockDate: String?,
itemCode: String?,
warehouseCode: String?,
storeId: String?,
lotNo: String?,
): TimedResult {
val asOfDate = LocalDate.now()
val asOfDateStr = asOfDate.format(dateFmt)
if (!stockDate.isNullOrBlank() && stockDate.replace("/", "-") != asOfDateStr) {
log.info("stock-lot-balance ignore stockDate={} use today={}", stockDate, asOfDateStr)
}

val timings = mutableListOf<String>()
val tAll = System.nanoTime()
fun <T> timed(name: String, extra: (T) -> String, block: () -> T): T {
val t0 = System.nanoTime()
return try {
val r = block()
val ms = (System.nanoTime() - t0) / 1_000_000
val line = "$name ${extra(r)} ${ms}ms"
timings.add(line)
log.info("stock-lot-balance {}", line)
r
} catch (e: Exception) {
val ms = (System.nanoTime() - t0) / 1_000_000
log.warn("stock-lot-balance {} FAILED {}ms: {}", name, ms, e.message)
timings.add("$name FAILED ${ms}ms")
throw e
}
}

val args = mutableMapOf<String, Any>(
"d0Start" to asOfDate.atStartOfDay(),
"d0EndExclusive" to asOfDate.plusDays(1).atStartOfDay(),
)
val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args)
val forceLedgerIndex = if (itemCode.isNullOrBlank()) {
"FORCE INDEX (idx_sl_deleted_date)"
} else {
""
}

val masterArgs = HashMap<String, Any>()
val itemCodeSqlIt = buildMultiValueLikeClause(itemCode, "it.code", "itemCodeIt", masterArgs)
val lotNoSql = buildMultiValueLikeClause(lotNo, "il.lotNo", "lotNo", masterArgs)
val warehouseCodeSql = if (!warehouseCode.isNullOrBlank() && warehouseCode.trim() != "All") {
buildMultiValueLikeClause(warehouseCode, "wh.code", "warehouseCode", masterArgs)
} else {
""
}
val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") {
masterArgs["storeId"] = storeId.trim()
"AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')"
} else {
""
}
val masterSelectSql = """
SELECT
ill.id AS inventoryLotLineId,
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
COALESCE(il.lotNo, '') AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
COALESCE(wh.code, '') AS warehouseCode,
COALESCE(wh.store_id, '') AS storeId,
(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS currentQty,
COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND il.deleted = 0
INNER JOIN items it
ON it.id = il.itemId AND it.deleted = 0
INNER JOIN warehouse wh
ON wh.id = ill.warehouseId AND wh.deleted = 0
LEFT JOIN item_uom iu
ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
WHERE ill.deleted = 0
AND it.code IS NOT NULL AND it.code <> ''
$itemCodeSqlIt
$warehouseCodeSql
$storeIdSql
$lotNoSql
""".trimIndent()

val liveLots = timed("1.live_lots", { rows: List<Map<String, Any>> -> "rows=${rows.size}" }) {
jdbcDao.queryForList(
"""
$masterSelectSql
AND (COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) <> 0
""".trimIndent(),
masterArgs,
)
}
val masterByLot = HashMap<Long, Map<String, Any>>(liveLots.size * 2)
for (r in liveLots) {
masterByLot[toLong(r["inventoryLotLineId"])] = r
}

val ledgerRows = timed("2.day_ledger", { rows: List<Map<String, Any>> -> "rows=${rows.size}" }) {
jdbcDao.queryForList(
"""
SELECT
sl.inventoryLotLineId,
sl.id AS slId,
sl.type,
COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
sl.lotQtyBefore,
sl.lotQtyAfter
FROM stock_ledger sl $forceLedgerIndex
WHERE sl.deleted = 0
AND sl.date >= :d0Start
AND sl.date < :d0EndExclusive
AND sl.inventoryLotLineId IS NOT NULL
$itemCodeSqlSl
""".trimIndent(),
args,
)
}

val movesByLot = timed("3.agg_types", { map: Map<Long, LotDayAgg> -> "lots=${map.size}" }) {
aggregateDayMoves(ledgerRows)
}

val missingMoveIds = movesByLot.keys.filter { it !in masterByLot }
timed("4.zero_lots", { rows: List<Map<String, Any>> -> "rows=${rows.size} missing=${missingMoveIds.size}" }) {
if (missingMoveIds.isEmpty()) emptyList()
else queryLotIdChunks(missingMoveIds, masterArgs) { chunkArgs ->
jdbcDao.queryForList(
"""
$masterSelectSql
AND ill.id IN (:lotIds)
""".trimIndent(),
chunkArgs,
)
}
}.forEach { r ->
masterByLot[toLong(r["inventoryLotLineId"])] = r
}

val rows = timed("5.assemble", { list: List<Map<String, Any>> -> "rows=${list.size}" }) {
assembleRows(movesByLot, masterByLot)
}

val totalMs = (System.nanoTime() - tAll) / 1_000_000
timings.add("search.total ${totalMs}ms")
log.info(
"stock-lot-balance DONE stockDate={} itemCode={} {}",
asOfDateStr,
itemCode ?: "",
timings.joinToString(" | "),
)
return TimedResult(rows = rows, timings = timings, stockDate = asOfDateStr)
}

private data class LotDayAgg(
var cumStockIn: Double = 0.0,
var cumStockOut: Double = 0.0,
var misInputAndLost: Double = 0.0,
var variance: Double = 0.0,
var defectiveGoods: Double = 0.0,
var expiredDisposed: Double = 0.0,
var firstSlId: Long = Long.MAX_VALUE,
var lastSlId: Long = Long.MIN_VALUE,
var firstQtyBefore: Double? = null,
var lastQtyAfter: Double? = null,
) {
fun hasMovement(): Boolean =
cumStockIn != 0.0 ||
cumStockOut != 0.0 ||
misInputAndLost != 0.0 ||
variance != 0.0 ||
defectiveGoods != 0.0 ||
expiredDisposed != 0.0
}

private fun aggregateDayMoves(ledgerRows: List<Map<String, Any>>): Map<Long, LotDayAgg> {
val byLot = HashMap<Long, LotDayAgg>()
for (r in ledgerRows) {
val lotId = toLong(r["inventoryLotLineId"])
if (lotId <= 0) continue
val slId = toLong(r["slId"])
val type = r["type"]?.toString()?.trim()?.uppercase().orEmpty()
val inQty = toDouble(r["inQty"])
val outQty = toDouble(r["outQty"])
val agg = byLot.getOrPut(lotId) { LotDayAgg() }
when (type) {
"NOR", "ADJ", "TRF", "OPEN" ->
if (inQty > 0) agg.cumStockIn += inQty
}
when (type) {
"NOR", "ADJ", "TRF" ->
if (outQty > 0) agg.cumStockOut += outQty
}
if (type == "MISS" && outQty > 0) agg.misInputAndLost += outQty
if (type == "BAD" && outQty > 0) agg.defectiveGoods += outQty
if (type == "EXPIRY" && outQty > 0) agg.expiredDisposed += outQty
if (type == "TKE") agg.variance += inQty - outQty
if (slId > 0 && slId < agg.firstSlId) {
agg.firstSlId = slId
agg.firstQtyBefore = r["lotQtyBefore"]?.let { toDouble(it) }
}
if (slId > 0 && slId > agg.lastSlId) {
agg.lastSlId = slId
agg.lastQtyAfter = r["lotQtyAfter"]?.let { toDouble(it) }
}
}
return byLot
}

private fun assembleRows(
movesByLot: Map<Long, LotDayAgg>,
masterByLot: Map<Long, Map<String, Any>>,
): List<Map<String, Any>> {
val lotIds = LinkedHashSet<Long>(masterByLot.size)
lotIds.addAll(masterByLot.keys)
val out = ArrayList<Map<String, Any>>(lotIds.size)
for (lotId in lotIds) {
val master = masterByLot[lotId] ?: continue
val move = movesByLot[lotId]
val current = toDouble(master["currentQty"])
val opening = move?.firstQtyBefore ?: current
val keep = current != 0.0 || (move?.hasMovement() == true)
if (!keep) continue
out.add(buildRow(lotId, master, opening, current, move))
}
out.sortWith(
compareBy<Map<String, Any>> { it["itemNo"]?.toString().orEmpty() }
.thenBy { it["lotNo"]?.toString().orEmpty() }
.thenBy { it["warehouseCode"]?.toString().orEmpty() }
.thenBy { it["inventoryLotLineId"]?.toString().orEmpty() },
)
return out
}

private fun buildRow(
lotId: Long,
master: Map<String, Any>,
opening: Double,
current: Double,
move: LotDayAgg?,
): Map<String, Any> {
val cumIn = move?.cumStockIn ?: 0.0
val cumOut = move?.cumStockOut ?: 0.0
val miss = move?.misInputAndLost ?: 0.0
val variance = move?.variance ?: 0.0
val defective = move?.defectiveGoods ?: 0.0
val expired = move?.expiredDisposed ?: 0.0
val avg = toDouble(master["avgUnitPriceRaw"])
val stockValue = avg * current
val row = HashMap<String, Any>(24)
row["inventoryLotLineId"] = lotId.toString()
row["itemNo"] = master["itemNo"] ?: ""
row["itemName"] = master["itemName"] ?: ""
row["unitOfMeasure"] = master["unitOfMeasure"] ?: ""
row["lotNo"] = master["lotNo"] ?: ""
row["expiryDate"] = master["expiryDate"] ?: ""
row["warehouseCode"] = master["warehouseCode"] ?: ""
row["storeId"] = master["storeId"] ?: ""
row["storeLocation"] = master["warehouseCode"] ?: ""
row["openingBalanceRaw"] = opening
row["cumStockInRaw"] = cumIn
row["cumStockOutRaw"] = cumOut
row["misInputAndLostRaw"] = miss
row["varianceRaw"] = variance
row["defectiveGoodsRaw"] = defective
row["expiredDisposedRaw"] = expired
row["currentBalanceRaw"] = current
row["avgUnitPriceRaw"] = avg
row["totalOpeningBalance"] = formatQty(opening)
row["totalCumStockIn"] = formatQty(cumIn)
row["totalCumStockOut"] = formatQty(cumOut)
row["totalMisInputAndLost"] = formatQty(miss)
row["totalVariance"] = formatQty(variance)
row["totalDefectiveGoods"] = formatQty(defective)
row["totalExpiredDisposed"] = formatQty(expired)
row["totalCurrentBalance"] = formatQty(current)
row["avgUnitPrice"] = formatQty(avg)
row["totalStockBalance"] = formatQty(stockValue)
return row
}

private fun queryLotIdChunks(
lotIds: Collection<Long>,
baseArgs: Map<String, Any>,
query: (Map<String, Any>) -> List<Map<String, Any>>,
): List<Map<String, Any>> {
val ids = lotIds.filter { it > 0 }.distinct()
if (ids.isEmpty()) return emptyList()
val out = ArrayList<Map<String, Any>>()
for (chunk in ids.chunked(800)) {
val chunkArgs = HashMap<String, Any>(baseArgs)
chunkArgs["lotIds"] = chunk
out.addAll(query(chunkArgs))
}
return out
}

private fun formatQty(n: Double): String {
val body = String.format(Locale.US, "%,.2f", kotlin.math.abs(n))
return if (n < 0) "($body)" else body
}

private fun toDouble(v: Any?): Double {
if (v == null) return 0.0
if (v is Number) return v.toDouble()
return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0
}

private fun toLong(v: Any?): Long {
if (v == null) return 0L
if (v is Number) return v.toLong()
return v.toString().toLongOrNull() ?: 0L
}

private fun buildMultiValueLikeClause(
paramValue: String?,
columnName: String,
paramPrefix: String,
args: MutableMap<String, Any>,
): String {
if (paramValue.isNullOrBlank()) return ""
val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() }
if (values.isEmpty()) return ""
val conditions = values.mapIndexed { index, value ->
val paramName = "${paramPrefix}_$index"
args[paramName] = "%$value%"
"$columnName LIKE :$paramName"
}
return "AND (${conditions.joinToString(" OR ")})"
}
}

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

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

import com.ffii.core.support.JdbcDao
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.format.DateTimeFormatter

/**
* 庫存流水帳:期初讀 as-of 當天 [stock_lot_day](2.6 carry),期間明細讀 stock_ledger。
*/
@Service
open class StockLotLedgerReportService(
private val jdbcDao: JdbcDao,
) {
private val log = LoggerFactory.getLogger(StockLotLedgerReportService::class.java)

data class LedgerPeriod(
val start: LocalDate,
val end: LocalDate,
) {
val startStr: String get() = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val endStr: String get() = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val asOfStr: String get() = start.minusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val endExclusiveStr: String get() = end.plusDays(1).format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
val isValid: Boolean get() = !end.isBefore(start)
}

data class TimedResult(
val rows: List<Map<String, Any>>,
val timings: List<String>,
val period: LedgerPeriod,
)

fun resolvePeriod(startRaw: String?, endRaw: String?): LedgerPeriod {
val end = if (!endRaw.isNullOrBlank()) {
LocalDate.parse(endRaw.replace("/", "-"))
} else {
LocalDate.now()
}
val startParsed = if (!startRaw.isNullOrBlank()) {
LocalDate.parse(startRaw.replace("/", "-"))
} else {
end.withDayOfMonth(1)
}
return LedgerPeriod(start = startParsed.withDayOfMonth(1), end = end)
}

/** First day of the earliest month that has a stock_ledger row. */
fun earliestLedgerMonth(): LocalDate {
val row = jdbcDao.queryForList(
"""
SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate
FROM stock_ledger sl
WHERE sl.deleted = 0
""".trimIndent(),
emptyMap<String, Any>(),
).firstOrNull()
val raw = row?.get("firstDate")?.toString()?.trim().orEmpty()
val parsed = raw.takeIf { it.isNotEmpty() && !it.equals("null", ignoreCase = true) }
?.let { runCatching { LocalDate.parse(it.take(10)) }.getOrNull() }
return (parsed ?: LocalDate.now()).withDayOfMonth(1)
}

fun searchStockLotLedgerReport(
itemCode: String?,
reportPeriodStart: String?,
reportPeriodEnd: String?,
): List<Map<String, Any>> = searchStockLotLedgerReportTimed(
itemCode, reportPeriodStart, reportPeriodEnd,
).rows

fun searchStockLotLedgerReportTimed(
itemCode: String?,
reportPeriodStart: String?,
reportPeriodEnd: String?,
): TimedResult {
val period = resolvePeriod(reportPeriodStart, reportPeriodEnd)
if (!period.isValid) {
return TimedResult(emptyList(), listOf("invalid-period 0ms"), period)
}

val timings = mutableListOf<String>()
val tAll = System.nanoTime()
fun <T> timed(name: String, extra: (T) -> String, block: () -> T): T {
val t0 = System.nanoTime()
return try {
val r = block()
val ms = (System.nanoTime() - t0) / 1_000_000
val note = extra(r)
val line = "$name $note ${ms}ms"
timings.add(line)
log.info("stock-lot-ledger {}", line)
r
} catch (e: Exception) {
val ms = (System.nanoTime() - t0) / 1_000_000
log.warn("stock-lot-ledger {} FAILED {}ms: {}", name, ms, e.message)
timings.add("$name FAILED ${ms}ms")
throw e
}
}

val args = mutableMapOf<String, Any>(
"reportPeriodStart" to period.start.atStartOfDay(),
"reportPeriodEndExclusive" to period.end.plusDays(1).atStartOfDay(),
"asOfDate" to period.asOfStr,
)
val itemCodeSqlSl = buildMultiValueLikeClause(itemCode, "sl.itemCode", "itemCodeSl", args)
val itemCodeSqlDay = buildMultiValueLikeClause(itemCode, "d.itemCode", "itemCodeDay", args)

val forceLedgerIndex = if (itemCode.isNullOrBlank()) {
"FORCE INDEX (idx_sl_deleted_date)"
} else {
""
}

val dayOpen = timed("1.day_open", { rows: List<Map<String, Any>> -> "rows=${rows.size}" }) {
jdbcDao.queryForList(
"""
SELECT d.inventoryLotLineId, d.closing AS openingQty
FROM stock_lot_day d
WHERE IFNULL(d.deleted, 0) = 0
AND d.date = :asOfDate
AND d.closing <> 0
$itemCodeSqlDay
""".trimIndent(),
args,
)
}
val dayOpenByLot = HashMap<Long, Double>(dayOpen.size * 2)
for (r in dayOpen) {
dayOpenByLot[toLong(r["inventoryLotLineId"])] = toDouble(r["openingQty"])
}

val ledgerRows = timed("2a.period_ledger", { rows: List<Map<String, Any>> -> "rows=${rows.size}" }) {
jdbcDao.queryForList(
"""
SELECT
sl.inventoryLotLineId,
sl.id AS slId,
DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate,
COALESCE(sl.inQty, 0) AS inQty,
COALESCE(sl.outQty, 0) AS outQty,
sl.lotQtyAfter AS lotQtyAfter,
sl.lotQtyBefore AS lotQtyBefore,
sl.stockInLineId,
sl.stockOutLineId
FROM stock_ledger sl $forceLedgerIndex
WHERE sl.deleted = 0
AND sl.date >= :reportPeriodStart
AND sl.date < :reportPeriodEndExclusive
AND sl.inventoryLotLineId IS NOT NULL
$itemCodeSqlSl
""".trimIndent(),
args,
)
}

val refBySlId = timed("2b.ref_docs", { map: Map<Long, String> -> "refs=${map.size}" }) {
loadRefDocs(ledgerRows)
}

val moves = timed("2c.attach_refs", { rows: List<Map<String, Any>> -> "rows=${rows.size}" }) {
ledgerRows.map { r ->
val out = HashMap(r)
val slId = toLong(r["slId"])
out["orderRefNo"] = refBySlId[slId] ?: ""
out
}
}

val periodLotIds = LinkedHashSet<Long>()
val firstMoveByLot = HashMap<Long, Double>()
val firstMoveKey = HashMap<Long, Pair<String, Long>>()
for (r in moves) {
val lotId = toLong(r["inventoryLotLineId"])
if (lotId <= 0) continue
periodLotIds.add(lotId)
if (lotId in dayOpenByLot) continue
val date = r["trnDate"]?.toString().orEmpty()
val slId = toLong(r["slId"])
val prev = firstMoveKey[lotId]
if (prev == null || date < prev.first || (date == prev.first && slId < prev.second)) {
firstMoveKey[lotId] = date to slId
firstMoveByLot[lotId] = toDouble(r["lotQtyBefore"])
}
}
timings.add("3.first_move derived lots=${firstMoveByLot.size} 0ms")
log.info("stock-lot-ledger 3.first_move derived lots={} 0ms", firstMoveByLot.size)

val eligibleLotIds = LinkedHashSet<Long>(dayOpenByLot.size + periodLotIds.size)
eligibleLotIds.addAll(dayOpenByLot.keys)
eligibleLotIds.addAll(periodLotIds)

val masterByLot = HashMap<Long, Map<String, Any>>(eligibleLotIds.size * 2)
timed("4.master", { rows: List<Map<String, Any>> -> "rows=${rows.size} lots=${eligibleLotIds.size}" }) {
if (eligibleLotIds.isEmpty()) emptyList()
else queryLotIdChunks(eligibleLotIds.toList(), emptyMap()) { chunkArgs ->
jdbcDao.queryForList(
"""
SELECT
ill.id AS inventoryLotLineId,
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName,
COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure,
COALESCE(il.lotNo, '') AS lotNo,
COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate,
iu.uomId AS lotUomId
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND il.deleted = 0
INNER JOIN items it
ON it.id = il.itemId AND it.deleted = 0
LEFT JOIN item_uom iu
ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON uc.id = iu.uomId
WHERE ill.deleted = 0
AND ill.id IN (:lotIds)
AND it.code IS NOT NULL AND it.code <> ''
""".trimIndent(),
chunkArgs,
)
}
}.forEach { r ->
masterByLot[toLong(r["inventoryLotLineId"])] = r
}

val assembled = timed("5.assemble", { rows: List<Map<String, Any>> -> "rows=${rows.size}" }) {
assembleRows(
asOfStr = period.asOfStr,
dayOpenByLot = dayOpenByLot,
firstMoveByLot = firstMoveByLot,
moves = moves,
masterByLot = masterByLot,
)
}

val rows = timed("6.totals", { list: List<Map<String, Any>> -> "rows=${list.size}" }) {
attachItemUomTotals(assembled)
}

val totalMs = (System.nanoTime() - tAll) / 1_000_000
timings.add("search.total ${totalMs}ms")
log.info(
"stock-lot-ledger DONE period={}..{} asOf={} itemCode={} {}",
period.startStr,
period.endStr,
period.asOfStr,
itemCode ?: "",
timings.joinToString(" | "),
)
return TimedResult(rows = rows, timings = timings, period = period)
}

private fun assembleRows(
asOfStr: String,
dayOpenByLot: Map<Long, Double>,
firstMoveByLot: Map<Long, Double>,
moves: List<Map<String, Any>>,
masterByLot: Map<Long, Map<String, Any>>,
): List<Map<String, Any>> {
val movesByLot = LinkedHashMap<Long, MutableList<Map<String, Any>>>()
for (m in moves) {
val lotId = toLong(m["inventoryLotLineId"])
if (lotId !in masterByLot) continue
movesByLot.getOrPut(lotId) { mutableListOf() }.add(m)
}

val openingLots = LinkedHashSet<Long>()
openingLots.addAll(dayOpenByLot.keys)
openingLots.addAll(firstMoveByLot.keys)
openingLots.addAll(movesByLot.keys)

val out = ArrayList<Map<String, Any>>(openingLots.size + moves.size)
for (lotId in openingLots) {
val master = masterByLot[lotId] ?: continue
val opening = dayOpenByLot[lotId] ?: firstMoveByLot[lotId] ?: 0.0
out.add(openingRow(lotId, asOfStr, opening, master))
val lotMoves = (movesByLot[lotId] ?: continue).sortedWith(
compareBy(
{ it["trnDate"]?.toString().orEmpty() },
{ toLong(it["slId"]) },
),
)
var run = opening
for (m in lotMoves) {
val inQty = toDouble(m["inQty"])
val outQty = toDouble(m["outQty"])
val after = m["lotQtyAfter"]
run = if (after != null) toDouble(after) else run + inQty - outQty
out.add(moveRow(lotId, m, inQty, outQty, run, master))
}
}

out.sortWith(
compareBy<Map<String, Any>> { it["itemNo"]?.toString().orEmpty() }
.thenBy { it["unitOfMeasure"]?.toString().orEmpty() }
.thenBy { it["lotNo"]?.toString().orEmpty() }
.thenBy { it["inventoryLotLineId"]?.toString().orEmpty() }
.thenByDescending { toInt(it["isOpening"]) }
.thenBy { it["trnDate"]?.toString().orEmpty() }
.thenBy { toLong(it["slId"]) },
)
return out
}

private fun loadRefDocs(ledgerRows: List<Map<String, Any>>): Map<Long, String> {
if (ledgerRows.isEmpty()) return emptyMap()
val slByIn = HashMap<Long, MutableList<Long>>()
val slByOut = HashMap<Long, MutableList<Long>>()
for (r in ledgerRows) {
val slId = toLong(r["slId"])
if (slId <= 0) continue
val inId = toLong(r["stockInLineId"])
val outId = toLong(r["stockOutLineId"])
if (inId > 0) slByIn.getOrPut(inId) { mutableListOf() }.add(slId)
if (outId > 0) slByOut.getOrPut(outId) { mutableListOf() }.add(slId)
}
val refs = HashMap<Long, String>()
if (slByOut.isNotEmpty()) {
for (chunk in slByOut.keys.toList().chunked(800)) {
val rows = jdbcDao.queryForList(
"""
SELECT
sol.id AS lineId,
COALESCE(
NULLIF(TRIM(do.code), ''),
NULLIF(TRIM(po_out.code), ''),
NULLIF(TRIM(jo.code), ''),
''
) AS orderRefNo
FROM stock_out_line sol
LEFT JOIN pick_order_line pol
ON pol.id = sol.pickOrderLineId AND pol.deleted = 0
LEFT JOIN pick_order po_out
ON po_out.id = pol.poId AND po_out.deleted = 0
LEFT JOIN delivery_order do
ON do.id = po_out.doId AND do.deleted = 0
LEFT JOIN job_order jo
ON jo.id = po_out.joId AND jo.deleted = 0
WHERE sol.id IN (:ids)
""".trimIndent(),
mapOf("ids" to chunk),
)
for (r in rows) {
val ref = r["orderRefNo"]?.toString()?.trim().orEmpty()
if (ref.isEmpty()) continue
slByOut[toLong(r["lineId"])]?.forEach { slId -> refs[slId] = ref }
}
}
}
if (slByIn.isNotEmpty()) {
for (chunk in slByIn.keys.toList().chunked(800)) {
val rows = jdbcDao.queryForList(
"""
SELECT
sil.id AS lineId,
COALESCE(
NULLIF(TRIM(jo.code), ''),
NULLIF(TRIM(po.code), ''),
NULLIF(TRIM(si.code), ''),
''
) AS orderRefNo
FROM stock_in_line sil
LEFT JOIN stock_in si
ON si.id = sil.stockInId AND si.deleted = 0
LEFT JOIN job_order jo
ON jo.id = sil.jobOrderId AND jo.deleted = 0
LEFT JOIN purchase_order po
ON po.id = sil.purchaseOrderId AND po.deleted = 0
WHERE sil.id IN (:ids)
""".trimIndent(),
mapOf("ids" to chunk),
)
for (r in rows) {
val ref = r["orderRefNo"]?.toString()?.trim().orEmpty()
if (ref.isEmpty()) continue
slByIn[toLong(r["lineId"])]?.forEach { slId ->
if (refs[slId].isNullOrEmpty()) refs[slId] = ref
}
}
}
}
return refs
}

private fun openingRow(
lotId: Long,
asOfStr: String,
opening: Double,
master: Map<String, Any>,
): Map<String, Any> {
val row = HashMap<String, Any>(16)
row["inventoryLotLineId"] = lotId.toString()
row["slId"] = 0L
row["itemNo"] = master["itemNo"] ?: ""
row["itemName"] = master["itemName"] ?: ""
row["unitOfMeasure"] = master["unitOfMeasure"] ?: ""
row["lotNo"] = master["lotNo"] ?: ""
row["expiryDate"] = master["expiryDate"] ?: ""
row["trnDate"] = asOfStr
row["orderRefNo"] = "As of $asOfStr"
row["isOpening"] = 1
row["openingQtyRaw"] = opening
row["inQtyRaw"] = 0.0
row["outQtyRaw"] = 0.0
row["balQtyRaw"] = opening
return row
}

private fun moveRow(
lotId: Long,
m: Map<String, Any>,
inQty: Double,
outQty: Double,
bal: Double,
master: Map<String, Any>,
): Map<String, Any> {
val row = HashMap<String, Any>(16)
row["inventoryLotLineId"] = lotId.toString()
row["slId"] = toLong(m["slId"])
row["itemNo"] = master["itemNo"] ?: ""
row["itemName"] = master["itemName"] ?: ""
row["unitOfMeasure"] = master["unitOfMeasure"] ?: ""
row["lotNo"] = master["lotNo"] ?: ""
row["expiryDate"] = master["expiryDate"] ?: ""
row["trnDate"] = m["trnDate"] ?: ""
row["orderRefNo"] = m["orderRefNo"] ?: ""
row["isOpening"] = 0
row["openingQtyRaw"] = 0.0
row["inQtyRaw"] = inQty
row["outQtyRaw"] = outQty
row["balQtyRaw"] = bal
return row
}

private fun queryLotIdChunks(
lotIds: Collection<Long>,
baseArgs: Map<String, Any>,
query: (Map<String, Any>) -> List<Map<String, Any>>,
): List<Map<String, Any>> {
val ids = lotIds.filter { it > 0 }.distinct()
if (ids.isEmpty()) return emptyList()
val out = ArrayList<Map<String, Any>>()
for (chunk in ids.chunked(800)) {
val chunkArgs = HashMap<String, Any>(baseArgs)
chunkArgs["lotIds"] = chunk
out.addAll(query(chunkArgs))
}
return out
}

private fun attachItemUomTotals(rows: List<Map<String, Any>>): List<Map<String, Any>> {
if (rows.isEmpty()) return rows
val lotEnd = LinkedHashMap<String, Pair<String, Double>>()
for (r in rows) {
val lotId = r["inventoryLotLineId"]?.toString().orEmpty()
val key = "${r["itemNo"]}|${r["unitOfMeasure"]}"
lotEnd[lotId] = key to toDouble(r["balQtyRaw"])
}
val tot = HashMap<String, Double>()
for ((_, v) in lotEnd) {
tot[v.first] = (tot[v.first] ?: 0.0) + v.second
}
return rows.map { r ->
val key = "${r["itemNo"]}|${r["unitOfMeasure"]}"
val out = HashMap(r)
out["totalCumBalance"] = tot[key] ?: 0.0
out
}
}

private fun toDouble(v: Any?): Double {
if (v == null) return 0.0
if (v is Number) return v.toDouble()
return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0
}

private fun toLong(v: Any?): Long {
if (v == null) return 0L
if (v is Number) return v.toLong()
return v.toString().toLongOrNull() ?: 0L
}

private fun toInt(v: Any?): Int {
if (v == null) return 0
if (v is Number) return v.toInt()
return v.toString().toIntOrNull() ?: 0
}

private fun buildMultiValueLikeClause(
paramValue: String?,
columnName: String,
paramPrefix: String,
args: MutableMap<String, Any>,
): String {
if (paramValue.isNullOrBlank()) return ""
val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() }
if (values.isEmpty()) return ""
val conditions = values.mapIndexed { index, value ->
val paramName = "${paramPrefix}_$index"
args[paramName] = "%$value%"
"$columnName LIKE :$paramName"
}
return "AND (${conditions.joinToString(" OR ")})"
}
}

+ 24
- 27
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt 查看文件

@@ -245,8 +245,7 @@ in_agg AS (
AND iu_purchase.purchaseUnit = 1
AND iu_purchase.deleted = 0
LEFT JOIN item_uom iu_stock
ON it.id = iu_stock.itemId
AND iu_stock.stockUnit = 1
ON iu_stock.id = ill.stockItemUomId
AND iu_stock.deleted = 0
WHERE ill.deleted = 0
GROUP BY ill.id
@@ -316,8 +315,7 @@ data AS (
$ROOT_STOCK_IN_JOIN_SQL

LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
ON iu.id = ill.stockItemUomId
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
@@ -377,16 +375,17 @@ SELECT
END AS variancePercentage,

/* item totals(footer 用);負數括號顯示,無小數 */
CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance,
CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty,
CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance,
CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty,
lotTypeRaw AS type

FROM data
ORDER BY
itemNo,
unitOfMeasure,
lotNo,
storeLocation
""".trimIndent()
@@ -521,8 +520,7 @@ in_agg AS (
AND iu_purchase.purchaseUnit = 1
AND iu_purchase.deleted = 0
LEFT JOIN item_uom iu_stock
ON it.id = iu_stock.itemId
AND iu_stock.stockUnit = 1
ON iu_stock.id = ill.stockItemUomId
AND iu_stock.deleted = 0
WHERE ill.deleted = 0
GROUP BY ill.id
@@ -598,8 +596,7 @@ data AS (
$ROOT_STOCK_IN_JOIN_SQL

LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
ON iu.id = ill.stockItemUomId
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
@@ -655,16 +652,17 @@ SELECT
ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%')
END AS variancePercentage,

CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance,
CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty,
CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance,
CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty,
lotTypeRaw AS type

FROM data
ORDER BY
itemNo,
unitOfMeasure,
lotNo,
storeLocation
""".trimIndent()
@@ -820,8 +818,7 @@ in_agg AS (
AND iu_purchase.purchaseUnit = 1
AND iu_purchase.deleted = 0
LEFT JOIN item_uom iu_stock
ON it.id = iu_stock.itemId
AND iu_stock.stockUnit = 1
ON iu_stock.id = ill.stockItemUomId
AND iu_stock.deleted = 0
WHERE ill.deleted = 0
GROUP BY lb.inventoryLotLineId
@@ -888,8 +885,7 @@ data AS (
$ROOT_STOCK_IN_JOIN_SQL

LEFT JOIN item_uom iu
ON it.id = iu.itemId
AND iu.stockUnit = 1
ON iu.id = ill.stockItemUomId
AND iu.deleted = 0
LEFT JOIN uom_conversion uc
ON iu.uomId = uc.id
@@ -945,16 +941,17 @@ SELECT
ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%')
END AS variancePercentage,

CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance,
CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty,
CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance,
CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn,
CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut,
CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance,
CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty,
lotTypeRaw AS type

FROM data
ORDER BY
itemNo,
unitOfMeasure,
lotNo,
storeLocation
""".trimIndent()


+ 6
- 3
src/main/java/com/ffii/fpsms/modules/report/web/FGStockOutTraceabilityReportController.kt 查看文件

@@ -159,7 +159,7 @@ class FGStockOutTraceabilityReportController(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
}

val dashStyle = workbook.createCellStyle().apply {
@@ -216,7 +216,7 @@ class FGStockOutTraceabilityReportController(
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
@@ -306,11 +306,13 @@ class FGStockOutTraceabilityReportController(
} else {
var currentItemNo: String? = null
var currentItemName = ""
var currentUom = ""
var currentItemTotalQty = 0.0

dbData.forEach { rowMap ->
val itemNo = rowMap["itemNo"]?.toString() ?: ""
if (currentItemNo != null && itemNo != currentItemNo) {
val uom = rowMap["unitOfMeasure"]?.toString().orEmpty()
if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) {
addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName)
addBlankSeparatorRow()
currentItemTotalQty = 0.0
@@ -331,6 +333,7 @@ class FGStockOutTraceabilityReportController(

currentItemNo = itemNo
currentItemName = rowMap["itemName"]?.toString() ?: ""
currentUom = uom
currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0
}



+ 2
- 2
src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt 查看文件

@@ -271,7 +271,7 @@ class ItemQcFailReportController(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df = workbook.creationHelper.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
}

val decimalNumberStyle = workbook.createCellStyle().apply {
@@ -282,7 +282,7 @@ class ItemQcFailReportController(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df = workbook.creationHelper.createDataFormat()
dataFormat = df.getFormat("#,##0.##")
dataFormat = df.getFormat("#,##0.00")
}

val headers = listOf(


+ 6
- 3
src/main/java/com/ffii/fpsms/modules/report/web/MaterialStockOutTraceabilityReportController.kt 查看文件

@@ -244,7 +244,7 @@ class MaterialStockOutTraceabilityReportController(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
}

val dashStyle = workbook.createCellStyle().apply {
@@ -282,7 +282,7 @@ class MaterialStockOutTraceabilityReportController(
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
@@ -358,11 +358,13 @@ class MaterialStockOutTraceabilityReportController(
} else {
var currentItemNo: String? = null
var currentItemName: String = ""
var currentUom = ""
var currentItemTotalQty: Double = 0.0

dbData.forEach { rowMap ->
val itemNo = rowMap["itemNo"]?.toString() ?: ""
if (currentItemNo != null && itemNo != currentItemNo) {
val uom = rowMap["unitOfMeasure"]?.toString().orEmpty()
if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) {
addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName)
addBlankSeparatorRow()
currentItemTotalQty = 0.0
@@ -383,6 +385,7 @@ class MaterialStockOutTraceabilityReportController(

currentItemNo = itemNo
currentItemName = rowMap["itemName"]?.toString() ?: ""
currentUom = uom
currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0
}



+ 98
- 16
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt 查看文件

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

@PostMapping("/print-stock-in-traceability")
fun generateStockInTraceabilityReportPost(
@@ -136,6 +145,12 @@ class ReportController(
r.combinedItemCode(),
r.lastInDateStart,
r.lastInDateEnd,
r.storeId,
r.warehouse,
r.area,
r.slot,
r.lotNo,
r.poPrefix,
)
}

@@ -144,6 +159,12 @@ class ReportController(
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
storeId: String?,
warehouse: String?,
area: String?,
slot: String?,
lotNo: String?,
poPrefix: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
@@ -159,7 +180,13 @@ class ReportController(
stockCategory,
itemCode,
lastInDateStart,
lastInDateEnd
lastInDateEnd,
storeId,
warehouse,
area,
slot,
lotNo,
poPrefix,
)

val pdfBytes = reportService.createPdfResponse(
@@ -183,8 +210,17 @@ class ReportController(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) warehouse: String?,
@RequestParam(required = false) area: String?,
@RequestParam(required = false) slot: String?,
@RequestParam(required = false) lotNo: String?,
@RequestParam(required = false) poPrefix: String?,
): ResponseEntity<ByteArray> =
buildStockInTraceabilityExcel(stockCategory, itemCode, lastInDateStart, lastInDateEnd)
buildStockInTraceabilityExcel(
stockCategory, itemCode, lastInDateStart, lastInDateEnd,
storeId, warehouse, area, slot, lotNo, poPrefix,
)

@PostMapping("/print-stock-in-traceability-excel")
fun exportStockInTraceabilityReportExcelPost(
@@ -196,6 +232,12 @@ class ReportController(
r.combinedItemCode(),
r.lastInDateStart,
r.lastInDateEnd,
r.storeId,
r.warehouse,
r.area,
r.slot,
r.lotNo,
r.poPrefix,
)
}

@@ -204,18 +246,37 @@ class ReportController(
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
storeId: String?,
warehouse: String?,
area: String?,
slot: String?,
lotNo: String?,
poPrefix: String?,
): ResponseEntity<ByteArray> {
val dbData = reportService.searchStockInTraceabilityReport(
stockCategory,
itemCode,
lastInDateStart,
lastInDateEnd,
storeId,
warehouse,
area,
slot,
lotNo,
poPrefix,
)

val excelBytes = createStockInTraceabilityExcel(
dbData = dbData,
lastInDateStart = lastInDateStart ?: "",
lastInDateEnd = lastInDateEnd ?: "",
itemCode = itemCode,
storeId = storeId,
warehouse = warehouse,
area = area,
slot = slot,
lotNo = lotNo,
poPrefix = poPrefix,
)

val headers = HttpHeaders().apply {
@@ -664,7 +725,7 @@ class ReportController(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
}
val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply {
alignment = HorizontalAlignment.RIGHT
@@ -777,6 +838,13 @@ class ReportController(
dbData: List<Map<String, Any>>,
lastInDateStart: String,
lastInDateEnd: String,
itemCode: String? = null,
storeId: String? = null,
warehouse: String? = null,
area: String? = null,
slot: String? = null,
lotNo: String? = null,
poPrefix: String? = null,
): ByteArray {
val workbook = XSSFWorkbook()
val styles = createCommonWorkbookStyles(workbook)
@@ -812,8 +880,18 @@ class ReportController(
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5))
subtitleRow.createCell(6).apply {
val cap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" })
setCellValue("最後入倉日期:${cap.trim()}")
val dateCap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" })
val filterBits = listOfNotNull(
itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" },
storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" },
warehouse?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉庫:$it" },
area?.takeIf { it.isNotBlank() && it != "All" }?.let { "區域:$it" },
slot?.takeIf { it.isNotBlank() && it != "All" }?.let { "儲位:$it" },
lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" },
poPrefix?.takeIf { it.isNotBlank() && it != "All" }?.let { "PP/PF:$it" },
).joinToString(" ")
val extra = if (filterBits.isNotBlank()) " $filterBits" else ""
setCellValue("最後入倉日期:${dateCap.trim()}$extra")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1))
@@ -869,7 +947,7 @@ class ReportController(
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()

if (currentItemNo != null && itemNo != currentItemNo) {
if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) {
addItemSummaryRow(
currentItemNo!!,
currentItemName,
@@ -1039,7 +1117,7 @@ class ReportController(
setTextCell(r, 4, m["dnNo"], styles.text)
setTextCell(r, 5, m["deliveryOrderNo"], styles.text)
setTextCell(r, 6, m["customerName"], styles.text)
setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = false)
setTextCell(r, 8, m["truckNo"], styles.center)

itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0
@@ -1063,6 +1141,7 @@ class ReportController(
return out.toByteArray()
}

/** FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 */
private fun createStockBalanceExcel(
dbData: List<Map<String, Any>>,
reportDate: String,
@@ -1121,13 +1200,13 @@ class ReportController(
setTextCell(r, 0, m["itemNo"], styles.text)
setTextCell(r, 1, m["itemName"], styles.text)
setTextCell(r, 2, m["unitOfMeasure"], styles.center)
setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true)
setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.number, styles.dash, preferInt = false)
setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true)
setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true)
}
@@ -1139,6 +1218,9 @@ class ReportController(
}
val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }
// Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now.
sheet.setColumnHidden(10, true)
sheet.setColumnHidden(11, true)

val out = ByteArrayOutputStream()
workbook.use { it.write(out) }


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

@@ -24,6 +24,12 @@ data class ReportItemFilterRequest(
val supplier: String? = null,
val poCode: String? = null,
val grnCode: String? = null,
val storeId: String? = null,
val warehouse: String? = null,
val area: String? = null,
val slot: String? = null,
val lotNo: String? = null,
val poPrefix: String? = null,
) {
fun combinedItemCode(): String? =
ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes)


+ 250
- 1
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,
@@ -228,7 +466,7 @@ class SemiFGProductionAnalysisReportController(
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0")
dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00")
}

val dashStyle = workbook.createCellStyle().apply {
@@ -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
}
}

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/report/web/StockItemConsumptionTrendReportController.kt 查看文件

@@ -237,7 +237,7 @@ class StockItemConsumptionTrendReportController(
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0")
dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00")
}

val dashStyle = workbook.createCellStyle().apply {


+ 23
- 32
src/main/java/com/ffii/fpsms/modules/report/web/StockLedgerReportController.kt 查看文件

@@ -1,12 +1,13 @@
package com.ffii.fpsms.modules.report.web
import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.*
import org.springframework.http.*
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens
import com.ffii.fpsms.modules.report.service.StockLedgerPdf
import com.ffii.fpsms.modules.report.service.StockLedgerReportService
import com.ffii.fpsms.modules.report.service.ReportService
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.DataFormat
@@ -24,9 +25,9 @@ import java.io.ByteArrayOutputStream
@RestController
@RequestMapping("/report")
class StockLedgerReportController(
private val reportService: ReportService,
private val stockLedgerReportService: StockLedgerReportService,
) {
private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java)
private data class ExcelStyles(
val title: CellStyle,
val subtitle: CellStyle,
@@ -98,27 +99,6 @@ class StockLedgerReportController(
reportPeriodStart: String?,
reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val tokenCount = ReportMultiValueTokens.split(itemCode).size
val parameters = mutableMapOf<String, Any>()

parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] =
when {
itemCode.isNullOrBlank() -> "All"
tokenCount > 30 -> "$tokenCount items"
else -> itemCode
}
parameters["year"] = LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))

parameters["storeLocation"] = storeLocation ?: ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["reportPeriodStart"] = reportPeriodStart ?: ""
parameters["reportPeriodEnd"] = reportPeriodEnd ?: ""

val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
@@ -127,10 +107,12 @@ class StockLedgerReportController(
reportPeriodEnd = reportPeriodEnd,
)

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

val headers = HttpHeaders().apply {
@@ -148,6 +130,7 @@ class StockLedgerReportController(
reportPeriodStart: String?,
reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val tSearch = System.nanoTime()
val dbData = stockLedgerReportService.searchStockLedgerReport(
stockCategory = stockCategory,
itemCode = itemCode,
@@ -155,12 +138,20 @@ class StockLedgerReportController(
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)

val searchMs = (System.nanoTime() - tSearch) / 1_000_000
val tExcel = System.nanoTime()
val excelBytes = createStockLedgerExcel(
dbData = dbData,
reportPeriodStart = reportPeriodStart ?: "",
reportPeriodEnd = reportPeriodEnd ?: "",
)
log.info(
"stock-ledger excel rows={} search={}ms excel={}ms bytes={}",
dbData.size,
searchMs,
(System.nanoTime() - tExcel) / 1_000_000,
excelBytes.size,
)

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
@@ -227,7 +218,7 @@ class StockLedgerReportController(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
}
val dashStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
@@ -241,7 +232,7 @@ class StockLedgerReportController(
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
@@ -323,7 +314,7 @@ class StockLedgerReportController(
return
}
if (parsed < 0) {
val s = "%,d".format(kotlin.math.abs(parsed).toLong())
val s = "%,.2f".format(kotlin.math.abs(parsed))
cell.setCellValue("($s)")
cell.cellStyle = dashStyle
} else {
@@ -427,7 +418,7 @@ class StockLedgerReportController(
val itemName = m["itemName"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()

if (currentItemNo != null && itemNo != currentItemNo) {
if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) {
addItemSummaryRow(
currentItemNo!!,
currentItemName,


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

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

import com.ffii.fpsms.modules.report.service.StockLotBalanceReportService
import org.slf4j.LoggerFactory
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.DataFormat
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.io.ByteArrayOutputStream
import java.time.LocalTime
import java.time.format.DateTimeFormatter

/**
* FP-MTMS | 庫存批次結餘報告 (Excel only, always today)
* Excel: /report/print-stock-lot-balance-excel
*/
@RestController
@RequestMapping("/report")
class StockLotBalanceReportController(
private val stockLotBalanceReportService: StockLotBalanceReportService,
) {
private val log = LoggerFactory.getLogger(StockLotBalanceReportController::class.java)
private data class ExcelStyles(
val title: CellStyle,
val subtitle: CellStyle,
val header: CellStyle,
val text: CellStyle,
val center: CellStyle,
val number: CellStyle,
val dash: CellStyle,
)

@GetMapping("/print-stock-lot-balance-excel")
fun exportStockLotBalanceReportExcel(
@RequestParam(required = false) stockDate: String?,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) warehouseCode: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false) lotNo: String?,
): ResponseEntity<ByteArray> {
val tAll = System.nanoTime()
val result = stockLotBalanceReportService.searchStockLotBalanceReportTimed(
stockDate = stockDate,
itemCode = itemCode,
warehouseCode = warehouseCode,
storeId = storeId,
lotNo = lotNo,
)
val reportDate = result.stockDate
val tExcel = System.nanoTime()
val excelBytes = createStockLotBalanceExcel(
dbData = result.rows,
reportDate = reportDate,
itemCode = itemCode,
warehouseCode = warehouseCode,
storeId = storeId,
lotNo = lotNo,
timings = result.timings,
)
val excelMs = (System.nanoTime() - tExcel) / 1_000_000
val totalMs = (System.nanoTime() - tAll) / 1_000_000
val excelLine = "6.excel rows=${result.rows.size} ${excelMs}ms"
val requestLine = "request.total ${totalMs}ms"
log.info("stock-lot-balance {} | {}", excelLine, requestLine)
log.info(
"stock-lot-balance REQUEST {} | {} | {}",
requestLine,
result.timings.joinToString(" | "),
excelLine,
)

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "StockLotBalanceReport.xlsx")
set("filename", "StockLotBalanceReport.xlsx")
set("X-Report-Timings", (result.timings + excelLine + requestLine).joinToString(" | "))
}
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

private fun createStyles(workbook: Workbook): ExcelStyles {
val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 14
}
setFont(font)
}
val subtitleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 11
}
setFont(font)
}
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
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val centerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
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
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0.00")
}
val dashStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
return ExcelStyles(
title = titleStyle,
subtitle = subtitleStyle,
header = headerStyle,
text = textStyle,
center = centerStyle,
number = numberStyle,
dash = dashStyle,
)
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
}
}

private fun parseSignedNumber(value: Any?): Double? {
val raw = value?.toString()?.trim().orEmpty()
if (raw.isBlank() || raw == "-" || raw.equals("null", ignoreCase = true)) return null
val negative = raw.startsWith("(") && raw.endsWith(")")
val cleaned = raw.removePrefix("(").removeSuffix(")").replace(",", "").trim()
val n = cleaned.toDoubleOrNull() ?: return null
return if (negative) -n else n
}

private fun setNumberCellFromFormatted(
row: Row,
col: Int,
value: Any?,
numberStyle: CellStyle,
dashStyle: CellStyle,
) {
val cell = row.createCell(col)
val parsed = parseSignedNumber(value)
if (parsed == null) {
cell.setCellValue("")
cell.cellStyle = dashStyle
return
}
if (parsed < 0) {
cell.setCellValue("(${"%,.2f".format(kotlin.math.abs(parsed))})")
cell.cellStyle = dashStyle
} else {
cell.setCellValue(parsed)
cell.cellStyle = numberStyle
}
}

private fun createStockLotBalanceExcel(
dbData: List<Map<String, Any>>,
reportDate: String,
itemCode: String?,
warehouseCode: String?,
storeId: String?,
lotNo: String?,
timings: List<String>,
): ByteArray {
val workbook = SXSSFWorkbook(100)
workbook.setCompressTempFiles(true)
try {
val styles = createStyles(workbook)
val reportTitle = "庫存批次結餘報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))

val headers = listOf(
"貨品編號", "貨品名稱", "單位",
"批號", "到期日", "倉位", "樓層",
"期初存量", "纍計存入量", "纍計存出量",
"錯誤輸入或遺失", "盤盈虧", "不良品棄置", "過期棄置",
"現存存貨", "單位均價", "庫存總價值",
)
val totalColumns = headers.size
var rowIndex = 0

val titleRow = sheet.createRow(rowIndex++)
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val reportDateTime =
reportDate +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val filterBits = listOfNotNull(
itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" },
warehouseCode?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉位:$it" },
storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" },
lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" },
).joinToString(" ")
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue(
"報告日期(現況):$reportDateTime" +
if (filterBits.isNotBlank()) " $filterBits" else "",
)
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1))
if (timings.isNotEmpty()) {
val timingRow = sheet.createRow(rowIndex++)
timingRow.createCell(0).apply {
setCellValue("耗時:${timings.joinToString(" | ")}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1))
} else {
sheet.createRow(rowIndex++)
}

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
} else {
dbData.forEach { m ->
val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, m["itemNo"], styles.text)
setTextCell(r, 1, m["itemName"], styles.text)
setTextCell(r, 2, m["unitOfMeasure"], styles.center)
setTextCell(r, 3, m["lotNo"], styles.text)
setTextCell(r, 4, m["expiryDate"], styles.center)
setTextCell(r, 5, m["warehouseCode"], styles.text)
setTextCell(r, 6, m["storeId"], styles.center)
setNumberCellFromFormatted(r, 7, m["totalOpeningBalance"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 8, m["totalCumStockIn"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 9, m["totalCumStockOut"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 10, m["totalMisInputAndLost"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 11, m["totalVariance"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 12, m["totalDefectiveGoods"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 13, m["totalExpiredDisposed"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 14, m["totalCurrentBalance"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 15, m["avgUnitPrice"], styles.number, styles.dash)
setNumberCellFromFormatted(r, 16, m["totalStockBalance"], styles.number, styles.dash)
}
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}
val widths = intArrayOf(14, 22, 12, 18, 12, 18, 8, 12, 12, 12, 14, 12, 12, 12, 12, 12, 14)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
} finally {
workbook.dispose()
workbook.close()
}
}
}

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

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

import com.ffii.fpsms.modules.report.service.StockLotLedgerReportService
import org.slf4j.LoggerFactory
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.CellStyle
import org.apache.poi.ss.usermodel.DataFormat
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.Row
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.streaming.SXSSFWorkbook
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.io.ByteArrayOutputStream
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter

/**
* 庫存流水帳報告 (Excel)
* Excel: /report/print-stock-lot-ledger-excel
*/
@RestController
@RequestMapping("/report")
class StockLotLedgerReportController(
private val stockLotLedgerReportService: StockLotLedgerReportService,
) {
private val log = LoggerFactory.getLogger(StockLotLedgerReportController::class.java)
private data class ExcelStyles(
val title: CellStyle,
val subtitle: CellStyle,
val header: CellStyle,
val text: CellStyle,
val center: CellStyle,
val number: CellStyle,
val dash: CellStyle,
)

@GetMapping("/stock-lot-ledger-earliest")
fun earliestLedgerMonth(): Map<String, String> {
val earliest = stockLotLedgerReportService.earliestLedgerMonth()
return mapOf("earliest" to earliest.format(DateTimeFormatter.ISO_LOCAL_DATE))
}

@GetMapping("/print-stock-lot-ledger-excel")
fun exportStockLotLedgerReportExcel(
@RequestParam(required = false) itemCode: String?,
@RequestParam(name = "lastInDateStart", required = false) reportPeriodStart: String?,
@RequestParam(name = "lastInDateEnd", required = false) reportPeriodEnd: String?,
): ResponseEntity<ByteArray> {
val tAll = System.nanoTime()
val result = stockLotLedgerReportService.searchStockLotLedgerReportTimed(
itemCode = itemCode,
reportPeriodStart = reportPeriodStart,
reportPeriodEnd = reportPeriodEnd,
)
val period = result.period
val tExcel = System.nanoTime()
val excelBytes = createExcel(
dbData = result.rows,
reportPeriodStart = period.startStr,
reportPeriodEnd = period.endStr,
timings = result.timings,
)
val excelMs = (System.nanoTime() - tExcel) / 1_000_000
val totalMs = (System.nanoTime() - tAll) / 1_000_000
val excelLine = "7.excel rows=${result.rows.size} ${excelMs}ms"
val requestLine = "request.total ${totalMs}ms"
log.info("stock-lot-ledger {} | {}", excelLine, requestLine)
log.info(
"stock-lot-ledger REQUEST {} | {} | {}",
requestLine,
result.timings.joinToString(" | "),
excelLine,
)

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
setContentDispositionFormData("attachment", "StockLotLedgerReport.xlsx")
set("filename", "StockLotLedgerReport.xlsx")
set("X-Report-Timings", (result.timings + excelLine + requestLine).joinToString(" | "))
}
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
}

private fun createStyles(workbook: Workbook): ExcelStyles {
val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 14
}
setFont(font)
}
val subtitleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
val font = workbook.createFont().apply {
bold = true
fontHeightInPoints = 11
}
setFont(font)
}
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
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val centerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
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
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0.00")
}
val dashStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
return ExcelStyles(
title = titleStyle,
subtitle = subtitleStyle,
header = headerStyle,
text = textStyle,
center = centerStyle,
number = numberStyle,
dash = dashStyle,
)
}

private fun setTextCell(row: Row, col: Int, value: Any?, style: CellStyle) {
row.createCell(col).apply {
setCellValue(value?.toString() ?: "")
cellStyle = style
}
}

private fun setNumberCell(row: Row, col: Int, value: Any?, numberStyle: CellStyle, dashStyle: CellStyle) {
val cell = row.createCell(col)
val n = when (value) {
null -> null
is Number -> value.toDouble()
else -> {
val raw = value.toString().trim()
if (raw.isEmpty() || raw == "-") null else raw.replace(",", "").toDoubleOrNull()
}
}
if (n == null) {
cell.setCellValue("")
cell.cellStyle = dashStyle
return
}
if (n < 0) {
cell.setCellValue("(${"%,.2f".format(kotlin.math.abs(n))})")
cell.cellStyle = dashStyle
} else {
cell.setCellValue(n)
cell.cellStyle = numberStyle
}
}

private fun isOpeningRow(m: Map<String, Any>): Boolean {
val v = m["isOpening"]
if (v is Number) return v.toInt() != 0
return v?.toString() == "1"
}

private fun createExcel(
dbData: List<Map<String, Any>>,
reportPeriodStart: String,
reportPeriodEnd: String,
timings: List<String>,
): ByteArray {
val workbook = SXSSFWorkbook(100)
workbook.setCompressTempFiles(true)
try {
val styles = createStyles(workbook)
val reportTitle = "庫存流水帳報告"
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName(reportTitle))
val headers = listOf(
"貨品編號", "貨品名稱", "單位", "貨品單位結餘",
"批號", "到期日", "參考日期", "參考單據",
"批號期初", "入庫", "出庫", "結餘",
)
val totalColumns = headers.size
var rowIndex = 0

val titleRow = sheet.createRow(rowIndex++)
titleRow.createCell(0).apply {
setCellValue(reportTitle)
cellStyle = styles.title
}
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val reportDateTime =
LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) +
"(" +
LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) +
")"
val subtitleRow = sheet.createRow(rowIndex++)
subtitleRow.createCell(0).apply {
setCellValue("報告日期:$reportDateTime")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 4))
subtitleRow.createCell(5).apply {
setCellValue("報告期間:${reportPeriodStart.trim()} 至 ${reportPeriodEnd.trim()}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(1, 1, 5, totalColumns - 1))
if (timings.isNotEmpty()) {
val timingRow = sheet.createRow(rowIndex++)
timingRow.createCell(0).apply {
setCellValue("耗時:${timings.joinToString(" | ")}")
cellStyle = styles.subtitle
}
sheet.addMergedRegion(CellRangeAddress(2, 2, 0, totalColumns - 1))
} else {
sheet.createRow(rowIndex++)
}

val headerRowIndex = rowIndex
val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { i, h ->
headerRow.createCell(i).apply {
setCellValue(h)
cellStyle = styles.header
}
}

if (dbData.isEmpty()) {
val r = sheet.createRow(rowIndex++)
for (c in 0 until totalColumns) {
r.createCell(c).apply { setCellValue("-"); cellStyle = styles.text }
}
} else {
var prevItemUom = ""
var prevLot = ""
dbData.forEach { m ->
val itemNo = m["itemNo"]?.toString().orEmpty()
val uom = m["unitOfMeasure"]?.toString().orEmpty()
val lotId = m["inventoryLotLineId"]?.toString().orEmpty()
val itemUomKey = "$itemNo|$uom"
val lotKey = "$itemUomKey|$lotId"
val showItem = itemUomKey != prevItemUom
val showLot = lotKey != prevLot
val r = sheet.createRow(rowIndex++)
setTextCell(r, 0, if (showItem) itemNo else "", styles.text)
setTextCell(r, 1, if (showItem) m["itemName"] else "", styles.text)
setTextCell(r, 2, if (showItem) uom else "", styles.center)
if (showItem) {
setNumberCell(r, 3, m["totalCumBalance"], styles.number, styles.dash)
} else {
setTextCell(r, 3, "", styles.dash)
}
setTextCell(r, 4, if (showLot) m["lotNo"] else "", styles.text)
setTextCell(r, 5, if (showLot) m["expiryDate"] else "", styles.center)
setTextCell(r, 6, m["trnDate"], styles.center)
setTextCell(r, 7, m["orderRefNo"], styles.text)
if (isOpeningRow(m)) {
setNumberCell(r, 8, m["openingQtyRaw"], styles.number, styles.dash)
} else {
setTextCell(r, 8, "", styles.dash)
}
setNumberCell(r, 9, m["inQtyRaw"], styles.number, styles.dash)
setNumberCell(r, 10, m["outQtyRaw"], styles.number, styles.dash)
setNumberCell(r, 11, m["balQtyRaw"], styles.number, styles.dash)
prevItemUom = itemUomKey
prevLot = lotKey
}
}

val lastRowIndex = rowIndex - 1
if (lastRowIndex >= headerRowIndex) {
sheet.setAutoFilter(CellRangeAddress(headerRowIndex, lastRowIndex, 0, 0))
}
intArrayOf(14, 26, 10, 14, 16, 12, 12, 22, 12, 10, 10, 12)
.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.write(out)
return out.toByteArray()
} finally {
workbook.dispose()
workbook.close()
}
}
}

+ 4
- 1
src/main/java/com/ffii/fpsms/modules/report/web/StockLotOnhandReportController.kt 查看文件

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

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

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


+ 4
- 5
src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt 查看文件

@@ -328,7 +328,7 @@ class StockTakeVarianceReportController(
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
}

val dashStyle = workbook.createCellStyle().apply {
@@ -344,7 +344,7 @@ class StockTakeVarianceReportController(
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
val df: DataFormat = workbook.createDataFormat()
dataFormat = df.getFormat("#,##0")
dataFormat = df.getFormat("#,##0.00")
borderTop = BorderStyle.THICK
borderBottom = BorderStyle.THICK
borderLeft = BorderStyle.THIN
@@ -490,7 +490,7 @@ class StockTakeVarianceReportController(
val itemName = rowMap["itemName"]?.toString().orEmpty()
val uom = rowMap["unitOfMeasure"]?.toString().orEmpty()

if (currentItemNo != null && itemNo != currentItemNo) {
if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) {
addItemSummaryRow(currentItemTotalQty, currentItemTotalStockTakeQty, currentItemNo!!, currentItemName)
addBlankSeparatorRow()
currentItemTotalQty = 0.0
@@ -578,8 +578,7 @@ class StockTakeVarianceReportController(
}

private fun formatAbsInt(v: Double): String {
val abs = kotlin.math.abs(v).toLong()
return "%,d".format(abs)
return "%,.2f".format(kotlin.math.abs(v))
}

private data class Rep012ReportData(


+ 13
- 0
src/main/java/com/ffii/fpsms/modules/settings/web/SettingsController.java 查看文件

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

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

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

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

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

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

public static class UpdateReq {
@NotBlank
private String value;


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

@@ -54,10 +54,19 @@ open class Inventory: BaseEntity<Long>(){
open var cpmUnit: String? = null

// @NotNull
/** Base UOM snapshot only (set when the inventory row is created; not the bucket key). */
@ManyToOne
@JoinColumn(name = "uomId")
open var uom: UomConversion? = null

/**
* Stock-UOM bucket key = uom_conversion.id.
* Unique with itemId: uk_inventory_item_stock_uom. Nullable until data patch.
*/
@ManyToOne
@JoinColumn(name = "stockUomId")
open var stockUom: UomConversion? = null

// @NotNull
@Column(name = "status")
open var status: String? = null

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

@@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.stock.entity
import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo
import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo
import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.Modifying
@@ -56,9 +57,119 @@ WHERE ill.id = :id

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

/** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.1 | 2026-09-21 */
@Query("select ill from InventoryLotLine ill where :id is null or ill.inventoryLot.item.id = :id order by ill.id desc")
fun findInventoryLotLineInfoByItemId(id: Long?, pageable: Pageable): Page<InventoryLotLineInfo>
@Query(
"""
SELECT ill FROM InventoryLotLine ill
WHERE (:id IS NULL OR ill.inventoryLot.item.id = :id)
AND (:uomId IS NULL OR ill.stockUom.uom.id = :uomId)
ORDER BY ill.id DESC
"""
)
/** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */
fun findInventoryLotLineInfoByItemId(
@Param("id") id: Long?,
@Param("uomId") uomId: Long?,
pageable: Pageable,
): Page<InventoryLotLineInfo>

@Query(
"""
SELECT DISTINCT ill.inventoryLot.item.id
FROM InventoryLotLine ill
WHERE ill.deleted = false
AND ill.status = :status
AND ill.warehouse.id IN :warehouseIds
"""
)
fun findDistinctItemIdsByWarehouseIdsAndStatus(
@Param("warehouseIds") warehouseIds: List<Long>,
@Param("status") status: InventoryLotLineStatus,
): List<Long>

@Query(
"""
SELECT ill FROM InventoryLotLine ill
WHERE (:id IS NULL OR ill.inventoryLot.item.id = :id)
AND ill.warehouse.id IN :warehouseIds
AND (:uomId IS NULL OR ill.stockUom.uom.id = :uomId)
ORDER BY ill.id DESC
"""
)
/** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */
fun findInventoryLotLineInfoByItemIdAndWarehouseIdIn(
@Param("id") id: Long?,
@Param("warehouseIds") warehouseIds: List<Long>,
@Param("uomId") uomId: Long?,
pageable: Pageable,
): Page<InventoryLotLineInfo>

@Query(
value = """
SELECT
MAX(CAST(ill.id AS long)) AS id,
CAST(item.id AS long) AS itemId,
item.code AS itemCode,
item.name AS itemName,
item.type AS itemType,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHandQty,
SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS availableQty,
CAST(uom.id AS long) AS uomId,
uom.code AS uomCode,
uom.udfudesc AS uomUdfudesc,
uom.udfShortDesc AS uomShortDesc,
SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS qtyPerSmallestUnit,
MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice,
MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate
FROM InventoryLotLine ill
JOIN ill.inventoryLot il
JOIN il.item item
JOIN ill.stockUom su
JOIN su.uom uom
WHERE ill.deleted = false
AND il.deleted = false
AND item.deleted = false
AND ill.status = :status
AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%')))
AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%')))
AND (:type IS NULL OR :type = '' OR item.type = :type)
AND (:hasWarehouseFilter = false OR ill.warehouse.id IN :warehouseIds)
AND (:lotNo IS NULL OR :lotNo = '' OR il.lotNo = :lotNo)
GROUP BY item.id, item.code, item.name, item.type, uom.id, uom.code, uom.udfudesc, uom.udfShortDesc
ORDER BY item.code ASC, uom.udfudesc ASC
""",
countQuery = """
SELECT COUNT(DISTINCT CONCAT(CAST(item.id AS string), ':', CAST(uom.id AS string)))
FROM InventoryLotLine ill
JOIN ill.inventoryLot il
JOIN il.item item
JOIN ill.stockUom su
JOIN su.uom uom
WHERE ill.deleted = false
AND il.deleted = false
AND item.deleted = false
AND ill.status = :status
AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%')))
AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%')))
AND (:type IS NULL OR :type = '' OR item.type = :type)
AND (:hasWarehouseFilter = false OR ill.warehouse.id IN :warehouseIds)
AND (:lotNo IS NULL OR :lotNo = '' OR il.lotNo = :lotNo)
""",
)
/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10
* Lot UOMs only. Location Search uses [com.ffii.fpsms.modules.stock.service.InventoryService.searchInventoriesByLatestInventory].
*/
fun searchInventoryGroupedByStockUom(
@Param("code") code: String,
@Param("name") name: String,
@Param("type") type: String,
@Param("status") status: InventoryLotLineStatus,
@Param("hasWarehouseFilter") hasWarehouseFilter: Boolean,
@Param("warehouseIds") warehouseIds: List<Long>,
@Param("lotNo") lotNo: String?,
pageable: Pageable,
): Page<InventoryStockUomSearchRow>

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

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

+ 50
- 41
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt 查看文件

@@ -14,43 +14,49 @@ import java.util.Optional
interface InventoryRepository: AbstractRepository<Inventory, Long> {
fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo>

/**
* All non-deleted inventory rows matching filters (one row per stock-UOM bucket).
* Does not require baseUnit match — that hid non-base buckets.
*/
@Query(
"""
SELECT i FROM Inventory i
WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%'))
AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%'))
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.deleted = false
AND EXISTS (
SELECT 1 FROM ItemUom iu
WHERE iu.item.id = i.item.id
AND iu.deleted = false
AND iu.baseUnit = true
AND iu.uom.id = i.uom.id
)
"""
)
fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page<InventoryInfo>
"""
SELECT i FROM Inventory i
WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%'))
AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%'))
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.deleted = false
"""
)
fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(
code: String,
name: String,
type: String,
pageable: Pageable,
): Page<InventoryInfo>

@Query("SELECT i FROM Inventory i " +
"WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " +
"AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " +
"AND (:type IS NULL OR :type = '' OR i.item.type = :type) " +
"AND i.item.id IN :itemIds " +
"AND i.deleted = false")
@Query(
"""
SELECT i FROM Inventory i
WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%'))
AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%'))
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.item.id IN :itemIds
AND i.deleted = false
"""
)
fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse(
code: String,
name: String,
type: String,
itemIds: List<Long>,
pageable: Pageable
pageable: Pageable,
): Page<InventoryInfo>

/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17
* Inventory search page only: no baseUnit/uomId match required;
* one row per item = latest inventory id (no larger id for same item).
* Formerly "latest id per item" — that hid older stock-UOM buckets.
* Now returns all matching rows (same as non-latest search). Prefer the non-Latest methods.
*/
@Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse — returns all stock-UOM buckets")
@Query(
"""
SELECT i FROM Inventory i
@@ -58,12 +64,6 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {
AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%')))
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.deleted = false
AND NOT EXISTS (
SELECT 1 FROM Inventory i2
WHERE i2.item.id = i.item.id
AND i2.deleted = false
AND i2.id > i.id
)
"""
)
fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(
@@ -73,7 +73,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {
pageable: Pageable,
): Page<InventoryInfo>

/** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */
@Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse")
@Query(
"""
SELECT i FROM Inventory i
@@ -82,12 +82,6 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {
AND (:type IS NULL OR :type = '' OR i.item.type = :type)
AND i.item.id IN :itemIds
AND i.deleted = false
AND NOT EXISTS (
SELECT 1 FROM Inventory i2
WHERE i2.item.id = i.item.id
AND i2.deleted = false
AND i2.id > i.id
)
"""
)
fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse(
@@ -102,7 +96,13 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {

fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo>

/**
* Unsafe when an item has multiple stock-UOM buckets (NonUniqueResultException).
* Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse] or [findAllByItemIdAndDeletedIsFalse].
*/
@Deprecated("Multiple stock-UOM buckets per item — use findByItemIdAndStockUomIdAndDeletedIsFalse")
fun findByItemId(itemId: Long): Optional<Inventory>

fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory>

@Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false")
@@ -110,6 +110,15 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {

fun findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId: Long): Inventory?

/** 與 item_uom 中 baseUnit=true 之 uomId 對齊時使用,避免同一 item 多筆 inventory 時 NonUniqueResult */
/**
* Legacy base-UOM lookup. Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse].
* Shared base uomId across buckets can still collide until callers migrate.
*/
@Deprecated("Prefer findByItemIdAndStockUomIdAndDeletedIsFalse")
fun findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, uomId: Long): Inventory?
}

/** Resolve one inventory bucket by (itemId, stockUomId). */
fun findByItemIdAndStockUomIdAndDeletedIsFalse(itemId: Long, stockUomId: Long): Inventory?

fun findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, stockUomId: Long): Inventory?
}

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

@@ -60,6 +60,7 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> {
""")
fun findLatestByItemId(@Param("itemId") itemId: Long): List<StockLedger>
fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLedger?
fun findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(inventoryId: Long): StockLedger?

@Query("""
SELECT sl FROM StockLedger sl


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


+ 10
- 23
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt 查看文件

@@ -14,41 +14,28 @@ interface InventoryInfo{
val itemName: String?
@get:Value("#{target.item.type}")
val itemType: String?
// @get:Value("#{target.qty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}")
// @get:Value("#{target.onHandQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}")
@get:Value("#{target.onHandQty}")
val onHandQty: BigDecimal?
// @get:Value("#{target.onHoldQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}")
@get:Value("#{target.onHoldQty}")
val onHoldQty: BigDecimal?
// @get:Value("#{target.unavailableQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}")
@get:Value("#{target.unavailableQty}")
val unavailableQty: BigDecimal?
// @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty) / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}")
@get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}")
/** Available = onHand - unavailable (onHold is not maintained by stock-UOM triggers). */
@get:Value("#{(target.onHandQty - target.unavailableQty)}")
val availableQty: BigDecimal?
@get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom.code}")
@get:Value("#{target.stockUom?.id}")
val stockUomId: Long?
@get:Value("#{target.stockUom?.code}")
val uomCode: String?
@get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfudesc}")
@get:Value("#{target.stockUom?.udfudesc}")
val uomUdfudesc: String?
@get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfShortDesc}")
@get:Value("#{target.stockUom?.udfShortDesc}")
val uomShortDesc: String?
// @get:Value("#{target.qty * target.uom.gramPerSmallestUnit}")
// val germPerSmallestUnit: BigDecimal?
@get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}")
@get:Value("#{(target.onHandQty - target.unavailableQty)}")
val qtyPerSmallestUnit: BigDecimal?
@get:Value("#{target.item.itemUoms.^[baseUnit == true && deleted == false]?.uom?.udfudesc}")
/** Base UOM snapshot from inventory.uomId (not current item baseUnit master). */
@get:Value("#{target.uom?.udfudesc}")
val baseUom: String?
// @get:Value("#{target.qty * (target.uom.unit4 != '' ? target.uom.unit4Qty " +
// ": target.uom.unit3 != '' ? target.uom.unit3Qty " +
// ": target.uom.unit2 != '' ? target.uom.unit2Qty " +
// ": target.uom.unit1Qty)}")
// val qtyPerSmallestUnit: BigDecimal?
// @get:Value("#{target.uom.unit4 != '' ? target.uom.unit4 " +
// ": target.uom.unit3 != '' ? target.uom.unit3 " +
// ": target.uom.unit2 != '' ? target.uom.unit2 " +
// ": target.uom.unit1}")
// val smallestUnit: String?
val price: BigDecimal?
@get:Value("#{target.currency?.name}")
val currencyName: String?


+ 28
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfoDto.kt 查看文件

@@ -0,0 +1,28 @@
package com.ffii.fpsms.modules.stock.entity.projection

import java.math.BigDecimal
import java.time.LocalDateTime

/** JDBC row for Item Search: inventory buckets plus current master stock UOM when missing. */
data class InventoryInfoDto(
override val id: Long? = null,
override val itemId: Long? = null,
override val itemCode: String? = null,
override val itemName: String? = null,
override val itemType: String? = null,
override val onHandQty: BigDecimal? = null,
override val onHoldQty: BigDecimal? = null,
override val unavailableQty: BigDecimal? = null,
override val availableQty: BigDecimal? = null,
override val stockUomId: Long? = null,
override val uomCode: String? = null,
override val uomUdfudesc: String? = null,
override val uomShortDesc: String? = null,
override val qtyPerSmallestUnit: BigDecimal? = null,
override val baseUom: String? = null,
override val price: BigDecimal? = null,
override val currencyName: String? = null,
override val status: String? = null,
override val latestMarketUnitPrice: Double? = null,
override val latestMupUpdatedDate: LocalDateTime? = null,
) : InventoryInfo

+ 31
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRow.kt 查看文件

@@ -0,0 +1,31 @@
package com.ffii.fpsms.modules.stock.entity.projection

import java.math.BigDecimal
import java.time.LocalDateTime

/**
* Inventory search page: one row per item + UoM
* (lot-line / bucket UOMs, plus current master stockUnit when it has no rows).
*/
interface InventoryStockUomSearchRow {
val id: Long?
val itemId: Long?
val itemCode: String?
val itemName: String?
val itemType: String?
val onHandQty: BigDecimal?
val onHoldQty: BigDecimal?
val unavailableQty: BigDecimal?
val availableQty: BigDecimal?
val uomId: Long?
val uomCode: String?
val uomUdfudesc: String?
val uomShortDesc: String?
val qtyPerSmallestUnit: BigDecimal?
val baseUom: String?
val price: BigDecimal?
val currencyName: String?
val status: String?
val latestMarketUnitPrice: Double?
val latestMupUpdatedDate: LocalDateTime?
}

+ 28
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryStockUomSearchRowDto.kt 查看文件

@@ -0,0 +1,28 @@
package com.ffii.fpsms.modules.stock.entity.projection

import java.math.BigDecimal
import java.time.LocalDateTime

/** JDBC row for inventory search: lot UOMs plus current master stock UOM when missing. */
data class InventoryStockUomSearchRowDto(
override val id: Long? = null,
override val itemId: Long? = null,
override val itemCode: String? = null,
override val itemName: String? = null,
override val itemType: String? = null,
override val onHandQty: BigDecimal? = null,
override val onHoldQty: BigDecimal? = null,
override val unavailableQty: BigDecimal? = null,
override val availableQty: BigDecimal? = null,
override val uomId: Long? = null,
override val uomCode: String? = null,
override val uomUdfudesc: String? = null,
override val uomShortDesc: String? = null,
override val qtyPerSmallestUnit: BigDecimal? = null,
override val baseUom: String? = null,
override val price: BigDecimal? = null,
override val currencyName: String? = null,
override val status: String? = null,
override val latestMarketUnitPrice: Double? = null,
override val latestMupUpdatedDate: LocalDateTime? = null,
) : InventoryStockUomSearchRow

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

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

import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.ItemUomRespository
import com.ffii.fpsms.modules.stock.entity.Inventory
import com.ffii.fpsms.modules.stock.entity.InventoryLotLine
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import org.springframework.stereotype.Service

/**
* ChangeList #34 — single door for inventory bucket lookup by (itemId, stockUomId).
*
* stockUomId = item_uom.uomId for the lot-line's stockItemUomId (same as DB trigger).
* Modules must call this instead of findByItemId / findInventoryForItemBaseUom.
*/
@Service
open class InventoryBucketResolver(
private val itemUomRepository: ItemUomRespository,
private val inventoryRepository: InventoryRepository,
) {
/** item_uom.id → uom_conversion.id used as inventory.stockUomId. */
open fun resolveStockUomId(stockItemUomId: Long): Long? {
val itemUom = itemUomRepository.findByIdAndDeletedIsFalse(stockItemUomId) ?: return null
return resolveStockUomId(itemUom)
}

open fun resolveStockUomId(itemUom: ItemUom?): Long? = itemUom?.uom?.id

open fun resolveStockUomId(lotLine: InventoryLotLine?): Long? =
resolveStockUomId(lotLine?.stockUom) ?: lotLine?.stockUom?.id?.let { resolveStockUomId(it) }

open fun findInventoryBucket(itemId: Long, stockUomId: Long): Inventory? =
inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUomId)
?: inventoryRepository.findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId, stockUomId)

/**
* Preferred path: resolve stockUomId from the lot line, then find the bucket.
* If the lot line has no stock UOM, falls back to the item's current stockUnit uomId
* (transition only — callers with a lot line should always set stockItemUomId).
*/
open fun findInventoryBucket(itemId: Long, lotLine: InventoryLotLine?): Inventory? {
val fromLot = resolveStockUomId(lotLine)
if (fromLot != null) {
return findInventoryBucket(itemId, fromLot)
}
val stockUnitUomId =
itemUomRepository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id
?: return null
return findInventoryBucket(itemId, stockUnitUomId)
}

open fun findInventoryBucketByStockItemUomId(itemId: Long, stockItemUomId: Long): Inventory? {
val stockUomId = resolveStockUomId(stockItemUomId) ?: return null
return findInventoryBucket(itemId, stockUomId)
}
}

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

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

import java.math.BigDecimal

/**
* Location Search qty rules aligned with inventory **bucket** display
* (`available = onHand − unavailable`; hold is separate, not subtracted from available).
*
* Location Search only aggregates `status = available` lot lines at the filtered location,
* so unavailable at that slice is 0 and available collapses to physical remain `in − out`.
*/
object InventoryLocationSearchQty {
fun onHand(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal {
val inn = inQty ?: BigDecimal.ZERO
val out = outQty ?: BigDecimal.ZERO
return inn.subtract(out)
}

/** Bucket-aligned available for available-status lines: physical remain (hold not subtracted). */
fun available(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal = onHand(inQty, outQty)

/** SQL fragments used by [InventoryService.buildLocationSearchUnionSql]. */
const val SQL_ON_HAND = "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))"
const val SQL_AVAILABLE = "SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0))"
const val SQL_ON_HOLD = "SUM(COALESCE(ill.holdQty, 0))"
}

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

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

import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
import java.math.BigDecimal

/**
* Shared lot-line remain rules.
*
* Option A triggers set inventory.onHand = Σ(in−out). Hold is lot metadata only.
* Pick / Miss / Bad / Expiry via [StockOutLineService.createStockOut] do
* `out += qty` and `hold = max(0, hold−qty)` — cap is **physical remain** `in−out`.
* Adjustment / TRF out-only paths keep hold unchanged — cap is `in−out−hold`.
*/
object InventoryLotLineQtyGuard {
fun physicalRemain(inQty: BigDecimal?, outQty: BigDecimal?): BigDecimal {
val i = inQty ?: BigDecimal.ZERO
val o = outQty ?: BigDecimal.ZERO
return i.subtract(o)
}

fun availableQty(
inQty: BigDecimal?,
outQty: BigDecimal?,
holdQty: BigDecimal?,
): BigDecimal {
val h = holdQty ?: BigDecimal.ZERO
return physicalRemain(inQty, outQty).subtract(h)
}

fun isIssueStockOutType(type: String?): Boolean {
val t = type?.trim()?.lowercase() ?: return false
return t == "miss" || t == "bad" || t == "expiry"
}

/** Cap for createStockOut / issue posting: qty ≤ in − out. */
fun requireQtyWithinPhysicalRemain(
inQty: BigDecimal?,
outQty: BigDecimal?,
qty: BigDecimal,
label: String = "數量",
) {
if (qty <= BigDecimal.ZERO) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "${label}必須大於 0")
}
val remain = physicalRemain(inQty, outQty)
if (qty > remain) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"${label}超過實體剩餘(remain=$remain, requested=$qty)。" +
"規則:requested ≤ inQty − outQty。",
)
}
}

/** Cap for adjustment / stock-transfer out-only: qty ≤ in − out − hold. */
fun requireQtyWithinAvailable(
inQty: BigDecimal?,
outQty: BigDecimal?,
holdQty: BigDecimal?,
qty: BigDecimal,
label: String = "數量",
) {
if (qty <= BigDecimal.ZERO) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "${label}必須大於 0")
}
val available = availableQty(inQty, outQty, holdQty)
if (qty > available) {
throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"${label}超過可用量(availableQty=$available, requested=$qty)。" +
"規則:requested ≤ inQty − outQty − holdQty。",
)
}
}
}

+ 69
- 52
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
@@ -25,8 +24,10 @@ import net.sf.jasperreports.engine.JasperCompileManager
import org.springframework.core.io.ClassPathResource
import org.springframework.data.domain.PageRequest
import java.time.LocalDate
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException
import java.io.FileNotFoundException
import java.io.IOException
import java.math.BigDecimal
@@ -35,7 +36,6 @@ import java.util.Optional
import kotlin.jvm.optionals.getOrNull
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest
import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse
import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo
@@ -44,6 +44,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse
import com.ffii.fpsms.modules.jobOrder.service.JobOrderService
import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest
import com.ffii.fpsms.modules.master.service.PrinterService
import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest
import com.ffii.core.utils.ZebraPrinterUtil
@@ -58,8 +59,8 @@ open class InventoryLotLineService(
private val warehouseRepository: WarehouseRepository,
private val itemUomRespository: ItemUomRespository,
private val stockInLineRepository: StockInLineRepository,
private val inventoryRepository: InventoryRepository,
private val printerService: PrinterService,
private val settingsService: SettingsService,
@Lazy
private val jobOrderService: JobOrderService
) {
@@ -76,10 +77,15 @@ open class InventoryLotLineService(
return inventoryLotLineRepository.findInventoryLotLineInfoByInventoryLotItemIdIn(itemIds)
}

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

val warehouseIds = resolveWarehouseIds(request.storeId, request.warehouse, request.area)
if (warehouseIds != null && warehouseIds.isEmpty()) {
return RecordsRes(emptyList(), 0)
}

val response = if (request.stockIssueBadItem == true) {
inventoryLotLineRepository.findStockIssueBadItemLotLinesByItemId(
request.itemId,
@@ -87,8 +93,15 @@ open class InventoryLotLineService(
listOf(InventoryLotLineStatus.AVAILABLE, InventoryLotLineStatus.UNAVAILABLE),
pageable,
)
} else if (warehouseIds != null) {
inventoryLotLineRepository.findInventoryLotLineInfoByItemIdAndWarehouseIdIn(
request.itemId,
warehouseIds,
request.uomId,
pageable,
)
} else {
inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, pageable)
inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, request.uomId, pageable)
}

val records = response.content
@@ -96,6 +109,28 @@ open class InventoryLotLineService(
return RecordsRes<InventoryLotLineInfo>(records, total.toInt());
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10
* Returns null when no location filter is set.
*/
private fun resolveWarehouseIds(
storeId: String?,
warehouse: String?,
area: String?,
): List<Long>? {
val trimmedStoreId = storeId?.trim()?.takeIf { it.isNotEmpty() }
val trimmedWarehouse = warehouse?.trim()?.takeIf { it.isNotEmpty() }
val trimmedArea = area?.trim()?.takeIf { it.isNotEmpty() }
if (trimmedStoreId == null && trimmedWarehouse == null && trimmedArea == null) {
return null
}
return warehouseRepository.findByLocationFilters(
trimmedStoreId,
trimmedWarehouse,
trimmedArea,
).mapNotNull { it.id }
}

open fun searchStockIssueBadItemLotLines(
request: SearchStockIssueBadItemLotLineRequest,
): RecordsRes<InventoryLotLineInfo> {
@@ -151,11 +186,29 @@ open class InventoryLotLineService(
}
}
open fun saveInventoryLotLine(request: SaveInventoryLotLineRequest): InventoryLotLine {
val inventoryLotLine =
request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } ?: InventoryLotLine()
val existing = request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() }
val inventoryLotLine = existing ?: InventoryLotLine()
val inventoryLot = request.inventoryLotId?.let { inventoryLotRepository.findById(it).getOrNull() }
val warehouse = request.warehouseId?.let { warehouseRepository.findById(it).getOrNull() }
val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() }
?: existing?.stockUom
if (stockUom == null) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "入倉明細缺少庫存單位,無法入帳")
}
// M-E-01: one inventory lot must not mix two stock-UOM buckets (item_uom.uomId).
val lotId = inventoryLot?.id ?: existing?.inventoryLot?.id
val newBucketUomId = stockUom.uom?.id
if (lotId != null && newBucketUomId != null) {
val conflict = inventoryLotLineRepository.findAllByInventoryLotId(lotId).any { sibling ->
sibling.deleted != true &&
sibling.id != existing?.id &&
sibling.stockUom?.uom?.id != null &&
sibling.stockUom!!.uom!!.id != newBucketUomId
}
if (conflict) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "同一批次不可混用不同庫存單位")
}
}
val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } }

inventoryLotLine.apply {
@@ -192,9 +245,6 @@ open class InventoryLotLineService(

val updatedLotLine = saveInventoryLotLine(updateRequest)

// ADD THIS: Update inventory table after lot line status change
updateInventoryTable(updatedLotLine)

return MessageResponse(
id = updatedLotLine.id,
name = updatedLotLine.id.toString(),
@@ -205,49 +255,11 @@ open class InventoryLotLineService(
)
}

// ADD THIS: New method to update inventory table
private fun updateInventoryTable(inventoryLotLine: InventoryLotLine) {
try {
// Get the item ID from the inventory lot
val itemId = inventoryLotLine.inventoryLot?.item?.id
if (itemId == null) {
println("Cannot update inventory table: itemId is null for lot line ${inventoryLotLine.id}")
return
}

// Calculate onHoldQty (sum of holdQty from available lots only)
val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE)
.sumOf { it.holdQty ?: BigDecimal.ZERO }

// Calculate unavailableQty (sum of inQty from unavailable lots only)
val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE)
.sumOf {
val inQty = it.inQty ?: BigDecimal.ZERO
val outQty = it.outQty ?: BigDecimal.ZERO
val remainingQty = inQty.minus(outQty)
remainingQty
}

// Update the inventory table
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
if (inventory != null) {
inventory.onHoldQty = onHoldQty
inventory.unavailableQty = unavailableQty
inventoryRepository.save(inventory)

println("Updated inventory for item $itemId: onHoldQty=$onHoldQty, unavailableQty=$unavailableQty")
} else {
println("Inventory not found for item $itemId")
}
} catch (e: Exception) {
println("Error updating inventory table for lot line ${inventoryLotLine.id}: ${e.message}")
e.printStackTrace()
}
}
@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")
@@ -287,13 +299,18 @@ open class InventoryLotLineService(
field["acceptedQty"] = "%.2f".format(info.acceptedQty)

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

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


+ 344
- 57
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryService.kt 查看文件

@@ -17,14 +17,19 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType
import com.ffii.fpsms.modules.stock.entity.*
import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo
import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfoDto
import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow
import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRowDto
import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest
import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.core.io.support.PathMatchingResourcePatternResolver
import org.springframework.data.domain.PageRequest
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.server.ResponseStatusException
import java.math.BigDecimal
import java.sql.Timestamp
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
@@ -57,73 +62,355 @@ open class InventoryService(
return inventoryRepository.findInventoryInfoByDeletedIsFalse();
}

open fun allInventoriesByPage(request: SearchInventoryRequest): RecordsRes<InventoryInfo>{
val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10)
/**
* Item Search: one row per (itemId, stockUomId) bucket, plus current master stockUnit
* as a 0-qty row when that UoM has no inventory bucket.
*/
open fun allInventoriesByPage(request: SearchInventoryRequest): RecordsRes<InventoryInfo> {
val pageSize = (request.pageSize ?: 10).coerceAtLeast(1)
val pageNum = (request.pageNum ?: 0).coerceAtLeast(0)
val code = request.code?.trim().orEmpty()
val name = request.name?.trim().orEmpty()
val type = request.type?.trim().orEmpty()
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }

val response = if (lotNo != null) {
val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo)
if (itemIds.isEmpty()) {
return RecordsRes(emptyList(), 0)
}
inventoryRepository.findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse(
code = request.code,
name = request.name,
type = request.type,
itemIds = itemIds,
pageable = pageable
)
val itemIds = if (lotNo != null) {
inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo)
} else {
inventoryRepository.findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(
code = request.code,
name = request.name,
type = request.type,
pageable = pageable
)
emptyList()
}
if (lotNo != null && itemIds.isEmpty()) {
return RecordsRes(emptyList(), 0)
}

val records = response.content
val total = response.totalElements

return RecordsRes<InventoryInfo>(records, total.toInt());
val args = mutableMapOf<String, Any>(
"code" to code,
"name" to name,
"type" to type,
"hasItemIdFilter" to if (lotNo != null) 1 else 0,
"itemIds" to itemIds.ifEmpty { listOf(-1L) },
)
val unionSql = buildItemSearchUnionSql()
val total = jdbcDao.queryForInt("SELECT COUNT(*) FROM ($unionSql) t", args)
args["limit"] = pageSize
args["offset"] = pageNum * pageSize
val rows = jdbcDao.queryForList(
"$unionSql ORDER BY itemCode ASC, uomUdfudesc ASC LIMIT :limit OFFSET :offset",
args,
)
return RecordsRes(rows.map { mapItemSearchRow(it) }, total)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21
* Inventory search page only: no baseUnit/uomId filter;
* one inventory row per item (latest id). Shared getRecordByPage is unchanged.
* Location Search: one row per item + lot-line stock UoM (bucket key), plus current
* master stockUnit as a 0-qty row when that UoM has no matching lots.
* Qty formula matches inventory bucket display: available = onHand − unavailable
* (hold shown separately; not subtracted). See [InventoryLocationSearchQty].
*/
open fun searchInventoriesByLatestInventory(
request: SearchInventoryRequest,
): RecordsRes<InventoryInfo> {
val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10)
): RecordsRes<InventoryStockUomSearchRow> {
val pageSize = (request.pageSize ?: 10).coerceAtLeast(1)
val pageNum = (request.pageNum ?: 0).coerceAtLeast(0)
val code = request.code?.trim().orEmpty()
val name = request.name?.trim().orEmpty()
val type = request.type?.trim().orEmpty()
val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() }
val lotNo = request.lotNo?.trim().orEmpty()
val warehouseIds = resolveWarehouseIds(request.storeId, request.warehouse, request.area)
if (warehouseIds != null && warehouseIds.isEmpty()) {
return RecordsRes(emptyList(), 0)
}

val response = if (lotNo != null) {
val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo)
if (itemIds.isEmpty()) {
return RecordsRes(emptyList(), 0)
}
inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse(
code = code,
name = name,
type = type,
itemIds = itemIds,
pageable = pageable,
)
} else {
inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(
code = code,
name = name,
type = type,
pageable = pageable,
)
val args = mutableMapOf<String, Any>(
"code" to code,
"name" to name,
"type" to type,
"lotNo" to lotNo,
"status" to InventoryLotLineStatus.AVAILABLE.value,
"hasWarehouseFilter" to if (warehouseIds != null) 1 else 0,
"warehouseIds" to (warehouseIds ?: listOf(-1L)),
)
val unionSql = buildLocationSearchUnionSql()
val total = jdbcDao.queryForInt("SELECT COUNT(*) FROM ($unionSql) t", args)
args["limit"] = pageSize
args["offset"] = pageNum * pageSize
val rows = jdbcDao.queryForList(
"$unionSql ORDER BY itemCode ASC, uomUdfudesc ASC LIMIT :limit OFFSET :offset",
args,
)
return RecordsRes(rows.map { mapLocationSearchRow(it) }, total)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */
private fun resolveWarehouseIds(
storeId: String?,
warehouse: String?,
area: String?,
): List<Long>? {
val trimmedStoreId = storeId?.trim()?.takeIf { it.isNotEmpty() }
val trimmedWarehouse = warehouse?.trim()?.takeIf { it.isNotEmpty() }
val trimmedArea = area?.trim()?.takeIf { it.isNotEmpty() }
if (trimmedStoreId == null && trimmedWarehouse == null && trimmedArea == null) {
return null
}
return warehouseRepository.findByLocationFilters(
trimmedStoreId,
trimmedWarehouse,
trimmedArea,
).mapNotNull { it.id }
}

private fun buildItemSearchUnionSql(): String {
val itemFilter = """
AND (:code = '' OR item.code LIKE CONCAT('%', :code, '%'))
AND (:name = '' OR item.name LIKE CONCAT('%', :name, '%'))
AND (:type = '' OR item.type = :type)
AND (:hasItemIdFilter = 0 OR item.id IN (:itemIds))
""".trimIndent()
return """
SELECT
inv.id AS id,
item.id AS itemId,
item.code AS itemCode,
item.name AS itemName,
item.type AS itemType,
COALESCE(inv.onHandQty, 0) AS onHandQty,
COALESCE(inv.onHoldQty, 0) AS onHoldQty,
COALESCE(inv.unavailableQty, 0) AS unavailableQty,
(COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0)) AS availableQty,
uc.id AS stockUomId,
uc.code AS uomCode,
uc.udfudesc AS uomUdfudesc,
uc.udfShortDesc AS uomShortDesc,
(COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0)) AS qtyPerSmallestUnit,
uc_base.udfudesc AS baseUom,
inv.price AS price,
cur.name AS currencyName,
inv.status AS status,
item.latestMarketUnitPrice AS latestMarketUnitPrice,
item.latestMupUpdatedDate AS latestMupUpdatedDate
FROM inventory inv
INNER JOIN items item ON item.id = inv.itemId AND item.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = inv.stockUomId
LEFT JOIN uom_conversion uc_base ON uc_base.id = inv.uomId
LEFT JOIN currency cur ON cur.id = inv.currencyId
WHERE inv.deleted = 0
$itemFilter

UNION ALL

SELECT
0 AS id,
item.id AS itemId,
item.code AS itemCode,
item.name AS itemName,
item.type AS itemType,
0 AS onHandQty,
0 AS onHoldQty,
0 AS unavailableQty,
0 AS availableQty,
uc.id AS stockUomId,
uc.code AS uomCode,
uc.udfudesc AS uomUdfudesc,
uc.udfShortDesc AS uomShortDesc,
0 AS qtyPerSmallestUnit,
uc.udfudesc AS baseUom,
NULL AS price,
NULL AS currencyName,
'active' AS status,
item.latestMarketUnitPrice AS latestMarketUnitPrice,
item.latestMupUpdatedDate AS latestMupUpdatedDate
FROM items item
INNER JOIN item_uom iu
ON iu.itemId = item.id
AND iu.stockUnit = 1
AND iu.deleted = 0
AND iu.id = (
SELECT MIN(iu2.id)
FROM item_uom iu2
WHERE iu2.itemId = item.id
AND iu2.stockUnit = 1
AND iu2.deleted = 0
)
INNER JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE item.deleted = 0
$itemFilter
AND EXISTS (
SELECT 1
FROM inventory inv2
WHERE inv2.itemId = item.id
AND inv2.deleted = 0
)
AND NOT EXISTS (
SELECT 1
FROM inventory inv2
WHERE inv2.itemId = item.id
AND inv2.deleted = 0
AND inv2.stockUomId = uc.id
)
""".trimIndent()
}

return RecordsRes(response.content, response.totalElements.toInt())
private fun buildLocationSearchUnionSql(): String {
val itemFilter = """
AND (:code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%')))
AND (:name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%')))
AND (:type = '' OR item.type = :type)
""".trimIndent()
val lotFilter = """
ill.deleted = 0
AND il.deleted = 0
AND item.deleted = 0
AND ill.status = :status
AND (:hasWarehouseFilter = 0 OR ill.warehouseId IN (:warehouseIds))
AND (:lotNo = '' OR il.lotNo = :lotNo)
""".trimIndent()
val lotExistsFilter = """
ill2.deleted = 0
AND il2.deleted = 0
AND ill2.status = :status
AND (:hasWarehouseFilter = 0 OR ill2.warehouseId IN (:warehouseIds))
AND (:lotNo = '' OR il2.lotNo = :lotNo)
""".trimIndent()
return """
SELECT
MAX(ill.id) AS id,
item.id AS itemId,
item.code AS itemCode,
item.name AS itemName,
item.type AS itemType,
${InventoryLocationSearchQty.SQL_ON_HAND} AS onHandQty,
${InventoryLocationSearchQty.SQL_ON_HOLD} AS onHoldQty,
${InventoryLocationSearchQty.SQL_AVAILABLE} AS availableQty,
uom.id AS uomId,
uom.code AS uomCode,
uom.udfudesc AS uomUdfudesc,
uom.udfShortDesc AS uomShortDesc,
${InventoryLocationSearchQty.SQL_AVAILABLE} AS qtyPerSmallestUnit,
MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice,
MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate
FROM inventory_lot_line ill
INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId
INNER JOIN items item ON item.id = il.itemId
INNER JOIN item_uom su ON su.id = ill.stockItemUomId
INNER JOIN uom_conversion uom ON uom.id = su.uomId
WHERE $lotFilter
$itemFilter
GROUP BY item.id, item.code, item.name, item.type, uom.id, uom.code, uom.udfudesc, uom.udfShortDesc

UNION ALL

SELECT
0 AS id,
item.id AS itemId,
item.code AS itemCode,
item.name AS itemName,
item.type AS itemType,
0 AS onHandQty,
0 AS onHoldQty,
0 AS availableQty,
uom.id AS uomId,
uom.code AS uomCode,
uom.udfudesc AS uomUdfudesc,
uom.udfShortDesc AS uomShortDesc,
0 AS qtyPerSmallestUnit,
item.latestMarketUnitPrice AS latestMarketUnitPrice,
item.latestMupUpdatedDate AS latestMupUpdatedDate
FROM items item
INNER JOIN item_uom iu
ON iu.itemId = item.id
AND iu.stockUnit = 1
AND iu.deleted = 0
AND iu.id = (
SELECT MIN(iu2.id)
FROM item_uom iu2
WHERE iu2.itemId = item.id
AND iu2.stockUnit = 1
AND iu2.deleted = 0
)
INNER JOIN uom_conversion uom ON uom.id = iu.uomId
WHERE item.deleted = 0
$itemFilter
AND EXISTS (
SELECT 1
FROM inventory_lot_line ill2
INNER JOIN inventory_lot il2 ON il2.id = ill2.inventoryLotId
WHERE il2.itemId = item.id
AND $lotExistsFilter
)
AND NOT EXISTS (
SELECT 1
FROM inventory_lot_line ill2
INNER JOIN inventory_lot il2 ON il2.id = ill2.inventoryLotId
INNER JOIN item_uom su2 ON su2.id = ill2.stockItemUomId
WHERE il2.itemId = item.id
AND su2.uomId = uom.id
AND $lotExistsFilter
)
""".trimIndent()
}

private fun mapItemSearchRow(row: Map<String, Any>): InventoryInfoDto {
val available = toBigDecimal(row["availableQty"])
return InventoryInfoDto(
id = toLong(row["id"]),
itemId = toLong(row["itemId"]),
itemCode = row["itemCode"]?.toString(),
itemName = row["itemName"]?.toString(),
itemType = row["itemType"]?.toString(),
onHandQty = toBigDecimal(row["onHandQty"]),
onHoldQty = toBigDecimal(row["onHoldQty"]),
unavailableQty = toBigDecimal(row["unavailableQty"]),
availableQty = available,
stockUomId = toLong(row["stockUomId"]),
uomCode = row["uomCode"]?.toString(),
uomUdfudesc = row["uomUdfudesc"]?.toString(),
uomShortDesc = row["uomShortDesc"]?.toString(),
qtyPerSmallestUnit = available,
baseUom = row["baseUom"]?.toString(),
price = toBigDecimal(row["price"]),
currencyName = row["currencyName"]?.toString(),
status = row["status"]?.toString(),
latestMarketUnitPrice = (row["latestMarketUnitPrice"] as? Number)?.toDouble(),
latestMupUpdatedDate = toLocalDateTime(row["latestMupUpdatedDate"]),
)
}

private fun mapLocationSearchRow(row: Map<String, Any>): InventoryStockUomSearchRowDto {
val available = toBigDecimal(row["availableQty"])
return InventoryStockUomSearchRowDto(
id = toLong(row["id"]),
itemId = toLong(row["itemId"]),
itemCode = row["itemCode"]?.toString(),
itemName = row["itemName"]?.toString(),
itemType = row["itemType"]?.toString(),
onHandQty = toBigDecimal(row["onHandQty"]),
onHoldQty = toBigDecimal(row["onHoldQty"]),
unavailableQty = BigDecimal.ZERO,
availableQty = available,
uomId = toLong(row["uomId"]),
uomCode = row["uomCode"]?.toString(),
uomUdfudesc = row["uomUdfudesc"]?.toString(),
uomShortDesc = row["uomShortDesc"]?.toString(),
qtyPerSmallestUnit = available,
latestMarketUnitPrice = (row["latestMarketUnitPrice"] as? Number)?.toDouble(),
latestMupUpdatedDate = toLocalDateTime(row["latestMupUpdatedDate"]),
)
}

private fun toLong(value: Any?): Long? = (value as? Number)?.toLong()

private fun toBigDecimal(value: Any?): BigDecimal? = when (value) {
null -> null
is BigDecimal -> value
is Number -> BigDecimal(value.toString())
else -> null
}

private fun toLocalDateTime(value: Any?): LocalDateTime? = when (value) {
is LocalDateTime -> value
is Timestamp -> value.toLocalDateTime()
is java.sql.Date -> value.toLocalDate().atStartOfDay()
else -> null
}

open fun allInventoriesByItems(items: List<Items>): List<InventoryInfo>{
@@ -366,17 +653,17 @@ open class InventoryService(
val warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!!
// val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!)
val stockUnit = itemUomService.findStockUnitByItemId(itemId = inventoryLot.item!!.id!!)
// val zero = BigDecimal.ZERO
// val one = BigDecimal.ONE
// val ratio = (salesUnit?.ratioN ?: zero).divide(salesUnit?.ratioD ?: one)

?: throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"入倉明細缺少庫存單位,無法入帳",
)
// ChangeList #8: lot-line stockItemUomId must be set so trigger writes inventory.stockUomId.
InventoryLotLine().apply {
this.inventoryLot = inventoryLot
this.warehouse = warehouse
this.inQty = request.qty
this.status = InventoryLotLineStatus.AVAILABLE
this.stockUom = stockUnit
// this.stockUom = salesUnit
}
}
val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries)


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

+ 3
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockAdjustmentService.kt 查看文件

@@ -25,7 +25,7 @@ open class StockAdjustmentService(
private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository
) {

/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */
/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.8 | 2026-09-16 */
@Transactional
open fun submit(request: StockAdjustmentRequest): MessageResponse {
val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id }
@@ -55,7 +55,8 @@ open class StockAdjustmentService(
productLotNo = current.productlotNo?.takeIf { it.isNotBlank() },
dnNo = current.dnNo?.takeIf { it.isNotBlank() },
type = stockType,
warehouseId = current.warehouseId
warehouseId = current.warehouseId,
uomId = current.uomId,
)
)
saveAdjustmentRecordForStockIn(stockInLine, current.remarks)


+ 83
- 25
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
@@ -28,11 +27,13 @@ import com.ffii.core.utils.PdfUtils;
import com.ffii.core.utils.ZebraPrinterUtil
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus
import com.ffii.fpsms.modules.master.entity.ItemUom
import com.ffii.fpsms.modules.master.entity.ItemUomRespository
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,11 +99,13 @@ 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,
private val stockLedgerRepository: StockLedgerRepository,
private val inventoryRepository: InventoryRepository,
private val inventoryBucketResolver: InventoryBucketResolver,
private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService,
private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository,
/** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */
@@ -331,6 +334,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 {
@@ -445,10 +475,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)
@@ -535,8 +568,13 @@ open class StockInLineService(
this.inventoryLot = stockInLine.inventoryLot
this.warehouse = warehouse
this.inQty = convertedBaseQty
this.outQty = BigDecimal.ZERO
this.status = InventoryLotLineStatus.AVAILABLE
this.stockUom = stockItemUom
?: throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"入倉明細缺少庫存單位,無法入帳",
)
}
saveLines.add(inventoryLotLine)
}
@@ -649,13 +687,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
@@ -944,6 +984,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 {
@@ -1030,6 +1071,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 _tPutAwayTotal = System.nanoTime()
@@ -1070,7 +1112,7 @@ open class StockInLineService(
if (putAwayDeltaStockQty > BigDecimal.ZERO) {
val _tLedger = System.nanoTime()
createStockLedgerForStockIn(stockInLine, putAwayDeltaStockQty.toDouble())
_logStep("createStockLedgerForStockIn(includes findLatestByItemId)", _tLedger)
_logStep("createStockLedgerForStockIn(includes findLatestByInventoryId)", _tLedger)
}

val _tQtyCalc = System.nanoTime()
@@ -1208,7 +1250,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")
@@ -1242,12 +1285,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)
}
@@ -1389,16 +1437,17 @@ open class StockInLineService(
val item = stockInLine.item ?: return

val _tInv = System.nanoTime()
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
_logStep("inventoryRepository.findInventoryForItemBaseUom", _tInv)
val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockInLine.inventoryLotLine) ?: return
_logStep("inventoryBucketResolver.findInventoryBucket", _tInv)

// ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算
// Chain running balance on this inventory bucket only (not the whole item).
val _tLatest = System.nanoTime()
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!)
_logStep("stockLedgerRepository.findLatestByItemId", _tLatest)
val latestLedger = inventory.id?.let {
stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it)
}
_logStep("stockLedgerRepository.findLatestByInventoryId", _tLatest)

// ✅ 修复:如果 latestLedger 为 null(第一次 stock in),previousBalance 应该是 0
// 因为 inventory.onHandQty 已经被触发器更新为包含本次 stock in 的数量
// First ledger on this bucket starts at 0: inventory.onHandQty already includes this stock-in.
val previousBalance = latestLedger?.balance ?: 0.0

val newBalance = previousBalance + inQty
@@ -1491,12 +1540,7 @@ open class StockInLineService(
savedStockInLine.inventoryLot = savedInventoryLot

// Step 4: Create a row in inventory_lot_line table
val stockItemUom = itemUomRepository.findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc(
request.itemId
) ?: throw IllegalArgumentException(
"Stock UOM not found for item: id=${request.itemId}, itemNo=${request.itemNo}. " +
"Ensure at least one item_uom row has stockUnit=1, deleted=0 for this item."
)
val stockItemUom = resolveItemUomForNewLot(request.itemId, request.itemNo, request.uomId)

val warehouse = if (request.warehouseId != null) {
warehouseRepository.findById(request.warehouseId).orElseThrow()
@@ -1526,6 +1570,20 @@ open class StockInLineService(
return savedStockInLine
}

private fun resolveItemUomForNewLot(itemId: Long, itemNo: String, uomId: Long?): ItemUom {
if (uomId != null) {
return itemUomRepository.findFirstByItemIdAndUomIdAndDeletedIsFalse(itemId, uomId)
?: throw IllegalArgumentException(
"Item UOM not found for item: id=$itemId, itemNo=$itemNo, uomId=$uomId."
)
}
return itemUomRepository.findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc(itemId)
?: throw ResponseStatusException(
HttpStatus.BAD_REQUEST,
"入倉明細缺少庫存單位,無法入帳",
)
}

@Transactional
open fun createStockInForExistingInventoryLotLine(
request: StockInRequest,


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

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

import com.ffii.core.support.JdbcDao
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.support.TransactionTemplate
import java.time.LocalDate
import java.util.concurrent.ConcurrentHashMap

/**
* Daily close for [stock_lot_day] (fix step 2.6):
* - moves: aggregate day's ledger by lot (opening / in / out / closing)
* - carry: previous closing > 0 with no ledger today → opening = closing = prev.closing
*
* Never closes today or future dates. Idempotent via ON DUPLICATE KEY UPDATE.
* Unchanged rows are skipped so a second close of the same day does not rewrite.
*/
@Service
open class StockLotDayCloseService(
private val jdbcDao: JdbcDao,
private val transactionManager: PlatformTransactionManager,
) {
private val log = LoggerFactory.getLogger(StockLotDayCloseService::class.java)
private val inFlight = ConcurrentHashMap.newKeySet<String>()

data class CloseDayResult(
val date: LocalDate,
val moveRows: Int,
val carryRows: Int,
) {
val rowsWritten: Int get() = moveRows + carryRows
}

data class CloseRunResult(
val days: List<CloseDayResult>,
val skipped: Boolean = false,
val skipReason: String? = null,
) {
val totalRows: Int get() = days.sumOf { it.rowsWritten }
val dateFrom: LocalDate? get() = days.minOfOrNull { it.date }
val dateTo: LocalDate? get() = days.maxOfOrNull { it.date }
}

/**
* Close [day] only. [actor] is written to createdBy/modifiedBy.
*/
open fun closeDay(day: LocalDate, actor: String = ACTOR_SCHEDULER): CloseDayResult {
require(day.isBefore(LocalDate.now())) {
"only dates before today can be closed (got $day)"
}
if (!inFlight.add(LOCK)) {
throw IllegalStateException("stock_lot_day close already running")
}
try {
return doCloseDay(day, actor)
} finally {
inFlight.remove(LOCK)
}
}

/**
* Same as [doCloseDay] without the scheduler lock — for [StockLedgerFixService] which already
* holds its own run lock around day steps 2.1–2.6.
* Fix UI may close **today** on a freeze-night dump (e.g. 31/8 dump → fix 0831 same evening).
* Scheduler [closeDay] / [closeThrough] still reject today.
*/
open fun upsertDayForFix(day: LocalDate): CloseDayResult {
require(!day.isAfter(LocalDate.now())) {
"only dates on or before today can be closed for fix (got $day)"
}
return doCloseDay(day, ACTOR_FIX)
}

/**
* Close yesterday, optionally catching up from last closed day (capped by [catchUpDays]).
*/
open fun closeYesterday(catchUpDays: Int = 7, actor: String = ACTOR_SCHEDULER): CloseRunResult {
val yesterday = LocalDate.now().minusDays(1)
return closeThrough(yesterday, catchUpDays, actor)
}

/**
* Close through [target] (inclusive). Catch-up from last closed + 1, capped by [catchUpDays].
* If [target] is null, uses yesterday.
*/
open fun closeThrough(
target: LocalDate? = null,
catchUpDays: Int = 7,
actor: String = ACTOR_SCHEDULER,
): CloseRunResult {
val end = target ?: LocalDate.now().minusDays(1)
require(end.isBefore(LocalDate.now())) {
"only dates before today can be closed (got $end)"
}
val cap = catchUpDays.coerceAtLeast(0)
if (!inFlight.add(LOCK)) {
log.warn("stock_lot_day close skipped: already running")
return CloseRunResult(emptyList(), skipped = true, skipReason = "already running")
}
try {
val lastClosed = maxClosedDate()
val from = when {
lastClosed == null -> end
lastClosed.isBefore(end) -> {
val gapStart = lastClosed.plusDays(1)
val earliest = end.minusDays(cap.toLong())
if (gapStart.isBefore(earliest)) earliest else gapStart
}
else -> end // already closed through end → re-close end (idempotent)
}
val days = mutableListOf<CloseDayResult>()
var d = from
while (!d.isAfter(end)) {
days += doCloseDay(d, actor)
d = d.plusDays(1)
}
log.info(
"stock_lot_day close done from={} to={} days={} rows={} actor={}",
from, end, days.size, days.sumOf { it.rowsWritten }, actor,
)
return CloseRunResult(days)
} finally {
inFlight.remove(LOCK)
}
}

private fun doCloseDay(day: LocalDate, actor: String): CloseDayResult {
val args = mapOf(
"day" to day,
"dayNext" to day.plusDays(1),
"prevDay" to day.minusDays(1),
"actor" to actor,
)
val t0 = System.nanoTime()
val result = TransactionTemplate(transactionManager).execute {
jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sld_lots_today")
jdbcDao.executeUpdate(
"""
CREATE TEMPORARY TABLE tmp_sld_lots_today (
inventoryLotLineId BIGINT NOT NULL PRIMARY KEY
)
""".trimIndent(),
)
try {
jdbcDao.executeUpdate(FILL_LOTS_TODAY_SQL, args)
val moveRows = jdbcDao.executeUpdate(MOVES_SQL, args)
val carryRows = jdbcDao.executeUpdate(CARRY_SQL, args)
CloseDayResult(date = day, moveRows = moveRows, carryRows = carryRows)
} finally {
jdbcDao.executeUpdate("DROP TEMPORARY TABLE IF EXISTS tmp_sld_lots_today")
}
} ?: CloseDayResult(date = day, moveRows = 0, carryRows = 0)
log.info(
"stock_lot_day close {} moves={} carry={} {}ms actor={}",
day, result.moveRows, result.carryRows, (System.nanoTime() - t0) / 1_000_000, actor,
)
return result
}

private fun maxClosedDate(): LocalDate? {
val row = jdbcDao.queryForList(
"""
SELECT MAX(date) AS d
FROM stock_lot_day
WHERE IFNULL(deleted, 0) = 0
""".trimIndent(),
).firstOrNull() ?: return null
val v = row["d"] ?: return null
return when (v) {
is LocalDate -> v
is java.sql.Date -> v.toLocalDate()
is java.sql.Timestamp -> v.toLocalDateTime().toLocalDate()
else -> LocalDate.parse(v.toString().take(10))
}
}

companion object {
const val ACTOR_SCHEDULER = "stock-lot-day-close"
const val ACTOR_FIX = "ledger-fix-api"
private const val LOCK = "stock-lot-day-close"

private const val ON_DUP = """
ON DUPLICATE KEY UPDATE
itemId = VALUES(itemId),
itemCode = VALUES(itemCode),
uomId = VALUES(uomId),
lotNo = VALUES(lotNo),
opening = VALUES(opening),
inQty = VALUES(inQty),
outQty = VALUES(outQty),
closing = VALUES(closing),
modified = NOW(),
modifiedBy = VALUES(modifiedBy),
version = stock_lot_day.version + 1,
deleted = 0
"""

private val FILL_LOTS_TODAY_SQL = """
INSERT INTO tmp_sld_lots_today (inventoryLotLineId)
SELECT DISTINCT sl.inventoryLotLineId
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
AND sl.inventoryLotLineId IS NOT NULL
"""

private val MOVES_SQL = """
INSERT INTO stock_lot_day (
created, createdBy, version, modified, modifiedBy, deleted,
inventoryLotLineId, itemId, itemCode, uomId, lotNo, date,
opening, inQty, outQty, closing
)
SELECT
NOW(), :actor, 0, NOW(), :actor, 0,
x.inventoryLotLineId,
x.itemId,
x.itemCode,
x.uomId,
il.lotNo,
:day,
firstSl.lotQtyBefore,
x.inQty,
x.outQty,
lastSl.lotQtyAfter
FROM (
SELECT
sl.inventoryLotLineId,
MIN(sl.itemId) AS itemId,
MIN(sl.itemCode) AS itemCode,
MIN(sl.uomId) AS uomId,
MIN(sl.id) AS firstId,
MAX(sl.id) AS lastId,
CAST(SUM(COALESCE(sl.inQty, 0)) AS DECIMAL(14,2)) AS inQty,
CAST(SUM(COALESCE(sl.outQty, 0)) AS DECIMAL(14,2)) AS outQty
FROM stock_ledger sl
WHERE sl.deleted = 0
AND sl.date >= :day AND sl.date < :dayNext
AND sl.inventoryLotLineId IS NOT NULL
AND sl.lotQtyAfter IS NOT NULL
GROUP BY sl.inventoryLotLineId
) x
INNER JOIN stock_ledger firstSl ON firstSl.id = x.firstId
INNER JOIN stock_ledger lastSl ON lastSl.id = x.lastId
LEFT JOIN inventory_lot_line ill ON ill.id = x.inventoryLotLineId
LEFT JOIN inventory_lot il ON il.id = ill.inventoryLotId
LEFT JOIN stock_lot_day cur
ON cur.inventoryLotLineId = x.inventoryLotLineId
AND cur.date = :day
WHERE cur.inventoryLotLineId IS NULL
OR IFNULL(cur.deleted, 0) <> 0
OR NOT (
cur.opening <=> firstSl.lotQtyBefore
AND cur.inQty <=> x.inQty
AND cur.outQty <=> x.outQty
AND cur.closing <=> lastSl.lotQtyAfter
AND cur.itemId <=> x.itemId
AND cur.uomId <=> x.uomId
AND IFNULL(cur.itemCode, '') <=> IFNULL(x.itemCode, '')
AND IFNULL(cur.lotNo, '') <=> IFNULL(il.lotNo, '')
)
$ON_DUP
"""

private val CARRY_SQL = """
INSERT INTO stock_lot_day (
created, createdBy, version, modified, modifiedBy, deleted,
inventoryLotLineId, itemId, itemCode, uomId, lotNo, date,
opening, inQty, outQty, closing
)
SELECT
NOW(), :actor, 0, NOW(), :actor, 0,
prev.inventoryLotLineId,
prev.itemId,
prev.itemCode,
prev.uomId,
prev.lotNo,
:day,
prev.closing,
0,
0,
prev.closing
FROM stock_lot_day prev
LEFT JOIN tmp_sld_lots_today t
ON t.inventoryLotLineId = prev.inventoryLotLineId
LEFT JOIN stock_lot_day cur
ON cur.inventoryLotLineId = prev.inventoryLotLineId
AND cur.date = :day
WHERE prev.date = :prevDay
AND IFNULL(prev.deleted, 0) = 0
AND prev.closing > 0
AND t.inventoryLotLineId IS NULL
AND (
cur.inventoryLotLineId IS NULL
OR IFNULL(cur.deleted, 0) <> 0
OR NOT (
cur.opening <=> prev.closing
AND cur.inQty <=> 0
AND cur.outQty <=> 0
AND cur.closing <=> prev.closing
AND cur.itemId <=> prev.itemId
AND cur.uomId <=> prev.uomId
AND IFNULL(cur.itemCode, '') <=> IFNULL(prev.itemCode, '')
AND IFNULL(cur.lotNo, '') <=> IFNULL(prev.lotNo, '')
)
)
$ON_DUP
"""
}
}

+ 43
- 108
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt 查看文件

@@ -43,7 +43,6 @@ import com.ffii.fpsms.modules.pickOrder.service.PickOrderService
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.common.SecurityUtils
import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository
import java.time.LocalTime
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
@@ -78,9 +77,9 @@ private val inventoryLotLineService: InventoryLotLineService,
private val bagService: BagService,
private val pickOrderService: PickOrderService,
private val stockLedgerRepository: StockLedgerRepository,
private val inventoryRepository: InventoryRepository,
private val pickExecutionIssueRepository: PickExecutionIssueRepository,
private val itemUomService: ItemUomService,
private val inventoryBucketResolver: InventoryBucketResolver,
@Lazy private val doReplenishmentService: DoReplenishmentService,
): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) {

@@ -265,13 +264,6 @@ fun handleQc(stockOutLine: StockOutLine, request: UpdateStockOutLineRequest): Li
this.holdQty = this.holdQty!!.minus((request.qty.div(ratio)).toBigDecimal())
}
inventoryLotLineRepository.save(targetLotLineEntry)
// update inventory
val inventory = inventoryRepository.findByItemId(request.itemId).orElseThrow()
val inventoryEntry = inventory.apply {
this.onHandQty = this.onHandQty!!.minus((request.qty.div(ratio)).toBigDecimal())
this.onHoldQty = this.onHoldQty!!.minus((request.qty.div(ratio)).toBigDecimal())
}
inventoryRepository.save(inventoryEntry)
return listOf(stockOutLine, newStockOutLine)
}
@Transactional
@@ -816,10 +808,6 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
val savedLotLine = inventoryLotLineRepository.save(inventoryLotLine)
println("Saved lot line: ${savedLotLine.id}, status: ${savedLotLine.status}, holdQty: ${savedLotLine.holdQty}")
// Step 3: Update inventory table
println("Updating inventory table...")
updateInventoryTableAfterLotRejection(inventoryLotLine)
// Step 4: Trigger resuggest
val pickOrderLine = stockOutLine.pickOrderLine
if (pickOrderLine?.pickOrder?.id != null) {
@@ -838,51 +826,6 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long {
}
}

// ADD THIS: Update inventory table after lot rejection
private fun updateInventoryTableAfterLotRejection(inventoryLotLine: InventoryLotLine) {
try {
println("=== UPDATING INVENTORY TABLE ===")
val itemId = inventoryLotLine.inventoryLot?.item?.id
println("Item ID: $itemId")
if (itemId != null) {
// Calculate onHoldQty (sum of holdQty from available lots only)
val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE)
.sumOf { it.holdQty ?: BigDecimal.ZERO }
// Calculate unavailableQty (sum of inQty from unavailable lots only)
val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId,InventoryLotLineStatus.UNAVAILABLE)
.sumOf {
val inQty = it.inQty ?: BigDecimal.ZERO
val outQty = it.outQty ?: BigDecimal.ZERO
val remainingQty = inQty.minus(outQty)
remainingQty
}
println("Calculated onHoldQty: $onHoldQty")
println("Calculated unavailableQty: $unavailableQty")
// Update the inventory table
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
if (inventory != null) {
println("Found inventory record: ${inventory.id}")
println("Current inventory - onHoldQty: ${inventory.onHoldQty}, unavailableQty: ${inventory.unavailableQty}")
inventory.onHoldQty = onHoldQty
inventory.unavailableQty = unavailableQty
val savedInventory = inventoryRepository.save(inventory)
println("Updated inventory - onHoldQty: ${savedInventory.onHoldQty}, unavailableQty: ${savedInventory.unavailableQty}")
} else {
println("No inventory record found for item $itemId")
}
}
} catch (e: Exception) {
println("Error updating inventory table after lot rejection: ${e.message}")
e.printStackTrace()
}
}

@Transactional(rollbackFor = [Exception::class])
open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
val startTime = System.currentTimeMillis()
@@ -954,14 +897,6 @@ open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
this.holdQty = (this.holdQty ?: zero) - baseQty
}
inventoryLotLineRepository.save(lotLine)

// 更新 inventory
val inv = inventoryRepository.findByItemId(lotLine.inventoryLot?.item?.id!!).orElseThrow()
inv.apply {
this.onHandQty = (this.onHandQty ?: zero) - baseQty
this.onHoldQty = (this.onHoldQty ?: zero) - baseQty
}
inventoryRepository.save(inv)
}

processedIds += line.stockOutLineId
@@ -1359,7 +1294,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
}
val item = savedSol.item
val inventoryBeforeUpdate = item?.id?.let { itemId ->
itemUomService.findInventoryForItemBaseUom(itemId)
inventoryBucketResolver.findInventoryBucket(itemId, ill)
}
val onHandQtyBeforeUpdate =
(inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
@@ -1374,7 +1309,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
} else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) {
val item = savedSol.item
val inventoryBeforeUpdate = item?.id?.let { itemId ->
itemUomService.findInventoryForItemBaseUom(itemId)
inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine)
}
val onHandQtyBeforeUpdate =
(inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble()
@@ -1512,12 +1447,10 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
/** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */
private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) {
val item = stockOutLine.item ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return
val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) ?: return

val outQty = stockOutLine.qty?.toDouble() ?: 0.0
// Use latest ledger balance (same pattern as createStockLedgerForStockIn) so balance is correct when multiple actions run in one transaction
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!)
val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = resolvePreviousBalance(inventory = inventory)
val newBalance = previousBalance - outQty

val ill = stockOutLine.inventoryLotLine
@@ -1647,9 +1580,20 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
open fun createStockOut(request: StockOutRequest): StockOutLine {
val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow()
val qtyBd = BigDecimal.valueOf(request.qty)
val label = when {
InventoryLotLineQtyGuard.isIssueStockOutType(request.type) ->
request.type!!.trim().replaceFirstChar { it.uppercase() } + " 數量"
else -> "出倉數量"
}
InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain(
inventoryLotLine.inQty,
inventoryLotLine.outQty,
qtyBd,
label,
)
val oldHold = inventoryLotLine.holdQty ?: BigDecimal.ZERO

// Increase outQty and release hold (pick / QR); inventory aggregates via trigger inventory_lot_line_AFTER_UPDATE
// Increase outQty and release hold (pick / QR / Miss / Bad); inventory aggregates via trigger
val updatedInventoryLotLine = inventoryLotLine.apply {
val currentOutQty = this.outQty ?: BigDecimal.ZERO
this.outQty = currentOutQty + qtyBd
@@ -1782,16 +1726,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
return
}

val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!)
val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine)
if (inventory == null) {
if (flushAfterSave) {
println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}")
println("${tracePrefix}Skip ledger creation: inventory bucket not found by itemId=${item.id}")
}
return
}

val previousBalance = resolvePreviousBalance(
itemId = item.id!!,
inventory = inventory,
onHandQtyBeforeUpdate = onHandQtyBeforeUpdate
)
@@ -1839,14 +1782,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse {
}

private fun resolvePreviousBalance(
itemId: Long,
inventory: Inventory,
onHandQtyBeforeUpdate: Double? = null
): Double {
if (onHandQtyBeforeUpdate != null) {
return onHandQtyBeforeUpdate
}
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId)
val latestLedger = inventory.id?.let {
stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it)
}
return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}

@@ -2155,13 +2099,19 @@ fun applyStockOutLineDelta(
val postingType = (typeOverride ?: savedSol.type ?: "").trim().lowercase()
val isIssuePosting = postingType == "miss" || postingType == "bad" || postingType == "expiry"

// 2) inventory_lot_line + inventory
// 2) inventory_lot_line; inventory onHand/unavailable is owned by the lot-line trigger
if (!skipInventoryWrite) {
val lotLine = savedSol.inventoryLotLine
if (lotLine != null) {
if (isIssuePosting) {
val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null)
if (latestLotLine != null) {
InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain(
latestLotLine.inQty,
latestLotLine.outQty,
deltaQty,
postingType.replaceFirstChar { it.uppercase() } + " 數量",
)
val prevStatus = latestLotLine.status
val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO
val currentOutQty = latestLotLine.outQty ?: BigDecimal.ZERO
@@ -2195,30 +2145,14 @@ fun applyStockOutLineDelta(
}
}
}

val itemId = savedSol.item?.id
if (itemId != null) {
val inv = itemUomService.findInventoryForItemBaseUom(itemId)
if (inv != null) {
val zero = BigDecimal.ZERO
inv.onHandQty = (inv.onHandQty ?: zero).minus(deltaQty)
if (!isIssuePosting) {
inv.onHoldQty = (inv.onHoldQty ?: zero).minus(deltaQty)
}
inventoryRepository.save(inv)
}
}
}

// 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave)
if (!skipLedgerWrite) {
val item = savedSol.item ?: return savedSol
val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return savedSol
val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, savedSol.inventoryLotLine) ?: return savedSol

val previousBalance = resolvePreviousBalance(
itemId = item.id!!,
inventory = inventory
)
val previousBalance = resolvePreviousBalance(inventory = inventory)
val outQty = deltaQty.toDouble()
val newBalance = previousBalance - outQty

@@ -2349,22 +2283,23 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult
val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert)

// 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut)
// Keep per-item running balance so multiple lines in the same batch
// Keep per-bucket running balance so multiple lines in the same batch
// chain from the previous ledger balance instead of reusing inventory.onHandQty.
val runningLedgerBalanceByItemId = mutableMapOf<Long, Double>()
val inventoryByItemId = mutableMapOf<Long, Inventory>()
val runningLedgerBalanceByBucket = mutableMapOf<Pair<Long, Long>, Double>()
val inventoryByBucket = mutableMapOf<Pair<Long, Long>, Inventory>()
savedLines.sortedBy { it.id ?: Long.MAX_VALUE }.forEach { sol ->
val item = sol.item ?: return@forEach
val itemId = item.id ?: return@forEach
val inv = inventoryByItemId[itemId]
?: itemUomService.findInventoryForItemBaseUom(itemId)?.also { inventoryByItemId[itemId] = it }
val stockUomId = inventoryBucketResolver.resolveStockUomId(sol.inventoryLotLine)
?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id
?: return@forEach
val bucketKey = itemId to stockUomId
val inv = inventoryByBucket[bucketKey]
?: inventoryBucketResolver.findInventoryBucket(itemId, stockUomId)?.also { inventoryByBucket[bucketKey] = it }
?: return@forEach
val delta = BigDecimal.valueOf(sol.qty ?: 0.0)
val prevBalance = runningLedgerBalanceByItemId[itemId]
?: run {
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId)
latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble()
}
val prevBalance = runningLedgerBalanceByBucket[bucketKey]
?: resolvePreviousBalance(inventory = inv)
val newBalance = prevBalance - delta.toDouble()

val ill = sol.inventoryLotLine
@@ -2392,7 +2327,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult
signedDelta = delta.negate(),
)
ledgersToInsert += ledger
runningLedgerBalanceByItemId[itemId] = newBalance
runningLedgerBalanceByBucket[bucketKey] = newBalance
}
stockLedgerRepository.saveAll(ledgersToInsert)



+ 7
- 5
src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineWorkbenchService.kt 查看文件

@@ -62,6 +62,7 @@ open class StockOutLineWorkbenchService(
private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService,
private val stockLedgerRepository: StockLedgerRepository,
private val itemUomService: ItemUomService,
private val inventoryBucketResolver: InventoryBucketResolver,
private val itemUomRespository: ItemUomRespository,
private val bagRepository: BagRepository,
private val bagService: BagService,
@@ -512,13 +513,13 @@ if (updated == 0) {
if (deltaQty <= BigDecimal.ZERO) return
val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return
val solItem = sol.item ?: return
val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return
// Fast path: use inventory onHand after update + delta to reconstruct previous balance.
val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = onHandAfter + deltaQty.toDouble()
val newBalance = previousBalance - deltaQty.toDouble()
val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) }
?: sol.inventoryLotLine
val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) }
val ledger = StockLedger().apply {
this.stockOutLine = sol
@@ -546,12 +547,13 @@ if (updated == 0) {
}

private fun resolveWorkbenchPreviousBalance(
itemId: Long,
inventory: Inventory,
onHandQtyBeforeUpdate: Double?,
): Double {
if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId)
val latestLedger = inventory.id?.let {
stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it)
}
return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}



+ 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" }
}
}

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

@@ -65,7 +65,8 @@ open class StockTakeRecordService(
val stockLedgerRepository: StockLedgerRepository,
val inventoryRepository: InventoryRepository,
val uomConversionRepository: UomConversionRepository,
val itemsRepository: ItemsRepository
val itemsRepository: ItemsRepository,
val inventoryBucketResolver: InventoryBucketResolver,

) {
private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java)
@@ -316,8 +317,31 @@ open class StockTakeRecordService(
}
}

/** 與 Approver 列表前端一致:優先第二次盤點數,否則第一次 */
/**
* 與核准列表「差異百分比」同一個選擇數量。
* lastSelect:1 第一次、2 第二次、3 審核員(盤點數 − 不良數)。
* 沒有 lastSelect 時用 finalQty 對應;再沒有才退回第二次,否則第一次。
*/
private fun resolvePickerQtyForVarianceFilter(response: InventoryLotDetailResponse): BigDecimal {
when (response.lastSelect) {
1 -> return response.firstStockTakeQty ?: BigDecimal.ZERO
2 -> return response.secondStockTakeQty ?: BigDecimal.ZERO
3 -> return approverGoodQty(response)
}
val finalQty = response.finalQty
if (finalQty != null) {
val approverGood = approverGoodQty(response)
if (response.approverQty != null && sameStockTakeQty(approverGood, finalQty)) {
return approverGood
}
val second = response.secondStockTakeQty
if (second != null && sameStockTakeQty(second, finalQty)) return second
val first = response.firstStockTakeQty
if (first != null && sameStockTakeQty(first, finalQty)) return first
if (response.approverQty != null && sameStockTakeQty(response.approverQty, finalQty)) {
return approverGood
}
}
val second = response.secondStockTakeQty
val first = response.firstStockTakeQty
return when {
@@ -327,6 +351,13 @@ open class StockTakeRecordService(
}
}

/** 與前端選擇數量一致:審核員盤點數減不良數。 */
private fun approverGoodQty(response: InventoryLotDetailResponse): BigDecimal =
(response.approverQty ?: BigDecimal.ZERO).subtract(response.approverBadQty ?: BigDecimal.ZERO)

private fun sameStockTakeQty(a: BigDecimal?, b: BigDecimal?): Boolean =
a != null && b != null && a.compareTo(b) == 0

private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal {
val selectedQty = resolvePickerQtyForVarianceFilter(response)
val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO
@@ -646,6 +677,7 @@ open class StockTakeRecordService(
return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */
open fun getApproverInventoryLotDetailsAll(
stockTakeId: Long? = null,
pageNum: Int = 0,
@@ -805,6 +837,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -1065,6 +1098,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -1175,6 +1209,7 @@ open class StockTakeRecordService(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseArea = warehouse?.area,
warehouseName = warehouse?.name,
@@ -1197,6 +1232,7 @@ open class StockTakeRecordService(
stockTakerName = rec.stockTakerName,
stockTakeEndTime = rec.stockTakeEndTime,
approverTime = rec.approverTime,
pickerReviseCount = rec.pickerReviseCount ?: 0,
)
}

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

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

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

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

val headerStatus = stockTake.status
val headerStillOpen = headerStatus == StockTakeStatus.PENDING ||
headerStatus == StockTakeStatus.STOCKTAKING ||
headerStatus == StockTakeStatus.APPROVING
val revisingOpenPass = existingRecord.pickerFirstStockTakeQty != null &&
existingRecord.pickerSecondStockTakeQty == null &&
existingRecord.approverStockTakeQty == null &&
existingRecord.status.equals("pass", ignoreCase = true) &&
headerStillOpen
when {
// 已盤點的列在區域「已完成」前可改第一次數量。列狀態已完成、或區域已完成,不能改。
request.reviseFirstQty || revisingOpenPass -> {
if (!headerStillOpen) {
throw IllegalArgumentException("Cannot change counted qty after the section is completed")
}
if (!existingRecord.status.equals("pass", ignoreCase = true)) {
throw IllegalArgumentException("Only counted records can be changed")
}
if (existingRecord.pickerFirstStockTakeQty == null) {
throw IllegalArgumentException("No first count to change")
}
if (existingRecord.pickerSecondStockTakeQty != null || existingRecord.approverStockTakeQty != null) {
throw IllegalArgumentException("Cannot change the first count after a later count exists")
}
val reviseCount = existingRecord.pickerReviseCount ?: 0
if (reviseCount >= 2) {
throw IllegalArgumentException("This count can only be changed 2 times")
}
val varianceQty = availableQty - request.qty - request.badQty
existingRecord.apply {
this.pickerFirstStockTakeQty = request.qty
this.pickerFirstBadQty = request.badQty
this.bookQty = availableQty
this.varianceQty = varianceQty
this.stockTakerId = stockTakerId
this.stockTakerName = user?.name
this.status = "pass"
this.pickerSecondStockTakeQty = null
this.pickerSecondBadQty = null
this.stockTakeEndTime = null
this.pickerReviseCount = reviseCount + 1
}
existingRecord
}
existingRecord.pickerFirstStockTakeQty == null -> {
val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty
val varianceQty = availableQty - request.qty - request.badQty
@@ -1363,7 +1448,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)
@@ -2125,7 +2211,7 @@ open fun batchSaveApproverStockTakeRecordsByIds(
"batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}",
adjustmentCache.inventoryLotLineByWarehouseLot.size,
adjustmentCache.inventoryLotById.size,
adjustmentCache.inventoryByItemId.size,
adjustmentCache.inventoryByItemAndStockUom.size,
adjustmentCache.stockTakeLineByRecordId.size,
elapsedMs(cacheBuildStartNs)
)
@@ -2213,7 +2299,7 @@ open fun batchSaveApproverStockTakeRecordsByIds(
val runtimeCache = BatchAdjustmentRuntimeCache(
stockOutByStockTakeId = mutableMapOf(),
stockInByStockTakeId = mutableMapOf(),
runningLedgerBalanceByItemId = mutableMapOf()
runningLedgerBalanceByBucket = mutableMapOf()
)
val adjustmentContext = StockTakeAdjustmentBatchContext()
var varianceCount = 0
@@ -2278,7 +2364,7 @@ open fun batchSaveApproverStockTakeRecordsByIds(
"batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}",
runtimeCache.stockOutByStockTakeId.size,
runtimeCache.stockInByStockTakeId.size,
runtimeCache.runningLedgerBalanceByItemId.size,
runtimeCache.runningLedgerBalanceByBucket.size,
adjustmentContext.stockTakeLineByRecordId.size,
adjustmentContext.stockOutLines.size,
adjustmentContext.stockInLines.size,
@@ -2398,9 +2484,7 @@ private fun applyVarianceAdjustment(
?: throw IllegalArgumentException("Inventory lot not found")

val itemId = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found")
val inventory = cache?.inventoryByItemId?.get(itemId)
?: inventoryRepository.findByItemId(itemId).orElse(null)
?: throw IllegalArgumentException("Inventory not found for item")
val inventory = resolveInventoryForStockTake(itemId, inventoryLotLine, cache)

// 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建
val stockTakeLine = stockTakeRecord.id?.let { rid ->
@@ -2481,11 +2565,9 @@ private fun applyVarianceAdjustment(
// 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。
val itemIdForLedger = inventoryLot.item?.id
?: throw IllegalArgumentException("Item ID not found for stock take ledger")
val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger)
?: run {
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}
val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory)
val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey)
?: previousBalanceForInventoryBucket(inventory)
val newBalance = previousBalance - qtyToRemove.toDouble()

// FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05
@@ -2519,7 +2601,7 @@ private fun applyVarianceAdjustment(
} else {
stockLedgerRepository.save(stockLedger)
}
runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance)
runtimeCache?.runningLedgerBalanceByBucket?.put(ledgerKey, newBalance)

val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove)
latestLine.outQty = newOutQty
@@ -2619,11 +2701,9 @@ private fun applyVarianceAdjustment(

val itemIdForLedger = inventoryLot.item?.id
?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)")
val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger)
?: run {
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}
val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory)
val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey)
?: previousBalanceForInventoryBucket(inventory)
val newBalance = previousBalance + plusQty.toDouble()

// latestLine.inQty already includes plusQty above — snapshot live lot line after move.
@@ -2656,21 +2736,55 @@ private fun applyVarianceAdjustment(
} else {
stockLedgerRepository.save(stockLedger)
}
runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance)
runtimeCache?.runningLedgerBalanceByBucket?.put(ledgerKey, newBalance)
}
}

private fun resolveInventoryForStockTake(
itemId: Long,
lotLine: InventoryLotLine,
cache: BatchAdjustmentCache?,
): com.ffii.fpsms.modules.stock.entity.Inventory {
val stockUomId = inventoryBucketResolver.resolveStockUomId(lotLine)
if (stockUomId != null) {
cache?.inventoryByItemAndStockUom?.get(itemId to stockUomId)?.let { return it }
inventoryBucketResolver.findInventoryBucket(itemId, stockUomId)?.let { return it }
}
inventoryBucketResolver.findInventoryBucket(itemId, lotLine)?.let { return it }
throw IllegalArgumentException("Inventory not found for item $itemId stock UOM $stockUomId")
}

private fun stockTakeLedgerBucketKey(
itemId: Long,
lotLine: InventoryLotLine,
inventory: com.ffii.fpsms.modules.stock.entity.Inventory,
): Pair<Long, Long> {
val stockUomId = inventoryBucketResolver.resolveStockUomId(lotLine)
?: inventory.stockUom?.id
?: 0L
return itemId to stockUomId
}

private fun previousBalanceForInventoryBucket(
inventory: com.ffii.fpsms.modules.stock.entity.Inventory,
): Double {
val latest = inventory.id?.let {
stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it)
}
return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}

private data class BatchAdjustmentCache(
val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>,
val inventoryLotById: Map<Long, InventoryLot>,
val inventoryByItemId: Map<Long, com.ffii.fpsms.modules.stock.entity.Inventory>,
val inventoryByItemAndStockUom: Map<Pair<Long, Long>, com.ffii.fpsms.modules.stock.entity.Inventory>,
val stockTakeLineByRecordId: Map<Long, StockTakeLine>
)

private data class BatchAdjustmentRuntimeCache(
val stockOutByStockTakeId: MutableMap<Long, StockOut>,
val stockInByStockTakeId: MutableMap<Long, StockIn>,
val runningLedgerBalanceByItemId: MutableMap<Long, Double>
val runningLedgerBalanceByBucket: MutableMap<Pair<Long, Long>, Double>
)

private data class StockTakeAdjustmentBatchContext(
@@ -2725,10 +2839,15 @@ private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdju
emptyMap()
}
val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct()
val inventoryByItemId =
val inventoryByItemAndStockUom =
if (itemIds.isNotEmpty()) {
inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds)
.groupBy { it.item?.id ?: 0L }
.mapNotNull { inv ->
val itemId = inv.item?.id ?: return@mapNotNull null
val stockUomId = inv.stockUom?.id ?: inv.uom?.id ?: return@mapNotNull null
(itemId to stockUomId) to inv
}
.groupBy({ it.first }, { it.second })
.mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! }
} else {
emptyMap()
@@ -2745,7 +2864,7 @@ private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdju
return BatchAdjustmentCache(
inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot,
inventoryLotById = inventoryLotById,
inventoryByItemId = inventoryByItemId,
inventoryByItemAndStockUom = inventoryByItemAndStockUom,
stockTakeLineByRecordId = stockTakeLineByRecordId
)
}
@@ -2854,6 +2973,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,
@@ -2954,6 +3074,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch(
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseName = warehouse?.name,
status = ill.status?.name,


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

@@ -28,8 +28,7 @@ import kotlin.jvm.optionals.getOrNull
import org.springframework.transaction.annotation.Transactional
import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository
import java.math.RoundingMode
import com.ffii.fpsms.modules.stock.entity.InventoryRepository
import java.math.RoundingMode
import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus
import com.ffii.fpsms.modules.stock.web.model.PickAnotherLotRequest
import com.ffii.fpsms.modules.stock.service.InventoryLotLineService
@@ -54,7 +53,6 @@ open class SuggestedPickLotService(
val itemUomService: ItemUomService,
val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号
val pickOrderRepository: PickOrderRepository,
val inventoryRepository: InventoryRepository,
val failInventoryLotLineRepository: FailInventoryLotLineRepository,
val stockOutRepository: StockOutRepository,
val itemRepository: ItemsRepository,
@@ -1070,38 +1068,6 @@ val basePickOrders = (listOf(pickOrder) + allCompetingPickOrders).distinctBy { i
}
}
// ✅ 优化:批量更新 inventory table(收集所有 item IDs,只更新一次)
val allItemIdsToUpdate = allPickOrdersToResuggest
.flatMap { it.pickOrderLines }
.mapNotNull { it.item?.id }
.distinct()
if (allItemIdsToUpdate.isNotEmpty()) {
println("=== Batch updating inventory table for ${allItemIdsToUpdate.size} items ===")
allItemIdsToUpdate.forEach { itemId ->
// Calculate onHoldQty for ALL pick orders that use this item
// ✅ FIX: Use enum directly, not .value
val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE)
.sumOf { it.holdQty ?: BigDecimal.ZERO }
// ✅ FIX: Use enum directly, not .value
val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE)
.sumOf {
val inQty = it.inQty ?: BigDecimal.ZERO
val outQty = it.outQty ?: BigDecimal.ZERO
val remainingQty = inQty.minus(outQty)
remainingQty
}
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
if (inventory != null) {
inventory.onHoldQty = onHoldQty
inventory.unavailableQty = unavailableQty
inventoryRepository.save(inventory)
}
}
println("✅ Batch updated inventory table for ${allItemIdsToUpdate.size} items")
}
println("=== RESUGGEST DEBUG END ===")
return MessageResponse(
@@ -1342,40 +1308,6 @@ private fun generateOptimalSuggestionsForAllPickOrders(
return suggestions
}

private fun updateInventoryTableAfterResuggest(pickOrder: PickOrder) {
try {
// Get all item IDs from the pick order
val itemIds = pickOrder.pickOrderLines.mapNotNull { it.item?.id }.distinct()
itemIds.forEach { itemId ->
// FIX: Calculate onHoldQty for ALL pick orders that use this item, not just the current one
val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE)
.sumOf { it.holdQty ?: BigDecimal.ZERO }
// FIX: Use enum method instead of string method
val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE)
.sumOf {
val inQty = it.inQty ?: BigDecimal.ZERO
val outQty = it.outQty ?: BigDecimal.ZERO
val remainingQty = inQty.minus(outQty)
remainingQty
}
// Update the inventory table
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
if (inventory != null) {
inventory.onHoldQty = onHoldQty
inventory.unavailableQty = unavailableQty
inventoryRepository.save(inventory)
println("Updated inventory for item $itemId: onHoldQty=$onHoldQty, unavailableQty=$unavailableQty")
}
}
} catch (e: Exception) {
println("Error updating inventory table after resuggest: ${e.message}")
e.printStackTrace()
}
}
private fun findAllSuggestionsForPickOrder(pickOrderId: Long): List<SuggestedPickLot> {
val pickOrderLines = pickOrderRepository.findById(pickOrderId)
.orElseThrow()
@@ -1799,27 +1731,9 @@ private fun handleMissingItem(inventoryLotLine: InventoryLotLine, failQty: BigDe
// 处理损坏物品的情况
private fun handleBrokenItem(inventoryLotLine: InventoryLotLine, failQty: BigDecimal) {
try {
// 1. 将库存批次行标记为不可用
inventoryLotLine.status = InventoryLotLineStatus.UNAVAILABLE
// 2. 更新库存表的不可用数量
val itemId = inventoryLotLine.inventoryLot?.item?.id
if (itemId != null) {
val inventory = inventoryRepository.findByItemId(itemId).orElse(null)
if (inventory != null) {
val currentUnavailableQty = inventory.unavailableQty ?: BigDecimal.ZERO
inventory.unavailableQty = currentUnavailableQty.plus(failQty)
inventoryRepository.save(inventory)
println("Broken item handled: Updated unavailableQty by $failQty for item $itemId")
}
}
// 3. 保存库存批次行的状态变更
inventoryLotLineRepository.save(inventoryLotLine)
println("Broken item handled: Marked lot line ${inventoryLotLine.id} as unavailable")
} catch (e: Exception) {
println("Error handling broken item: ${e.message}")
throw e


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

@@ -24,6 +24,21 @@ data class WorkbenchSuggestionSummary(
val skippedExisting: Int,
)

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Case-insensitive match of [warehouseCode] against [excludeCodes].
* An entry matches the code exactly, or any code that starts with `{entry}-`.
* `"4F"` therefore excludes every 4F bin.
*/
fun isWorkbenchWarehouseCodeExcluded(warehouseCode: String?, excludeCodes: Collection<String>): Boolean {
val code = warehouseCode?.trim()?.uppercase() ?: return false
if (code.isEmpty() || excludeCodes.isEmpty()) return false
return excludeCodes.any { raw ->
val entry = raw.trim().uppercase()
entry.isNotEmpty() && (code == entry || code.startsWith("$entry-"))
}
}

@Service
open class SuggestedPickLotWorkbenchService(
private val pickOrderLineRepository: PickOrderLineRepository,
@@ -138,9 +153,32 @@ open class SuggestedPickLotWorkbenchService(
}

private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set<String>): Boolean {
if (excludeWarehouseCodes.isEmpty()) return false
val code = lot.warehouse?.code?.trim()?.uppercase()
return !code.isNullOrEmpty() && code in excludeWarehouseCodes
return isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, excludeWarehouseCodes)
}

/** 3F before 2F when expiry is the same. Other floors stay after those two. */
private fun suggestionFloorRank(lot: InventoryLotLine): Int {
val store = normalizeStoreId(lot.warehouse?.store_id)
?: floorTokenFromWarehouseCode(lot.warehouse?.code)
return when (store) {
"3F" -> 0
"2F" -> 1
else -> 2
}
}

private fun floorTokenFromWarehouseCode(code: String?): String? {
val head = code?.trim()?.substringBefore("-") ?: return null
return normalizeStoreId(head)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Closest expiry first; same day prefers 3F, then 2F.
*/
private fun compareSuggestedLots(a: InventoryLotLine, b: InventoryLotLine): Int {
val byExpiry = compareValues(a.inventoryLot?.expiryDate, b.inventoryLot?.expiryDate)
if (byExpiry != 0) return byExpiry
return suggestionFloorRank(a).compareTo(suggestionFloorRank(b))
}

private fun effectiveExcludeWarehouseCodes(raw: List<String>?): Set<String> {
@@ -163,7 +201,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Build suggestions with no-hold rule:
* available = inQty - outQty (ignore holdQty).
* When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id).
@@ -199,7 +237,7 @@ open class SuggestedPickLotWorkbenchService(
.filter { it.status == InventoryLotLineStatus.AVAILABLE }
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.filter { !isWarehouseExcluded(it, excludeSet) }
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)
.groupBy { it.inventoryLot?.item?.id }

var created = 0
@@ -296,7 +334,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId].
* Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order.
*/
@@ -360,7 +398,7 @@ open class SuggestedPickLotWorkbenchService(
if (storeKey == null) true
else normalizeStoreId(lot.warehouse?.store_id) == storeKey
}
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)

val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId)
val created = syncSuggestedPickLotsForLine(lineFresh, desired)
@@ -388,7 +426,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Workbench explicit-qty remainder support (single-next-lot):
* Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot.
* When [storeId] is provided, the next lot must be within the same store (warehouse.store_id).
@@ -436,7 +474,7 @@ open class SuggestedPickLotWorkbenchService(
if (storeKey == null) true
else normalizeStoreId(lot.warehouse?.store_id) == storeKey
}
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)
.firstOrNull()

val desired = listOf(nextLot to targetQty)
@@ -449,7 +487,7 @@ open class SuggestedPickLotWorkbenchService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02
* Workbench explicit-qty split support:
* Set suggestions for this POL to an exact [targetQty] (independent of POL.qty).
* - If [targetQty] <= 0: soft-delete all active suggestions for the line.
@@ -486,7 +524,7 @@ open class SuggestedPickLotWorkbenchService(
.filter { it.inventoryLot?.expiryDate?.isBefore(today) != true }
.filter { !isWarehouseExcluded(it, excludeSet) }
.filter { matchesPolUom(it, line) }
.sortedBy { it.inventoryLot?.expiryDate }
.sortedWith(this::compareSuggestedLots)

val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId)
val created = syncSuggestedPickLotsForLine(line, desired)


+ 6
- 4
src/main/java/com/ffii/fpsms/modules/stock/web/InventoryController.kt 查看文件

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

import com.ffii.core.response.RecordsRes
import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo
import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow
import com.ffii.fpsms.modules.stock.service.InventoryService
import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest
import org.springframework.web.bind.annotation.*
@@ -16,19 +17,20 @@ class InventoryController(
return inventoryService.allInventories()
}

/** Full inventory search: one row per (itemId, stockUomId) bucket, plus current stockUnit when missing. */
@GetMapping("/getRecordByPage")
fun allInventoriesByPage(@ModelAttribute request: SearchInventoryRequest): RecordsRes<InventoryInfo> {
return inventoryService.allInventoriesByPage(request);
return inventoryService.allInventoriesByPage(request)
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21
* Inventory search page only: latest inventory row per item, no baseUnit/uomId filter.
* Location Search: one row per item + lot-line stock UoM, plus current master stockUnit when missing.
* Item Search uses GET /inventory/getRecordByPage (inventory buckets + current stockUnit).
*/
@GetMapping("/searchLatest/getRecordByPage")
fun searchInventoriesByLatestInventory(
@ModelAttribute request: SearchInventoryRequest,
): RecordsRes<InventoryInfo> {
): RecordsRes<InventoryStockUomSearchRow> {
return inventoryService.searchInventoriesByLatestInventory(request)
}



部分文件因为文件数量过多而无法显示

正在加载...
取消
保存