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; | |||
| import java.sql.SQLException; | |||
| import java.util.UUID; | |||
| import org.apache.commons.logging.Log; | |||
| import org.apache.commons.logging.LogFactory; | |||
| import org.springframework.dao.DataAccessException; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.security.access.AccessDeniedException; | |||
| @@ -33,6 +35,39 @@ public class ErrorHandler extends ResponseEntityExceptionHandler { | |||
| return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); | |||
| } | |||
| /** MySQL SIGNAL SQLSTATE 45000 is a rejected business write, not an unknown crash. */ | |||
| @ExceptionHandler(DataAccessException.class) | |||
| public ResponseEntity<?> sqlSignal(final DataAccessException ex) { | |||
| String signal = sqlSignalMessage(ex); | |||
| if (signal != null) { | |||
| logger.warn("SQLSTATE 45000: " + signal); | |||
| return new ResponseEntity<>(new FailureRes(signal), HttpStatus.BAD_REQUEST); | |||
| } | |||
| return error500(ex); | |||
| } | |||
| private String sqlSignalMessage(Throwable ex) { | |||
| String shortest = null; | |||
| Throwable current = ex; | |||
| while (current != null) { | |||
| if (current instanceof SQLException sql && "45000".equals(sql.getSQLState())) { | |||
| String message = sql.getMessage(); | |||
| if (message != null && !message.isBlank()) { | |||
| String trimmed = message.trim(); | |||
| if (shortest == null || trimmed.length() < shortest.length()) { | |||
| shortest = trimmed; | |||
| } | |||
| } | |||
| } | |||
| Throwable next = current.getCause(); | |||
| if (next == current) { | |||
| break; | |||
| } | |||
| current = next; | |||
| } | |||
| return shortest; | |||
| } | |||
| @ExceptionHandler({ InternalServerErrorException.class, Exception.class }) | |||
| @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) | |||
| public ResponseEntity<ErrorRes> error500(final Exception ex) { | |||
| @@ -3,8 +3,11 @@ package com.ffii.core.utils | |||
| import org.apache.pdfbox.pdmodel.PDDocument | |||
| import org.apache.pdfbox.rendering.PDFRenderer | |||
| import java.awt.image.BufferedImage | |||
| import java.awt.image.DataBufferByte | |||
| import java.awt.image.MultiPixelPackedSampleModel | |||
| import java.io.ByteArrayOutputStream | |||
| import java.io.File | |||
| import java.net.InetSocketAddress | |||
| import java.net.Socket | |||
| import org.slf4j.LoggerFactory | |||
| @@ -12,6 +15,7 @@ class BrotherPrinterUtil { | |||
| companion object { | |||
| private const val SOCKET_TIMEOUT_MS = 120_000 // 2 min for large jobs; Brother may be slow to accept | |||
| private const val CONNECT_TIMEOUT_MS = 5_000 | |||
| private const val BROTHER_DPI = 200 // Lower DPI = smaller job; some Brother models buffer better at 200 | |||
| private val log = LoggerFactory.getLogger(BrotherPrinterUtil::class.java) | |||
| @@ -25,11 +29,14 @@ class BrotherPrinterUtil { | |||
| log.info("Brother print: file=${pdfFile.name} size=${pdfFile.length()} ip=$printerIp port=$printerPort copies=$printQty") | |||
| PrinterLockManager.withLock(printerIp, printerPort) { | |||
| PDDocument.load(pdfFile).use { document -> | |||
| val renderer = PDFRenderer(document) | |||
| val totalPages = document.numberOfPages | |||
| Socket(printerIp, printerPort).use { socket -> | |||
| Socket().use { socket -> | |||
| socket.tcpNoDelay = true | |||
| socket.connect(InetSocketAddress(printerIp, printerPort), CONNECT_TIMEOUT_MS) | |||
| socket.soTimeout = SOCKET_TIMEOUT_MS | |||
| val os = socket.getOutputStream() | |||
| @@ -52,6 +59,7 @@ class BrotherPrinterUtil { | |||
| log.info("Brother print job sent successfully") | |||
| } | |||
| } | |||
| } | |||
| } | |||
| private fun getPjlHeader(qty: Int): ByteArray { | |||
| @@ -67,6 +75,8 @@ class BrotherPrinterUtil { | |||
| * Uses given DPI (e.g. 200) to keep job size smaller for printers with limited buffer. | |||
| */ | |||
| private fun convertImageToPcl(image: BufferedImage, dpi: Int): ByteArray { | |||
| convertPackedBinaryToPcl(image, dpi)?.let { return it } | |||
| val out = ByteArrayOutputStream() | |||
| val width = image.width | |||
| val height = image.height | |||
| @@ -75,21 +85,21 @@ class BrotherPrinterUtil { | |||
| out.write("\u001BE".toByteArray(Charsets.US_ASCII)) // Esc E = reset | |||
| // PCL: Set resolution and start raster | |||
| out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) | |||
| for (y in 0 until height) { | |||
| val rowBytes = (width + 7) / 8 | |||
| val data = ByteArray(rowBytes) | |||
| for (x in 0 until width) { | |||
| // Check if pixel is dark (threshold) | |||
| val color = image.getRGB(x, y) | |||
| val isBlack = (color and 0xFF) < 128 | |||
| val isBlack = (color and 0xFF) < 128 | |||
| if (isBlack) { | |||
| data[x / 8] = (data[x / 8].toInt() or (0x80 ushr (x % 8))).toByte() | |||
| } | |||
| } | |||
| // PCL: Transfer Raster Data (Esc * b # W [data]) | |||
| out.write("\u001B*b${rowBytes}W".toByteArray()) | |||
| out.write(data) | |||
| @@ -99,5 +109,64 @@ class BrotherPrinterUtil { | |||
| out.write("\u001B*rB".toByteArray()) | |||
| return out.toByteArray() | |||
| } | |||
| /** | |||
| * TYPE_BYTE_BINARY from PDFBox: sample 0 is black, sample 1 is white, MSB first. | |||
| * PCL raster uses the opposite polarity (bit 1 = black). getRGB() per pixel is too slow | |||
| * for a multi-page A4 note; pack the row from the data buffer instead. | |||
| * Returns null when the raster layout does not match, so the caller keeps the slow path. | |||
| */ | |||
| private fun convertPackedBinaryToPcl(image: BufferedImage, dpi: Int): ByteArray? { | |||
| if (image.type != BufferedImage.TYPE_BYTE_BINARY) return null | |||
| val raster = image.raster | |||
| val sampleModel = raster.sampleModel as? MultiPixelPackedSampleModel ?: return null | |||
| if (sampleModel.pixelBitStride != 1 || sampleModel.dataBitOffset != 0) return null | |||
| val dataBuffer = raster.dataBuffer as? DataBufferByte ?: return null | |||
| if (dataBuffer.numBanks != 1) return null | |||
| val bank = dataBuffer.data | |||
| val bankOffset = dataBuffer.offsets[0] | |||
| val width = image.width | |||
| val height = image.height | |||
| if (width <= 0 || height <= 0) return null | |||
| val stride = sampleModel.scanlineStride | |||
| val rowBytes = (width + 7) / 8 | |||
| if (stride < rowBytes) return null | |||
| if (bankOffset < 0 || bankOffset + (height - 1) * stride + rowBytes > bank.size) return null | |||
| val check = minOf(8, width) | |||
| for (x in 0 until check) { | |||
| val srcBit = (bank[bankOffset].toInt() ushr (7 - x)) and 1 | |||
| val rgbBlack = (image.getRGB(x, 0) and 0xFF) < 128 | |||
| if ((srcBit == 0) != rgbBlack) return null | |||
| } | |||
| val out = ByteArrayOutputStream(height * (rowBytes + 12) + 32) | |||
| out.write("\u001BE".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*t${dpi}R".toByteArray(Charsets.US_ASCII)) | |||
| out.write("\u001B*r1A".toByteArray(Charsets.US_ASCII)) | |||
| val fullBytes = width / 8 | |||
| val remainder = width % 8 | |||
| val remainderMask = if (remainder == 0) 0 else (0xFF shl (8 - remainder)) and 0xFF | |||
| val row = ByteArray(rowBytes) | |||
| for (y in 0 until height) { | |||
| val rowStart = bankOffset + y * stride | |||
| var i = 0 | |||
| while (i < fullBytes) { | |||
| row[i] = (bank[rowStart + i].toInt().inv() and 0xFF).toByte() | |||
| i++ | |||
| } | |||
| if (remainder != 0) { | |||
| val src = bank[rowStart + fullBytes].toInt() and 0xFF | |||
| row[fullBytes] = (src.inv() and remainderMask).toByte() | |||
| } | |||
| out.write("\u001B*b${rowBytes}W".toByteArray(Charsets.US_ASCII)) | |||
| out.write(row) | |||
| } | |||
| out.write("\u001B*rB".toByteArray(Charsets.US_ASCII)) | |||
| return out.toByteArray() | |||
| } | |||
| } | |||
| } | |||
| @@ -4,6 +4,7 @@ import org.apache.pdfbox.pdmodel.PDDocument | |||
| import org.slf4j.LoggerFactory | |||
| import java.io.File | |||
| import java.io.OutputStream | |||
| import java.net.InetSocketAddress | |||
| import java.net.Socket | |||
| /** | |||
| @@ -111,7 +112,11 @@ open class CanonPrinterUtil { | |||
| val copies = printQty ?: 1 | |||
| repeat(copies) { copyIndex -> | |||
| val tSend = System.nanoTime() | |||
| Socket(printerIp, printerPort).use { socket -> | |||
| PrinterLockManager.withLock(printerIp, printerPort) { | |||
| Socket().use { socket -> | |||
| socket.tcpNoDelay = true | |||
| socket.connect(InetSocketAddress(printerIp, printerPort), 5_000) | |||
| socket.soTimeout = 120_000 | |||
| val os: OutputStream = socket.getOutputStream() | |||
| // Send PJL commands | |||
| @@ -126,6 +131,7 @@ open class CanonPrinterUtil { | |||
| os.flush() | |||
| } | |||
| } | |||
| val sendMs = (System.nanoTime() - tSend) / 1_000_000.0 | |||
| log.info( | |||
| "Canon printPdfToCanon: copy {} of {} sendMs={} printer={}:{} pdfBytes={} (high sendMs: check LAN/WiFi, firewall, or printer 9100 queue)", | |||
| @@ -1,18 +1,21 @@ | |||
| package com.ffii.core.utils | |||
| import com.google.zxing.BarcodeFormat | |||
| import com.google.zxing.EncodeHintType | |||
| import java.awt.image.BufferedImage | |||
| import com.google.zxing.client.j2se.MatrixToImageWriter; | |||
| import com.google.zxing.common.BitMatrix; | |||
| import com.google.zxing.qrcode.QRCodeWriter; | |||
| import com.google.zxing.client.j2se.MatrixToImageWriter | |||
| import com.google.zxing.qrcode.QRCodeWriter | |||
| open class QrCodeUtil { | |||
| companion object { | |||
| fun generateQRCodeImage(barcodeText: String?): BufferedImage { | |||
| val barcodeWriter = QRCodeWriter() | |||
| val bitMatrix: BitMatrix = barcodeWriter.encode(barcodeText, BarcodeFormat.QR_CODE, 250, 250) | |||
| val img: BufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix) | |||
| return img | |||
| return generateQRCodeImage(barcodeText, 250, 4) | |||
| } | |||
| fun generateQRCodeImage(barcodeText: String?, size: Int, margin: Int): BufferedImage { | |||
| val hints = hashMapOf<EncodeHintType, Any>(EncodeHintType.MARGIN to margin) | |||
| val bitMatrix = QRCodeWriter().encode(barcodeText, BarcodeFormat.QR_CODE, size, size, hints) | |||
| return MatrixToImageWriter.toBufferedImage(bitMatrix) | |||
| } | |||
| } | |||
| } | |||
| @@ -79,6 +79,7 @@ public class SecurityConfig { | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 51 | v1.0.1 | 2026-08-06 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 | |||
| * (stockAdjustment/submit and GET /latestRemarks → INVENTORY_ADJUST) | |||
| * Route board truck APIs → ADMIN / TRUCK_LANE / TESTING | |||
| */ | |||
| @Bean | |||
| @Order(1) | |||
| @@ -140,6 +141,18 @@ public class SecurityConfig { | |||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | |||
| .requestMatchers(HttpMethod.GET, "/m18/test/product-by-code") | |||
| .hasAnyAuthority("ADMIN", "M18_SYNC") | |||
| /* 車線看板 (/settings/shop/board):ADMIN / TRUCK_LANE / TESTING */ | |||
| .requestMatchers("/truck/**") | |||
| .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") | |||
| .requestMatchers("/truckLaneVersion/**") | |||
| .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") | |||
| .requestMatchers("/truckLaneSchedule/**") | |||
| .hasAnyAuthority("ADMIN", "TRUCK_LANE", "TESTING") | |||
| /* 員工發貨績效 (/chart/delivery): PERFORMANCE (績效) */ | |||
| .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance") | |||
| .hasAuthority("PERFORMANCE") | |||
| .requestMatchers(HttpMethod.GET, "/chart/staff-delivery-performance-handlers") | |||
| .hasAuthority("PERFORMANCE") | |||
| .anyRequest().authenticated()) | |||
| .httpBasic(httpBasic -> httpBasic.authenticationEntryPoint( | |||
| (request, response, authException) -> sendUnauthorizedJson(response, "Unauthorized", "UNAUTHORIZED"))) | |||
| @@ -1,10 +1,20 @@ | |||
| package com.ffii.fpsms.m18.model | |||
| import java.time.LocalDateTime | |||
| data class SyncResult( | |||
| val totalProcessed: Int, | |||
| val totalSuccess: Int, | |||
| val totalFail: Int, | |||
| val query: String? = null | |||
| ) | |||
| ) | |||
| /** | |||
| * Bounded suffix for [scheduler_sync_log.query]. Listing every skipped document | |||
| * overflows VARCHAR(2000) on a multi-day PO window. | |||
| */ | |||
| fun skippedNotPendingQuerySuffix(entries: List<String>, previewLimit: Int = 20): String { | |||
| if (entries.isEmpty()) return "" | |||
| val shown = entries.take(previewLimit) | |||
| val hidden = entries.size - shown.size | |||
| val more = if (hidden > 0) "; ... +$hidden more" else "" | |||
| return " | skipped (not pending, only pending may be overwritten) count=${entries.size}: ${shown.joinToString("; ")}$more" | |||
| } | |||
| @@ -89,14 +89,8 @@ open class M18DeliveryOrderService( | |||
| "" | |||
| } | |||
| // Shop PO | |||
| val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") | |||
| val shopPoSupplier = commonUtils.listToString( | |||
| shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), | |||
| "venId=equal=", | |||
| "=or=" | |||
| ) | |||
| var shopPoConds = "(${shopPoBuyers})=and=(${shopPoSupplier})" | |||
| // Shop PO — same BE/supplier scope as [buildShopPoScopeConds] | |||
| var shopPoConds = buildShopPoScopeConds() | |||
| if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { | |||
| shopPoConds += "=and=(${lastDateConds})" | |||
| @@ -160,9 +154,24 @@ open class M18DeliveryOrderService( | |||
| ) | |||
| } | |||
| /** | |||
| * Shop PO (MTMS DO) BE/supplier scope used by scheduler and by-code sync: | |||
| * beId = TOA and venId in [M18Config.SHOP_PO_SUPPLIER] (e.g. P06/P07). | |||
| */ | |||
| private fun buildShopPoScopeConds(): String { | |||
| val shopPoBuyers = commonUtils.listToString(listOf(m18Config.BEID_TOA), "beId=equal=", "=or=") | |||
| val shopPoSupplier = commonUtils.listToString( | |||
| shopService.findM18VendorIdsByCodeRegexp(m18Config.SHOP_PO_SUPPLIER), | |||
| "venId=equal=", | |||
| "=or=" | |||
| ) | |||
| return "(${shopPoBuyers})=and=(${shopPoSupplier})" | |||
| } | |||
| /** | |||
| * Sync a single M18 shop PO / delivery order by document [code], same search pattern as | |||
| * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. | |||
| * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode], including | |||
| * the scheduler shop-PO BE/supplier filters (rejects material POs). | |||
| * | |||
| * @param isExtraSync when true, persist local `delivery_order.isExtra=true` (manual DO(加單) sync). | |||
| * No M18-side "加單" filtering is used. | |||
| @@ -173,15 +182,16 @@ open class M18DeliveryOrderService( | |||
| isExtraSync: Boolean = false, | |||
| newOnly: Boolean = false, | |||
| ): SyncResult { | |||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { | |||
| val trimmed = code.trim() | |||
| if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(trimmed)) { | |||
| return SyncResult( | |||
| totalProcessed = 1, | |||
| totalSuccess = 0, | |||
| totalFail = 0, | |||
| query = "skipped (newOnly=true): delivery_order.code already exists: $code", | |||
| query = "skipped (newOnly=true): delivery_order.code already exists: $trimmed", | |||
| ) | |||
| } | |||
| val conds = "(code=equal=$code)" | |||
| val conds = "(code=equal=$trimmed)=and=${buildShopPoScopeConds()}" | |||
| val searchRequest = M18PurchaseOrderListRequest( | |||
| stSearch = "po", | |||
| params = null, | |||
| @@ -228,6 +238,7 @@ open class M18DeliveryOrderService( | |||
| val successList = mutableListOf<Long>() | |||
| val skippedList = mutableListOf<Long>() | |||
| val skippedNotPendingList = mutableListOf<String>() | |||
| val successDetailList = mutableListOf<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| val failDetailList = mutableListOf<Long>() | |||
| @@ -291,6 +302,23 @@ open class M18DeliveryOrderService( | |||
| // logger.info("${doRefType}: Finding For Latest M18 Data Log...") | |||
| val latestDeliveryOrderLog = | |||
| m18DataLogService.findLatestM18DataLogWithSuccess(deliveryOrder.id, doRefType) | |||
| val existingDeliveryOrderForSync = | |||
| latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } | |||
| if (existingDeliveryOrderForSync != null && | |||
| existingDeliveryOrderForSync.deleted != true && | |||
| existingDeliveryOrderForSync.status != DeliveryOrderStatus.PENDING | |||
| ) { | |||
| val statusValue = existingDeliveryOrderForSync.status?.value ?: "unknown" | |||
| logger.info( | |||
| "${doRefType}: Skipping M18 sync — local DO id=${existingDeliveryOrderForSync.id} " + | |||
| "code=${existingDeliveryOrderForSync.code} status=$statusValue " + | |||
| "(only pending may be overwritten; receiving=已放單). M18 ID: ${deliveryOrder.id}" | |||
| ) | |||
| skippedNotPendingList.add( | |||
| "code=${existingDeliveryOrderForSync.code} status=$statusValue" | |||
| ) | |||
| return@forEach | |||
| } | |||
| //logger.info(latestDeliveryOrderLog.toString()) | |||
| // Save to m18_data_log table | |||
| // logger.info("${doRefType}: Saving for M18 Data Log...") | |||
| @@ -312,10 +340,9 @@ open class M18DeliveryOrderService( | |||
| // logger.info("${doRefType}: Saved M18 Data Log. ID: ${saveM18DeliveryOrderLog.id}") | |||
| try { | |||
| // Find the delivery_order if exist | |||
| // Find the delivery_order if exist (re-use lookup from pending guard above) | |||
| // logger.info("${doRefType}: Finding exising delivery order...") | |||
| val existingDeliveryOrder = | |||
| latestDeliveryOrderLog?.id?.let { deliveryOrderService.findByM18DataLogId(it) } | |||
| val existingDeliveryOrder = existingDeliveryOrderForSync | |||
| // logger.info("${doRefType}: Exising delivery order ID: ${existingDeliveryOrder?.id}") | |||
| // Save to delivery_order table | |||
| @@ -621,12 +648,13 @@ open class M18DeliveryOrderService( | |||
| logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | |||
| val skippedSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" | |||
| val skippedExistingSuffix = if (skippedList.isNotEmpty()) " | skipped=${skippedList.size}" else "" | |||
| val skippedNotPendingSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) | |||
| return SyncResult( | |||
| totalProcessed = successList.size + failList.size + skippedList.size, | |||
| totalProcessed = successList.size + failList.size + skippedList.size + skippedNotPendingList.size, | |||
| totalSuccess = successList.size, | |||
| totalFail = failList.size, | |||
| query = (deliveryOrdersWithType?.query ?: "") + skippedSuffix, | |||
| query = (deliveryOrdersWithType?.query ?: "") + skippedExistingSuffix + skippedNotPendingSuffix, | |||
| ) | |||
| } | |||
| } | |||
| @@ -97,15 +97,8 @@ open class M18PurchaseOrderService( | |||
| "" | |||
| } | |||
| // Material PO | |||
| val materialPoBuyers = | |||
| commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") | |||
| val materialPoSupplierNot = commonUtils.listToString( | |||
| shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), | |||
| "venId=unequal=", | |||
| "=or=" | |||
| ) | |||
| var materialPoConds = "(${materialPoBuyers})=and=(${materialPoSupplierNot}))" | |||
| // Material PO — same BE/supplier scope as [buildMaterialPoScopeConds] | |||
| var materialPoConds = buildMaterialPoScopeConds() | |||
| if (request.modifiedDateFrom != null && request.modifiedDateTo != null) { | |||
| materialPoConds += "=and=(${lastDateConds})" | |||
| @@ -233,11 +226,28 @@ open class M18PurchaseOrderService( | |||
| return if (beId?.toString() == m18Config.BEID_TOA) PurchaseOrderType.SHOP else PurchaseOrderType.MATERIAL | |||
| } | |||
| /** | |||
| * Material PO BE/supplier scope used by scheduler and by-code sync: | |||
| * beId in (PP, PF) and venId not in [M18Config.MATERIAL_PO_SUPPLIER_NOT] (e.g. P06/P07 shop suppliers). | |||
| */ | |||
| private fun buildMaterialPoScopeConds(): String { | |||
| val materialPoBuyers = | |||
| commonUtils.listToString(listOf(m18Config.BEID_PP, m18Config.BEID_PF), "beId=equal=", "=or=") | |||
| val materialPoSupplierNot = commonUtils.listToString( | |||
| shopService.findM18VendorIdsByCodeRegexp(m18Config.MATERIAL_PO_SUPPLIER_NOT), | |||
| "venId=unequal=", | |||
| "=or=" | |||
| ) | |||
| return "(${materialPoBuyers})=and=(${materialPoSupplierNot})" | |||
| } | |||
| open fun savePurchaseOrderByCode(code: String): SyncResult { | |||
| // Follow the scheduler's original approach: | |||
| // 1) use /search/search to find the PO id by code | |||
| // 2) then sync by PO id (this reuses the same create/update logic) | |||
| val conds = "(code=equal=$code)" | |||
| // Same approach as scheduler list sync, restricted to one document code: | |||
| // 1) /search/search with material-PO BE/supplier scope + code | |||
| // 2) sync by PO id (reuses create/update logic) | |||
| // Rejects shop POs (TOA / P06|P07) that belong to DO sync instead. | |||
| val trimmed = code.trim() | |||
| val conds = "(code=equal=$trimmed)=and=${buildMaterialPoScopeConds()}" | |||
| return saveFromPoListSearch(conds, searchPurchaseOrdersByConds(conds)) | |||
| } | |||
| @@ -357,6 +367,7 @@ open class M18PurchaseOrderService( | |||
| val successDetailList = mutableListOf<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| val failDetailList = mutableListOf<Long>() | |||
| val skippedNotPendingList = mutableListOf<String>() | |||
| val affectedItemIds = mutableSetOf<Long>() | |||
| val uomByM18IdCache = mutableMapOf<Long, com.ffii.fpsms.modules.master.entity.ItemUom?>() | |||
| @@ -398,11 +409,15 @@ open class M18PurchaseOrderService( | |||
| if (existingPurchaseOrderForSync != null && | |||
| existingPurchaseOrderForSync.status != PurchaseOrderStatus.PENDING | |||
| ) { | |||
| val statusValue = existingPurchaseOrderForSync.status?.value ?: "unknown" | |||
| logger.info( | |||
| "${poRefType}: Skipping M18 sync — local PO id=${existingPurchaseOrderForSync.id} " + | |||
| "code=${existingPurchaseOrderForSync.code} status=${existingPurchaseOrderForSync.status?.value} " + | |||
| "code=${existingPurchaseOrderForSync.code} status=$statusValue " + | |||
| "(only pending may be overwritten). M18 ID: $m18PurchaseOrderId" | |||
| ) | |||
| skippedNotPendingList.add( | |||
| "code=${existingPurchaseOrderForSync.code} status=$statusValue" | |||
| ) | |||
| return@forEach | |||
| } | |||
| @@ -729,11 +744,14 @@ open class M18PurchaseOrderService( | |||
| logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") | |||
| val baseQuery = purchaseOrdersWithType?.query ?: "" | |||
| val skippedSuffix = skippedNotPendingQuerySuffix(skippedNotPendingList) | |||
| return SyncResult( | |||
| totalProcessed = successList.size + failList.size, | |||
| totalProcessed = successList.size + failList.size + skippedNotPendingList.size, | |||
| totalSuccess = successList.size, | |||
| totalFail = failList.size, | |||
| query = purchaseOrdersWithType?.query ?: "" | |||
| query = baseQuery + skippedSuffix | |||
| ) | |||
| } | |||
| } | |||
| @@ -69,6 +69,9 @@ public abstract class SettingNames { | |||
| /** Mark expired inventory lot lines as unavailable (default 00:05 daily) */ | |||
| public static final String SCHEDULE_INVENTORY_LOT_EXPIRY = "SCHEDULE.inventoryLot.expiry"; | |||
| /** Daily stock_lot_day close for yesterday (default 00:15; moves + carry) */ | |||
| public static final String SCHEDULE_STOCK_LOT_DAY_CLOSE = "SCHEDULE.stockLotDay.close"; | |||
| public static final String SCHEDULE_PROD_ROUGH = "SCHEDULE.prod.rough"; | |||
| public static final String SCHEDULE_PROD_DETAILED = "SCHEDULE.prod.detailed"; | |||
| @@ -77,6 +80,12 @@ public abstract class SettingNames { | |||
| * Job order plan-start overdue batch (default 00:00:15 daily): hide or reschedule JOs whose plan day was yesterday. | |||
| */ | |||
| public static final String SCHEDULE_JO_PLAN_START = "SCHEDULE.jo.planStart"; | |||
| /** | |||
| * Stock-take count vs on-hand gap (percent) that shows a non-blocking warning. | |||
| * Either direction. Default 50. | |||
| */ | |||
| public static final String STOCK_TAKE_QTY_GAP_WARN_PERCENT = "STOCK_TAKE.qtyGapWarnPercent"; | |||
| /* | |||
| * Mail settings | |||
| */ | |||
| @@ -129,4 +138,7 @@ public abstract class SettingNames { | |||
| /** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */ | |||
| public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess"; | |||
| /** PO / stock-in item label: 1 = original, 2 = enlarged, 3 = smaller QR, 4 = version 3 with the version 1 QR size. */ | |||
| public static final String LABEL_PO_ITEM_VERSION = "LABEL.poItem.version"; | |||
| } | |||
| @@ -13,8 +13,9 @@ import com.ffii.fpsms.modules.common.alert.SchedulerSyncAlertService | |||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderPlanStartAutoService | |||
| import com.ffii.fpsms.modules.master.service.BomM18ShopBulkPushService | |||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | |||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | |||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||
| import com.ffii.fpsms.modules.stock.service.SearchCompletedDnService | |||
| import com.ffii.fpsms.modules.stock.service.StockLotDayCloseService | |||
| import com.ffii.fpsms.modules.settings.entity.Settings | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import jakarta.annotation.PostConstruct | |||
| @@ -43,6 +44,8 @@ open class SchedulerService( | |||
| @Value("\${scheduler.m18Units.enabled:true}") val m18UnitsSchedulerEnabled: Boolean, | |||
| @Value("\${scheduler.m18Units.incrementalLookbackDays:7}") val m18UnitsIncrementalLookbackDays: Int, | |||
| @Value("\${scheduler.inventoryLotExpiry.enabled:true}") val inventoryLotExpiryEnabled: Boolean, | |||
| @Value("\${scheduler.stockLotDayClose.enabled:true}") val stockLotDayCloseEnabled: Boolean, | |||
| @Value("\${scheduler.stockLotDayClose.catchUpDays:7}") val stockLotDayCloseCatchUpDays: Int, | |||
| /** When false (default), M18 PO / DO1 / DO2 / master-data cron jobs are not registered — use true in production only. */ | |||
| @Value("\${scheduler.m18Sync.enabled:false}") val m18SyncEnabled: Boolean, | |||
| @Value("\${scheduler.jo.planStart.enabled:true}") val jobOrderPlanStartAutoEnabled: Boolean, | |||
| @@ -71,6 +74,7 @@ open class SchedulerService( | |||
| val jobOrderPlanStartAutoService: JobOrderPlanStartAutoService, | |||
| private val bomM18ShopBulkPushService: BomM18ShopBulkPushService, | |||
| private val schedulerSyncAlertService: SchedulerSyncAlertService, | |||
| private val stockLotDayCloseService: StockLotDayCloseService, | |||
| @Value("\${scheduler.sync-alert.check-cron:0 */15 * * * *}") private val syncAlertCheckCron: String, | |||
| @Value("\${scheduler.sync-alert.enabled:false}") private val syncAlertEnabled: Boolean, | |||
| ) { | |||
| @@ -87,6 +91,10 @@ open class SchedulerService( | |||
| const val M18_BOM_SHOP_DEFAULT_CRON: String = "0 0 23 * * *" | |||
| /** Daily 00:00:15 — process job orders whose planStart was yesterday. */ | |||
| const val JO_PLAN_START_DEFAULT_CRON: String = "15 0 0 * * *" | |||
| /** Daily 00:15 — stock_lot_day close for yesterday (moves + carry). */ | |||
| const val STOCK_LOT_DAY_CLOSE_DEFAULT_CRON: String = "0 15 0 * * *" | |||
| /** MySQL TEXT is 65535 bytes; 16000 chars stays under that for utf8mb4. */ | |||
| const val SYNC_LOG_TEXT_MAX_CHARS: Int = 16_000 | |||
| } | |||
| /** Class logger (was incorrectly wired to JwtTokenUtil, so all scheduler lines showed under that category). */ | |||
| @@ -116,6 +124,8 @@ open class SchedulerService( | |||
| var scheduledGrnCodeSync: ScheduledFuture<*>? = null | |||
| var scheduledInventoryLotExpiry: ScheduledFuture<*>? = null | |||
| var scheduledStockLotDayClose: ScheduledFuture<*>? = null | |||
| var scheduledJobOrderPlanStart: ScheduledFuture<*>? = null | |||
| var scheduledDo1CatchUp: ScheduledFuture<*>? = null | |||
| @@ -213,6 +223,7 @@ open class SchedulerService( | |||
| schedulePostCompletedDnGrn(); | |||
| scheduleGrnCodeSync(); | |||
| scheduleInventoryLotExpiry(); | |||
| scheduleStockLotDayClose(); | |||
| scheduleJobOrderPlanStartAuto(); | |||
| scheduleDo1CatchUpOnce(); | |||
| scheduleSyncAlertWatchdog(); | |||
| @@ -586,6 +597,78 @@ open class SchedulerService( | |||
| } | |||
| } | |||
| /** | |||
| * Daily stock_lot_day close at 00:15 (yesterday + catch-up). | |||
| * Set scheduler.stockLotDayClose.enabled=false to disable. | |||
| */ | |||
| fun scheduleStockLotDayClose() { | |||
| if (!stockLotDayCloseEnabled) { | |||
| scheduledStockLotDayClose?.cancel(false) | |||
| scheduledStockLotDayClose = null | |||
| logger.info("stock_lot_day close scheduler disabled (scheduler.stockLotDayClose.enabled=false)") | |||
| return | |||
| } | |||
| scheduledStockLotDayClose = commonSchedule( | |||
| scheduledStockLotDayClose, | |||
| SettingNames.SCHEDULE_STOCK_LOT_DAY_CLOSE, | |||
| STOCK_LOT_DAY_CLOSE_DEFAULT_CRON, | |||
| { runStockLotDayClose() }, | |||
| ) | |||
| logger.info( | |||
| "Scheduled stock_lot_day close (default cron={}, catchUpDays={})", | |||
| STOCK_LOT_DAY_CLOSE_DEFAULT_CRON, | |||
| stockLotDayCloseCatchUpDays, | |||
| ) | |||
| } | |||
| /** | |||
| * Close through [date] (default yesterday). Catch-up from last closed day, capped by catchUpDays. | |||
| * Rejects date >= today. | |||
| */ | |||
| open fun runStockLotDayClose(date: LocalDate? = null): String { | |||
| val currentTime = LocalDateTime.now() | |||
| return try { | |||
| val result = stockLotDayCloseService.closeThrough( | |||
| target = date, | |||
| catchUpDays = stockLotDayCloseCatchUpDays, | |||
| ) | |||
| if (result.skipped) { | |||
| val msg = "stock_lot_day close skipped: ${result.skipReason}" | |||
| logger.warn("Scheduler - {}", msg) | |||
| saveSyncLog( | |||
| type = "STOCK_LOT_DAY", | |||
| status = "SKIPPED", | |||
| result = SyncResult(0, 0, 0, msg), | |||
| start = currentTime, | |||
| ) | |||
| return msg | |||
| } | |||
| val query = "from=${result.dateFrom} to=${result.dateTo} days=${result.days.size} catchUpDays=$stockLotDayCloseCatchUpDays" | |||
| saveSyncLog( | |||
| type = "STOCK_LOT_DAY", | |||
| status = "SUCCESS", | |||
| result = SyncResult( | |||
| totalProcessed = result.days.size, | |||
| totalSuccess = result.totalRows, | |||
| totalFail = 0, | |||
| query = query, | |||
| ), | |||
| start = currentTime, | |||
| ) | |||
| logger.info("Scheduler - stock_lot_day close done {}", query) | |||
| "stock_lot_day close ok $query rows=${result.totalRows}" | |||
| } catch (e: Exception) { | |||
| logger.error("Scheduler - stock_lot_day close failed: ${e.message}", e) | |||
| saveSyncLog( | |||
| type = "STOCK_LOT_DAY", | |||
| status = "FAILED", | |||
| error = e.message, | |||
| start = currentTime, | |||
| ) | |||
| throw e | |||
| } | |||
| } | |||
| // Function for schedule | |||
| // --------------------------- FP-MTMS --------------------------- // | |||
| @@ -730,14 +813,21 @@ open class SchedulerService( | |||
| recordsProcessed = result?.totalProcessed ?: 0, | |||
| recordsSaved = result?.totalSuccess ?: 0, | |||
| recordsFailed = result?.totalFail ?: 0, | |||
| errorMessage = error, | |||
| query = result?.query ?: "", | |||
| errorMessage = fitSyncLogText(error), | |||
| query = fitSyncLogText(result?.query ?: ""), | |||
| startTime = start, | |||
| endTime = LocalDateTime.now() | |||
| ) | |||
| schedulerSyncLogRepository.save(log) | |||
| } | |||
| private fun fitSyncLogText(value: String?): String? { | |||
| if (value == null || value.length <= SYNC_LOG_TEXT_MAX_CHARS) return value | |||
| val marker = "...(truncated, length=${value.length})" | |||
| logger.warn("scheduler_sync_log text truncated from {} to {} characters", value.length, SYNC_LOG_TEXT_MAX_CHARS) | |||
| return value.take((SYNC_LOG_TEXT_MAX_CHARS - marker.length).coerceAtLeast(0)) + marker | |||
| } | |||
| open fun getM18Dos2() { | |||
| logger.info("DO Scheduler 2 - DO") | |||
| val currentTime = LocalDateTime.now() | |||
| @@ -96,6 +96,17 @@ class SchedulerController( | |||
| return "Inventory lot expiry status update triggered" | |||
| } | |||
| /** | |||
| * Manual stock_lot_day close (moves + carry). Omit [date] → through yesterday (with catch-up). | |||
| * [date] must be before today. Example: GET /scheduler/trigger/stock-lot-day-close?date=2026-08-20 | |||
| */ | |||
| @GetMapping("/trigger/stock-lot-day-close") | |||
| fun triggerStockLotDayClose( | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) date: LocalDate? = null, | |||
| ): String { | |||
| return schedulerService.runStockLotDayClose(date) | |||
| } | |||
| @GetMapping("/trigger/post-completed-dn-grn") | |||
| fun triggerPostCompletedDnGrn( | |||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) receiptDate: LocalDate? = null, | |||
| @@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value | |||
| import java.math.BigDecimal | |||
| interface DeliveryOrderLineInfo { | |||
| val id: Long | |||
| @get:Value("#{target.item?.name}") | |||
| val itemName: String? | |||
| val itemNo: String | |||
| @@ -43,6 +43,7 @@ import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||
| import java.time.LocalTime | |||
| import com.ffii.fpsms.modules.pickOrder.entity.Truck | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.stock.entity.StockOutRepository | |||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | |||
| import com.ffii.fpsms.modules.stock.entity.StockOut | |||
| @@ -91,7 +92,6 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.models.DeliveryOrderLineInfo | |||
| import com.ffii.fpsms.modules.master.entity.Items | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | |||
| import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | |||
| import java.util.Locale | |||
| import org.slf4j.Logger | |||
| @@ -110,6 +110,7 @@ open class DeliveryOrderService( | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val suggestedPickLotService: SuggestedPickLotService, | |||
| private val inventoryLotLineRepository: InventoryLotLineRepository, | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val stockOutRepository: StockOutRepository, | |||
| private val stockOutLineRepository: StockOutLIneRepository, | |||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||
| @@ -159,6 +160,7 @@ open class DeliveryOrderService( | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */ | |||
| open fun searchDoLiteByPage( | |||
| code: String?, | |||
| shopName: String?, | |||
| @@ -203,10 +205,14 @@ open class DeliveryOrderService( | |||
| val preFilteredContent = allResult.content | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val ticketLaneByDoId = loadTicketTruckLanesByDoId( | |||
| deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, | |||
| ) | |||
| // ✅ 优化3: 收集所有需要查询的 shopId 和日期组合(只处理预过滤后的记录) | |||
| // 未放單才依供應商樓層查 truck;已放單用票據車線 | |||
| val shopIdAndDatePairs = preFilteredContent.mapNotNull { info -> | |||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||
| if (isReleasedDeliveryOrder(deliveryOrder)) return@mapNotNull null | |||
| val shopId = deliveryOrder?.shop?.id | |||
| val estimatedArrivalDate = info.estimatedArrivalDate | |||
| if (shopId != null && estimatedArrivalDate != null) { | |||
| @@ -234,23 +240,16 @@ open class DeliveryOrderService( | |||
| // 处理预过滤后的记录:计算 truckLanceCode 并过滤 | |||
| val processedRecords = preFilteredContent.map { info -> | |||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shop = deliveryOrder?.shop | |||
| val shopId = shop?.id | |||
| val estimatedArrivalDate = info.estimatedArrivalDate | |||
| val calculatedTruckLanceCode = | |||
| if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { | |||
| val targetDate = estimatedArrivalDate.toLocalDate() | |||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||
| // 从缓存中获取 Truck | |||
| val matchedTruck = truckCache[Triple(shopId, preferredFloor, dayAbbr)] | |||
| matchedTruck?.truckLanceCode | |||
| } else { | |||
| null | |||
| } | |||
| val calculatedTruckLanceCode = displayedTruckLanceCode( | |||
| deliveryOrder = deliveryOrder, | |||
| estimatedArrivalDate = estimatedArrivalDate, | |||
| floorSuppliers2F = floorSuppliers2F, | |||
| floorSuppliers4F = floorSuppliers4F, | |||
| truckCache = truckCache, | |||
| ticketLaneByDoId = ticketLaneByDoId, | |||
| ) | |||
| DeliveryOrderInfoLiteDto( | |||
| id = info.id, | |||
| @@ -299,22 +298,22 @@ open class DeliveryOrderService( | |||
| ) | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val loadedOrders = result.content.associate { info -> | |||
| info.id to deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) | |||
| } | |||
| val ticketLaneByDoId = loadTicketTruckLanesByDoId( | |||
| loadedOrders.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it?.id }, | |||
| ) | |||
| val records = result.content.map { info -> | |||
| val deliveryOrder = deliveryOrderRepository.findByIdAndDeletedIsFalse(info.id) | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shop = deliveryOrder?.shop | |||
| val shopId = shop?.id | |||
| val estimatedArrivalDate = info.estimatedArrivalDate | |||
| val calculatedTruckLanceCode = | |||
| if (deliveryOrder != null && shopId != null && estimatedArrivalDate != null) { | |||
| val targetDate = estimatedArrivalDate.toLocalDate() | |||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||
| resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode | |||
| } else { | |||
| null | |||
| } | |||
| val deliveryOrder = loadedOrders[info.id] | |||
| val calculatedTruckLanceCode = displayedTruckLanceCode( | |||
| deliveryOrder = deliveryOrder, | |||
| estimatedArrivalDate = info.estimatedArrivalDate, | |||
| floorSuppliers2F = floorSuppliers2F, | |||
| floorSuppliers4F = floorSuppliers4F, | |||
| truckCache = null, | |||
| ticketLaneByDoId = ticketLaneByDoId, | |||
| ) | |||
| DeliveryOrderInfoLiteDto( | |||
| id = info.id, | |||
| @@ -391,8 +390,9 @@ open class DeliveryOrderService( | |||
| } | |||
| /** | |||
| * 僅回傳依店鋪/ETA 從 truck 排程**推算後** `truckLanceCode` 為 null 或空白的送貨單(畫面上對應「車線-X」)。 | |||
| * 與 [searchDoLiteByPage] 帶一般車線關鍵字分開,避免 `車線-X` 在 truck 表無 shopId 時走舊邏輯漏單。 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 | |||
| * 僅回傳顯示車線為 null 或空白的送貨單(畫面上對應「車線-X」)。 | |||
| * 已放單(receiving/completed)用票據車線;未放單才依店鋪/ETA 從 truck 排程推算。 | |||
| */ | |||
| open fun searchDoLiteUnassignedTruckByPage( | |||
| code: String?, | |||
| @@ -425,23 +425,20 @@ open class DeliveryOrderService( | |||
| val deliveryOrderIds = allResult.content.mapNotNull { it.id } | |||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(deliveryOrderIds).associateBy { it.id } | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val ticketLaneByDoId = loadTicketTruckLanesByDoId( | |||
| deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, | |||
| ) | |||
| val processedRecords = allResult.content.map { info -> | |||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shop = deliveryOrder?.shop | |||
| val shopId = shop?.id | |||
| val infoEta = info.estimatedArrivalDate | |||
| val calculatedTruckLanceCode = | |||
| if (deliveryOrder != null && shopId != null && infoEta != null) { | |||
| val targetDate = infoEta.toLocalDate() | |||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||
| resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode | |||
| } else { | |||
| null | |||
| } | |||
| val calculatedTruckLanceCode = displayedTruckLanceCode( | |||
| deliveryOrder = deliveryOrder, | |||
| estimatedArrivalDate = info.estimatedArrivalDate, | |||
| floorSuppliers2F = floorSuppliers2F, | |||
| floorSuppliers4F = floorSuppliers4F, | |||
| truckCache = null, | |||
| ticketLaneByDoId = ticketLaneByDoId, | |||
| ) | |||
| DeliveryOrderInfoLiteDto( | |||
| id = info.id, | |||
| @@ -481,14 +478,18 @@ open class DeliveryOrderService( | |||
| return deliveryOrderRepository.findByIdAndDeletedIsFalse(id); | |||
| } | |||
| */ | |||
| private fun computeAvailableStockQtyForItem(itemId: Long): BigDecimal { | |||
| val availableLots = inventoryLotLineRepository | |||
| .findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||
| return availableLots.sumOf { lot -> | |||
| (lot.inQty ?: BigDecimal.ZERO) | |||
| .minus(lot.outQty ?: BigDecimal.ZERO) | |||
| .minus(lot.holdQty ?: BigDecimal.ZERO) | |||
| } | |||
| /** | |||
| * DO line stock figure: one inventory bucket `(itemId, stockUomId)` where | |||
| * [stockUomId] is the line's UOM (`uom_conversion.id`). | |||
| * `available = onHand − unavailable` (hold not subtracted), same as Item Search. | |||
| * Must not sum lot lines across all stock UOMs of the item. | |||
| */ | |||
| private fun computeAvailableStockQtyForItem(itemId: Long, stockUomId: Long?): BigDecimal { | |||
| if (stockUomId == null) return BigDecimal.ZERO | |||
| val bucket = inventoryRepository.findByItemIdAndStockUomIdAndDeletedIsFalse(itemId, stockUomId) | |||
| ?: return BigDecimal.ZERO | |||
| return (bucket.onHandQty ?: BigDecimal.ZERO) | |||
| .minus(bucket.unavailableQty ?: BigDecimal.ZERO) | |||
| } | |||
| /** | |||
| @@ -603,11 +604,13 @@ open class DeliveryOrderService( | |||
| null | |||
| } | |||
| val itemIds = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { it.item?.id }.distinct() | |||
| val stockQtyByItemId = itemIds.associateWith { itemId -> | |||
| computeAvailableStockQtyForItem(itemId) | |||
| } | |||
| val fallbackQtyByItemId = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.mapNotNull { line -> | |||
| val activeLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true } | |||
| val stockQtyByBucket = activeLines.mapNotNull { line -> | |||
| val itemId = line.item?.id ?: return@mapNotNull null | |||
| val stockUomId = line.uom?.id ?: return@mapNotNull null | |||
| (itemId to stockUomId) to computeAvailableStockQtyForItem(itemId, stockUomId) | |||
| }.toMap() | |||
| val fallbackQtyByItemId = activeLines.mapNotNull { line -> | |||
| val itemId = line.item?.id ?: return@mapNotNull null | |||
| itemId to (line.qty ?: BigDecimal.ZERO) | |||
| }.toMap() | |||
| @@ -632,9 +635,14 @@ open class DeliveryOrderService( | |||
| isExtra = deliveryOrder.isExtra, | |||
| handlerName = handlerName, | |||
| truckLaneCode = truckLaneCode, | |||
| deliveryOrderLines = deliveryOrder.deliveryOrderLines.filter { it.deleted != true }.map { line -> | |||
| deliveryOrderLines = activeLines.map { line -> | |||
| val itemId = line.item?.id | |||
| val stockQty = itemId?.let { stockQtyByItemId[it] } ?: BigDecimal.ZERO | |||
| val stockUomId = line.uom?.id | |||
| val stockQty = if (itemId != null && stockUomId != null) { | |||
| stockQtyByBucket[itemId to stockUomId] ?: BigDecimal.ZERO | |||
| } else { | |||
| BigDecimal.ZERO | |||
| } | |||
| val requiredQty = line.qty ?: BigDecimal.ZERO | |||
| val availableStatus = | |||
| if (stockQty >= requiredQty) "available" else "insufficient" | |||
| @@ -1387,6 +1395,7 @@ open class DeliveryOrderService( | |||
| ) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ | |||
| private fun exportDeliveryNoteFromRecord( | |||
| request: ExportDeliveryNoteRequest, | |||
| deliveryNote: net.sf.jasperreports.engine.JasperReport, | |||
| @@ -1480,15 +1489,19 @@ open class DeliveryOrderService( | |||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | |||
| val isExtraByDoId = deliveryOrders.associate { it.id!! to it.isExtra } | |||
| val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(deliveryOrderIds) | |||
| val pickOrderLineIdByDeliveryLine = matchDeliveryNotePickOrderLines( | |||
| exportLines = exportLines, | |||
| pickOrderIdByDoId = pickOrderIdByDoId, | |||
| pickOrderLines = pickOrderLines, | |||
| replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), | |||
| ) | |||
| sortedLines.forEach { row -> | |||
| fields.add( | |||
| buildDeliveryNotePdfLineField( | |||
| deliveryOrderId = row.deliveryOrderId, | |||
| line = row.line, | |||
| sequenceNumber = fields.size + 1, | |||
| pickOrderIdByDoId = pickOrderIdByDoId, | |||
| pickOrderLines = pickOrderLines, | |||
| stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, | |||
| illById = illById, | |||
| itemsById = itemsById, | |||
| @@ -1497,6 +1510,7 @@ open class DeliveryOrderService( | |||
| headerTicketNo = null, | |||
| headerIsMerge = false, | |||
| replenishPdfIndex = replenishPdfIndex, | |||
| pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, | |||
| ), | |||
| ) | |||
| } | |||
| @@ -1557,17 +1571,47 @@ open class DeliveryOrderService( | |||
| info.deliveryOrderLines.filter { it.deleted != true }.map { line -> DeliveryNoteExportLine(info.id, line) } | |||
| } | |||
| fun resolvePickOrderLineIdForDeliveryNoteLine( | |||
| deliveryOrderId: Long, | |||
| itemId: Long?, | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 | |||
| * Pair each delivery-order line with its own pick line. | |||
| * Same item on one pick order is matched by qty first (4↔4, 1↔1). | |||
| * Equal qty is paired in id order. Replenishment pick lines are left out; | |||
| * those rows still print from do_replenishment.pickOrderLineId. | |||
| */ | |||
| fun matchDeliveryNotePickOrderLines( | |||
| exportLines: List<DeliveryNoteExportLine>, | |||
| pickOrderIdByDoId: Map<Long, Long>, | |||
| pickOrderLines: List<PickOrderLine>, | |||
| ): Long? { | |||
| if (itemId == null) return null | |||
| val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: return null | |||
| return pickOrderLines | |||
| .firstOrNull { pol -> pol.pickOrder?.id == pickOrderId && pol.item?.id == itemId } | |||
| ?.id | |||
| replenishmentPickOrderLineIds: Set<Long> = emptySet(), | |||
| ): Map<Pair<Long, Long>, Long> { | |||
| val keys = exportLines.mapNotNull { row -> | |||
| val itemId = row.line.itemId ?: return@mapNotNull null | |||
| if (row.line.deleted == true) return@mapNotNull null | |||
| DeliveryNoteLineMatchKey( | |||
| deliveryOrderId = row.deliveryOrderId, | |||
| lineId = row.line.id, | |||
| itemId = itemId, | |||
| qty = row.line.qty, | |||
| ) | |||
| } | |||
| val candidates = pickOrderLines.mapNotNull { pol -> | |||
| val polId = pol.id ?: return@mapNotNull null | |||
| val pickOrderId = pol.pickOrder?.id ?: return@mapNotNull null | |||
| val itemId = pol.item?.id ?: return@mapNotNull null | |||
| val qty = pol.qty ?: return@mapNotNull null | |||
| DeliveryNotePickLineCandidate( | |||
| id = polId, | |||
| pickOrderId = pickOrderId, | |||
| itemId = itemId, | |||
| qty = qty, | |||
| ) | |||
| } | |||
| return DeliveryNotePickLineMatcher.match( | |||
| lines = keys, | |||
| pickOrderIdByDoId = pickOrderIdByDoId, | |||
| pickLines = candidates, | |||
| replenishmentPickOrderLineIds = replenishmentPickOrderLineIds, | |||
| ) | |||
| } | |||
| fun parseTicketTypeLetter(ticketNo: String?): String? { | |||
| @@ -1679,12 +1723,11 @@ open class DeliveryOrderService( | |||
| return field | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ | |||
| fun buildDeliveryNotePdfLineField( | |||
| deliveryOrderId: Long, | |||
| line: DeliveryOrderLineInfo, | |||
| sequenceNumber: Int, | |||
| pickOrderIdByDoId: Map<Long, Long>, | |||
| pickOrderLines: List<PickOrderLine>, | |||
| stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>>, | |||
| illById: Map<Long, InventoryLotLine>, | |||
| itemsById: Map<Long, Items>, | |||
| @@ -1693,6 +1736,7 @@ open class DeliveryOrderService( | |||
| headerTicketNo: String? = null, | |||
| headerIsMerge: Boolean = false, | |||
| replenishPdfIndex: DoReplenishmentService.ReplenishPdfIndex = DoReplenishmentService.ReplenishPdfIndex.EMPTY, | |||
| pickOrderLineIdByDeliveryLine: Map<Pair<Long, Long>, Long> = emptyMap(), | |||
| ): MutableMap<String, Any> { | |||
| val field = mutableMapOf<String, Any>() | |||
| val isExtra = isExtraDeliveryTicket( | |||
| @@ -1701,12 +1745,7 @@ open class DeliveryOrderService( | |||
| headerIsMerge = headerIsMerge, | |||
| ) | |||
| val polId = resolvePickOrderLineIdForDeliveryNoteLine( | |||
| deliveryOrderId = deliveryOrderId, | |||
| itemId = line.itemId, | |||
| pickOrderIdByDoId = pickOrderIdByDoId, | |||
| pickOrderLines = pickOrderLines, | |||
| ) | |||
| val polId = pickOrderLineIdByDeliveryLine[deliveryOrderId to line.id] | |||
| val isReplenish = replenishPdfIndex.matches(deliveryOrderId, line.itemId, polId) | |||
| field["sequenceNumber"] = formatSequenceNumber(sequenceNumber, isExtra, isReplenish) | |||
| @@ -2502,6 +2541,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| return out | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 */ | |||
| private fun buildResolvedTruckDtosForLiteRows( | |||
| rows: List<DeliveryOrderInfoLite>, | |||
| ): List<DeliveryOrderInfoLiteDto> { | |||
| @@ -2509,9 +2549,13 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| if (ids.isEmpty()) return emptyList() | |||
| val deliveryOrdersMap = deliveryOrderRepository.findAllById(ids).associateBy { it.id } | |||
| val (floorSuppliers2F, floorSuppliers4F) = loadDoFloorSupplierLists() | |||
| val ticketLaneByDoId = loadTicketTruckLanesByDoId( | |||
| deliveryOrdersMap.values.filter { isReleasedDeliveryOrder(it) }.mapNotNull { it.id }, | |||
| ) | |||
| val shopIdAndDatePairs = rows.mapNotNull { info -> | |||
| val d = deliveryOrdersMap[info.id] | |||
| if (isReleasedDeliveryOrder(d)) return@mapNotNull null | |||
| val shopId = d?.shop?.id | |||
| val eta = info.estimatedArrivalDate | |||
| if (shopId != null && eta != null) { | |||
| @@ -2533,18 +2577,14 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| return rows.map { info -> | |||
| val deliveryOrder = deliveryOrdersMap[info.id] | |||
| val supplierCode = deliveryOrder?.supplier?.code | |||
| val preferredFloor = preferredStoreFloorForSupplier(supplierCode, floorSuppliers2F, floorSuppliers4F) | |||
| val shopId = deliveryOrder?.shop?.id | |||
| val infoEta = info.estimatedArrivalDate | |||
| val calculatedTruckLanceCode = | |||
| if (deliveryOrder != null && shopId != null && infoEta != null) { | |||
| val targetDate = infoEta.toLocalDate() | |||
| val dayAbbr = getDayOfWeekAbbr(targetDate) | |||
| truckCache[Triple(shopId, preferredFloor, dayAbbr)]?.truckLanceCode | |||
| } else { | |||
| null | |||
| } | |||
| val calculatedTruckLanceCode = displayedTruckLanceCode( | |||
| deliveryOrder = deliveryOrder, | |||
| estimatedArrivalDate = info.estimatedArrivalDate, | |||
| floorSuppliers2F = floorSuppliers2F, | |||
| floorSuppliers4F = floorSuppliers4F, | |||
| truckCache = truckCache, | |||
| ticketLaneByDoId = ticketLaneByDoId, | |||
| ) | |||
| DeliveryOrderInfoLiteDto( | |||
| id = info.id, | |||
| code = info.code, | |||
| @@ -2560,6 +2600,139 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| } | |||
| } | |||
| private class TicketLaneHit( | |||
| val lane: String?, | |||
| val ticketId: Long, | |||
| val srcRank: Int, | |||
| ) | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 | |||
| * 放單後狀態:receiving 表示已發布,completed 表示已完成。 | |||
| */ | |||
| private fun isReleasedDeliveryOrder(deliveryOrder: DeliveryOrder?): Boolean = | |||
| deliveryOrder?.status == DeliveryOrderStatus.RECEIVING || | |||
| deliveryOrder?.status == DeliveryOrderStatus.COMPLETED | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 | |||
| * 已放單讀票據車線(工作台 `delivery_order_pick_order`,否則 `do_pick_order`/record)。 | |||
| * 未放單才依供應商樓層 + 店鋪 + 星期從 truck 推算。 | |||
| * [truckCache] 為 null 時,未放單即時查 truck。 | |||
| */ | |||
| private fun displayedTruckLanceCode( | |||
| deliveryOrder: DeliveryOrder?, | |||
| estimatedArrivalDate: LocalDateTime?, | |||
| floorSuppliers2F: List<String>, | |||
| floorSuppliers4F: List<String>, | |||
| truckCache: Map<Triple<Long, String, String>, Truck?>?, | |||
| ticketLaneByDoId: Map<Long, String?>, | |||
| ): String? { | |||
| if (isReleasedDeliveryOrder(deliveryOrder)) { | |||
| return deliveryOrder?.id?.let { ticketLaneByDoId[it] } | |||
| } | |||
| val shopId = deliveryOrder?.shop?.id ?: return null | |||
| val eta = estimatedArrivalDate ?: return null | |||
| val preferredFloor = preferredStoreFloorForSupplier( | |||
| deliveryOrder.supplier?.code, | |||
| floorSuppliers2F, | |||
| floorSuppliers4F, | |||
| ) | |||
| val dayAbbr = getDayOfWeekAbbr(eta.toLocalDate()) | |||
| val key = Triple(shopId, preferredFloor, dayAbbr) | |||
| if (truckCache != null) { | |||
| return truckCache[key]?.truckLanceCode | |||
| } | |||
| return resolveTruckForShopFloorAndDay(shopId, preferredFloor, dayAbbr)?.truckLanceCode | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 88 | v1.0.1 | 2026-10-28 | |||
| * 一批送貨單對應的票據車線。同一張單有多張票時,工作台票優先,同來源取 id 最大的一張。 | |||
| * 票上的車線為空白時仍回傳 null,呼叫端不再改查 truck。 | |||
| */ | |||
| private fun loadTicketTruckLanesByDoId(doIds: Collection<Long>): Map<Long, String?> { | |||
| val ids = doIds.distinct() | |||
| if (ids.isEmpty()) return emptyMap() | |||
| val best = HashMap<Long, TicketLaneHit>() | |||
| val sql = """ | |||
| SELECT do_id, truck_lance_code, ticket_id, src_rank | |||
| FROM ( | |||
| SELECT po.doId AS do_id, | |||
| NULLIF(TRIM(dop.truckLanceCode), '') AS truck_lance_code, | |||
| dop.id AS ticket_id, | |||
| 1 AS src_rank | |||
| FROM fpsmsdb.delivery_order_pick_order dop | |||
| INNER JOIN fpsmsdb.pick_order po | |||
| ON po.deliveryOrderPickOrderId = dop.id AND po.deleted = 0 | |||
| WHERE dop.deleted = 0 | |||
| AND po.doId IN (:doIds) | |||
| UNION ALL | |||
| SELECT dpo.do_order_id AS do_id, | |||
| NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code, | |||
| dpo.id AS ticket_id, | |||
| 2 AS src_rank | |||
| FROM fpsmsdb.do_pick_order dpo | |||
| WHERE dpo.deleted = 0 | |||
| AND dpo.do_order_id IN (:doIds) | |||
| UNION ALL | |||
| SELECT dpol.do_order_id AS do_id, | |||
| NULLIF(TRIM(dpo.TruckLanceCode), '') AS truck_lance_code, | |||
| dpo.id AS ticket_id, | |||
| 2 AS src_rank | |||
| FROM fpsmsdb.do_pick_order dpo | |||
| INNER JOIN fpsmsdb.do_pick_order_line dpol | |||
| ON dpol.do_pick_order_id = dpo.id AND dpol.deleted = 0 | |||
| WHERE dpo.deleted = 0 | |||
| AND dpol.do_order_id IN (:doIds) | |||
| UNION ALL | |||
| SELECT rec.do_order_id AS do_id, | |||
| NULLIF(TRIM(rec.TruckLanceCode), '') AS truck_lance_code, | |||
| rec.id AS ticket_id, | |||
| 3 AS src_rank | |||
| FROM fpsmsdb.do_pick_order_record rec | |||
| WHERE rec.deleted = 0 | |||
| AND rec.do_order_id IN (:doIds) | |||
| ) lanes | |||
| """.trimIndent() | |||
| ids.chunked(400).forEach { chunk -> | |||
| val rows = jdbcDao.queryForList(sql, mapOf("doIds" to chunk)) | |||
| for (row in rows) { | |||
| val doId = jdbcNumber(row, "do_id")?.toLong() ?: continue | |||
| val ticketId = jdbcNumber(row, "ticket_id")?.toLong() ?: 0L | |||
| val srcRank = jdbcNumber(row, "src_rank")?.toInt() ?: 9 | |||
| val lane = jdbcText(row, "truck_lance_code") | |||
| val hit = TicketLaneHit(lane, ticketId, srcRank) | |||
| val current = best[doId] | |||
| if ( | |||
| current == null || | |||
| hit.srcRank < current.srcRank || | |||
| (hit.srcRank == current.srcRank && hit.ticketId > current.ticketId) | |||
| ) { | |||
| best[doId] = hit | |||
| } | |||
| } | |||
| } | |||
| return best.mapValues { it.value.lane } | |||
| } | |||
| private fun jdbcNumber(row: Map<*, *>, name: String): Number? { | |||
| val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null | |||
| return value as? Number | |||
| } | |||
| private fun jdbcText(row: Map<*, *>, name: String): String? { | |||
| val value = row.entries.firstOrNull { it.key?.toString().equals(name, true) }?.value ?: return null | |||
| return value.toString().trim().takeIf { it.isNotEmpty() } | |||
| } | |||
| /** | |||
| * 依店鋪 + 揀貨樓層解析當日應顯示之車線。 | |||
| * - **2F**(P07/P06D/P06Y):`TruckLanceCode` 多為 `車線-…` 且不含星期縮寫,不依 `LIKE '%Mon%'` 篩選;取該店該樓層未刪除車輛中出發時間最早者。 | |||
| @@ -2676,3 +2849,52 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } | |||
| } | |||
| } | |||
| } | |||
| internal data class DeliveryNoteLineMatchKey( | |||
| val deliveryOrderId: Long, | |||
| val lineId: Long, | |||
| val itemId: Long, | |||
| val qty: BigDecimal, | |||
| ) | |||
| internal data class DeliveryNotePickLineCandidate( | |||
| val id: Long, | |||
| val pickOrderId: Long, | |||
| val itemId: Long, | |||
| val qty: BigDecimal, | |||
| ) | |||
| internal object DeliveryNotePickLineMatcher { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ | |||
| fun match( | |||
| lines: List<DeliveryNoteLineMatchKey>, | |||
| pickOrderIdByDoId: Map<Long, Long>, | |||
| pickLines: List<DeliveryNotePickLineCandidate>, | |||
| replenishmentPickOrderLineIds: Set<Long> = emptySet(), | |||
| ): Map<Pair<Long, Long>, Long> { | |||
| val result = mutableMapOf<Pair<Long, Long>, Long>() | |||
| val rowsByDoAndItem = lines.groupBy { it.deliveryOrderId to it.itemId } | |||
| for ((key, rows) in rowsByDoAndItem) { | |||
| val (deliveryOrderId, itemId) = key | |||
| val pickOrderId = pickOrderIdByDoId[deliveryOrderId] ?: continue | |||
| val available = pickLines | |||
| .filter { pol -> | |||
| pol.pickOrderId == pickOrderId && | |||
| pol.itemId == itemId && | |||
| pol.id !in replenishmentPickOrderLineIds | |||
| } | |||
| .sortedBy { it.id } | |||
| .toMutableList() | |||
| for (row in rows.sortedBy { it.lineId }) { | |||
| val qtyIndex = available.indexOfFirst { it.qty.compareTo(row.qty) == 0 } | |||
| val chosen = when { | |||
| qtyIndex >= 0 -> available.removeAt(qtyIndex) | |||
| available.isNotEmpty() -> available.removeAt(0) | |||
| else -> null | |||
| } ?: continue | |||
| result[row.deliveryOrderId to row.lineId] = chosen.id | |||
| } | |||
| } | |||
| return result | |||
| } | |||
| } | |||
| @@ -162,6 +162,9 @@ open class DoReplenishmentService( | |||
| return deliveryOrderId to resolvedItemId in targetDoItemKeys | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ | |||
| fun replenishmentPickOrderLineIds(): Set<Long> = pickOrderLineIds | |||
| companion object { | |||
| val EMPTY = ReplenishPdfIndex(emptySet(), emptySet()) | |||
| } | |||
| @@ -46,6 +46,7 @@ import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | |||
| import com.ffii.fpsms.modules.stock.service.StockOutLineWorkbenchService | |||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotWorkbenchService | |||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | |||
| import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver | |||
| import com.ffii.fpsms.modules.stock.service.WorkbenchStockOutLinePickProgress | |||
| import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | |||
| import org.springframework.stereotype.Service | |||
| @@ -70,6 +71,7 @@ import jakarta.persistence.EntityManager | |||
| import jakarta.persistence.PersistenceContext | |||
| import org.springframework.orm.ObjectOptimisticLockingFailureException | |||
| import com.ffii.core.utils.PdfUtils | |||
| import com.ffii.core.utils.PrinterLockManager | |||
| import com.ffii.core.utils.ZebraPrinterUtil | |||
| import com.ffii.fpsms.modules.master.print.A4PrintDriverRegistry | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| @@ -84,11 +86,11 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.PrintDNLabelsReprintReque | |||
| import java.util.concurrent.ConcurrentHashMap | |||
| import java.util.concurrent.ConcurrentLinkedQueue | |||
| import java.util.concurrent.CountDownLatch | |||
| import java.util.concurrent.Executors | |||
| import org.springframework.core.io.ClassPathResource | |||
| import net.sf.jasperreports.engine.JasperCompileManager | |||
| import net.sf.jasperreports.engine.JasperExportManager | |||
| import net.sf.jasperreports.engine.JasperPrint | |||
| import net.sf.jasperreports.engine.JasperReport | |||
| import java.io.File | |||
| import java.io.FileNotFoundException | |||
| import java.util.Locale | |||
| @@ -114,6 +116,7 @@ open class DoWorkbenchMainService( | |||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | |||
| private val stockLedgerRepository: StockLedgerRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||
| private val itemUomRespository: ItemUomRespository, | |||
| private val bagRepository: BagRepository, | |||
| private val bagService: BagService, | |||
| @@ -141,6 +144,20 @@ open class DoWorkbenchMainService( | |||
| companion object { | |||
| private val log = LoggerFactory.getLogger(DoWorkbenchMainService::class.java) | |||
| private const val DELIVERY_NOTE_JRXML = "DeliveryNote/DeliveryNotePDF.jrxml" | |||
| private const val CARTON_LABEL_JRXML = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" | |||
| private val deliveryNoteJasperTemplate: JasperReport by lazy { compileJasper(DELIVERY_NOTE_JRXML) } | |||
| private val cartonLabelJasperTemplate: JasperReport by lazy { compileJasper(CARTON_LABEL_JRXML) } | |||
| private fun compileJasper(path: String): JasperReport { | |||
| val resource = ClassPathResource(path) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Report file not found: $path") | |||
| } | |||
| return resource.inputStream.use { JasperCompileManager.compileReport(it) } | |||
| } | |||
| } | |||
| private sealed interface WorkbenchScanIllResolve { | |||
| @@ -243,7 +260,7 @@ open class DoWorkbenchMainService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.2 | 2026-08-13 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.3 | 2026-11-02 | |||
| * Warehouse exclude list for workbench **re-suggest** after scan-pick (shortfall / lot split). | |||
| * - JO: setting `JO.pick.excludeWarehouses` (fallback is JoWorkbenchPickConstants when the row is missing). | |||
| * - Consumable: hardcoded user [ConsumableWorkbenchPickConstants.HARDCODED_EXCLUDE_USER_ID] → JO list; else empty (no limit). | |||
| @@ -2639,15 +2656,15 @@ return MessageResponse( | |||
| private fun createWorkbenchPickLedger(sol: StockOutLine, deltaQty: BigDecimal) { | |||
| if (deltaQty <= BigDecimal.ZERO) return | |||
| val solItem = sol.item ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return | |||
| val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } | |||
| ?: sol.inventoryLotLine | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return | |||
| // Fast path (batch-submit style): avoid querying latest ledger. | |||
| // At this point inventory.onHandQty has already been updated by DB triggers after lot outQty change. | |||
| // So previousBalance can be reconstructed as (onHandAfter + deltaQty). | |||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val previousBalance = onHandAfter + deltaQty.toDouble() | |||
| val newBalance = previousBalance - deltaQty.toDouble() | |||
| val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } | |||
| ?: sol.inventoryLotLine | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val ledger = StockLedger().apply { | |||
| this.stockOutLine = sol | |||
| @@ -2707,12 +2724,13 @@ return MessageResponse( | |||
| } | |||
| private fun resolveWorkbenchPreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| onHandQtyBeforeUpdate: Double?, | |||
| ): Double { | |||
| if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| @@ -2975,6 +2993,7 @@ return MessageResponse( | |||
| } | |||
| @Transactional | |||
| open fun printDeliveryNoteWorkbench(request: PrintDeliveryNoteRequest) { | |||
| val started = System.nanoTime() | |||
| val printer = | |||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | |||
| @@ -2988,17 +3007,38 @@ return MessageResponse( | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||
| var handedToPrinter = false | |||
| try { | |||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||
| printer.ip?.let { ip -> | |||
| val port = printer.port ?: 9100 | |||
| val driver = A4PrintDriverRegistry.getDriver(printer.brand) | |||
| driver.print(tempPdfFile, ip, port, printQty) | |||
| val ip = printer.ip?.trim().orEmpty() | |||
| if (ip.isEmpty()) { | |||
| tempPdfFile.delete() | |||
| throw IllegalStateException("Printer ${printer.id} has no IP address") | |||
| } | |||
| updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) | |||
| } finally { | |||
| //tempPdfFile.delete() | |||
| val port = printer.port ?: 9100 | |||
| val brand = printer.brand | |||
| val doPickOrderId = request.doPickOrderId | |||
| val cartonQty = request.numOfCarton | |||
| sendToPrinter( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| afterPrint = { saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) }, | |||
| ) { | |||
| A4PrintDriverRegistry.getDriver(brand).print(tempPdfFile, ip, port, printQty) | |||
| } | |||
| handedToPrinter = true | |||
| log.info( | |||
| "Workbench print-DN sent buildMs={} doPickOrderId={} printerId={} printerIp={}", | |||
| (System.nanoTime() - started) / 1_000_000, | |||
| request.doPickOrderId, | |||
| request.printerId, | |||
| printer.ip ?: "null", | |||
| ) | |||
| } catch (e: Exception) { | |||
| if (!handedToPrinter) tempPdfFile.delete() | |||
| throw e | |||
| } | |||
| } | |||
| @@ -3064,15 +3104,9 @@ return MessageResponse( | |||
| return deliveryOrderService.formatDoCodesPlain(codes) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 89 | v1.0.0 | 2026-10-28 */ | |||
| open fun exportDeliveryNoteWorkbench(request: ExportDeliveryNoteRequest): Map<String, Any> { | |||
| val DELIVERYNOTE_PDF = "DeliveryNote/DeliveryNotePDF.jrxml" | |||
| val resource = ClassPathResource(DELIVERYNOTE_PDF) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Report file not found: $DELIVERYNOTE_PDF") | |||
| } | |||
| val inputStream = resource.inputStream | |||
| val deliveryNote = JasperCompileManager.compileReport(inputStream) | |||
| val deliveryNote = deliveryNoteJasperTemplate | |||
| val fields = mutableListOf<MutableMap<String, Any>>() | |||
| val params = mutableMapOf<String, Any>() | |||
| @@ -3086,18 +3120,9 @@ return MessageResponse( | |||
| val exportLines = deliveryOrderService.deliveryNoteExportLines(deliveryNoteInfo) | |||
| val pickOrderLines = ctx.pickOrderIds.flatMap { pid -> | |||
| pickOrderLineRepository.findAllByPickOrderId(pid) | |||
| } | |||
| val pickOrderLines = loadPickOrderLinesForPrint(ctx.pickOrderIds) | |||
| val allPickOrderLineIds = pickOrderLines.mapNotNull { it.id } | |||
| val stockOutLinesByPickOrderLineId: Map<Long, List<StockOutLineInfo>> = | |||
| if (allPickOrderLineIds.isNotEmpty()) { | |||
| allPickOrderLineIds.associateWith { polId -> | |||
| stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) | |||
| } | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| val stockOutLinesByPickOrderLineId = loadStockOutLinesForPrint(allPickOrderLineIds) | |||
| val doStoreFloorKey = ctx.header.storeId?.toString()?.trim()?.uppercase(Locale.ROOT) | |||
| ?.replace("/", "") | |||
| @@ -3123,11 +3148,7 @@ return MessageResponse( | |||
| val allIllIds = stockOutLinesByPickOrderLineId.values | |||
| .flatMap { lines -> lines.mapNotNull { it.inventoryLotLineId } } | |||
| .distinct() | |||
| val illById: Map<Long, InventoryLotLine> = if (allIllIds.isNotEmpty()) { | |||
| inventoryLotLineRepository.findAllById(allIllIds).associateBy { it.id!! } | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| val illById = loadInventoryLotLinesWithWarehouse(allIllIds) | |||
| val deliveryOrders = deliveryOrderRepository.findAllById(ctx.deliveryOrderIds) | |||
| val deliveryOrderCodeById = deliveryOrders.associate { it.id!! to (it.code ?: "") } | |||
| @@ -3135,6 +3156,12 @@ return MessageResponse( | |||
| val headerIsMerge = ctx.header.ticketNo?.startsWith("TI-M-") == true | |||
| val headerTicketNo = ctx.header.ticketNo | |||
| val replenishPdfIndex = doReplenishmentService.buildReplenishPdfIndex(ctx.deliveryOrderIds) | |||
| val pickOrderLineIdByDeliveryLine = deliveryOrderService.matchDeliveryNotePickOrderLines( | |||
| exportLines = exportLines, | |||
| pickOrderIdByDoId = ctx.pickOrderIdByDoId, | |||
| pickOrderLines = pickOrderLines, | |||
| replenishmentPickOrderLineIds = replenishPdfIndex.replenishmentPickOrderLineIds(), | |||
| ) | |||
| sortedLines.forEach { row -> | |||
| fields.add( | |||
| @@ -3142,8 +3169,6 @@ return MessageResponse( | |||
| deliveryOrderId = row.deliveryOrderId, | |||
| line = row.line, | |||
| sequenceNumber = fields.size + 1, | |||
| pickOrderIdByDoId = ctx.pickOrderIdByDoId, | |||
| pickOrderLines = pickOrderLines, | |||
| stockOutLinesByPickOrderLineId = stockOutLinesByPickOrderLineId, | |||
| illById = illById, | |||
| itemsById = itemsById, | |||
| @@ -3152,6 +3177,7 @@ return MessageResponse( | |||
| headerTicketNo = headerTicketNo, | |||
| headerIsMerge = headerIsMerge, | |||
| replenishPdfIndex = replenishPdfIndex, | |||
| pickOrderLineIdByDeliveryLine = pickOrderLineIdByDeliveryLine, | |||
| ), | |||
| ) | |||
| } | |||
| @@ -3201,6 +3227,7 @@ return MessageResponse( | |||
| } | |||
| @Transactional | |||
| open fun printDNLabelsWorkbench(request: PrintDNLabelsRequest) { | |||
| val started = System.nanoTime() | |||
| val printer = | |||
| printerService.findById(request.printerId) ?: throw java.util.NoSuchElementException("No such printer") | |||
| @@ -3214,26 +3241,48 @@ return MessageResponse( | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||
| var handedToPrinter = false | |||
| try { | |||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||
| printer.ip?.let { ip -> | |||
| printer.port?.let { port -> | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| printer.dpi, | |||
| ) | |||
| } | |||
| val ip = printer.ip?.trim().orEmpty() | |||
| val port = printer.port | |||
| if (ip.isEmpty() || port == null) { | |||
| tempPdfFile.delete() | |||
| throw IllegalStateException("Label printer ${printer.id} has no IP or port") | |||
| } | |||
| if (request.blankCartonNumber != true) { | |||
| updateWorkbenchCartonQty(request.doPickOrderId, request.numOfCarton) | |||
| val dpi = printer.dpi | |||
| val saveCarton = request.blankCartonNumber != true | |||
| val doPickOrderId = request.doPickOrderId | |||
| val cartonQty = request.numOfCarton | |||
| sendToPrinter( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| afterPrint = { | |||
| if (saveCarton) saveCartonQtyAfterSuccessfulPrint(doPickOrderId, cartonQty) | |||
| }, | |||
| ) { | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| dpi, | |||
| ) | |||
| } | |||
| } finally { | |||
| //tempPdfFile.delete() | |||
| handedToPrinter = true | |||
| log.info( | |||
| "Workbench print-DNLabels sent buildMs={} doPickOrderId={} cartons={} printerIp={}", | |||
| (System.nanoTime() - started) / 1_000_000, | |||
| request.doPickOrderId, | |||
| request.numOfCarton, | |||
| printer.ip ?: "null", | |||
| ) | |||
| } catch (e: Exception) { | |||
| if (!handedToPrinter) tempPdfFile.delete() | |||
| throw e | |||
| } | |||
| } | |||
| @@ -3254,15 +3303,8 @@ return MessageResponse( | |||
| } | |||
| private fun exportDNLabelsWorkbench(request: ExportDNLabelsRequest): Map<String, Any> { | |||
| val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" | |||
| val resource = ClassPathResource(DNLABELS_PDF) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Label file not found: $DNLABELS_PDF") | |||
| } | |||
| val ctx = getWorkbenchPrintContext(request.doPickOrderId) | |||
| val inputStream = resource.inputStream | |||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||
| val cartonLabel = cartonLabelJasperTemplate | |||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | |||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | |||
| }.toMutableList() | |||
| @@ -3323,15 +3365,8 @@ return MessageResponse( | |||
| totalCartonsOnShipment = request.totalCartonsOnShipment, | |||
| ) | |||
| val DNLABELS_PDF = "DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml" | |||
| val resource = ClassPathResource(DNLABELS_PDF) | |||
| if (!resource.exists()) { | |||
| throw FileNotFoundException("Label file not found: $DNLABELS_PDF") | |||
| } | |||
| val ctx = getWorkbenchPrintContext(request.doPickOrderId) | |||
| val inputStream = resource.inputStream | |||
| val cartonLabel = JasperCompileManager.compileReport(inputStream) | |||
| val cartonLabel = cartonLabelJasperTemplate | |||
| val cartonLabelInfo = ctx.deliveryOrderIds.flatMap { deliveryOrderId -> | |||
| deliveryOrderRepository.findDeliveryOrderInfoById(deliveryOrderId) | |||
| }.toMutableList() | |||
| @@ -3371,23 +3406,147 @@ return MessageResponse( | |||
| val pdf = exportDNLabelsReprintWorkbench(request) | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||
| var handedToPrinter = false | |||
| try { | |||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||
| printer.ip?.let { ip -> | |||
| printer.port?.let { port -> | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| printer.dpi, | |||
| ) | |||
| } | |||
| val ip = printer.ip?.trim().orEmpty() | |||
| val port = printer.port | |||
| if (ip.isEmpty() || port == null) { | |||
| tempPdfFile.delete() | |||
| throw IllegalStateException("Label printer ${printer.id} has no IP or port") | |||
| } | |||
| val dpi = printer.dpi | |||
| sendToPrinter(tempPdfFile, ip, port) { | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| dpi, | |||
| ) | |||
| } | |||
| handedToPrinter = true | |||
| } catch (e: Exception) { | |||
| if (!handedToPrinter) tempPdfFile.delete() | |||
| throw e | |||
| } | |||
| } | |||
| /** | |||
| * Send on this request so a printer timeout is returned to the tablet. | |||
| * Carton qty is saved only after the printer accepts the job. | |||
| */ | |||
| private fun sendToPrinter( | |||
| tempPdfFile: File, | |||
| ip: String, | |||
| port: Int, | |||
| afterPrint: (() -> Unit)? = null, | |||
| action: () -> Unit, | |||
| ) { | |||
| try { | |||
| PrinterLockManager.withLock(ip, port) { | |||
| action() | |||
| } | |||
| log.info("Workbench print sent printer={}:{} file={}", ip, port, tempPdfFile.name) | |||
| afterPrint?.invoke() | |||
| } catch (e: Exception) { | |||
| log.error("Workbench print failed printer={}:{} file={}", ip, port, tempPdfFile.name, e) | |||
| throw e | |||
| } finally { | |||
| // tempPdfFile.delete() | |||
| tempPdfFile.delete() | |||
| } | |||
| } | |||
| /** Same column as before (`cartonQty` only). Runs only after the printer accepts the job. */ | |||
| private fun saveCartonQtyAfterSuccessfulPrint(deliveryOrderPickOrderId: Long, cartonQty: Int) { | |||
| if (cartonQty <= 0) return | |||
| val tx = TransactionTemplate(transactionManager).apply { | |||
| propagationBehavior = TransactionDefinition.PROPAGATION_REQUIRES_NEW | |||
| } | |||
| tx.executeWithoutResult { | |||
| updateWorkbenchCartonQty(deliveryOrderPickOrderId, cartonQty) | |||
| } | |||
| } | |||
| private fun loadPickOrderLinesForPrint(pickOrderIds: List<Long>): List<com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine> { | |||
| if (pickOrderIds.isEmpty()) return emptyList() | |||
| return pickOrderIds.distinct().chunked(400).flatMap { chunk -> | |||
| pickOrderLineRepository.findAllForPrintByPickOrderIdIn(chunk) | |||
| } | |||
| } | |||
| private fun loadStockOutLinesForPrint(pickOrderLineIds: List<Long>): Map<Long, List<StockOutLineInfo>> { | |||
| if (pickOrderLineIds.isEmpty()) return emptyMap() | |||
| val lines = mutableListOf<PrintStockOutLineInfo>() | |||
| pickOrderLineIds.distinct().chunked(400).forEach { chunk -> | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| sol.id AS id, | |||
| sol.pickOrderLineId AS pickOrderLineId, | |||
| sol.qty AS qty, | |||
| sol.inventoryLotLineId AS inventoryLotLineId, | |||
| sol.status AS status, | |||
| il.lotNo AS lotNo | |||
| FROM fpsmsdb.stock_out_line sol | |||
| LEFT JOIN fpsmsdb.inventory_lot_line ill | |||
| ON ill.id = sol.inventoryLotLineId | |||
| LEFT JOIN fpsmsdb.inventory_lot il | |||
| ON il.id = ill.inventoryLotId | |||
| WHERE sol.deleted = 0 | |||
| AND sol.pickOrderLineId IN (:ids) | |||
| """.trimIndent(), | |||
| mapOf("ids" to chunk), | |||
| ) | |||
| rows.forEach { row -> | |||
| val polId = (rowValue(row, "pickOrderLineId") as? Number)?.toLong() ?: return@forEach | |||
| val qtyRaw = rowValue(row, "qty") | |||
| lines.add( | |||
| PrintStockOutLineInfo( | |||
| id = (rowValue(row, "id") as? Number)?.toLong() ?: 0L, | |||
| pickOrderLineId = polId, | |||
| qty = when (qtyRaw) { | |||
| is BigDecimal -> qtyRaw | |||
| is Number -> BigDecimal(qtyRaw.toString()) | |||
| else -> BigDecimal.ZERO | |||
| }, | |||
| inventoryLotLineId = (rowValue(row, "inventoryLotLineId") as? Number)?.toLong(), | |||
| lotNo = rowValue(row, "lotNo")?.toString()?.takeIf { it.isNotBlank() }, | |||
| status = rowValue(row, "status")?.toString() ?: "", | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| return lines.groupBy { it.pickOrderLineId!! } | |||
| } | |||
| private fun loadInventoryLotLinesWithWarehouse(ids: Collection<Long>): Map<Long, InventoryLotLine> { | |||
| if (ids.isEmpty()) return emptyMap() | |||
| val loaded = ids.distinct().chunked(400).flatMap { chunk -> | |||
| inventoryLotLineRepository.findAllByIdInWithWarehouse(chunk) | |||
| } | |||
| return loaded.mapNotNull { line -> line.id?.let { id -> id to line } }.toMap() | |||
| } | |||
| private fun rowValue(row: Map<String, Any?>, key: String): Any? { | |||
| val matched = row.keys.find { it.equals(key, ignoreCase = true) } ?: return null | |||
| return row[matched] | |||
| } | |||
| private data class PrintStockOutLineInfo( | |||
| override val id: Long, | |||
| override val pickOrderLineId: Long?, | |||
| override val qty: BigDecimal, | |||
| override val inventoryLotLineId: Long?, | |||
| override val lotNo: String?, | |||
| override val status: String, | |||
| ) : StockOutLineInfo { | |||
| override val itemId: Long = 0L | |||
| override val itemName: String? = null | |||
| override val itemNo: String = "" | |||
| override val stockOutId: Long = 0L | |||
| override val pickTime: java.time.LocalDateTime? = null | |||
| } | |||
| } | |||
| @@ -54,8 +54,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| json_arrayagg( | |||
| json_object( | |||
| 'id', jobm.id, | |||
| 'itemId', jobm.itemId, | |||
| 'uomId', jobm.uomId, | |||
| 'code', i.code, | |||
| 'name', i.name, | |||
| 'type', i.type, | |||
| 'pickedLotNo', pln.pickedLotNo, | |||
| 'reqQty', jobm.reqQty, | |||
| 'uom', uc.udfudesc, | |||
| @@ -93,8 +96,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| json_arrayagg( | |||
| json_object( | |||
| 'id', jobm.id, | |||
| 'itemId', jobm.itemId, | |||
| 'uomId', jobm.uomId, | |||
| 'code', i.code, | |||
| 'name', i.name, | |||
| 'type', i.type, | |||
| 'lotNo', il.lotNo, | |||
| 'reqQty', jobm.reqQty, | |||
| 'uom', uc.udfudesc, | |||
| @@ -272,8 +278,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| json_arrayagg( | |||
| json_object( | |||
| 'id', jobm.id, | |||
| 'itemId', jobm.itemId, | |||
| 'uomId', jobm.uomId, | |||
| 'code', i.code, | |||
| 'name', i.name, | |||
| 'type', i.type, | |||
| 'lotNo', il.lotNo, | |||
| 'reqQty', jobm.reqQty, | |||
| 'uom', uc.udfudesc, | |||
| @@ -312,8 +321,11 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| json_arrayagg( | |||
| json_object( | |||
| 'id', jobm.id, | |||
| 'itemId', jobm.itemId, | |||
| 'uomId', jobm.uomId, | |||
| 'code', i.code, | |||
| 'name', i.name, | |||
| 'type', i.type, | |||
| 'lotNo', il.lotNo, | |||
| 'reqQty', jobm.reqQty, | |||
| 'uom', uc.udfudesc, | |||
| @@ -126,6 +126,8 @@ data class JobOrderDetail( | |||
| data class JobOrderDetailPickLine( | |||
| val id: Long?, | |||
| val itemId: Long?, | |||
| val uomId: Long?, | |||
| val code: String?, | |||
| val name: String?, | |||
| val pickedLotNo: List<JobOrderDetailPickedLotNo>?, | |||
| @@ -166,7 +166,12 @@ open class JobOrderService( | |||
| val inventoriesMap = if (allItemIds.isNotEmpty()) { | |||
| inventoryRepository.findInventoryInfoByItemIdInAndDeletedIsFalse(allItemIds) | |||
| .associateBy { it.itemId } | |||
| .mapNotNull { info -> | |||
| val itemId = info.itemId ?: return@mapNotNull null | |||
| val stockUomId = info.stockUomId ?: return@mapNotNull null | |||
| (itemId to stockUomId) to info | |||
| } | |||
| .toMap() | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| @@ -228,7 +233,7 @@ open class JobOrderService( | |||
| private fun calculateStockCounts( | |||
| jobOrder: JobOrder, | |||
| inventoriesMap: Map<Long?, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo> | |||
| inventoriesMap: Map<Pair<Long, Long>, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo> | |||
| ): Pair<Int, Int> { | |||
| // 过滤掉 consumables 和 CMB 类型的物料 | |||
| val nonConsumablesJobms = jobOrder.jobms.filter { jobm -> | |||
| @@ -243,36 +248,22 @@ open class JobOrderService( | |||
| var sufficientCount = 0 | |||
| var insufficientCount = 0 | |||
| //println("=== JobOrderService.calculateStockCounts for JobOrder: ${jobOrder.code} ===") | |||
| nonConsumablesJobms.forEach { jobm -> | |||
| val itemId = jobm.item?.id | |||
| val itemCode = jobm.item?.code ?: "N/A" | |||
| val itemName = jobm.item?.name ?: "N/A" | |||
| if (itemId != null) { | |||
| val inventory = inventoriesMap[itemId] | |||
| val availableQty = if (inventory != null) { | |||
| inventory.availableQty ?: ( | |||
| (inventory.onHandQty ?: BigDecimal.ZERO) - | |||
| (inventory.onHoldQty ?: BigDecimal.ZERO) - | |||
| (inventory.unavailableQty ?: BigDecimal.ZERO) | |||
| ) | |||
| } else { | |||
| BigDecimal.ZERO | |||
| } | |||
| val jobmUomId = jobm.uom?.id | |||
| val inventory = resolveInventoryForJobm(itemId, jobmUomId, inventoriesMap) | |||
| val availableQty = inventory?.availableQty | |||
| ?: (inventory?.let { | |||
| (it.onHandQty ?: BigDecimal.ZERO) - (it.unavailableQty ?: BigDecimal.ZERO) | |||
| } ?: BigDecimal.ZERO) | |||
| // ✅ 获取 reqQty 和 availableQty 的单位信息 | |||
| val reqQty = jobm.reqQty ?: BigDecimal.ZERO | |||
| val reqUomId = jobm.uom?.id ?: 0L | |||
| val reqUomName = jobm.uom?.udfudesc ?: "N/A" | |||
| // ✅ 修复:使用 itemUomService 获取 stockUomId(与 ProductProcessService 保持一致) | |||
| val stockUnitItemUom = itemUomService.findStockUnitByItemId(itemId) | |||
| val stockUomId = stockUnitItemUom?.uom?.id ?: 0L | |||
| val stockUomName = stockUnitItemUom?.uom?.udfudesc ?: "N/A" | |||
| val availableUomId = inventory?.stockUomId ?: stockUnitItemUom?.uom?.id ?: 0L | |||
| // ✅ 转换为 base unit 进行比较(与 ProductProcessService 保持一致) | |||
| val baseReqQtyResult = if (reqUomId > 0 && reqQty > BigDecimal.ZERO) { | |||
| try { | |||
| itemUomService.convertUomByItem( | |||
| @@ -291,13 +282,13 @@ open class JobOrderService( | |||
| null | |||
| } | |||
| val baseAvailableQtyResult = if (stockUomId > 0 && availableQty > BigDecimal.ZERO) { | |||
| val baseAvailableQtyResult = if (availableUomId > 0 && availableQty > BigDecimal.ZERO) { | |||
| try { | |||
| itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = availableQty, | |||
| uomId = stockUomId, | |||
| uomId = availableUomId, | |||
| targetUnit = "baseUnit" | |||
| ) | |||
| ) | |||
| @@ -311,27 +302,32 @@ open class JobOrderService( | |||
| val baseReqQty = baseReqQtyResult?.newQty ?: BigDecimal.ZERO | |||
| val baseAvailableQty = baseAvailableQtyResult?.newQty ?: BigDecimal.ZERO | |||
| val baseUomName = baseReqQtyResult?.udfudesc ?: baseAvailableQtyResult?.udfudesc ?: "N/A" | |||
| // ✅ 使用 base unit 进行比较 | |||
| if (baseAvailableQty >= baseReqQty) { | |||
| sufficientCount++ | |||
| //println("✅ SUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)") | |||
| } else { | |||
| insufficientCount++ | |||
| //println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - reqQty: $reqQty ($reqUomName) = $baseReqQty ($baseUomName), availableQty: $availableQty ($stockUomName) = $baseAvailableQty ($baseUomName)") | |||
| } | |||
| } else { | |||
| // 如果没有 itemId,视为不足 | |||
| insufficientCount++ | |||
| //println("❌ INSUFFICIENT - Item: $itemCode ($itemName) - No itemId") | |||
| } | |||
| } | |||
| //println("=== Result: sufficient=$sufficientCount, insufficient=$insufficientCount ===") | |||
| return Pair(sufficientCount, insufficientCount) | |||
| } | |||
| private fun resolveInventoryForJobm( | |||
| itemId: Long, | |||
| jobmUomId: Long?, | |||
| inventoriesMap: Map<Pair<Long, Long>, com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo>, | |||
| ): com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo? { | |||
| if (jobmUomId != null) { | |||
| inventoriesMap[itemId to jobmUomId]?.let { return it } | |||
| } | |||
| val stockUnitUomId = itemUomService.findStockUnitByItemId(itemId)?.uom?.id ?: return null | |||
| return inventoriesMap[itemId to stockUnitUomId] | |||
| } | |||
| open fun jobOrderDetail(id: Long): JobOrderDetail { | |||
| val sqlResult = jobOrderRepository.findJobOrderDetailById(id) ?: throw NoSuchElementException(); | |||
| @@ -77,10 +77,31 @@ open class PSService( | |||
| FROM | |||
| bom | |||
| LEFT JOIN items ON bom.itemId = items.id | |||
| LEFT JOIN inventory ON items.id = inventory.itemId | |||
| LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode | |||
| LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1 | |||
| LEFT JOIN item_uom iu | |||
| ON iu.itemId = items.id | |||
| AND iu.stockUnit = 1 | |||
| AND IFNULL(iu.deleted, 0) = 0 | |||
| AND iu.id = ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = items.id | |||
| AND iu2.stockUnit = 1 | |||
| AND IFNULL(iu2.deleted, 0) = 0 | |||
| ) | |||
| LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId | |||
| LEFT JOIN inventory ON inventory.id = ( | |||
| SELECT i2.id | |||
| FROM inventory i2 | |||
| WHERE i2.itemId = items.id | |||
| AND IFNULL(i2.deleted, 0) = 0 | |||
| AND ( | |||
| i2.stockUomId = iu.uomId | |||
| OR (i2.stockUomId IS NULL AND i2.uomId = iu.uomId) | |||
| ) | |||
| ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id | |||
| LIMIT 1 | |||
| ) | |||
| LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode | |||
| WHERE bom.deleted = 0 and bom.description = 'FG' | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -168,8 +189,8 @@ open class PSService( | |||
| /** Set or clear coffee_or_tea for itemCode + systemType (coffee / tea / lemon). */ | |||
| /** | |||
| * Recalculate [inventory.onHandQty] / hold / unavailable from [inventory_lot_line] for FG BOM items. | |||
| * Same aggregation as pick-issue manual inventory sync. | |||
| * Recalculate [inventory.onHandQty] / unavailable from [inventory_lot_line] for FG BOM items. | |||
| * Per (itemId, stockUomId) bucket. Does not write onHoldQty. | |||
| */ | |||
| fun refreshInventoryOnHandForFgBomItems(): Int { | |||
| val sql = """ | |||
| @@ -182,24 +203,28 @@ open class PSService( | |||
| LEFT JOIN ( | |||
| SELECT | |||
| il.itemId, | |||
| iu.uomId AS stockUomId, | |||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS totalOnHandQty, | |||
| SUM(COALESCE(ill.holdQty, 0)) AS totalOnHoldQty, | |||
| SUM(CASE | |||
| WHEN ill.status = 'unavailable' | |||
| THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) | |||
| WHEN LOWER(ill.status) = 'unavailable' | |||
| THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) | |||
| ELSE 0 | |||
| END) AS totalUnavailableQty | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||
| WHERE ill.deleted = 0 | |||
| GROUP BY il.itemId | |||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND IFNULL(il.deleted, 0) = 0 | |||
| INNER JOIN item_uom iu ON iu.id = ill.stockItemUomId AND IFNULL(iu.deleted, 0) = 0 | |||
| WHERE IFNULL(ill.deleted, 0) = 0 | |||
| GROUP BY il.itemId, iu.uomId | |||
| ) calc ON calc.itemId = i.itemId | |||
| AND ( | |||
| i.stockUomId = calc.stockUomId | |||
| OR (i.stockUomId IS NULL AND i.uomId = calc.stockUomId) | |||
| ) | |||
| SET | |||
| i.onHandQty = COALESCE(calc.totalOnHandQty, 0), | |||
| i.onHoldQty = COALESCE(calc.totalOnHoldQty, 0), | |||
| i.unavailableQty = COALESCE(calc.totalUnavailableQty, 0), | |||
| i.status = IF( | |||
| COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalOnHoldQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0, | |||
| COALESCE(calc.totalOnHandQty, 0) - COALESCE(calc.totalUnavailableQty, 0) > 0, | |||
| 'available', | |||
| 'unavailable' | |||
| ), | |||
| @@ -27,14 +27,25 @@ interface ProductionScheduleLineRepository : AbstractRepository<ProductionSchedu | |||
| bmi.code, | |||
| bmi.name, | |||
| bmi.`type`, | |||
| coalesce(i.onHandQty, 0) - coalesce(i.onHoldQty, 0) - coalesce(i.unavailableQty, 0) as availableQty, | |||
| coalesce(i.onHandQty, 0) - coalesce(i.unavailableQty, 0) as availableQty, | |||
| ceil(coalesce(bm.qty, 0) * pp.proportion) as demandQty | |||
| from production_schedule_line psl | |||
| left join prod_prop pp on pp.pslId = psl.id | |||
| left join bom b on b.itemId = psl.itemId | |||
| left join bom_material bm on bm.bomId = b.id | |||
| left join items bmi on bmi.id = bm.itemId | |||
| left join inventory i on i.itemId = bmi.id | |||
| left join inventory i on i.id = ( | |||
| select i2.id | |||
| from inventory i2 | |||
| where i2.itemId = bmi.id | |||
| and ifnull(i2.deleted, 0) = 0 | |||
| and ( | |||
| i2.stockUomId = coalesce(bm.stockUnit, bm.uomId) | |||
| or (i2.stockUomId is null and i2.uomId = coalesce(bm.stockUnit, bm.uomId)) | |||
| ) | |||
| order by case when i2.stockUomId is null then 1 else 0 end, i2.id | |||
| limit 1 | |||
| ) | |||
| where psl.id = :id and bmi.id is not null | |||
| group by psl.id, bm.id, i.id, pp.proportion | |||
| """) | |||
| @@ -183,7 +183,7 @@ interface ProductionScheduleRepository : AbstractRepository<ProductionSchedule, | |||
| bmi.code, | |||
| bmi.name, | |||
| bmi.`type`, | |||
| coalesce(i.onHandQty, 0) - coalesce(i.onHoldQty, 0) - coalesce(i.unavailableQty, 0) as availableQty, | |||
| coalesce(i.onHandQty, 0) - coalesce(i.unavailableQty, 0) as availableQty, | |||
| -- ceil(coalesce(bm.qty, 0) * pp.proportion) as demandQty | |||
| coalesce(bm.qty, 0) * pp.proportion as demandQty, | |||
| uc.udfudesc as uomName | |||
| @@ -195,7 +195,13 @@ interface ProductionScheduleRepository : AbstractRepository<ProductionSchedule, | |||
| left join items bmi on bmi.id = bm.itemId | |||
| left join item_uom iu on iu.itemId = bmi.id and iu.salesUnit = true | |||
| left join uom_conversion uc on uc.id = iu.uomId | |||
| left join item_uom iu_stock on iu_stock.itemId = bmi.id and iu_stock.stockUnit = 1 and ifnull(iu_stock.deleted, 0) = 0 | |||
| left join inventory i on i.itemId = bmi.id | |||
| and ifnull(i.deleted, 0) = 0 | |||
| and ( | |||
| i.stockUomId = coalesce(nullif(bm.stockUnit, 0), iu_stock.uomId) | |||
| or (i.stockUomId is null and i.uomId = coalesce(nullif(bm.stockUnit, 0), iu_stock.uomId)) | |||
| ) | |||
| where ps.id = :id and bmi.id is not null | |||
| group by ps.id, psl.id, bm.id, i.id, pp.proportion, uc.udfudesc | |||
| ) r2 | |||
| @@ -5,6 +5,7 @@ import com.ffii.fpsms.modules.master.entity.projections.WarehouseCombo | |||
| import org.springframework.data.domain.Page | |||
| import org.springframework.data.domain.Pageable | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| import java.io.Serializable | |||
| @@ -22,6 +23,22 @@ interface WarehouseRepository : AbstractRepository<Warehouse, Long> { | |||
| fun findAllByIdIn(ids: List<Long>): List<Warehouse>; | |||
| fun findAllByCodeAndDeletedIsFalse(code: String): List<Warehouse> | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */ | |||
| @Query( | |||
| """ | |||
| SELECT w FROM Warehouse w | |||
| WHERE w.deleted = false | |||
| AND (:storeId IS NULL OR :storeId = '' OR w.store_id = :storeId) | |||
| AND (:warehouse IS NULL OR :warehouse = '' OR w.warehouse = :warehouse) | |||
| AND (:area IS NULL OR :area = '' OR w.area = :area) | |||
| """ | |||
| ) | |||
| fun findByLocationFilters( | |||
| @Param("storeId") storeId: String?, | |||
| @Param("warehouse") warehouse: String?, | |||
| @Param("area") area: String?, | |||
| ): List<Warehouse> | |||
| @Query( | |||
| """ | |||
| SELECT COUNT(w) FROM Warehouse w | |||
| @@ -7,7 +7,7 @@ interface BomCombo { | |||
| val id: Long; | |||
| @get:Value("#{target.id}") | |||
| val value: Long; | |||
| @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") | |||
| @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[stockUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") | |||
| val label: String; | |||
| val outputQty: BigDecimal; | |||
| val outputQtyUom: String?; | |||
| @@ -13,7 +13,8 @@ import org.slf4j.Logger | |||
| import org.slf4j.LoggerFactory | |||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.stock.entity.Inventory | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||
| import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver | |||
| @Service | |||
| open class ItemUomService( | |||
| @@ -22,7 +23,7 @@ open class ItemUomService( | |||
| val itemUomRespository: ItemUomRespository, | |||
| val currencyService: CurrencyService, | |||
| val itemsRepository: ItemsRepository, | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||
| ) { | |||
| val logger = org.slf4j.LoggerFactory.getLogger(this::class.java) | |||
| @@ -70,20 +71,21 @@ open class ItemUomService( | |||
| } | |||
| /** | |||
| * 同一 [itemId] 可能對應多筆 [Inventory](不同 uom)。 | |||
| * 優先取 `inventory.uomId` 與 `item_uom` 中 `baseUnit = true` 之列的 `uomId` 一致的那一筆; | |||
| * 若無 base 設定或無匹配列,則回退為 `findFirstByItemIdAndDeletedIsFalseOrderByIdAsc`。 | |||
| * @deprecated ChangeList #4 — do not use. Multi stock-UOM buckets cannot be keyed by base UOM. | |||
| * Use [com.ffii.fpsms.modules.stock.service.InventoryBucketResolver.findInventoryBucket] with | |||
| * lot-line stockItemUomId / stockUomId instead. | |||
| * | |||
| * Temporary fallback: looks up by the item's current stockUnit uomId (not base). | |||
| */ | |||
| @Deprecated( | |||
| message = "Use InventoryBucketResolver.findInventoryBucket(itemId, lotLine)", | |||
| replaceWith = ReplaceWith( | |||
| "inventoryBucketResolver.findInventoryBucket(itemId, lotLine)", | |||
| "com.ffii.fpsms.modules.stock.service.InventoryBucketResolver", | |||
| ), | |||
| ) | |||
| open fun findInventoryForItemBaseUom(itemId: Long): Inventory? { | |||
| val base = findBaseUnitByItemId(itemId) | |||
| val uomId = base?.uom?.id | |||
| if (uomId != null) { | |||
| val match = inventoryRepository.findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId, uomId) | |||
| if (match != null) { | |||
| return match | |||
| } | |||
| } | |||
| return inventoryRepository.findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId) | |||
| return inventoryBucketResolver.findInventoryBucket(itemId, null as InventoryLotLine?) | |||
| } | |||
| open fun findPickingUnitByItemId(itemId: Long): ItemUom? { | |||
| @@ -104,6 +106,9 @@ open class ItemUomService( | |||
| open fun convertPurchaseQtyToStockQty(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | |||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty; | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; | |||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||
| return purchaseQty.setScale(0, RoundingMode.UP) | |||
| } | |||
| val one = BigDecimal.ONE; | |||
| // IMPORTANT: | |||
| @@ -129,6 +134,9 @@ open class ItemUomService( | |||
| open fun convertPurchaseQtyToStockQtyRoundDown(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | |||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | |||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||
| return purchaseQty.setScale(0, RoundingMode.DOWN) | |||
| } | |||
| val one = BigDecimal.ONE | |||
| // Use high precision for intermediate steps, and round down only at the end. | |||
| @@ -149,6 +157,9 @@ open class ItemUomService( | |||
| open fun convertStockQtyToPurchaseQty(itemId: Long, stockQty: BigDecimal): BigDecimal { | |||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty | |||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||
| return stockQty.setScale(0, RoundingMode.UP) | |||
| } | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| @@ -168,8 +179,11 @@ open class ItemUomService( | |||
| * Returns source qty when source/purchase unit mapping is not found. | |||
| */ | |||
| open fun convertQtyToPurchaseQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | |||
| val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return sourceQty | |||
| if (sameUomId(uomId, purchaseUnit)) { | |||
| return sourceQty.setScale(0, RoundingMode.UP) | |||
| } | |||
| val sourceItemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| @@ -185,9 +199,12 @@ open class ItemUomService( | |||
| } | |||
| open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty; | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO; | |||
| val stockUnit = findStockUnitByItemId(itemId) | |||
| if (stockUnit != null && sameUomId(uomId, stockUnit)) { | |||
| return sourceQty.setScale(0, RoundingMode.UP) | |||
| } | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||
| if (stockUnit == null) return BigDecimal.ZERO | |||
| val one = BigDecimal.ONE; | |||
| val calcScale = 10 | |||
| @@ -208,8 +225,12 @@ open class ItemUomService( | |||
| * Same as convertQtyToStockQty but rounds down the final stock qty to integer. | |||
| */ | |||
| open fun convertQtyToStockQtyRoundDown(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | |||
| val stockUnit = findStockUnitByItemId(itemId) | |||
| if (stockUnit != null && sameUomId(uomId, stockUnit)) { | |||
| return sourceQty.setScale(0, RoundingMode.DOWN) | |||
| } | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO | |||
| if (stockUnit == null) return BigDecimal.ZERO | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| @@ -224,6 +245,18 @@ open class ItemUomService( | |||
| return stockQty.setScale(0, RoundingMode.DOWN) | |||
| } | |||
| /** True when both item UOMs point at the same `uom_conversion` row. */ | |||
| private fun sameUomId(left: ItemUom?, right: ItemUom?): Boolean { | |||
| val leftId = left?.uom?.id ?: return false | |||
| val rightId = right?.uom?.id ?: return false | |||
| return leftId == rightId | |||
| } | |||
| private fun sameUomId(uomId: Long, unit: ItemUom?): Boolean { | |||
| val unitUomId = unit?.uom?.id ?: return false | |||
| return unitUomId == uomId | |||
| } | |||
| /** | |||
| * Count-type stock units must be whole numbers: PCS/CTN…, 包, 箱, 件, 個, PCS, CTN, etc. | |||
| * Weight/volume units (克, 公斤, 升…) keep 2 decimals. | |||
| @@ -268,6 +301,9 @@ open class ItemUomService( | |||
| open fun convertPurchaseQtyToStockQtyPrecise(itemId: Long, purchaseQty: BigDecimal): BigDecimal { | |||
| val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return purchaseQty | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty | |||
| if (sameUomId(purchaseUnit, stockUnit)) { | |||
| return finalizePreciseStockQty(stockUnit, purchaseQty) | |||
| } | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| @@ -287,8 +323,12 @@ open class ItemUomService( | |||
| * Used for PO (M18 UOM) conversion where round-down is not allowed. | |||
| */ | |||
| open fun convertQtyToStockQtyPrecise(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { | |||
| val stockUnit = findStockUnitByItemId(itemId) | |||
| if (stockUnit != null && sameUomId(uomId, stockUnit)) { | |||
| return finalizePreciseStockQty(stockUnit, sourceQty) | |||
| } | |||
| val itemUom = findFirstByItemIdAndUomId(itemId, uomId) ?: return sourceQty | |||
| val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO | |||
| if (stockUnit == null) return BigDecimal.ZERO | |||
| val one = BigDecimal.ONE | |||
| val calcScale = 10 | |||
| @@ -410,8 +410,13 @@ open class ItemsService( | |||
| "i.AverageUnitPrice as averageUnitPrice, " + | |||
| "i.LatestMarketUnitPrice as latestMarketUnitPrice, " + | |||
| "DATE_FORMAT(i.latestMupUpdatedDate, '%Y-%m-%d %H:%i') as latestMupUpdatedDate, " + | |||
| "uc_s.id as uomId, " + | |||
| "uc_s.code as uom, " + | |||
| "uc_s.udfudesc as uomDesc, " + | |||
| "uc_p.udfudesc as purchaseUnit " + | |||
| "FROM items i " + | |||
| "LEFT JOIN item_uom iu_s ON iu_s.itemId = i.id AND iu_s.deleted = false AND iu_s.stockUnit = true " + | |||
| "LEFT JOIN uom_conversion uc_s ON uc_s.id = iu_s.uomId " + | |||
| "LEFT JOIN item_uom iu_p ON iu_p.itemId = i.id AND iu_p.deleted = false AND iu_p.purchaseUnit = true " + | |||
| "LEFT JOIN uom_conversion uc_p ON uc_p.id = iu_p.uomId " + | |||
| "WHERE i.deleted = false" | |||
| @@ -171,29 +171,29 @@ open class ProductionScheduleService( | |||
| val proportion = BigDecimal.ONE // BigDecimal(line.prodQty).divide(bm.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP) | |||
| val demandQty = bm.qty?.times(proportion) ?: zero | |||
| val saleUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| val materialStockUomId = bm.stockUnit?.toLong() | |||
| ?: bm.uom?.id | |||
| ?: bm.item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id } | |||
| RoughProdScheduleLineBomMaterialInfoByFg( | |||
| id = bm.id, | |||
| code = bm.item?.code, | |||
| name = bm.item?.name, | |||
| type = bm.item?.type, | |||
| availableQty = bm.item?.inventories?.sumOf { | |||
| (it.onHandQty ?: zero) - (it.onHoldQty ?: zero) - (it.unavailableQty ?: zero) | |||
| }, | |||
| availableQty = availableQtyWithoutHold(bm.item?.inventories, materialStockUomId, zero), | |||
| demandQty = demandQty, | |||
| uomName = saleUnit?.uom?.udfudesc | |||
| ) | |||
| } | |||
| val saleUnit = line.item.id?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| val fgStockUomId = line.item.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id } | |||
| RoughProdScheduleLineInfoByFg( | |||
| id = line.id, | |||
| code = line.item.code, | |||
| name = line.item.name, | |||
| type = line.item.type, | |||
| availableQty = line.item.inventories.sumOf { | |||
| (it.onHandQty ?: zero) - (it.onHoldQty ?: zero) - (it.unavailableQty ?: zero) | |||
| }, | |||
| availableQty = availableQtyWithoutHold(line.item.inventories, fgStockUomId, zero), | |||
| prodQty = BigDecimal(line.prodQty), | |||
| lastMonthAvgSales = BigDecimal(line.lastMonthAvgSales), | |||
| estCloseBal = BigDecimal(line.estCloseBal), | |||
| @@ -258,15 +258,16 @@ open class ProductionScheduleService( | |||
| val proportion = BigDecimal.ONE //BigDecimal(line.prodQty).divide(bm.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP) | |||
| val demandQty = bm.qty?.times(proportion) ?: zero | |||
| val saleUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| val materialStockUomId = bm.stockUnit?.toLong() | |||
| ?: bm.uom?.id | |||
| ?: bm.item?.id?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id } | |||
| RoughProdScheduleLineInfoByBomByDate( | |||
| id = bm.item?.id, | |||
| code = bm.item?.code, | |||
| name = bm.item?.name, | |||
| type = bm.item?.type, | |||
| availableQty = bm.item?.inventories?.sumOf { | |||
| (it.onHandQty ?: zero) - (it.onHoldQty ?: zero) - (it.unavailableQty ?: zero) | |||
| } ?: zero, | |||
| availableQty = availableQtyWithoutHold(bm.item?.inventories, materialStockUomId, zero), | |||
| demandQty = demandQty, | |||
| assignDate = line.assignDate, | |||
| uomName = saleUnit?.uom?.udfudesc | |||
| @@ -724,7 +725,21 @@ open class ProductionScheduleService( | |||
| FROM | |||
| bom | |||
| LEFT JOIN items ON bom.itemId = items.id | |||
| LEFT JOIN inventory ON items.id = inventory.itemId | |||
| LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id | |||
| AND iu_stock.stockUnit = 1 | |||
| AND IFNULL(iu_stock.deleted, 0) = 0 | |||
| LEFT JOIN inventory ON inventory.id = ( | |||
| SELECT i2.id | |||
| FROM inventory i2 | |||
| WHERE i2.itemId = items.id | |||
| AND IFNULL(i2.deleted, 0) = 0 | |||
| AND ( | |||
| i2.stockUomId = iu_stock.uomId | |||
| OR (i2.stockUomId IS NULL AND i2.uomId = iu_stock.uomId) | |||
| ) | |||
| ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id | |||
| LIMIT 1 | |||
| ) | |||
| left join item_fake_onhand on items.code = item_fake_onhand.itemCode | |||
| WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active' | |||
| -- and bom.itemId != 16771 | |||
| @@ -1821,7 +1836,18 @@ open class ProductionScheduleService( | |||
| left join bom on it.id = bom.itemId | |||
| left join bom_material bm on bom.id = bm.bomId | |||
| left join items itm on bm.itemId = itm.id | |||
| left join inventory iv on itm.id = iv.itemId | |||
| left join inventory iv on iv.id = ( | |||
| select i2.id | |||
| from inventory i2 | |||
| where i2.itemId = itm.id | |||
| and ifnull(i2.deleted, 0) = 0 | |||
| and ( | |||
| i2.stockUomId = ius.uomId | |||
| or (i2.stockUomId is null and i2.uomId = ius.uomId) | |||
| ) | |||
| order by case when i2.stockUomId is null then 1 else 0 end, i2.id | |||
| limit 1 | |||
| ) | |||
| left join item_uom ius on itm.id = ius.itemId and ius.stockUnit = 1 | |||
| left join uom_conversion ucs on ius.uomId = ucs.id | |||
| @@ -1893,7 +1919,18 @@ open class ProductionScheduleService( | |||
| LEFT JOIN item_uom ium18 ON ium18.itemId = itm.id AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0 | |||
| LEFT JOIN uom_conversion uomM18 ON uomM18.id = ium18.uomId | |||
| JOIN item_uom itsm ON itm.id = itsm.itemId and itsm.stockUnit = 1 | |||
| LEFT JOIN inventory iv ON itm.id = iv.itemId | |||
| LEFT JOIN inventory iv ON iv.id = ( | |||
| SELECT i2.id | |||
| FROM inventory i2 | |||
| WHERE i2.itemId = itm.id | |||
| AND IFNULL(i2.deleted, 0) = 0 | |||
| AND ( | |||
| i2.stockUomId = itsm.uomId | |||
| OR (i2.stockUomId IS NULL AND i2.uomId = itsm.uomId) | |||
| ) | |||
| ORDER BY CASE WHEN i2.stockUomId IS NULL THEN 1 ELSE 0 END, i2.id | |||
| LIMIT 1 | |||
| ) | |||
| WHERE DATE(ps.produceAt) >= DATE_ADD(:fromDate, INTERVAL 1 DAY) | |||
| AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY) | |||
| AND ps.id = ( | |||
| @@ -1968,5 +2005,16 @@ open class ProductionScheduleService( | |||
| // Optional: log the action (if you have logging setup) | |||
| // logger.info("Cleared all production schedules with produceAt >= today") | |||
| } | |||
| private fun availableQtyWithoutHold( | |||
| inventories: List<Inventory>?, | |||
| stockUomId: Long?, | |||
| zero: BigDecimal, | |||
| ): BigDecimal { | |||
| if (inventories.isNullOrEmpty() || stockUomId == null) return zero | |||
| val bucket = inventories.firstOrNull { inv -> | |||
| inv.stockUom?.id == stockUomId || (inv.stockUom == null && inv.uom?.id == stockUomId) | |||
| } ?: return zero | |||
| return (bucket.onHandQty ?: zero) - (bucket.unavailableQty ?: zero) | |||
| } | |||
| } | |||
| @@ -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") | |||
| fun getPickOrderItemsByPage(request: HttpServletRequest): RecordsRes<Map<String, Any>> { | |||
| try { | |||
| println("=== Debug: getPickOrderItemsByPage ===") | |||
| println("Request parameters: ${request.parameterMap}") | |||
| //println("=== Debug: getPickOrderItemsByPage ===") | |||
| //println("Request parameters: ${request.parameterMap}") | |||
| val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | |||
| .addStringLike("itemCode") | |||
| @@ -100,18 +100,18 @@ class ItemsController( | |||
| .addString("targetDateTo") | |||
| .build() | |||
| println("Criteria args: $criteriaArgs") | |||
| //println("Criteria args: $criteriaArgs") | |||
| val pageSize = request.getParameter("pageSize")?.toIntOrNull() ?: 10 | |||
| val pageNum = request.getParameter("pageNum")?.toIntOrNull() ?: 1 | |||
| println("Page size: $pageSize, Page num: $pageNum") | |||
| //println("Page size: $pageSize, Page num: $pageNum") | |||
| val fullList = itemsService.getPickOrderItemsByPage(criteriaArgs) | |||
| println("Full list size: ${fullList.size}") | |||
| //println("Full list size: ${fullList.size}") | |||
| val paginatedList = PagingUtils.getPaginatedList(fullList, pageSize, pageNum) | |||
| println("Paginated list size: ${paginatedList.size}") | |||
| //println("Paginated list size: ${paginatedList.size}") | |||
| return RecordsRes(paginatedList, fullList.size) | |||
| } catch (e: Exception) { | |||
| @@ -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 org.springframework.stereotype.Repository | |||
| import org.springframework.data.jpa.repository.EntityGraph | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| @@ -33,6 +34,17 @@ fun findByPickOrderId(pickOrderId: Long): List<PickOrderLine> | |||
| ) | |||
| fun findAllByPickOrderIdInAndDeletedFalse(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickOrderLine> | |||
| /** Delivery-note print: one query, associations initialized (avoids per-line lazy loads). */ | |||
| @EntityGraph(attributePaths = ["item", "pickOrder", "uom"]) | |||
| @Query( | |||
| """ | |||
| SELECT pol FROM PickOrderLine pol | |||
| WHERE pol.pickOrder.id IN :pickOrderIds | |||
| AND pol.deleted = false | |||
| """ | |||
| ) | |||
| fun findAllForPrintByPickOrderIdIn(@Param("pickOrderIds") pickOrderIds: List<Long>): List<PickOrderLine> | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| value = """ | |||
| @@ -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.InventoryLotLine | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | |||
| import com.ffii.fpsms.modules.stock.entity.Inventory | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.stock.service.StockOutLineService | |||
| @@ -46,10 +47,13 @@ import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.stock.service.StockLedgerLotSnapshot | |||
| import com.ffii.fpsms.modules.stock.service.InventoryBucketResolver | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||
| import org.springframework.beans.factory.annotation.Value | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest | |||
| @Service | |||
| @@ -72,7 +76,8 @@ open class PickExecutionIssueService( | |||
| private val stockLedgerRepository: StockLedgerRepository, | |||
| private val deliveryOrderRepository: DeliveryOrderRepository, | |||
| private val suggestedPickLotRepository: SuggestPickLotRepository, | |||
| private val doPickOrderRepository: DoPickOrderRepository | |||
| private val doPickOrderRepository: DoPickOrderRepository, | |||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||
| ) { | |||
| // 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议) | |||
| @@ -858,13 +863,8 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac | |||
| println("Miss Qty: ${missQty}") | |||
| val lotId = request.lotId ?: return | |||
| val itemId = request.itemId ?: return | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| if (inventoryLotLine != null) { | |||
| // ✅ 修改:更新 outQty 为 actualPickQty | |||
| val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | |||
| @@ -927,9 +927,7 @@ private fun handleMissItemOnly(request: PickExecutionIssueRequest, missQty: BigD | |||
| val itemId = request.itemId ?: return | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine) | |||
| if (inventoryLotLine != null) { | |||
| // ✅ 修改:释放 holdQty(减少 holdQty) | |||
| @@ -1128,10 +1126,9 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty | |||
| val lotId = request.lotId ?: return | |||
| val itemId = request.itemId ?: return | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val onHandQtyBeforeUpdate = bucketOnHandQty(itemId, inventoryLotLine) | |||
| // ✅ 修复:更新 stock_out_line,但不要累积 qty | |||
| val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | |||
| @@ -1163,7 +1160,6 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty | |||
| } | |||
| // ✅ 修复:更新 inventory_lot_line 的 outQty | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| if (inventoryLotLine != null) { | |||
| // ✅ 修复:计算新的 outQty,考虑之前的 outQty | |||
| val currentOutQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | |||
| @@ -2313,71 +2309,6 @@ private fun updateLotLineAfterIssue(lotLineId: Long, qty: BigDecimal, isMissItem | |||
| lotLine.modifiedBy = "system" | |||
| // 修复:使用 saveAndFlush 确保立即提交到数据库,触发触发器 | |||
| inventoryLotLineRepository.saveAndFlush(lotLine) | |||
| updateInventoryAfterLotLineChange(lotLine) | |||
| } | |||
| } | |||
| private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { | |||
| try { | |||
| val item = lotLine.inventoryLot?.item ?: return | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| // 使用 SQL 查询计算所有相关 lot lines 的总和 | |||
| val sql = """ | |||
| SELECT | |||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) as totalOnHandQty, | |||
| SUM(COALESCE(ill.holdQty, 0)) as totalOnHoldQty, | |||
| SUM(CASE | |||
| WHEN ill.status = 'unavailable' | |||
| THEN COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0) - COALESCE(ill.holdQty, 0) | |||
| ELSE 0 | |||
| END) as totalUnavailableQty | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId | |||
| WHERE il.itemId = :itemId | |||
| AND ill.deleted = 0 | |||
| """.trimIndent() | |||
| val result = jdbcDao.queryForMap(sql, mapOf("itemId" to item.id!!)).orElse(null) | |||
| if (result != null) { | |||
| val totalOnHandQty = (result["totalOnHandQty"] as? Number)?.let { | |||
| BigDecimal(it.toString()) | |||
| } ?: BigDecimal.ZERO | |||
| val totalOnHoldQty = (result["totalOnHoldQty"] as? Number)?.let { | |||
| BigDecimal(it.toString()) | |||
| } ?: BigDecimal.ZERO | |||
| val totalUnavailableQty = (result["totalUnavailableQty"] as? Number)?.let { | |||
| BigDecimal(it.toString()) | |||
| } ?: BigDecimal.ZERO | |||
| // 更新 inventory | |||
| inventory.onHandQty = totalOnHandQty | |||
| inventory.onHoldQty = totalOnHoldQty | |||
| inventory.unavailableQty = totalUnavailableQty | |||
| inventory.status = if (totalOnHandQty.subtract(totalOnHoldQty).subtract(totalUnavailableQty) > BigDecimal.ZERO) { | |||
| "available" | |||
| } else { | |||
| "unavailable" | |||
| } | |||
| inventory.modified = LocalDateTime.now() | |||
| inventory.modifiedBy = "system" | |||
| inventoryRepository.saveAndFlush(inventory) | |||
| println("=== MANUALLY UPDATED INVENTORY ===") | |||
| println("Item ID: ${item.id}") | |||
| println("Total OnHandQty: ${totalOnHandQty}") | |||
| println("Total OnHoldQty: ${totalOnHoldQty}") | |||
| println("Total UnavailableQty: ${totalUnavailableQty}") | |||
| println("Status: ${inventory.status}") | |||
| println("==================================") | |||
| } | |||
| } catch (e: Exception) { | |||
| println("Error updating inventory manually: ${e.message}") | |||
| e.printStackTrace() | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ | |||
| @@ -2389,23 +2320,19 @@ private fun createStockLedgerForStockOut( | |||
| val item = stockOutLine.item ?: return | |||
| val outQty = stockOutLine.qty?.toDouble() ?: 0.0 | |||
| // ✅ 修复:如果传入了 balance,直接使用;否则重新查询 inventory | |||
| val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return | |||
| val finalBalance = if (balance != null) { | |||
| if (balance < 0) 0.0 else balance | |||
| } else { | |||
| // 重新查询 inventory 以获取触发器更新后的最新 onHandQty | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val currentOnHandQty = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val newBalance = currentOnHandQty - outQty | |||
| val previous = previousBalanceForInventory(inventory) | |||
| val newBalance = previous - outQty | |||
| if (newBalance < 0) 0.0 else newBalance | |||
| } | |||
| // 使用传入的 ledgerType,如果没有则使用 stockOutLine.type | |||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| @@ -2768,7 +2695,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||
| lotLine.modified = LocalDateTime.now() | |||
| lotLine.modifiedBy = "system" | |||
| inventoryLotLineRepository.saveAndFlush(lotLine) | |||
| updateInventoryAfterLotLineChange(lotLine) | |||
| println("✅ Reset lot ${request.lotId}: issueQty=0, status=AVAILABLE") | |||
| } | |||
| val rejectedLines = stockOutLineRepository | |||
| @@ -2802,9 +2728,17 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||
| ) | |||
| } | |||
| // ✅ 修复:在创建和保存 stockOutLine 之前获取 inventory 的当前 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(request.itemId).orElse(null) | |||
| val lotLine = request.lotId.let { | |||
| inventoryLotLineRepository.findById(it).orElse(null) | |||
| } | |||
| val inventoryBeforeUpdate = inventoryBucketResolver.findInventoryBucket(request.itemId, lotLine) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| if (lotLine != null && inventoryBeforeUpdate == null) { | |||
| throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "此批次的庫存單位與貨品不符,無法提交貨品遺失或不良貨品。", | |||
| ) | |||
| } | |||
| println("=== submitIssueWithQty: Before update ===") | |||
| println("Item ID: ${request.itemId}") | |||
| @@ -2812,9 +2746,6 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||
| println("OnHandQty Before Update: ${onHandQtyBeforeUpdate}") | |||
| println("==========================================") | |||
| val lotLine = request.lotId.let { | |||
| inventoryLotLineRepository.findById(it).orElse(null) | |||
| } | |||
| println("Lot line: id=${lotLine?.id}, lotNo=${lotLine?.inventoryLot?.lotNo}") | |||
| val item = itemsRepository.findById(request.itemId).orElse(null) | |||
| @@ -2901,7 +2832,7 @@ private fun createStockLedgerForStockOutWithBalance( | |||
| // Use传入的 ledgerType,如果没有则使用 stockOutLine.type | |||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val inventory = resolveLedgerInventory(item.id!!, stockOutLine.inventoryLotLine) ?: return | |||
| val ill = stockOutLine.inventoryLotLine | |||
| val outQtyBd = BigDecimal.valueOf(outQty) | |||
| @@ -3013,4 +2944,19 @@ private fun resolveLotUomInfo(lotId: Long?): Pair<String?, String?> { | |||
| return Pair(uomCode, uomDesc) | |||
| } | |||
| private fun bucketOnHandQty(itemId: Long, lotLine: InventoryLotLine?): Double { | |||
| val inventory = resolveLedgerInventory(itemId, lotLine) | |||
| return (inventory?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| private fun resolveLedgerInventory(itemId: Long, lotLine: InventoryLotLine?): Inventory? = | |||
| inventoryBucketResolver.findInventoryBucket(itemId, lotLine) | |||
| private fun previousBalanceForInventory(inventory: Inventory): Double { | |||
| val latest = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| } | |||
| @@ -31,6 +31,7 @@ import com.ffii.fpsms.modules.stock.entity.StockOutRepository | |||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | |||
| import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo | |||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo | |||
| import com.ffii.fpsms.modules.stock.service.InventoryService | |||
| import com.ffii.fpsms.modules.stock.service.StockOutLineService | |||
| import com.ffii.fpsms.modules.stock.service.SuggestedPickLotService | |||
| @@ -215,28 +216,17 @@ open class PickOrderService( | |||
| val today = LocalDate.now() | |||
| val zero = BigDecimal.ZERO | |||
| val inventories = if (itemIds.isNotEmpty()) { | |||
| inventoryLotLineService | |||
| .allInventoryLotLinesByItemIdIn(itemIds) | |||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } | |||
| .filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero } | |||
| .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } | |||
| .groupBy { it.item?.id } | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero) | |||
| // Build response (removed suggestions) | |||
| val pickOrderInfos = fullPickOrders.map { po -> | |||
| val pickOrderLineInfos = po.pickOrderLines.map { pol -> | |||
| val inventory = pol.item?.id?.let { inventories[it] } | |||
| GetPickOrderLineInfo( | |||
| id = pol.id, | |||
| itemId = pol.item?.id, | |||
| itemCode = pol.item?.code, | |||
| itemName = pol.item?.name, | |||
| availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) }, | |||
| availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero), | |||
| requiredQty = pol.qty, | |||
| uomCode = pol.uom?.code, | |||
| uomDesc = pol.uom?.udfudesc, | |||
| @@ -660,7 +650,7 @@ open class PickOrderService( | |||
| // Get Inventory Data | |||
| val requiredItems = pos | |||
| .flatMap { it.pickOrderLines } | |||
| .groupBy { it.item?.id } | |||
| .groupBy { it.item?.id to it.uom?.id } | |||
| .map { (key, value) -> | |||
| key to object : CurrentInventoryItemInfo { | |||
| override val id: Long? = value[0].item?.id | |||
| @@ -671,42 +661,25 @@ open class PickOrderService( | |||
| override val requiredQty: BigDecimal = value.sumOf { it.qty ?: zero } | |||
| } | |||
| } // itemId - requiredQty | |||
| } // itemId+uom - requiredQty | |||
| val itemIds = requiredItems.mapNotNull { it.first } | |||
| // val inventories = inventoryLotLineRepository.findCurrentInventoryByItems(itemIds) | |||
| // val inventories = inventoryService.allInventoriesByItemIds(itemIds) | |||
| val inventories = inventoryLotLineService | |||
| .allInventoryLotLinesByItemIdIn(itemIds) | |||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } | |||
| .filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero } | |||
| .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } | |||
| .sortedBy { it.expiryDate } | |||
| .groupBy { it.item?.id } | |||
| val itemIds = requiredItems.mapNotNull { it.first.first } | |||
| val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero) | |||
| // Pick Orders | |||
| val releasePickOrderInfos = pos | |||
| .map { po -> | |||
| val releasePickOrderLineInfos = po.pickOrderLines.map { pol -> | |||
| // if (pol.item?.id != null && pol.item!!.id!! > 0) { | |||
| val inventory = pol.item?.id?.let { inventories[it] } | |||
| val itemUom = pol.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| // val inventory = inventories.find { it.itemId == pol.item?.id } | |||
| // Return | |||
| ReleasePickOrderLineInfo( | |||
| id = pol.id, | |||
| itemId = pol.item?.id, | |||
| itemCode = pol.item?.code, | |||
| itemName = pol.item?.name, | |||
| // availableQty = inventory?.availableQty, | |||
| availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) }, | |||
| // availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) * (itemUom?.ratioN ?: one) * (itemUom?.ratioD ?: one) }, | |||
| availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero), | |||
| requiredQty = pol.qty, | |||
| uomCode = pol.uom?.code, | |||
| uomDesc = pol.uom?.udfudesc, | |||
| ) | |||
| // } | |||
| } | |||
| // Return | |||
| @@ -725,16 +698,12 @@ open class PickOrderService( | |||
| // val inventory = inventories | |||
| // .find { it.itemId == item.first } | |||
| val inventory = item.first?.let { inventories[it] } | |||
| val itemUom = item.first?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| val inventory = item.first.let { (itemId, uomId) -> | |||
| if (itemId != null && uomId != null) inventories[itemId to uomId] else null | |||
| } | |||
| item.second.let { | |||
| // it.availableQty = inventory?.availableQty | |||
| it.availableQty = inventory?.sumOf { i -> i.availableQty ?: zero } | |||
| // it.availableQty = inventory?.sumOf { i -> (i.availableQty ?: zero) * (itemUom?.ratioN ?: one) * (itemUom?.ratioD ?: one) } | |||
| // return | |||
| it.availableQty = inventory?.sumOf { i -> (i.inQty ?: zero) - (i.outQty ?: zero) } | |||
| it | |||
| } | |||
| } | |||
| @@ -759,7 +728,7 @@ open class PickOrderService( | |||
| // Get Inventory Data | |||
| val requiredItems = pos | |||
| .flatMap { it.pickOrderLines } | |||
| .groupBy { it.item?.id } | |||
| .groupBy { it.item?.id to it.uom?.id } | |||
| .map { (key, value) -> | |||
| key to object : CurrentInventoryItemInfo { | |||
| override val id: Long? = value[0].item?.id | |||
| @@ -769,16 +738,10 @@ open class PickOrderService( | |||
| override var availableQty: BigDecimal? = zero | |||
| override val requiredQty: BigDecimal = value.sumOf { it.qty ?: zero } | |||
| } | |||
| } // itemId - requiredQty | |||
| } | |||
| val itemIds = requiredItems.mapNotNull { it.first } | |||
| val inventories = inventoryLotLineService | |||
| .allInventoryLotLinesByItemIdIn(itemIds) | |||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } | |||
| .filter { (it.inQty ?: zero).minus(it.outQty ?: zero).minus(it.holdQty ?: zero) > zero } | |||
| .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } | |||
| .sortedBy { it.expiryDate } | |||
| .groupBy { it.item?.id } | |||
| val itemIds = requiredItems.mapNotNull { it.first.first } | |||
| val inventories = pickableLotLinesByItemAndStockUom(itemIds, today, zero) | |||
| val suggestions = | |||
| suggestedPickLotService.suggestionForPickOrders(SuggestedPickLotForPoRequest(pickOrders = pos)) | |||
| @@ -793,24 +756,10 @@ open class PickOrderService( | |||
| emptyMap() | |||
| } | |||
| // Pre-calculate available quantities for each item ONCE | |||
| val itemAvailableQtyMap = mutableMapOf<Long, BigDecimal>() | |||
| itemIds.forEach { itemId -> | |||
| val inventory = inventories[itemId] | |||
| val totalAvailableQty = inventory?.sumOf { i -> | |||
| val inQty = i.inQty ?: zero | |||
| val outQty = i.outQty ?: zero | |||
| val holdQty = i.holdQty ?: zero | |||
| inQty.minus(outQty).minus(holdQty) | |||
| } ?: zero | |||
| itemAvailableQtyMap[itemId] = totalAvailableQty | |||
| } | |||
| val releasePickOrderLineInfos = pos | |||
| .map { po -> | |||
| val releasePickOrderLineInfos = po.pickOrderLines.map { pol -> | |||
| val itemId = pol.item?.id | |||
| val availableQty = itemId?.let { itemAvailableQtyMap[it] } ?: zero | |||
| val availableQty = pickLineAvailableQty(pol.item?.id, pol.uom?.id, inventories, zero) | |||
| // 取得這一行的所有 stock_out_line | |||
| val stockOutLines = stockOutLinesByPickOrderLineId[pol.id] ?: emptyList() | |||
| @@ -871,16 +820,11 @@ open class PickOrderService( | |||
| } | |||
| // Items | |||
| val currentInventoryInfos = requiredItems.map { item -> | |||
| val inventory = item.first?.let { inventories[it] } | |||
| val itemUom = item.first?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| val inventory = item.first.let { (itemId, uomId) -> | |||
| if (itemId != null && uomId != null) inventories[itemId to uomId] else null | |||
| } | |||
| item.second.let { | |||
| val convertedAvailableQty = inventory?.sumOf { i -> | |||
| val baseQty = (i.availableQty ?: zero) | |||
| baseQty | |||
| } | |||
| it.availableQty = convertedAvailableQty | |||
| it.availableQty = inventory?.sumOf { i -> (i.inQty ?: zero) - (i.outQty ?: zero) } | |||
| it | |||
| } | |||
| } | |||
| @@ -2344,19 +2288,16 @@ open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageRespons | |||
| -- Calculate available quantity from inventory | |||
| COALESCE(( | |||
| SELECT SUM( | |||
| COALESCE(inv.onHandQty, 0) | |||
| - COALESCE(inv.onHoldQty, 0) | |||
| - COALESCE(inv.unavailableQty, 0) | |||
| ) | |||
| SELECT COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0) | |||
| FROM fpsmsdb.inventory inv | |||
| JOIN fpsmsdb.item_uom iu | |||
| ON iu.itemId = inv.itemId | |||
| AND iu.uomId = inv.uomId | |||
| AND iu.baseUnit = 1 | |||
| AND iu.deleted = false | |||
| WHERE inv.itemId = i.id | |||
| AND inv.deleted = false | |||
| AND ( | |||
| inv.stockUomId = pol.uomId | |||
| OR (inv.stockUomId IS NULL AND inv.uomId = pol.uomId) | |||
| ) | |||
| ORDER BY CASE WHEN inv.stockUomId IS NULL THEN 1 ELSE 0 END, inv.id | |||
| LIMIT 1 | |||
| ), 0) as availableQty, | |||
| -- Check if all stock out lines for this pick order line are completed | |||
| @@ -5201,5 +5142,36 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||
| ) | |||
| } | |||
| private fun pickableLotLinesByItemAndStockUom( | |||
| itemIds: List<Long>, | |||
| today: LocalDate, | |||
| zero: BigDecimal, | |||
| ): Map<Pair<Long, Long>, List<InventoryLotLineInfo>> { | |||
| if (itemIds.isEmpty()) return emptyMap() | |||
| return inventoryLotLineService | |||
| .allInventoryLotLinesByItemIdIn(itemIds) | |||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE.value } | |||
| .filter { ((it.inQty ?: zero) - (it.outQty ?: zero)) > zero } | |||
| .filter { it.expiryDate.isAfter(today) || it.expiryDate.isEqual(today) } | |||
| .mapNotNull { line -> | |||
| val itemId = line.item?.id ?: return@mapNotNull null | |||
| val stockUomId = line.uomId ?: return@mapNotNull null | |||
| (itemId to stockUomId) to line | |||
| } | |||
| .groupBy({ it.first }, { it.second }) | |||
| } | |||
| private fun pickLineAvailableQty( | |||
| itemId: Long?, | |||
| polUomId: Long?, | |||
| lotsByBucket: Map<Pair<Long, Long>, List<InventoryLotLineInfo>>, | |||
| zero: BigDecimal, | |||
| ): BigDecimal { | |||
| if (itemId == null || polUomId == null) return zero | |||
| return lotsByBucket[itemId to polUomId] | |||
| ?.sumOf { (it.inQty ?: zero) - (it.outQty ?: zero) } | |||
| ?: zero | |||
| } | |||
| } | |||
| @@ -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 truckLaneVersionLineRepository: TruckLaneVersionLineRepository, | |||
| private val logisticRepository: LogisticRepository, | |||
| private val truckLaneRenameService: TruckLaneRenameService, | |||
| ) { | |||
| private fun toResponse(v: TruckLaneVersion): TruckLaneVersionResponse = | |||
| TruckLaneVersionResponse( | |||
| @@ -271,6 +272,7 @@ open class TruckLaneVersionService( | |||
| } | |||
| } | |||
| rewriteOpenSchedulesBackToSnapshot(version, lines) | |||
| restoreRowsFromLines(version, lines) | |||
| if (!skipPostSnapshot) { | |||
| @@ -316,6 +318,7 @@ open class TruckLaneVersionService( | |||
| return "No snapshot lines for affected lanes (versionId=$versionId)" | |||
| } | |||
| rewriteOpenSchedulesBackToSnapshot(version, scopedLines) | |||
| val fullSnapshotIds = allLines.mapNotNull { it.truckRowId }.filter { it > 0 }.toSet() | |||
| for (lane in codes) { | |||
| val currentLane = truckRepository.findAllByTruckLanceCodeAndDeletedFalse(lane) | |||
| @@ -340,6 +343,29 @@ open class TruckLaneVersionService( | |||
| return "Restored ${scopedLines.size} rows in ${codes.size} lane(s) from versionId=$versionId" | |||
| } | |||
| /** | |||
| * Open schedule from/to that still use the live name are pointed back at the snapshot name. | |||
| * Unfinished delivery tickets are left on the current name. | |||
| */ | |||
| private fun rewriteOpenSchedulesBackToSnapshot( | |||
| version: TruckLaneVersion, | |||
| lines: List<TruckLaneVersionLine>, | |||
| ) { | |||
| val ids = lines.mapNotNull { it.truckRowId }.filter { it > 0 }.distinct() | |||
| if (ids.isEmpty()) return | |||
| val trucks = truckRepository.findAllById(ids).associateBy { it.id } | |||
| val seen = HashSet<String>() | |||
| for (line in lines) { | |||
| val truck = trucks[line.truckRowId] ?: continue | |||
| val before = truck.truckLanceCode?.trim().orEmpty() | |||
| val after = (line.truckLanceCode ?: version.truckLanceCode)?.trim().orEmpty() | |||
| if (before.isEmpty() || after.isEmpty() || before == after) continue | |||
| val key = "$before|$after|${truck.storeId}|${truck.remark}" | |||
| if (!seen.add(key)) continue | |||
| truckLaneRenameService.rewriteOpenScheduleNames(before, after, truck.storeId, truck.remark) | |||
| } | |||
| } | |||
| private fun restoreRowsFromLines( | |||
| version: TruckLaneVersion, | |||
| lines: List<TruckLaneVersionLine>, | |||
| @@ -18,8 +18,11 @@ import org.springframework.web.multipart.MultipartHttpServletRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.CreateTruckWithoutShopRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.UpdateTruckShopDetailsRequest | |||
| import com.ffii.fpsms.modules.pickOrder.service.TruckLaneRenameService | |||
| import com.ffii.fpsms.modules.pickOrder.service.TruckService | |||
| import com.ffii.fpsms.modules.pickOrder.entity.TruckRepository | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.PreviewTruckLaneRenameRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.RenameTruckLaneRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.SaveTruckLane | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.TruckLaneCombinationResponse | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.ExportRouteLanesRequest | |||
| @@ -37,6 +40,7 @@ import jakarta.validation.Valid | |||
| open class TruckController( | |||
| private val truckService: TruckService, | |||
| private val truckRepository: TruckRepository, | |||
| private val truckLaneRenameService: TruckLaneRenameService, | |||
| ) { | |||
| @@ -469,4 +473,15 @@ open class TruckController( | |||
| } | |||
| } | |||
| @PostMapping("/previewTruckLaneRename") | |||
| open fun previewTruckLaneRename(@RequestBody req: PreviewTruckLaneRenameRequest) = | |||
| truckLaneRenameService.preview(req) | |||
| @PostMapping("/renameTruckLane") | |||
| open fun renameTruckLane(@RequestBody req: RenameTruckLaneRequest) = | |||
| truckLaneRenameService.rename(req) | |||
| @GetMapping("/truckLaneRenameLog") | |||
| open fun truckLaneRenameLog() = truckLaneRenameService.recentLogs() | |||
| } | |||
| @@ -22,13 +22,13 @@ import java.time.LocalDateTime | |||
| open class TruckLaneScheduleController @Autowired constructor( | |||
| private val truckLaneScheduleService: TruckLaneScheduleService, | |||
| ) { | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||
| @PostMapping | |||
| open fun create(@Valid @RequestBody request: CreateTruckLaneScheduleRequest): TruckLaneScheduleResponse { | |||
| return truckLaneScheduleService.createManual(request) | |||
| } | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||
| @PostMapping("/planFromRouteExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| open fun planFromRouteExcel(request: HttpServletRequest): ResponseEntity<RouteExcelSchedulePlanResponse> { | |||
| @@ -66,19 +66,19 @@ open class TruckLaneScheduleController @Autowired constructor( | |||
| return truckLaneScheduleService.getById(id) | |||
| } | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||
| @PostMapping("/{id}/cancel") | |||
| open fun cancel(@PathVariable id: Long): TruckLaneScheduleResponse { | |||
| return truckLaneScheduleService.cancel(id) | |||
| } | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||
| @PostMapping("/{id}/applyNow") | |||
| open fun applyNow(@PathVariable id: Long): TruckLaneScheduleResponse { | |||
| return truckLaneScheduleService.applyNow(id) | |||
| } | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||
| @PostMapping("/{id}/retry-failed") | |||
| open fun retryFailed( | |||
| @PathVariable id: Long, | |||
| @@ -87,7 +87,7 @@ open class TruckLaneScheduleController @Autowired constructor( | |||
| return truckLaneScheduleService.retryFailed(id, request?.executeAt) | |||
| } | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||
| @PostMapping("/{id}/reactivate") | |||
| open fun reactivateCancelled( | |||
| @PathVariable id: Long, | |||
| @@ -124,7 +124,7 @@ open class TruckLaneScheduleController @Autowired constructor( | |||
| } | |||
| } | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING')") | |||
| @PreAuthorize("hasAnyAuthority('ADMIN','TESTING','TRUCK_LANE')") | |||
| @PostMapping("/importExcel") | |||
| @Throws(ServletRequestBindingException::class) | |||
| open fun importExcel( | |||
| @@ -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 stockBaseUom: String?, | |||
| val stockUomId: Long?, | |||
| val availableStatus: String?, | |||
| val bomProcessId: Long?, | |||
| @@ -915,6 +915,7 @@ open class ProductProcessService( | |||
| reqBaseUom = baseUomName ?: "", | |||
| stockUom = stockUomName ?: "", | |||
| stockBaseUom = baseUomName ?: "", | |||
| stockUomId = stockUomId, | |||
| type = line.item?.type ?: "", | |||
| availableStatus = availableStatus, | |||
| @@ -103,7 +103,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| orderDate = it["orderDate"] as LocalDateTime?, | |||
| estimatedArrivalDate = it["estimatedArrivalDate"] as LocalDateTime?, | |||
| supplierName = it["supplierName"] as String?, | |||
| escalated = (it["escalated"] as Boolean?) ?: false, | |||
| escalated = it["escalated"] == 1L, | |||
| ) | |||
| } | |||
| } | |||
| @@ -211,8 +211,19 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> { | |||
| sql.append(" group by po.id"); | |||
| sql.append(" order by po.orderDate desc") | |||
| sql.append(" ) r") | |||
| if (args.containsKey("itemDetail")){ | |||
| sql.append(" where r.itemDetail like :itemDetail "); | |||
| val outerFilters = mutableListOf<String>() | |||
| if (args.containsKey("itemDetail")) { | |||
| outerFilters.add("r.itemDetail like :itemDetail") | |||
| } | |||
| if (args.containsKey("itemCode")) { | |||
| outerFilters.add("r.itemCode like :itemCode") | |||
| } | |||
| if (args.containsKey("itemName")) { | |||
| outerFilters.add("r.itemName like :itemName") | |||
| } | |||
| if (outerFilters.isNotEmpty()) { | |||
| sql.append(" where ") | |||
| sql.append(outerFilters.joinToString(" AND ")) | |||
| } | |||
| return sql.toString() | |||
| } | |||
| @@ -47,6 +47,8 @@ class PurchaseOrderController( | |||
| val criteriaArgs = CriteriaArgsBuilder.withRequest(request) | |||
| .addStringLike("supplier") | |||
| .addStringLike("code") | |||
| .addStringLike("itemCode") | |||
| .addStringLike("itemName") | |||
| .addString("status") | |||
| .addBoolean("escalated") | |||
| .addDate("orderDate") | |||
| @@ -105,7 +105,7 @@ class FGStockOutTraceabilityReportService( | |||
| CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | |||
| IFNULL(sp.name, '') AS customerName, | |||
| FORMAT( | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 | |||
| ) AS qtyNumeric, | |||
| FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, | |||
| '' AS truckNo, | |||
| @@ -131,7 +131,7 @@ class FGStockOutTraceabilityReportService( | |||
| GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | |||
| '' AS pickRemark, | |||
| FORMAT( | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 | |||
| ) AS totalStockOutQty, | |||
| 0 AS stockSubCategory | |||
| FROM delivery_order_pick_order dopo | |||
| @@ -154,11 +154,6 @@ class FGStockOutTraceabilityReportService( | |||
| ON dol.deliveryOrderId = do.id | |||
| AND dol.itemId = pol.itemId | |||
| AND dol.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN stock_out_line sol | |||
| ON pol.id = sol.pickOrderLineId | |||
| AND sol.itemId = it.id | |||
| @@ -170,6 +165,20 @@ class FGStockOutTraceabilityReportService( | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON sol.inventoryLotLineId = ill.id | |||
| AND ill.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON iu.deleted = 0 | |||
| AND iu.id = COALESCE( | |||
| ill.stockItemUomId, | |||
| ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = it.id | |||
| AND iu2.deleted = 0 | |||
| AND iu2.stockUnit = 1 | |||
| ) | |||
| ) | |||
| LEFT JOIN uom_conversion uc | |||
| ON uc.id = iu.uomId | |||
| LEFT JOIN inventory_lot il | |||
| ON ill.inventoryLotId = il.id | |||
| AND il.deleted = 0 | |||
| @@ -213,6 +222,7 @@ class FGStockOutTraceabilityReportService( | |||
| il.expiryDate | |||
| ORDER BY | |||
| it.code, | |||
| uc.udfudesc, | |||
| deliveryDate, | |||
| il.lotNo | |||
| """.trimIndent() | |||
| @@ -109,12 +109,23 @@ open class ItemQcFailReportService( | |||
| LEFT JOIN qc_result_measurement qrm | |||
| ON qrm.qcResultId = qr.id | |||
| AND qrm.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_uom | |||
| ON ill_uom.deleted = 0 | |||
| AND ill_uom.id = COALESCE( | |||
| sil.inventoryLotLineId, | |||
| ( | |||
| SELECT MIN(illx.id) | |||
| FROM inventory_lot_line illx | |||
| WHERE illx.inventoryLotId = sil.inventoryLotId | |||
| AND illx.deleted = 0 | |||
| AND illx.stockItemUomId IS NOT NULL | |||
| ) | |||
| ) | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| ON iu.id = ill_uom.stockItemUomId | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| ON uc.id = iu.uomId | |||
| LEFT JOIN item_uom iu_purchase | |||
| ON it.id = iu_purchase.itemId | |||
| AND iu_purchase.purchaseUnit = 1 | |||
| @@ -164,6 +175,7 @@ open class ItemQcFailReportService( | |||
| qr.qcPassed DESC, | |||
| it.type, | |||
| it.code, | |||
| COALESCE(uc.udfudesc, uc.code, ''), | |||
| COALESCE(sil.lotNo, il.lotNo, ''), | |||
| CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END, | |||
| COALESCE(qic.`order`, 9999), | |||
| @@ -105,7 +105,7 @@ class MaterialStockOutTraceabilityReportService( | |||
| COALESCE(wh.code, '') AS storeLocation, | |||
| '' AS pickRemark, | |||
| FORMAT( | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 | |||
| ) AS totalStockOutQty | |||
| FROM stock_out_line sol | |||
| INNER JOIN stock_out so | |||
| @@ -126,14 +126,23 @@ class MaterialStockOutTraceabilityReportService( | |||
| INNER JOIN items it | |||
| ON sol.itemId = it.id | |||
| AND it.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON sol.inventoryLotLineId = ill.id | |||
| AND ill.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON iu.deleted = 0 | |||
| AND iu.id = COALESCE( | |||
| ill.stockItemUomId, | |||
| ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = it.id | |||
| AND iu2.deleted = 0 | |||
| AND iu2.stockUnit = 1 | |||
| ) | |||
| ) | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN inventory_lot il | |||
| ON ill.inventoryLotId = il.id | |||
| AND il.deleted = 0 | |||
| @@ -169,6 +178,7 @@ class MaterialStockOutTraceabilityReportService( | |||
| $handlerSql | |||
| ORDER BY | |||
| it.code, | |||
| uc.udfudesc, | |||
| il.lotNo, | |||
| sol.endTime | |||
| """.trimIndent() | |||
| @@ -4,7 +4,7 @@ import org.springframework.stereotype.Service | |||
| import net.sf.jasperreports.engine.* | |||
| import net.sf.jasperreports.engine.data.JRMapCollectionDataSource | |||
| import java.io.ByteArrayOutputStream | |||
| import java.io.InputStream | |||
| import java.util.concurrent.ConcurrentHashMap | |||
| import com.ffii.core.support.JdbcDao | |||
| import com.ffii.fpsms.m18.M18GrnRules | |||
| import com.ffii.fpsms.modules.master.entity.ShopRepository | |||
| @@ -12,14 +12,11 @@ import com.ffii.fpsms.modules.master.enums.ShopType | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService | |||
| import java.math.BigDecimal | |||
| import java.text.DecimalFormat | |||
| import java.text.DecimalFormatSymbols | |||
| import java.time.LocalDate | |||
| import java.util.ArrayList | |||
| import java.util.Locale | |||
| import net.sf.jasperreports.engine.export.ooxml.JRXlsxExporter | |||
| import net.sf.jasperreports.export.SimpleExporterInput | |||
| import net.sf.jasperreports.export.SimpleOutputStreamExporterOutput | |||
| import org.slf4j.LoggerFactory | |||
| @Service | |||
| open class ReportService( | |||
| @@ -28,6 +25,8 @@ open class ReportService( | |||
| private val shopRepository: ShopRepository, | |||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | |||
| ) { | |||
| private val log = LoggerFactory.getLogger(javaClass) | |||
| private val compiledReports = java.util.concurrent.ConcurrentHashMap<String, JasperReport>() | |||
| /** | |||
| * Queries the database for inventory data based on dates and optional item type. | |||
| */ | |||
| @@ -373,7 +372,7 @@ return result | |||
| CAST(IFNULL(sp.id, 0) AS CHAR) AS customerId, | |||
| IFNULL(sp.name, '') AS customerName, | |||
| FORMAT( | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 | |||
| ) AS qtyNumeric, | |||
| FORMAT(ROUND(IFNULL(sol.qty, 0), 0), 0) AS qty, | |||
| '' AS truckNo, | |||
| @@ -397,7 +396,7 @@ return result | |||
| GROUP_CONCAT(DISTINCT wh.code ORDER BY wh.code SEPARATOR ', ') AS storeLocation, | |||
| '' AS pickRemark, | |||
| FORMAT( | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code), 0), 0 | |||
| ROUND(SUM(IFNULL(sol.qty, 0)) OVER (PARTITION BY it.code, uc.udfudesc), 0), 0 | |||
| ) AS totalStockOutQty, | |||
| 0 AS stockSubCategory | |||
| FROM do_pick_order_line_record dpolr | |||
| @@ -417,11 +416,6 @@ return result | |||
| LEFT JOIN items it | |||
| ON dol.itemId = it.id | |||
| AND it.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN pick_order_line pol | |||
| ON dpolr.pick_order_id = pol.poId | |||
| AND pol.itemId = it.id | |||
| @@ -440,6 +434,20 @@ return result | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON sol.inventoryLotLineId = ill.id | |||
| AND ill.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON iu.deleted = 0 | |||
| AND iu.id = COALESCE( | |||
| ill.stockItemUomId, | |||
| ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = it.id | |||
| AND iu2.deleted = 0 | |||
| AND iu2.stockUnit = 1 | |||
| ) | |||
| ) | |||
| LEFT JOIN uom_conversion uc | |||
| ON uc.id = iu.uomId | |||
| LEFT JOIN inventory_lot il | |||
| ON ill.inventoryLotId = il.id | |||
| AND il.deleted = 0 | |||
| @@ -480,6 +488,7 @@ return result | |||
| il.expiryDate | |||
| ORDER BY | |||
| it.code, | |||
| uc.udfudesc, | |||
| deliveryDate, | |||
| il.lotNo | |||
| """.trimIndent() | |||
| @@ -673,12 +682,19 @@ return result | |||
| * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. | |||
| * Supports comma-separated values for stockCategory (items.type) and itemCode. | |||
| * Date range [lastInDateStart, lastInDateEnd] filters on stock_in_line.productionDate (完成生產日期), same basis as 成品/半成品生產分析報告. | |||
| * Always limited to NOR stock-in (excludes TRF). PP/PF uses this line's PO code prefix, not lot-origin TRF walk. | |||
| */ | |||
| fun searchStockInTraceabilityReport( | |||
| stockCategory: String?, | |||
| itemCode: String?, | |||
| lastInDateStart: String?, | |||
| lastInDateEnd: String? | |||
| lastInDateEnd: String?, | |||
| storeId: String? = null, | |||
| warehouse: String? = null, | |||
| area: String? = null, | |||
| slot: String? = null, | |||
| lotNo: String? = null, | |||
| poPrefix: String? = null, | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| @@ -689,7 +705,7 @@ return result | |||
| } else { | |||
| "" | |||
| } | |||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) | |||
| val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args) | |||
| val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { | |||
| val formattedDate = lastInDateStart.replace("/", "-") | |||
| @@ -703,6 +719,34 @@ return result | |||
| "AND sil.productionDate IS NOT NULL AND DATE(sil.productionDate) <= DATE(:lastInDateEnd)" | |||
| } else "" | |||
| val storeIdSql = if (!storeId.isNullOrBlank() && storeId.trim() != "All") { | |||
| args["storeId"] = storeId.trim() | |||
| "AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')" | |||
| } else { | |||
| "" | |||
| } | |||
| val warehouseSql = if (!warehouse.isNullOrBlank() && warehouse.trim() != "All") { | |||
| buildMultiValueLikeClause(warehouse, "wh.warehouse", "warehousePart", args) | |||
| } else { | |||
| "" | |||
| } | |||
| val areaSql = if (!area.isNullOrBlank() && area.trim() != "All") { | |||
| buildMultiValueLikeClause(area, "wh.area", "areaPart", args) | |||
| } else { | |||
| "" | |||
| } | |||
| val slotSql = if (!slot.isNullOrBlank() && slot.trim() != "All") { | |||
| buildMultiValueLikeClause(slot, "wh.slot", "slotPart", args) | |||
| } else { | |||
| "" | |||
| } | |||
| val lotNoSql = buildMultiValueLikeClause(lotNo, "COALESCE(sil.lotNo, il.lotNo)", "lotNo", args) | |||
| val poPrefixSql = when (poPrefix?.trim()?.uppercase()) { | |||
| "PP" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PP'" | |||
| "PF" -> "AND UPPER(LEFT(TRIM(COALESCE(po.code, '')), 2)) = 'PF'" | |||
| else -> "" | |||
| } | |||
| val sql = """ | |||
| SELECT | |||
| COALESCE(it.code, '') as itemNo, | |||
| @@ -723,19 +767,33 @@ return result | |||
| END, 2))) as iqcDefectPercentage, | |||
| CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN '不合格' ELSE '已接受' END as iqcResult, | |||
| COALESCE(qr_agg.remarksConcat, '') as iqcRemarks, | |||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(qr_agg.failQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcDefectQty, | |||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(qr_agg.failQtySum, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalIqcDefectQty, | |||
| COALESCE(wh.code, '') as storeLocation, | |||
| COALESCE(sp_si.code, sp_po.code, '') as supplierID, | |||
| COALESCE(sp_si.name, sp_po.name, '') as supplierName, | |||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id), 2))) as totalStockInQty, | |||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id), 2))) as totalIqcSampleQty | |||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(pa_sil.putAwayQtySum, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalStockInQty, | |||
| TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(SUM(COALESCE(sil.acceptedQty, 0)) OVER (PARTITION BY it.id, iu.id), 2))) as totalIqcSampleQty | |||
| FROM stock_in_line sil | |||
| LEFT JOIN stock_in si ON sil.stockInId = si.id | |||
| LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | |||
| LEFT JOIN items it ON sil.itemId = it.id | |||
| LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true | |||
| LEFT JOIN uom_conversion uc ON iu.uomId = uc.id | |||
| LEFT JOIN inventory_lot il ON sil.inventoryLotId = il.id | |||
| LEFT JOIN inventory_lot_line ill_uom | |||
| ON ill_uom.deleted = 0 | |||
| AND ill_uom.id = COALESCE( | |||
| sil.inventoryLotLineId, | |||
| ( | |||
| SELECT MIN(illx.id) | |||
| FROM inventory_lot_line illx | |||
| WHERE illx.inventoryLotId = il.id | |||
| AND illx.deleted = 0 | |||
| AND illx.stockItemUomId IS NOT NULL | |||
| ) | |||
| ) | |||
| LEFT JOIN item_uom iu | |||
| ON iu.id = ill_uom.stockItemUomId | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc ON uc.id = iu.uomId | |||
| LEFT JOIN ( | |||
| SELECT inventoryLotId, SUM(COALESCE(inQty, 0)) AS putAwayQtySum | |||
| FROM inventory_lot_line | |||
| @@ -760,11 +818,18 @@ return result | |||
| GROUP BY qr.stockInLineId | |||
| ) qr_agg ON qr_agg.stockInLineId = sil.id | |||
| WHERE sil.deleted = false | |||
| AND UPPER(TRIM(COALESCE(sil.type, ''))) = 'NOR' | |||
| $stockCategorySql | |||
| $itemCodeSql | |||
| $lastInDateStartSql | |||
| $lastInDateEndSql | |||
| ORDER BY it.code, sil.lotNo | |||
| $storeIdSql | |||
| $warehouseSql | |||
| $areaSql | |||
| $slotSql | |||
| $lotNoSql | |||
| $poPrefixSql | |||
| ORDER BY it.code, uc.udfudesc, sil.lotNo | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| @@ -1485,14 +1550,17 @@ return result | |||
| } | |||
| /** | |||
| * Stock Balance Report (date-driven). | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.2 | 2026-09-21 | |||
| * Stock Balance Report (date-driven). One summarized row per item + stock UOM. | |||
| * | |||
| * Opening / current balance use the latest stock_ledger row per item (index on itemId+date) | |||
| * instead of ranking the whole ledger. Item codes prefer `IN` over `LIKE %x%`. | |||
| * | |||
| * - 期初存量: stockDate - 1 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) | |||
| * - 現存存貨: stockDate 的最後一筆 stock_ledger.balance (以 sl.date, sl.id 排序) | |||
| * - 單位均價 / 庫存總價值: items.AverageUnitPrice * 現存存貨 | |||
| * Qty comes from [stock_lot_day] (daily lot close) instead of scanning all stock_ledger: | |||
| * - 期初存量: historical = that day's opening; today = yesterday's closing | |||
| * - 現存存貨: historical = that day's closing; today = inventory.onHandQty | |||
| * (today is not closed yet). If the day row is missing, fall back to previous | |||
| * close + that day's ledger net. | |||
| * - 當日入/出/MISS/BAD/TKE: that calendar day's stock_ledger only | |||
| * - 單位均價: items.AverageUnitPrice (HKD / stock UOM), converted by item_uom ratio | |||
| * - 庫存總價值: 該 UOM 均價 * 該 UOM 現存存貨 | |||
| */ | |||
| fun searchStockBalanceReportByDate( | |||
| stockCategory: String?, | |||
| @@ -1502,238 +1570,311 @@ return result | |||
| balanceFilterEnd: String?, | |||
| storeLocation: String?, | |||
| ): List<Map<String, Any>> { | |||
| val itemArgs = mutableMapOf<String, Any>() | |||
| val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", itemArgs) | |||
| val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", itemArgs) | |||
| val hasItemCodeFilter = itemCodeSql.isNotBlank() | |||
| val args = mutableMapOf<String, Any>() | |||
| val formattedStockDate = stockDate.replace("/", "-") | |||
| val stockLocalDate = LocalDate.parse(formattedStockDate) | |||
| val useLiveInventory = !stockLocalDate.isBefore(LocalDate.now()) | |||
| val liveFlag = if (useLiveInventory) "1" else "0" | |||
| args["d0"] = stockLocalDate | |||
| args["d1"] = stockLocalDate.minusDays(1) | |||
| args["d0Start"] = stockLocalDate.atStartOfDay() | |||
| args["d0EndExclusive"] = stockLocalDate.plusDays(1).atStartOfDay() | |||
| val items = jdbcDao.queryForList( | |||
| val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) | |||
| val itemCodeSql = buildItemCodeFilterClause(itemCode, "it.code", "itemCode", args) | |||
| val itemCodeSqlDay = buildItemCodeFilterClause(itemCode, "d.itemCode", "itemCodeDay", args) | |||
| val itemCodeSqlSl = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCodeSl", args) | |||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | |||
| args["storeLocation"] = "%$storeLocation%" | |||
| """ | |||
| SELECT | |||
| it.id AS itemId, | |||
| it.code AS itemNo, | |||
| it.name AS itemName, | |||
| COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw | |||
| FROM items it | |||
| WHERE it.deleted = 0 | |||
| AND it.code IS NOT NULL AND it.code <> '' | |||
| $itemCodeSql | |||
| $stockCategorySql | |||
| ORDER BY it.code | |||
| """.trimIndent(), | |||
| itemArgs, | |||
| ) | |||
| if (items.isEmpty()) return emptyList() | |||
| val itemIds = items.mapNotNull { jdbcLong(it["itemId"]) }.distinct() | |||
| if (itemIds.isEmpty()) return emptyList() | |||
| val day = LocalDate.parse(stockDate.replace("/", "-").take(10)) | |||
| val openingCutoff = day.atStartOfDay() | |||
| val currentCutoff = day.plusDays(1).atStartOfDay() | |||
| val openingByItem = loadLatestLedgerBalances(itemIds, openingCutoff) | |||
| val currentByItem = loadLatestLedgerBalances(itemIds, currentCutoff) | |||
| val movesByItem = loadStockDateLedgerMoves(itemIds, openingCutoff, currentCutoff) | |||
| val uomByItem = loadStockUnitLabels(itemIds) | |||
| val minBalance = balanceFilterStart?.toDoubleOrNull() | |||
| val maxBalance = balanceFilterEnd?.toDoubleOrNull() | |||
| return items.mapNotNull { item -> | |||
| val itemId = jdbcLong(item["itemId"]) ?: return@mapNotNull null | |||
| val opening = openingByItem[itemId] ?: 0.0 | |||
| val current = currentByItem[itemId] ?: 0.0 | |||
| val moves = movesByItem[itemId] | |||
| val hasLedger = openingByItem.containsKey(itemId) || | |||
| currentByItem.containsKey(itemId) || | |||
| movesByItem.containsKey(itemId) | |||
| if (!hasItemCodeFilter && !hasLedger) return@mapNotNull null | |||
| if (minBalance != null && current < minBalance) return@mapNotNull null | |||
| if (maxBalance != null && current > maxBalance) return@mapNotNull null | |||
| val avg = jdbcDouble(item["avgUnitPriceRaw"]) | |||
| val cumIn = moves?.cumStockIn ?: 0.0 | |||
| val cumOut = moves?.cumStockOut ?: 0.0 | |||
| val miss = moves?.misInputAndLost ?: 0.0 | |||
| val variance = moves?.variance ?: 0.0 | |||
| val defective = moves?.defectiveGoods ?: 0.0 | |||
| mapOf( | |||
| "stockSubCategory" to "", | |||
| "itemNo" to (item["itemNo"]?.toString().orEmpty()), | |||
| "itemName" to (item["itemName"]?.toString().orEmpty()), | |||
| "unitOfMeasure" to (uomByItem[itemId] ?: ""), | |||
| "lotNo" to "", | |||
| "expiryDate" to "", | |||
| "openingBalance" to "", | |||
| "cumStockIn" to "", | |||
| "cumStockOut" to "", | |||
| "currentBalance" to "", | |||
| "reOrderQty" to "", | |||
| "storeLocation" to "", | |||
| "lastInDate" to "", | |||
| "lastOutDate" to "", | |||
| "openingBalanceRaw" to opening, | |||
| "currentBalanceRaw" to current, | |||
| "totalOpeningBalance" to formatReportQty(opening), | |||
| "totalCumStockIn" to formatReportQty(cumIn), | |||
| "totalCumStockOut" to formatReportQty(cumOut), | |||
| "totalCurrentBalance" to formatReportQty(current), | |||
| "misInputAndLost" to "", | |||
| "defectiveGoods" to "", | |||
| "variance" to "", | |||
| "totalMisInputAndLost" to formatReportQty(miss), | |||
| "totalVariance" to formatReportQty(variance), | |||
| "totalDefectiveGoods" to formatReportQty(defective), | |||
| "avgUnitPrice" to formatReportMoney(avg), | |||
| "totalStockBalance" to formatReportMoney(avg * current), | |||
| ) | |||
| AND EXISTS ( | |||
| SELECT 1 | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||
| INNER JOIN warehouse wh ON wh.id = ill.warehouseId AND wh.deleted = 0 | |||
| LEFT JOIN item_uom iu_loc ON iu_loc.id = ill.stockItemUomId AND iu_loc.deleted = 0 | |||
| WHERE ill.deleted = 0 | |||
| AND il.itemId = q.itemId | |||
| AND iu_loc.uomId <=> q.uomId | |||
| AND wh.code LIKE :storeLocation | |||
| ) | |||
| """.trimIndent() | |||
| } else "" | |||
| val forceLedgerIndex = if (itemCode.isNullOrBlank()) { | |||
| "FORCE INDEX (idx_sl_deleted_date)" | |||
| } else { | |||
| "" | |||
| } | |||
| } | |||
| private data class StockBalanceMoves( | |||
| val cumStockIn: Double, | |||
| val cumStockOut: Double, | |||
| val misInputAndLost: Double, | |||
| val defectiveGoods: Double, | |||
| val variance: Double, | |||
| ) | |||
| private fun loadLatestLedgerBalances( | |||
| itemIds: List<Long>, | |||
| cutoffExclusive: java.time.LocalDateTime, | |||
| ): Map<Long, Double> { | |||
| val out = HashMap<Long, Double>(itemIds.size) | |||
| itemIds.chunked(400).forEach { chunk -> | |||
| val args = mutableMapOf<String, Any>( | |||
| "itemIds" to ArrayList(chunk), | |||
| "cutoff" to cutoffExclusive, | |||
| ) | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| SELECT sl.itemId AS itemId, COALESCE(sl.balance, 0) AS balance | |||
| FROM stock_ledger sl | |||
| INNER JOIN ( | |||
| SELECT s.itemId AS itemId, MAX(s.id) AS maxId | |||
| FROM stock_ledger s | |||
| INNER JOIN ( | |||
| SELECT itemId, MAX(date) AS maxDate | |||
| FROM stock_ledger | |||
| WHERE deleted = 0 | |||
| AND itemId IN (:itemIds) | |||
| AND date < :cutoff | |||
| GROUP BY itemId | |||
| ) latest ON latest.itemId = s.itemId AND s.date = latest.maxDate | |||
| WHERE s.deleted = 0 | |||
| AND s.itemId IN (:itemIds) | |||
| GROUP BY s.itemId | |||
| ) t ON sl.id = t.maxId | |||
| """.trimIndent(), | |||
| args, | |||
| ) | |||
| for (row in rows) { | |||
| val id = jdbcLong(row["itemId"]) ?: continue | |||
| out[id] = jdbcDouble(row["balance"]) | |||
| } | |||
| val inventoryScopeUnion = if (useLiveInventory) { | |||
| """ | |||
| UNION | |||
| SELECT itemId, uomId FROM inventory_per_item WHERE currentBalance <> 0 | |||
| """.trimIndent() | |||
| } else { | |||
| "" | |||
| } | |||
| return out | |||
| } | |||
| private fun loadStockDateLedgerMoves( | |||
| itemIds: List<Long>, | |||
| startInclusive: java.time.LocalDateTime, | |||
| endExclusive: java.time.LocalDateTime, | |||
| ): Map<Long, StockBalanceMoves> { | |||
| val out = HashMap<Long, StockBalanceMoves>(itemIds.size) | |||
| itemIds.chunked(400).forEach { chunk -> | |||
| val args = mutableMapOf<String, Any>( | |||
| "itemIds" to ArrayList(chunk), | |||
| "startAt" to startInclusive, | |||
| "endAt" to endExclusive, | |||
| ) | |||
| val rows = jdbcDao.queryForList( | |||
| """ | |||
| val baseSql = """ | |||
| WITH day0 AS ( | |||
| SELECT | |||
| sl.itemId AS itemId, | |||
| SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.inQty, 0) > 0 THEN COALESCE(sl.inQty, 0) ELSE 0 END) AS cumStockIn, | |||
| SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS cumStockOut, | |||
| SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS misInputAndLost, | |||
| SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD' AND COALESCE(sl.outQty, 0) > 0 THEN COALESCE(sl.outQty, 0) ELSE 0 END) AS defectiveGoods, | |||
| SUM(CASE WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) ELSE 0 END) AS variance | |||
| FROM stock_ledger sl | |||
| d.itemId, | |||
| d.uomId, | |||
| SUM(COALESCE(d.opening, 0)) AS openingBalance, | |||
| SUM(COALESCE(d.closing, 0)) AS currentBalance | |||
| FROM stock_lot_day d | |||
| WHERE IFNULL(d.deleted, 0) = 0 | |||
| AND d.date = :d0 | |||
| AND d.itemId IS NOT NULL | |||
| $itemCodeSqlDay | |||
| GROUP BY d.itemId, d.uomId | |||
| ), | |||
| day1 AS ( | |||
| SELECT | |||
| d.itemId, | |||
| d.uomId, | |||
| SUM(COALESCE(d.closing, 0)) AS openingBalance | |||
| FROM stock_lot_day d | |||
| WHERE IFNULL(d.deleted, 0) = 0 | |||
| AND d.date = :d1 | |||
| AND d.itemId IS NOT NULL | |||
| $itemCodeSqlDay | |||
| GROUP BY d.itemId, d.uomId | |||
| ), | |||
| inventory_per_item AS ( | |||
| SELECT | |||
| iv.itemId, | |||
| iv.stockUomId AS uomId, | |||
| SUM(COALESCE(iv.onHandQty, 0)) AS currentBalance | |||
| FROM inventory iv | |||
| INNER JOIN items it ON it.id = iv.itemId AND it.deleted = 0 | |||
| WHERE iv.deleted = 0 | |||
| $itemCodeSql | |||
| $stockCategorySql | |||
| GROUP BY iv.itemId, iv.stockUomId | |||
| ), | |||
| ledger_moves AS ( | |||
| SELECT | |||
| sl.itemId, | |||
| sl.uomId, | |||
| SUM( | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') | |||
| AND COALESCE(sl.inQty, 0) > 0 | |||
| THEN COALESCE(sl.inQty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) AS cumStockIn, | |||
| SUM( | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) IN ('NOR', 'ADJ') | |||
| AND COALESCE(sl.outQty, 0) > 0 | |||
| THEN COALESCE(sl.outQty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) AS cumStockOut, | |||
| SUM( | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'MISS' | |||
| AND COALESCE(sl.outQty, 0) > 0 | |||
| THEN COALESCE(sl.outQty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) AS misInputAndLost, | |||
| SUM( | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'BAD' | |||
| AND COALESCE(sl.outQty, 0) > 0 | |||
| THEN COALESCE(sl.outQty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) AS defectiveGoods, | |||
| SUM( | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' | |||
| THEN COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0) | |||
| ELSE 0 | |||
| END | |||
| ) AS variance, | |||
| SUM(COALESCE(sl.inQty, 0) - COALESCE(sl.outQty, 0)) AS dayNet | |||
| FROM stock_ledger sl $forceLedgerIndex | |||
| WHERE sl.deleted = 0 | |||
| AND sl.itemId IN (:itemIds) | |||
| AND sl.date >= :startAt | |||
| AND sl.date < :endAt | |||
| GROUP BY sl.itemId | |||
| """.trimIndent(), | |||
| args, | |||
| AND sl.itemId IS NOT NULL | |||
| AND sl.date >= :d0Start | |||
| AND sl.date < :d0EndExclusive | |||
| $itemCodeSqlSl | |||
| GROUP BY sl.itemId, sl.uomId | |||
| ), | |||
| item_ids AS ( | |||
| SELECT itemId, uomId FROM day0 | |||
| UNION | |||
| SELECT itemId, uomId FROM day1 | |||
| UNION | |||
| SELECT itemId, uomId FROM ledger_moves | |||
| $inventoryScopeUnion | |||
| ), | |||
| item_scope AS ( | |||
| SELECT | |||
| it.id AS itemId, | |||
| it.code AS itemNo, | |||
| it.name AS itemName, | |||
| COALESCE(CAST(NULLIF(it.AverageUnitPrice, '') AS DECIMAL(14, 4)), 0) AS avgUnitPriceRaw | |||
| FROM items it | |||
| WHERE it.deleted = 0 | |||
| AND it.code IS NOT NULL AND it.code <> '' | |||
| AND it.id IN (SELECT DISTINCT itemId FROM item_ids) | |||
| $itemCodeSql | |||
| $stockCategorySql | |||
| ), | |||
| stock_one AS ( | |||
| SELECT iu.* | |||
| FROM item_uom iu | |||
| INNER JOIN ( | |||
| SELECT itemId, MIN(id) AS id | |||
| FROM item_uom | |||
| WHERE deleted = 0 AND stockUnit = 1 | |||
| GROUP BY itemId | |||
| ) x ON x.id = iu.id | |||
| ), | |||
| qty_per_item AS ( | |||
| SELECT | |||
| ids.itemId, | |||
| ids.uomId, | |||
| COALESCE( | |||
| CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, | |||
| d1.openingBalance, | |||
| 0 | |||
| ) AS openingBalance, | |||
| COALESCE( | |||
| CASE WHEN $liveFlag = 1 THEN invc.currentBalance ELSE d0.currentBalance END, | |||
| COALESCE( | |||
| CASE WHEN $liveFlag = 1 THEN d1.openingBalance ELSE d0.openingBalance END, | |||
| d1.openingBalance, | |||
| 0 | |||
| ) + COALESCE(lm.dayNet, 0), | |||
| 0 | |||
| ) AS currentBalance, | |||
| COALESCE(lm.cumStockIn, 0) AS cumStockIn, | |||
| COALESCE(lm.cumStockOut, 0) AS cumStockOut, | |||
| COALESCE(lm.misInputAndLost, 0) AS misInputAndLost, | |||
| COALESCE(lm.variance, 0) AS variance, | |||
| COALESCE(lm.defectiveGoods, 0) AS defectiveGoods | |||
| FROM item_ids ids | |||
| LEFT JOIN day0 d0 ON d0.itemId = ids.itemId AND d0.uomId <=> ids.uomId | |||
| LEFT JOIN day1 d1 ON d1.itemId = ids.itemId AND d1.uomId <=> ids.uomId | |||
| LEFT JOIN inventory_per_item invc ON invc.itemId = ids.itemId AND invc.uomId <=> ids.uomId | |||
| LEFT JOIN ledger_moves lm ON lm.itemId = ids.itemId AND lm.uomId <=> ids.uomId | |||
| ) | |||
| for (row in rows) { | |||
| val id = jdbcLong(row["itemId"]) ?: continue | |||
| out[id] = StockBalanceMoves( | |||
| cumStockIn = jdbcDouble(row["cumStockIn"]), | |||
| cumStockOut = jdbcDouble(row["cumStockOut"]), | |||
| misInputAndLost = jdbcDouble(row["misInputAndLost"]), | |||
| defectiveGoods = jdbcDouble(row["defectiveGoods"]), | |||
| variance = jdbcDouble(row["variance"]), | |||
| ) | |||
| } | |||
| SELECT | |||
| '' AS stockSubCategory, | |||
| COALESCE(s.itemNo, '') AS itemNo, | |||
| COALESCE(s.itemName, '') AS itemName, | |||
| COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure, | |||
| '' AS lotNo, | |||
| '' AS expiryDate, | |||
| '' AS openingBalance, | |||
| '' AS cumStockIn, | |||
| '' AS cumStockOut, | |||
| '' AS currentBalance, | |||
| '' AS reOrderQty, | |||
| '' AS storeLocation, | |||
| '' AS lastInDate, | |||
| '' AS lastOutDate, | |||
| q.openingBalance AS openingBalanceRaw, | |||
| q.currentBalance AS currentBalanceRaw, | |||
| CASE WHEN q.openingBalance < 0 THEN CONCAT('(', FORMAT(-q.openingBalance, 0), ')') ELSE FORMAT(q.openingBalance, 0) END AS totalOpeningBalance, | |||
| CASE WHEN q.cumStockIn < 0 THEN CONCAT('(', FORMAT(-q.cumStockIn, 0), ')') ELSE FORMAT(q.cumStockIn, 0) END AS totalCumStockIn, | |||
| CASE WHEN q.cumStockOut < 0 THEN CONCAT('(', FORMAT(-q.cumStockOut, 0), ')') ELSE FORMAT(q.cumStockOut, 0) END AS totalCumStockOut, | |||
| CASE WHEN q.currentBalance < 0 THEN CONCAT('(', FORMAT(-q.currentBalance, 0), ')') ELSE FORMAT(q.currentBalance, 0) END AS totalCurrentBalance, | |||
| '' AS misInputAndLost, | |||
| '' AS defectiveGoods, | |||
| '' AS variance, | |||
| CASE WHEN q.misInputAndLost < 0 THEN CONCAT('(', FORMAT(-q.misInputAndLost, 0), ')') ELSE FORMAT(q.misInputAndLost, 0) END AS totalMisInputAndLost, | |||
| CASE WHEN q.variance < 0 THEN CONCAT('(', FORMAT(-q.variance, 0), ')') ELSE FORMAT(q.variance, 0) END AS totalVariance, | |||
| CASE WHEN q.defectiveGoods < 0 THEN CONCAT('(', FORMAT(-q.defectiveGoods, 0), ')') ELSE FORMAT(q.defectiveGoods, 0) END AS totalDefectiveGoods, | |||
| FORMAT(ROUND( | |||
| CASE | |||
| WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0) | |||
| WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0) | |||
| ELSE ROUND( | |||
| COALESCE(s.avgUnitPriceRaw, 0) | |||
| * (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1)) | |||
| * (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)), | |||
| 4 | |||
| ) | |||
| END | |||
| , 2), 2) AS avgUnitPrice, | |||
| FORMAT(ROUND( | |||
| CASE | |||
| WHEN iu.id IS NULL OR so.id IS NULL THEN COALESCE(s.avgUnitPriceRaw, 0) | |||
| WHEN iu.uomId <=> so.uomId THEN COALESCE(s.avgUnitPriceRaw, 0) | |||
| ELSE ROUND( | |||
| COALESCE(s.avgUnitPriceRaw, 0) | |||
| * (COALESCE(iu.ratioN, 1) / COALESCE(NULLIF(iu.ratioD, 0), 1)) | |||
| * (COALESCE(so.ratioD, 1) / COALESCE(NULLIF(so.ratioN, 0), 1)), | |||
| 4 | |||
| ) | |||
| END | |||
| * COALESCE(q.currentBalance, 0) | |||
| , 2), 2) AS totalStockBalance | |||
| FROM qty_per_item q | |||
| INNER JOIN item_scope s ON s.itemId = q.itemId | |||
| LEFT JOIN stock_one so ON so.itemId = q.itemId | |||
| LEFT JOIN item_uom iu | |||
| ON iu.itemId = q.itemId | |||
| AND iu.deleted = 0 | |||
| AND iu.uomId <=> q.uomId | |||
| AND iu.id = ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = q.itemId | |||
| AND iu2.deleted = 0 | |||
| AND iu2.uomId <=> q.uomId | |||
| ) | |||
| LEFT JOIN uom_conversion uc ON uc.id = COALESCE(iu.uomId, q.uomId) | |||
| WHERE 1=1 | |||
| $storeLocationSql | |||
| """.trimIndent() | |||
| val filters = mutableListOf<String>() | |||
| if (!balanceFilterStart.isNullOrBlank()) { | |||
| args["balanceFilterStart"] = balanceFilterStart.toDoubleOrNull() ?: 0.0 | |||
| filters.add("COALESCE(currentBalanceRaw, 0) >= :balanceFilterStart") | |||
| } | |||
| if (!balanceFilterEnd.isNullOrBlank()) { | |||
| args["balanceFilterEnd"] = balanceFilterEnd.toDoubleOrNull() ?: 0.0 | |||
| filters.add("COALESCE(currentBalanceRaw, 0) <= :balanceFilterEnd") | |||
| } | |||
| return out | |||
| } | |||
| private fun loadStockUnitLabels(itemIds: List<Long>): Map<Long, String> { | |||
| val out = HashMap<Long, String>(itemIds.size) | |||
| itemIds.chunked(400).forEach { chunk -> | |||
| val args = mutableMapOf<String, Any>("itemIds" to ArrayList(chunk)) | |||
| val rows = jdbcDao.queryForList( | |||
| val finalSql = | |||
| if (filters.isEmpty()) { | |||
| baseSql | |||
| } else { | |||
| """ | |||
| SELECT iu.itemId AS itemId, COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure | |||
| FROM item_uom iu | |||
| LEFT JOIN uom_conversion uc ON iu.uomId = uc.id | |||
| WHERE iu.deleted = 0 | |||
| AND iu.stockUnit = 1 | |||
| AND iu.itemId IN (:itemIds) | |||
| """.trimIndent(), | |||
| args, | |||
| ) | |||
| for (row in rows) { | |||
| val id = jdbcLong(row["itemId"]) ?: continue | |||
| out[id] = row["unitOfMeasure"]?.toString().orEmpty() | |||
| SELECT * FROM ( | |||
| $baseSql | |||
| ) base | |||
| WHERE ${filters.joinToString(" AND ")} | |||
| """.trimIndent() | |||
| } | |||
| } | |||
| return out | |||
| } | |||
| private fun jdbcLong(value: Any?): Long? = when (value) { | |||
| null -> null | |||
| is Number -> value.toLong() | |||
| else -> value.toString().toLongOrNull() | |||
| } | |||
| private fun jdbcDouble(value: Any?): Double = when (value) { | |||
| null -> 0.0 | |||
| is Number -> value.toDouble() | |||
| else -> value.toString().replace(",", "").toDoubleOrNull() ?: 0.0 | |||
| } | |||
| private fun formatReportQty(value: Double): String { | |||
| val fmt = DecimalFormat("#,##0", DecimalFormatSymbols(Locale.US)) | |||
| return if (value < 0) "(${fmt.format(-value)})" else fmt.format(value) | |||
| val t0 = System.nanoTime() | |||
| val rows = jdbcDao.queryForList("$finalSql ORDER BY itemNo, unitOfMeasure", args) | |||
| log.info( | |||
| "stock-balance-by-date date={} liveInventory={} itemCode={} rows={} {}ms", | |||
| stockLocalDate, | |||
| useLiveInventory, | |||
| itemCode ?: "", | |||
| rows.size, | |||
| (System.nanoTime() - t0) / 1_000_000, | |||
| ) | |||
| return rows | |||
| } | |||
| private fun formatReportMoney(value: Double): String { | |||
| val fmt = DecimalFormat("#,##0.00", DecimalFormatSymbols(Locale.US)) | |||
| return fmt.format(kotlin.math.round(value * 100.0) / 100.0) | |||
| } | |||
| /** | |||
| * Compiles and fills a Jasper Report, then exports to Excel (.xlsx). Same layout/columns as the report template. | |||
| */ | |||
| fun createExcelResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray { | |||
| val stream = this::class.java.getResourceAsStream(templatePath) | |||
| ?: throw RuntimeException("Report template not found: $templatePath") | |||
| val jasperReport = JasperCompileManager.compileReport(stream) | |||
| val jasperReport = compiledReport(templatePath) | |||
| val dataSource = JRMapCollectionDataSource(dataList) | |||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | |||
| val out = ByteArrayOutputStream() | |||
| @@ -1748,17 +1889,20 @@ return result | |||
| * Compiles and fills a Jasper Report, returning the PDF as a ByteArray. | |||
| */ | |||
| fun createPdfResponse(templatePath: String, params: Map<String, Any>, dataList: List<Map<String, Any>>): ByteArray { | |||
| val stream = this::class.java.getResourceAsStream(templatePath) | |||
| ?: throw RuntimeException("Report template not found: $templatePath") | |||
| val jasperReport = JasperCompileManager.compileReport(stream) | |||
| val jasperReport = compiledReport(templatePath) | |||
| val dataSource = JRMapCollectionDataSource(dataList) | |||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | |||
| return JasperExportManager.exportReportToPdf(jasperPrint) | |||
| } | |||
| private fun compiledReport(templatePath: String): JasperReport { | |||
| return compiledReports.computeIfAbsent(templatePath) { | |||
| val stream = this::class.java.getResourceAsStream(templatePath) | |||
| ?: throw RuntimeException("Report template not found: $templatePath") | |||
| stream.use { JasperCompileManager.compileReport(it) } | |||
| } | |||
| } | |||
| fun mergePdfBytes(parts: List<ByteArray>): ByteArray { | |||
| if (parts.isEmpty()) return ByteArray(0) | |||
| if (parts.size == 1) return parts[0] | |||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.report.service | |||
| import org.springframework.stereotype.Service | |||
| import com.ffii.core.support.JdbcDao | |||
| import java.time.LocalDate | |||
| @Service | |||
| class SemiFGProductionAnalysisReportService( | |||
| @@ -199,6 +200,122 @@ class SemiFGProductionAnalysisReportService( | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| /** | |||
| * Same filters as [searchSemiFGProductionAnalysisReport], one row per item and production date. | |||
| * Quantity is not pivoted; the Excel builder places each date in the requested range as its own column. | |||
| */ | |||
| fun searchSemiFGProductionAnalysisDaily( | |||
| stockCategory: String?, | |||
| stockSubCategory: String?, | |||
| itemCode: String?, | |||
| year: String?, | |||
| rangeStart: LocalDate, | |||
| rangeEnd: LocalDate, | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val stockCategorySql = if (!itemCode.isNullOrBlank()) { | |||
| "" | |||
| } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | |||
| buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args) | |||
| } else { | |||
| "" | |||
| } | |||
| val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) | |||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args) | |||
| val yearSql = if (!year.isNullOrBlank() && year != "All") { | |||
| args["year"] = year | |||
| "AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year" | |||
| } else { | |||
| "" | |||
| } | |||
| args["lastOutDateStart"] = rangeStart.toString() | |||
| args["lastOutDateEnd"] = rangeEnd.toString() | |||
| val lastOutDateStartSql = | |||
| "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)" | |||
| val lastOutDateEndSql = | |||
| "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)" | |||
| val sql = """ | |||
| WITH qr_agg AS ( | |||
| SELECT | |||
| qr.stockInLineId, | |||
| MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed | |||
| FROM qc_result qr | |||
| WHERE qr.deleted = 0 | |||
| GROUP BY qr.stockInLineId | |||
| ), | |||
| base AS ( | |||
| SELECT | |||
| COALESCE(it.code, '') AS itemNo, | |||
| COALESCE(it.name, '') AS itemName, | |||
| COALESCE(ic.sub, '') AS stockSubCategory, | |||
| COALESCE(uc.udfudesc, '') AS unitOfMeasure, | |||
| DATE( | |||
| CASE | |||
| WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate | |||
| ELSE si.productionDate | |||
| END | |||
| ) AS prodDate, | |||
| si.id AS stockInLineId, | |||
| CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 | |||
| ELSE COALESCE(si.acceptedQty, 0) | |||
| END AS linePutAwayQty | |||
| FROM stock_in_line si | |||
| INNER JOIN items it ON si.itemId = it.id | |||
| INNER JOIN bom b ON b.code = it.code AND b.deleted = false | |||
| LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false | |||
| LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id | |||
| LEFT JOIN item_category ic ON it.categoryId = ic.id | |||
| LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true | |||
| LEFT JOIN uom_conversion uc ON iu.uomId = uc.id | |||
| WHERE si.deleted = false | |||
| AND si.productLotNo IS NOT NULL | |||
| AND TRIM(si.productLotNo) <> '' | |||
| AND ( | |||
| CASE | |||
| WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate | |||
| ELSE si.productionDate | |||
| END | |||
| ) IS NOT NULL | |||
| AND si.jobOrderId IS NOT NULL | |||
| AND (si.status IS NULL OR si.status <> 'Pending') | |||
| $stockCategorySql | |||
| $stockSubCategorySql | |||
| $itemCodeSql | |||
| $yearSql | |||
| $lastOutDateStartSql | |||
| $lastOutDateEndSql | |||
| ), | |||
| dedup AS ( | |||
| SELECT | |||
| itemNo, | |||
| itemName, | |||
| stockSubCategory, | |||
| unitOfMeasure, | |||
| prodDate, | |||
| stockInLineId, | |||
| MAX(linePutAwayQty) AS linePutAwayQty | |||
| FROM base | |||
| GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, prodDate, stockInLineId | |||
| ) | |||
| SELECT | |||
| d.itemNo AS itemNo, | |||
| MAX(d.itemName) AS itemName, | |||
| MAX(d.unitOfMeasure) AS unitOfMeasure, | |||
| d.prodDate AS prodDate, | |||
| CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS DECIMAL(18,2)) AS qty | |||
| FROM dedup d | |||
| GROUP BY d.itemNo, d.prodDate | |||
| HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0 | |||
| ORDER BY d.itemNo, d.prodDate | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| /** | |||
| * Gets list of item codes (bom.code) with names based on stockCategory filter. | |||
| * Supports multiple categories separated by comma (e.g., "FG,WIP"). | |||
| @@ -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 | |||
| import com.ffii.core.support.JdbcDao | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.stereotype.Service | |||
| import java.math.BigDecimal | |||
| import java.math.RoundingMode | |||
| import java.time.LocalDate | |||
| import java.time.format.DateTimeFormatter | |||
| @@ -9,7 +12,7 @@ import java.time.format.DateTimeFormatter | |||
| open class StockLedgerReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| private val log = LoggerFactory.getLogger(StockLedgerReportService::class.java) | |||
| private val dateFmt = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |||
| /** | |||
| @@ -18,11 +21,12 @@ open class StockLedgerReportService( | |||
| * | |||
| * - stockSubCategory = items.type | |||
| * - trnDate = stock_ledger.date | |||
| * - trnRefNo = stock_ledger.type | |||
| * - cumBalance = stock_ledger.balance(異動後纍計存量) | |||
| * - cumOpeningBal = balance - inQty + outQty(異動前纍計期初) | |||
| * - trnRefNo = stock_ledger.type(ADJ 且關聯盤點單時改為盤點) | |||
| * - cumBalance = stock_ledger.balance(異動後貨品纍計存量) | |||
| * - cumOpeningBal = balance - inQty + outQty(異動前貨品纍計) | |||
| * | |||
| * 只查 [start, end] 期間列,不掃起日以前全歷史。 | |||
| * 帳本先依日期區間撈窄欄,批號/單據再依這段出現的 id 分批補。 | |||
| * 餘額用帳本上的貨品纍計,排序為貨品 → 時間 → id。 | |||
| */ | |||
| fun searchStockLedgerReport( | |||
| stockCategory: String?, | |||
| @@ -31,263 +35,587 @@ open class StockLedgerReportService( | |||
| reportPeriodStart: String?, | |||
| reportPeriodEnd: String?, | |||
| ): List<Map<String, Any>> { | |||
| val tAll = System.nanoTime() | |||
| val timings = mutableListOf<String>() | |||
| fun <T> timed(name: String, extra: (T) -> String, block: () -> T): T { | |||
| val t0 = System.nanoTime() | |||
| val result = block() | |||
| val line = "$name ${extra(result)} ${(System.nanoTime() - t0) / 1_000_000}ms" | |||
| timings.add(line) | |||
| log.info("stock-ledger {}", line) | |||
| return result | |||
| } | |||
| val args = mutableMapOf<String, Any>() | |||
| val reportPeriodEnd = (reportPeriodEnd?.replace("/", "-") | |||
| val endDate = (reportPeriodEnd?.replace("/", "-") | |||
| ?: LocalDate.now().format(dateFmt)) | |||
| val reportPeriodStart = if (!reportPeriodStart.isNullOrBlank()) { | |||
| val filterArgs = mutableMapOf<String, Any>() | |||
| val itemPredicate = ledgerItemPredicate(itemCode, filterArgs) | |||
| val startDate = if (!reportPeriodStart.isNullOrBlank()) { | |||
| reportPeriodStart.replace("/", "-") | |||
| } else { | |||
| val minDateSql = """ | |||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | |||
| FROM stock_ledger sl | |||
| earliestLedgerDate(itemPredicate, filterArgs) ?: endDate | |||
| } | |||
| val endExclusive = LocalDate.parse(endDate).plusDays(1).format(dateFmt) | |||
| val args = mutableMapOf<String, Any>( | |||
| "reportPeriodStart" to LocalDate.parse(startDate).atStartOfDay(), | |||
| "reportPeriodEndExclusive" to LocalDate.parse(endExclusive).atStartOfDay(), | |||
| ) | |||
| args.putAll(filterArgs) | |||
| val categorySql = buildCategoryItemFilter(stockCategory, args) | |||
| val moves = timed("1.ledger", { rows: List<LedgerMove> -> "rows=${rows.size}" }) { | |||
| jdbcDao.queryForList( | |||
| """ | |||
| SELECT | |||
| sl.id AS slId, | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, | |||
| TRIM(COALESCE(sl.type, '')) AS rawType, | |||
| sl.itemId AS itemId, | |||
| sl.itemCode AS itemCode, | |||
| COALESCE(sl.inQty, 0) AS inQty, | |||
| COALESCE(sl.outQty, 0) AS outQty, | |||
| COALESCE(sl.balance, 0) AS bal, | |||
| sl.uomId AS ledgerUomId, | |||
| sl.inventoryLotLineId AS inventoryLotLineId, | |||
| sl.stockInLineId AS stockInLineId, | |||
| sl.stockOutLineId AS stockOutLineId | |||
| FROM stock_ledger sl ${itemPredicate.indexHint} | |||
| WHERE sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| """.trimIndent() | |||
| AND sl.date >= :reportPeriodStart | |||
| AND sl.date < :reportPeriodEndExclusive | |||
| ${itemPredicate.sql} | |||
| $categorySql | |||
| """.trimIndent(), | |||
| args, | |||
| ).map { r -> | |||
| LedgerMove( | |||
| slId = toLong(r["slId"]), | |||
| trnDate = r["trnDate"]?.toString().orEmpty(), | |||
| rawType = r["rawType"]?.toString().orEmpty(), | |||
| itemId = toLong(r["itemId"]), | |||
| itemCode = r["itemCode"]?.toString().orEmpty(), | |||
| inQty = toDouble(r["inQty"]), | |||
| outQty = toDouble(r["outQty"]), | |||
| bal = toDouble(r["bal"]), | |||
| ledgerUomId = toLong(r["ledgerUomId"]), | |||
| lotLineId = toLong(r["inventoryLotLineId"]), | |||
| stockInLineId = toLong(r["stockInLineId"]), | |||
| stockOutLineId = toLong(r["stockOutLineId"]), | |||
| ) | |||
| } | |||
| } | |||
| if (moves.isEmpty()) { | |||
| log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | ")) | |||
| return emptyList() | |||
| } | |||
| val lineByIn = HashMap<Long, Pair<Long, Long>>() | |||
| val lineByOut = HashMap<Long, Long>() | |||
| timed("2.lot_keys", { _: Unit -> | |||
| "in=${lineByIn.size} out=${lineByOut.size}" | |||
| }) { | |||
| val missingIn = moves.filter { it.lotLineId <= 0 && it.stockInLineId > 0 } | |||
| .map { it.stockInLineId } | |||
| val missingOut = moves.filter { it.lotLineId <= 0 && it.stockOutLineId > 0 } | |||
| .map { it.stockOutLineId } | |||
| for (r in queryChunks( | |||
| missingIn, | |||
| """ | |||
| SELECT id, inventoryLotLineId, inventoryLotId | |||
| FROM stock_in_line | |||
| WHERE deleted = 0 AND id IN (:ids) | |||
| """.trimIndent(), | |||
| )) { | |||
| lineByIn[toLong(r["id"])] = toLong(r["inventoryLotLineId"]) to toLong(r["inventoryLotId"]) | |||
| } | |||
| for (r in queryChunks( | |||
| missingOut, | |||
| """ | |||
| SELECT id, inventoryLotLineId | |||
| FROM stock_out_line | |||
| WHERE deleted = 0 AND id IN (:ids) | |||
| """.trimIndent(), | |||
| )) { | |||
| lineByOut[toLong(r["id"])] = toLong(r["inventoryLotLineId"]) | |||
| } | |||
| } | |||
| data class LotKey(val lineId: Long, val lotId: Long) | |||
| val lotKeyBySl = HashMap<Long, LotKey>(moves.size * 2) | |||
| for (m in moves) { | |||
| val fromIn = lineByIn[m.stockInLineId] | |||
| val inLine = fromIn?.first ?: 0L | |||
| val inLot = fromIn?.second ?: 0L | |||
| val outLine = lineByOut[m.stockOutLineId] ?: 0L | |||
| val lineId = when { | |||
| m.lotLineId > 0 -> m.lotLineId | |||
| inLine > 0 -> inLine | |||
| inLot > 0 -> 0L | |||
| else -> outLine | |||
| } | |||
| val lotId = if (lineId <= 0 && inLot > 0) inLot else 0L | |||
| if (lineId > 0 || lotId > 0) { | |||
| lotKeyBySl[m.slId] = LotKey(lineId, lotId) | |||
| } | |||
| } | |||
| val lotByLine = HashMap<Long, LotInfo>() | |||
| val lotByHeader = HashMap<Long, LotInfo>() | |||
| timed("3.lots", { _: Unit -> "lines=${lotByLine.size} headers=${lotByHeader.size}" }) { | |||
| for (r in queryChunks( | |||
| lotKeyBySl.values.map { it.lineId }, | |||
| """ | |||
| SELECT | |||
| ill.id AS lineId, | |||
| COALESCE(il.lotNo, '') AS lotNo, | |||
| COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||
| COALESCE(wh.code, '') AS storeLocation, | |||
| COALESCE(uc.udfudesc, uc.code, '') AS unitOfMeasure | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il | |||
| ON il.id = ill.inventoryLotId AND il.deleted = 0 | |||
| LEFT JOIN warehouse wh | |||
| ON wh.id = ill.warehouseId AND wh.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON iu.id = ill.stockItemUomId AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON uc.id = iu.uomId | |||
| WHERE ill.deleted = 0 | |||
| AND ill.id IN (:ids) | |||
| """.trimIndent(), | |||
| )) { | |||
| lotByLine[toLong(r["lineId"])] = LotInfo( | |||
| lotNo = r["lotNo"]?.toString().orEmpty(), | |||
| expiryDate = r["expiryDate"]?.toString().orEmpty(), | |||
| storeLocation = r["storeLocation"]?.toString().orEmpty(), | |||
| unitOfMeasure = r["unitOfMeasure"]?.toString().orEmpty(), | |||
| ) | |||
| } | |||
| val headerIds = lotKeyBySl.values | |||
| .filter { it.lineId <= 0 && it.lotId > 0 } | |||
| .map { it.lotId } | |||
| for (r in queryChunks( | |||
| headerIds, | |||
| """ | |||
| SELECT | |||
| il.id AS lotId, | |||
| COALESCE(il.lotNo, '') AS lotNo, | |||
| COALESCE(DATE_FORMAT(il.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||
| COALESCE(MAX(wh.code), '') AS storeLocation, | |||
| COALESCE(MAX(uc.udfudesc), MAX(uc.code), '') AS unitOfMeasure | |||
| FROM inventory_lot il | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON ill.inventoryLotId = il.id AND ill.deleted = 0 | |||
| LEFT JOIN warehouse wh | |||
| ON wh.id = ill.warehouseId AND wh.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON iu.id = ill.stockItemUomId AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON uc.id = iu.uomId | |||
| WHERE il.deleted = 0 | |||
| AND il.id IN (:ids) | |||
| GROUP BY il.id, il.lotNo, il.expiryDate | |||
| """.trimIndent(), | |||
| )) { | |||
| lotByHeader[toLong(r["lotId"])] = LotInfo( | |||
| lotNo = r["lotNo"]?.toString().orEmpty(), | |||
| expiryDate = r["expiryDate"]?.toString().orEmpty(), | |||
| storeLocation = r["storeLocation"]?.toString().orEmpty(), | |||
| unitOfMeasure = r["unitOfMeasure"]?.toString().orEmpty(), | |||
| ) | |||
| } | |||
| } | |||
| val minDateRow = jdbcDao.queryForList(minDateSql, emptyMap<String, Any>()).firstOrNull() | |||
| (minDateRow?.get("firstDate") as? String) | |||
| ?: reportPeriodEnd | |||
| val uomDescById = HashMap<Long, String>() | |||
| val missingUomIds = ArrayList<Long>() | |||
| for (m in moves) { | |||
| val key = lotKeyBySl[m.slId] ?: continue | |||
| val lot = if (key.lineId > 0) lotByLine[key.lineId] else lotByHeader[key.lotId] | |||
| if (lot != null && lot.unitOfMeasure.isBlank() && m.ledgerUomId > 0) { | |||
| missingUomIds.add(m.ledgerUomId) | |||
| } | |||
| } | |||
| for (r in queryChunks( | |||
| missingUomIds, | |||
| """ | |||
| SELECT id AS uomId, COALESCE(udfudesc, code, '') AS unitOfMeasure | |||
| FROM uom_conversion | |||
| WHERE id IN (:ids) | |||
| """.trimIndent(), | |||
| )) { | |||
| uomDescById[toLong(r["uomId"])] = r["unitOfMeasure"]?.toString().orEmpty() | |||
| } | |||
| val endExclusive = LocalDate.parse(reportPeriodEnd).plusDays(1).format(dateFmt) | |||
| args["reportPeriodStart"] = reportPeriodStart | |||
| args["reportPeriodEndExclusive"] = endExclusive | |||
| val locationFilter = storeLocation?.trim().orEmpty() | |||
| val withLot = ArrayList<MoveWithLot>(moves.size) | |||
| for (m in moves) { | |||
| val key = lotKeyBySl[m.slId] ?: continue | |||
| val lot = if (key.lineId > 0) lotByLine[key.lineId] else lotByHeader[key.lotId] | |||
| if (lot == null) continue | |||
| if (locationFilter.isNotEmpty() && | |||
| !lot.storeLocation.contains(locationFilter, ignoreCase = true) | |||
| ) { | |||
| continue | |||
| } | |||
| val uom = lot.unitOfMeasure.ifBlank { uomDescById[m.ledgerUomId].orEmpty() } | |||
| withLot.add(MoveWithLot(m, lot, uom)) | |||
| } | |||
| timings.add("4.filter rows=${withLot.size}") | |||
| if (withLot.isEmpty()) { | |||
| log.info("stock-ledger DONE rows=0 {}", timings.joinToString(" | ")) | |||
| return emptyList() | |||
| } | |||
| val stockCategorySql = buildMultiValueExactClause( | |||
| stockCategory, | |||
| "it.type", | |||
| "stockCategory", | |||
| args | |||
| data class ItemInfo( | |||
| val type: String, | |||
| val code: String, | |||
| val name: String, | |||
| ) | |||
| val items = HashMap<Long, ItemInfo>() | |||
| timed("5.items", { _: Unit -> "items=${items.size}" }) { | |||
| for (r in queryChunks( | |||
| withLot.map { it.move.itemId }, | |||
| """ | |||
| SELECT | |||
| it.id AS itemId, | |||
| COALESCE(it.type, '') AS stockSubCategory, | |||
| COALESCE(it.code, '') AS itemNo, | |||
| COALESCE(it.name, '') AS itemName | |||
| FROM items it | |||
| WHERE it.deleted = 0 | |||
| AND it.id IN (:ids) | |||
| """.trimIndent(), | |||
| )) { | |||
| items[toLong(r["itemId"])] = ItemInfo( | |||
| type = r["stockSubCategory"]?.toString().orEmpty(), | |||
| code = r["itemNo"]?.toString().orEmpty(), | |||
| name = r["itemName"]?.toString().orEmpty(), | |||
| ) | |||
| } | |||
| } | |||
| data class InRef(val orderRefNo: String?, val jobOrderNo: String, val stockTake: Boolean) | |||
| data class OutRef(val orderRefNo: String?, val stockTake: Boolean) | |||
| val inRefs = HashMap<Long, InRef>() | |||
| val outRefs = HashMap<Long, OutRef>() | |||
| timed("6.refs", { _: Unit -> "in=${inRefs.size} out=${outRefs.size}" }) { | |||
| for (r in queryChunks( | |||
| withLot.map { it.move.stockOutLineId }, | |||
| """ | |||
| SELECT | |||
| sol.id AS lineId, | |||
| CASE | |||
| WHEN po_out.type = 'consumable' THEN TRIM(po_out.code) | |||
| ELSE COALESCE(TRIM(do.code), TRIM(jo_po.code)) | |||
| END AS orderRefNo, | |||
| CASE | |||
| WHEN so.stockTakeId IS NOT NULL OR so.type = 'stockTake' THEN 1 | |||
| ELSE 0 | |||
| END AS isStockTake | |||
| FROM stock_out_line sol | |||
| LEFT JOIN stock_out so | |||
| ON so.id = sol.stockOutId AND so.deleted = 0 | |||
| LEFT JOIN pick_order_line pol | |||
| ON pol.id = sol.pickOrderLineId AND pol.deleted = 0 | |||
| LEFT JOIN pick_order po_out | |||
| ON po_out.id = pol.poId AND po_out.deleted = 0 | |||
| LEFT JOIN job_order jo_po | |||
| ON jo_po.id = po_out.joId AND jo_po.deleted = 0 | |||
| LEFT JOIN delivery_order do | |||
| ON do.id = po_out.doId AND do.deleted = 0 | |||
| WHERE sol.deleted = 0 | |||
| AND sol.id IN (:ids) | |||
| """.trimIndent(), | |||
| )) { | |||
| outRefs[toLong(r["lineId"])] = OutRef( | |||
| orderRefNo = r["orderRefNo"]?.toString(), | |||
| stockTake = toLong(r["isStockTake"]) == 1L, | |||
| ) | |||
| } | |||
| for (r in queryChunks( | |||
| withLot.map { it.move.stockInLineId }, | |||
| """ | |||
| SELECT | |||
| sil.id AS lineId, | |||
| COALESCE(TRIM(jo.code), TRIM(po.code)) AS orderRefNo, | |||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo, | |||
| CASE WHEN si.stockTakeId IS NOT NULL THEN 1 ELSE 0 END AS isStockTake | |||
| FROM stock_in_line sil | |||
| LEFT JOIN stock_in si | |||
| ON si.id = sil.stockInId AND si.deleted = 0 | |||
| LEFT JOIN job_order jo | |||
| ON jo.id = sil.jobOrderId AND jo.deleted = 0 | |||
| LEFT JOIN purchase_order po | |||
| ON po.id = sil.purchaseOrderId AND po.deleted = 0 | |||
| WHERE sil.deleted = 0 | |||
| AND sil.id IN (:ids) | |||
| """.trimIndent(), | |||
| )) { | |||
| inRefs[toLong(r["lineId"])] = InRef( | |||
| orderRefNo = r["orderRefNo"]?.toString(), | |||
| jobOrderNo = r["jobOrderNo"]?.toString().orEmpty(), | |||
| stockTake = toLong(r["isStockTake"]) == 1L, | |||
| ) | |||
| } | |||
| } | |||
| val totals = HashMap<String, ItemTotal>() | |||
| for (row in withLot) { | |||
| val m = row.move | |||
| val total = totals.getOrPut("${m.itemCode}|${row.unitOfMeasure}") { ItemTotal() } | |||
| total.inSum += m.inQty | |||
| total.outSum += m.outQty | |||
| val opening = m.bal - m.inQty + m.outQty | |||
| if (total.isBeforeFirst(m.trnDate, m.slId)) { | |||
| total.firstDate = m.trnDate | |||
| total.firstId = m.slId | |||
| total.opening = opening | |||
| } | |||
| if (total.isAfterLast(m.trnDate, m.slId)) { | |||
| total.lastDate = m.trnDate | |||
| total.lastId = m.slId | |||
| total.closing = m.bal | |||
| } | |||
| } | |||
| val rows = timed("7.assemble", { list: List<Map<String, Any>> -> "rows=${list.size}" }) { | |||
| val sorted = withLot.sortedWith( | |||
| compareBy<MoveWithLot> { items[it.move.itemId]?.code.orEmpty() } | |||
| .thenBy { it.unitOfMeasure } | |||
| .thenBy { it.move.trnDate } | |||
| .thenBy { it.move.slId } | |||
| .thenBy { it.lot.lotNo }, | |||
| ) | |||
| sorted.map { row -> | |||
| val m = row.move | |||
| val item = items[m.itemId] | |||
| val inRef = inRefs[m.stockInLineId] | |||
| val outRef = outRefs[m.stockOutLineId] | |||
| val norm = m.rawType.uppercase() | |||
| val refType = when { | |||
| norm == "TKE" -> "TKE" | |||
| norm == "ADJ" && (outRef?.stockTake == true || inRef?.stockTake == true) -> "TKE" | |||
| else -> m.rawType | |||
| } | |||
| val total = totals["${m.itemCode}|${row.unitOfMeasure}"] | |||
| reportRow( | |||
| stockSubCategory = item?.type.orEmpty(), | |||
| itemNo = item?.code.orEmpty(), | |||
| itemName = item?.name.orEmpty(), | |||
| unitOfMeasure = row.unitOfMeasure, | |||
| lotNo = row.lot.lotNo, | |||
| expiryDate = row.lot.expiryDate, | |||
| trnDate = m.trnDate, | |||
| trnRefNo = typeLabel(refType), | |||
| storeLocation = row.lot.storeLocation, | |||
| orderRefNo = outRef?.orderRefNo ?: inRef?.orderRefNo ?: "", | |||
| jobOrderNo = inRef?.jobOrderNo.orEmpty(), | |||
| inQty = m.inQty, | |||
| outQty = m.outQty, | |||
| opening = m.bal - m.inQty + m.outQty, | |||
| balance = m.bal, | |||
| totalOpening = total?.opening ?: 0.0, | |||
| totalIn = total?.inSum ?: 0.0, | |||
| totalOut = total?.outSum ?: 0.0, | |||
| totalBalance = total?.closing ?: 0.0, | |||
| ) | |||
| } | |||
| } | |||
| val totalMs = (System.nanoTime() - tAll) / 1_000_000 | |||
| timings.add("search.total ${totalMs}ms") | |||
| log.info("stock-ledger DONE {}", timings.joinToString(" | ")) | |||
| return rows | |||
| } | |||
| private fun reportRow( | |||
| stockSubCategory: String, | |||
| itemNo: String, | |||
| itemName: String, | |||
| unitOfMeasure: String, | |||
| lotNo: String, | |||
| expiryDate: String, | |||
| trnDate: String, | |||
| trnRefNo: String, | |||
| storeLocation: String, | |||
| orderRefNo: String, | |||
| jobOrderNo: String, | |||
| inQty: Double, | |||
| outQty: Double, | |||
| opening: Double, | |||
| balance: Double, | |||
| totalOpening: Double, | |||
| totalIn: Double, | |||
| totalOut: Double, | |||
| totalBalance: Double, | |||
| ): Map<String, Any> { | |||
| val row = HashMap<String, Any>(32) | |||
| row["stockSubCategory"] = stockSubCategory | |||
| row["itemNo"] = itemNo | |||
| row["itemName"] = itemName | |||
| row["unitOfMeasure"] = unitOfMeasure | |||
| row["lotNo"] = lotNo | |||
| row["expiryDate"] = expiryDate | |||
| row["trnDate"] = trnDate | |||
| row["trnRefNo"] = trnRefNo | |||
| row["storeLocation"] = storeLocation | |||
| row["orderRefNo"] = orderRefNo | |||
| row["jobOrderNo"] = jobOrderNo | |||
| row["openingBalance"] = "" | |||
| row["cumStockIn"] = "" | |||
| row["cumStockOut"] = "" | |||
| row["currentBalance"] = "" | |||
| row["lastInDate"] = "" | |||
| row["lastOutDate"] = "" | |||
| row["reOrderLevel"] = "" | |||
| row["reOrderQty"] = "" | |||
| row["stockIn"] = formatQty(inQty) | |||
| row["stockOut"] = formatQty(outQty) | |||
| row["cumOpeningBal"] = formatQty(opening) | |||
| row["cumBalance"] = formatQty(balance) | |||
| row["totalCumOpeningBal"] = formatQty(totalOpening) | |||
| row["totalStockIn"] = formatQty(totalIn) | |||
| row["totalStockOut"] = formatQty(totalOut) | |||
| row["totalCumBalance"] = formatQty(totalBalance) | |||
| return row | |||
| } | |||
| private fun typeLabel(raw: String): String = when (raw.uppercase()) { | |||
| "OPEN" -> "開倉" | |||
| "NOR" -> "出入倉" | |||
| "TRF" -> "轉倉" | |||
| "ADJ" -> "調整" | |||
| "MISS" -> "遺失" | |||
| "BAD" -> "不良品" | |||
| "TKE" -> "盤點" | |||
| else -> raw | |||
| } | |||
| /** Same shape as MySQL FORMAT(n, 0), negatives in parentheses. */ | |||
| private fun formatQty(value: Double): String { | |||
| val rounded = BigDecimal.valueOf(value).setScale(0, RoundingMode.HALF_UP) | |||
| val negative = rounded.signum() < 0 | |||
| val digits = rounded.abs().toPlainString() | |||
| val grouped = digits.reversed().chunked(3).joinToString(",").reversed() | |||
| return if (negative) "($grouped)" else grouped | |||
| } | |||
| private fun buildCategoryItemFilter( | |||
| stockCategory: String?, | |||
| args: MutableMap<String, Any>, | |||
| ): String { | |||
| if (stockCategory.isNullOrBlank()) return "" | |||
| val values = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() } | |||
| if (values.isEmpty()) return "" | |||
| val ors = values.mapIndexed { index, value -> | |||
| val name = "stockCategory_$index" | |||
| args[name] = value | |||
| "it.type = :$name" | |||
| } | |||
| return """ | |||
| AND sl.itemId IN ( | |||
| SELECT it.id FROM items it | |||
| WHERE it.deleted = 0 AND (${ors.joinToString(" OR ")}) | |||
| ) | |||
| """.trimIndent() | |||
| } | |||
| val itemCodeSql = buildItemCodeFilterClause( | |||
| itemCode, | |||
| "sl.itemCode", | |||
| "itemCode", | |||
| args | |||
| private fun queryChunks(ids: Collection<Long>, sql: String): List<Map<String, Any>> { | |||
| val distinct = ids.filter { it > 0 }.distinct() | |||
| if (distinct.isEmpty()) return emptyList() | |||
| val out = ArrayList<Map<String, Any>>() | |||
| for (chunk in distinct.chunked(800)) { | |||
| out.addAll(jdbcDao.queryForList(sql, mapOf("ids" to chunk))) | |||
| } | |||
| return out | |||
| } | |||
| /** | |||
| * Exact item codes become itemId so MySQL can use | |||
| * idx_ledger_item_deleted_date_id (itemId, deleted, date, id). | |||
| * Short tokens stay as prefix LIKE. Lot / stock-UOM assembly is unchanged. | |||
| */ | |||
| private fun ledgerItemPredicate(itemCode: String?, args: MutableMap<String, Any>): LedgerItemPredicate { | |||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(itemCode) | |||
| if (exact.isEmpty() && fuzzy.isEmpty()) { | |||
| return LedgerItemPredicate( | |||
| sql = "", | |||
| indexHint = "FORCE INDEX (idx_sl_deleted_date)", | |||
| ) | |||
| } | |||
| if (fuzzy.isEmpty()) { | |||
| val ids = lookupItemIds(exact) | |||
| if (ids.isNotEmpty()) { | |||
| val names = ids.mapIndexed { index, id -> | |||
| val name = "itemId_$index" | |||
| args[name] = id | |||
| name | |||
| } | |||
| return LedgerItemPredicate( | |||
| sql = "AND sl.itemId IN (${names.joinToString(", ") { ":$it" }})", | |||
| indexHint = "FORCE INDEX (idx_ledger_item_deleted_date_id)", | |||
| ) | |||
| } | |||
| } | |||
| val tokenCount = exact.size + fuzzy.size | |||
| return LedgerItemPredicate( | |||
| sql = buildItemCodeFilterClause(itemCode, "sl.itemCode", "itemCode", args), | |||
| indexHint = if (tokenCount == 1) "FORCE INDEX (idx_sl_deleted_itemcode_created)" else "", | |||
| ) | |||
| } | |||
| // 用 lot 子查詢的 storeLocation,避免 ill_in 放大列數 | |||
| val storeLocationSql = if (!storeLocation.isNullOrBlank()) { | |||
| args["storeLocation"] = "%$storeLocation%" | |||
| "AND lot.storeLocation LIKE :storeLocation" | |||
| } else { | |||
| "" | |||
| private fun lookupItemIds(codes: List<String>): List<Long> { | |||
| if (codes.isEmpty()) return emptyList() | |||
| val args = HashMap<String, Any>(codes.size) | |||
| val names = codes.mapIndexed { index, code -> | |||
| val name = "lookupCode_$index" | |||
| args[name] = code | |||
| ":$name" | |||
| } | |||
| val sql = """ | |||
| SELECT id AS itemId | |||
| FROM items | |||
| WHERE code IN (${names.joinToString(", ")}) | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args).mapNotNull { row -> | |||
| when (val id = row["itemId"]) { | |||
| is Number -> id.toLong() | |||
| else -> id?.toString()?.toLongOrNull() | |||
| } | |||
| }.distinct() | |||
| } | |||
| /** Blank start date: earliest movement for this filter, not MIN() of the whole ledger. */ | |||
| private fun earliestLedgerDate(predicate: LedgerItemPredicate, args: Map<String, Any>): String? { | |||
| val sql = """ | |||
| SELECT | |||
| stockSubCategory, | |||
| itemNo, | |||
| itemName, | |||
| unitOfMeasure, | |||
| lotNo, | |||
| expiryDate, | |||
| trnDate, | |||
| CASE trnRefNoRaw | |||
| WHEN 'OPEN' THEN '開倉' | |||
| WHEN 'NOR' THEN '出入倉' | |||
| WHEN 'TRF' THEN '轉倉' | |||
| WHEN 'ADJ' THEN '調整' | |||
| WHEN 'MISS' THEN '遺失' | |||
| WHEN 'BAD' THEN '不良品' | |||
| WHEN 'TKE' THEN '盤點' | |||
| ELSE trnRefNoRaw | |||
| END AS trnRefNo, | |||
| storeLocation, | |||
| orderRefNo, | |||
| jobOrderNo, | |||
| openingBalance, | |||
| cumStockIn, | |||
| cumStockOut, | |||
| currentBalance, | |||
| lastInDate, | |||
| lastOutDate, | |||
| reOrderLevel, | |||
| reOrderQty, | |||
| CASE WHEN inQty < 0 THEN CONCAT('(', FORMAT(-inQty, 0), ')') ELSE FORMAT(inQty, 0) END AS stockIn, | |||
| CASE WHEN outQty < 0 THEN CONCAT('(', FORMAT(-outQty, 0), ')') ELSE FORMAT(outQty, 0) END AS stockOut, | |||
| CASE WHEN cumOpeningBalRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-cumOpeningBalRaw, 0), ')') | |||
| ELSE FORMAT(cumOpeningBalRaw, 0) END AS cumOpeningBal, | |||
| CASE WHEN bal < 0 | |||
| THEN CONCAT('(', FORMAT(-bal, 0), ')') | |||
| ELSE FORMAT(bal, 0) END AS cumBalance, | |||
| CASE WHEN totalCumOpeningBalRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalCumOpeningBalRaw, 0), ')') | |||
| ELSE FORMAT(totalCumOpeningBalRaw, 0) END AS totalCumOpeningBal, | |||
| CASE WHEN totalStockInRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalStockInRaw, 0), ')') | |||
| ELSE FORMAT(totalStockInRaw, 0) END AS totalStockIn, | |||
| CASE WHEN totalStockOutRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalStockOutRaw, 0), ')') | |||
| ELSE FORMAT(totalStockOutRaw, 0) END AS totalStockOut, | |||
| CASE WHEN totalCumBalanceRaw < 0 | |||
| THEN CONCAT('(', FORMAT(-totalCumBalanceRaw, 0), ')') | |||
| ELSE FORMAT(totalCumBalanceRaw, 0) END AS totalCumBalance | |||
| FROM ( | |||
| SELECT | |||
| x.*, | |||
| FIRST_VALUE(cumOpeningBalRaw) OVER ( | |||
| PARTITION BY itemCode ORDER BY trnDateRaw, slId | |||
| ) AS totalCumOpeningBalRaw, | |||
| SUM(inQty) OVER (PARTITION BY itemCode) AS totalStockInRaw, | |||
| SUM(outQty) OVER (PARTITION BY itemCode) AS totalStockOutRaw, | |||
| FIRST_VALUE(bal) OVER ( | |||
| PARTITION BY itemCode ORDER BY trnDateRaw DESC, slId DESC | |||
| ) AS totalCumBalanceRaw | |||
| FROM ( | |||
| SELECT | |||
| sl.id AS slId, | |||
| DATE(sl.date) AS trnDateRaw, | |||
| DATE_FORMAT(sl.date, '%Y-%m-%d') AS trnDate, | |||
| CASE | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'TKE' THEN 'TKE' | |||
| WHEN UPPER(TRIM(COALESCE(sl.type, ''))) = 'ADJ' | |||
| AND (so.stockTakeId IS NOT NULL OR so.type = 'stockTake' OR si.stockTakeId IS NOT NULL) THEN 'TKE' | |||
| ELSE COALESCE(sl.type, '') | |||
| END AS trnRefNoRaw, | |||
| sl.itemCode AS itemCode, | |||
| COALESCE(sl.inQty, 0) AS inQty, | |||
| COALESCE(sl.outQty, 0) AS outQty, | |||
| COALESCE(sl.balance, 0) AS bal, | |||
| COALESCE(sl.balance, 0) | |||
| - COALESCE(sl.inQty, 0) | |||
| + COALESCE(sl.outQty, 0) AS cumOpeningBalRaw, | |||
| it.type AS stockSubCategory, | |||
| it.code AS itemNo, | |||
| it.name AS itemName, | |||
| uc.udfudesc AS unitOfMeasure, | |||
| lot.lotNo AS lotNo, | |||
| COALESCE(DATE_FORMAT(lot.expiryDate, '%Y-%m-%d'), '') AS expiryDate, | |||
| lot.storeLocation AS storeLocation, | |||
| COALESCE(TRIM(do.code), TRIM(jo_po.code), TRIM(jo.code), TRIM(po.code), '') AS orderRefNo, | |||
| COALESCE(TRIM(jo.code), '') AS jobOrderNo, | |||
| '' AS openingBalance, | |||
| '' AS cumStockIn, | |||
| '' AS cumStockOut, | |||
| '' AS currentBalance, | |||
| '' AS lastInDate, | |||
| '' AS lastOutDate, | |||
| '' AS reOrderLevel, | |||
| '' AS reOrderQty | |||
| FROM stock_ledger sl | |||
| LEFT JOIN stock_in_line sil | |||
| ON sl.stockInLineId = sil.id | |||
| AND sil.deleted = 0 | |||
| LEFT JOIN inventory_lot il_in | |||
| ON sil.inventoryLotId = il_in.id | |||
| AND il_in.deleted = 0 | |||
| LEFT JOIN stock_out_line sol | |||
| ON sl.stockOutLineId = sol.id | |||
| AND sol.deleted = 0 | |||
| LEFT JOIN inventory_lot_line ill_out | |||
| ON sol.inventoryLotLineId = ill_out.id | |||
| AND ill_out.deleted = 0 | |||
| LEFT JOIN inventory_lot il_out | |||
| ON ill_out.inventoryLotId = il_out.id | |||
| AND il_out.deleted = 0 | |||
| LEFT JOIN ( | |||
| SELECT | |||
| il.id AS lotId, | |||
| il.lotNo AS lotNo, | |||
| il.expiryDate AS expiryDate, | |||
| MAX(wh.code) AS storeLocation | |||
| FROM inventory_lot il | |||
| LEFT JOIN inventory_lot_line ill | |||
| ON ill.inventoryLotId = il.id | |||
| AND ill.deleted = 0 | |||
| LEFT JOIN warehouse wh | |||
| ON ill.warehouseId = wh.id | |||
| AND wh.deleted = 0 | |||
| WHERE il.deleted = 0 | |||
| GROUP BY | |||
| il.id, il.lotNo, il.expiryDate | |||
| ) lot | |||
| ON lot.lotId = COALESCE(il_in.id, il_out.id) | |||
| LEFT JOIN items it | |||
| ON sl.itemId = it.id | |||
| AND it.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| LEFT JOIN stock_out so | |||
| ON sol.stockOutId = so.id | |||
| AND so.deleted = 0 | |||
| LEFT JOIN pick_order_line pol | |||
| ON sol.pickOrderLineId = pol.id | |||
| AND pol.deleted = 0 | |||
| LEFT JOIN pick_order po_out | |||
| ON pol.poId = po_out.id | |||
| AND po_out.deleted = 0 | |||
| LEFT JOIN job_order jo_po | |||
| ON po_out.joId = jo_po.id | |||
| AND jo_po.deleted = 0 | |||
| LEFT JOIN delivery_order do | |||
| ON po_out.doId = do.id | |||
| AND do.deleted = 0 | |||
| LEFT JOIN stock_in si | |||
| ON sil.stockInId = si.id | |||
| AND si.deleted = 0 | |||
| LEFT JOIN job_order jo | |||
| ON sil.jobOrderId = jo.id | |||
| AND jo.deleted = 0 | |||
| LEFT JOIN purchase_order po | |||
| ON sil.purchaseOrderId = po.id | |||
| AND po.deleted = 0 | |||
| WHERE | |||
| sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| AND sl.date >= :reportPeriodStart | |||
| AND sl.date < :reportPeriodEndExclusive | |||
| $stockCategorySql | |||
| $itemCodeSql | |||
| $storeLocationSql | |||
| AND lot.lotId IS NOT NULL | |||
| ) x | |||
| ) y | |||
| ORDER BY | |||
| itemNo, | |||
| trnDateRaw, | |||
| slId, | |||
| lotNo | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| SELECT DATE_FORMAT(MIN(sl.date), '%Y-%m-%d') AS firstDate | |||
| FROM stock_ledger sl ${predicate.indexHint} | |||
| WHERE sl.deleted = 0 | |||
| AND sl.itemCode IS NOT NULL | |||
| AND sl.itemCode <> '' | |||
| ${predicate.sql} | |||
| """.trimIndent() | |||
| val row = jdbcDao.queryForList(sql, args).firstOrNull() | |||
| return row?.get("firstDate") as? String | |||
| } | |||
| private data class LedgerItemPredicate( | |||
| val sql: String, | |||
| val indexHint: String, | |||
| ) | |||
| /** Full codes use equality; short tokens use prefix LIKE. */ | |||
| private fun buildItemCodeFilterClause( | |||
| paramValue: String?, | |||
| columnName: String, | |||
| paramPrefix: String, | |||
| args: MutableMap<String, Any> | |||
| args: MutableMap<String, Any>, | |||
| ): String { | |||
| val (exact, fuzzy) = ReportMultiValueTokens.partitionExactAndFuzzy(paramValue) | |||
| if (exact.isEmpty() && fuzzy.isEmpty()) return "" | |||
| val parts = mutableListOf<String>() | |||
| exact.forEachIndexed { index, value -> | |||
| val paramName = "${paramPrefix}_eq_$index" | |||
| @@ -302,22 +630,64 @@ ORDER BY | |||
| return "AND (${parts.joinToString(" OR ")})" | |||
| } | |||
| /** = 多值工具方法 */ | |||
| private fun buildMultiValueExactClause( | |||
| paramValue: String?, | |||
| columnName: String, | |||
| paramPrefix: String, | |||
| args: MutableMap<String, Any> | |||
| ): String { | |||
| if (paramValue.isNullOrBlank()) return "" | |||
| val values = paramValue.split(",").map { it.trim() }.filter { it.isNotBlank() } | |||
| if (values.isEmpty()) return "" | |||
| private fun toDouble(v: Any?): Double { | |||
| if (v == null) return 0.0 | |||
| if (v is Number) return v.toDouble() | |||
| return v.toString().replace(",", "").toDoubleOrNull() ?: 0.0 | |||
| } | |||
| val conditions = values.mapIndexed { index, value -> | |||
| val paramName = "${paramPrefix}_$index" | |||
| args[paramName] = value | |||
| "$columnName = :$paramName" | |||
| private fun toLong(v: Any?): Long { | |||
| if (v == null) return 0L | |||
| if (v is Number) return v.toLong() | |||
| return v.toString().toLongOrNull() ?: 0L | |||
| } | |||
| private data class LedgerMove( | |||
| val slId: Long, | |||
| val trnDate: String, | |||
| val rawType: String, | |||
| val itemId: Long, | |||
| val itemCode: String, | |||
| val inQty: Double, | |||
| val outQty: Double, | |||
| val bal: Double, | |||
| val ledgerUomId: Long, | |||
| val lotLineId: Long, | |||
| val stockInLineId: Long, | |||
| val stockOutLineId: Long, | |||
| ) | |||
| private data class MoveWithLot( | |||
| val move: LedgerMove, | |||
| val lot: LotInfo, | |||
| val unitOfMeasure: String, | |||
| ) | |||
| private data class LotInfo( | |||
| val lotNo: String, | |||
| val expiryDate: String, | |||
| val storeLocation: String, | |||
| val unitOfMeasure: String, | |||
| ) | |||
| private class ItemTotal { | |||
| var firstDate: String? = null | |||
| var firstId: Long = 0L | |||
| var lastDate: String? = null | |||
| var lastId: Long = 0L | |||
| var opening: Double = 0.0 | |||
| var closing: Double = 0.0 | |||
| var inSum: Double = 0.0 | |||
| var outSum: Double = 0.0 | |||
| fun isBeforeFirst(date: String, id: Long): Boolean { | |||
| val current = firstDate ?: return true | |||
| return date < current || (date == current && id < firstId) | |||
| } | |||
| fun isAfterLast(date: String, id: Long): Boolean { | |||
| val current = lastDate ?: return true | |||
| return date > current || (date == current && id > lastId) | |||
| } | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| } | |||
| @@ -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.deleted = 0 | |||
| LEFT JOIN item_uom iu_stock | |||
| ON it.id = iu_stock.itemId | |||
| AND iu_stock.stockUnit = 1 | |||
| ON iu_stock.id = ill.stockItemUomId | |||
| AND iu_stock.deleted = 0 | |||
| WHERE ill.deleted = 0 | |||
| GROUP BY ill.id | |||
| @@ -316,8 +315,7 @@ data AS ( | |||
| $ROOT_STOCK_IN_JOIN_SQL | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| ON iu.id = ill.stockItemUomId | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| @@ -377,16 +375,17 @@ SELECT | |||
| END AS variancePercentage, | |||
| /* item totals(footer 用);負數括號顯示,無小數 */ | |||
| CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance, | |||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn, | |||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut, | |||
| CASE WHEN SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance, | |||
| CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty, | |||
| CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance, | |||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn, | |||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut, | |||
| CASE WHEN SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(currentQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance, | |||
| CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty, | |||
| lotTypeRaw AS type | |||
| FROM data | |||
| ORDER BY | |||
| itemNo, | |||
| unitOfMeasure, | |||
| lotNo, | |||
| storeLocation | |||
| """.trimIndent() | |||
| @@ -521,8 +520,7 @@ in_agg AS ( | |||
| AND iu_purchase.purchaseUnit = 1 | |||
| AND iu_purchase.deleted = 0 | |||
| LEFT JOIN item_uom iu_stock | |||
| ON it.id = iu_stock.itemId | |||
| AND iu_stock.stockUnit = 1 | |||
| ON iu_stock.id = ill.stockItemUomId | |||
| AND iu_stock.deleted = 0 | |||
| WHERE ill.deleted = 0 | |||
| GROUP BY ill.id | |||
| @@ -598,8 +596,7 @@ data AS ( | |||
| $ROOT_STOCK_IN_JOIN_SQL | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| ON iu.id = ill.stockItemUomId | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| @@ -655,16 +652,17 @@ SELECT | |||
| ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') | |||
| END AS variancePercentage, | |||
| CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance, | |||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn, | |||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut, | |||
| CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance, | |||
| CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty, | |||
| CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance, | |||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn, | |||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut, | |||
| CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance, | |||
| CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty, | |||
| lotTypeRaw AS type | |||
| FROM data | |||
| ORDER BY | |||
| itemNo, | |||
| unitOfMeasure, | |||
| lotNo, | |||
| storeLocation | |||
| """.trimIndent() | |||
| @@ -820,8 +818,7 @@ in_agg AS ( | |||
| AND iu_purchase.purchaseUnit = 1 | |||
| AND iu_purchase.deleted = 0 | |||
| LEFT JOIN item_uom iu_stock | |||
| ON it.id = iu_stock.itemId | |||
| AND iu_stock.stockUnit = 1 | |||
| ON iu_stock.id = ill.stockItemUomId | |||
| AND iu_stock.deleted = 0 | |||
| WHERE ill.deleted = 0 | |||
| GROUP BY lb.inventoryLotLineId | |||
| @@ -888,8 +885,7 @@ data AS ( | |||
| $ROOT_STOCK_IN_JOIN_SQL | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| ON iu.id = ill.stockItemUomId | |||
| AND iu.deleted = 0 | |||
| LEFT JOIN uom_conversion uc | |||
| ON iu.uomId = uc.id | |||
| @@ -945,16 +941,17 @@ SELECT | |||
| ELSE CONCAT(FORMAT((COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%') | |||
| END AS variancePercentage, | |||
| CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalOpeningBalance, | |||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockIn, | |||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCumStockOut, | |||
| CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalCurrentBalance, | |||
| CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo), 0) END AS totalStockTakeQty, | |||
| CASE WHEN SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(openingQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalOpeningBalance, | |||
| CASE WHEN SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(inQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockIn, | |||
| CASE WHEN SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(outQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCumStockOut, | |||
| CASE WHEN SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkBookQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalCurrentBalance, | |||
| CASE WHEN SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure) < 0 THEN CONCAT('(', FORMAT(-SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0), ')') ELSE FORMAT(SUM(COALESCE(stkApproverQty, 0)) OVER (PARTITION BY itemNo, unitOfMeasure), 0) END AS totalStockTakeQty, | |||
| lotTypeRaw AS type | |||
| FROM data | |||
| ORDER BY | |||
| itemNo, | |||
| unitOfMeasure, | |||
| lotNo, | |||
| storeLocation | |||
| """.trimIndent() | |||
| @@ -159,7 +159,7 @@ class FGStockOutTraceabilityReportController( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| @@ -216,7 +216,7 @@ class FGStockOutTraceabilityReportController( | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| borderTop = BorderStyle.THICK | |||
| borderBottom = BorderStyle.THICK | |||
| borderLeft = BorderStyle.THIN | |||
| @@ -306,11 +306,13 @@ class FGStockOutTraceabilityReportController( | |||
| } else { | |||
| var currentItemNo: String? = null | |||
| var currentItemName = "" | |||
| var currentUom = "" | |||
| var currentItemTotalQty = 0.0 | |||
| dbData.forEach { rowMap -> | |||
| val itemNo = rowMap["itemNo"]?.toString() ?: "" | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| val uom = rowMap["unitOfMeasure"]?.toString().orEmpty() | |||
| if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { | |||
| addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) | |||
| addBlankSeparatorRow() | |||
| currentItemTotalQty = 0.0 | |||
| @@ -331,6 +333,7 @@ class FGStockOutTraceabilityReportController( | |||
| currentItemNo = itemNo | |||
| currentItemName = rowMap["itemName"]?.toString() ?: "" | |||
| currentUom = uom | |||
| currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 | |||
| } | |||
| @@ -271,7 +271,7 @@ class ItemQcFailReportController( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val df = workbook.creationHelper.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| } | |||
| val decimalNumberStyle = workbook.createCellStyle().apply { | |||
| @@ -282,7 +282,7 @@ class ItemQcFailReportController( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val df = workbook.creationHelper.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0.##") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| } | |||
| val headers = listOf( | |||
| @@ -244,7 +244,7 @@ class MaterialStockOutTraceabilityReportController( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| @@ -282,7 +282,7 @@ class MaterialStockOutTraceabilityReportController( | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THICK | |||
| borderLeft = BorderStyle.THIN | |||
| @@ -358,11 +358,13 @@ class MaterialStockOutTraceabilityReportController( | |||
| } else { | |||
| var currentItemNo: String? = null | |||
| var currentItemName: String = "" | |||
| var currentUom = "" | |||
| var currentItemTotalQty: Double = 0.0 | |||
| dbData.forEach { rowMap -> | |||
| val itemNo = rowMap["itemNo"]?.toString() ?: "" | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| val uom = rowMap["unitOfMeasure"]?.toString().orEmpty() | |||
| if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { | |||
| addItemSummaryRow(currentItemTotalQty, currentItemNo!!, currentItemName) | |||
| addBlankSeparatorRow() | |||
| currentItemTotalQty = 0.0 | |||
| @@ -383,6 +385,7 @@ class MaterialStockOutTraceabilityReportController( | |||
| currentItemNo = itemNo | |||
| currentItemName = rowMap["itemName"]?.toString() ?: "" | |||
| currentUom = uom | |||
| currentItemTotalQty += parseNullableNumber(rowMap["stockOutQty"]) ?: 0.0 | |||
| } | |||
| @@ -122,9 +122,18 @@ class ReportController( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) lastInDateStart: String?, | |||
| @RequestParam(required = false) lastInDateEnd: String? | |||
| @RequestParam(required = false) lastInDateEnd: String?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) warehouse: String?, | |||
| @RequestParam(required = false) area: String?, | |||
| @RequestParam(required = false) slot: String?, | |||
| @RequestParam(required = false) lotNo: String?, | |||
| @RequestParam(required = false) poPrefix: String?, | |||
| ): ResponseEntity<ByteArray> = | |||
| buildStockInTraceabilityPdf(stockCategory, itemCode, lastInDateStart, lastInDateEnd) | |||
| buildStockInTraceabilityPdf( | |||
| stockCategory, itemCode, lastInDateStart, lastInDateEnd, | |||
| storeId, warehouse, area, slot, lotNo, poPrefix, | |||
| ) | |||
| @PostMapping("/print-stock-in-traceability") | |||
| fun generateStockInTraceabilityReportPost( | |||
| @@ -136,6 +145,12 @@ class ReportController( | |||
| r.combinedItemCode(), | |||
| r.lastInDateStart, | |||
| r.lastInDateEnd, | |||
| r.storeId, | |||
| r.warehouse, | |||
| r.area, | |||
| r.slot, | |||
| r.lotNo, | |||
| r.poPrefix, | |||
| ) | |||
| } | |||
| @@ -144,6 +159,12 @@ class ReportController( | |||
| itemCode: String?, | |||
| lastInDateStart: String?, | |||
| lastInDateEnd: String?, | |||
| storeId: String?, | |||
| warehouse: String?, | |||
| area: String?, | |||
| slot: String?, | |||
| lotNo: String?, | |||
| poPrefix: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| @@ -159,7 +180,13 @@ class ReportController( | |||
| stockCategory, | |||
| itemCode, | |||
| lastInDateStart, | |||
| lastInDateEnd | |||
| lastInDateEnd, | |||
| storeId, | |||
| warehouse, | |||
| area, | |||
| slot, | |||
| lotNo, | |||
| poPrefix, | |||
| ) | |||
| val pdfBytes = reportService.createPdfResponse( | |||
| @@ -183,8 +210,17 @@ class ReportController( | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) lastInDateStart: String?, | |||
| @RequestParam(required = false) lastInDateEnd: String?, | |||
| @RequestParam(required = false) storeId: String?, | |||
| @RequestParam(required = false) warehouse: String?, | |||
| @RequestParam(required = false) area: String?, | |||
| @RequestParam(required = false) slot: String?, | |||
| @RequestParam(required = false) lotNo: String?, | |||
| @RequestParam(required = false) poPrefix: String?, | |||
| ): ResponseEntity<ByteArray> = | |||
| buildStockInTraceabilityExcel(stockCategory, itemCode, lastInDateStart, lastInDateEnd) | |||
| buildStockInTraceabilityExcel( | |||
| stockCategory, itemCode, lastInDateStart, lastInDateEnd, | |||
| storeId, warehouse, area, slot, lotNo, poPrefix, | |||
| ) | |||
| @PostMapping("/print-stock-in-traceability-excel") | |||
| fun exportStockInTraceabilityReportExcelPost( | |||
| @@ -196,6 +232,12 @@ class ReportController( | |||
| r.combinedItemCode(), | |||
| r.lastInDateStart, | |||
| r.lastInDateEnd, | |||
| r.storeId, | |||
| r.warehouse, | |||
| r.area, | |||
| r.slot, | |||
| r.lotNo, | |||
| r.poPrefix, | |||
| ) | |||
| } | |||
| @@ -204,18 +246,37 @@ class ReportController( | |||
| itemCode: String?, | |||
| lastInDateStart: String?, | |||
| lastInDateEnd: String?, | |||
| storeId: String?, | |||
| warehouse: String?, | |||
| area: String?, | |||
| slot: String?, | |||
| lotNo: String?, | |||
| poPrefix: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val dbData = reportService.searchStockInTraceabilityReport( | |||
| stockCategory, | |||
| itemCode, | |||
| lastInDateStart, | |||
| lastInDateEnd, | |||
| storeId, | |||
| warehouse, | |||
| area, | |||
| slot, | |||
| lotNo, | |||
| poPrefix, | |||
| ) | |||
| val excelBytes = createStockInTraceabilityExcel( | |||
| dbData = dbData, | |||
| lastInDateStart = lastInDateStart ?: "", | |||
| lastInDateEnd = lastInDateEnd ?: "", | |||
| itemCode = itemCode, | |||
| storeId = storeId, | |||
| warehouse = warehouse, | |||
| area = area, | |||
| slot = slot, | |||
| lotNo = lotNo, | |||
| poPrefix = poPrefix, | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| @@ -664,7 +725,7 @@ class ReportController( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| } | |||
| val dashStyle = (workbook.createCellStyle() as XSSFCellStyle).apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| @@ -777,6 +838,13 @@ class ReportController( | |||
| dbData: List<Map<String, Any>>, | |||
| lastInDateStart: String, | |||
| lastInDateEnd: String, | |||
| itemCode: String? = null, | |||
| storeId: String? = null, | |||
| warehouse: String? = null, | |||
| area: String? = null, | |||
| slot: String? = null, | |||
| lotNo: String? = null, | |||
| poPrefix: String? = null, | |||
| ): ByteArray { | |||
| val workbook = XSSFWorkbook() | |||
| val styles = createCommonWorkbookStyles(workbook) | |||
| @@ -812,8 +880,18 @@ class ReportController( | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) | |||
| subtitleRow.createCell(6).apply { | |||
| val cap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" }) | |||
| setCellValue("最後入倉日期:${cap.trim()}") | |||
| val dateCap = (lastInDateStart.trim().ifBlank { "" }) + " 至 " + (lastInDateEnd.trim().ifBlank { "" }) | |||
| val filterBits = listOfNotNull( | |||
| itemCode?.takeIf { it.isNotBlank() }?.let { "貨品:$it" }, | |||
| storeId?.takeIf { it.isNotBlank() && it != "All" }?.let { "樓層:$it" }, | |||
| warehouse?.takeIf { it.isNotBlank() && it != "All" }?.let { "倉庫:$it" }, | |||
| area?.takeIf { it.isNotBlank() && it != "All" }?.let { "區域:$it" }, | |||
| slot?.takeIf { it.isNotBlank() && it != "All" }?.let { "儲位:$it" }, | |||
| lotNo?.takeIf { it.isNotBlank() }?.let { "批號:$it" }, | |||
| poPrefix?.takeIf { it.isNotBlank() && it != "All" }?.let { "PP/PF:$it" }, | |||
| ).joinToString(" ") | |||
| val extra = if (filterBits.isNotBlank()) " $filterBits" else "" | |||
| setCellValue("最後入倉日期:${dateCap.trim()}$extra") | |||
| cellStyle = styles.subtitle | |||
| } | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 6, totalColumns - 1)) | |||
| @@ -869,7 +947,7 @@ class ReportController( | |||
| val itemName = m["itemName"]?.toString().orEmpty() | |||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { | |||
| addItemSummaryRow( | |||
| currentItemNo!!, | |||
| currentItemName, | |||
| @@ -1039,7 +1117,7 @@ class ReportController( | |||
| setTextCell(r, 4, m["dnNo"], styles.text) | |||
| setTextCell(r, 5, m["deliveryOrderNo"], styles.text) | |||
| setTextCell(r, 6, m["customerName"], styles.text) | |||
| setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 7, m["qty"], styles.int, styles.dash, preferInt = false) | |||
| setTextCell(r, 8, m["truckNo"], styles.center) | |||
| itemTotalQty += parseSignedNumber(m["qty"]) ?: 0.0 | |||
| @@ -1063,6 +1141,7 @@ class ReportController( | |||
| return out.toByteArray() | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 87 | v1.0.0 | 2026-09-28 */ | |||
| private fun createStockBalanceExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportDate: String, | |||
| @@ -1121,13 +1200,13 @@ class ReportController( | |||
| setTextCell(r, 0, m["itemNo"], styles.text) | |||
| setTextCell(r, 1, m["itemName"], styles.text) | |||
| setTextCell(r, 2, m["unitOfMeasure"], styles.center) | |||
| setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.int, styles.dash, preferInt = true) | |||
| setNumberCellFromFormatted(r, 3, m["totalOpeningBalance"], styles.number, styles.dash, preferInt = false) | |||
| setNumberCellFromFormatted(r, 4, m["totalCumStockIn"], styles.number, styles.dash, preferInt = false) | |||
| setNumberCellFromFormatted(r, 5, m["totalCumStockOut"], styles.number, styles.dash, preferInt = false) | |||
| setNumberCellFromFormatted(r, 6, m["totalMisInputAndLost"], styles.number, styles.dash, preferInt = false) | |||
| setNumberCellFromFormatted(r, 7, m["totalVariance"], styles.number, styles.dash, preferInt = false) | |||
| setNumberCellFromFormatted(r, 8, m["totalDefectiveGoods"], styles.number, styles.dash, preferInt = false) | |||
| setNumberCellFromFormatted(r, 9, m["totalCurrentBalance"], styles.number, styles.dash, preferInt = false) | |||
| setNumberCellFromFormatted(r, 10, m["avgUnitPrice"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) | |||
| setNumberCellFromFormatted(r, 11, m["totalStockBalance"], styles.number, styles.dash, preferInt = false, emptyAsBlank = true) | |||
| } | |||
| @@ -1139,6 +1218,9 @@ class ReportController( | |||
| } | |||
| val widths = intArrayOf(14, 26, 10, 12, 12, 12, 14, 12, 12, 12, 12, 14) | |||
| widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||
| // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. | |||
| sheet.setColumnHidden(10, true) | |||
| sheet.setColumnHidden(11, true) | |||
| val out = ByteArrayOutputStream() | |||
| workbook.use { it.write(out) } | |||
| @@ -24,6 +24,12 @@ data class ReportItemFilterRequest( | |||
| val supplier: String? = null, | |||
| val poCode: String? = null, | |||
| val grnCode: String? = null, | |||
| val storeId: String? = null, | |||
| val warehouse: String? = null, | |||
| val area: String? = null, | |||
| val slot: String? = null, | |||
| val lotNo: String? = null, | |||
| val poPrefix: String? = null, | |||
| ) { | |||
| fun combinedItemCode(): String? = | |||
| ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) | |||
| @@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| import java.time.temporal.ChronoUnit | |||
| import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService | |||
| import com.ffii.fpsms.modules.report.service.ReportService | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| @@ -173,6 +174,243 @@ class SemiFGProductionAnalysisReportController( | |||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | |||
| } | |||
| @PostMapping("/print-semi-fg-production-analysis-excel-daily") | |||
| fun exportSemiFGProductionAnalysisReportExcelDaily( | |||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val r = req ?: ReportItemFilterRequest() | |||
| val start = parseReportDate(r.lastOutDateStart) | |||
| val end = parseReportDate(r.lastOutDateEnd) | |||
| if (start == null || end == null) { | |||
| return jsonError(HttpStatus.BAD_REQUEST, "請先輸入完成生產日期(由、至)") | |||
| } | |||
| if (end.isBefore(start)) { | |||
| return jsonError(HttpStatus.BAD_REQUEST, "完成生產日期「由」不可晚於「至」") | |||
| } | |||
| val dayCount = ChronoUnit.DAYS.between(start, end) + 1 | |||
| if (dayCount > MAX_DAILY_COLUMNS) { | |||
| return jsonError(HttpStatus.BAD_REQUEST, "日期範圍不可超過 $MAX_DAILY_COLUMNS 日") | |||
| } | |||
| val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisDaily( | |||
| r.stockCategory, | |||
| r.stockSubCategory, | |||
| r.combinedItemCode(), | |||
| r.year, | |||
| start, | |||
| end, | |||
| ) | |||
| val dates = generateSequence(start) { previous -> | |||
| val next = previous.plusDays(1) | |||
| if (next.isAfter(end)) null else next | |||
| }.toList() | |||
| val excelBytes = createSemiFGProductionAnalysisDailyExcel( | |||
| dbData = dbData, | |||
| reportTitle = "成品/半成品生產分析報告", | |||
| dates = dates, | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") | |||
| setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport_Daily.xlsx") | |||
| set("filename", "SemiFGProductionAnalysisReport_Daily.xlsx") | |||
| } | |||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | |||
| } | |||
| private fun createSemiFGProductionAnalysisDailyExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportTitle: String, | |||
| dates: List<LocalDate>, | |||
| ): ByteArray { | |||
| val workbook = XSSFWorkbook() | |||
| val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) | |||
| val sheet = workbook.createSheet(safeSheetName) | |||
| val dateFormat = DateTimeFormatter.ISO_LOCAL_DATE | |||
| val columnDateFormat = DateTimeFormatter.ofPattern( | |||
| if (dates.first().year == dates.last().year) "d/M" else "d/M/yy", | |||
| ) | |||
| val fixedColumns = 3 | |||
| val totalColumns = fixedColumns + dates.size + 1 | |||
| var rowIndex = 0 | |||
| val titleStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| val titleFont = workbook.createFont().apply { | |||
| bold = true | |||
| fontHeightInPoints = 16 | |||
| } | |||
| titleStyle.setFont(titleFont) | |||
| val infoStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| val headerStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| wrapText = true | |||
| } | |||
| val headerFont = workbook.createFont().apply { bold = true } | |||
| headerStyle.setFont(headerFont) | |||
| val textStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val numberStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val titleRow = sheet.createRow(rowIndex++) | |||
| titleRow.heightInPoints = 28f | |||
| val titleCell = titleRow.createCell(0) | |||
| titleCell.setCellValue(reportTitle) | |||
| titleCell.cellStyle = titleStyle | |||
| if (totalColumns > 1) { | |||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) | |||
| } | |||
| val rangeLabel = "${dates.first().format(dateFormat)} 至 ${dates.last().format(dateFormat)}" | |||
| val infoRow = sheet.createRow(rowIndex++) | |||
| val infoCell = infoRow.createCell(0) | |||
| infoCell.setCellValue( | |||
| "報告日期:${LocalDate.now().format(dateFormat)} " + | |||
| "報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))} " + | |||
| "完成生產日期:$rangeLabel" | |||
| ) | |||
| infoCell.cellStyle = infoStyle | |||
| if (totalColumns > 1) { | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, totalColumns - 1)) | |||
| } | |||
| rowIndex++ | |||
| val headerRowIndex = rowIndex | |||
| val headerRow = sheet.createRow(rowIndex++) | |||
| headerRow.heightInPoints = 32f | |||
| val headers = buildList { | |||
| add("貨品編號") | |||
| add("貨品名稱") | |||
| add("單位") | |||
| dates.forEach { add(it.format(columnDateFormat)) } | |||
| add("上架總計") | |||
| } | |||
| headers.forEachIndexed { index, name -> | |||
| val cell = headerRow.createCell(index) | |||
| cell.setCellValue(name) | |||
| cell.cellStyle = headerStyle | |||
| } | |||
| val items = pivotDailyRows(dbData) | |||
| if (items.isEmpty()) { | |||
| val dataRow = sheet.createRow(rowIndex++) | |||
| setTextCell(dataRow, 0, "-", textStyle) | |||
| setTextCell(dataRow, 1, "-", textStyle) | |||
| setTextCell(dataRow, 2, "-", textStyle) | |||
| for (col in fixedColumns until totalColumns) { | |||
| setDashCell(dataRow, col, dashStyle) | |||
| } | |||
| } else { | |||
| items.forEach { item -> | |||
| val dataRow = sheet.createRow(rowIndex++) | |||
| setTextCell(dataRow, 0, item.itemNo.ifBlank { "-" }, textStyle) | |||
| setTextCell(dataRow, 1, item.itemName.ifBlank { "-" }, textStyle) | |||
| setTextCell(dataRow, 2, item.unitOfMeasure.ifBlank { "-" }, textStyle) | |||
| var total = 0.0 | |||
| dates.forEachIndexed { index, date -> | |||
| val qty = item.qtyByDate[date] ?: 0.0 | |||
| total += qty | |||
| setNumberCell(dataRow, fixedColumns + index, qty, numberStyle, dashStyle) | |||
| } | |||
| setNumberCell(dataRow, totalColumns - 1, total, numberStyle, dashStyle) | |||
| } | |||
| } | |||
| sheet.createFreezePane(fixedColumns, headerRowIndex + 1) | |||
| sheet.setColumnWidth(0, 18 * 256) | |||
| sheet.setColumnWidth(1, 24 * 256) | |||
| sheet.setColumnWidth(2, 12 * 256) | |||
| for (col in fixedColumns until totalColumns) { | |||
| sheet.setColumnWidth(col, 12 * 256) | |||
| } | |||
| val output = ByteArrayOutputStream() | |||
| workbook.use { it.write(output) } | |||
| return output.toByteArray() | |||
| } | |||
| private fun pivotDailyRows(dbData: List<Map<String, Any>>): List<DailyItemQty> { | |||
| val byItem = linkedMapOf<String, DailyItemQty>() | |||
| dbData.forEach { row -> | |||
| val itemNo = row["itemNo"]?.toString()?.trim().orEmpty() | |||
| val prodDate = toLocalDate(row["prodDate"]) ?: return@forEach | |||
| val qty = parseNumericForExcelChart(row["qty"]) | |||
| val item = byItem.getOrPut(itemNo) { | |||
| DailyItemQty( | |||
| itemNo = itemNo, | |||
| itemName = row["itemName"]?.toString().orEmpty(), | |||
| unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(), | |||
| qtyByDate = linkedMapOf(), | |||
| ) | |||
| } | |||
| item.qtyByDate[prodDate] = (item.qtyByDate[prodDate] ?: 0.0) + qty | |||
| } | |||
| return byItem.values.toList() | |||
| } | |||
| private fun parseReportDate(raw: String?): LocalDate? { | |||
| if (raw.isNullOrBlank()) return null | |||
| val normalized = raw.trim().replace("/", "-") | |||
| return runCatching { LocalDate.parse(normalized) }.getOrNull() | |||
| ?: runCatching { LocalDate.parse(normalized, DateTimeFormatter.ofPattern("dd-MM-yyyy")) }.getOrNull() | |||
| } | |||
| private fun toLocalDate(value: Any?): LocalDate? = when (value) { | |||
| is LocalDate -> value | |||
| is java.sql.Date -> value.toLocalDate() | |||
| is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() | |||
| is java.time.LocalDateTime -> value.toLocalDate() | |||
| is java.util.Date -> value.toInstant().atZone(java.time.ZoneId.systemDefault()).toLocalDate() | |||
| is String -> parseReportDate(value.take(10)) | |||
| else -> null | |||
| } | |||
| private fun jsonError(status: HttpStatus, message: String): ResponseEntity<ByteArray> { | |||
| val escaped = message.replace("\\", "\\\\").replace("\"", "\\\"") | |||
| return ResponseEntity.status(status) | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .body("""{"message":"$escaped"}""".toByteArray(Charsets.UTF_8)) | |||
| } | |||
| private fun createSemiFGProductionAnalysisExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportTitle: String, | |||
| @@ -228,7 +466,7 @@ class SemiFGProductionAnalysisReportController( | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| @@ -517,4 +755,15 @@ class SemiFGProductionAnalysisReportController( | |||
| val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) | |||
| return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) | |||
| } | |||
| private data class DailyItemQty( | |||
| val itemNo: String, | |||
| val itemName: String, | |||
| val unitOfMeasure: String, | |||
| val qtyByDate: MutableMap<LocalDate, Double>, | |||
| ) | |||
| companion object { | |||
| private const val MAX_DAILY_COLUMNS = 366 | |||
| } | |||
| } | |||
| @@ -237,7 +237,7 @@ class StockItemConsumptionTrendReportController( | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0.00") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| @@ -1,12 +1,13 @@ | |||
| package com.ffii.fpsms.modules.report.web | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.web.bind.annotation.* | |||
| import org.springframework.http.* | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| import com.ffii.fpsms.modules.report.service.ReportMultiValueTokens | |||
| import com.ffii.fpsms.modules.report.service.StockLedgerPdf | |||
| import com.ffii.fpsms.modules.report.service.StockLedgerReportService | |||
| import com.ffii.fpsms.modules.report.service.ReportService | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.CellStyle | |||
| import org.apache.poi.ss.usermodel.DataFormat | |||
| @@ -24,9 +25,9 @@ import java.io.ByteArrayOutputStream | |||
| @RestController | |||
| @RequestMapping("/report") | |||
| class StockLedgerReportController( | |||
| private val reportService: ReportService, | |||
| private val stockLedgerReportService: StockLedgerReportService, | |||
| ) { | |||
| private val log = LoggerFactory.getLogger(StockLedgerReportController::class.java) | |||
| private data class ExcelStyles( | |||
| val title: CellStyle, | |||
| val subtitle: CellStyle, | |||
| @@ -98,27 +99,6 @@ class StockLedgerReportController( | |||
| reportPeriodStart: String?, | |||
| reportPeriodEnd: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val tokenCount = ReportMultiValueTokens.split(itemCode).size | |||
| val parameters = mutableMapOf<String, Any>() | |||
| parameters["stockCategory"] = stockCategory ?: "All" | |||
| parameters["stockSubCategory"] = stockCategory ?: "All" | |||
| parameters["itemNo"] = | |||
| when { | |||
| itemCode.isNullOrBlank() -> "All" | |||
| tokenCount > 30 -> "$tokenCount items" | |||
| else -> itemCode | |||
| } | |||
| parameters["year"] = LocalDate.now().year.toString() | |||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||
| parameters["storeLocation"] = storeLocation ?: "" | |||
| parameters["balanceFilterStart"] = "" | |||
| parameters["balanceFilterEnd"] = "" | |||
| parameters["reportPeriodStart"] = reportPeriodStart ?: "" | |||
| parameters["reportPeriodEnd"] = reportPeriodEnd ?: "" | |||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | |||
| stockCategory = stockCategory, | |||
| itemCode = itemCode, | |||
| @@ -127,10 +107,12 @@ class StockLedgerReportController( | |||
| reportPeriodEnd = reportPeriodEnd, | |||
| ) | |||
| val pdfBytes = reportService.createPdfResponse( | |||
| "/jasper/StockLedgarReport.jrxml", | |||
| parameters, | |||
| dbData, | |||
| val pdfBytes = StockLedgerPdf.write( | |||
| rows = dbData, | |||
| reportDate = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")), | |||
| reportTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")), | |||
| periodStart = reportPeriodStart?.trim().orEmpty(), | |||
| periodEnd = reportPeriodEnd?.trim().orEmpty(), | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| @@ -148,6 +130,7 @@ class StockLedgerReportController( | |||
| reportPeriodStart: String?, | |||
| reportPeriodEnd: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val tSearch = System.nanoTime() | |||
| val dbData = stockLedgerReportService.searchStockLedgerReport( | |||
| stockCategory = stockCategory, | |||
| itemCode = itemCode, | |||
| @@ -155,12 +138,20 @@ class StockLedgerReportController( | |||
| reportPeriodStart = reportPeriodStart, | |||
| reportPeriodEnd = reportPeriodEnd, | |||
| ) | |||
| val searchMs = (System.nanoTime() - tSearch) / 1_000_000 | |||
| val tExcel = System.nanoTime() | |||
| val excelBytes = createStockLedgerExcel( | |||
| dbData = dbData, | |||
| reportPeriodStart = reportPeriodStart ?: "", | |||
| reportPeriodEnd = reportPeriodEnd ?: "", | |||
| ) | |||
| log.info( | |||
| "stock-ledger excel rows={} search={}ms excel={}ms bytes={}", | |||
| dbData.size, | |||
| searchMs, | |||
| (System.nanoTime() - tExcel) / 1_000_000, | |||
| excelBytes.size, | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType( | |||
| @@ -227,7 +218,7 @@ class StockLedgerReportController( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| @@ -241,7 +232,7 @@ class StockLedgerReportController( | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| borderTop = BorderStyle.THICK | |||
| borderBottom = BorderStyle.THICK | |||
| borderLeft = BorderStyle.THIN | |||
| @@ -323,7 +314,7 @@ class StockLedgerReportController( | |||
| return | |||
| } | |||
| if (parsed < 0) { | |||
| val s = "%,d".format(kotlin.math.abs(parsed).toLong()) | |||
| val s = "%,.2f".format(kotlin.math.abs(parsed)) | |||
| cell.setCellValue("($s)") | |||
| cell.cellStyle = dashStyle | |||
| } else { | |||
| @@ -427,7 +418,7 @@ class StockLedgerReportController( | |||
| val itemName = m["itemName"]?.toString().orEmpty() | |||
| val uom = m["unitOfMeasure"]?.toString().orEmpty() | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { | |||
| addItemSummaryRow( | |||
| currentItemNo!!, | |||
| currentItemName, | |||
| @@ -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 | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.1 | 2026-09-07 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 68 | v1.0.2 | 2026-09-28 */ | |||
| private fun createExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportDate: String, | |||
| @@ -310,6 +310,9 @@ class StockLotOnhandReportController( | |||
| sheet.createFreezePane(0, headerRowIndex + 1) | |||
| intArrayOf(14, 28, 14, 8, 20, 12, 10, 12, 10, 10, 12, 20, 12, 12, 14) | |||
| .forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) } | |||
| // Keep 單位均價 and 庫存總價值 in the sheet, but hide them for now. | |||
| sheet.setColumnHidden(13, true) | |||
| sheet.setColumnHidden(14, true) | |||
| val out = ByteArrayOutputStream() | |||
| workbook.write(out) | |||
| @@ -328,7 +328,7 @@ class StockTakeVarianceReportController( | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| @@ -344,7 +344,7 @@ class StockTakeVarianceReportController( | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| val df: DataFormat = workbook.createDataFormat() | |||
| dataFormat = df.getFormat("#,##0") | |||
| dataFormat = df.getFormat("#,##0.00") | |||
| borderTop = BorderStyle.THICK | |||
| borderBottom = BorderStyle.THICK | |||
| borderLeft = BorderStyle.THIN | |||
| @@ -490,7 +490,7 @@ class StockTakeVarianceReportController( | |||
| val itemName = rowMap["itemName"]?.toString().orEmpty() | |||
| val uom = rowMap["unitOfMeasure"]?.toString().orEmpty() | |||
| if (currentItemNo != null && itemNo != currentItemNo) { | |||
| if (currentItemNo != null && (itemNo != currentItemNo || uom != currentUom)) { | |||
| addItemSummaryRow(currentItemTotalQty, currentItemTotalStockTakeQty, currentItemNo!!, currentItemName) | |||
| addBlankSeparatorRow() | |||
| currentItemTotalQty = 0.0 | |||
| @@ -578,8 +578,7 @@ class StockTakeVarianceReportController( | |||
| } | |||
| private fun formatAbsInt(v: Double): String { | |||
| val abs = kotlin.math.abs(v).toLong() | |||
| return "%,d".format(abs) | |||
| return "%,.2f".format(kotlin.math.abs(v)) | |||
| } | |||
| private data class Rep012ReportData( | |||
| @@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.settings.web; | |||
| import java.util.List; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.security.access.AccessDeniedException; | |||
| import org.springframework.security.core.Authentication; | |||
| import org.springframework.security.core.context.SecurityContextHolder; | |||
| import org.springframework.web.bind.annotation.GetMapping; | |||
| import org.springframework.web.bind.annotation.PatchMapping; | |||
| import org.springframework.web.bind.annotation.PostMapping; | |||
| @@ -14,6 +17,7 @@ import org.springframework.web.bind.annotation.RestController; | |||
| import com.ffii.core.exception.BadRequestException; | |||
| import com.ffii.core.exception.NotFoundException; | |||
| import com.ffii.fpsms.modules.common.SettingNames; | |||
| import com.ffii.fpsms.modules.settings.entity.Settings; | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService; | |||
| @@ -53,6 +57,9 @@ public class SettingsController{ | |||
| } | |||
| private void applyUpdate(String name, UpdateReq body) { | |||
| if (SettingNames.STOCK_TAKE_QTY_GAP_WARN_PERCENT.equals(name) && !isAdmin()) { | |||
| throw new AccessDeniedException("ADMIN only"); | |||
| } | |||
| Settings entity = this.settingsService.findByName(name) | |||
| .orElseThrow(NotFoundException::new); | |||
| if (!this.settingsService.validateType(entity.getType(), body.getValue())) { | |||
| @@ -63,6 +70,12 @@ public class SettingsController{ | |||
| this.settingsService.save(entity); | |||
| } | |||
| private static boolean isAdmin() { | |||
| Authentication auth = SecurityContextHolder.getContext().getAuthentication(); | |||
| if (auth == null) return false; | |||
| return auth.getAuthorities().stream().anyMatch(a -> "ADMIN".equals(a.getAuthority())); | |||
| } | |||
| public static class UpdateReq { | |||
| @NotBlank | |||
| private String value; | |||
| @@ -54,10 +54,19 @@ open class Inventory: BaseEntity<Long>(){ | |||
| open var cpmUnit: String? = null | |||
| // @NotNull | |||
| /** Base UOM snapshot only (set when the inventory row is created; not the bucket key). */ | |||
| @ManyToOne | |||
| @JoinColumn(name = "uomId") | |||
| open var uom: UomConversion? = null | |||
| /** | |||
| * Stock-UOM bucket key = uom_conversion.id. | |||
| * Unique with itemId: uk_inventory_item_stock_uom. Nullable until data patch. | |||
| */ | |||
| @ManyToOne | |||
| @JoinColumn(name = "stockUomId") | |||
| open var stockUom: UomConversion? = null | |||
| // @NotNull | |||
| @Column(name = "status") | |||
| open var status: String? = null | |||
| @@ -3,6 +3,7 @@ package com.ffii.fpsms.modules.stock.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import com.ffii.fpsms.modules.stock.entity.projection.CurrentInventoryItemInfo | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryLotLineInfo | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow | |||
| import org.springframework.data.domain.Page | |||
| import org.springframework.data.domain.Pageable | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| @@ -56,9 +57,119 @@ WHERE ill.id = :id | |||
| fun findInventoryLotLineInfoByInventoryLotItemIdIn(ids: List<Serializable>): List<InventoryLotLineInfo> | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.1 | 2026-09-21 */ | |||
| @Query("select ill from InventoryLotLine ill where :id is null or ill.inventoryLot.item.id = :id order by ill.id desc") | |||
| fun findInventoryLotLineInfoByItemId(id: Long?, pageable: Pageable): Page<InventoryLotLineInfo> | |||
| @Query( | |||
| """ | |||
| SELECT ill FROM InventoryLotLine ill | |||
| WHERE (:id IS NULL OR ill.inventoryLot.item.id = :id) | |||
| AND (:uomId IS NULL OR ill.stockUom.uom.id = :uomId) | |||
| ORDER BY ill.id DESC | |||
| """ | |||
| ) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */ | |||
| fun findInventoryLotLineInfoByItemId( | |||
| @Param("id") id: Long?, | |||
| @Param("uomId") uomId: Long?, | |||
| pageable: Pageable, | |||
| ): Page<InventoryLotLineInfo> | |||
| @Query( | |||
| """ | |||
| SELECT DISTINCT ill.inventoryLot.item.id | |||
| FROM InventoryLotLine ill | |||
| WHERE ill.deleted = false | |||
| AND ill.status = :status | |||
| AND ill.warehouse.id IN :warehouseIds | |||
| """ | |||
| ) | |||
| fun findDistinctItemIdsByWarehouseIdsAndStatus( | |||
| @Param("warehouseIds") warehouseIds: List<Long>, | |||
| @Param("status") status: InventoryLotLineStatus, | |||
| ): List<Long> | |||
| @Query( | |||
| """ | |||
| SELECT ill FROM InventoryLotLine ill | |||
| WHERE (:id IS NULL OR ill.inventoryLot.item.id = :id) | |||
| AND ill.warehouse.id IN :warehouseIds | |||
| AND (:uomId IS NULL OR ill.stockUom.uom.id = :uomId) | |||
| ORDER BY ill.id DESC | |||
| """ | |||
| ) | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */ | |||
| fun findInventoryLotLineInfoByItemIdAndWarehouseIdIn( | |||
| @Param("id") id: Long?, | |||
| @Param("warehouseIds") warehouseIds: List<Long>, | |||
| @Param("uomId") uomId: Long?, | |||
| pageable: Pageable, | |||
| ): Page<InventoryLotLineInfo> | |||
| @Query( | |||
| value = """ | |||
| SELECT | |||
| MAX(CAST(ill.id AS long)) AS id, | |||
| CAST(item.id AS long) AS itemId, | |||
| item.code AS itemCode, | |||
| item.name AS itemName, | |||
| item.type AS itemType, | |||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS onHandQty, | |||
| SUM(COALESCE(ill.holdQty, 0)) AS onHoldQty, | |||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS availableQty, | |||
| CAST(uom.id AS long) AS uomId, | |||
| uom.code AS uomCode, | |||
| uom.udfudesc AS uomUdfudesc, | |||
| uom.udfShortDesc AS uomShortDesc, | |||
| SUM(COALESCE(ill.inQty, 0) - COALESCE(ill.outQty, 0)) AS qtyPerSmallestUnit, | |||
| MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, | |||
| MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate | |||
| FROM InventoryLotLine ill | |||
| JOIN ill.inventoryLot il | |||
| JOIN il.item item | |||
| JOIN ill.stockUom su | |||
| JOIN su.uom uom | |||
| WHERE ill.deleted = false | |||
| AND il.deleted = false | |||
| AND item.deleted = false | |||
| AND ill.status = :status | |||
| AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) | |||
| AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) | |||
| AND (:type IS NULL OR :type = '' OR item.type = :type) | |||
| AND (:hasWarehouseFilter = false OR ill.warehouse.id IN :warehouseIds) | |||
| AND (:lotNo IS NULL OR :lotNo = '' OR il.lotNo = :lotNo) | |||
| GROUP BY item.id, item.code, item.name, item.type, uom.id, uom.code, uom.udfudesc, uom.udfShortDesc | |||
| ORDER BY item.code ASC, uom.udfudesc ASC | |||
| """, | |||
| countQuery = """ | |||
| SELECT COUNT(DISTINCT CONCAT(CAST(item.id AS string), ':', CAST(uom.id AS string))) | |||
| FROM InventoryLotLine ill | |||
| JOIN ill.inventoryLot il | |||
| JOIN il.item item | |||
| JOIN ill.stockUom su | |||
| JOIN su.uom uom | |||
| WHERE ill.deleted = false | |||
| AND il.deleted = false | |||
| AND item.deleted = false | |||
| AND ill.status = :status | |||
| AND (:code IS NULL OR :code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) | |||
| AND (:name IS NULL OR :name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) | |||
| AND (:type IS NULL OR :type = '' OR item.type = :type) | |||
| AND (:hasWarehouseFilter = false OR ill.warehouse.id IN :warehouseIds) | |||
| AND (:lotNo IS NULL OR :lotNo = '' OR il.lotNo = :lotNo) | |||
| """, | |||
| ) | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 | |||
| * Lot UOMs only. Location Search uses [com.ffii.fpsms.modules.stock.service.InventoryService.searchInventoriesByLatestInventory]. | |||
| */ | |||
| fun searchInventoryGroupedByStockUom( | |||
| @Param("code") code: String, | |||
| @Param("name") name: String, | |||
| @Param("type") type: String, | |||
| @Param("status") status: InventoryLotLineStatus, | |||
| @Param("hasWarehouseFilter") hasWarehouseFilter: Boolean, | |||
| @Param("warehouseIds") warehouseIds: List<Long>, | |||
| @Param("lotNo") lotNo: String?, | |||
| pageable: Pageable, | |||
| ): Page<InventoryStockUomSearchRow> | |||
| @Query(""" | |||
| SELECT ill FROM InventoryLotLine ill | |||
| @@ -326,4 +437,17 @@ WHERE ill.id = :id | |||
| fun findAllByIdInAndDeletedFalseWithRefs( | |||
| @Param("ids") ids: Collection<Long> | |||
| ): List<InventoryLotLine> | |||
| /** Delivery-note route column: warehouse code without a lazy load per lot line. */ | |||
| @EntityGraph( | |||
| type = EntityGraph.EntityGraphType.FETCH, | |||
| attributePaths = ["warehouse"] | |||
| ) | |||
| @Query( | |||
| """ | |||
| SELECT ill FROM InventoryLotLine ill | |||
| WHERE ill.id IN :ids | |||
| """ | |||
| ) | |||
| fun findAllByIdInWithWarehouse(@Param("ids") ids: Collection<Long>): List<InventoryLotLine> | |||
| } | |||
| @@ -14,43 +14,49 @@ import java.util.Optional | |||
| interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| fun findInventoryInfoByDeletedIsFalse(): List<InventoryInfo> | |||
| /** | |||
| * All non-deleted inventory rows matching filters (one row per stock-UOM bucket). | |||
| * Does not require baseUnit match — that hid non-base buckets. | |||
| */ | |||
| @Query( | |||
| """ | |||
| SELECT i FROM Inventory i | |||
| WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) | |||
| AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) | |||
| AND (:type IS NULL OR :type = '' OR i.item.type = :type) | |||
| AND i.deleted = false | |||
| AND EXISTS ( | |||
| SELECT 1 FROM ItemUom iu | |||
| WHERE iu.item.id = i.item.id | |||
| AND iu.deleted = false | |||
| AND iu.baseUnit = true | |||
| AND iu.uom.id = i.uom.id | |||
| ) | |||
| """ | |||
| ) | |||
| fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse(code: String, name: String, type: String, pageable: Pageable): Page<InventoryInfo> | |||
| """ | |||
| SELECT i FROM Inventory i | |||
| WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) | |||
| AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) | |||
| AND (:type IS NULL OR :type = '' OR i.item.type = :type) | |||
| AND i.deleted = false | |||
| """ | |||
| ) | |||
| fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( | |||
| code: String, | |||
| name: String, | |||
| type: String, | |||
| pageable: Pageable, | |||
| ): Page<InventoryInfo> | |||
| @Query("SELECT i FROM Inventory i " + | |||
| "WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) " + | |||
| "AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) " + | |||
| "AND (:type IS NULL OR :type = '' OR i.item.type = :type) " + | |||
| "AND i.item.id IN :itemIds " + | |||
| "AND i.deleted = false") | |||
| @Query( | |||
| """ | |||
| SELECT i FROM Inventory i | |||
| WHERE (:code IS NULL OR i.item.code LIKE CONCAT('%', :code, '%')) | |||
| AND (:name IS NULL OR i.item.name LIKE CONCAT('%', :name, '%')) | |||
| AND (:type IS NULL OR :type = '' OR i.item.type = :type) | |||
| AND i.item.id IN :itemIds | |||
| AND i.deleted = false | |||
| """ | |||
| ) | |||
| fun findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | |||
| code: String, | |||
| name: String, | |||
| type: String, | |||
| itemIds: List<Long>, | |||
| pageable: Pageable | |||
| pageable: Pageable, | |||
| ): Page<InventoryInfo> | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 | |||
| * Inventory search page only: no baseUnit/uomId match required; | |||
| * one row per item = latest inventory id (no larger id for same item). | |||
| * Formerly "latest id per item" — that hid older stock-UOM buckets. | |||
| * Now returns all matching rows (same as non-latest search). Prefer the non-Latest methods. | |||
| */ | |||
| @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse — returns all stock-UOM buckets") | |||
| @Query( | |||
| """ | |||
| SELECT i FROM Inventory i | |||
| @@ -58,12 +64,6 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| AND (:name IS NULL OR :name = '' OR LOWER(i.item.name) LIKE LOWER(CONCAT('%', :name, '%'))) | |||
| AND (:type IS NULL OR :type = '' OR i.item.type = :type) | |||
| AND i.deleted = false | |||
| AND NOT EXISTS ( | |||
| SELECT 1 FROM Inventory i2 | |||
| WHERE i2.item.id = i.item.id | |||
| AND i2.deleted = false | |||
| AND i2.id > i.id | |||
| ) | |||
| """ | |||
| ) | |||
| fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( | |||
| @@ -73,7 +73,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| pageable: Pageable, | |||
| ): Page<InventoryInfo> | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.0 | 2026-07-17 */ | |||
| @Deprecated("Use findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse") | |||
| @Query( | |||
| """ | |||
| SELECT i FROM Inventory i | |||
| @@ -82,12 +82,6 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| AND (:type IS NULL OR :type = '' OR i.item.type = :type) | |||
| AND i.item.id IN :itemIds | |||
| AND i.deleted = false | |||
| AND NOT EXISTS ( | |||
| SELECT 1 FROM Inventory i2 | |||
| WHERE i2.item.id = i.item.id | |||
| AND i2.deleted = false | |||
| AND i2.id > i.id | |||
| ) | |||
| """ | |||
| ) | |||
| fun findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | |||
| @@ -102,7 +96,13 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo> | |||
| /** | |||
| * Unsafe when an item has multiple stock-UOM buckets (NonUniqueResultException). | |||
| * Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse] or [findAllByItemIdAndDeletedIsFalse]. | |||
| */ | |||
| @Deprecated("Multiple stock-UOM buckets per item — use findByItemIdAndStockUomIdAndDeletedIsFalse") | |||
| fun findByItemId(itemId: Long): Optional<Inventory> | |||
| fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory> | |||
| @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") | |||
| @@ -110,6 +110,15 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> { | |||
| fun findFirstByItemIdAndDeletedIsFalseOrderByIdAsc(itemId: Long): Inventory? | |||
| /** 與 item_uom 中 baseUnit=true 之 uomId 對齊時使用,避免同一 item 多筆 inventory 時 NonUniqueResult */ | |||
| /** | |||
| * Legacy base-UOM lookup. Prefer [findByItemIdAndStockUomIdAndDeletedIsFalse]. | |||
| * Shared base uomId across buckets can still collide until callers migrate. | |||
| */ | |||
| @Deprecated("Prefer findByItemIdAndStockUomIdAndDeletedIsFalse") | |||
| fun findFirstByItemIdAndUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, uomId: Long): Inventory? | |||
| } | |||
| /** Resolve one inventory bucket by (itemId, stockUomId). */ | |||
| fun findByItemIdAndStockUomIdAndDeletedIsFalse(itemId: Long, stockUomId: Long): Inventory? | |||
| fun findFirstByItemIdAndStockUomIdAndDeletedIsFalseOrderByIdAsc(itemId: Long, stockUomId: Long): Inventory? | |||
| } | |||
| @@ -60,6 +60,7 @@ interface StockLedgerRepository: AbstractRepository<StockLedger, Long> { | |||
| """) | |||
| fun findLatestByItemId(@Param("itemId") itemId: Long): List<StockLedger> | |||
| fun findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId: Long): StockLedger? | |||
| fun findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(inventoryId: Long): StockLedger? | |||
| @Query(""" | |||
| SELECT sl FROM StockLedger sl | |||
| @@ -81,6 +81,10 @@ open class StockTakeRecord : BaseEntity<Long>() { | |||
| @Column(name = "lastSelect") | |||
| open var lastSelect: Int? = null | |||
| /** How many times the picker has changed an already saved first count. Max 2. */ | |||
| @Column(name = "pickerReviseCount", nullable = false) | |||
| open var pickerReviseCount: Int? = 0 | |||
| @Column(name = "approverTime") | |||
| open var approverTime: LocalDateTime? = null | |||
| @Column(name = "date", nullable = false) | |||
| @@ -14,41 +14,28 @@ interface InventoryInfo{ | |||
| val itemName: String? | |||
| @get:Value("#{target.item.type}") | |||
| val itemType: String? | |||
| // @get:Value("#{target.qty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") | |||
| // @get:Value("#{target.onHandQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") | |||
| @get:Value("#{target.onHandQty}") | |||
| val onHandQty: BigDecimal? | |||
| // @get:Value("#{target.onHoldQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") | |||
| @get:Value("#{target.onHoldQty}") | |||
| val onHoldQty: BigDecimal? | |||
| // @get:Value("#{target.unavailableQty / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") | |||
| @get:Value("#{target.unavailableQty}") | |||
| val unavailableQty: BigDecimal? | |||
| // @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty) / (target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioN / target.item.itemUoms.^[stockUnit == true && deleted == false]?.ratioD)}") | |||
| @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}") | |||
| /** Available = onHand - unavailable (onHold is not maintained by stock-UOM triggers). */ | |||
| @get:Value("#{(target.onHandQty - target.unavailableQty)}") | |||
| val availableQty: BigDecimal? | |||
| @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom.code}") | |||
| @get:Value("#{target.stockUom?.id}") | |||
| val stockUomId: Long? | |||
| @get:Value("#{target.stockUom?.code}") | |||
| val uomCode: String? | |||
| @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfudesc}") | |||
| @get:Value("#{target.stockUom?.udfudesc}") | |||
| val uomUdfudesc: String? | |||
| @get:Value("#{target.item.itemUoms.^[stockUnit == true && deleted == false]?.uom?.udfShortDesc}") | |||
| @get:Value("#{target.stockUom?.udfShortDesc}") | |||
| val uomShortDesc: String? | |||
| // @get:Value("#{target.qty * target.uom.gramPerSmallestUnit}") | |||
| // val germPerSmallestUnit: BigDecimal? | |||
| @get:Value("#{(target.onHandQty - target.onHoldQty - target.unavailableQty)}") | |||
| @get:Value("#{(target.onHandQty - target.unavailableQty)}") | |||
| val qtyPerSmallestUnit: BigDecimal? | |||
| @get:Value("#{target.item.itemUoms.^[baseUnit == true && deleted == false]?.uom?.udfudesc}") | |||
| /** Base UOM snapshot from inventory.uomId (not current item baseUnit master). */ | |||
| @get:Value("#{target.uom?.udfudesc}") | |||
| val baseUom: String? | |||
| // @get:Value("#{target.qty * (target.uom.unit4 != '' ? target.uom.unit4Qty " + | |||
| // ": target.uom.unit3 != '' ? target.uom.unit3Qty " + | |||
| // ": target.uom.unit2 != '' ? target.uom.unit2Qty " + | |||
| // ": target.uom.unit1Qty)}") | |||
| // val qtyPerSmallestUnit: BigDecimal? | |||
| // @get:Value("#{target.uom.unit4 != '' ? target.uom.unit4 " + | |||
| // ": target.uom.unit3 != '' ? target.uom.unit3 " + | |||
| // ": target.uom.unit2 != '' ? target.uom.unit2 " + | |||
| // ": target.uom.unit1}") | |||
| // val smallestUnit: String? | |||
| val price: BigDecimal? | |||
| @get:Value("#{target.currency?.name}") | |||
| val currencyName: String? | |||
| @@ -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.utils.PdfUtils | |||
| import com.ffii.core.utils.QrCodeUtil | |||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | |||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||
| @@ -25,8 +24,10 @@ import net.sf.jasperreports.engine.JasperCompileManager | |||
| import org.springframework.core.io.ClassPathResource | |||
| import org.springframework.data.domain.PageRequest | |||
| import java.time.LocalDate | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import java.io.FileNotFoundException | |||
| import java.io.IOException | |||
| import java.math.BigDecimal | |||
| @@ -35,7 +36,6 @@ import java.util.Optional | |||
| import kotlin.jvm.optionals.getOrNull | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | |||
| import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo | |||
| @@ -44,6 +44,7 @@ import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | |||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest | |||
| import com.ffii.core.utils.ZebraPrinterUtil | |||
| @@ -58,8 +59,8 @@ open class InventoryLotLineService( | |||
| private val warehouseRepository: WarehouseRepository, | |||
| private val itemUomRespository: ItemUomRespository, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val printerService: PrinterService, | |||
| private val settingsService: SettingsService, | |||
| @Lazy | |||
| private val jobOrderService: JobOrderService | |||
| ) { | |||
| @@ -76,10 +77,15 @@ open class InventoryLotLineService( | |||
| return inventoryLotLineRepository.findInventoryLotLineInfoByInventoryLotItemIdIn(itemIds) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.1 | 2026-09-21 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 */ | |||
| open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes<InventoryLotLineInfo> { | |||
| val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10); | |||
| val warehouseIds = resolveWarehouseIds(request.storeId, request.warehouse, request.area) | |||
| if (warehouseIds != null && warehouseIds.isEmpty()) { | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| val response = if (request.stockIssueBadItem == true) { | |||
| inventoryLotLineRepository.findStockIssueBadItemLotLinesByItemId( | |||
| request.itemId, | |||
| @@ -87,8 +93,15 @@ open class InventoryLotLineService( | |||
| listOf(InventoryLotLineStatus.AVAILABLE, InventoryLotLineStatus.UNAVAILABLE), | |||
| pageable, | |||
| ) | |||
| } else if (warehouseIds != null) { | |||
| inventoryLotLineRepository.findInventoryLotLineInfoByItemIdAndWarehouseIdIn( | |||
| request.itemId, | |||
| warehouseIds, | |||
| request.uomId, | |||
| pageable, | |||
| ) | |||
| } else { | |||
| inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, pageable) | |||
| inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, request.uomId, pageable) | |||
| } | |||
| val records = response.content | |||
| @@ -96,6 +109,28 @@ open class InventoryLotLineService( | |||
| return RecordsRes<InventoryLotLineInfo>(records, total.toInt()); | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 80 | v1.0.0 | 2026-09-10 | |||
| * Returns null when no location filter is set. | |||
| */ | |||
| private fun resolveWarehouseIds( | |||
| storeId: String?, | |||
| warehouse: String?, | |||
| area: String?, | |||
| ): List<Long>? { | |||
| val trimmedStoreId = storeId?.trim()?.takeIf { it.isNotEmpty() } | |||
| val trimmedWarehouse = warehouse?.trim()?.takeIf { it.isNotEmpty() } | |||
| val trimmedArea = area?.trim()?.takeIf { it.isNotEmpty() } | |||
| if (trimmedStoreId == null && trimmedWarehouse == null && trimmedArea == null) { | |||
| return null | |||
| } | |||
| return warehouseRepository.findByLocationFilters( | |||
| trimmedStoreId, | |||
| trimmedWarehouse, | |||
| trimmedArea, | |||
| ).mapNotNull { it.id } | |||
| } | |||
| open fun searchStockIssueBadItemLotLines( | |||
| request: SearchStockIssueBadItemLotLineRequest, | |||
| ): RecordsRes<InventoryLotLineInfo> { | |||
| @@ -151,11 +186,29 @@ open class InventoryLotLineService( | |||
| } | |||
| } | |||
| open fun saveInventoryLotLine(request: SaveInventoryLotLineRequest): InventoryLotLine { | |||
| val inventoryLotLine = | |||
| request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } ?: InventoryLotLine() | |||
| val existing = request.id?.let { inventoryLotLineRepository.findById(it).getOrNull() } | |||
| val inventoryLotLine = existing ?: InventoryLotLine() | |||
| val inventoryLot = request.inventoryLotId?.let { inventoryLotRepository.findById(it).getOrNull() } | |||
| val warehouse = request.warehouseId?.let { warehouseRepository.findById(it).getOrNull() } | |||
| val stockUom = request.stockUomId?.let { itemUomRespository.findById(it).getOrNull() } | |||
| ?: existing?.stockUom | |||
| if (stockUom == null) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "入倉明細缺少庫存單位,無法入帳") | |||
| } | |||
| // M-E-01: one inventory lot must not mix two stock-UOM buckets (item_uom.uomId). | |||
| val lotId = inventoryLot?.id ?: existing?.inventoryLot?.id | |||
| val newBucketUomId = stockUom.uom?.id | |||
| if (lotId != null && newBucketUomId != null) { | |||
| val conflict = inventoryLotLineRepository.findAllByInventoryLotId(lotId).any { sibling -> | |||
| sibling.deleted != true && | |||
| sibling.id != existing?.id && | |||
| sibling.stockUom?.uom?.id != null && | |||
| sibling.stockUom!!.uom!!.id != newBucketUomId | |||
| } | |||
| if (conflict) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, "同一批次不可混用不同庫存單位") | |||
| } | |||
| } | |||
| val status = request.status?.let { _status -> InventoryLotLineStatus.entries.find { it.value == _status } } | |||
| inventoryLotLine.apply { | |||
| @@ -192,9 +245,6 @@ open class InventoryLotLineService( | |||
| val updatedLotLine = saveInventoryLotLine(updateRequest) | |||
| // ADD THIS: Update inventory table after lot line status change | |||
| updateInventoryTable(updatedLotLine) | |||
| return MessageResponse( | |||
| id = updatedLotLine.id, | |||
| name = updatedLotLine.id.toString(), | |||
| @@ -205,49 +255,11 @@ open class InventoryLotLineService( | |||
| ) | |||
| } | |||
| // ADD THIS: New method to update inventory table | |||
| private fun updateInventoryTable(inventoryLotLine: InventoryLotLine) { | |||
| try { | |||
| // Get the item ID from the inventory lot | |||
| val itemId = inventoryLotLine.inventoryLot?.item?.id | |||
| if (itemId == null) { | |||
| println("Cannot update inventory table: itemId is null for lot line ${inventoryLotLine.id}") | |||
| return | |||
| } | |||
| // Calculate onHoldQty (sum of holdQty from available lots only) | |||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||
| .sumOf { it.holdQty ?: BigDecimal.ZERO } | |||
| // Calculate unavailableQty (sum of inQty from unavailable lots only) | |||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE) | |||
| .sumOf { | |||
| val inQty = it.inQty ?: BigDecimal.ZERO | |||
| val outQty = it.outQty ?: BigDecimal.ZERO | |||
| val remainingQty = inQty.minus(outQty) | |||
| remainingQty | |||
| } | |||
| // Update the inventory table | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| if (inventory != null) { | |||
| inventory.onHoldQty = onHoldQty | |||
| inventory.unavailableQty = unavailableQty | |||
| inventoryRepository.save(inventory) | |||
| println("Updated inventory for item $itemId: onHoldQty=$onHoldQty, unavailableQty=$unavailableQty") | |||
| } else { | |||
| println("Inventory not found for item $itemId") | |||
| } | |||
| } catch (e: Exception) { | |||
| println("Error updating inventory table for lot line ${inventoryLotLine.id}: ${e.message}") | |||
| e.printStackTrace() | |||
| } | |||
| } | |||
| @Throws(IOException::class) | |||
| @Transactional | |||
| open fun exportStockInLineQrcode(request: LotLineToQrcode): Map<String, Any> { | |||
| val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" | |||
| val labelVersion = PoItemLabelTemplate.version(settingsService) | |||
| val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) | |||
| val resource = ClassPathResource(QRCODE_PDF) | |||
| if (!resource.exists()) { | |||
| println("here") | |||
| @@ -287,13 +299,18 @@ open class InventoryLotLineService( | |||
| field["acceptedQty"] = "%.2f".format(info.acceptedQty) | |||
| val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) | |||
| field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" | |||
| val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" | |||
| val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc | |||
| field["uom"] = fullUom | |||
| val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) | |||
| field["qtyLabel"] = qtyLabel | |||
| field["qtyDetail"] = qtyDetail | |||
| field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | |||
| field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | |||
| field["lotNo"] = info.lotNo!! | |||
| field["supplier"] = info.supplier ?: "N/A" | |||
| val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) | |||
| val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) | |||
| field["qrCode"] = image | |||
| fields.add(field) | |||
| } | |||
| @@ -17,14 +17,19 @@ import com.ffii.fpsms.modules.purchaseOrder.enums.PurchaseOrderType | |||
| import com.ffii.fpsms.modules.stock.entity.* | |||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfoDto | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRowDto | |||
| import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest | |||
| import org.apache.poi.ss.usermodel.Sheet | |||
| import org.apache.poi.ss.usermodel.Workbook | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.springframework.core.io.support.PathMatchingResourcePatternResolver | |||
| import org.springframework.data.domain.PageRequest | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import java.math.BigDecimal | |||
| import java.sql.Timestamp | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import java.time.ZoneId | |||
| @@ -57,73 +62,355 @@ open class InventoryService( | |||
| return inventoryRepository.findInventoryInfoByDeletedIsFalse(); | |||
| } | |||
| open fun allInventoriesByPage(request: SearchInventoryRequest): RecordsRes<InventoryInfo>{ | |||
| val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) | |||
| /** | |||
| * Item Search: one row per (itemId, stockUomId) bucket, plus current master stockUnit | |||
| * as a 0-qty row when that UoM has no inventory bucket. | |||
| */ | |||
| open fun allInventoriesByPage(request: SearchInventoryRequest): RecordsRes<InventoryInfo> { | |||
| val pageSize = (request.pageSize ?: 10).coerceAtLeast(1) | |||
| val pageNum = (request.pageNum ?: 0).coerceAtLeast(0) | |||
| val code = request.code?.trim().orEmpty() | |||
| val name = request.name?.trim().orEmpty() | |||
| val type = request.type?.trim().orEmpty() | |||
| val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } | |||
| val response = if (lotNo != null) { | |||
| val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) | |||
| if (itemIds.isEmpty()) { | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| inventoryRepository.findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | |||
| code = request.code, | |||
| name = request.name, | |||
| type = request.type, | |||
| itemIds = itemIds, | |||
| pageable = pageable | |||
| ) | |||
| val itemIds = if (lotNo != null) { | |||
| inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) | |||
| } else { | |||
| inventoryRepository.findInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( | |||
| code = request.code, | |||
| name = request.name, | |||
| type = request.type, | |||
| pageable = pageable | |||
| ) | |||
| emptyList() | |||
| } | |||
| if (lotNo != null && itemIds.isEmpty()) { | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| val records = response.content | |||
| val total = response.totalElements | |||
| return RecordsRes<InventoryInfo>(records, total.toInt()); | |||
| val args = mutableMapOf<String, Any>( | |||
| "code" to code, | |||
| "name" to name, | |||
| "type" to type, | |||
| "hasItemIdFilter" to if (lotNo != null) 1 else 0, | |||
| "itemIds" to itemIds.ifEmpty { listOf(-1L) }, | |||
| ) | |||
| val unionSql = buildItemSearchUnionSql() | |||
| val total = jdbcDao.queryForInt("SELECT COUNT(*) FROM ($unionSql) t", args) | |||
| args["limit"] = pageSize | |||
| args["offset"] = pageNum * pageSize | |||
| val rows = jdbcDao.queryForList( | |||
| "$unionSql ORDER BY itemCode ASC, uomUdfudesc ASC LIMIT :limit OFFSET :offset", | |||
| args, | |||
| ) | |||
| return RecordsRes(rows.map { mapItemSearchRow(it) }, total) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21 | |||
| * Inventory search page only: no baseUnit/uomId filter; | |||
| * one inventory row per item (latest id). Shared getRecordByPage is unchanged. | |||
| * Location Search: one row per item + lot-line stock UoM (bucket key), plus current | |||
| * master stockUnit as a 0-qty row when that UoM has no matching lots. | |||
| * Qty formula matches inventory bucket display: available = onHand − unavailable | |||
| * (hold shown separately; not subtracted). See [InventoryLocationSearchQty]. | |||
| */ | |||
| open fun searchInventoriesByLatestInventory( | |||
| request: SearchInventoryRequest, | |||
| ): RecordsRes<InventoryInfo> { | |||
| val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10) | |||
| ): RecordsRes<InventoryStockUomSearchRow> { | |||
| val pageSize = (request.pageSize ?: 10).coerceAtLeast(1) | |||
| val pageNum = (request.pageNum ?: 0).coerceAtLeast(0) | |||
| val code = request.code?.trim().orEmpty() | |||
| val name = request.name?.trim().orEmpty() | |||
| val type = request.type?.trim().orEmpty() | |||
| val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } | |||
| val lotNo = request.lotNo?.trim().orEmpty() | |||
| val warehouseIds = resolveWarehouseIds(request.storeId, request.warehouse, request.area) | |||
| if (warehouseIds != null && warehouseIds.isEmpty()) { | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| val response = if (lotNo != null) { | |||
| val itemIds = inventoryLotLineRepository.findDistinctItemIdsByLotNo(lotNo) | |||
| if (itemIds.isEmpty()) { | |||
| return RecordsRes(emptyList(), 0) | |||
| } | |||
| inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndItemIdInAndDeletedIsFalse( | |||
| code = code, | |||
| name = name, | |||
| type = type, | |||
| itemIds = itemIds, | |||
| pageable = pageable, | |||
| ) | |||
| } else { | |||
| inventoryRepository.findLatestInventoryInfoByItemCodeContainsAndItemNameContainsAndItemTypeAndDeletedIsFalse( | |||
| code = code, | |||
| name = name, | |||
| type = type, | |||
| pageable = pageable, | |||
| ) | |||
| val args = mutableMapOf<String, Any>( | |||
| "code" to code, | |||
| "name" to name, | |||
| "type" to type, | |||
| "lotNo" to lotNo, | |||
| "status" to InventoryLotLineStatus.AVAILABLE.value, | |||
| "hasWarehouseFilter" to if (warehouseIds != null) 1 else 0, | |||
| "warehouseIds" to (warehouseIds ?: listOf(-1L)), | |||
| ) | |||
| val unionSql = buildLocationSearchUnionSql() | |||
| val total = jdbcDao.queryForInt("SELECT COUNT(*) FROM ($unionSql) t", args) | |||
| args["limit"] = pageSize | |||
| args["offset"] = pageNum * pageSize | |||
| val rows = jdbcDao.queryForList( | |||
| "$unionSql ORDER BY itemCode ASC, uomUdfudesc ASC LIMIT :limit OFFSET :offset", | |||
| args, | |||
| ) | |||
| return RecordsRes(rows.map { mapLocationSearchRow(it) }, total) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.1 | 2026-09-10 */ | |||
| private fun resolveWarehouseIds( | |||
| storeId: String?, | |||
| warehouse: String?, | |||
| area: String?, | |||
| ): List<Long>? { | |||
| val trimmedStoreId = storeId?.trim()?.takeIf { it.isNotEmpty() } | |||
| val trimmedWarehouse = warehouse?.trim()?.takeIf { it.isNotEmpty() } | |||
| val trimmedArea = area?.trim()?.takeIf { it.isNotEmpty() } | |||
| if (trimmedStoreId == null && trimmedWarehouse == null && trimmedArea == null) { | |||
| return null | |||
| } | |||
| return warehouseRepository.findByLocationFilters( | |||
| trimmedStoreId, | |||
| trimmedWarehouse, | |||
| trimmedArea, | |||
| ).mapNotNull { it.id } | |||
| } | |||
| private fun buildItemSearchUnionSql(): String { | |||
| val itemFilter = """ | |||
| AND (:code = '' OR item.code LIKE CONCAT('%', :code, '%')) | |||
| AND (:name = '' OR item.name LIKE CONCAT('%', :name, '%')) | |||
| AND (:type = '' OR item.type = :type) | |||
| AND (:hasItemIdFilter = 0 OR item.id IN (:itemIds)) | |||
| """.trimIndent() | |||
| return """ | |||
| SELECT | |||
| inv.id AS id, | |||
| item.id AS itemId, | |||
| item.code AS itemCode, | |||
| item.name AS itemName, | |||
| item.type AS itemType, | |||
| COALESCE(inv.onHandQty, 0) AS onHandQty, | |||
| COALESCE(inv.onHoldQty, 0) AS onHoldQty, | |||
| COALESCE(inv.unavailableQty, 0) AS unavailableQty, | |||
| (COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0)) AS availableQty, | |||
| uc.id AS stockUomId, | |||
| uc.code AS uomCode, | |||
| uc.udfudesc AS uomUdfudesc, | |||
| uc.udfShortDesc AS uomShortDesc, | |||
| (COALESCE(inv.onHandQty, 0) - COALESCE(inv.unavailableQty, 0)) AS qtyPerSmallestUnit, | |||
| uc_base.udfudesc AS baseUom, | |||
| inv.price AS price, | |||
| cur.name AS currencyName, | |||
| inv.status AS status, | |||
| item.latestMarketUnitPrice AS latestMarketUnitPrice, | |||
| item.latestMupUpdatedDate AS latestMupUpdatedDate | |||
| FROM inventory inv | |||
| INNER JOIN items item ON item.id = inv.itemId AND item.deleted = 0 | |||
| LEFT JOIN uom_conversion uc ON uc.id = inv.stockUomId | |||
| LEFT JOIN uom_conversion uc_base ON uc_base.id = inv.uomId | |||
| LEFT JOIN currency cur ON cur.id = inv.currencyId | |||
| WHERE inv.deleted = 0 | |||
| $itemFilter | |||
| UNION ALL | |||
| SELECT | |||
| 0 AS id, | |||
| item.id AS itemId, | |||
| item.code AS itemCode, | |||
| item.name AS itemName, | |||
| item.type AS itemType, | |||
| 0 AS onHandQty, | |||
| 0 AS onHoldQty, | |||
| 0 AS unavailableQty, | |||
| 0 AS availableQty, | |||
| uc.id AS stockUomId, | |||
| uc.code AS uomCode, | |||
| uc.udfudesc AS uomUdfudesc, | |||
| uc.udfShortDesc AS uomShortDesc, | |||
| 0 AS qtyPerSmallestUnit, | |||
| uc.udfudesc AS baseUom, | |||
| NULL AS price, | |||
| NULL AS currencyName, | |||
| 'active' AS status, | |||
| item.latestMarketUnitPrice AS latestMarketUnitPrice, | |||
| item.latestMupUpdatedDate AS latestMupUpdatedDate | |||
| FROM items item | |||
| INNER JOIN item_uom iu | |||
| ON iu.itemId = item.id | |||
| AND iu.stockUnit = 1 | |||
| AND iu.deleted = 0 | |||
| AND iu.id = ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = item.id | |||
| AND iu2.stockUnit = 1 | |||
| AND iu2.deleted = 0 | |||
| ) | |||
| INNER JOIN uom_conversion uc ON uc.id = iu.uomId | |||
| WHERE item.deleted = 0 | |||
| $itemFilter | |||
| AND EXISTS ( | |||
| SELECT 1 | |||
| FROM inventory inv2 | |||
| WHERE inv2.itemId = item.id | |||
| AND inv2.deleted = 0 | |||
| ) | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM inventory inv2 | |||
| WHERE inv2.itemId = item.id | |||
| AND inv2.deleted = 0 | |||
| AND inv2.stockUomId = uc.id | |||
| ) | |||
| """.trimIndent() | |||
| } | |||
| return RecordsRes(response.content, response.totalElements.toInt()) | |||
| private fun buildLocationSearchUnionSql(): String { | |||
| val itemFilter = """ | |||
| AND (:code = '' OR LOWER(item.code) LIKE LOWER(CONCAT('%', :code, '%'))) | |||
| AND (:name = '' OR LOWER(item.name) LIKE LOWER(CONCAT('%', :name, '%'))) | |||
| AND (:type = '' OR item.type = :type) | |||
| """.trimIndent() | |||
| val lotFilter = """ | |||
| ill.deleted = 0 | |||
| AND il.deleted = 0 | |||
| AND item.deleted = 0 | |||
| AND ill.status = :status | |||
| AND (:hasWarehouseFilter = 0 OR ill.warehouseId IN (:warehouseIds)) | |||
| AND (:lotNo = '' OR il.lotNo = :lotNo) | |||
| """.trimIndent() | |||
| val lotExistsFilter = """ | |||
| ill2.deleted = 0 | |||
| AND il2.deleted = 0 | |||
| AND ill2.status = :status | |||
| AND (:hasWarehouseFilter = 0 OR ill2.warehouseId IN (:warehouseIds)) | |||
| AND (:lotNo = '' OR il2.lotNo = :lotNo) | |||
| """.trimIndent() | |||
| return """ | |||
| SELECT | |||
| MAX(ill.id) AS id, | |||
| item.id AS itemId, | |||
| item.code AS itemCode, | |||
| item.name AS itemName, | |||
| item.type AS itemType, | |||
| ${InventoryLocationSearchQty.SQL_ON_HAND} AS onHandQty, | |||
| ${InventoryLocationSearchQty.SQL_ON_HOLD} AS onHoldQty, | |||
| ${InventoryLocationSearchQty.SQL_AVAILABLE} AS availableQty, | |||
| uom.id AS uomId, | |||
| uom.code AS uomCode, | |||
| uom.udfudesc AS uomUdfudesc, | |||
| uom.udfShortDesc AS uomShortDesc, | |||
| ${InventoryLocationSearchQty.SQL_AVAILABLE} AS qtyPerSmallestUnit, | |||
| MAX(item.latestMarketUnitPrice) AS latestMarketUnitPrice, | |||
| MAX(item.latestMupUpdatedDate) AS latestMupUpdatedDate | |||
| FROM inventory_lot_line ill | |||
| INNER JOIN inventory_lot il ON il.id = ill.inventoryLotId | |||
| INNER JOIN items item ON item.id = il.itemId | |||
| INNER JOIN item_uom su ON su.id = ill.stockItemUomId | |||
| INNER JOIN uom_conversion uom ON uom.id = su.uomId | |||
| WHERE $lotFilter | |||
| $itemFilter | |||
| GROUP BY item.id, item.code, item.name, item.type, uom.id, uom.code, uom.udfudesc, uom.udfShortDesc | |||
| UNION ALL | |||
| SELECT | |||
| 0 AS id, | |||
| item.id AS itemId, | |||
| item.code AS itemCode, | |||
| item.name AS itemName, | |||
| item.type AS itemType, | |||
| 0 AS onHandQty, | |||
| 0 AS onHoldQty, | |||
| 0 AS availableQty, | |||
| uom.id AS uomId, | |||
| uom.code AS uomCode, | |||
| uom.udfudesc AS uomUdfudesc, | |||
| uom.udfShortDesc AS uomShortDesc, | |||
| 0 AS qtyPerSmallestUnit, | |||
| item.latestMarketUnitPrice AS latestMarketUnitPrice, | |||
| item.latestMupUpdatedDate AS latestMupUpdatedDate | |||
| FROM items item | |||
| INNER JOIN item_uom iu | |||
| ON iu.itemId = item.id | |||
| AND iu.stockUnit = 1 | |||
| AND iu.deleted = 0 | |||
| AND iu.id = ( | |||
| SELECT MIN(iu2.id) | |||
| FROM item_uom iu2 | |||
| WHERE iu2.itemId = item.id | |||
| AND iu2.stockUnit = 1 | |||
| AND iu2.deleted = 0 | |||
| ) | |||
| INNER JOIN uom_conversion uom ON uom.id = iu.uomId | |||
| WHERE item.deleted = 0 | |||
| $itemFilter | |||
| AND EXISTS ( | |||
| SELECT 1 | |||
| FROM inventory_lot_line ill2 | |||
| INNER JOIN inventory_lot il2 ON il2.id = ill2.inventoryLotId | |||
| WHERE il2.itemId = item.id | |||
| AND $lotExistsFilter | |||
| ) | |||
| AND NOT EXISTS ( | |||
| SELECT 1 | |||
| FROM inventory_lot_line ill2 | |||
| INNER JOIN inventory_lot il2 ON il2.id = ill2.inventoryLotId | |||
| INNER JOIN item_uom su2 ON su2.id = ill2.stockItemUomId | |||
| WHERE il2.itemId = item.id | |||
| AND su2.uomId = uom.id | |||
| AND $lotExistsFilter | |||
| ) | |||
| """.trimIndent() | |||
| } | |||
| private fun mapItemSearchRow(row: Map<String, Any>): InventoryInfoDto { | |||
| val available = toBigDecimal(row["availableQty"]) | |||
| return InventoryInfoDto( | |||
| id = toLong(row["id"]), | |||
| itemId = toLong(row["itemId"]), | |||
| itemCode = row["itemCode"]?.toString(), | |||
| itemName = row["itemName"]?.toString(), | |||
| itemType = row["itemType"]?.toString(), | |||
| onHandQty = toBigDecimal(row["onHandQty"]), | |||
| onHoldQty = toBigDecimal(row["onHoldQty"]), | |||
| unavailableQty = toBigDecimal(row["unavailableQty"]), | |||
| availableQty = available, | |||
| stockUomId = toLong(row["stockUomId"]), | |||
| uomCode = row["uomCode"]?.toString(), | |||
| uomUdfudesc = row["uomUdfudesc"]?.toString(), | |||
| uomShortDesc = row["uomShortDesc"]?.toString(), | |||
| qtyPerSmallestUnit = available, | |||
| baseUom = row["baseUom"]?.toString(), | |||
| price = toBigDecimal(row["price"]), | |||
| currencyName = row["currencyName"]?.toString(), | |||
| status = row["status"]?.toString(), | |||
| latestMarketUnitPrice = (row["latestMarketUnitPrice"] as? Number)?.toDouble(), | |||
| latestMupUpdatedDate = toLocalDateTime(row["latestMupUpdatedDate"]), | |||
| ) | |||
| } | |||
| private fun mapLocationSearchRow(row: Map<String, Any>): InventoryStockUomSearchRowDto { | |||
| val available = toBigDecimal(row["availableQty"]) | |||
| return InventoryStockUomSearchRowDto( | |||
| id = toLong(row["id"]), | |||
| itemId = toLong(row["itemId"]), | |||
| itemCode = row["itemCode"]?.toString(), | |||
| itemName = row["itemName"]?.toString(), | |||
| itemType = row["itemType"]?.toString(), | |||
| onHandQty = toBigDecimal(row["onHandQty"]), | |||
| onHoldQty = toBigDecimal(row["onHoldQty"]), | |||
| unavailableQty = BigDecimal.ZERO, | |||
| availableQty = available, | |||
| uomId = toLong(row["uomId"]), | |||
| uomCode = row["uomCode"]?.toString(), | |||
| uomUdfudesc = row["uomUdfudesc"]?.toString(), | |||
| uomShortDesc = row["uomShortDesc"]?.toString(), | |||
| qtyPerSmallestUnit = available, | |||
| latestMarketUnitPrice = (row["latestMarketUnitPrice"] as? Number)?.toDouble(), | |||
| latestMupUpdatedDate = toLocalDateTime(row["latestMupUpdatedDate"]), | |||
| ) | |||
| } | |||
| private fun toLong(value: Any?): Long? = (value as? Number)?.toLong() | |||
| private fun toBigDecimal(value: Any?): BigDecimal? = when (value) { | |||
| null -> null | |||
| is BigDecimal -> value | |||
| is Number -> BigDecimal(value.toString()) | |||
| else -> null | |||
| } | |||
| private fun toLocalDateTime(value: Any?): LocalDateTime? = when (value) { | |||
| is LocalDateTime -> value | |||
| is Timestamp -> value.toLocalDateTime() | |||
| is java.sql.Date -> value.toLocalDate().atStartOfDay() | |||
| else -> null | |||
| } | |||
| open fun allInventoriesByItems(items: List<Items>): List<InventoryInfo>{ | |||
| @@ -366,17 +653,17 @@ open class InventoryService( | |||
| val warehouse = warehouseRepository.findAll().find { it.code == request.warehouseCode }!! | |||
| // val salesUnit = itemUomService.findSalesUnitByItemId(itemId = inventoryLot.item!!.id!!) | |||
| val stockUnit = itemUomService.findStockUnitByItemId(itemId = inventoryLot.item!!.id!!) | |||
| // val zero = BigDecimal.ZERO | |||
| // val one = BigDecimal.ONE | |||
| // val ratio = (salesUnit?.ratioN ?: zero).divide(salesUnit?.ratioD ?: one) | |||
| ?: throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "入倉明細缺少庫存單位,無法入帳", | |||
| ) | |||
| // ChangeList #8: lot-line stockItemUomId must be set so trigger writes inventory.stockUomId. | |||
| InventoryLotLine().apply { | |||
| this.inventoryLot = inventoryLot | |||
| this.warehouse = warehouse | |||
| this.inQty = request.qty | |||
| this.status = InventoryLotLineStatus.AVAILABLE | |||
| this.stockUom = stockUnit | |||
| // this.stockUom = salesUnit | |||
| } | |||
| } | |||
| val savedInventoryLotLine = inventoryLotLineRepository.saveAllAndFlush(inventoryLotLineEntries) | |||
| @@ -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 | |||
| ) { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.8 | 2026-09-16 */ | |||
| @Transactional | |||
| open fun submit(request: StockAdjustmentRequest): MessageResponse { | |||
| val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id } | |||
| @@ -55,7 +55,8 @@ open class StockAdjustmentService( | |||
| productLotNo = current.productlotNo?.takeIf { it.isNotBlank() }, | |||
| dnNo = current.dnNo?.takeIf { it.isNotBlank() }, | |||
| type = stockType, | |||
| warehouseId = current.warehouseId | |||
| warehouseId = current.warehouseId, | |||
| uomId = current.uomId, | |||
| ) | |||
| ) | |||
| saveAdjustmentRecordForStockIn(stockInLine, current.remarks) | |||
| @@ -3,7 +3,6 @@ package com.ffii.fpsms.modules.stock.service | |||
| import com.ffii.core.exception.BadRequestException | |||
| import com.ffii.core.support.AbstractBaseEntityService | |||
| import com.ffii.core.support.JdbcDao | |||
| import com.ffii.core.utils.QrCodeUtil | |||
| import com.ffii.fpsms.modules.common.CodeGenerator | |||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.master.entity.QcItemRepository | |||
| @@ -28,11 +27,13 @@ import com.ffii.core.utils.PdfUtils; | |||
| import com.ffii.core.utils.ZebraPrinterUtil | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository | |||
| import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | |||
| import com.ffii.fpsms.modules.master.entity.ItemUom | |||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | |||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| //import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| import com.ffii.fpsms.modules.settings.service.SettingsService | |||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder | |||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | |||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository | |||
| @@ -98,11 +99,13 @@ open class StockInLineService( | |||
| private val itemUomRepository: ItemUomRespository, | |||
| private val itemUomService: ItemUomService, | |||
| private val printerService: PrinterService, | |||
| private val settingsService: SettingsService, | |||
| private val stockTakeLineRepository: StockTakeLineRepository, | |||
| private val inventoryLotLineService: InventoryLotLineService, | |||
| private val deliveryOrderRepository: DeliveryOrderRepository, | |||
| private val stockLedgerRepository: StockLedgerRepository, | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||
| private val m18GoodsReceiptNoteService: M18GoodsReceiptNoteService, | |||
| private val m18GoodsReceiptNoteLogRepository: M18GoodsReceiptNoteLogRepository, | |||
| /** Lazy to avoid circular dependency: M18PurchaseOrderService → … → StockInLineService. */ | |||
| @@ -331,6 +334,33 @@ open class StockInLineService( | |||
| latestCode = latestCode, | |||
| ) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ | |||
| private fun applyRequestedStockQtyRound( | |||
| stockInLine: StockInLine, | |||
| request: SaveStockInLineRequest, | |||
| isCreate: Boolean, | |||
| ) { | |||
| if (request.stockQtyRoundMode.isNullOrBlank()) return | |||
| if (stockInLine.purchaseOrderLine == null) return | |||
| if (!isCreate) { | |||
| val status = stockInLine.status?.lowercase()?.trim() | |||
| if (status != StockInLineStatus.PENDING.status && status != StockInLineStatus.ESCALATED.status) { | |||
| return | |||
| } | |||
| } | |||
| val currentQty = stockInLine.acceptedQty ?: return | |||
| val (roundedQty, remarks) = StockQtyRoundSupport.applyIfRequested( | |||
| currentQty = currentQty, | |||
| currentRemarks = stockInLine.remarks ?: request.remarks, | |||
| request = request, | |||
| defaultSource = if (isCreate) "CREATE" else "QC", | |||
| ) | |||
| stockInLine.acceptedQty = roundedQty | |||
| stockInLine.remarks = remarks | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ | |||
| @Throws(IOException::class) | |||
| @Transactional | |||
| open fun create(request: SaveStockInLineRequest): MessageResponse { | |||
| @@ -445,10 +475,13 @@ open class StockInLineService( | |||
| } | |||
| dnNo = request.dnNo | |||
| receiptDate = request.receiptDate?.atStartOfDay() ?: LocalDateTime.now() | |||
| // The item label prints this field under 收貨日期. | |||
| productionDate = request.productionDate?.atStartOfDay() ?: receiptDate | |||
| productLotNo = request.productLotNo | |||
| this.type = "NOR" | |||
| status = StockInLineStatus.PENDING.status | |||
| } | |||
| applyRequestedStockQtyRound(stockInLine, request, isCreate = true) | |||
| if (jo != null) { | |||
| val planStartDate = jo.planStart?.toLocalDate() | |||
| stockInLine.lotNo = assignLotNoForJo(planStartDate) | |||
| @@ -535,8 +568,13 @@ open class StockInLineService( | |||
| this.inventoryLot = stockInLine.inventoryLot | |||
| this.warehouse = warehouse | |||
| this.inQty = convertedBaseQty | |||
| this.outQty = BigDecimal.ZERO | |||
| this.status = InventoryLotLineStatus.AVAILABLE | |||
| this.stockUom = stockItemUom | |||
| ?: throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "入倉明細缺少庫存單位,無法入帳", | |||
| ) | |||
| } | |||
| saveLines.add(inventoryLotLine) | |||
| } | |||
| @@ -649,13 +687,15 @@ open class StockInLineService( | |||
| open fun updatePurchaseOrderStatus(po : PurchaseOrder) : PurchaseOrder { | |||
| val pols = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(po.id!!) | |||
| var newStatus = PurchaseOrderStatus.PENDING | |||
| if (pols.isNotEmpty()) { | |||
| newStatus = if (pols.any { pol-> pol.status != PurchaseOrderLineStatus.COMPLETED }) { | |||
| PurchaseOrderStatus.RECEIVING | |||
| } else { | |||
| // A line that was never received, or whose stock-in rows were all removed before putaway, is pending. | |||
| // Receiving only while some line is still receiving or already completed. | |||
| val newStatus = when { | |||
| pols.isEmpty() || pols.all { it.status == null || it.status == PurchaseOrderLineStatus.PENDING } -> | |||
| PurchaseOrderStatus.PENDING | |||
| pols.all { it.status == PurchaseOrderLineStatus.COMPLETED } -> | |||
| PurchaseOrderStatus.COMPLETED | |||
| } | |||
| else -> | |||
| PurchaseOrderStatus.RECEIVING | |||
| } | |||
| po.apply { | |||
| status = newStatus | |||
| @@ -944,6 +984,7 @@ open class StockInLineService( | |||
| } | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ | |||
| @Throws(IOException::class) | |||
| @Transactional | |||
| open fun update(request: SaveStockInLineRequest): MessageResponse { | |||
| @@ -1030,6 +1071,7 @@ open class StockInLineService( | |||
| this.expiryDate = stockInLine.expiryDate ?: request.expiryDate | |||
| this.remarks = stockInLine.remarks ?: request.remarks | |||
| } | |||
| applyRequestedStockQtyRound(stockInLine, request, isCreate = false) | |||
| _logStep("apply_fields", _tApplyFields) | |||
| if (request.status == StockInLineStatus.RECEIVED.status) { | |||
| val _tPutAwayTotal = System.nanoTime() | |||
| @@ -1070,7 +1112,7 @@ open class StockInLineService( | |||
| if (putAwayDeltaStockQty > BigDecimal.ZERO) { | |||
| val _tLedger = System.nanoTime() | |||
| createStockLedgerForStockIn(stockInLine, putAwayDeltaStockQty.toDouble()) | |||
| _logStep("createStockLedgerForStockIn(includes findLatestByItemId)", _tLedger) | |||
| _logStep("createStockLedgerForStockIn(includes findLatestByInventoryId)", _tLedger) | |||
| } | |||
| val _tQtyCalc = System.nanoTime() | |||
| @@ -1208,7 +1250,8 @@ open class StockInLineService( | |||
| @Throws(IOException::class) | |||
| @Transactional | |||
| open fun exportStockInLineQrcode(request: ExportQrCodeRequest): Map<String, Any> { | |||
| val QRCODE_PDF = "qrCodeLabel/poItemPDF.jrxml" | |||
| val labelVersion = PoItemLabelTemplate.version(settingsService) | |||
| val QRCODE_PDF = PoItemLabelTemplate.path(labelVersion) | |||
| val resource = ClassPathResource(QRCODE_PDF) | |||
| if (!resource.exists()) { | |||
| println("here") | |||
| @@ -1242,12 +1285,17 @@ open class StockInLineService( | |||
| field["poCode"] = info.poCode ?: "N/A" | |||
| field["itemType"] = info.itemType ?: "N/A" | |||
| field["acceptedQty"] = "%.2f".format(acceptedQty) | |||
| field["uom"] = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" | |||
| field["productionDate"] = info.productionDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | |||
| val fullUom = stockItemUom?.uom?.udfudesc ?: info.uom?.udfudesc?.toString() ?: "N/A" | |||
| val shortUom = stockItemUom?.uom?.udfShortDesc ?: info.uom?.udfShortDesc | |||
| field["uom"] = fullUom | |||
| val (qtyLabel, qtyDetail) = PoItemLabelTemplate.qtyLines(field["acceptedQty"] as String, fullUom, shortUom) | |||
| field["qtyLabel"] = qtyLabel | |||
| field["qtyDetail"] = qtyDetail | |||
| field["productionDate"] = (info.productionDate ?: info.receiptDate)?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | |||
| field["expiryDate"] = info.expiryDate?.format(DateTimeFormatter.ISO_LOCAL_DATE) ?: "" | |||
| field["lotNo"] = info.lotNo ?: "N/A" | |||
| field["supplier"] = info.supplier ?: "N/A" | |||
| val image = QrCodeUtil.generateQRCodeImage(qrCodeContent) | |||
| val image = PoItemLabelTemplate.qrImage(qrCodeContent, labelVersion) | |||
| field["qrCode"] = image | |||
| fields.add(field) | |||
| } | |||
| @@ -1389,16 +1437,17 @@ open class StockInLineService( | |||
| val item = stockInLine.item ?: return | |||
| val _tInv = System.nanoTime() | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return | |||
| _logStep("inventoryRepository.findInventoryForItemBaseUom", _tInv) | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockInLine.inventoryLotLine) ?: return | |||
| _logStep("inventoryBucketResolver.findInventoryBucket", _tInv) | |||
| // ✅ 修复:查询最新的 stock_ledger 记录,基于前一笔 balance 计算 | |||
| // Chain running balance on this inventory bucket only (not the whole item). | |||
| val _tLatest = System.nanoTime() | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!) | |||
| _logStep("stockLedgerRepository.findLatestByItemId", _tLatest) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| _logStep("stockLedgerRepository.findLatestByInventoryId", _tLatest) | |||
| // ✅ 修复:如果 latestLedger 为 null(第一次 stock in),previousBalance 应该是 0 | |||
| // 因为 inventory.onHandQty 已经被触发器更新为包含本次 stock in 的数量 | |||
| // First ledger on this bucket starts at 0: inventory.onHandQty already includes this stock-in. | |||
| val previousBalance = latestLedger?.balance ?: 0.0 | |||
| val newBalance = previousBalance + inQty | |||
| @@ -1491,12 +1540,7 @@ open class StockInLineService( | |||
| savedStockInLine.inventoryLot = savedInventoryLot | |||
| // Step 4: Create a row in inventory_lot_line table | |||
| val stockItemUom = itemUomRepository.findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc( | |||
| request.itemId | |||
| ) ?: throw IllegalArgumentException( | |||
| "Stock UOM not found for item: id=${request.itemId}, itemNo=${request.itemNo}. " + | |||
| "Ensure at least one item_uom row has stockUnit=1, deleted=0 for this item." | |||
| ) | |||
| val stockItemUom = resolveItemUomForNewLot(request.itemId, request.itemNo, request.uomId) | |||
| val warehouse = if (request.warehouseId != null) { | |||
| warehouseRepository.findById(request.warehouseId).orElseThrow() | |||
| @@ -1526,6 +1570,20 @@ open class StockInLineService( | |||
| return savedStockInLine | |||
| } | |||
| private fun resolveItemUomForNewLot(itemId: Long, itemNo: String, uomId: Long?): ItemUom { | |||
| if (uomId != null) { | |||
| return itemUomRepository.findFirstByItemIdAndUomIdAndDeletedIsFalse(itemId, uomId) | |||
| ?: throw IllegalArgumentException( | |||
| "Item UOM not found for item: id=$itemId, itemNo=$itemNo, uomId=$uomId." | |||
| ) | |||
| } | |||
| return itemUomRepository.findFirstByItemIdAndStockUnitIsTrueAndDeletedIsFalseOrderByIdAsc(itemId) | |||
| ?: throw ResponseStatusException( | |||
| HttpStatus.BAD_REQUEST, | |||
| "入倉明細缺少庫存單位,無法入帳", | |||
| ) | |||
| } | |||
| @Transactional | |||
| open fun createStockInForExistingInventoryLotLine( | |||
| request: StockInRequest, | |||
| @@ -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.common.SecurityUtils | |||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository | |||
| import java.time.LocalTime | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| @@ -78,9 +77,9 @@ private val inventoryLotLineService: InventoryLotLineService, | |||
| private val bagService: BagService, | |||
| private val pickOrderService: PickOrderService, | |||
| private val stockLedgerRepository: StockLedgerRepository, | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||
| @Lazy private val doReplenishmentService: DoReplenishmentService, | |||
| ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | |||
| @@ -265,13 +264,6 @@ fun handleQc(stockOutLine: StockOutLine, request: UpdateStockOutLineRequest): Li | |||
| this.holdQty = this.holdQty!!.minus((request.qty.div(ratio)).toBigDecimal()) | |||
| } | |||
| inventoryLotLineRepository.save(targetLotLineEntry) | |||
| // update inventory | |||
| val inventory = inventoryRepository.findByItemId(request.itemId).orElseThrow() | |||
| val inventoryEntry = inventory.apply { | |||
| this.onHandQty = this.onHandQty!!.minus((request.qty.div(ratio)).toBigDecimal()) | |||
| this.onHoldQty = this.onHoldQty!!.minus((request.qty.div(ratio)).toBigDecimal()) | |||
| } | |||
| inventoryRepository.save(inventoryEntry) | |||
| return listOf(stockOutLine, newStockOutLine) | |||
| } | |||
| @Transactional | |||
| @@ -816,10 +808,6 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||
| val savedLotLine = inventoryLotLineRepository.save(inventoryLotLine) | |||
| println("Saved lot line: ${savedLotLine.id}, status: ${savedLotLine.status}, holdQty: ${savedLotLine.holdQty}") | |||
| // Step 3: Update inventory table | |||
| println("Updating inventory table...") | |||
| updateInventoryTableAfterLotRejection(inventoryLotLine) | |||
| // Step 4: Trigger resuggest | |||
| val pickOrderLine = stockOutLine.pickOrderLine | |||
| if (pickOrderLine?.pickOrder?.id != null) { | |||
| @@ -838,51 +826,6 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||
| } | |||
| } | |||
| // ADD THIS: Update inventory table after lot rejection | |||
| private fun updateInventoryTableAfterLotRejection(inventoryLotLine: InventoryLotLine) { | |||
| try { | |||
| println("=== UPDATING INVENTORY TABLE ===") | |||
| val itemId = inventoryLotLine.inventoryLot?.item?.id | |||
| println("Item ID: $itemId") | |||
| if (itemId != null) { | |||
| // Calculate onHoldQty (sum of holdQty from available lots only) | |||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||
| .sumOf { it.holdQty ?: BigDecimal.ZERO } | |||
| // Calculate unavailableQty (sum of inQty from unavailable lots only) | |||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId,InventoryLotLineStatus.UNAVAILABLE) | |||
| .sumOf { | |||
| val inQty = it.inQty ?: BigDecimal.ZERO | |||
| val outQty = it.outQty ?: BigDecimal.ZERO | |||
| val remainingQty = inQty.minus(outQty) | |||
| remainingQty | |||
| } | |||
| println("Calculated onHoldQty: $onHoldQty") | |||
| println("Calculated unavailableQty: $unavailableQty") | |||
| // Update the inventory table | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| if (inventory != null) { | |||
| println("Found inventory record: ${inventory.id}") | |||
| println("Current inventory - onHoldQty: ${inventory.onHoldQty}, unavailableQty: ${inventory.unavailableQty}") | |||
| inventory.onHoldQty = onHoldQty | |||
| inventory.unavailableQty = unavailableQty | |||
| val savedInventory = inventoryRepository.save(inventory) | |||
| println("Updated inventory - onHoldQty: ${savedInventory.onHoldQty}, unavailableQty: ${savedInventory.unavailableQty}") | |||
| } else { | |||
| println("No inventory record found for item $itemId") | |||
| } | |||
| } | |||
| } catch (e: Exception) { | |||
| println("Error updating inventory table after lot rejection: ${e.message}") | |||
| e.printStackTrace() | |||
| } | |||
| } | |||
| @Transactional(rollbackFor = [Exception::class]) | |||
| open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| val startTime = System.currentTimeMillis() | |||
| @@ -954,14 +897,6 @@ open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| this.holdQty = (this.holdQty ?: zero) - baseQty | |||
| } | |||
| inventoryLotLineRepository.save(lotLine) | |||
| // 更新 inventory | |||
| val inv = inventoryRepository.findByItemId(lotLine.inventoryLot?.item?.id!!).orElseThrow() | |||
| inv.apply { | |||
| this.onHandQty = (this.onHandQty ?: zero) - baseQty | |||
| this.onHoldQty = (this.onHoldQty ?: zero) - baseQty | |||
| } | |||
| inventoryRepository.save(inv) | |||
| } | |||
| processedIds += line.stockOutLineId | |||
| @@ -1359,7 +1294,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| } | |||
| val item = savedSol.item | |||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | |||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||
| inventoryBucketResolver.findInventoryBucket(itemId, ill) | |||
| } | |||
| val onHandQtyBeforeUpdate = | |||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| @@ -1374,7 +1309,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| } else if (submitQty > BigDecimal.ZERO && actualInventoryLotLineId == null) { | |||
| val item = savedSol.item | |||
| val inventoryBeforeUpdate = item?.id?.let { itemId -> | |||
| itemUomService.findInventoryForItemBaseUom(itemId) | |||
| inventoryBucketResolver.findInventoryBucket(itemId, savedSol.inventoryLotLine) | |||
| } | |||
| val onHandQtyBeforeUpdate = | |||
| (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| @@ -1512,12 +1447,10 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 */ | |||
| private fun createStockLedgerForStockOut(stockOutLine: StockOutLine) { | |||
| val item = stockOutLine.item ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) ?: return | |||
| val outQty = stockOutLine.qty?.toDouble() ?: 0.0 | |||
| // Use latest ledger balance (same pattern as createStockLedgerForStockIn) so balance is correct when multiple actions run in one transaction | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(item.id!!) | |||
| val previousBalance = latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val previousBalance = resolvePreviousBalance(inventory = inventory) | |||
| val newBalance = previousBalance - outQty | |||
| val ill = stockOutLine.inventoryLotLine | |||
| @@ -1647,9 +1580,20 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| open fun createStockOut(request: StockOutRequest): StockOutLine { | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | |||
| val qtyBd = BigDecimal.valueOf(request.qty) | |||
| val label = when { | |||
| InventoryLotLineQtyGuard.isIssueStockOutType(request.type) -> | |||
| request.type!!.trim().replaceFirstChar { it.uppercase() } + " 數量" | |||
| else -> "出倉數量" | |||
| } | |||
| InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( | |||
| inventoryLotLine.inQty, | |||
| inventoryLotLine.outQty, | |||
| qtyBd, | |||
| label, | |||
| ) | |||
| val oldHold = inventoryLotLine.holdQty ?: BigDecimal.ZERO | |||
| // Increase outQty and release hold (pick / QR); inventory aggregates via trigger inventory_lot_line_AFTER_UPDATE | |||
| // Increase outQty and release hold (pick / QR / Miss / Bad); inventory aggregates via trigger | |||
| val updatedInventoryLotLine = inventoryLotLine.apply { | |||
| val currentOutQty = this.outQty ?: BigDecimal.ZERO | |||
| this.outQty = currentOutQty + qtyBd | |||
| @@ -1782,16 +1726,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| return | |||
| } | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, stockOutLine.inventoryLotLine) | |||
| if (inventory == null) { | |||
| if (flushAfterSave) { | |||
| println("${tracePrefix}Skip ledger creation: inventory not found by itemId=${item.id}") | |||
| println("${tracePrefix}Skip ledger creation: inventory bucket not found by itemId=${item.id}") | |||
| } | |||
| return | |||
| } | |||
| val previousBalance = resolvePreviousBalance( | |||
| itemId = item.id!!, | |||
| inventory = inventory, | |||
| onHandQtyBeforeUpdate = onHandQtyBeforeUpdate | |||
| ) | |||
| @@ -1839,14 +1782,15 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| } | |||
| private fun resolvePreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| onHandQtyBeforeUpdate: Double? = null | |||
| ): Double { | |||
| if (onHandQtyBeforeUpdate != null) { | |||
| return onHandQtyBeforeUpdate | |||
| } | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| @@ -2155,13 +2099,19 @@ fun applyStockOutLineDelta( | |||
| val postingType = (typeOverride ?: savedSol.type ?: "").trim().lowercase() | |||
| val isIssuePosting = postingType == "miss" || postingType == "bad" || postingType == "expiry" | |||
| // 2) inventory_lot_line + inventory | |||
| // 2) inventory_lot_line; inventory onHand/unavailable is owned by the lot-line trigger | |||
| if (!skipInventoryWrite) { | |||
| val lotLine = savedSol.inventoryLotLine | |||
| if (lotLine != null) { | |||
| if (isIssuePosting) { | |||
| val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) | |||
| if (latestLotLine != null) { | |||
| InventoryLotLineQtyGuard.requireQtyWithinPhysicalRemain( | |||
| latestLotLine.inQty, | |||
| latestLotLine.outQty, | |||
| deltaQty, | |||
| postingType.replaceFirstChar { it.uppercase() } + " 數量", | |||
| ) | |||
| val prevStatus = latestLotLine.status | |||
| val currentHoldQty = latestLotLine.holdQty ?: BigDecimal.ZERO | |||
| val currentOutQty = latestLotLine.outQty ?: BigDecimal.ZERO | |||
| @@ -2195,30 +2145,14 @@ fun applyStockOutLineDelta( | |||
| } | |||
| } | |||
| } | |||
| val itemId = savedSol.item?.id | |||
| if (itemId != null) { | |||
| val inv = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| if (inv != null) { | |||
| val zero = BigDecimal.ZERO | |||
| inv.onHandQty = (inv.onHandQty ?: zero).minus(deltaQty) | |||
| if (!isIssuePosting) { | |||
| inv.onHoldQty = (inv.onHoldQty ?: zero).minus(deltaQty) | |||
| } | |||
| inventoryRepository.save(inv) | |||
| } | |||
| } | |||
| } | |||
| // 3) stock_ledger (same ledger shape as createStockLedgerForStockOut / completeStockOutAfterLotLineSave) | |||
| if (!skipLedgerWrite) { | |||
| val item = savedSol.item ?: return savedSol | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return savedSol | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(item.id!!, savedSol.inventoryLotLine) ?: return savedSol | |||
| val previousBalance = resolvePreviousBalance( | |||
| itemId = item.id!!, | |||
| inventory = inventory | |||
| ) | |||
| val previousBalance = resolvePreviousBalance(inventory = inventory) | |||
| val outQty = deltaQty.toDouble() | |||
| val newBalance = previousBalance - outQty | |||
| @@ -2349,22 +2283,23 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||
| val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) | |||
| // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) | |||
| // Keep per-item running balance so multiple lines in the same batch | |||
| // Keep per-bucket running balance so multiple lines in the same batch | |||
| // chain from the previous ledger balance instead of reusing inventory.onHandQty. | |||
| val runningLedgerBalanceByItemId = mutableMapOf<Long, Double>() | |||
| val inventoryByItemId = mutableMapOf<Long, Inventory>() | |||
| val runningLedgerBalanceByBucket = mutableMapOf<Pair<Long, Long>, Double>() | |||
| val inventoryByBucket = mutableMapOf<Pair<Long, Long>, Inventory>() | |||
| savedLines.sortedBy { it.id ?: Long.MAX_VALUE }.forEach { sol -> | |||
| val item = sol.item ?: return@forEach | |||
| val itemId = item.id ?: return@forEach | |||
| val inv = inventoryByItemId[itemId] | |||
| ?: itemUomService.findInventoryForItemBaseUom(itemId)?.also { inventoryByItemId[itemId] = it } | |||
| val stockUomId = inventoryBucketResolver.resolveStockUomId(sol.inventoryLotLine) | |||
| ?: itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(itemId)?.uom?.id | |||
| ?: return@forEach | |||
| val bucketKey = itemId to stockUomId | |||
| val inv = inventoryByBucket[bucketKey] | |||
| ?: inventoryBucketResolver.findInventoryBucket(itemId, stockUomId)?.also { inventoryByBucket[bucketKey] = it } | |||
| ?: return@forEach | |||
| val delta = BigDecimal.valueOf(sol.qty ?: 0.0) | |||
| val prevBalance = runningLedgerBalanceByItemId[itemId] | |||
| ?: run { | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| latestLedger?.balance ?: (inv.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| val prevBalance = runningLedgerBalanceByBucket[bucketKey] | |||
| ?: resolvePreviousBalance(inventory = inv) | |||
| val newBalance = prevBalance - delta.toDouble() | |||
| val ill = sol.inventoryLotLine | |||
| @@ -2392,7 +2327,7 @@ open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult | |||
| signedDelta = delta.negate(), | |||
| ) | |||
| ledgersToInsert += ledger | |||
| runningLedgerBalanceByItemId[itemId] = newBalance | |||
| runningLedgerBalanceByBucket[bucketKey] = newBalance | |||
| } | |||
| stockLedgerRepository.saveAll(ledgersToInsert) | |||
| @@ -62,6 +62,7 @@ open class StockOutLineWorkbenchService( | |||
| private val suggestedPickLotWorkbenchService: SuggestedPickLotWorkbenchService, | |||
| private val stockLedgerRepository: StockLedgerRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val inventoryBucketResolver: InventoryBucketResolver, | |||
| private val itemUomRespository: ItemUomRespository, | |||
| private val bagRepository: BagRepository, | |||
| private val bagService: BagService, | |||
| @@ -512,13 +513,13 @@ if (updated == 0) { | |||
| if (deltaQty <= BigDecimal.ZERO) return | |||
| val sol = stockOutLIneRepository.findById(stockOutLineId).orElse(null) ?: return | |||
| val solItem = sol.item ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(solItem.id!!) ?: return | |||
| val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } | |||
| ?: sol.inventoryLotLine | |||
| val inventory = inventoryBucketResolver.findInventoryBucket(solItem.id!!, ill) ?: return | |||
| // Fast path: use inventory onHand after update + delta to reconstruct previous balance. | |||
| val onHandAfter = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val previousBalance = onHandAfter + deltaQty.toDouble() | |||
| val newBalance = previousBalance - deltaQty.toDouble() | |||
| val ill = sol.inventoryLotLine?.id?.let { inventoryLotLineRepository.findById(it).orElse(sol.inventoryLotLine) } | |||
| ?: sol.inventoryLotLine | |||
| val lotAfter = ill?.let { StockLedgerLotSnapshot.availableQty(it) } | |||
| val ledger = StockLedger().apply { | |||
| this.stockOutLine = sol | |||
| @@ -546,12 +547,13 @@ if (updated == 0) { | |||
| } | |||
| private fun resolveWorkbenchPreviousBalance( | |||
| itemId: Long, | |||
| inventory: Inventory, | |||
| onHandQtyBeforeUpdate: Double?, | |||
| ): Double { | |||
| if (onHandQtyBeforeUpdate != null) return onHandQtyBeforeUpdate | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemId) | |||
| val latestLedger = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| @@ -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 inventoryRepository: InventoryRepository, | |||
| val uomConversionRepository: UomConversionRepository, | |||
| val itemsRepository: ItemsRepository | |||
| val itemsRepository: ItemsRepository, | |||
| val inventoryBucketResolver: InventoryBucketResolver, | |||
| ) { | |||
| private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) | |||
| @@ -316,8 +317,31 @@ open class StockTakeRecordService( | |||
| } | |||
| } | |||
| /** 與 Approver 列表前端一致:優先第二次盤點數,否則第一次 */ | |||
| /** | |||
| * 與核准列表「差異百分比」同一個選擇數量。 | |||
| * lastSelect:1 第一次、2 第二次、3 審核員(盤點數 − 不良數)。 | |||
| * 沒有 lastSelect 時用 finalQty 對應;再沒有才退回第二次,否則第一次。 | |||
| */ | |||
| private fun resolvePickerQtyForVarianceFilter(response: InventoryLotDetailResponse): BigDecimal { | |||
| when (response.lastSelect) { | |||
| 1 -> return response.firstStockTakeQty ?: BigDecimal.ZERO | |||
| 2 -> return response.secondStockTakeQty ?: BigDecimal.ZERO | |||
| 3 -> return approverGoodQty(response) | |||
| } | |||
| val finalQty = response.finalQty | |||
| if (finalQty != null) { | |||
| val approverGood = approverGoodQty(response) | |||
| if (response.approverQty != null && sameStockTakeQty(approverGood, finalQty)) { | |||
| return approverGood | |||
| } | |||
| val second = response.secondStockTakeQty | |||
| if (second != null && sameStockTakeQty(second, finalQty)) return second | |||
| val first = response.firstStockTakeQty | |||
| if (first != null && sameStockTakeQty(first, finalQty)) return first | |||
| if (response.approverQty != null && sameStockTakeQty(response.approverQty, finalQty)) { | |||
| return approverGood | |||
| } | |||
| } | |||
| val second = response.secondStockTakeQty | |||
| val first = response.firstStockTakeQty | |||
| return when { | |||
| @@ -327,6 +351,13 @@ open class StockTakeRecordService( | |||
| } | |||
| } | |||
| /** 與前端選擇數量一致:審核員盤點數減不良數。 */ | |||
| private fun approverGoodQty(response: InventoryLotDetailResponse): BigDecimal = | |||
| (response.approverQty ?: BigDecimal.ZERO).subtract(response.approverBadQty ?: BigDecimal.ZERO) | |||
| private fun sameStockTakeQty(a: BigDecimal?, b: BigDecimal?): Boolean = | |||
| a != null && b != null && a.compareTo(b) == 0 | |||
| private fun computeInventoryLotDetailVariancePercentage(response: InventoryLotDetailResponse): BigDecimal { | |||
| val selectedQty = resolvePickerQtyForVarianceFilter(response) | |||
| val bookQty = response.bookQty ?: response.availableQty ?: BigDecimal.ZERO | |||
| @@ -646,6 +677,7 @@ open class StockTakeRecordService( | |||
| return stockTakeRepository.findByIdAndDeletedIsFalse(roundKey) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 90 | v1.0.0 | 2026-11-02 */ | |||
| open fun getApproverInventoryLotDetailsAll( | |||
| stockTakeId: Long? = null, | |||
| pageNum: Int = 0, | |||
| @@ -805,6 +837,7 @@ open class StockTakeRecordService( | |||
| holdQty = ill.holdQty, | |||
| availableQty = availableQty, | |||
| uom = ill.stockUom?.uom?.udfudesc, | |||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||
| warehouseCode = warehouse?.code, | |||
| warehouseName = warehouse?.name, | |||
| status = ill.status?.name, | |||
| @@ -1065,6 +1098,7 @@ open class StockTakeRecordService( | |||
| holdQty = ill.holdQty, | |||
| availableQty = availableQty, | |||
| uom = ill.stockUom?.uom?.udfudesc, | |||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||
| warehouseCode = warehouse?.code, | |||
| warehouseName = warehouse?.name, | |||
| status = ill.status?.name, | |||
| @@ -1175,6 +1209,7 @@ open class StockTakeRecordService( | |||
| holdQty = ill.holdQty, | |||
| availableQty = availableQty, | |||
| uom = ill.stockUom?.uom?.udfudesc, | |||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||
| warehouseCode = warehouse?.code, | |||
| warehouseArea = warehouse?.area, | |||
| warehouseName = warehouse?.name, | |||
| @@ -1197,6 +1232,7 @@ open class StockTakeRecordService( | |||
| stockTakerName = rec.stockTakerName, | |||
| stockTakeEndTime = rec.stockTakeEndTime, | |||
| approverTime = rec.approverTime, | |||
| pickerReviseCount = rec.pickerReviseCount ?: 0, | |||
| ) | |||
| } | |||
| @@ -1270,6 +1306,7 @@ open class StockTakeRecordService( | |||
| holdQty = ill.holdQty, | |||
| availableQty = availableQty, | |||
| uom = ill.stockUom?.uom?.udfudesc, | |||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||
| warehouseCode = warehouse?.code, | |||
| warehouseArea = warehouse?.area, | |||
| warehouseName = warehouse?.name, | |||
| @@ -1292,6 +1329,7 @@ open class StockTakeRecordService( | |||
| stockTakerName = stockTakeRecord?.stockTakerName, | |||
| stockTakeEndTime = stockTakeRecord?.stockTakeEndTime, | |||
| approverTime = stockTakeRecord?.approverTime, | |||
| pickerReviseCount = stockTakeRecord?.pickerReviseCount ?: 0, | |||
| ) | |||
| } | |||
| @@ -1338,12 +1376,59 @@ open class StockTakeRecordService( | |||
| .subtract(inventoryLotLine.outQty ?: BigDecimal.ZERO) | |||
| //.subtract(inventoryLotLine.holdQty ?: BigDecimal.ZERO) | |||
| if (request.reviseFirstQty && request.stockTakeRecordId == null) { | |||
| throw IllegalArgumentException("Stock take record ID is required") | |||
| } | |||
| // 3. 新建、更新預建記錄之第一次盤點、或第二次盤點 | |||
| val stockTakeRecord: StockTakeRecord = if (request.stockTakeRecordId != null) { | |||
| val existingRecord = stockTakeRecordRepository.findByIdAndDeletedIsFalse(request.stockTakeRecordId) | |||
| ?: throw IllegalArgumentException("Stock take record not found: ${request.stockTakeRecordId}") | |||
| val headerStatus = stockTake.status | |||
| val headerStillOpen = headerStatus == StockTakeStatus.PENDING || | |||
| headerStatus == StockTakeStatus.STOCKTAKING || | |||
| headerStatus == StockTakeStatus.APPROVING | |||
| val revisingOpenPass = existingRecord.pickerFirstStockTakeQty != null && | |||
| existingRecord.pickerSecondStockTakeQty == null && | |||
| existingRecord.approverStockTakeQty == null && | |||
| existingRecord.status.equals("pass", ignoreCase = true) && | |||
| headerStillOpen | |||
| when { | |||
| // 已盤點的列在區域「已完成」前可改第一次數量。列狀態已完成、或區域已完成,不能改。 | |||
| request.reviseFirstQty || revisingOpenPass -> { | |||
| if (!headerStillOpen) { | |||
| throw IllegalArgumentException("Cannot change counted qty after the section is completed") | |||
| } | |||
| if (!existingRecord.status.equals("pass", ignoreCase = true)) { | |||
| throw IllegalArgumentException("Only counted records can be changed") | |||
| } | |||
| if (existingRecord.pickerFirstStockTakeQty == null) { | |||
| throw IllegalArgumentException("No first count to change") | |||
| } | |||
| if (existingRecord.pickerSecondStockTakeQty != null || existingRecord.approverStockTakeQty != null) { | |||
| throw IllegalArgumentException("Cannot change the first count after a later count exists") | |||
| } | |||
| val reviseCount = existingRecord.pickerReviseCount ?: 0 | |||
| if (reviseCount >= 2) { | |||
| throw IllegalArgumentException("This count can only be changed 2 times") | |||
| } | |||
| val varianceQty = availableQty - request.qty - request.badQty | |||
| existingRecord.apply { | |||
| this.pickerFirstStockTakeQty = request.qty | |||
| this.pickerFirstBadQty = request.badQty | |||
| this.bookQty = availableQty | |||
| this.varianceQty = varianceQty | |||
| this.stockTakerId = stockTakerId | |||
| this.stockTakerName = user?.name | |||
| this.status = "pass" | |||
| this.pickerSecondStockTakeQty = null | |||
| this.pickerSecondBadQty = null | |||
| this.stockTakeEndTime = null | |||
| this.pickerReviseCount = reviseCount + 1 | |||
| } | |||
| existingRecord | |||
| } | |||
| existingRecord.pickerFirstStockTakeQty == null -> { | |||
| val isCompled = inventoryLotLine.inQty == inventoryLotLine.outQty | |||
| val varianceQty = availableQty - request.qty - request.badQty | |||
| @@ -1363,7 +1448,8 @@ open class StockTakeRecordService( | |||
| } | |||
| existingRecord | |||
| } | |||
| existingRecord.pickerSecondStockTakeQty == null -> { | |||
| existingRecord.pickerSecondStockTakeQty == null && | |||
| existingRecord.status.equals("notMatch", ignoreCase = true) -> { | |||
| val varianceQty = availableQty - request.qty - request.badQty | |||
| existingRecord.apply { | |||
| this.stockTakeRoundId = this.stockTakeRoundId ?: (stockTake.stockTakeRoundId ?: stockTake.id) | |||
| @@ -2125,7 +2211,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||
| "batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}", | |||
| adjustmentCache.inventoryLotLineByWarehouseLot.size, | |||
| adjustmentCache.inventoryLotById.size, | |||
| adjustmentCache.inventoryByItemId.size, | |||
| adjustmentCache.inventoryByItemAndStockUom.size, | |||
| adjustmentCache.stockTakeLineByRecordId.size, | |||
| elapsedMs(cacheBuildStartNs) | |||
| ) | |||
| @@ -2213,7 +2299,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||
| val runtimeCache = BatchAdjustmentRuntimeCache( | |||
| stockOutByStockTakeId = mutableMapOf(), | |||
| stockInByStockTakeId = mutableMapOf(), | |||
| runningLedgerBalanceByItemId = mutableMapOf() | |||
| runningLedgerBalanceByBucket = mutableMapOf() | |||
| ) | |||
| val adjustmentContext = StockTakeAdjustmentBatchContext() | |||
| var varianceCount = 0 | |||
| @@ -2278,7 +2364,7 @@ open fun batchSaveApproverStockTakeRecordsByIds( | |||
| "batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}", | |||
| runtimeCache.stockOutByStockTakeId.size, | |||
| runtimeCache.stockInByStockTakeId.size, | |||
| runtimeCache.runningLedgerBalanceByItemId.size, | |||
| runtimeCache.runningLedgerBalanceByBucket.size, | |||
| adjustmentContext.stockTakeLineByRecordId.size, | |||
| adjustmentContext.stockOutLines.size, | |||
| adjustmentContext.stockInLines.size, | |||
| @@ -2398,9 +2484,7 @@ private fun applyVarianceAdjustment( | |||
| ?: throw IllegalArgumentException("Inventory lot not found") | |||
| val itemId = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found") | |||
| val inventory = cache?.inventoryByItemId?.get(itemId) | |||
| ?: inventoryRepository.findByItemId(itemId).orElse(null) | |||
| ?: throw IllegalArgumentException("Inventory not found for item") | |||
| val inventory = resolveInventoryForStockTake(itemId, inventoryLotLine, cache) | |||
| // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 | |||
| val stockTakeLine = stockTakeRecord.id?.let { rid -> | |||
| @@ -2481,11 +2565,9 @@ private fun applyVarianceAdjustment( | |||
| // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 | |||
| val itemIdForLedger = inventoryLot.item?.id | |||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger") | |||
| val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) | |||
| ?: run { | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||
| latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) | |||
| val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) | |||
| ?: previousBalanceForInventoryBucket(inventory) | |||
| val newBalance = previousBalance - qtyToRemove.toDouble() | |||
| // FP-MTMS Version Checklist | Functions Ref. No. 46 | v1.0.1 | 2026-08-05 | |||
| @@ -2519,7 +2601,7 @@ private fun applyVarianceAdjustment( | |||
| } else { | |||
| stockLedgerRepository.save(stockLedger) | |||
| } | |||
| runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) | |||
| runtimeCache?.runningLedgerBalanceByBucket?.put(ledgerKey, newBalance) | |||
| val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) | |||
| latestLine.outQty = newOutQty | |||
| @@ -2619,11 +2701,9 @@ private fun applyVarianceAdjustment( | |||
| val itemIdForLedger = inventoryLot.item?.id | |||
| ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") | |||
| val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) | |||
| ?: run { | |||
| val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) | |||
| latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| val ledgerKey = stockTakeLedgerBucketKey(itemIdForLedger, latestLine, inventory) | |||
| val previousBalance = runtimeCache?.runningLedgerBalanceByBucket?.get(ledgerKey) | |||
| ?: previousBalanceForInventoryBucket(inventory) | |||
| val newBalance = previousBalance + plusQty.toDouble() | |||
| // latestLine.inQty already includes plusQty above — snapshot live lot line after move. | |||
| @@ -2656,21 +2736,55 @@ private fun applyVarianceAdjustment( | |||
| } else { | |||
| stockLedgerRepository.save(stockLedger) | |||
| } | |||
| runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) | |||
| runtimeCache?.runningLedgerBalanceByBucket?.put(ledgerKey, newBalance) | |||
| } | |||
| } | |||
| private fun resolveInventoryForStockTake( | |||
| itemId: Long, | |||
| lotLine: InventoryLotLine, | |||
| cache: BatchAdjustmentCache?, | |||
| ): com.ffii.fpsms.modules.stock.entity.Inventory { | |||
| val stockUomId = inventoryBucketResolver.resolveStockUomId(lotLine) | |||
| if (stockUomId != null) { | |||
| cache?.inventoryByItemAndStockUom?.get(itemId to stockUomId)?.let { return it } | |||
| inventoryBucketResolver.findInventoryBucket(itemId, stockUomId)?.let { return it } | |||
| } | |||
| inventoryBucketResolver.findInventoryBucket(itemId, lotLine)?.let { return it } | |||
| throw IllegalArgumentException("Inventory not found for item $itemId stock UOM $stockUomId") | |||
| } | |||
| private fun stockTakeLedgerBucketKey( | |||
| itemId: Long, | |||
| lotLine: InventoryLotLine, | |||
| inventory: com.ffii.fpsms.modules.stock.entity.Inventory, | |||
| ): Pair<Long, Long> { | |||
| val stockUomId = inventoryBucketResolver.resolveStockUomId(lotLine) | |||
| ?: inventory.stockUom?.id | |||
| ?: 0L | |||
| return itemId to stockUomId | |||
| } | |||
| private fun previousBalanceForInventoryBucket( | |||
| inventory: com.ffii.fpsms.modules.stock.entity.Inventory, | |||
| ): Double { | |||
| val latest = inventory.id?.let { | |||
| stockLedgerRepository.findFirstByInventory_IdAndDeletedFalseOrderByDateDescIdDesc(it) | |||
| } | |||
| return latest?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| } | |||
| private data class BatchAdjustmentCache( | |||
| val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>, | |||
| val inventoryLotById: Map<Long, InventoryLot>, | |||
| val inventoryByItemId: Map<Long, com.ffii.fpsms.modules.stock.entity.Inventory>, | |||
| val inventoryByItemAndStockUom: Map<Pair<Long, Long>, com.ffii.fpsms.modules.stock.entity.Inventory>, | |||
| val stockTakeLineByRecordId: Map<Long, StockTakeLine> | |||
| ) | |||
| private data class BatchAdjustmentRuntimeCache( | |||
| val stockOutByStockTakeId: MutableMap<Long, StockOut>, | |||
| val stockInByStockTakeId: MutableMap<Long, StockIn>, | |||
| val runningLedgerBalanceByItemId: MutableMap<Long, Double> | |||
| val runningLedgerBalanceByBucket: MutableMap<Pair<Long, Long>, Double> | |||
| ) | |||
| private data class StockTakeAdjustmentBatchContext( | |||
| @@ -2725,10 +2839,15 @@ private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdju | |||
| emptyMap() | |||
| } | |||
| val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct() | |||
| val inventoryByItemId = | |||
| val inventoryByItemAndStockUom = | |||
| if (itemIds.isNotEmpty()) { | |||
| inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds) | |||
| .groupBy { it.item?.id ?: 0L } | |||
| .mapNotNull { inv -> | |||
| val itemId = inv.item?.id ?: return@mapNotNull null | |||
| val stockUomId = inv.stockUom?.id ?: inv.uom?.id ?: return@mapNotNull null | |||
| (itemId to stockUomId) to inv | |||
| } | |||
| .groupBy({ it.first }, { it.second }) | |||
| .mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! } | |||
| } else { | |||
| emptyMap() | |||
| @@ -2745,7 +2864,7 @@ private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdju | |||
| return BatchAdjustmentCache( | |||
| inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot, | |||
| inventoryLotById = inventoryLotById, | |||
| inventoryByItemId = inventoryByItemId, | |||
| inventoryByItemAndStockUom = inventoryByItemAndStockUom, | |||
| stockTakeLineByRecordId = stockTakeLineByRecordId | |||
| ) | |||
| } | |||
| @@ -2854,6 +2973,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||
| holdQty = ill.holdQty, | |||
| availableQty = availableQty, | |||
| uom = ill.stockUom?.uom?.udfudesc, | |||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||
| warehouseCode = warehouse?.code, | |||
| warehouseName = warehouse?.name, | |||
| status = ill.status?.name, | |||
| @@ -2954,6 +3074,7 @@ open fun getInventoryLotDetailsByStockTakeSectionNotMatch( | |||
| holdQty = ill.holdQty, | |||
| availableQty = availableQty, | |||
| uom = ill.stockUom?.uom?.udfudesc, | |||
| uomShortDesc = ill.stockUom?.uom?.udfShortDesc, | |||
| warehouseCode = warehouse?.code, | |||
| warehouseName = warehouse?.name, | |||
| status = ill.status?.name, | |||
| @@ -28,8 +28,7 @@ import kotlin.jvm.optionals.getOrNull | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||
| import java.math.RoundingMode | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import java.math.RoundingMode | |||
| import com.ffii.fpsms.modules.stock.web.model.StockOutLineStatus | |||
| import com.ffii.fpsms.modules.stock.web.model.PickAnotherLotRequest | |||
| import com.ffii.fpsms.modules.stock.service.InventoryLotLineService | |||
| @@ -54,7 +53,6 @@ open class SuggestedPickLotService( | |||
| val itemUomService: ItemUomService, | |||
| val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号 | |||
| val pickOrderRepository: PickOrderRepository, | |||
| val inventoryRepository: InventoryRepository, | |||
| val failInventoryLotLineRepository: FailInventoryLotLineRepository, | |||
| val stockOutRepository: StockOutRepository, | |||
| val itemRepository: ItemsRepository, | |||
| @@ -1070,38 +1068,6 @@ val basePickOrders = (listOf(pickOrder) + allCompetingPickOrders).distinctBy { i | |||
| } | |||
| } | |||
| // ✅ 优化:批量更新 inventory table(收集所有 item IDs,只更新一次) | |||
| val allItemIdsToUpdate = allPickOrdersToResuggest | |||
| .flatMap { it.pickOrderLines } | |||
| .mapNotNull { it.item?.id } | |||
| .distinct() | |||
| if (allItemIdsToUpdate.isNotEmpty()) { | |||
| println("=== Batch updating inventory table for ${allItemIdsToUpdate.size} items ===") | |||
| allItemIdsToUpdate.forEach { itemId -> | |||
| // Calculate onHoldQty for ALL pick orders that use this item | |||
| // ✅ FIX: Use enum directly, not .value | |||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||
| .sumOf { it.holdQty ?: BigDecimal.ZERO } | |||
| // ✅ FIX: Use enum directly, not .value | |||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE) | |||
| .sumOf { | |||
| val inQty = it.inQty ?: BigDecimal.ZERO | |||
| val outQty = it.outQty ?: BigDecimal.ZERO | |||
| val remainingQty = inQty.minus(outQty) | |||
| remainingQty | |||
| } | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| if (inventory != null) { | |||
| inventory.onHoldQty = onHoldQty | |||
| inventory.unavailableQty = unavailableQty | |||
| inventoryRepository.save(inventory) | |||
| } | |||
| } | |||
| println("✅ Batch updated inventory table for ${allItemIdsToUpdate.size} items") | |||
| } | |||
| println("=== RESUGGEST DEBUG END ===") | |||
| return MessageResponse( | |||
| @@ -1342,40 +1308,6 @@ private fun generateOptimalSuggestionsForAllPickOrders( | |||
| return suggestions | |||
| } | |||
| private fun updateInventoryTableAfterResuggest(pickOrder: PickOrder) { | |||
| try { | |||
| // Get all item IDs from the pick order | |||
| val itemIds = pickOrder.pickOrderLines.mapNotNull { it.item?.id }.distinct() | |||
| itemIds.forEach { itemId -> | |||
| // FIX: Calculate onHoldQty for ALL pick orders that use this item, not just the current one | |||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||
| .sumOf { it.holdQty ?: BigDecimal.ZERO } | |||
| // FIX: Use enum method instead of string method | |||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE) | |||
| .sumOf { | |||
| val inQty = it.inQty ?: BigDecimal.ZERO | |||
| val outQty = it.outQty ?: BigDecimal.ZERO | |||
| val remainingQty = inQty.minus(outQty) | |||
| remainingQty | |||
| } | |||
| // Update the inventory table | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| if (inventory != null) { | |||
| inventory.onHoldQty = onHoldQty | |||
| inventory.unavailableQty = unavailableQty | |||
| inventoryRepository.save(inventory) | |||
| println("Updated inventory for item $itemId: onHoldQty=$onHoldQty, unavailableQty=$unavailableQty") | |||
| } | |||
| } | |||
| } catch (e: Exception) { | |||
| println("Error updating inventory table after resuggest: ${e.message}") | |||
| e.printStackTrace() | |||
| } | |||
| } | |||
| private fun findAllSuggestionsForPickOrder(pickOrderId: Long): List<SuggestedPickLot> { | |||
| val pickOrderLines = pickOrderRepository.findById(pickOrderId) | |||
| .orElseThrow() | |||
| @@ -1799,27 +1731,9 @@ private fun handleMissingItem(inventoryLotLine: InventoryLotLine, failQty: BigDe | |||
| // 处理损坏物品的情况 | |||
| private fun handleBrokenItem(inventoryLotLine: InventoryLotLine, failQty: BigDecimal) { | |||
| try { | |||
| // 1. 将库存批次行标记为不可用 | |||
| inventoryLotLine.status = InventoryLotLineStatus.UNAVAILABLE | |||
| // 2. 更新库存表的不可用数量 | |||
| val itemId = inventoryLotLine.inventoryLot?.item?.id | |||
| if (itemId != null) { | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| if (inventory != null) { | |||
| val currentUnavailableQty = inventory.unavailableQty ?: BigDecimal.ZERO | |||
| inventory.unavailableQty = currentUnavailableQty.plus(failQty) | |||
| inventoryRepository.save(inventory) | |||
| println("Broken item handled: Updated unavailableQty by $failQty for item $itemId") | |||
| } | |||
| } | |||
| // 3. 保存库存批次行的状态变更 | |||
| inventoryLotLineRepository.save(inventoryLotLine) | |||
| println("Broken item handled: Marked lot line ${inventoryLotLine.id} as unavailable") | |||
| } catch (e: Exception) { | |||
| println("Error handling broken item: ${e.message}") | |||
| throw e | |||
| @@ -24,6 +24,21 @@ data class WorkbenchSuggestionSummary( | |||
| val skippedExisting: Int, | |||
| ) | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Case-insensitive match of [warehouseCode] against [excludeCodes]. | |||
| * An entry matches the code exactly, or any code that starts with `{entry}-`. | |||
| * `"4F"` therefore excludes every 4F bin. | |||
| */ | |||
| fun isWorkbenchWarehouseCodeExcluded(warehouseCode: String?, excludeCodes: Collection<String>): Boolean { | |||
| val code = warehouseCode?.trim()?.uppercase() ?: return false | |||
| if (code.isEmpty() || excludeCodes.isEmpty()) return false | |||
| return excludeCodes.any { raw -> | |||
| val entry = raw.trim().uppercase() | |||
| entry.isNotEmpty() && (code == entry || code.startsWith("$entry-")) | |||
| } | |||
| } | |||
| @Service | |||
| open class SuggestedPickLotWorkbenchService( | |||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||
| @@ -138,9 +153,32 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| private fun isWarehouseExcluded(lot: InventoryLotLine, excludeWarehouseCodes: Set<String>): Boolean { | |||
| if (excludeWarehouseCodes.isEmpty()) return false | |||
| val code = lot.warehouse?.code?.trim()?.uppercase() | |||
| return !code.isNullOrEmpty() && code in excludeWarehouseCodes | |||
| return isWorkbenchWarehouseCodeExcluded(lot.warehouse?.code, excludeWarehouseCodes) | |||
| } | |||
| /** 3F before 2F when expiry is the same. Other floors stay after those two. */ | |||
| private fun suggestionFloorRank(lot: InventoryLotLine): Int { | |||
| val store = normalizeStoreId(lot.warehouse?.store_id) | |||
| ?: floorTokenFromWarehouseCode(lot.warehouse?.code) | |||
| return when (store) { | |||
| "3F" -> 0 | |||
| "2F" -> 1 | |||
| else -> 2 | |||
| } | |||
| } | |||
| private fun floorTokenFromWarehouseCode(code: String?): String? { | |||
| val head = code?.trim()?.substringBefore("-") ?: return null | |||
| return normalizeStoreId(head) | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Closest expiry first; same day prefers 3F, then 2F. | |||
| */ | |||
| private fun compareSuggestedLots(a: InventoryLotLine, b: InventoryLotLine): Int { | |||
| val byExpiry = compareValues(a.inventoryLot?.expiryDate, b.inventoryLot?.expiryDate) | |||
| if (byExpiry != 0) return byExpiry | |||
| return suggestionFloorRank(a).compareTo(suggestionFloorRank(b)) | |||
| } | |||
| private fun effectiveExcludeWarehouseCodes(raw: List<String>?): Set<String> { | |||
| @@ -163,7 +201,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Build suggestions with no-hold rule: | |||
| * available = inQty - outQty (ignore holdQty). | |||
| * When [desired] is a single segment, reuses one SPL row (max id) and repoints lot + qty (switch-lot without new id). | |||
| @@ -199,7 +237,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| .filter { it.status == InventoryLotLineStatus.AVAILABLE } | |||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | |||
| .filter { !isWarehouseExcluded(it, excludeSet) } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| .groupBy { it.inventoryLot?.item?.id } | |||
| var created = 0 | |||
| @@ -296,7 +334,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Same no-hold allocation as [rebuildNoHoldSuggestionsForPickOrder], but only for one [pickOrderLineId]. | |||
| * Used by workbench scan-pick to avoid rebuilding suggestions for every line on the pick order. | |||
| */ | |||
| @@ -360,7 +398,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| if (storeKey == null) true | |||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | |||
| } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| val desired = buildDesiredAllocations(lots, remaining, pickOrderLineId) | |||
| val created = syncSuggestedPickLotsForLine(lineFresh, desired) | |||
| @@ -388,7 +426,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Workbench explicit-qty remainder support (single-next-lot): | |||
| * Create exactly ONE suggested lot row for this POL with [targetQty] pointing to the next available lot. | |||
| * When [storeId] is provided, the next lot must be within the same store (warehouse.store_id). | |||
| @@ -436,7 +474,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| if (storeKey == null) true | |||
| else normalizeStoreId(lot.warehouse?.store_id) == storeKey | |||
| } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| .firstOrNull() | |||
| val desired = listOf(nextLot to targetQty) | |||
| @@ -449,7 +487,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.2 | 2026-08-03 | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 32 | v1.0.3 | 2026-11-02 | |||
| * Workbench explicit-qty split support: | |||
| * Set suggestions for this POL to an exact [targetQty] (independent of POL.qty). | |||
| * - If [targetQty] <= 0: soft-delete all active suggestions for the line. | |||
| @@ -486,7 +524,7 @@ open class SuggestedPickLotWorkbenchService( | |||
| .filter { it.inventoryLot?.expiryDate?.isBefore(today) != true } | |||
| .filter { !isWarehouseExcluded(it, excludeSet) } | |||
| .filter { matchesPolUom(it, line) } | |||
| .sortedBy { it.inventoryLot?.expiryDate } | |||
| .sortedWith(this::compareSuggestedLots) | |||
| val desired = buildDesiredAllocations(lots, targetQty, pickOrderLineId) | |||
| val created = syncSuggestedPickLotsForLine(line, desired) | |||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.stock.web | |||
| import com.ffii.core.response.RecordsRes | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryInfo | |||
| import com.ffii.fpsms.modules.stock.entity.projection.InventoryStockUomSearchRow | |||
| import com.ffii.fpsms.modules.stock.service.InventoryService | |||
| import com.ffii.fpsms.modules.stock.web.model.SearchInventoryRequest | |||
| import org.springframework.web.bind.annotation.* | |||
| @@ -16,19 +17,20 @@ class InventoryController( | |||
| return inventoryService.allInventories() | |||
| } | |||
| /** Full inventory search: one row per (itemId, stockUomId) bucket, plus current stockUnit when missing. */ | |||
| @GetMapping("/getRecordByPage") | |||
| fun allInventoriesByPage(@ModelAttribute request: SearchInventoryRequest): RecordsRes<InventoryInfo> { | |||
| return inventoryService.allInventoriesByPage(request); | |||
| return inventoryService.allInventoriesByPage(request) | |||
| } | |||
| /** | |||
| * FP-MTMS Version Checklist | Functions Ref. No. 19 | v1.0.2 | 2026-09-21 | |||
| * Inventory search page only: latest inventory row per item, no baseUnit/uomId filter. | |||
| * Location Search: one row per item + lot-line stock UoM, plus current master stockUnit when missing. | |||
| * Item Search uses GET /inventory/getRecordByPage (inventory buckets + current stockUnit). | |||
| */ | |||
| @GetMapping("/searchLatest/getRecordByPage") | |||
| fun searchInventoriesByLatestInventory( | |||
| @ModelAttribute request: SearchInventoryRequest, | |||
| ): RecordsRes<InventoryInfo> { | |||
| ): RecordsRes<InventoryStockUomSearchRow> { | |||
| return inventoryService.searchInventoriesByLatestInventory(request) | |||
| } | |||