Keep pick-exclude rules on the settings page. Same expiry date still suggests 3F before 2F. Co-authored-by: Cursor <[email protected]>controllQRcodescanWarehouse
| @@ -1,9 +1,11 @@ | |||||
| package com.ffii.core.support; | package com.ffii.core.support; | ||||
| import java.sql.SQLException; | |||||
| import java.util.UUID; | import java.util.UUID; | ||||
| import org.apache.commons.logging.Log; | import org.apache.commons.logging.Log; | ||||
| import org.apache.commons.logging.LogFactory; | import org.apache.commons.logging.LogFactory; | ||||
| import org.springframework.dao.DataAccessException; | |||||
| import org.springframework.http.HttpStatus; | import org.springframework.http.HttpStatus; | ||||
| import org.springframework.http.ResponseEntity; | import org.springframework.http.ResponseEntity; | ||||
| import org.springframework.security.access.AccessDeniedException; | import org.springframework.security.access.AccessDeniedException; | ||||
| @@ -33,6 +35,39 @@ public class ErrorHandler extends ResponseEntityExceptionHandler { | |||||
| return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); | 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 }) | @ExceptionHandler({ InternalServerErrorException.class, Exception.class }) | ||||
| @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) | @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) | ||||
| public ResponseEntity<ErrorRes> error500(final Exception ex) { | public ResponseEntity<ErrorRes> error500(final Exception ex) { | ||||
| @@ -3,8 +3,11 @@ package com.ffii.core.utils | |||||
| import org.apache.pdfbox.pdmodel.PDDocument | import org.apache.pdfbox.pdmodel.PDDocument | ||||
| import org.apache.pdfbox.rendering.PDFRenderer | import org.apache.pdfbox.rendering.PDFRenderer | ||||
| import java.awt.image.BufferedImage | import java.awt.image.BufferedImage | ||||
| import java.awt.image.DataBufferByte | |||||
| import java.awt.image.MultiPixelPackedSampleModel | |||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.io.File | import java.io.File | ||||
| import java.net.InetSocketAddress | |||||
| import java.net.Socket | import java.net.Socket | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| @@ -12,6 +15,7 @@ class BrotherPrinterUtil { | |||||
| companion object { | companion object { | ||||
| private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept | 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 const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 | ||||
| private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) | 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") | 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 -> | PDDocument.load(pdfFile).use { document -> | ||||
| val renderer = PDFRenderer(document) | val renderer = PDFRenderer(document) | ||||
| val totalPages = document.numberOfPages | 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 | socket.soTimeout = SOCKET_TIMEOUT_MS | ||||
| val os = socket.getOutputStream() | val os = socket.getOutputStream() | ||||
| @@ -52,6 +59,7 @@ class BrotherPrinterUtil { | |||||
| log.info("Brother print job sent successfully") | log.info("Brother print job sent successfully") | ||||
| } | } | ||||
| } | } | ||||
| } | |||||
| } | } | ||||
| private fun getPjlHeader(qty: Int): ByteArray { | 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. | * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. | ||||
| */ | */ | ||||
| private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { | private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { | ||||
| convertPackedBinaryToPcl(image, dpi)?.let { return it } | |||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| val width = image.width | val width = image.width | ||||
| val height = image.height | val height = image.height | ||||
| @@ -75,21 +85,21 @@ class BrotherPrinterUtil { | |||||
| out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset | out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset | ||||
| // PCL: Set resolution and start raster | // PCL: Set resolution and start raster | ||||
| out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) | 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) { | for (y in 0 until height) { | ||||
| val rowBytes = (width + 7) / 8 | val rowBytes = (width + 7) / 8 | ||||
| val data = ByteArray(rowBytes) | val data = ByteArray(rowBytes) | ||||
| for (x in 0 until width) { | for (x in 0 until width) { | ||||
| // Check if pixel is dark (threshold) | // Check if pixel is dark (threshold) | ||||
| val color = image.getRGB(x, y) | val color = image.getRGB(x, y) | ||||
| val isBlack = (color and 0xFF) < 128 | |||||
| val isBlack = (color and 0xFF) < 128 | |||||
| if (isBlack) { | if (isBlack) { | ||||
| data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() | data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() | ||||
| } | } | ||||
| } | } | ||||
| // PCL: Transfer Raster Data (Esc * b # W [data]) | // PCL: Transfer Raster Data (Esc * b # W [data]) | ||||
| out.write("\u001B*b${rowBytes}W".toByteArray()) | out.write("\u001B*b${rowBytes}W".toByteArray()) | ||||
| out.write(data) | out.write(data) | ||||
| @@ -99,5 +109,64 @@ class BrotherPrinterUtil { | |||||
| out.write("\u001B*rB".toByteArray()) | out.write("\u001B*rB".toByteArray()) | ||||
| return out.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() | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument | |||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import java.io.File | import java.io.File | ||||
| import java.io.OutputStream | import java.io.OutputStream | ||||
| import java.net.InetSocketAddress | |||||
| import java.net.Socket | import java.net.Socket | ||||
| /** | /** | ||||
| @@ -111,7 +112,11 @@ open class CanonPrinterUtil { | |||||
| val copies = printQty ?: 1 | val copies = printQty ?: 1 | ||||
| repeat(copies) { copyIndex -> | repeat(copies) { copyIndex -> | ||||
| val tSend = System.nanoTime() | 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() | val os: OutputStream = socket.getOutputStream() | ||||
| // Send PJL commands | // Send PJL commands | ||||
| @@ -126,6 +131,7 @@ open class CanonPrinterUtil { | |||||
| os.flush() | os.flush() | ||||
| } | } | ||||
| } | |||||
| val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 | val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 | ||||
| log.info( | log.info( | ||||
| "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", | "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", | ||||
| @@ -1,18 +1,21 @@ | |||||
| package com.ffii.core.utils | package com.ffii.core.utils | ||||
| import com.google.zxing.BarcodeFormat | import com.google.zxing.BarcodeFormat | ||||
| import com.google.zxing.EncodeHintType | |||||
| import java.awt.image.BufferedImage | 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 { | open class QrCodeUtil { | ||||
| companion object { | companion object { | ||||
| fun generateQRCodeImage(barcodeText: String?): BufferedImage { | 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) | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -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. 51 | v1.0.1 | 2026-08-06 | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 | * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 | ||||
| * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) | * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) | ||||
| * Route board truck APIs → ADMIN / TRUCK_LANE / TESTING | |||||
| */ | */ | ||||
| @Bean | @Bean | ||||
| @Order(1) | @Order(1) | ||||
| @@ -140,6 +141,18 @@ public class SecurityConfig { | |||||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | .hasAnyAuthority("ADMIN", "M18_SYNC") | ||||
| .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") | .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") | ||||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | .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()) | .anyRequest().authenticated()) | ||||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | ||||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | ||||
| @@ -1,10 +1,20 @@ | |||||
| package com.ffii.fpsms.m18.model | package com.ffii.fpsms.m18.model | ||||
| import java.time.LocalDateTime | |||||
| data class SyncResult( | data class SyncResult( | ||||
| val totalProcessed: Int, | val totalProcessed: Int, | ||||
| val totalSuccess: Int, | val totalSuccess: Int, | ||||
| val totalFail: Int, | val totalFail: Int, | ||||
| val query: String? = null | 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" | |||||
| } | |||||
| @@ -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) { | if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { | ||||
| shopPoConds += "=and=(${lastDateConds})" | 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 | * 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). | * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). | ||||
| * No M18-side "加單" filtering is used. | * No M18-side "加單" filtering is used. | ||||
| @@ -173,15 +182,16 @@ open class M18DeliveryOrderService( | |||||
| isExtraSync: Boolean = false, | isExtraSync: Boolean = false, | ||||
| newOnly: Boolean = false, | newOnly: Boolean = false, | ||||
| ): SyncResult { | ): SyncResult { | ||||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | |||||
| val trimmed = code.trim() | |||||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) { | |||||
| return SyncResult( | return SyncResult( | ||||
| totalProcessed = 1, | totalProcessed = 1, | ||||
| totalSuccess = 0, | totalSuccess = 0, | ||||
| totalFail = 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( | val searchRequest = M18PurchaseOrderListRequest( | ||||
| stSearch = "po", | stSearch = "po", | ||||
| params = null, | params = null, | ||||
| @@ -228,6 +238,7 @@ open class M18DeliveryOrderService( | |||||
| val successList = mutableListOf<Long>() | val successList = mutableListOf<Long>() | ||||
| val skippedList = mutableListOf<Long>() | val skippedList = mutableListOf<Long>() | ||||
| val skippedNotPendingList = mutableListOf<String>() | |||||
| val successDetailList = mutableListOf<Long>() | val successDetailList = mutableListOf<Long>() | ||||
| val failList = mutableListOf<Long>() | val failList = mutableListOf<Long>() | ||||
| val failDetailList = mutableListOf<Long>() | val failDetailList = mutableListOf<Long>() | ||||
| @@ -291,6 +302,23 @@ open class M18DeliveryOrderService( | |||||
| // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | ||||
| val latestDeliveryOrderLog = | val latestDeliveryOrderLog = | ||||
| m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) | 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()) | //logger.info(latestDeliveryOrderLog.toString()) | ||||
| // Save to m18_data_log table | // Save to m18_data_log table | ||||
| // logger.info("${doRefType}: Saving for M18 Data Log...") | // 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}") | // logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}") | ||||
| try { | 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...") | // 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}") | // logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}") | ||||
| // Save to delivery_order table | // Save to delivery_order table | ||||
| @@ -621,12 +648,13 @@ open class M18DeliveryOrderService( | |||||
| logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | 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( | return SyncResult( | ||||
| totalProcessed = successList.size + failList.size + skippedList.size, | |||||
| totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, | |||||
| totalSuccess = successList.size, | totalSuccess = successList.size, | ||||
| totalFail = failList.size, | totalFail = failList.size, | ||||
| query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, | |||||
| query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -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) { | if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { | ||||
| materialPoConds += "=and=(${lastDateConds})" | materialPoConds += "=and=(${lastDateConds})" | ||||
| @@ -233,11 +226,28 @@ open class M18PurchaseOrderService( | |||||
| return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL | 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 { | 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)) | return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds)) | ||||
| } | } | ||||
| @@ -357,6 +367,7 @@ open class M18PurchaseOrderService( | |||||
| val successDetailList = mutableListOf<Long>() | val successDetailList = mutableListOf<Long>() | ||||
| val failList = mutableListOf<Long>() | val failList = mutableListOf<Long>() | ||||
| val failDetailList = mutableListOf<Long>() | val failDetailList = mutableListOf<Long>() | ||||
| val skippedNotPendingList = mutableListOf<String>() | |||||
| val affectedItemIds = mutableSetOf<Long>() | val affectedItemIds = mutableSetOf<Long>() | ||||
| val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>() | val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>() | ||||
| @@ -398,11 +409,15 @@ open class M18PurchaseOrderService( | |||||
| if (existingPurchaseOrderForSync != null && | if (existingPurchaseOrderForSync != null && | ||||
| existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING | existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING | ||||
| ) { | ) { | ||||
| val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown" | |||||
| logger.info( | logger.info( | ||||
| "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + | "${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" | "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" | ||||
| ) | ) | ||||
| skippedNotPendingList.add( | |||||
| "code=${existingPurchaseOrderForSync.code} status=$statusValue" | |||||
| ) | |||||
| return@forEach | return@forEach | ||||
| } | } | ||||
| @@ -729,11 +744,14 @@ open class M18PurchaseOrderService( | |||||
| logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") | logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") | ||||
| val baseQuery = purchaseOrdersWithType?.query ?: "" | |||||
| val skippedSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) | |||||
| return SyncResult( | return SyncResult( | ||||
| totalProcessed = successList.size + failList.size, | |||||
| totalProcessed = successList.size + failList.size + skippedNotPendingList.size, | |||||
| totalSuccess = successList.size, | totalSuccess = successList.size, | ||||
| totalFail = failList.size, | totalFail = failList.size, | ||||
| query = purchaseOrdersWithType?.query ?: "" | |||||
| query = baseQuery + skippedSuffix | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -69,6 +69,9 @@ public abstract class SettingNames { | |||||
| /** Mark expired inventory lot lines as unavailable (default 00:05 daily) */ | /** Mark expired inventory lot lines as unavailable (default 00:05 daily) */ | ||||
| public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry"; | 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_ROUGH = "SCHEDULE.prod.rough"; | ||||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | 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. | * 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"; | 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 | * 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.) */ | /** 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"; | 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"; | |||||
| } | } | ||||
| @@ -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.jobOrder.service.JobOrderPlanStartAutoService | ||||
| import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | ||||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | 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.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.entity.Settings | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | import com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import jakarta.annotation.PostConstruct | import jakarta.annotation.PostConstruct | ||||
| @@ -43,6 +44,8 @@ open class SchedulerService( | |||||
| @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, | @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, | ||||
| @Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int, | @Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int, | ||||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | @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. */ | /** 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.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | ||||
| @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | ||||
| @@ -71,6 +74,7 @@ open class SchedulerService( | |||||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | ||||
| private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | ||||
| private val schedulerSyncAlertService: SchedulerSyncAlertService, | private val schedulerSyncAlertService: SchedulerSyncAlertService, | ||||
| private val stockLotDayCloseService: StockLotDayCloseService, | |||||
| @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, | @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, | ||||
| @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, | @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 * * *" | const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" | ||||
| /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | ||||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | 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). */ | /** 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 scheduledGrnCodeSync: ScheduledFuture<*>? = null | ||||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | ||||
| var scheduledStockLotDayClose: ScheduledFuture<*>? = null | |||||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | ||||
| var scheduledDo1CatchUp: ScheduledFuture<*>? = null | var scheduledDo1CatchUp: ScheduledFuture<*>? = null | ||||
| @@ -213,6 +223,7 @@ open class SchedulerService( | |||||
| schedulePostCompletedDnGrn(); | schedulePostCompletedDnGrn(); | ||||
| scheduleGrnCodeSync(); | scheduleGrnCodeSync(); | ||||
| scheduleInventoryLotExpiry(); | scheduleInventoryLotExpiry(); | ||||
| scheduleStockLotDayClose(); | |||||
| scheduleJobOrderPlanStartAuto(); | scheduleJobOrderPlanStartAuto(); | ||||
| scheduleDo1CatchUpOnce(); | scheduleDo1CatchUpOnce(); | ||||
| scheduleSyncAlertWatchdog(); | 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 | // Function for schedule | ||||
| // --------------------------- FP-MTMS --------------------------- // | // --------------------------- FP-MTMS --------------------------- // | ||||
| @@ -730,14 +813,21 @@ open class SchedulerService( | |||||
| recordsProcessed = result?.totalProcessed ?: 0, | recordsProcessed = result?.totalProcessed ?: 0, | ||||
| recordsSaved = result?.totalSuccess ?: 0, | recordsSaved = result?.totalSuccess ?: 0, | ||||
| recordsFailed = result?.totalFail ?: 0, | recordsFailed = result?.totalFail ?: 0, | ||||
| errorMessage = error, | |||||
| query = result?.query ?: "", | |||||
| errorMessage = fitSyncLogText(error), | |||||
| query = fitSyncLogText(result?.query ?: ""), | |||||
| startTime = start, | startTime = start, | ||||
| endTime = LocalDateTime.now() | endTime = LocalDateTime.now() | ||||
| ) | ) | ||||
| schedulerSyncLogRepository.save(log) | 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() { | open fun getM18Dos2() { | ||||
| logger.info("DO Scheduler 2 - DO") | logger.info("DO Scheduler 2 - DO") | ||||
| val currentTime = LocalDateTime.now() | val currentTime = LocalDateTime.now() | ||||
| @@ -96,6 +96,17 @@ class SchedulerController( | |||||
| return "Inventory lot expiry status update triggered" | 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") | @GetMapping("/trigger/post-completed-dn-grn") | ||||
| fun triggerPostCompletedDnGrn( | fun triggerPostCompletedDnGrn( | ||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | ||||
| @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value | |||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| interface DeliveryOrderLineInfo { | interface DeliveryOrderLineInfo { | ||||
| val id: Long | |||||
| @get:Value("#{target.item?.name}") | @get:Value("#{target.item?.name}") | ||||
| val itemName: String? | val itemName: String? | ||||
| val itemNo: String | val itemNo: String | ||||
| @@ -43,6 +43,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | import com.ffii.fpsms.modules.pickOrder.entity.Truck | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | 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.StockOutRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOut | 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.master.entity.Items | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | 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 com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | ||||
| import java.util.Locale | import java.util.Locale | ||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| @@ -110,6 +110,7 @@ open class DeliveryOrderService( | |||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val suggestedPickLotService: SuggestedPickLotService, | private val suggestedPickLotService: SuggestedPickLotService, | ||||
| private val inventoryLotLineRepository: InventoryLotLineRepository, | private val inventoryLotLineRepository: InventoryLotLineRepository, | ||||
| private val inventoryRepository: InventoryRepository, | |||||
| private val stockOutRepository: StockOutRepository, | private val stockOutRepository: StockOutRepository, | ||||
| private val stockOutLineRepository: StockOutLIneRepository, | private val stockOutLineRepository: StockOutLIneRepository, | ||||
| private val pickOrderLineRepository: PickOrderLineRepository, | 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( | open fun searchDoLiteByPage( | ||||
| code: String?, | code: String?, | ||||
| shopName: String?, | shopName: String?, | ||||
| @@ -203,10 +205,14 @@ open class DeliveryOrderService( | |||||
| val preFilteredContent = allResult.content | val preFilteredContent = allResult.content | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | ||||
| val ticketLaneByDoId = loadTicketTruckLanesByDoId( | |||||
| deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, | |||||
| ) | |||||
| // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) | |||||
| // 未放單才依供應商樓層查 truck;已放單用票據車線 | |||||
| val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | ||||
| val deliveryOrder = deliveryOrdersMap[info.id] | val deliveryOrder = deliveryOrdersMap[info.id] | ||||
| if (isReleasedDeliveryOrder(deliveryOrder)) return@mapNotNull null | |||||
| val shopId = deliveryOrder?.shop?.id | val shopId = deliveryOrder?.shop?.id | ||||
| val estimatedArrivalDate = info.estimatedArrivalDate | val estimatedArrivalDate = info.estimatedArrivalDate | ||||
| if (shopId != null && estimatedArrivalDate != null) { | if (shopId != null && estimatedArrivalDate != null) { | ||||
| @@ -234,23 +240,16 @@ open class DeliveryOrderService( | |||||
| // 处理预过滤后的记录:计算 truckLanceCode 并过滤 | // 处理预过滤后的记录:计算 truckLanceCode 并过滤 | ||||
| val processedRecords = preFilteredContent.map { info -> | val processedRecords = preFilteredContent.map { info -> | ||||
| val deliveryOrder = deliveryOrdersMap[info.id] | 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 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( | DeliveryOrderInfoLiteDto( | ||||
| id = info.id, | id = info.id, | ||||
| @@ -299,22 +298,22 @@ open class DeliveryOrderService( | |||||
| ) | ) | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | 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 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( | DeliveryOrderInfoLiteDto( | ||||
| id = info.id, | 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( | open fun searchDoLiteUnassignedTruckByPage( | ||||
| code: String?, | code: String?, | ||||
| @@ -425,23 +425,20 @@ open class DeliveryOrderService( | |||||
| val deliveryOrderIds = allResult.content.mapNotNull { it.id } | val deliveryOrderIds = allResult.content.mapNotNull { it.id } | ||||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } | val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | ||||
| val ticketLaneByDoId = loadTicketTruckLanesByDoId( | |||||
| deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, | |||||
| ) | |||||
| val processedRecords = allResult.content.map { info -> | val processedRecords = allResult.content.map { info -> | ||||
| val deliveryOrder = deliveryOrdersMap[info.id] | 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( | DeliveryOrderInfoLiteDto( | ||||
| id = info.id, | id = info.id, | ||||
| @@ -481,14 +478,18 @@ open class DeliveryOrderService( | |||||
| return deliveryOrderRepository.findByIdAndDeletedIsFalse(id); | 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 | 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 | val itemId = line.item?.id ?: return@mapNotNull null | ||||
| itemId to (line.qty ?: BigDecimal.ZERO) | itemId to (line.qty ?: BigDecimal.ZERO) | ||||
| }.toMap() | }.toMap() | ||||
| @@ -632,9 +635,14 @@ open class DeliveryOrderService( | |||||
| isExtra = deliveryOrder.isExtra, | isExtra = deliveryOrder.isExtra, | ||||
| handlerName = handlerName, | handlerName = handlerName, | ||||
| truckLaneCode = truckLaneCode, | truckLaneCode = truckLaneCode, | ||||
| deliveryOrderLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.map { line -> | |||||
| deliveryOrderLines = activeLines.map { line -> | |||||
| val itemId = line.item?.id | 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 requiredQty = line.qty ?: BigDecimal.ZERO | ||||
| val availableStatus = | val availableStatus = | ||||
| if (stockQty >= requiredQty) "available" else "insufficient" | 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( | private fun exportDeliveryNoteFromRecord( | ||||
| request: ExportDeliveryNoteRequest, | request: ExportDeliveryNoteRequest, | ||||
| deliveryNote: net.sf.jasperreports.engine.JasperReport, | deliveryNote: net.sf.jasperreports.engine.JasperReport, | ||||
| @@ -1480,15 +1489,19 @@ open class DeliveryOrderService( | |||||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | ||||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | ||||
| val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds) | val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds) | ||||
| val pickOrderLineIdByDeliveryLine = matchDeliveryNotePickOrderLines( | |||||
| exportLines = exportLines, | |||||
| pickOrderIdByDoId = pickOrderIdByDoId, | |||||
| pickOrderLines = pickOrderLines, | |||||
| replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), | |||||
| ) | |||||
| sortedLines.forEach { row -> | sortedLines.forEach { row -> | ||||
| fields.add( | fields.add( | ||||
| buildDeliveryNotePdfLineField( | buildDeliveryNotePdfLineField( | ||||
| deliveryOrderId = row.deliveryOrderId, | deliveryOrderId = row.deliveryOrderId, | ||||
| line = row.line, | line = row.line, | ||||
| sequenceNumber = fields.size + 1, | sequenceNumber = fields.size + 1, | ||||
| pickOrderIdByDoId = pickOrderIdByDoId, | |||||
| pickOrderLines = pickOrderLines, | |||||
| stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, | stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, | ||||
| illById = illById, | illById = illById, | ||||
| itemsById = itemsById, | itemsById = itemsById, | ||||
| @@ -1497,6 +1510,7 @@ open class DeliveryOrderService( | |||||
| headerTicketNo = null, | headerTicketNo = null, | ||||
| headerIsMerge = false, | headerIsMerge = false, | ||||
| replenishPdfIndex = replenishPdfIndex, | replenishPdfIndex = replenishPdfIndex, | ||||
| pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, | |||||
| ), | ), | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -1557,17 +1571,47 @@ open class DeliveryOrderService( | |||||
| info.deliveryOrderLines.filter { it.deleted != true }.map { line -> DeliveryNoteExportLine(info.id, line) } | 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>, | pickOrderIdByDoId: Map<Long, Long>, | ||||
| pickOrderLines: List<PickOrderLine>, | 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? { | fun parseTicketTypeLetter(ticketNo: String?): String? { | ||||
| @@ -1679,12 +1723,11 @@ open class DeliveryOrderService( | |||||
| return field | return field | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ | |||||
| fun buildDeliveryNotePdfLineField( | fun buildDeliveryNotePdfLineField( | ||||
| deliveryOrderId: Long, | deliveryOrderId: Long, | ||||
| line: DeliveryOrderLineInfo, | line: DeliveryOrderLineInfo, | ||||
| sequenceNumber: Int, | sequenceNumber: Int, | ||||
| pickOrderIdByDoId: Map<Long, Long>, | |||||
| pickOrderLines: List<PickOrderLine>, | |||||
| stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>, | stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>, | ||||
| illById: Map<Long, InventoryLotLine>, | illById: Map<Long, InventoryLotLine>, | ||||
| itemsById: Map<Long, Items>, | itemsById: Map<Long, Items>, | ||||
| @@ -1693,6 +1736,7 @@ open class DeliveryOrderService( | |||||
| headerTicketNo: String? = null, | headerTicketNo: String? = null, | ||||
| headerIsMerge: Boolean = false, | headerIsMerge: Boolean = false, | ||||
| replenishPdfIndex: DoReplenishmentService.ReplenishPdfIndex = DoReplenishmentService.ReplenishPdfIndex.EMPTY, | replenishPdfIndex: DoReplenishmentService.ReplenishPdfIndex = DoReplenishmentService.ReplenishPdfIndex.EMPTY, | ||||
| pickOrderLineIdByDeliveryLine: Map<Pair<Long, Long>, Long> = emptyMap(), | |||||
| ): MutableMap<String, Any> { | ): MutableMap<String, Any> { | ||||
| val field = mutableMapOf<String, Any>() | val field = mutableMapOf<String, Any>() | ||||
| val isExtra = isExtraDeliveryTicket( | val isExtra = isExtraDeliveryTicket( | ||||
| @@ -1701,12 +1745,7 @@ open class DeliveryOrderService( | |||||
| headerIsMerge = headerIsMerge, | 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) | val isReplenish = replenishPdfIndex.matches(deliveryOrderId, line.itemId, polId) | ||||
| field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish) | field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish) | ||||
| @@ -2502,6 +2541,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| return out | return out | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */ | |||||
| private fun buildResolvedTruckDtosForLiteRows( | private fun buildResolvedTruckDtosForLiteRows( | ||||
| rows: List<DeliveryOrderInfoLite>, | rows: List<DeliveryOrderInfoLite>, | ||||
| ): List<DeliveryOrderInfoLiteDto> { | ): List<DeliveryOrderInfoLiteDto> { | ||||
| @@ -2509,9 +2549,13 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| if (ids.isEmpty()) return emptyList() | if (ids.isEmpty()) return emptyList() | ||||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } | val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } | ||||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | ||||
| val ticketLaneByDoId = loadTicketTruckLanesByDoId( | |||||
| deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, | |||||
| ) | |||||
| val shopIdAndDatePairs = rows.mapNotNull { info -> | val shopIdAndDatePairs = rows.mapNotNull { info -> | ||||
| val d = deliveryOrdersMap[info.id] | val d = deliveryOrdersMap[info.id] | ||||
| if (isReleasedDeliveryOrder(d)) return@mapNotNull null | |||||
| val shopId = d?.shop?.id | val shopId = d?.shop?.id | ||||
| val eta = info.estimatedArrivalDate | val eta = info.estimatedArrivalDate | ||||
| if (shopId != null && eta != null) { | if (shopId != null && eta != null) { | ||||
| @@ -2533,18 +2577,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||||
| return rows.map { info -> | return rows.map { info -> | ||||
| val deliveryOrder = deliveryOrdersMap[info.id] | 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( | DeliveryOrderInfoLiteDto( | ||||
| id = info.id, | id = info.id, | ||||
| code = info.code, | 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%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 | * - **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 | |||||
| } | |||||
| } | |||||
| @@ -162,6 +162,9 @@ open class DoReplenishmentService( | |||||
| return deliveryOrderId to resolvedItemId in targetDoItemKeys | 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 { | companion object { | ||||
| val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) | val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) | ||||
| } | } | ||||
| @@ -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.StockOutLineWorkbenchService | ||||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | ||||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | 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.service.WorkbenchStockOutLinePickProgress | ||||
| import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| @@ -70,6 +71,7 @@ import jakarta.persistence.EntityManager | |||||
| import jakarta.persistence.PersistenceContext | import jakarta.persistence.PersistenceContext | ||||
| import org.springframework.orm.ObjectOptimisticLockingFailureException | import org.springframework.orm.ObjectOptimisticLockingFailureException | ||||
| import com.ffii.core.utils.PdfUtils | import com.ffii.core.utils.PdfUtils | ||||
| import com.ffii.core.utils.PrinterLockManager | |||||
| import com.ffii.core.utils.ZebraPrinterUtil | import com.ffii.core.utils.ZebraPrinterUtil | ||||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | ||||
| import com.ffii.fpsms.modules.master.service.PrinterService | 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.ConcurrentHashMap | ||||
| import java.util.concurrent.ConcurrentLinkedQueue | import java.util.concurrent.ConcurrentLinkedQueue | ||||
| import java.util.concurrent.CountDownLatch | import java.util.concurrent.CountDownLatch | ||||
| import java.util.concurrent.Executors | |||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import net.sf.jasperreports.engine.JasperExportManager | import net.sf.jasperreports.engine.JasperExportManager | ||||
| import net.sf.jasperreports.engine.JasperPrint | import net.sf.jasperreports.engine.JasperPrint | ||||
| import net.sf.jasperreports.engine.JasperReport | |||||
| import java.io.File | import java.io.File | ||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.util.Locale | import java.util.Locale | ||||
| @@ -114,6 +116,7 @@ open class DoWorkbenchMainService( | |||||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val bagRepository: BagRepository, | private val bagRepository: BagRepository, | ||||
| private val bagService: BagService, | private val bagService: BagService, | ||||
| @@ -141,6 +144,20 @@ open class DoWorkbenchMainService( | |||||
| companion object { | companion object { | ||||
| private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) | 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 { | 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). | * 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). | * - 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). | * - 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) { | private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) { | ||||
| if (deltaQty <= BigDecimal.ZERO) return | if (deltaQty <= BigDecimal.ZERO) return | ||||
| val solItem = sol.item ?: 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. | // 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. | // At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change. | ||||
| // So previousBalance can be reconstructed as (onHandAfter + deltaQty). | // So previousBalance can be reconstructed as (onHandAfter + deltaQty). | ||||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| val previousBalance = onHandAfter + deltaQty.toDouble() | val previousBalance = onHandAfter + deltaQty.toDouble() | ||||
| val newBalance = previousBalance - 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 lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | ||||
| val ledger = StockLedger().apply { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = sol | this.stockOutLine = sol | ||||
| @@ -2707,12 +2724,13 @@ return MessageResponse( | |||||
| } | } | ||||
| private fun resolveWorkbenchPreviousBalance( | private fun resolveWorkbenchPreviousBalance( | ||||
| itemId: Long, | |||||
| inventory: Inventory, | inventory: Inventory, | ||||
| onHandQtyBeforeUpdate: Double?, | onHandQtyBeforeUpdate: Double?, | ||||
| ): Double { | ): Double { | ||||
| if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate | 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() | return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| } | } | ||||
| @@ -2975,6 +2993,7 @@ return MessageResponse( | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { | open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { | ||||
| val started = System.nanoTime() | |||||
| val printer = | val printer = | ||||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such 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 jasperPrint = pdf["report"] as JasperPrint | ||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | val tempPdfFile = File.createTempFile("print_job_", ".pdf") | ||||
| var handedToPrinter = false | |||||
| try { | try { | ||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | ||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | 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) | 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> { | 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 fields = mutableListOf<MutableMap<String, Any>>() | ||||
| val params = mutableMapOf<String, Any>() | val params = mutableMapOf<String, Any>() | ||||
| @@ -3086,18 +3120,9 @@ return MessageResponse( | |||||
| val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo) | 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 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) | val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) | ||||
| ?.replace("/", "") | ?.replace("/", "") | ||||
| @@ -3123,11 +3148,7 @@ return MessageResponse( | |||||
| val allIllIds = stockOutLinesByPickOrderLineId.values | val allIllIds = stockOutLinesByPickOrderLineId.values | ||||
| .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } | .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } | ||||
| .distinct() | .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 deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds) | ||||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | 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 headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true | ||||
| val headerTicketNo = ctx.header.ticketNo | val headerTicketNo = ctx.header.ticketNo | ||||
| val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(ctx.deliveryOrderIds) | val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(ctx.deliveryOrderIds) | ||||
| val pickOrderLineIdByDeliveryLine = deliveryOrderService.matchDeliveryNotePickOrderLines( | |||||
| exportLines = exportLines, | |||||
| pickOrderIdByDoId = ctx.pickOrderIdByDoId, | |||||
| pickOrderLines = pickOrderLines, | |||||
| replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), | |||||
| ) | |||||
| sortedLines.forEach { row -> | sortedLines.forEach { row -> | ||||
| fields.add( | fields.add( | ||||
| @@ -3142,8 +3169,6 @@ return MessageResponse( | |||||
| deliveryOrderId = row.deliveryOrderId, | deliveryOrderId = row.deliveryOrderId, | ||||
| line = row.line, | line = row.line, | ||||
| sequenceNumber = fields.size + 1, | sequenceNumber = fields.size + 1, | ||||
| pickOrderIdByDoId = ctx.pickOrderIdByDoId, | |||||
| pickOrderLines = pickOrderLines, | |||||
| stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, | stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, | ||||
| illById = illById, | illById = illById, | ||||
| itemsById = itemsById, | itemsById = itemsById, | ||||
| @@ -3152,6 +3177,7 @@ return MessageResponse( | |||||
| headerTicketNo = headerTicketNo, | headerTicketNo = headerTicketNo, | ||||
| headerIsMerge = headerIsMerge, | headerIsMerge = headerIsMerge, | ||||
| replenishPdfIndex = replenishPdfIndex, | replenishPdfIndex = replenishPdfIndex, | ||||
| pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, | |||||
| ), | ), | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -3201,6 +3227,7 @@ return MessageResponse( | |||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { | open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { | ||||
| val started = System.nanoTime() | |||||
| val printer = | val printer = | ||||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such 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 jasperPrint = pdf["report"] as JasperPrint | ||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | val tempPdfFile = File.createTempFile("print_job_", ".pdf") | ||||
| var handedToPrinter = false | |||||
| try { | try { | ||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | ||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | 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> { | 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 ctx = getWorkbenchPrintContext(request.doPickOrderId) | ||||
| val inputStream = resource.inputStream | |||||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||||
| val cartonLabel = cartonLabelJasperTemplate | |||||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | ||||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | ||||
| }.toMutableList() | }.toMutableList() | ||||
| @@ -3323,15 +3365,8 @@ return MessageResponse( | |||||
| totalCartonsOnShipment = request.totalCartonsOnShipment, | 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 ctx = getWorkbenchPrintContext(request.doPickOrderId) | ||||
| val inputStream = resource.inputStream | |||||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||||
| val cartonLabel = cartonLabelJasperTemplate | |||||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | ||||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | ||||
| }.toMutableList() | }.toMutableList() | ||||
| @@ -3371,23 +3406,147 @@ return MessageResponse( | |||||
| val pdf = exportDNLabelsReprintWorkbench(request) | val pdf = exportDNLabelsReprintWorkbench(request) | ||||
| val jasperPrint = pdf["report"] as JasperPrint | val jasperPrint = pdf["report"] as JasperPrint | ||||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | val tempPdfFile = File.createTempFile("print_job_", ".pdf") | ||||
| var handedToPrinter = false | |||||
| try { | try { | ||||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | ||||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | 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 { | } 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 | |||||
| } | |||||
| } | } | ||||
| @@ -54,8 +54,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||||
| json_arrayagg( | json_arrayagg( | ||||
| json_object( | json_object( | ||||
| 'id', jobm.id, | 'id', jobm.id, | ||||
| 'itemId', jobm.itemId, | |||||
| 'uomId', jobm.uomId, | |||||
| 'code', i.code, | 'code', i.code, | ||||
| 'name', i.name, | 'name', i.name, | ||||
| 'type', i.type, | |||||
| 'pickedLotNo', pln.pickedLotNo, | 'pickedLotNo', pln.pickedLotNo, | ||||
| 'reqQty', jobm.reqQty, | 'reqQty', jobm.reqQty, | ||||
| 'uom', uc.udfudesc, | 'uom', uc.udfudesc, | ||||
| @@ -93,8 +96,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||||
| json_arrayagg( | json_arrayagg( | ||||
| json_object( | json_object( | ||||
| 'id', jobm.id, | 'id', jobm.id, | ||||
| 'itemId', jobm.itemId, | |||||
| 'uomId', jobm.uomId, | |||||
| 'code', i.code, | 'code', i.code, | ||||
| 'name', i.name, | 'name', i.name, | ||||
| 'type', i.type, | |||||
| 'lotNo', il.lotNo, | 'lotNo', il.lotNo, | ||||
| 'reqQty', jobm.reqQty, | 'reqQty', jobm.reqQty, | ||||
| 'uom', uc.udfudesc, | 'uom', uc.udfudesc, | ||||
| @@ -272,8 +278,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||||
| json_arrayagg( | json_arrayagg( | ||||
| json_object( | json_object( | ||||
| 'id', jobm.id, | 'id', jobm.id, | ||||
| 'itemId', jobm.itemId, | |||||
| 'uomId', jobm.uomId, | |||||
| 'code', i.code, | 'code', i.code, | ||||
| 'name', i.name, | 'name', i.name, | ||||
| 'type', i.type, | |||||
| 'lotNo', il.lotNo, | 'lotNo', il.lotNo, | ||||
| 'reqQty', jobm.reqQty, | 'reqQty', jobm.reqQty, | ||||
| 'uom', uc.udfudesc, | 'uom', uc.udfudesc, | ||||
| @@ -312,8 +321,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||||
| json_arrayagg( | json_arrayagg( | ||||
| json_object( | json_object( | ||||
| 'id', jobm.id, | 'id', jobm.id, | ||||
| 'itemId', jobm.itemId, | |||||
| 'uomId', jobm.uomId, | |||||
| 'code', i.code, | 'code', i.code, | ||||
| 'name', i.name, | 'name', i.name, | ||||
| 'type', i.type, | |||||
| 'lotNo', il.lotNo, | 'lotNo', il.lotNo, | ||||
| 'reqQty', jobm.reqQty, | 'reqQty', jobm.reqQty, | ||||
| 'uom', uc.udfudesc, | 'uom', uc.udfudesc, | ||||
| @@ -126,6 +126,8 @@ data class JobOrderDetail( | |||||
| data class JobOrderDetailPickLine( | data class JobOrderDetailPickLine( | ||||
| val id: Long?, | val id: Long?, | ||||
| val itemId: Long?, | |||||
| val uomId: Long?, | |||||
| val code: String?, | val code: String?, | ||||
| val name: String?, | val name: String?, | ||||
| val pickedLotNo: List<JobOrderDetailPickedLotNo>?, | val pickedLotNo: List<JobOrderDetailPickedLotNo>?, | ||||
| @@ -166,7 +166,12 @@ open class JobOrderService( | |||||
| val inventoriesMap = if (allItemIds.isNotEmpty()) { | val inventoriesMap = if (allItemIds.isNotEmpty()) { | ||||
| inventoryRepository.findInventoryInfoByItemIdInAndDeletedIsFalse(allItemIds) | 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 { | } else { | ||||
| emptyMap() | emptyMap() | ||||
| } | } | ||||
| @@ -228,7 +233,7 @@ open class JobOrderService( | |||||
| private fun calculateStockCounts( | private fun calculateStockCounts( | ||||
| jobOrder: JobOrder, | 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> { | ): Pair<Int, Int> { | ||||
| // 过滤掉 consumables 和 CMB 类型的物料 | // 过滤掉 consumables 和 CMB 类型的物料 | ||||
| val nonConsumablesJobms = jobOrder.jobms.filter { jobm -> | val nonConsumablesJobms = jobOrder.jobms.filter { jobm -> | ||||
| @@ -243,36 +248,22 @@ open class JobOrderService( | |||||
| var sufficientCount = 0 | var sufficientCount = 0 | ||||
| var insufficientCount = 0 | var insufficientCount = 0 | ||||
| //println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") | |||||
| nonConsumablesJobms.forEach { jobm -> | nonConsumablesJobms.forEach { jobm -> | ||||
| val itemId = jobm.item?.id | val itemId = jobm.item?.id | ||||
| val itemCode = jobm.item?.code ?: "N/A" | |||||
| val itemName = jobm.item?.name ?: "N/A" | |||||
| if (itemId != null) { | 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 reqQty = jobm.reqQty ?: BigDecimal.ZERO | ||||
| val reqUomId = jobm.uom?.id ?: 0L | val reqUomId = jobm.uom?.id ?: 0L | ||||
| val reqUomName = jobm.uom?.udfudesc ?: "N/A" | |||||
| // ✅ 修复:使用 itemUomService 获取 stockUomId(与 ProductProcessService 保持一致) | |||||
| val stockUnitItemUom = itemUomService.findStockUnitByItemId(itemId) | 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) { | val baseReqQtyResult = if (reqUomId > 0 && reqQty > BigDecimal.ZERO) { | ||||
| try { | try { | ||||
| itemUomService.convertUomByItem( | itemUomService.convertUomByItem( | ||||
| @@ -291,13 +282,13 @@ open class JobOrderService( | |||||
| null | null | ||||
| } | } | ||||
| val baseAvailableQtyResult = if (stockUomId > 0 && availableQty > BigDecimal.ZERO) { | |||||
| val baseAvailableQtyResult = if (availableUomId > 0 && availableQty > BigDecimal.ZERO) { | |||||
| try { | try { | ||||
| itemUomService.convertUomByItem( | itemUomService.convertUomByItem( | ||||
| ConvertUomByItemRequest( | ConvertUomByItemRequest( | ||||
| itemId = itemId, | itemId = itemId, | ||||
| qty = availableQty, | qty = availableQty, | ||||
| uomId = stockUomId, | |||||
| uomId = availableUomId, | |||||
| targetUnit = "baseUnit" | targetUnit = "baseUnit" | ||||
| ) | ) | ||||
| ) | ) | ||||
| @@ -311,27 +302,32 @@ open class JobOrderService( | |||||
| val baseReqQty = baseReqQtyResult?.newQty ?: BigDecimal.ZERO | val baseReqQty = baseReqQtyResult?.newQty ?: BigDecimal.ZERO | ||||
| val baseAvailableQty = baseAvailableQtyResult?.newQty ?: BigDecimal.ZERO | val baseAvailableQty = baseAvailableQtyResult?.newQty ?: BigDecimal.ZERO | ||||
| val baseUomName = baseReqQtyResult?.udfudesc ?: baseAvailableQtyResult?.udfudesc ?: "N/A" | |||||
| // ✅ 使用 base unit 进行比较 | |||||
| if (baseAvailableQty >= baseReqQty) { | if (baseAvailableQty >= baseReqQty) { | ||||
| sufficientCount++ | sufficientCount++ | ||||
| //println("✅ SUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)") | |||||
| } else { | } else { | ||||
| insufficientCount++ | insufficientCount++ | ||||
| //println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)") | |||||
| } | } | ||||
| } else { | } else { | ||||
| // 如果没有 itemId,视为不足 | |||||
| insufficientCount++ | insufficientCount++ | ||||
| //println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - No itemId") | |||||
| } | } | ||||
| } | } | ||||
| //println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===") | |||||
| return Pair(sufficientCount, 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 { | open fun jobOrderDetail(id: Long): JobOrderDetail { | ||||
| val sqlResult = jobOrderRepository.findJobOrderDetailById(id) ?: throw NoSuchElementException(); | val sqlResult = jobOrderRepository.findJobOrderDetailById(id) ?: throw NoSuchElementException(); | ||||
| @@ -77,10 +77,31 @@ open class PSService( | |||||
| FROM | FROM | ||||
| bom | bom | ||||
| LEFT JOIN items ON bom.itemId = items.id | 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 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' | WHERE bom.deleted = 0 and bom.description = 'FG' | ||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| @@ -168,8 +189,8 @@ open class PSService( | |||||
| /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ | /** 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 { | fun refreshInventoryOnHandForFgBomItems(): Int { | ||||
| val sql = """ | val sql = """ | ||||
| @@ -182,24 +203,28 @@ open class PSService( | |||||
| LEFT JOIN ( | LEFT JOIN ( | ||||
| SELECT | SELECT | ||||
| il.itemId, | il.itemId, | ||||
| iu.uomId AS stockUomId, | |||||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty, | SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty, | ||||
| SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty, | |||||
| SUM(CASE | 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 | ELSE 0 | ||||
| END) AS totalUnavailableQty | END) AS totalUnavailableQty | ||||
| FROM inventory_lot_line ill | 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 | ) calc ON calc.itemId = i.itemId | ||||
| AND ( | |||||
| i.stockUomId = calc.stockUomId | |||||
| OR (i.stockUomId IS NULL AND i.uomId = calc.stockUomId) | |||||
| ) | |||||
| SET | SET | ||||
| i.onHandQty = COALESCE(calc.totalOnHandQty, 0), | i.onHandQty = COALESCE(calc.totalOnHandQty, 0), | ||||
| i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0), | |||||
| i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0), | i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0), | ||||
| i.status = IF( | 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', | 'available', | ||||
| 'unavailable' | 'unavailable' | ||||
| ), | ), | ||||
| @@ -27,14 +27,25 @@ interface ProductionScheduleLineRepository : AbstractRepository<ProductionSchedu | |||||
| bmi.code, | bmi.code, | ||||
| bmi.name, | bmi.name, | ||||
| bmi.`type`, | 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 | ceil(coalesce(bm.qty, 0) * pp.proportion) as demandQty | ||||
| from production_schedule_line psl | from production_schedule_line psl | ||||
| left join prod_prop pp on pp.pslId = psl.id | left join prod_prop pp on pp.pslId = psl.id | ||||
| left join bom b on b.itemId = psl.itemId | left join bom b on b.itemId = psl.itemId | ||||
| left join bom_material bm on bm.bomId = b.id | left join bom_material bm on bm.bomId = b.id | ||||
| left join items bmi on bmi.id = bm.itemId | 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 | where psl.id = :id and bmi.id is not null | ||||
| group by psl.id, bm.id, i.id, pp.proportion | group by psl.id, bm.id, i.id, pp.proportion | ||||
| """) | """) | ||||
| @@ -183,7 +183,7 @@ interface ProductionScheduleRepository : AbstractRepository<ProductionSchedule, | |||||
| bmi.code, | bmi.code, | ||||
| bmi.name, | bmi.name, | ||||
| bmi.`type`, | 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 | -- ceil(coalesce(bm.qty, 0) * pp.proportion) as demandQty | ||||
| coalesce(bm.qty, 0) * pp.proportion as demandQty, | coalesce(bm.qty, 0) * pp.proportion as demandQty, | ||||
| uc.udfudesc as uomName | uc.udfudesc as uomName | ||||
| @@ -195,7 +195,13 @@ interface ProductionScheduleRepository : AbstractRepository<ProductionSchedule, | |||||
| left join items bmi on bmi.id = bm.itemId | 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 item_uom iu on iu.itemId = bmi.id and iu.salesUnit = true | ||||
| left join uom_conversion uc on uc.id = iu.uomId | 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 | 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 | where ps.id = :id and bmi.id is not null | ||||
| group by ps.id, psl.id, bm.id, i.id, pp.proportion, uc.udfudesc | group by ps.id, psl.id, bm.id, i.id, pp.proportion, uc.udfudesc | ||||
| ) r2 | ) r2 | ||||
| @@ -5,6 +5,7 @@ import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo | |||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| @@ -22,6 +23,22 @@ interface WarehouseRepository : AbstractRepository<Warehouse, Long> { | |||||
| fun findAllByIdIn(ids: List<Long>): List<Warehouse>; | fun findAllByIdIn(ids: List<Long>): List<Warehouse>; | ||||
| fun findAllByCodeAndDeletedIsFalse(code: String): 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( | @Query( | ||||
| """ | """ | ||||
| SELECT COUNT(w) FROM Warehouse w | SELECT COUNT(w) FROM Warehouse w | ||||
| @@ -7,7 +7,7 @@ interface BomCombo { | |||||
| val id: Long; | val id: Long; | ||||
| @get:Value("#{target.id}") | @get:Value("#{target.id}") | ||||
| val value: Long; | 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 label: String; | ||||
| val outputQty: BigDecimal; | val outputQty: BigDecimal; | ||||
| val outputQtyUom: String?; | val outputQtyUom: String?; | ||||
| @@ -13,7 +13,8 @@ import org.slf4j.Logger | |||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | import com.ffii.fpsms.modules.master.entity.ItemsRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.Inventory | 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 | @Service | ||||
| open class ItemUomService( | open class ItemUomService( | ||||
| @@ -22,7 +23,7 @@ open class ItemUomService( | |||||
| val itemUomRespository: ItemUomRespository, | val itemUomRespository: ItemUomRespository, | ||||
| val currencyService: CurrencyService, | val currencyService: CurrencyService, | ||||
| val itemsRepository: ItemsRepository, | val itemsRepository: ItemsRepository, | ||||
| private val inventoryRepository: InventoryRepository, | |||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| ) { | ) { | ||||
| val logger = org.slf4j.LoggerFactory.getLogger(this::class.java) | 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? { | 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? { | open fun findPickingUnitByItemId(itemId: Long): ItemUom? { | ||||
| @@ -104,6 +106,9 @@ open class ItemUomService( | |||||
| open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; | val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return purchaseQty.setScale(0, RoundingMode.UP) | |||||
| } | |||||
| val one = BigDecimal.ONE; | val one = BigDecimal.ONE; | ||||
| // IMPORTANT: | // IMPORTANT: | ||||
| @@ -129,6 +134,9 @@ open class ItemUomService( | |||||
| open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return purchaseQty.setScale(0, RoundingMode.DOWN) | |||||
| } | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| // Use high precision for intermediate steps, and round down only at the end. | // 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 { | open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty | val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return stockQty.setScale(0, RoundingMode.UP) | |||||
| } | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -168,8 +179,11 @@ open class ItemUomService( | |||||
| * Returns source qty when source/purchase unit mapping is not found. | * Returns source qty when source/purchase unit mapping is not found. | ||||
| */ | */ | ||||
| open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | ||||
| val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: 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 one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -185,9 +199,12 @@ open class ItemUomService( | |||||
| } | } | ||||
| open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | 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 one = BigDecimal.ONE; | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -208,8 +225,12 @@ open class ItemUomService( | |||||
| * Same as convertQtyToStockQty but rounds down the final stock qty to integer. | * Same as convertQtyToStockQty but rounds down the final stock qty to integer. | ||||
| */ | */ | ||||
| open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | 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 itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO | |||||
| if (stockUnit == null) return BigDecimal.ZERO | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -224,6 +245,18 @@ open class ItemUomService( | |||||
| return stockQty.setScale(0, RoundingMode.DOWN) | 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. | * Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc. | ||||
| * Weight/volume units (克, 公斤, 升…) keep 2 decimals. | * Weight/volume units (克, 公斤, 升…) keep 2 decimals. | ||||
| @@ -268,6 +301,9 @@ open class ItemUomService( | |||||
| open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | ||||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | ||||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||||
| return finalizePreciseStockQty(stockUnit, purchaseQty) | |||||
| } | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -287,8 +323,12 @@ open class ItemUomService( | |||||
| * Used for PO (M18 UOM) conversion where round-down is not allowed. | * Used for PO (M18 UOM) conversion where round-down is not allowed. | ||||
| */ | */ | ||||
| open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | 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 itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO | |||||
| if (stockUnit == null) return BigDecimal.ZERO | |||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val calcScale = 10 | val calcScale = 10 | ||||
| @@ -410,8 +410,13 @@ open class ItemsService( | |||||
| "i.AverageUnitPrice as averageUnitPrice, " + | "i.AverageUnitPrice as averageUnitPrice, " + | ||||
| "i.LatestMarketUnitPrice as latestMarketUnitPrice, " + | "i.LatestMarketUnitPrice as latestMarketUnitPrice, " + | ||||
| "DATE_FORMAT(i.latestMupUpdatedDate, '%Y-%m-%d %H:%i') as latestMupUpdatedDate, " + | "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 " + | "uc_p.udfudesc as purchaseUnit " + | ||||
| "FROM items i " + | "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 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 " + | "LEFT JOIN uom_conversion uc_p ON uc_p.id = iu_p.uomId " + | ||||
| "WHERE i.deleted = false" | "WHERE i.deleted = false" | ||||
| @@ -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 proportion = BigDecimal.ONE // BigDecimal(line.prodQty).divide(bm.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP) | ||||
| val demandQty = bm.qty?.times(proportion) ?: zero | val demandQty = bm.qty?.times(proportion) ?: zero | ||||
| val saleUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } | 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( | RoughProdScheduleLineBomMaterialInfoByFg( | ||||
| id = bm.id, | id = bm.id, | ||||
| code = bm.item?.code, | code = bm.item?.code, | ||||
| name = bm.item?.name, | name = bm.item?.name, | ||||
| type = bm.item?.type, | 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, | demandQty = demandQty, | ||||
| uomName = saleUnit?.uom?.udfudesc | uomName = saleUnit?.uom?.udfudesc | ||||
| ) | ) | ||||
| } | } | ||||
| val saleUnit = line.item.id?.let { itemUomService.findSalesUnitByItemId(it) } | val saleUnit = line.item.id?.let { itemUomService.findSalesUnitByItemId(it) } | ||||
| val fgStockUomId = line.item.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id } | |||||
| RoughProdScheduleLineInfoByFg( | RoughProdScheduleLineInfoByFg( | ||||
| id = line.id, | id = line.id, | ||||
| code = line.item.code, | code = line.item.code, | ||||
| name = line.item.name, | name = line.item.name, | ||||
| type = line.item.type, | 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), | prodQty = BigDecimal(line.prodQty), | ||||
| lastMonthAvgSales = BigDecimal(line.lastMonthAvgSales), | lastMonthAvgSales = BigDecimal(line.lastMonthAvgSales), | ||||
| estCloseBal = BigDecimal(line.estCloseBal), | 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 proportion = BigDecimal.ONE //BigDecimal(line.prodQty).divide(bm.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP) | ||||
| val demandQty = bm.qty?.times(proportion) ?: zero | val demandQty = bm.qty?.times(proportion) ?: zero | ||||
| val saleUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } | 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( | RoughProdScheduleLineInfoByBomByDate( | ||||
| id = bm.item?.id, | id = bm.item?.id, | ||||
| code = bm.item?.code, | code = bm.item?.code, | ||||
| name = bm.item?.name, | name = bm.item?.name, | ||||
| type = bm.item?.type, | 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, | demandQty = demandQty, | ||||
| assignDate = line.assignDate, | assignDate = line.assignDate, | ||||
| uomName = saleUnit?.uom?.udfudesc | uomName = saleUnit?.uom?.udfudesc | ||||
| @@ -724,7 +725,21 @@ open class ProductionScheduleService( | |||||
| FROM | FROM | ||||
| bom | bom | ||||
| LEFT JOIN items ON bom.itemId = items.id | 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 | left join item_fake_onhand on items.code = item_fake_onhand.itemCode | ||||
| WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active' | WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active' | ||||
| -- and bom.itemId != 16771 | -- and bom.itemId != 16771 | ||||
| @@ -1821,7 +1836,18 @@ open class ProductionScheduleService( | |||||
| left join bom on it.id = bom.itemId | left join bom on it.id = bom.itemId | ||||
| left join bom_material bm on bom.id = bm.bomId | left join bom_material bm on bom.id = bm.bomId | ||||
| left join items itm on bm.itemId = itm.id | 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 item_uom ius on itm.id = ius.itemId and ius.stockUnit = 1 | ||||
| left join uom_conversion ucs on ius.uomId = ucs.id | 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 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 | LEFT JOIN uom_conversion uomM18 ON uomM18.id = ium18.uomId | ||||
| JOIN item_uom itsm ON itm.id = itsm.itemId and itsm.stockUnit = 1 | 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) | WHERE DATE(ps.produceAt) >= DATE_ADD(:fromDate, INTERVAL 1 DAY) | ||||
| AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY) | AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY) | ||||
| AND ps.id = ( | AND ps.id = ( | ||||
| @@ -1968,5 +2005,16 @@ open class ProductionScheduleService( | |||||
| // Optional: log the action (if you have logging setup) | // Optional: log the action (if you have logging setup) | ||||
| // logger.info("Cleared all production schedules with produceAt >= today") | // 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) | |||||
| } | |||||
| } | } | ||||
| @@ -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) | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| } | |||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| """ | |||||
| } | |||||
| @@ -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"), | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -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", | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -89,8 +89,8 @@ class ItemsController( | |||||
| @GetMapping("/pickOrderItems") | @GetMapping("/pickOrderItems") | ||||
| fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> { | fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> { | ||||
| try { | try { | ||||
| println("=== Debug: getPickOrderItemsByPage ===") | |||||
| println("Request parameters: ${request.parameterMap}") | |||||
| //println("=== Debug: getPickOrderItemsByPage ===") | |||||
| //println("Request parameters: ${request.parameterMap}") | |||||
| val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | ||||
| .addStringLike("itemCode") | .addStringLike("itemCode") | ||||
| @@ -100,18 +100,18 @@ class ItemsController( | |||||
| .addString("targetDateTo") | .addString("targetDateTo") | ||||
| .build() | .build() | ||||
| println("Criteria args: $criteriaArgs") | |||||
| //println("Criteria args: $criteriaArgs") | |||||
| val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 | val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 | ||||
| val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1 | 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) | val fullList = itemsService.getPickOrderItemsByPage(criteriaArgs) | ||||
| println("Full list size: ${fullList.size}") | |||||
| //println("Full list size: ${fullList.size}") | |||||
| val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum) | val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum) | ||||
| println("Paginated list size: ${paginatedList.size}") | |||||
| //println("Paginated list size: ${paginatedList.size}") | |||||
| return RecordsRes(paginatedList, fullList.size) | return RecordsRes(paginatedList, fullList.size) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| @@ -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") | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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>, | |||||
| ) | |||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.pickOrder.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import org.springframework.stereotype.Repository | 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.Modifying | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | 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> | 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) | @Modifying(clearAutomatically = true, flushAutomatically = true) | ||||
| @Query( | @Query( | ||||
| value = """ | value = """ | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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> | |||||
| } | |||||
| @@ -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.StockOutLIneRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | 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.entity.InventoryRepository | ||||
| import com.ffii.fpsms.modules.stock.service.StockOutLineService | 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.StockLedger | ||||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | ||||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | 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.entity.DeliveryOrderRepository | ||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | ||||
| import org.springframework.beans.factory.annotation.Value | 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.BatchStockOutRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest | import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest | ||||
| @Service | @Service | ||||
| @@ -72,7 +76,8 @@ open class PickExecutionIssueService( | |||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | private val deliveryOrderRepository: DeliveryOrderRepository, | ||||
| private val suggestedPickLotRepository: SuggestPickLotRepository, | private val suggestedPickLotRepository: SuggestPickLotRepository, | ||||
| private val doPickOrderRepository: DoPickOrderRepository | |||||
| private val doPickOrderRepository: DoPickOrderRepository, | |||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| ) { | ) { | ||||
| // 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议) | // 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议) | ||||
| @@ -858,13 +863,8 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac | |||||
| println("Miss Qty: ${missQty}") | println("Miss Qty: ${missQty}") | ||||
| val lotId = request.lotId ?: return | val lotId = request.lotId ?: return | ||||
| val itemId = request.itemId ?: return | |||||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | 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) { | if (inventoryLotLine != null) { | ||||
| // ✅ 修改:更新 outQty 为 actualPickQty | // ✅ 修改:更新 outQty 为 actualPickQty | ||||
| val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | ||||
| @@ -927,9 +927,7 @@ private fun handleMissItemOnly(request: PickExecutionIssueRequest, missQty: BigD | |||||
| val itemId = request.itemId ?: return | val itemId = request.itemId ?: return | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | 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) { | if (inventoryLotLine != null) { | ||||
| // ✅ 修改:释放 holdQty(减少 holdQty) | // ✅ 修改:释放 holdQty(减少 holdQty) | ||||
| @@ -1128,10 +1126,9 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty | |||||
| val lotId = request.lotId ?: return | val lotId = request.lotId ?: return | ||||
| val itemId = request.itemId ?: 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 | // ✅ 修复:更新 stock_out_line,但不要累积 qty | ||||
| val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | ||||
| @@ -1163,7 +1160,6 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty | |||||
| } | } | ||||
| // ✅ 修复:更新 inventory_lot_line 的 outQty | // ✅ 修复:更新 inventory_lot_line 的 outQty | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||||
| if (inventoryLotLine != null) { | if (inventoryLotLine != null) { | ||||
| // ✅ 修复:计算新的 outQty,考虑之前的 outQty | // ✅ 修复:计算新的 outQty,考虑之前的 outQty | ||||
| val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | ||||
| @@ -2313,71 +2309,6 @@ private fun updateLotLineAfterIssue(lotLineId: Long, qty: BigDecimal, isMissItem | |||||
| lotLine.modifiedBy = "system" | lotLine.modifiedBy = "system" | ||||
| // 修复:使用 saveAndFlush 确保立即提交到数据库,触发触发器 | // 修复:使用 saveAndFlush 确保立即提交到数据库,触发触发器 | ||||
| inventoryLotLineRepository.saveAndFlush(lotLine) | 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 */ | /** 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 item = stockOutLine.item ?: return | ||||
| val outQty = stockOutLine.qty?.toDouble() ?: 0.0 | val outQty = stockOutLine.qty?.toDouble() ?: 0.0 | ||||
| // ✅ 修复:如果传入了 balance,直接使用;否则重新查询 inventory | |||||
| val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return | |||||
| val finalBalance = if (balance != null) { | val finalBalance = if (balance != null) { | ||||
| if (balance < 0) 0.0 else balance | if (balance < 0) 0.0 else balance | ||||
| } else { | } 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 | if (newBalance < 0) 0.0 else newBalance | ||||
| } | } | ||||
| // 使用传入的 ledgerType,如果没有则使用 stockOutLine.type | // 使用传入的 ledgerType,如果没有则使用 stockOutLine.type | ||||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | ||||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||||
| val ill = stockOutLine.inventoryLotLine | val ill = stockOutLine.inventoryLotLine | ||||
| val outQtyBd = BigDecimal.valueOf(outQty) | val outQtyBd = BigDecimal.valueOf(outQty) | ||||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | ||||
| @@ -2768,7 +2695,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||||
| lotLine.modified = LocalDateTime.now() | lotLine.modified = LocalDateTime.now() | ||||
| lotLine.modifiedBy = "system" | lotLine.modifiedBy = "system" | ||||
| inventoryLotLineRepository.saveAndFlush(lotLine) | inventoryLotLineRepository.saveAndFlush(lotLine) | ||||
| updateInventoryAfterLotLineChange(lotLine) | |||||
| println("✅ Reset lot ${request.lotId}: issueQty=0, status=AVAILABLE") | println("✅ Reset lot ${request.lotId}: issueQty=0, status=AVAILABLE") | ||||
| } | } | ||||
| val rejectedLines = stockOutLineRepository | 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() | val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| if (lotLine != null && inventoryBeforeUpdate == null) { | |||||
| throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "此批次的庫存單位與貨品不符,無法提交貨品遺失或不良貨品。", | |||||
| ) | |||||
| } | |||||
| println("=== submitIssueWithQty: Before update ===") | println("=== submitIssueWithQty: Before update ===") | ||||
| println("Item ID: ${request.itemId}") | println("Item ID: ${request.itemId}") | ||||
| @@ -2812,9 +2746,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||||
| println("OnHandQty Before Update: ${onHandQtyBeforeUpdate}") | println("OnHandQty Before Update: ${onHandQtyBeforeUpdate}") | ||||
| println("==========================================") | println("==========================================") | ||||
| val lotLine = request.lotId.let { | |||||
| inventoryLotLineRepository.findById(it).orElse(null) | |||||
| } | |||||
| println("Lot line: id=${lotLine?.id}, lotNo=${lotLine?.inventoryLot?.lotNo}") | println("Lot line: id=${lotLine?.id}, lotNo=${lotLine?.inventoryLot?.lotNo}") | ||||
| val item = itemsRepository.findById(request.itemId).orElse(null) | val item = itemsRepository.findById(request.itemId).orElse(null) | ||||
| @@ -2901,7 +2832,7 @@ private fun createStockLedgerForStockOutWithBalance( | |||||
| // Use传入的 ledgerType,如果没有则使用 stockOutLine.type | // Use传入的 ledgerType,如果没有则使用 stockOutLine.type | ||||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | 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 ill = stockOutLine.inventoryLotLine | ||||
| val outQtyBd = BigDecimal.valueOf(outQty) | val outQtyBd = BigDecimal.valueOf(outQty) | ||||
| @@ -3013,4 +2944,19 @@ private fun resolveLotUomInfo(lotId: Long?): Pair<String?, String?> { | |||||
| return Pair(uomCode, uomDesc) | 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() | |||||
| } | |||||
| } | } | ||||
| @@ -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.enum.InventoryLotLineStatus | ||||
| import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo | import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | 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.InventoryService | ||||
| import com.ffii.fpsms.modules.stock.service.StockOutLineService | import com.ffii.fpsms.modules.stock.service.StockOutLineService | ||||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotService | import com.ffii.fpsms.modules.stock.service.SuggestedPickLotService | ||||
| @@ -215,28 +216,17 @@ open class PickOrderService( | |||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| val zero = BigDecimal.ZERO | 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) | // Build response (removed suggestions) | ||||
| val pickOrderInfos = fullPickOrders.map { po -> | val pickOrderInfos = fullPickOrders.map { po -> | ||||
| val pickOrderLineInfos = po.pickOrderLines.map { pol -> | val pickOrderLineInfos = po.pickOrderLines.map { pol -> | ||||
| val inventory = pol.item?.id?.let { inventories[it] } | |||||
| GetPickOrderLineInfo( | GetPickOrderLineInfo( | ||||
| id = pol.id, | id = pol.id, | ||||
| itemId = pol.item?.id, | itemId = pol.item?.id, | ||||
| itemCode = pol.item?.code, | itemCode = pol.item?.code, | ||||
| itemName = pol.item?.name, | itemName = pol.item?.name, | ||||
| availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) }, | |||||
| availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero), | |||||
| requiredQty = pol.qty, | requiredQty = pol.qty, | ||||
| uomCode = pol.uom?.code, | uomCode = pol.uom?.code, | ||||
| uomDesc = pol.uom?.udfudesc, | uomDesc = pol.uom?.udfudesc, | ||||
| @@ -660,7 +650,7 @@ open class PickOrderService( | |||||
| // Get Inventory Data | // Get Inventory Data | ||||
| val requiredItems = pos | val requiredItems = pos | ||||
| .flatMap { it.pickOrderLines } | .flatMap { it.pickOrderLines } | ||||
| .groupBy { it.item?.id } | |||||
| .groupBy { it.item?.id to it.uom?.id } | |||||
| .map { (key, value) -> | .map { (key, value) -> | ||||
| key to object : CurrentInventoryItemInfo { | key to object : CurrentInventoryItemInfo { | ||||
| override val id: Long? = value[0].item?.id | override val id: Long? = value[0].item?.id | ||||
| @@ -671,42 +661,25 @@ open class PickOrderService( | |||||
| override val requiredQty: BigDecimal = value.sumOf { it.qty ?: zero } | 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 | // Pick Orders | ||||
| val releasePickOrderInfos = pos | val releasePickOrderInfos = pos | ||||
| .map { po -> | .map { po -> | ||||
| val releasePickOrderLineInfos = po.pickOrderLines.map { pol -> | 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( | ReleasePickOrderLineInfo( | ||||
| id = pol.id, | id = pol.id, | ||||
| itemId = pol.item?.id, | itemId = pol.item?.id, | ||||
| itemCode = pol.item?.code, | itemCode = pol.item?.code, | ||||
| itemName = pol.item?.name, | 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, | requiredQty = pol.qty, | ||||
| uomCode = pol.uom?.code, | uomCode = pol.uom?.code, | ||||
| uomDesc = pol.uom?.udfudesc, | uomDesc = pol.uom?.udfudesc, | ||||
| ) | ) | ||||
| // } | |||||
| } | } | ||||
| // Return | // Return | ||||
| @@ -725,16 +698,12 @@ open class PickOrderService( | |||||
| // val inventory = inventories | // val inventory = inventories | ||||
| // .find { it.itemId == item.first } | // .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 { | 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 | it | ||||
| } | } | ||||
| } | } | ||||
| @@ -759,7 +728,7 @@ open class PickOrderService( | |||||
| // Get Inventory Data | // Get Inventory Data | ||||
| val requiredItems = pos | val requiredItems = pos | ||||
| .flatMap { it.pickOrderLines } | .flatMap { it.pickOrderLines } | ||||
| .groupBy { it.item?.id } | |||||
| .groupBy { it.item?.id to it.uom?.id } | |||||
| .map { (key, value) -> | .map { (key, value) -> | ||||
| key to object : CurrentInventoryItemInfo { | key to object : CurrentInventoryItemInfo { | ||||
| override val id: Long? = value[0].item?.id | override val id: Long? = value[0].item?.id | ||||
| @@ -769,16 +738,10 @@ open class PickOrderService( | |||||
| override var availableQty: BigDecimal? = zero | override var availableQty: BigDecimal? = zero | ||||
| override val requiredQty: BigDecimal = value.sumOf { it.qty ?: 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 = | val suggestions = | ||||
| suggestedPickLotService.suggestionForPickOrders(SuggestedPickLotForPoRequest(pickOrders = pos)) | suggestedPickLotService.suggestionForPickOrders(SuggestedPickLotForPoRequest(pickOrders = pos)) | ||||
| @@ -793,24 +756,10 @@ open class PickOrderService( | |||||
| emptyMap() | 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 | val releasePickOrderLineInfos = pos | ||||
| .map { po -> | .map { po -> | ||||
| val releasePickOrderLineInfos = po.pickOrderLines.map { pol -> | 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 | // 取得這一行的所有 stock_out_line | ||||
| val stockOutLines = stockOutLinesByPickOrderLineId[pol.id] ?: emptyList() | val stockOutLines = stockOutLinesByPickOrderLineId[pol.id] ?: emptyList() | ||||
| @@ -871,16 +820,11 @@ open class PickOrderService( | |||||
| } | } | ||||
| // Items | // Items | ||||
| val currentInventoryInfos = requiredItems.map { item -> | 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 { | 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 | it | ||||
| } | } | ||||
| } | } | ||||
| @@ -2344,19 +2288,16 @@ open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageRespons | |||||
| -- Calculate available quantity from inventory | -- Calculate available quantity from inventory | ||||
| COALESCE(( | 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 | 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 | WHERE inv.itemId = i.id | ||||
| AND inv.deleted = false | 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, | ), 0) as availableQty, | ||||
| -- Check if all stock out lines for this pick order line are completed | -- 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 | |||||
| } | |||||
| } | } | ||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| } | |||||
| @@ -16,6 +16,7 @@ open class TruckLaneVersionService( | |||||
| private val truckLaneVersionRepository: TruckLaneVersionRepository, | private val truckLaneVersionRepository: TruckLaneVersionRepository, | ||||
| private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | private val truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | ||||
| private val logisticRepository: LogisticRepository, | private val logisticRepository: LogisticRepository, | ||||
| private val truckLaneRenameService: TruckLaneRenameService, | |||||
| ) { | ) { | ||||
| private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = | private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = | ||||
| TruckLaneVersionResponse( | TruckLaneVersionResponse( | ||||
| @@ -271,6 +272,7 @@ open class TruckLaneVersionService( | |||||
| } | } | ||||
| } | } | ||||
| rewriteOpenSchedulesBackToSnapshot(version, lines) | |||||
| restoreRowsFromLines(version, lines) | restoreRowsFromLines(version, lines) | ||||
| if (!skipPostSnapshot) { | if (!skipPostSnapshot) { | ||||
| @@ -316,6 +318,7 @@ open class TruckLaneVersionService( | |||||
| return "No snapshot lines for affected lanes (versionId=$versionId)" | return "No snapshot lines for affected lanes (versionId=$versionId)" | ||||
| } | } | ||||
| rewriteOpenSchedulesBackToSnapshot(version, scopedLines) | |||||
| val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() | val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() | ||||
| for (lane in codes) { | for (lane in codes) { | ||||
| val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) | 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" | 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( | private fun restoreRowsFromLines( | ||||
| version: TruckLaneVersion, | version: TruckLaneVersion, | ||||
| lines: List<TruckLaneVersionLine>, | lines: List<TruckLaneVersionLine>, | ||||
| @@ -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.SaveTruckRequest | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest | import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest | 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.service.TruckService | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | 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.SaveTruckLane | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse | import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest | import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest | ||||
| @@ -37,6 +40,7 @@ import jakarta.validation.Valid | |||||
| open class TruckController( | open class TruckController( | ||||
| private val truckService: TruckService, | private val truckService: TruckService, | ||||
| private val truckRepository: TruckRepository, | 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() | |||||
| } | } | ||||
| @@ -22,13 +22,13 @@ import java.time.LocalDateTime | |||||
| open class TruckLaneScheduleController @Autowired constructor( | open class TruckLaneScheduleController @Autowired constructor( | ||||
| private val truckLaneScheduleService: TruckLaneScheduleService, | private val truckLaneScheduleService: TruckLaneScheduleService, | ||||
| ) { | ) { | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping | @PostMapping | ||||
| open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | ||||
| return truckLaneScheduleService.createManual(request) | return truckLaneScheduleService.createManual(request) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/planFromRouteExcel") | @PostMapping("/planFromRouteExcel") | ||||
| @Throws(ServletRequestBindingException::class) | @Throws(ServletRequestBindingException::class) | ||||
| open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> { | open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> { | ||||
| @@ -66,19 +66,19 @@ open class TruckLaneScheduleController @Autowired constructor( | |||||
| return truckLaneScheduleService.getById(id) | return truckLaneScheduleService.getById(id) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/cancel") | @PostMapping("/{id}/cancel") | ||||
| open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { | open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { | ||||
| return truckLaneScheduleService.cancel(id) | return truckLaneScheduleService.cancel(id) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/applyNow") | @PostMapping("/{id}/applyNow") | ||||
| open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { | open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { | ||||
| return truckLaneScheduleService.applyNow(id) | return truckLaneScheduleService.applyNow(id) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/retry-failed") | @PostMapping("/{id}/retry-failed") | ||||
| open fun retryFailed( | open fun retryFailed( | ||||
| @PathVariable id: Long, | @PathVariable id: Long, | ||||
| @@ -87,7 +87,7 @@ open class TruckLaneScheduleController @Autowired constructor( | |||||
| return truckLaneScheduleService.retryFailed(id, request?.executeAt) | return truckLaneScheduleService.retryFailed(id, request?.executeAt) | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/{id}/reactivate") | @PostMapping("/{id}/reactivate") | ||||
| open fun reactivateCancelled( | open fun reactivateCancelled( | ||||
| @PathVariable id: Long, | @PathVariable id: Long, | ||||
| @@ -124,7 +124,7 @@ open class TruckLaneScheduleController @Autowired constructor( | |||||
| } | } | ||||
| } | } | ||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||||
| @PostMapping("/importExcel") | @PostMapping("/importExcel") | ||||
| @Throws(ServletRequestBindingException::class) | @Throws(ServletRequestBindingException::class) | ||||
| open fun importExcel( | open fun importExcel( | ||||
| @@ -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, | |||||
| ) | |||||
| @@ -109,6 +109,7 @@ data class jobOrderLineInfo( | |||||
| val stockUom: String?, | val stockUom: String?, | ||||
| val stockBaseUom: String?, | val stockBaseUom: String?, | ||||
| val stockUomId: Long?, | |||||
| val availableStatus: String?, | val availableStatus: String?, | ||||
| val bomProcessId: Long?, | val bomProcessId: Long?, | ||||
| @@ -915,6 +915,7 @@ open class ProductProcessService( | |||||
| reqBaseUom = baseUomName ?: "", | reqBaseUom = baseUomName ?: "", | ||||
| stockUom = stockUomName ?: "", | stockUom = stockUomName ?: "", | ||||
| stockBaseUom = baseUomName ?: "", | stockBaseUom = baseUomName ?: "", | ||||
| stockUomId = stockUomId, | |||||
| type = line.item?.type ?: "", | type = line.item?.type ?: "", | ||||
| availableStatus = availableStatus, | availableStatus = availableStatus, | ||||
| @@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||||
| orderDate = it["orderDate"] as LocalDateTime?, | orderDate = it["orderDate"] as LocalDateTime?, | ||||
| estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, | estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, | ||||
| supplierName = it["supplierName"] as String?, | 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(" group by po.id"); | ||||
| sql.append(" order by po.orderDate desc") | sql.append(" order by po.orderDate desc") | ||||
| sql.append(" ) r") | 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() | return sql.toString() | ||||
| } | } | ||||
| @@ -47,6 +47,8 @@ class PurchaseOrderController( | |||||
| val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | ||||
| .addStringLike("supplier") | .addStringLike("supplier") | ||||
| .addStringLike("code") | .addStringLike("code") | ||||
| .addStringLike("itemCode") | |||||
| .addStringLike("itemName") | |||||
| .addString("status") | .addString("status") | ||||
| .addBoolean("escalated") | .addBoolean("escalated") | ||||
| .addDate("orderDate") | .addDate("orderDate") | ||||
| @@ -105,7 +105,7 @@ class FGStockOutTraceabilityReportService( | |||||
| CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | ||||
| IFNULL(sp.name, '') AS customerName, | IFNULL(sp.name, '') AS customerName, | ||||
| FORMAT( | 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, | ) AS qtyNumeric, | ||||
| FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, | FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, | ||||
| '' AS truckNo, | '' AS truckNo, | ||||
| @@ -131,7 +131,7 @@ class FGStockOutTraceabilityReportService( | |||||
| GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | ||||
| '' AS pickRemark, | '' AS pickRemark, | ||||
| FORMAT( | 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, | ) AS totalStockOutQty, | ||||
| 0 AS stockSubCategory | 0 AS stockSubCategory | ||||
| FROM delivery_order_pick_order dopo | FROM delivery_order_pick_order dopo | ||||
| @@ -154,11 +154,6 @@ class FGStockOutTraceabilityReportService( | |||||
| ON dol.deliveryOrderId = do.id | ON dol.deliveryOrderId = do.id | ||||
| AND dol.itemId = pol.itemId | AND dol.itemId = pol.itemId | ||||
| AND dol.deleted = 0 | 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 | LEFT JOIN stock_out_line sol | ||||
| ON pol.id = sol.pickOrderLineId | ON pol.id = sol.pickOrderLineId | ||||
| AND sol.itemId = it.id | AND sol.itemId = it.id | ||||
| @@ -170,6 +165,20 @@ class FGStockOutTraceabilityReportService( | |||||
| LEFT JOIN inventory_lot_line ill | LEFT JOIN inventory_lot_line ill | ||||
| ON sol.inventoryLotLineId = ill.id | ON sol.inventoryLotLineId = ill.id | ||||
| AND ill.deleted = 0 | 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 | LEFT JOIN inventory_lot il | ||||
| ON ill.inventoryLotId = il.id | ON ill.inventoryLotId = il.id | ||||
| AND il.deleted = 0 | AND il.deleted = 0 | ||||
| @@ -213,6 +222,7 @@ class FGStockOutTraceabilityReportService( | |||||
| il.expiryDate | il.expiryDate | ||||
| ORDER BY | ORDER BY | ||||
| it.code, | it.code, | ||||
| uc.udfudesc, | |||||
| deliveryDate, | deliveryDate, | ||||
| il.lotNo | il.lotNo | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -109,12 +109,23 @@ open class ItemQcFailReportService( | |||||
| LEFT JOIN qc_result_measurement qrm | LEFT JOIN qc_result_measurement qrm | ||||
| ON qrm.qcResultId = qr.id | ON qrm.qcResultId = qr.id | ||||
| AND qrm.deleted = 0 | 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 | LEFT JOIN item_uom iu | ||||
| ON it.id = iu.itemId | |||||
| AND iu.stockUnit = 1 | |||||
| ON iu.id = ill_uom.stockItemUomId | |||||
| AND iu.deleted = 0 | AND iu.deleted = 0 | ||||
| LEFT JOIN uom_conversion uc | LEFT JOIN uom_conversion uc | ||||
| ON iu.uomId = uc.id | |||||
| ON uc.id = iu.uomId | |||||
| LEFT JOIN item_uom iu_purchase | LEFT JOIN item_uom iu_purchase | ||||
| ON it.id = iu_purchase.itemId | ON it.id = iu_purchase.itemId | ||||
| AND iu_purchase.purchaseUnit = 1 | AND iu_purchase.purchaseUnit = 1 | ||||
| @@ -164,6 +175,7 @@ open class ItemQcFailReportService( | |||||
| qr.qcPassed DESC, | qr.qcPassed DESC, | ||||
| it.type, | it.type, | ||||
| it.code, | it.code, | ||||
| COALESCE(uc.udfudesc, uc.code, ''), | |||||
| COALESCE(sil.lotNo, il.lotNo, ''), | COALESCE(sil.lotNo, il.lotNo, ''), | ||||
| CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END, | CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END, | ||||
| COALESCE(qic.`order`, 9999), | COALESCE(qic.`order`, 9999), | ||||
| @@ -105,7 +105,7 @@ class MaterialStockOutTraceabilityReportService( | |||||
| COALESCE(wh.code, '') AS storeLocation, | COALESCE(wh.code, '') AS storeLocation, | ||||
| '' AS pickRemark, | '' AS pickRemark, | ||||
| FORMAT( | 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 | ) AS totalStockOutQty | ||||
| FROM stock_out_line sol | FROM stock_out_line sol | ||||
| INNER JOIN stock_out so | INNER JOIN stock_out so | ||||
| @@ -126,14 +126,23 @@ class MaterialStockOutTraceabilityReportService( | |||||
| INNER JOIN items it | INNER JOIN items it | ||||
| ON sol.itemId = it.id | ON sol.itemId = it.id | ||||
| AND it.deleted = 0 | 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 | LEFT JOIN inventory_lot_line ill | ||||
| ON sol.inventoryLotLineId = ill.id | ON sol.inventoryLotLineId = ill.id | ||||
| AND ill.deleted = 0 | 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 | LEFT JOIN inventory_lot il | ||||
| ON ill.inventoryLotId = il.id | ON ill.inventoryLotId = il.id | ||||
| AND il.deleted = 0 | AND il.deleted = 0 | ||||
| @@ -169,6 +178,7 @@ class MaterialStockOutTraceabilityReportService( | |||||
| $handlerSql | $handlerSql | ||||
| ORDER BY | ORDER BY | ||||
| it.code, | it.code, | ||||
| uc.udfudesc, | |||||
| il.lotNo, | il.lotNo, | ||||
| sol.endTime | sol.endTime | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -4,7 +4,7 @@ import org.springframework.stereotype.Service | |||||
| import net.sf.jasperreports.engine.* | import net.sf.jasperreports.engine.* | ||||
| import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | ||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.io.InputStream | |||||
| import java.util.concurrent.ConcurrentHashMap | |||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.fpsms.m18.M18GrnRules | import com.ffii.fpsms.m18.M18GrnRules | ||||
| import com.ffii.fpsms.modules.master.entity.ShopRepository | 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.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.text.DecimalFormat | |||||
| import java.text.DecimalFormatSymbols | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.util.ArrayList | |||||
| import java.util.Locale | |||||
| import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | ||||
| import net.sf.jasperreports.export.SimpleExporterInput | import net.sf.jasperreports.export.SimpleExporterInput | ||||
| import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | ||||
| import org.slf4j.LoggerFactory | |||||
| @Service | @Service | ||||
| open class ReportService( | open class ReportService( | ||||
| @@ -28,6 +25,8 @@ open class ReportService( | |||||
| private val shopRepository: ShopRepository, | private val shopRepository: ShopRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | 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. | * 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, | CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | ||||
| IFNULL(sp.name, '') AS customerName, | IFNULL(sp.name, '') AS customerName, | ||||
| FORMAT( | 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, | ) AS qtyNumeric, | ||||
| FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, | FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, | ||||
| '' AS truckNo, | '' AS truckNo, | ||||
| @@ -397,7 +396,7 @@ return result | |||||
| GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | ||||
| '' AS pickRemark, | '' AS pickRemark, | ||||
| FORMAT( | 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, | ) AS totalStockOutQty, | ||||
| 0 AS stockSubCategory | 0 AS stockSubCategory | ||||
| FROM do_pick_order_line_record dpolr | FROM do_pick_order_line_record dpolr | ||||
| @@ -417,11 +416,6 @@ return result | |||||
| LEFT JOIN items it | LEFT JOIN items it | ||||
| ON dol.itemId = it.id | ON dol.itemId = it.id | ||||
| AND it.deleted = 0 | 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 | LEFT JOIN pick_order_line pol | ||||
| ON dpolr.pick_order_id = pol.poId | ON dpolr.pick_order_id = pol.poId | ||||
| AND pol.itemId = it.id | AND pol.itemId = it.id | ||||
| @@ -440,6 +434,20 @@ return result | |||||
| LEFT JOIN inventory_lot_line ill | LEFT JOIN inventory_lot_line ill | ||||
| ON sol.inventoryLotLineId = ill.id | ON sol.inventoryLotLineId = ill.id | ||||
| AND ill.deleted = 0 | 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 | LEFT JOIN inventory_lot il | ||||
| ON ill.inventoryLotId = il.id | ON ill.inventoryLotId = il.id | ||||
| AND il.deleted = 0 | AND il.deleted = 0 | ||||
| @@ -480,6 +488,7 @@ return result | |||||
| il.expiryDate | il.expiryDate | ||||
| ORDER BY | ORDER BY | ||||
| it.code, | it.code, | ||||
| uc.udfudesc, | |||||
| deliveryDate, | deliveryDate, | ||||
| il.lotNo | il.lotNo | ||||
| """.trimIndent() | """.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. | * 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. | * Supports comma-separated values for stockCategory (items.type) and itemCode. | ||||
| * Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告. | * 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( | fun searchStockInTraceabilityReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| itemCode: String?, | itemCode: String?, | ||||
| lastInDateStart: 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>> { | ): List<Map<String, Any>> { | ||||
| val args = mutableMapOf<String, Any>() | val args = mutableMapOf<String, Any>() | ||||
| @@ -689,7 +705,7 @@ return result | |||||
| } else { | } else { | ||||
| "" | "" | ||||
| } | } | ||||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) | |||||
| val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args) | |||||
| val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | ||||
| val formattedDate = lastInDateStart.replace("/", "-") | val formattedDate = lastInDateStart.replace("/", "-") | ||||
| @@ -703,6 +719,34 @@ return result | |||||
| "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)" | "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)" | ||||
| } else "" | } 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 = """ | val sql = """ | ||||
| SELECT | SELECT | ||||
| COALESCE(it.code, '') as itemNo, | COALESCE(it.code, '') as itemNo, | ||||
| @@ -723,19 +767,33 @@ return result | |||||
| END, 2))) as iqcDefectPercentage, | END, 2))) as iqcDefectPercentage, | ||||
| CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '不合格' ELSE '已接受' END as iqcResult, | CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '不合格' ELSE '已接受' END as iqcResult, | ||||
| COALESCE(qr_agg.remarksConcat, '') as iqcRemarks, | 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(wh.code, '') as storeLocation, | ||||
| COALESCE(sp_si.code, sp_po.code, '') as supplierID, | COALESCE(sp_si.code, sp_po.code, '') as supplierID, | ||||
| COALESCE(sp_si.name, sp_po.name, '') as supplierName, | 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 | FROM stock_in_line sil | ||||
| LEFT JOIN stock_in si ON sil.stockInId = si.id | LEFT JOIN stock_in si ON sil.stockInId = si.id | ||||
| LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | ||||
| LEFT JOIN items it ON sil.itemId = it.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 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 ( | LEFT JOIN ( | ||||
| SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum | SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum | ||||
| FROM inventory_lot_line | FROM inventory_lot_line | ||||
| @@ -760,11 +818,18 @@ return result | |||||
| GROUP BY qr.stockInLineId | GROUP BY qr.stockInLineId | ||||
| ) qr_agg ON qr_agg.stockInLineId = sil.id | ) qr_agg ON qr_agg.stockInLineId = sil.id | ||||
| WHERE sil.deleted = false | WHERE sil.deleted = false | ||||
| AND UPPER(TRIM(COALESCE(sil.type, ''))) = 'NOR' | |||||
| $stockCategorySql | $stockCategorySql | ||||
| $itemCodeSql | $itemCodeSql | ||||
| $lastInDateStartSql | $lastInDateStartSql | ||||
| $lastInDateEndSql | $lastInDateEndSql | ||||
| ORDER BY it.code, sil.lotNo | |||||
| $storeIdSql | |||||
| $warehouseSql | |||||
| $areaSql | |||||
| $slotSql | |||||
| $lotNoSql | |||||
| $poPrefixSql | |||||
| ORDER BY it.code, uc.udfudesc, sil.lotNo | |||||
| """.trimIndent() | """.trimIndent() | ||||
| return jdbcDao.queryForList(sql, args) | 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( | fun searchStockBalanceReportByDate( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -1502,238 +1570,311 @@ return result | |||||
| balanceFilterEnd: String?, | balanceFilterEnd: String?, | ||||
| storeLocation: String?, | storeLocation: String?, | ||||
| ): List<Map<String, Any>> { | ): 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 | 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 | 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. | * 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 { | 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 dataSource = JRMapCollectionDataSource(dataList) | ||||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | ||||
| val out = ByteArrayOutputStream() | val out = ByteArrayOutputStream() | ||||
| @@ -1748,17 +1889,20 @@ return result | |||||
| * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. | * 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 { | 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 dataSource = JRMapCollectionDataSource(dataList) | ||||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | ||||
| return JasperExportManager.exportReportToPdf(jasperPrint) | 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 { | fun mergePdfBytes(parts: List<ByteArray>): ByteArray { | ||||
| if (parts.isEmpty()) return ByteArray(0) | if (parts.isEmpty()) return ByteArray(0) | ||||
| if (parts.size == 1) return parts[0] | if (parts.size == 1) return parts[0] | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import java.time.LocalDate | |||||
| @Service | @Service | ||||
| class SemiFGProductionAnalysisReportService( | class SemiFGProductionAnalysisReportService( | ||||
| @@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService( | |||||
| return jdbcDao.queryForList(sql, args) | 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. | * Gets list of item codes (bom.code) with names based on stockCategory filter. | ||||
| * Supports multiple categories separated by comma (e.g., "FG,WIP"). | * Supports multiple categories separated by comma (e.g., "FG,WIP"). | ||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -1,7 +1,10 @@ | |||||
| package com.ffii.fpsms.modules.report.service | package com.ffii.fpsms.modules.report.service | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.math.BigDecimal | |||||
| import java.math.RoundingMode | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| @@ -9,7 +12,7 @@ import java.time.format.DateTimeFormatter | |||||
| open class StockLedgerReportService( | open class StockLedgerReportService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(StockLedgerReportService::class.java) | |||||
| private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| /** | /** | ||||
| @@ -18,11 +21,12 @@ open class StockLedgerReportService( | |||||
| * | * | ||||
| * - stockSubCategory = items.type | * - stockSubCategory = items.type | ||||
| * - trnDate = stock_ledger.date | * - 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( | fun searchStockLedgerReport( | ||||
| stockCategory: String?, | stockCategory: String?, | ||||
| @@ -31,263 +35,587 @@ open class StockLedgerReportService( | |||||
| reportPeriodStart: String?, | reportPeriodStart: String?, | ||||
| reportPeriodEnd: String?, | reportPeriodEnd: String?, | ||||
| ): List<Map<String, Any>> { | ): 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)) | ?: 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("/", "-") | reportPeriodStart.replace("/", "-") | ||||
| } else { | } 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 | WHERE sl.deleted = 0 | ||||
| AND sl.itemCode IS NOT NULL | AND sl.itemCode IS NOT NULL | ||||
| AND sl.itemCode <> '' | 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 = """ | 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. */ | /** Full codes use equality; short tokens use prefix LIKE. */ | ||||
| private fun buildItemCodeFilterClause( | private fun buildItemCodeFilterClause( | ||||
| paramValue: String?, | paramValue: String?, | ||||
| columnName: String, | columnName: String, | ||||
| paramPrefix: String, | paramPrefix: String, | ||||
| args: MutableMap<String, Any> | |||||
| args: MutableMap<String, Any>, | |||||
| ): String { | ): String { | ||||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue) | val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue) | ||||
| if (exact.isEmpty() && fuzzy.isEmpty()) return "" | if (exact.isEmpty() && fuzzy.isEmpty()) return "" | ||||
| val parts = mutableListOf<String>() | val parts = mutableListOf<String>() | ||||
| exact.forEachIndexed { index, value -> | exact.forEachIndexed { index, value -> | ||||
| val paramName = "${paramPrefix}_eq_$index" | val paramName = "${paramPrefix}_eq_$index" | ||||
| @@ -302,22 +630,64 @@ ORDER BY | |||||
| return "AND (${parts.joinToString(" OR ")})" | 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 ")})" | |||||
| } | } | ||||
| } | } | ||||
| @@ -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 ")})" | |||||
| } | |||||
| } | |||||
| @@ -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 ")})" | |||||
| } | |||||
| } | |||||
| @@ -245,8 +245,7 @@ in_agg AS ( | |||||
| AND iu_purchase.purchaseUnit = 1 | AND iu_purchase.purchaseUnit = 1 | ||||
| AND iu_purchase.deleted = 0 | AND iu_purchase.deleted = 0 | ||||
| LEFT JOIN item_uom iu_stock | 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 | AND iu_stock.deleted = 0 | ||||
| WHERE ill.deleted = 0 | WHERE ill.deleted = 0 | ||||
| GROUP BY ill.id | GROUP BY ill.id | ||||
| @@ -316,8 +315,7 @@ data AS ( | |||||
| $ROOT_STOCK_IN_JOIN_SQL | $ROOT_STOCK_IN_JOIN_SQL | ||||
| LEFT JOIN item_uom iu | LEFT JOIN item_uom iu | ||||
| ON it.id = iu.itemId | |||||
| AND iu.stockUnit = 1 | |||||
| ON iu.id = ill.stockItemUomId | |||||
| AND iu.deleted = 0 | AND iu.deleted = 0 | ||||
| LEFT JOIN uom_conversion uc | LEFT JOIN uom_conversion uc | ||||
| ON iu.uomId = uc.id | ON iu.uomId = uc.id | ||||
| @@ -377,16 +375,17 @@ SELECT | |||||
| END AS variancePercentage, | END AS variancePercentage, | ||||
| /* item totals(footer 用);負數括號顯示,無小數 */ | /* 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 | lotTypeRaw AS type | ||||
| FROM data | FROM data | ||||
| ORDER BY | ORDER BY | ||||
| itemNo, | itemNo, | ||||
| unitOfMeasure, | |||||
| lotNo, | lotNo, | ||||
| storeLocation | storeLocation | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -521,8 +520,7 @@ in_agg AS ( | |||||
| AND iu_purchase.purchaseUnit = 1 | AND iu_purchase.purchaseUnit = 1 | ||||
| AND iu_purchase.deleted = 0 | AND iu_purchase.deleted = 0 | ||||
| LEFT JOIN item_uom iu_stock | 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 | AND iu_stock.deleted = 0 | ||||
| WHERE ill.deleted = 0 | WHERE ill.deleted = 0 | ||||
| GROUP BY ill.id | GROUP BY ill.id | ||||
| @@ -598,8 +596,7 @@ data AS ( | |||||
| $ROOT_STOCK_IN_JOIN_SQL | $ROOT_STOCK_IN_JOIN_SQL | ||||
| LEFT JOIN item_uom iu | LEFT JOIN item_uom iu | ||||
| ON it.id = iu.itemId | |||||
| AND iu.stockUnit = 1 | |||||
| ON iu.id = ill.stockItemUomId | |||||
| AND iu.deleted = 0 | AND iu.deleted = 0 | ||||
| LEFT JOIN uom_conversion uc | LEFT JOIN uom_conversion uc | ||||
| ON iu.uomId = uc.id | ON iu.uomId = uc.id | ||||
| @@ -655,16 +652,17 @@ SELECT | |||||
| ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') | ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') | ||||
| END AS variancePercentage, | 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 | lotTypeRaw AS type | ||||
| FROM data | FROM data | ||||
| ORDER BY | ORDER BY | ||||
| itemNo, | itemNo, | ||||
| unitOfMeasure, | |||||
| lotNo, | lotNo, | ||||
| storeLocation | storeLocation | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -820,8 +818,7 @@ in_agg AS ( | |||||
| AND iu_purchase.purchaseUnit = 1 | AND iu_purchase.purchaseUnit = 1 | ||||
| AND iu_purchase.deleted = 0 | AND iu_purchase.deleted = 0 | ||||
| LEFT JOIN item_uom iu_stock | 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 | AND iu_stock.deleted = 0 | ||||
| WHERE ill.deleted = 0 | WHERE ill.deleted = 0 | ||||
| GROUP BY lb.inventoryLotLineId | GROUP BY lb.inventoryLotLineId | ||||
| @@ -888,8 +885,7 @@ data AS ( | |||||
| $ROOT_STOCK_IN_JOIN_SQL | $ROOT_STOCK_IN_JOIN_SQL | ||||
| LEFT JOIN item_uom iu | LEFT JOIN item_uom iu | ||||
| ON it.id = iu.itemId | |||||
| AND iu.stockUnit = 1 | |||||
| ON iu.id = ill.stockItemUomId | |||||
| AND iu.deleted = 0 | AND iu.deleted = 0 | ||||
| LEFT JOIN uom_conversion uc | LEFT JOIN uom_conversion uc | ||||
| ON iu.uomId = uc.id | ON iu.uomId = uc.id | ||||
| @@ -945,16 +941,17 @@ SELECT | |||||
| ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') | ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') | ||||
| END AS variancePercentage, | 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 | lotTypeRaw AS type | ||||
| FROM data | FROM data | ||||
| ORDER BY | ORDER BY | ||||
| itemNo, | itemNo, | ||||
| unitOfMeasure, | |||||
| lotNo, | lotNo, | ||||
| storeLocation | storeLocation | ||||
| """.trimIndent() | """.trimIndent() | ||||
| @@ -159,7 +159,7 @@ class FGStockOutTraceabilityReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -216,7 +216,7 @@ class FGStockOutTraceabilityReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -306,11 +306,13 @@ class FGStockOutTraceabilityReportController( | |||||
| } else { | } else { | ||||
| var currentItemNo: String? = null | var currentItemNo: String? = null | ||||
| var currentItemName = "" | var currentItemName = "" | ||||
| var currentUom = "" | |||||
| var currentItemTotalQty = 0.0 | var currentItemTotalQty = 0.0 | ||||
| dbData.forEach { rowMap -> | dbData.forEach { rowMap -> | ||||
| val itemNo = rowMap["itemNo"]?.toString() ?: "" | 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) | addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) | ||||
| addBlankSeparatorRow() | addBlankSeparatorRow() | ||||
| currentItemTotalQty = 0.0 | currentItemTotalQty = 0.0 | ||||
| @@ -331,6 +333,7 @@ class FGStockOutTraceabilityReportController( | |||||
| currentItemNo = itemNo | currentItemNo = itemNo | ||||
| currentItemName = rowMap["itemName"]?.toString() ?: "" | currentItemName = rowMap["itemName"]?.toString() ?: "" | ||||
| currentUom = uom | |||||
| currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 | currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 | ||||
| } | } | ||||
| @@ -271,7 +271,7 @@ class ItemQcFailReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df = workbook.creationHelper.createDataFormat() | val df = workbook.creationHelper.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val decimalNumberStyle = workbook.createCellStyle().apply { | val decimalNumberStyle = workbook.createCellStyle().apply { | ||||
| @@ -282,7 +282,7 @@ class ItemQcFailReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df = workbook.creationHelper.createDataFormat() | val df = workbook.creationHelper.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0.##") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val headers = listOf( | val headers = listOf( | ||||
| @@ -244,7 +244,7 @@ class MaterialStockOutTraceabilityReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -282,7 +282,7 @@ class MaterialStockOutTraceabilityReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THIN | borderTop = BorderStyle.THIN | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -358,11 +358,13 @@ class MaterialStockOutTraceabilityReportController( | |||||
| } else { | } else { | ||||
| var currentItemNo: String? = null | var currentItemNo: String? = null | ||||
| var currentItemName: String = "" | var currentItemName: String = "" | ||||
| var currentUom = "" | |||||
| var currentItemTotalQty: Double = 0.0 | var currentItemTotalQty: Double = 0.0 | ||||
| dbData.forEach { rowMap -> | dbData.forEach { rowMap -> | ||||
| val itemNo = rowMap["itemNo"]?.toString() ?: "" | 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) | addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) | ||||
| addBlankSeparatorRow() | addBlankSeparatorRow() | ||||
| currentItemTotalQty = 0.0 | currentItemTotalQty = 0.0 | ||||
| @@ -383,6 +385,7 @@ class MaterialStockOutTraceabilityReportController( | |||||
| currentItemNo = itemNo | currentItemNo = itemNo | ||||
| currentItemName = rowMap["itemName"]?.toString() ?: "" | currentItemName = rowMap["itemName"]?.toString() ?: "" | ||||
| currentUom = uom | |||||
| currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 | currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 | ||||
| } | } | ||||
| @@ -122,9 +122,18 @@ class ReportController( | |||||
| @RequestParam(required = false) stockCategory: String?, | @RequestParam(required = false) stockCategory: String?, | ||||
| @RequestParam(required = false) itemCode: String?, | @RequestParam(required = false) itemCode: String?, | ||||
| @RequestParam(required = false) lastInDateStart: 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> = | ): ResponseEntity<ByteArray> = | ||||
| buildStockInTraceabilityPdf(stockCategory, itemCode, lastInDateStart, lastInDateEnd) | |||||
| buildStockInTraceabilityPdf( | |||||
| stockCategory, itemCode, lastInDateStart, lastInDateEnd, | |||||
| storeId, warehouse, area, slot, lotNo, poPrefix, | |||||
| ) | |||||
| @PostMapping("/print-stock-in-traceability") | @PostMapping("/print-stock-in-traceability") | ||||
| fun generateStockInTraceabilityReportPost( | fun generateStockInTraceabilityReportPost( | ||||
| @@ -136,6 +145,12 @@ class ReportController( | |||||
| r.combinedItemCode(), | r.combinedItemCode(), | ||||
| r.lastInDateStart, | r.lastInDateStart, | ||||
| r.lastInDateEnd, | r.lastInDateEnd, | ||||
| r.storeId, | |||||
| r.warehouse, | |||||
| r.area, | |||||
| r.slot, | |||||
| r.lotNo, | |||||
| r.poPrefix, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -144,6 +159,12 @@ class ReportController( | |||||
| itemCode: String?, | itemCode: String?, | ||||
| lastInDateStart: String?, | lastInDateStart: String?, | ||||
| lastInDateEnd: String?, | lastInDateEnd: String?, | ||||
| storeId: String?, | |||||
| warehouse: String?, | |||||
| area: String?, | |||||
| slot: String?, | |||||
| lotNo: String?, | |||||
| poPrefix: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val parameters = mutableMapOf<String, Any>() | val parameters = mutableMapOf<String, Any>() | ||||
| @@ -159,7 +180,13 @@ class ReportController( | |||||
| stockCategory, | stockCategory, | ||||
| itemCode, | itemCode, | ||||
| lastInDateStart, | lastInDateStart, | ||||
| lastInDateEnd | |||||
| lastInDateEnd, | |||||
| storeId, | |||||
| warehouse, | |||||
| area, | |||||
| slot, | |||||
| lotNo, | |||||
| poPrefix, | |||||
| ) | ) | ||||
| val pdfBytes = reportService.createPdfResponse( | val pdfBytes = reportService.createPdfResponse( | ||||
| @@ -183,8 +210,17 @@ class ReportController( | |||||
| @RequestParam(required = false) itemCode: String?, | @RequestParam(required = false) itemCode: String?, | ||||
| @RequestParam(required = false) lastInDateStart: 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> = | ): ResponseEntity<ByteArray> = | ||||
| buildStockInTraceabilityExcel(stockCategory, itemCode, lastInDateStart, lastInDateEnd) | |||||
| buildStockInTraceabilityExcel( | |||||
| stockCategory, itemCode, lastInDateStart, lastInDateEnd, | |||||
| storeId, warehouse, area, slot, lotNo, poPrefix, | |||||
| ) | |||||
| @PostMapping("/print-stock-in-traceability-excel") | @PostMapping("/print-stock-in-traceability-excel") | ||||
| fun exportStockInTraceabilityReportExcelPost( | fun exportStockInTraceabilityReportExcelPost( | ||||
| @@ -196,6 +232,12 @@ class ReportController( | |||||
| r.combinedItemCode(), | r.combinedItemCode(), | ||||
| r.lastInDateStart, | r.lastInDateStart, | ||||
| r.lastInDateEnd, | r.lastInDateEnd, | ||||
| r.storeId, | |||||
| r.warehouse, | |||||
| r.area, | |||||
| r.slot, | |||||
| r.lotNo, | |||||
| r.poPrefix, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -204,18 +246,37 @@ class ReportController( | |||||
| itemCode: String?, | itemCode: String?, | ||||
| lastInDateStart: String?, | lastInDateStart: String?, | ||||
| lastInDateEnd: String?, | lastInDateEnd: String?, | ||||
| storeId: String?, | |||||
| warehouse: String?, | |||||
| area: String?, | |||||
| slot: String?, | |||||
| lotNo: String?, | |||||
| poPrefix: String?, | |||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val dbData = reportService.searchStockInTraceabilityReport( | val dbData = reportService.searchStockInTraceabilityReport( | ||||
| stockCategory, | stockCategory, | ||||
| itemCode, | itemCode, | ||||
| lastInDateStart, | lastInDateStart, | ||||
| lastInDateEnd, | lastInDateEnd, | ||||
| storeId, | |||||
| warehouse, | |||||
| area, | |||||
| slot, | |||||
| lotNo, | |||||
| poPrefix, | |||||
| ) | ) | ||||
| val excelBytes = createStockInTraceabilityExcel( | val excelBytes = createStockInTraceabilityExcel( | ||||
| dbData = dbData, | dbData = dbData, | ||||
| lastInDateStart = lastInDateStart ?: "", | lastInDateStart = lastInDateStart ?: "", | ||||
| lastInDateEnd = lastInDateEnd ?: "", | lastInDateEnd = lastInDateEnd ?: "", | ||||
| itemCode = itemCode, | |||||
| storeId = storeId, | |||||
| warehouse = warehouse, | |||||
| area = area, | |||||
| slot = slot, | |||||
| lotNo = lotNo, | |||||
| poPrefix = poPrefix, | |||||
| ) | ) | ||||
| val headers = HttpHeaders().apply { | val headers = HttpHeaders().apply { | ||||
| @@ -664,7 +725,7 @@ class ReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | ||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| @@ -777,6 +838,13 @@ class ReportController( | |||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| lastInDateStart: String, | lastInDateStart: String, | ||||
| lastInDateEnd: 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 { | ): ByteArray { | ||||
| val workbook = XSSFWorkbook() | val workbook = XSSFWorkbook() | ||||
| val styles = createCommonWorkbookStyles(workbook) | val styles = createCommonWorkbookStyles(workbook) | ||||
| @@ -812,8 +880,18 @@ class ReportController( | |||||
| } | } | ||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) | sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) | ||||
| subtitleRow.createCell(6).apply { | 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 | cellStyle = styles.subtitle | ||||
| } | } | ||||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1)) | sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1)) | ||||
| @@ -869,7 +947,7 @@ class ReportController( | |||||
| val itemName = m["itemName"]?.toString().orEmpty() | val itemName = m["itemName"]?.toString().orEmpty() | ||||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | val uom = m["unitOfMeasure"]?.toString().orEmpty() | ||||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||||
| if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { | |||||
| addItemSummaryRow( | addItemSummaryRow( | ||||
| currentItemNo!!, | currentItemNo!!, | ||||
| currentItemName, | currentItemName, | ||||
| @@ -1039,7 +1117,7 @@ class ReportController( | |||||
| setTextCell(r, 4, m["dnNo"], styles.text) | setTextCell(r, 4, m["dnNo"], styles.text) | ||||
| setTextCell(r, 5, m["deliveryOrderNo"], styles.text) | setTextCell(r, 5, m["deliveryOrderNo"], styles.text) | ||||
| setTextCell(r, 6, m["customerName"], 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) | setTextCell(r, 8, m["truckNo"], styles.center) | ||||
| itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0 | itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0 | ||||
| @@ -1063,6 +1141,7 @@ class ReportController( | |||||
| return out.toByteArray() | return out.toByteArray() | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 */ | |||||
| private fun createStockBalanceExcel( | private fun createStockBalanceExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportDate: String, | reportDate: String, | ||||
| @@ -1121,13 +1200,13 @@ class ReportController( | |||||
| setTextCell(r, 0, m["itemNo"], styles.text) | setTextCell(r, 0, m["itemNo"], styles.text) | ||||
| setTextCell(r, 1, m["itemName"], styles.text) | setTextCell(r, 1, m["itemName"], styles.text) | ||||
| setTextCell(r, 2, m["unitOfMeasure"], styles.center) | 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, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) | ||||
| setNumberCellFromFormatted(r, 11, m["totalStockBalance"], 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) | val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14) | ||||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | 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() | val out = ByteArrayOutputStream() | ||||
| workbook.use { it.write(out) } | workbook.use { it.write(out) } | ||||
| @@ -24,6 +24,12 @@ data class ReportItemFilterRequest( | |||||
| val supplier: String? = null, | val supplier: String? = null, | ||||
| val poCode: String? = null, | val poCode: String? = null, | ||||
| val grnCode: 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? = | fun combinedItemCode(): String? = | ||||
| ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) | ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) | ||||
| @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import java.time.format.DateTimeFormatter | 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.SemiFGProductionAnalysisReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportService | import com.ffii.fpsms.modules.report.service.ReportService | ||||
| import org.apache.poi.ss.usermodel.BorderStyle | import org.apache.poi.ss.usermodel.BorderStyle | ||||
| @@ -173,6 +174,243 @@ class SemiFGProductionAnalysisReportController( | |||||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | 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( | private fun createSemiFGProductionAnalysisExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportTitle: String, | reportTitle: String, | ||||
| @@ -228,7 +466,7 @@ class SemiFGProductionAnalysisReportController( | |||||
| borderBottom = BorderStyle.THIN | borderBottom = BorderStyle.THIN | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -517,4 +755,15 @@ class SemiFGProductionAnalysisReportController( | |||||
| val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) | val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) | ||||
| return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) | 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 | |||||
| } | |||||
| } | } | ||||
| @@ -237,7 +237,7 @@ class StockItemConsumptionTrendReportController( | |||||
| borderBottom = BorderStyle.THIN | borderBottom = BorderStyle.THIN | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -1,12 +1,13 @@ | |||||
| package com.ffii.fpsms.modules.report.web | package com.ffii.fpsms.modules.report.web | ||||
| import org.slf4j.LoggerFactory | |||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| import org.springframework.http.* | import org.springframework.http.* | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens | 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.StockLedgerReportService | ||||
| import com.ffii.fpsms.modules.report.service.ReportService | |||||
| import org.apache.poi.ss.usermodel.BorderStyle | import org.apache.poi.ss.usermodel.BorderStyle | ||||
| import org.apache.poi.ss.usermodel.CellStyle | import org.apache.poi.ss.usermodel.CellStyle | ||||
| import org.apache.poi.ss.usermodel.DataFormat | import org.apache.poi.ss.usermodel.DataFormat | ||||
| @@ -24,9 +25,9 @@ import java.io.ByteArrayOutputStream | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/report") | @RequestMapping("/report") | ||||
| class StockLedgerReportController( | class StockLedgerReportController( | ||||
| private val reportService: ReportService, | |||||
| private val stockLedgerReportService: StockLedgerReportService, | private val stockLedgerReportService: StockLedgerReportService, | ||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java) | |||||
| private data class ExcelStyles( | private data class ExcelStyles( | ||||
| val title: CellStyle, | val title: CellStyle, | ||||
| val subtitle: CellStyle, | val subtitle: CellStyle, | ||||
| @@ -98,27 +99,6 @@ class StockLedgerReportController( | |||||
| reportPeriodStart: String?, | reportPeriodStart: String?, | ||||
| reportPeriodEnd: String?, | reportPeriodEnd: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): 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( | val dbData = stockLedgerReportService.searchStockLedgerReport( | ||||
| stockCategory = stockCategory, | stockCategory = stockCategory, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| @@ -127,10 +107,12 @@ class StockLedgerReportController( | |||||
| reportPeriodEnd = reportPeriodEnd, | 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 { | val headers = HttpHeaders().apply { | ||||
| @@ -148,6 +130,7 @@ class StockLedgerReportController( | |||||
| reportPeriodStart: String?, | reportPeriodStart: String?, | ||||
| reportPeriodEnd: String?, | reportPeriodEnd: String?, | ||||
| ): ResponseEntity<ByteArray> { | ): ResponseEntity<ByteArray> { | ||||
| val tSearch = System.nanoTime() | |||||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | val dbData = stockLedgerReportService.searchStockLedgerReport( | ||||
| stockCategory = stockCategory, | stockCategory = stockCategory, | ||||
| itemCode = itemCode, | itemCode = itemCode, | ||||
| @@ -155,12 +138,20 @@ class StockLedgerReportController( | |||||
| reportPeriodStart = reportPeriodStart, | reportPeriodStart = reportPeriodStart, | ||||
| reportPeriodEnd = reportPeriodEnd, | reportPeriodEnd = reportPeriodEnd, | ||||
| ) | ) | ||||
| val searchMs = (System.nanoTime() - tSearch) / 1_000_000 | |||||
| val tExcel = System.nanoTime() | |||||
| val excelBytes = createStockLedgerExcel( | val excelBytes = createStockLedgerExcel( | ||||
| dbData = dbData, | dbData = dbData, | ||||
| reportPeriodStart = reportPeriodStart ?: "", | reportPeriodStart = reportPeriodStart ?: "", | ||||
| reportPeriodEnd = reportPeriodEnd ?: "", | 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 { | val headers = HttpHeaders().apply { | ||||
| contentType = MediaType.parseMediaType( | contentType = MediaType.parseMediaType( | ||||
| @@ -227,7 +218,7 @@ class StockLedgerReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| @@ -241,7 +232,7 @@ class StockLedgerReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -323,7 +314,7 @@ class StockLedgerReportController( | |||||
| return | return | ||||
| } | } | ||||
| if (parsed < 0) { | if (parsed < 0) { | ||||
| val s = "%,d".format(kotlin.math.abs(parsed).toLong()) | |||||
| val s = "%,.2f".format(kotlin.math.abs(parsed)) | |||||
| cell.setCellValue("($s)") | cell.setCellValue("($s)") | ||||
| cell.cellStyle = dashStyle | cell.cellStyle = dashStyle | ||||
| } else { | } else { | ||||
| @@ -427,7 +418,7 @@ class StockLedgerReportController( | |||||
| val itemName = m["itemName"]?.toString().orEmpty() | val itemName = m["itemName"]?.toString().orEmpty() | ||||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | val uom = m["unitOfMeasure"]?.toString().orEmpty() | ||||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||||
| if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { | |||||
| addItemSummaryRow( | addItemSummaryRow( | ||||
| currentItemNo!!, | currentItemNo!!, | ||||
| currentItemName, | currentItemName, | ||||
| @@ -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() | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -191,7 +191,7 @@ class StockLotOnhandReportController( | |||||
| return v | 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( | private fun createExcel( | ||||
| dbData: List<Map<String, Any>>, | dbData: List<Map<String, Any>>, | ||||
| reportDate: String, | reportDate: String, | ||||
| @@ -310,6 +310,9 @@ class StockLotOnhandReportController( | |||||
| sheet.createFreezePane(0, headerRowIndex + 1) | sheet.createFreezePane(0, headerRowIndex + 1) | ||||
| intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) | intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) | ||||
| .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | .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() | val out = ByteArrayOutputStream() | ||||
| workbook.write(out) | workbook.write(out) | ||||
| @@ -328,7 +328,7 @@ class StockTakeVarianceReportController( | |||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| borderRight = BorderStyle.THIN | borderRight = BorderStyle.THIN | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| } | } | ||||
| val dashStyle = workbook.createCellStyle().apply { | val dashStyle = workbook.createCellStyle().apply { | ||||
| @@ -344,7 +344,7 @@ class StockTakeVarianceReportController( | |||||
| alignment = HorizontalAlignment.RIGHT | alignment = HorizontalAlignment.RIGHT | ||||
| verticalAlignment = VerticalAlignment.CENTER | verticalAlignment = VerticalAlignment.CENTER | ||||
| val df: DataFormat = workbook.createDataFormat() | val df: DataFormat = workbook.createDataFormat() | ||||
| dataFormat = df.getFormat("#,##0") | |||||
| dataFormat = df.getFormat("#,##0.00") | |||||
| borderTop = BorderStyle.THICK | borderTop = BorderStyle.THICK | ||||
| borderBottom = BorderStyle.THICK | borderBottom = BorderStyle.THICK | ||||
| borderLeft = BorderStyle.THIN | borderLeft = BorderStyle.THIN | ||||
| @@ -490,7 +490,7 @@ class StockTakeVarianceReportController( | |||||
| val itemName = rowMap["itemName"]?.toString().orEmpty() | val itemName = rowMap["itemName"]?.toString().orEmpty() | ||||
| val uom = rowMap["unitOfMeasure"]?.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) | addItemSummaryRow(currentItemTotalQty, currentItemTotalStockTakeQty, currentItemNo!!, currentItemName) | ||||
| addBlankSeparatorRow() | addBlankSeparatorRow() | ||||
| currentItemTotalQty = 0.0 | currentItemTotalQty = 0.0 | ||||
| @@ -578,8 +578,7 @@ class StockTakeVarianceReportController( | |||||
| } | } | ||||
| private fun formatAbsInt(v: Double): String { | 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( | private data class Rep012ReportData( | ||||
| @@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web; | |||||
| import java.util.List; | import java.util.List; | ||||
| import org.springframework.http.HttpStatus; | 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.GetMapping; | ||||
| import org.springframework.web.bind.annotation.PatchMapping; | import org.springframework.web.bind.annotation.PatchMapping; | ||||
| import org.springframework.web.bind.annotation.PostMapping; | 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.BadRequestException; | ||||
| import com.ffii.core.exception.NotFoundException; | 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.entity.Settings; | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService; | import com.ffii.fpsms.modules.settings.service.SettingsService; | ||||
| @@ -53,6 +57,9 @@ public class SettingsController{ | |||||
| } | } | ||||
| private void applyUpdate(String name, UpdateReq body) { | 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) | Settings entity = this.settingsService.findByName(name) | ||||
| .orElseThrow(NotFoundException::new); | .orElseThrow(NotFoundException::new); | ||||
| if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | ||||
| @@ -63,6 +70,12 @@ public class SettingsController{ | |||||
| this.settingsService.save(entity); | 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 { | public static class UpdateReq { | ||||
| @NotBlank | @NotBlank | ||||
| private String value; | private String value; | ||||
| @@ -54,10 +54,19 @@ open class Inventory: BaseEntity<Long>(){ | |||||
| open var cpmUnit: String? = null | open var cpmUnit: String? = null | ||||
| // @NotNull | // @NotNull | ||||
| /** Base UOM snapshot only (set when the inventory row is created; not the bucket key). */ | |||||
| @ManyToOne | @ManyToOne | ||||
| @JoinColumn(name = "uomId") | @JoinColumn(name = "uomId") | ||||
| open var uom: UomConversion? = null | 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 | // @NotNull | ||||
| @Column(name = "status") | @Column(name = "status") | ||||
| open var status: String? = null | open var status: String? = null | ||||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.stock.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo | 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.InventoryLotLineInfo | ||||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow | |||||
| import org.springframework.data.domain.Page | import org.springframework.data.domain.Page | ||||
| import org.springframework.data.domain.Pageable | import org.springframework.data.domain.Pageable | ||||
| import org.springframework.data.jpa.repository.Modifying | import org.springframework.data.jpa.repository.Modifying | ||||
| @@ -56,9 +57,119 @@ WHERE ill.id = :id | |||||
| fun findInventoryLotLineInfoByInventoryLotItemIdIn(ids: List<Serializable>): List<InventoryLotLineInfo> | 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(""" | @Query(""" | ||||
| SELECT ill FROM InventoryLotLine ill | SELECT ill FROM InventoryLotLine ill | ||||
| @@ -326,4 +437,17 @@ WHERE ill.id = :id | |||||
| fun findAllByIdInAndDeletedFalseWithRefs( | fun findAllByIdInAndDeletedFalseWithRefs( | ||||
| @Param("ids") ids: Collection<Long> | @Param("ids") ids: Collection<Long> | ||||
| ): List<InventoryLotLine> | ): 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> | |||||
| } | } | ||||
| @@ -14,43 +14,49 @@ import java.util.Optional | |||||
| interface InventoryRepository: AbstractRepository<Inventory, Long> { | interface InventoryRepository: AbstractRepository<Inventory, Long> { | ||||
| fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> | 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( | @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( | fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | ||||
| code: String, | code: String, | ||||
| name: String, | name: String, | ||||
| type: String, | type: String, | ||||
| itemIds: List<Long>, | itemIds: List<Long>, | ||||
| pageable: Pageable | |||||
| pageable: Pageable, | |||||
| ): Page<InventoryInfo> | ): 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( | @Query( | ||||
| """ | """ | ||||
| SELECT i FROM Inventory i | 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 (: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 (:type IS NULL OR :type = '' OR i.item.type = :type) | ||||
| AND i.deleted = false | 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( | fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( | ||||
| @@ -73,7 +73,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||||
| pageable: Pageable, | pageable: Pageable, | ||||
| ): Page<InventoryInfo> | ): Page<InventoryInfo> | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */ | |||||
| @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse") | |||||
| @Query( | @Query( | ||||
| """ | """ | ||||
| SELECT i FROM Inventory i | 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 (:type IS NULL OR :type = '' OR i.item.type = :type) | ||||
| AND i.item.id IN :itemIds | AND i.item.id IN :itemIds | ||||
| AND i.deleted = false | 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( | fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | ||||
| @@ -102,7 +96,13 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||||
| fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | 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 findByItemId(itemId: Long): Optional<Inventory> | ||||
| fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory> | fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory> | ||||
| @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") | @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? | 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? | 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? | |||||
| } | |||||
| @@ -60,6 +60,7 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||||
| """) | """) | ||||
| fun findLatestByItemId(@Param("itemId") itemId: Long): List<StockLedger> | fun findLatestByItemId(@Param("itemId") itemId: Long): List<StockLedger> | ||||
| fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLedger? | fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLedger? | ||||
| fun findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(inventoryId: Long): StockLedger? | |||||
| @Query(""" | @Query(""" | ||||
| SELECT sl FROM StockLedger sl | SELECT sl FROM StockLedger sl | ||||
| @@ -81,6 +81,10 @@ open class StockTakeRecord : BaseEntity<Long>() { | |||||
| @Column(name = "lastSelect") | @Column(name = "lastSelect") | ||||
| open var lastSelect: Int? = null | 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") | @Column(name = "approverTime") | ||||
| open var approverTime: LocalDateTime? = null | open var approverTime: LocalDateTime? = null | ||||
| @Column(name = "date", nullable = false) | @Column(name = "date", nullable = false) | ||||
| @@ -14,41 +14,28 @@ interface InventoryInfo{ | |||||
| val itemName: String? | val itemName: String? | ||||
| @get:Value("#{target.item.type}") | @get:Value("#{target.item.type}") | ||||
| val itemType: String? | 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}") | @get:Value("#{target.onHandQty}") | ||||
| val onHandQty: BigDecimal? | 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}") | @get:Value("#{target.onHoldQty}") | ||||
| val onHoldQty: BigDecimal? | 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}") | @get:Value("#{target.unavailableQty}") | ||||
| val unavailableQty: BigDecimal? | 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? | 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? | val uomCode: String? | ||||
| @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfudesc}") | |||||
| @get:Value("#{target.stockUom?.udfudesc}") | |||||
| val uomUdfudesc: String? | val uomUdfudesc: String? | ||||
| @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfShortDesc}") | |||||
| @get:Value("#{target.stockUom?.udfShortDesc}") | |||||
| val uomShortDesc: String? | 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? | 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? | 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? | val price: BigDecimal? | ||||
| @get:Value("#{target.currency?.name}") | @get:Value("#{target.currency?.name}") | ||||
| val currencyName: String? | val currencyName: String? | ||||
| @@ -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 | |||||
| @@ -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? | |||||
| } | |||||
| @@ -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 | |||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| @@ -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))" | |||||
| } | |||||
| @@ -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。", | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -2,7 +2,6 @@ package com.ffii.fpsms.modules.stock.service | |||||
| import com.ffii.core.response.RecordsRes | import com.ffii.core.response.RecordsRes | ||||
| import com.ffii.core.utils.PdfUtils | 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.ItemUomRespository | ||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | import com.ffii.fpsms.modules.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | 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.core.io.ClassPathResource | ||||
| import org.springframework.data.domain.PageRequest | import org.springframework.data.domain.PageRequest | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import org.springframework.http.HttpStatus | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| import java.io.IOException | import java.io.IOException | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| @@ -35,7 +36,6 @@ import java.util.Optional | |||||
| import kotlin.jvm.optionals.getOrNull | import kotlin.jvm.optionals.getOrNull | ||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | import com.ffii.fpsms.modules.master.web.models.MessageResponse | ||||
| import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest | 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.QrCodeAnalysisRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | ||||
| import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo | 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.service.JobOrderService | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | ||||
| import com.ffii.fpsms.modules.master.service.PrinterService | 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.stock.web.model.PrintLabelForInventoryLotLineRequest | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest | import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest | ||||
| import com.ffii.core.utils.ZebraPrinterUtil | import com.ffii.core.utils.ZebraPrinterUtil | ||||
| @@ -58,8 +59,8 @@ open class InventoryLotLineService( | |||||
| private val warehouseRepository: WarehouseRepository, | private val warehouseRepository: WarehouseRepository, | ||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val stockInLineRepository: StockInLineRepository, | private val stockInLineRepository: StockInLineRepository, | ||||
| private val inventoryRepository: InventoryRepository, | |||||
| private val printerService: PrinterService, | private val printerService: PrinterService, | ||||
| private val settingsService: SettingsService, | |||||
| @Lazy | @Lazy | ||||
| private val jobOrderService: JobOrderService | private val jobOrderService: JobOrderService | ||||
| ) { | ) { | ||||
| @@ -76,10 +77,15 @@ open class InventoryLotLineService( | |||||
| return inventoryLotLineRepository.findInventoryLotLineInfoByInventoryLotItemIdIn(itemIds) | 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> { | open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes<InventoryLotLineInfo> { | ||||
| val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10); | 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) { | val response = if (request.stockIssueBadItem == true) { | ||||
| inventoryLotLineRepository.findStockIssueBadItemLotLinesByItemId( | inventoryLotLineRepository.findStockIssueBadItemLotLinesByItemId( | ||||
| request.itemId, | request.itemId, | ||||
| @@ -87,8 +93,15 @@ open class InventoryLotLineService( | |||||
| listOf(InventoryLotLineStatus.AVAILABLE, InventoryLotLineStatus.UNAVAILABLE), | listOf(InventoryLotLineStatus.AVAILABLE, InventoryLotLineStatus.UNAVAILABLE), | ||||
| pageable, | pageable, | ||||
| ) | ) | ||||
| } else if (warehouseIds != null) { | |||||
| inventoryLotLineRepository.findInventoryLotLineInfoByItemIdAndWarehouseIdIn( | |||||
| request.itemId, | |||||
| warehouseIds, | |||||
| request.uomId, | |||||
| pageable, | |||||
| ) | |||||
| } else { | } else { | ||||
| inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, pageable) | |||||
| inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, request.uomId, pageable) | |||||
| } | } | ||||
| val records = response.content | val records = response.content | ||||
| @@ -96,6 +109,28 @@ open class InventoryLotLineService( | |||||
| return RecordsRes<InventoryLotLineInfo>(records, total.toInt()); | 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( | open fun searchStockIssueBadItemLotLines( | ||||
| request: SearchStockIssueBadItemLotLineRequest, | request: SearchStockIssueBadItemLotLineRequest, | ||||
| ): RecordsRes<InventoryLotLineInfo> { | ): RecordsRes<InventoryLotLineInfo> { | ||||
| @@ -151,11 +186,29 @@ open class InventoryLotLineService( | |||||
| } | } | ||||
| } | } | ||||
| open fun saveInventoryLotLine(request: SaveInventoryLotLineRequest): InventoryLotLine { | 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 inventoryLot = request.inventoryLotId?.let { inventoryLotRepository.findById(it).getOrNull() } | ||||
| val warehouse = request.warehouseId?.let { warehouseRepository.findById(it).getOrNull() } | val warehouse = request.warehouseId?.let { warehouseRepository.findById(it).getOrNull() } | ||||
| val stockUom = request.stockUomId?.let { itemUomRespository.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 } } | val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } } | ||||
| inventoryLotLine.apply { | inventoryLotLine.apply { | ||||
| @@ -192,9 +245,6 @@ open class InventoryLotLineService( | |||||
| val updatedLotLine = saveInventoryLotLine(updateRequest) | val updatedLotLine = saveInventoryLotLine(updateRequest) | ||||
| // ADD THIS: Update inventory table after lot line status change | |||||
| updateInventoryTable(updatedLotLine) | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = updatedLotLine.id, | id = updatedLotLine.id, | ||||
| name = updatedLotLine.id.toString(), | 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) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun exportStockInLineQrcode(request: LotLineToQrcode): Map<String, Any> { | 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) | val resource = ClassPathResource(QRCODE_PDF) | ||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| println("here") | println("here") | ||||
| @@ -287,13 +299,18 @@ open class InventoryLotLineService( | |||||
| field["acceptedQty"] = "%.2f".format(info.acceptedQty) | field["acceptedQty"] = "%.2f".format(info.acceptedQty) | ||||
| val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) | 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["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | ||||
| field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | ||||
| field["lotNo"] = info.lotNo!! | field["lotNo"] = info.lotNo!! | ||||
| field["supplier"] = info.supplier ?: "N/A" | field["supplier"] = info.supplier ?: "N/A" | ||||
| val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) | |||||
| val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) | |||||
| field["qrCode"] = image | field["qrCode"] = image | ||||
| fields.add(field) | fields.add(field) | ||||
| } | } | ||||
| @@ -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.* | ||||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | 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.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 com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest | ||||
| import org.apache.poi.ss.usermodel.Sheet | import org.apache.poi.ss.usermodel.Sheet | ||||
| import org.apache.poi.ss.usermodel.Workbook | import org.apache.poi.ss.usermodel.Workbook | ||||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | import org.apache.poi.xssf.usermodel.XSSFWorkbook | ||||
| import org.springframework.core.io.support.PathMatchingResourcePatternResolver | 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.stereotype.Service | ||||
| import org.springframework.web.server.ResponseStatusException | |||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.sql.Timestamp | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import java.time.ZoneId | import java.time.ZoneId | ||||
| @@ -57,73 +62,355 @@ open class InventoryService( | |||||
| return inventoryRepository.findInventoryInfoByDeletedIsFalse(); | 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 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 { | } 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( | open fun searchInventoriesByLatestInventory( | ||||
| request: SearchInventoryRequest, | 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 code = request.code?.trim().orEmpty() | ||||
| val name = request.name?.trim().orEmpty() | val name = request.name?.trim().orEmpty() | ||||
| val type = request.type?.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>{ | 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 warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!! | ||||
| // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) | // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) | ||||
| val stockUnit = itemUomService.findStockUnitByItemId(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 { | InventoryLotLine().apply { | ||||
| this.inventoryLot = inventoryLot | this.inventoryLot = inventoryLot | ||||
| this.warehouse = warehouse | this.warehouse = warehouse | ||||
| this.inQty = request.qty | this.inQty = request.qty | ||||
| this.status = InventoryLotLineStatus.AVAILABLE | this.status = InventoryLotLineStatus.AVAILABLE | ||||
| this.stockUom = stockUnit | this.stockUom = stockUnit | ||||
| // this.stockUom = salesUnit | |||||
| } | } | ||||
| } | } | ||||
| val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries) | val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries) | ||||
| @@ -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(".") | |||||
| } | |||||
| } | |||||
| @@ -25,7 +25,7 @@ open class StockAdjustmentService( | |||||
| private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository | 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 | @Transactional | ||||
| open fun submit(request: StockAdjustmentRequest): MessageResponse { | open fun submit(request: StockAdjustmentRequest): MessageResponse { | ||||
| val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id } | val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id } | ||||
| @@ -55,7 +55,8 @@ open class StockAdjustmentService( | |||||
| productLotNo = current.productlotNo?.takeIf { it.isNotBlank() }, | productLotNo = current.productlotNo?.takeIf { it.isNotBlank() }, | ||||
| dnNo = current.dnNo?.takeIf { it.isNotBlank() }, | dnNo = current.dnNo?.takeIf { it.isNotBlank() }, | ||||
| type = stockType, | type = stockType, | ||||
| warehouseId = current.warehouseId | |||||
| warehouseId = current.warehouseId, | |||||
| uomId = current.uomId, | |||||
| ) | ) | ||||
| ) | ) | ||||
| saveAdjustmentRecordForStockIn(stockInLine, current.remarks) | saveAdjustmentRecordForStockIn(stockInLine, current.remarks) | ||||
| @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.service | |||||
| import com.ffii.core.exception.BadRequestException | import com.ffii.core.exception.BadRequestException | ||||
| import com.ffii.core.support.AbstractBaseEntityService | import com.ffii.core.support.AbstractBaseEntityService | ||||
| import com.ffii.core.support.JdbcDao | import com.ffii.core.support.JdbcDao | ||||
| import com.ffii.core.utils.QrCodeUtil | |||||
| import com.ffii.fpsms.modules.common.CodeGenerator | import com.ffii.fpsms.modules.common.CodeGenerator | ||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | import com.ffii.fpsms.modules.master.entity.ItemsRepository | ||||
| import com.ffii.fpsms.modules.master.entity.QcItemRepository | 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.core.utils.ZebraPrinterUtil | ||||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | ||||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | 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.ItemUomRespository | ||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | import com.ffii.fpsms.modules.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| //import com.ffii.fpsms.modules.master.service.BomOutputQtyService | //import com.ffii.fpsms.modules.master.service.BomOutputQtyService | ||||
| import com.ffii.fpsms.modules.master.service.PrinterService | 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.PurchaseOrder | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | ||||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository | import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository | ||||
| @@ -98,11 +99,13 @@ open class StockInLineService( | |||||
| private val itemUomRepository: ItemUomRespository, | private val itemUomRepository: ItemUomRespository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val printerService: PrinterService, | private val printerService: PrinterService, | ||||
| private val settingsService: SettingsService, | |||||
| private val stockTakeLineRepository: StockTakeLineRepository, | private val stockTakeLineRepository: StockTakeLineRepository, | ||||
| private val inventoryLotLineService: InventoryLotLineService, | private val inventoryLotLineService: InventoryLotLineService, | ||||
| private val deliveryOrderRepository: DeliveryOrderRepository, | private val deliveryOrderRepository: DeliveryOrderRepository, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val inventoryRepository: InventoryRepository, | private val inventoryRepository: InventoryRepository, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, | private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, | ||||
| private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | ||||
| /** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */ | /** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */ | ||||
| @@ -331,6 +334,33 @@ open class StockInLineService( | |||||
| latestCode = latestCode, | 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) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun create(request: SaveStockInLineRequest): MessageResponse { | open fun create(request: SaveStockInLineRequest): MessageResponse { | ||||
| @@ -445,10 +475,13 @@ open class StockInLineService( | |||||
| } | } | ||||
| dnNo = request.dnNo | dnNo = request.dnNo | ||||
| receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | ||||
| // The item label prints this field under 收貨日期. | |||||
| productionDate = request.productionDate?.atStartOfDay() ?: receiptDate | |||||
| productLotNo = request.productLotNo | productLotNo = request.productLotNo | ||||
| this.type = "NOR" | this.type = "NOR" | ||||
| status = StockInLineStatus.PENDING.status | status = StockInLineStatus.PENDING.status | ||||
| } | } | ||||
| applyRequestedStockQtyRound(stockInLine, request, isCreate = true) | |||||
| if (jo != null) { | if (jo != null) { | ||||
| val planStartDate = jo.planStart?.toLocalDate() | val planStartDate = jo.planStart?.toLocalDate() | ||||
| stockInLine.lotNo = assignLotNoForJo(planStartDate) | stockInLine.lotNo = assignLotNoForJo(planStartDate) | ||||
| @@ -535,8 +568,13 @@ open class StockInLineService( | |||||
| this.inventoryLot = stockInLine.inventoryLot | this.inventoryLot = stockInLine.inventoryLot | ||||
| this.warehouse = warehouse | this.warehouse = warehouse | ||||
| this.inQty = convertedBaseQty | this.inQty = convertedBaseQty | ||||
| this.outQty = BigDecimal.ZERO | |||||
| this.status = InventoryLotLineStatus.AVAILABLE | this.status = InventoryLotLineStatus.AVAILABLE | ||||
| this.stockUom = stockItemUom | this.stockUom = stockItemUom | ||||
| ?: throw ResponseStatusException( | |||||
| HttpStatus.BAD_REQUEST, | |||||
| "入倉明細缺少庫存單位,無法入帳", | |||||
| ) | |||||
| } | } | ||||
| saveLines.add(inventoryLotLine) | saveLines.add(inventoryLotLine) | ||||
| } | } | ||||
| @@ -649,13 +687,15 @@ open class StockInLineService( | |||||
| open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { | open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { | ||||
| val pols = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(po.id!!) | 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 | PurchaseOrderStatus.COMPLETED | ||||
| } | |||||
| else -> | |||||
| PurchaseOrderStatus.RECEIVING | |||||
| } | } | ||||
| po.apply { | po.apply { | ||||
| status = newStatus | 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) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun update(request: SaveStockInLineRequest): MessageResponse { | open fun update(request: SaveStockInLineRequest): MessageResponse { | ||||
| @@ -1030,6 +1071,7 @@ open class StockInLineService( | |||||
| this.expiryDate = stockInLine.expiryDate ?: request.expiryDate | this.expiryDate = stockInLine.expiryDate ?: request.expiryDate | ||||
| this.remarks = stockInLine.remarks ?: request.remarks | this.remarks = stockInLine.remarks ?: request.remarks | ||||
| } | } | ||||
| applyRequestedStockQtyRound(stockInLine, request, isCreate = false) | |||||
| _logStep("apply_fields", _tApplyFields) | _logStep("apply_fields", _tApplyFields) | ||||
| if (request.status == StockInLineStatus.RECEIVED.status) { | if (request.status == StockInLineStatus.RECEIVED.status) { | ||||
| val _tPutAwayTotal = System.nanoTime() | val _tPutAwayTotal = System.nanoTime() | ||||
| @@ -1070,7 +1112,7 @@ open class StockInLineService( | |||||
| if (putAwayDeltaStockQty > BigDecimal.ZERO) { | if (putAwayDeltaStockQty > BigDecimal.ZERO) { | ||||
| val _tLedger = System.nanoTime() | val _tLedger = System.nanoTime() | ||||
| createStockLedgerForStockIn(stockInLine, putAwayDeltaStockQty.toDouble()) | createStockLedgerForStockIn(stockInLine, putAwayDeltaStockQty.toDouble()) | ||||
| _logStep("createStockLedgerForStockIn(includes findLatestByItemId)", _tLedger) | |||||
| _logStep("createStockLedgerForStockIn(includes findLatestByInventoryId)", _tLedger) | |||||
| } | } | ||||
| val _tQtyCalc = System.nanoTime() | val _tQtyCalc = System.nanoTime() | ||||
| @@ -1208,7 +1250,8 @@ open class StockInLineService( | |||||
| @Throws(IOException::class) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map<String, Any> { | 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) | val resource = ClassPathResource(QRCODE_PDF) | ||||
| if (!resource.exists()) { | if (!resource.exists()) { | ||||
| println("here") | println("here") | ||||
| @@ -1242,12 +1285,17 @@ open class StockInLineService( | |||||
| field["poCode"] = info.poCode ?: "N/A" | field["poCode"] = info.poCode ?: "N/A" | ||||
| field["itemType"] = info.itemType ?: "N/A" | field["itemType"] = info.itemType ?: "N/A" | ||||
| field["acceptedQty"] = "%.2f".format(acceptedQty) | 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["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | ||||
| field["lotNo"] = info.lotNo ?: "N/A" | field["lotNo"] = info.lotNo ?: "N/A" | ||||
| field["supplier"] = info.supplier ?: "N/A" | field["supplier"] = info.supplier ?: "N/A" | ||||
| val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) | |||||
| val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) | |||||
| field["qrCode"] = image | field["qrCode"] = image | ||||
| fields.add(field) | fields.add(field) | ||||
| } | } | ||||
| @@ -1389,16 +1437,17 @@ open class StockInLineService( | |||||
| val item = stockInLine.item ?: return | val item = stockInLine.item ?: return | ||||
| val _tInv = System.nanoTime() | 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 _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 previousBalance = latestLedger?.balance ?: 0.0 | ||||
| val newBalance = previousBalance + inQty | val newBalance = previousBalance + inQty | ||||
| @@ -1491,12 +1540,7 @@ open class StockInLineService( | |||||
| savedStockInLine.inventoryLot = savedInventoryLot | savedStockInLine.inventoryLot = savedInventoryLot | ||||
| // Step 4: Create a row in inventory_lot_line table | // 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) { | val warehouse = if (request.warehouseId != null) { | ||||
| warehouseRepository.findById(request.warehouseId).orElseThrow() | warehouseRepository.findById(request.warehouseId).orElseThrow() | ||||
| @@ -1526,6 +1570,20 @@ open class StockInLineService( | |||||
| return savedStockInLine | 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 | @Transactional | ||||
| open fun createStockInForExistingInventoryLotLine( | open fun createStockInForExistingInventoryLotLine( | ||||
| request: StockInRequest, | request: StockInRequest, | ||||
| @@ -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,7 +43,6 @@ import com.ffii.fpsms.modules.pickOrder.service.PickOrderService | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.common.SecurityUtils | import com.ffii.fpsms.modules.common.SecurityUtils | ||||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | 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 com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| @@ -78,9 +77,9 @@ private val inventoryLotLineService: InventoryLotLineService, | |||||
| private val bagService: BagService, | private val bagService: BagService, | ||||
| private val pickOrderService: PickOrderService, | private val pickOrderService: PickOrderService, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val inventoryRepository: InventoryRepository, | |||||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | private val pickExecutionIssueRepository: PickExecutionIssueRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| @Lazy private val doReplenishmentService: DoReplenishmentService, | @Lazy private val doReplenishmentService: DoReplenishmentService, | ||||
| ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ): 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()) | this.holdQty = this.holdQty!!.minus((request.qty.div(ratio)).toBigDecimal()) | ||||
| } | } | ||||
| inventoryLotLineRepository.save(targetLotLineEntry) | 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) | return listOf(stockOutLine, newStockOutLine) | ||||
| } | } | ||||
| @Transactional | @Transactional | ||||
| @@ -816,10 +808,6 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| val savedLotLine = inventoryLotLineRepository.save(inventoryLotLine) | val savedLotLine = inventoryLotLineRepository.save(inventoryLotLine) | ||||
| println("Saved lot line: ${savedLotLine.id}, status: ${savedLotLine.status}, holdQty: ${savedLotLine.holdQty}") | 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 | // Step 4: Trigger resuggest | ||||
| val pickOrderLine = stockOutLine.pickOrderLine | val pickOrderLine = stockOutLine.pickOrderLine | ||||
| if (pickOrderLine?.pickOrder?.id != null) { | 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]) | @Transactional(rollbackFor = [Exception::class]) | ||||
| open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | ||||
| val startTime = System.currentTimeMillis() | val startTime = System.currentTimeMillis() | ||||
| @@ -954,14 +897,6 @@ open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| this.holdQty = (this.holdQty ?: zero) - baseQty | this.holdQty = (this.holdQty ?: zero) - baseQty | ||||
| } | } | ||||
| inventoryLotLineRepository.save(lotLine) | 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 | processedIds += line.stockOutLineId | ||||
| @@ -1359,7 +1294,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| } | } | ||||
| val item = savedSol.item | val item = savedSol.item | ||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | val inventoryBeforeUpdate = item?.id?.let { itemId -> | ||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| inventoryBucketResolver.findInventoryBucket(itemId, ill) | |||||
| } | } | ||||
| val onHandQtyBeforeUpdate = | val onHandQtyBeforeUpdate = | ||||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| @@ -1374,7 +1309,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | ||||
| val item = savedSol.item | val item = savedSol.item | ||||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | val inventoryBeforeUpdate = item?.id?.let { itemId -> | ||||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||||
| inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) | |||||
| } | } | ||||
| val onHandQtyBeforeUpdate = | val onHandQtyBeforeUpdate = | ||||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | (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 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ | ||||
| private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | ||||
| val item = stockOutLine.item ?: return | 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 | 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 newBalance = previousBalance - outQty | ||||
| val ill = stockOutLine.inventoryLotLine | val ill = stockOutLine.inventoryLotLine | ||||
| @@ -1647,9 +1580,20 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| open fun createStockOut(request: StockOutRequest): StockOutLine { | open fun createStockOut(request: StockOutRequest): StockOutLine { | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | ||||
| val qtyBd = BigDecimal.valueOf(request.qty) | 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 | 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 updatedInventoryLotLine = inventoryLotLine.apply { | ||||
| val currentOutQty = this.outQty ?: BigDecimal.ZERO | val currentOutQty = this.outQty ?: BigDecimal.ZERO | ||||
| this.outQty = currentOutQty + qtyBd | this.outQty = currentOutQty + qtyBd | ||||
| @@ -1782,16 +1726,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| return | return | ||||
| } | } | ||||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) | |||||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) | |||||
| if (inventory == null) { | if (inventory == null) { | ||||
| if (flushAfterSave) { | 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 | return | ||||
| } | } | ||||
| val previousBalance = resolvePreviousBalance( | val previousBalance = resolvePreviousBalance( | ||||
| itemId = item.id!!, | |||||
| inventory = inventory, | inventory = inventory, | ||||
| onHandQtyBeforeUpdate = onHandQtyBeforeUpdate | onHandQtyBeforeUpdate = onHandQtyBeforeUpdate | ||||
| ) | ) | ||||
| @@ -1839,14 +1782,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| } | } | ||||
| private fun resolvePreviousBalance( | private fun resolvePreviousBalance( | ||||
| itemId: Long, | |||||
| inventory: Inventory, | inventory: Inventory, | ||||
| onHandQtyBeforeUpdate: Double? = null | onHandQtyBeforeUpdate: Double? = null | ||||
| ): Double { | ): Double { | ||||
| if (onHandQtyBeforeUpdate != null) { | if (onHandQtyBeforeUpdate != null) { | ||||
| return onHandQtyBeforeUpdate | return onHandQtyBeforeUpdate | ||||
| } | } | ||||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||||
| val latestLedger = inventory.id?.let { | |||||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||||
| } | |||||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| } | } | ||||
| @@ -2155,13 +2099,19 @@ fun applyStockOutLineDelta( | |||||
| val postingType = (typeOverride ?: savedSol.type ?: "").trim().lowercase() | val postingType = (typeOverride ?: savedSol.type ?: "").trim().lowercase() | ||||
| val isIssuePosting = postingType == "miss" || postingType == "bad" || postingType == "expiry" | 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) { | if (!skipInventoryWrite) { | ||||
| val lotLine = savedSol.inventoryLotLine | val lotLine = savedSol.inventoryLotLine | ||||
| if (lotLine != null) { | if (lotLine != null) { | ||||
| if (isIssuePosting) { | if (isIssuePosting) { | ||||
| val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) | val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) | ||||
| if (latestLotLine != null) { | if (latestLotLine != null) { | ||||
| InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( | |||||
| latestLotLine.inQty, | |||||
| latestLotLine.outQty, | |||||
| deltaQty, | |||||
| postingType.replaceFirstChar { it.uppercase() } + " 數量", | |||||
| ) | |||||
| val prevStatus = latestLotLine.status | val prevStatus = latestLotLine.status | ||||
| val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO | val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO | ||||
| val currentOutQty = latestLotLine.outQty ?: 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) | // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) | ||||
| if (!skipLedgerWrite) { | if (!skipLedgerWrite) { | ||||
| val item = savedSol.item ?: return savedSol | 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 outQty = deltaQty.toDouble() | ||||
| val newBalance = previousBalance - outQty | val newBalance = previousBalance - outQty | ||||
| @@ -2349,22 +2283,23 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||||
| val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) | val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) | ||||
| // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) | // 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. | // 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 -> | savedLines.sortedBy { it.id ?: Long.MAX_VALUE }.forEach { sol -> | ||||
| val item = sol.item ?: return@forEach | val item = sol.item ?: return@forEach | ||||
| val itemId = item.id ?: 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 | ?: return@forEach | ||||
| val delta = BigDecimal.valueOf(sol.qty ?: 0.0) | 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 newBalance = prevBalance - delta.toDouble() | ||||
| val ill = sol.inventoryLotLine | val ill = sol.inventoryLotLine | ||||
| @@ -2392,7 +2327,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||||
| signedDelta = delta.negate(), | signedDelta = delta.negate(), | ||||
| ) | ) | ||||
| ledgersToInsert += ledger | ledgersToInsert += ledger | ||||
| runningLedgerBalanceByItemId[itemId] = newBalance | |||||
| runningLedgerBalanceByBucket[bucketKey] = newBalance | |||||
| } | } | ||||
| stockLedgerRepository.saveAll(ledgersToInsert) | stockLedgerRepository.saveAll(ledgersToInsert) | ||||
| @@ -62,6 +62,7 @@ open class StockOutLineWorkbenchService( | |||||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | ||||
| private val stockLedgerRepository: StockLedgerRepository, | private val stockLedgerRepository: StockLedgerRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val bagRepository: BagRepository, | private val bagRepository: BagRepository, | ||||
| private val bagService: BagService, | private val bagService: BagService, | ||||
| @@ -512,13 +513,13 @@ if (updated == 0) { | |||||
| if (deltaQty <= BigDecimal.ZERO) return | if (deltaQty <= BigDecimal.ZERO) return | ||||
| val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return | val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return | ||||
| val solItem = sol.item ?: 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. | // Fast path: use inventory onHand after update + delta to reconstruct previous balance. | ||||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| val previousBalance = onHandAfter + deltaQty.toDouble() | val previousBalance = onHandAfter + deltaQty.toDouble() | ||||
| val newBalance = previousBalance - 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 lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | ||||
| val ledger = StockLedger().apply { | val ledger = StockLedger().apply { | ||||
| this.stockOutLine = sol | this.stockOutLine = sol | ||||
| @@ -546,12 +547,13 @@ if (updated == 0) { | |||||
| } | } | ||||
| private fun resolveWorkbenchPreviousBalance( | private fun resolveWorkbenchPreviousBalance( | ||||
| itemId: Long, | |||||
| inventory: Inventory, | inventory: Inventory, | ||||
| onHandQtyBeforeUpdate: Double?, | onHandQtyBeforeUpdate: Double?, | ||||
| ): Double { | ): Double { | ||||
| if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate | 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() | return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | ||||
| } | } | ||||
| @@ -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" } | |||||
| } | |||||
| } | |||||
| @@ -65,7 +65,8 @@ open class StockTakeRecordService( | |||||
| val stockLedgerRepository: StockLedgerRepository, | val stockLedgerRepository: StockLedgerRepository, | ||||
| val inventoryRepository: InventoryRepository, | val inventoryRepository: InventoryRepository, | ||||
| val uomConversionRepository: UomConversionRepository, | val uomConversionRepository: UomConversionRepository, | ||||
| val itemsRepository: ItemsRepository | |||||
| val itemsRepository: ItemsRepository, | |||||
| val inventoryBucketResolver: InventoryBucketResolver, | |||||
| ) { | ) { | ||||
| private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) | 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 { | 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 second = response.secondStockTakeQty | ||||
| val first = response.firstStockTakeQty | val first = response.firstStockTakeQty | ||||
| return when { | 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 { | private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal { | ||||
| val selectedQty = resolvePickerQtyForVarianceFilter(response) | val selectedQty = resolvePickerQtyForVarianceFilter(response) | ||||
| val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO | val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO | ||||
| @@ -646,6 +677,7 @@ open class StockTakeRecordService( | |||||
| return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) | return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */ | |||||
| open fun getApproverInventoryLotDetailsAll( | open fun getApproverInventoryLotDetailsAll( | ||||
| stockTakeId: Long? = null, | stockTakeId: Long? = null, | ||||
| pageNum: Int = 0, | pageNum: Int = 0, | ||||
| @@ -805,6 +837,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -1065,6 +1098,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -1175,6 +1209,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseArea = warehouse?.area, | warehouseArea = warehouse?.area, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| @@ -1197,6 +1232,7 @@ open class StockTakeRecordService( | |||||
| stockTakerName = rec.stockTakerName, | stockTakerName = rec.stockTakerName, | ||||
| stockTakeEndTime = rec.stockTakeEndTime, | stockTakeEndTime = rec.stockTakeEndTime, | ||||
| approverTime = rec.approverTime, | approverTime = rec.approverTime, | ||||
| pickerReviseCount = rec.pickerReviseCount ?: 0, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -1270,6 +1306,7 @@ open class StockTakeRecordService( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseArea = warehouse?.area, | warehouseArea = warehouse?.area, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| @@ -1292,6 +1329,7 @@ open class StockTakeRecordService( | |||||
| stockTakerName = stockTakeRecord?.stockTakerName, | stockTakerName = stockTakeRecord?.stockTakerName, | ||||
| stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, | stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, | ||||
| approverTime = stockTakeRecord?.approverTime, | approverTime = stockTakeRecord?.approverTime, | ||||
| pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -1338,12 +1376,59 @@ open class StockTakeRecordService( | |||||
| .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) | .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) | ||||
| //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) | //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) | ||||
| if (request.reviseFirstQty && request.stockTakeRecordId == null) { | |||||
| throw IllegalArgumentException("Stock take record ID is required") | |||||
| } | |||||
| // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 | // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 | ||||
| val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { | val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { | ||||
| val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) | val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) | ||||
| ?: throw IllegalArgumentException("Stock take record not found: ${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 { | 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 -> { | existingRecord.pickerFirstStockTakeQty == null -> { | ||||
| val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty | val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty | ||||
| val varianceQty = availableQty - request.qty - request.badQty | val varianceQty = availableQty - request.qty - request.badQty | ||||
| @@ -1363,7 +1448,8 @@ open class StockTakeRecordService( | |||||
| } | } | ||||
| existingRecord | existingRecord | ||||
| } | } | ||||
| existingRecord.pickerSecondStockTakeQty == null -> { | |||||
| existingRecord.pickerSecondStockTakeQty == null && | |||||
| existingRecord.status.equals("notMatch", ignoreCase = true) -> { | |||||
| val varianceQty = availableQty - request.qty - request.badQty | val varianceQty = availableQty - request.qty - request.badQty | ||||
| existingRecord.apply { | existingRecord.apply { | ||||
| this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) | this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) | ||||
| @@ -2125,7 +2211,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||||
| "batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}", | "batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}", | ||||
| adjustmentCache.inventoryLotLineByWarehouseLot.size, | adjustmentCache.inventoryLotLineByWarehouseLot.size, | ||||
| adjustmentCache.inventoryLotById.size, | adjustmentCache.inventoryLotById.size, | ||||
| adjustmentCache.inventoryByItemId.size, | |||||
| adjustmentCache.inventoryByItemAndStockUom.size, | |||||
| adjustmentCache.stockTakeLineByRecordId.size, | adjustmentCache.stockTakeLineByRecordId.size, | ||||
| elapsedMs(cacheBuildStartNs) | elapsedMs(cacheBuildStartNs) | ||||
| ) | ) | ||||
| @@ -2213,7 +2299,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||||
| val runtimeCache = BatchAdjustmentRuntimeCache( | val runtimeCache = BatchAdjustmentRuntimeCache( | ||||
| stockOutByStockTakeId = mutableMapOf(), | stockOutByStockTakeId = mutableMapOf(), | ||||
| stockInByStockTakeId = mutableMapOf(), | stockInByStockTakeId = mutableMapOf(), | ||||
| runningLedgerBalanceByItemId = mutableMapOf() | |||||
| runningLedgerBalanceByBucket = mutableMapOf() | |||||
| ) | ) | ||||
| val adjustmentContext = StockTakeAdjustmentBatchContext() | val adjustmentContext = StockTakeAdjustmentBatchContext() | ||||
| var varianceCount = 0 | var varianceCount = 0 | ||||
| @@ -2278,7 +2364,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||||
| "batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}", | "batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}", | ||||
| runtimeCache.stockOutByStockTakeId.size, | runtimeCache.stockOutByStockTakeId.size, | ||||
| runtimeCache.stockInByStockTakeId.size, | runtimeCache.stockInByStockTakeId.size, | ||||
| runtimeCache.runningLedgerBalanceByItemId.size, | |||||
| runtimeCache.runningLedgerBalanceByBucket.size, | |||||
| adjustmentContext.stockTakeLineByRecordId.size, | adjustmentContext.stockTakeLineByRecordId.size, | ||||
| adjustmentContext.stockOutLines.size, | adjustmentContext.stockOutLines.size, | ||||
| adjustmentContext.stockInLines.size, | adjustmentContext.stockInLines.size, | ||||
| @@ -2398,9 +2484,7 @@ private fun applyVarianceAdjustment( | |||||
| ?: throw IllegalArgumentException("Inventory lot not found") | ?: throw IllegalArgumentException("Inventory lot not found") | ||||
| val itemId = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID 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,或舊資料無預建時新建 | // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 | ||||
| val stockTakeLine = stockTakeRecord.id?.let { rid -> | val stockTakeLine = stockTakeRecord.id?.let { rid -> | ||||
| @@ -2481,11 +2565,9 @@ private fun applyVarianceAdjustment( | |||||
| // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 | // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 | ||||
| val itemIdForLedger = inventoryLot.item?.id | val itemIdForLedger = inventoryLot.item?.id | ||||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger") | ?: 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() | val newBalance = previousBalance - qtyToRemove.toDouble() | ||||
| // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 | // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 | ||||
| @@ -2519,7 +2601,7 @@ private fun applyVarianceAdjustment( | |||||
| } else { | } else { | ||||
| stockLedgerRepository.save(stockLedger) | stockLedgerRepository.save(stockLedger) | ||||
| } | } | ||||
| runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) | |||||
| runtimeCache?.runningLedgerBalanceByBucket?.put(ledgerKey, newBalance) | |||||
| val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) | val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) | ||||
| latestLine.outQty = newOutQty | latestLine.outQty = newOutQty | ||||
| @@ -2619,11 +2701,9 @@ private fun applyVarianceAdjustment( | |||||
| val itemIdForLedger = inventoryLot.item?.id | val itemIdForLedger = inventoryLot.item?.id | ||||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") | ?: 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() | val newBalance = previousBalance + plusQty.toDouble() | ||||
| // latestLine.inQty already includes plusQty above — snapshot live lot line after move. | // latestLine.inQty already includes plusQty above — snapshot live lot line after move. | ||||
| @@ -2656,21 +2736,55 @@ private fun applyVarianceAdjustment( | |||||
| } else { | } else { | ||||
| stockLedgerRepository.save(stockLedger) | 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( | private data class BatchAdjustmentCache( | ||||
| val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>, | val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>, | ||||
| val inventoryLotById: Map<Long, InventoryLot>, | 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> | val stockTakeLineByRecordId: Map<Long, StockTakeLine> | ||||
| ) | ) | ||||
| private data class BatchAdjustmentRuntimeCache( | private data class BatchAdjustmentRuntimeCache( | ||||
| val stockOutByStockTakeId: MutableMap<Long, StockOut>, | val stockOutByStockTakeId: MutableMap<Long, StockOut>, | ||||
| val stockInByStockTakeId: MutableMap<Long, StockIn>, | val stockInByStockTakeId: MutableMap<Long, StockIn>, | ||||
| val runningLedgerBalanceByItemId: MutableMap<Long, Double> | |||||
| val runningLedgerBalanceByBucket: MutableMap<Pair<Long, Long>, Double> | |||||
| ) | ) | ||||
| private data class StockTakeAdjustmentBatchContext( | private data class StockTakeAdjustmentBatchContext( | ||||
| @@ -2725,10 +2839,15 @@ private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdju | |||||
| emptyMap() | emptyMap() | ||||
| } | } | ||||
| val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct() | val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct() | ||||
| val inventoryByItemId = | |||||
| val inventoryByItemAndStockUom = | |||||
| if (itemIds.isNotEmpty()) { | if (itemIds.isNotEmpty()) { | ||||
| inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds) | 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 }!! } | .mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! } | ||||
| } else { | } else { | ||||
| emptyMap() | emptyMap() | ||||
| @@ -2745,7 +2864,7 @@ private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdju | |||||
| return BatchAdjustmentCache( | return BatchAdjustmentCache( | ||||
| inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot, | inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot, | ||||
| inventoryLotById = inventoryLotById, | inventoryLotById = inventoryLotById, | ||||
| inventoryByItemId = inventoryByItemId, | |||||
| inventoryByItemAndStockUom = inventoryByItemAndStockUom, | |||||
| stockTakeLineByRecordId = stockTakeLineByRecordId | stockTakeLineByRecordId = stockTakeLineByRecordId | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -2854,6 +2973,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -2954,6 +3074,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||||
| holdQty = ill.holdQty, | holdQty = ill.holdQty, | ||||
| availableQty = availableQty, | availableQty = availableQty, | ||||
| uom = ill.stockUom?.uom?.udfudesc, | uom = ill.stockUom?.uom?.udfudesc, | ||||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||||
| warehouseCode = warehouse?.code, | warehouseCode = warehouse?.code, | ||||
| warehouseName = warehouse?.name, | warehouseName = warehouse?.name, | ||||
| status = ill.status?.name, | status = ill.status?.name, | ||||
| @@ -28,8 +28,7 @@ import kotlin.jvm.optionals.getOrNull | |||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | import com.ffii.fpsms.modules.master.web.models.MessageResponse | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | 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.StockOutLineStatus | ||||
| import com.ffii.fpsms.modules.stock.web.model.PickAnotherLotRequest | import com.ffii.fpsms.modules.stock.web.model.PickAnotherLotRequest | ||||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | ||||
| @@ -54,7 +53,6 @@ open class SuggestedPickLotService( | |||||
| val itemUomService: ItemUomService, | val itemUomService: ItemUomService, | ||||
| val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号 | val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号 | ||||
| val pickOrderRepository: PickOrderRepository, | val pickOrderRepository: PickOrderRepository, | ||||
| val inventoryRepository: InventoryRepository, | |||||
| val failInventoryLotLineRepository: FailInventoryLotLineRepository, | val failInventoryLotLineRepository: FailInventoryLotLineRepository, | ||||
| val stockOutRepository: StockOutRepository, | val stockOutRepository: StockOutRepository, | ||||
| val itemRepository: ItemsRepository, | 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 ===") | println("=== RESUGGEST DEBUG END ===") | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -1342,40 +1308,6 @@ private fun generateOptimalSuggestionsForAllPickOrders( | |||||
| return suggestions | 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> { | private fun findAllSuggestionsForPickOrder(pickOrderId: Long): List<SuggestedPickLot> { | ||||
| val pickOrderLines = pickOrderRepository.findById(pickOrderId) | val pickOrderLines = pickOrderRepository.findById(pickOrderId) | ||||
| .orElseThrow() | .orElseThrow() | ||||
| @@ -1799,27 +1731,9 @@ private fun handleMissingItem(inventoryLotLine: InventoryLotLine, failQty: BigDe | |||||
| // 处理损坏物品的情况 | // 处理损坏物品的情况 | ||||
| private fun handleBrokenItem(inventoryLotLine: InventoryLotLine, failQty: BigDecimal) { | private fun handleBrokenItem(inventoryLotLine: InventoryLotLine, failQty: BigDecimal) { | ||||
| try { | try { | ||||
| // 1. 将库存批次行标记为不可用 | |||||
| inventoryLotLine.status = InventoryLotLineStatus.UNAVAILABLE | 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) | inventoryLotLineRepository.save(inventoryLotLine) | ||||
| println("Broken item handled: Marked lot line ${inventoryLotLine.id} as unavailable") | println("Broken item handled: Marked lot line ${inventoryLotLine.id} as unavailable") | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| println("Error handling broken item: ${e.message}") | println("Error handling broken item: ${e.message}") | ||||
| throw e | throw e | ||||
| @@ -24,6 +24,21 @@ data class WorkbenchSuggestionSummary( | |||||
| val skippedExisting: Int, | 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 | @Service | ||||
| open class SuggestedPickLotWorkbenchService( | open class SuggestedPickLotWorkbenchService( | ||||
| private val pickOrderLineRepository: PickOrderLineRepository, | private val pickOrderLineRepository: PickOrderLineRepository, | ||||
| @@ -138,9 +153,32 @@ open class SuggestedPickLotWorkbenchService( | |||||
| } | } | ||||
| private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set<String>): Boolean { | 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> { | 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: | * Build suggestions with no-hold rule: | ||||
| * available = inQty - outQty (ignore holdQty). | * 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). | * 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.status == InventoryLotLineStatus.AVAILABLE } | ||||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | ||||
| .filter { !isWarehouseExcluded(it, excludeSet) } | .filter { !isWarehouseExcluded(it, excludeSet) } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| .groupBy { it.inventoryLot?.item?.id } | .groupBy { it.inventoryLot?.item?.id } | ||||
| var created = 0 | 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]. | * 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. | * 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 | if (storeKey == null) true | ||||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | else normalizeStoreId(lot.warehouse?.store_id) == storeKey | ||||
| } | } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) | val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) | ||||
| val created = syncSuggestedPickLotsForLine(lineFresh, desired) | 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): | * 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. | * 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). | * 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 | if (storeKey == null) true | ||||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | else normalizeStoreId(lot.warehouse?.store_id) == storeKey | ||||
| } | } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| .firstOrNull() | .firstOrNull() | ||||
| val desired = listOf(nextLot to targetQty) | 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: | * Workbench explicit-qty split support: | ||||
| * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | ||||
| * - If [targetQty] <= 0: soft-delete all active suggestions for the line. | * - 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 { it.inventoryLot?.expiryDate?.isBefore(today) != true } | ||||
| .filter { !isWarehouseExcluded(it, excludeSet) } | .filter { !isWarehouseExcluded(it, excludeSet) } | ||||
| .filter { matchesPolUom(it, line) } | .filter { matchesPolUom(it, line) } | ||||
| .sortedBy { it.inventoryLot?.expiryDate } | |||||
| .sortedWith(this::compareSuggestedLots) | |||||
| val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) | val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) | ||||
| val created = syncSuggestedPickLotsForLine(line, desired) | val created = syncSuggestedPickLotsForLine(line, desired) | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.stock.web | |||||
| import com.ffii.core.response.RecordsRes | import com.ffii.core.response.RecordsRes | ||||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo | 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.service.InventoryService | ||||
| import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest | import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest | ||||
| import org.springframework.web.bind.annotation.* | import org.springframework.web.bind.annotation.* | ||||
| @@ -16,19 +17,20 @@ class InventoryController( | |||||
| return inventoryService.allInventories() | return inventoryService.allInventories() | ||||
| } | } | ||||
| /** Full inventory search: one row per (itemId, stockUomId) bucket, plus current stockUnit when missing. */ | |||||
| @GetMapping("/getRecordByPage") | @GetMapping("/getRecordByPage") | ||||
| fun allInventoriesByPage(@ModelAttribute request: SearchInventoryRequest): RecordsRes<InventoryInfo> { | 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") | @GetMapping("/searchLatest/getRecordByPage") | ||||
| fun searchInventoriesByLatestInventory( | fun searchInventoriesByLatestInventory( | ||||
| @ModelAttribute request: SearchInventoryRequest, | @ModelAttribute request: SearchInventoryRequest, | ||||
| ): RecordsRes<InventoryInfo> { | |||||
| ): RecordsRes<InventoryStockUomSearchRow> { | |||||
| return inventoryService.searchInventoriesByLatestInventory(request) | return inventoryService.searchInventoriesByLatestInventory(request) | ||||
| } | } | ||||