diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index 5ecdecc..f00a937 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -1624,16 +1624,17 @@ open class PickOrderService( } throw IllegalStateException("Failed to generate unique delivery note code after $maxAttempts attempts") } - + /* @Transactional(rollbackFor = [java.lang.Exception::class]) + open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse { try { - println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") - println("consoCode: $consoCode") + // println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") + // println("consoCode: $consoCode") val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode) if (stockOut == null) { - println("❌ No stock_out found for consoCode: $consoCode") + //println("❌ No stock_out found for consoCode: $consoCode") return MessageResponse( id = null, name = "Stock out not found", @@ -1680,13 +1681,13 @@ open class PickOrderService( !(isComplete || isRejected || isPartiallyComplete) } - println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}") + // println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}") if (unfinishedLines.isEmpty()) { println(" All stock out lines completed, updating pick order statuses...") return completeStockOut(consoCode) } else { - println("⏳ Still have ${unfinishedLines.size} unfinished lines") + //println("⏳ Still have ${unfinishedLines.size} unfinished lines") return MessageResponse( id = stockOut.id, name = stockOut.consoPickOrderCode ?: stockOut.deliveryOrderCode, @@ -1710,8 +1711,47 @@ open class PickOrderService( ) } } - - +*/ +@Transactional(readOnly = true) +open fun countUnfinishedLinesByConsoCode(consoCode: String): Int { + val sql = """ + SELECT COUNT(1) AS unfinished_count + FROM stock_out_line sol + JOIN pick_order_line pol ON pol.id = sol.pickOrderLineId + JOIN pick_order po ON po.id = pol.poId + WHERE po.consoCode = :consoCode + AND sol.deleted = false + AND LOWER(TRIM(COALESCE(sol.status, ''))) NOT IN ( + 'completed', + 'complete', + 'rejected', + 'partially_completed', + 'partially_complete' + ) + """.trimIndent() + val rows = jdbcDao.queryForList(sql, mapOf("consoCode" to consoCode)) + val countAny = rows.firstOrNull()?.get("unfinished_count") + return when (countAny) { + is Number -> countAny.toInt() + is String -> countAny.toIntOrNull() ?: 0 + else -> 0 + } +} +@Transactional(rollbackFor = [Exception::class]) +open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse { + val unfinished = countUnfinishedLinesByConsoCode(consoCode) + if (unfinished > 0) { + return MessageResponse( + id = null, + name = consoCode, + code = "NOT_COMPLETED", + type = "pickorder", + message = "Pick order not completed yet, $unfinished lines remaining", + errorPosition = null + ) + } + return completeStockOut(consoCode) +} open fun createGroup(name: String, targetDate: LocalDate, pickOrderId: Long?): PickOrderGroup { val group = PickOrderGroup().apply { this.name = name diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt index b3b7085..510019d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt @@ -305,6 +305,8 @@ ORDER BY fun searchStockTakeVarianceReportV2( stockTakeRoundId: Long, itemCode: String?, + storeId: String?, + status: String?, ): List> { val countSql = """ SELECT COUNT(*) AS c FROM stocktakerecord s @@ -320,6 +322,34 @@ ORDER BY val args = mutableMapOf() args["stockTakeRoundId"] = stockTakeRoundId + + val statusNormalized = status?.trim()?.lowercase().orEmpty() + // status 映射规则: + // - All/null:不过滤 + // - pending:包含 pending/pass/notMatch + // - completed:只看 completed + val statusLatestSql = when (statusNormalized) { + "pending" -> """ + AND str.status IN ('pending', 'pass', 'notMatch') + """.trimIndent() + "completed" -> """ + AND str.status = 'completed' + """.trimIndent() + else -> "" + } + + val storeIdSql = run { + val normalized = storeId?.trim() + if (normalized.isNullOrBlank() || normalized.equals("all", ignoreCase = true)) { + "" + } else { + args["storeId"] = normalized + // DB 里 store_id 可能是 "2/F" 或 "2F";用 REPLACE 去斜線做匹配 + """ + AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '') + """.trimIndent() + } + } val itemCodeSql = buildMultiValueLikeClause( itemCode, "it.code", @@ -345,10 +375,12 @@ latest_str AS ( str.approverStockTakeQty, str.date AS strDate, str.id, - str.approverTime + str.approverTime, + str.status AS stockTakeRecordStatus FROM stocktakerecord str WHERE str.deleted = 0 AND str.stockTakeRoundId = :stockTakeRoundId +$statusLatestSql ), in_agg AS ( SELECT @@ -443,7 +475,8 @@ data AS ( ls.approverStockTakeQty AS stkApproverQty, ls.varianceQty AS stkVarianceQty, ls.strDate AS stockTakeDateRaw, - ls.approverTime AS approvalDateTimeRaw + ls.approverTime AS approvalDateTimeRaw, + ls.stockTakeRecordStatus AS stockTakeRecordStatus FROM latest_str ls INNER JOIN inventory_lot il ON ls.lotId = il.id @@ -471,6 +504,7 @@ data AS ( WHERE 1=1 $itemCodeSql + $storeIdSql ) SELECT @@ -501,12 +535,14 @@ SELECT END AS stockTakeQty, CASE + WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0' WHEN stkVarianceQty IS NULL THEN '0' WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')') ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0) END AS variance, CASE + WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0%' WHEN stkVarianceQty IS NULL THEN '0%' WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%' WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)') diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt index dac4db1..d78a82a 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt @@ -143,6 +143,8 @@ class StockTakeVarianceReportController( fun generateStockTakeVarianceReportV2( @RequestParam stockTakeRoundId: Long, @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false, name = "store_id") storeId: String?, + @RequestParam(required = false) status: String?, ): ResponseEntity { val parameters = mutableMapOf() @@ -169,6 +171,8 @@ class StockTakeVarianceReportController( val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( stockTakeRoundId = stockTakeRoundId, itemCode = itemCode, + storeId = storeId, + status = status, ) val stockTakeDateDisplay = dbData .mapNotNull { it["stockTakeDate"] as? String } @@ -196,11 +200,15 @@ class StockTakeVarianceReportController( fun exportStockTakeVarianceReportV2Excel( @RequestParam stockTakeRoundId: Long, @RequestParam(required = false) itemCode: String?, + @RequestParam(required = false, name = "store_id") storeId: String?, + @RequestParam(required = false) status: String?, ): ResponseEntity { val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( stockTakeRoundId = stockTakeRoundId, itemCode = itemCode, + storeId = storeId, + status = status, ) val excelBytes = createStockTakeVarianceExcel( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt index 6e0f5f9..e75cb39 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt @@ -40,6 +40,7 @@ interface InventoryRepository: AbstractRepository { fun findInventoryInfoByItemInAndDeletedIsFalse(items: List): List fun findByItemId(itemId: Long): Optional + fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection): List @Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false") fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt index 648ef52..b5feedf 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt @@ -9,6 +9,7 @@ interface StockTakeLineRepository : AbstractRepository { fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?; fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?; fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine? + fun findAllByStockTakeRecord_IdInAndDeletedIsFalse(stockTakeRecordIds: Collection): List fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse( stockTakeIds: Collection, inventoryLotLineIds: Collection diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index eca5189..b15337b 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -422,6 +422,14 @@ open class StockTakeRecordService( .mapNotNull { it.stockTakeSectionDescription } // 先去掉 null .distinct() // 去重(防止误填多个不同值) .firstOrNull() + val warehouseArea = warehouses + .mapNotNull { it.area } + .distinct() + .firstOrNull() + val storeId = warehouses + .mapNotNull { it.store_id } + .distinct() + .firstOrNull() val roundIdForLatest = latestStockTake?.let { st -> st.stockTakeRoundId ?: st.id } val roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) { @@ -483,7 +491,9 @@ open class StockTakeRecordService( endTime = latestStockTake?.actualEnd, ReStockTakeTrueFalse = reStockTakeTrueFalse, planStartDate = latestStockTake?.planStart?.toLocalDate(), - stockTakeSectionDescription = sectionDescription + stockTakeSectionDescription = sectionDescription, + warehouseArea = warehouseArea, + storeId = storeId ) ) @@ -804,7 +814,9 @@ open class StockTakeRecordService( endTime = latestBaseStockTake.actualEnd, ReStockTakeTrueFalse = anyNotMatch, planStartDate = latestBaseStockTake.planStart?.toLocalDate(), - stockTakeSectionDescription = null + stockTakeSectionDescription = null, + warehouseArea = null, + storeId = null ) ) } @@ -839,7 +851,9 @@ open class StockTakeRecordService( endTime = latestBaseStockTake.actualEnd, ReStockTakeTrueFalse = false, planStartDate = latestBaseStockTake.planStart?.toLocalDate(), - stockTakeSectionDescription = null + stockTakeSectionDescription = null, + warehouseArea = null, + storeId = null ) } @@ -1842,11 +1856,14 @@ if (itemParts.isNotEmpty()) { open fun batchSaveApproverStockTakeRecordsByIds( request: BatchSaveApproverStockTakeByIdsRequest ): BatchSaveApproverStockTakeRecordResponse { - println("batchSaveApproverStockTakeRecordsByIds called for stockTakeId: ${request.stockTakeId}, ids=${request.recordIds.size}") + val totalStartNs = System.nanoTime() + fun elapsedMs(startNs: Long): Long = (System.nanoTime() - startNs) / 1_000_000 + logger.info("batchSaveApproverStockTakeRecordsByIds start: stockTakeId={}, ids={}", request.stockTakeId, request.recordIds.size) if (request.recordIds.isEmpty()) { return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided")) } + val loadStartNs = System.nanoTime() val user = userRepository.findById(request.approverId).orElse(null) val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") @@ -1859,15 +1876,33 @@ open fun batchSaveApproverStockTakeRecordsByIds( (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && it.approverStockTakeQty == null } - println("Found ${stockTakeRecords.size} records to process by IDs") + logger.info( + "batchSaveApproverStockTakeRecordsByIds load completed: candidates={}, loadMs={}", + stockTakeRecords.size, + elapsedMs(loadStartNs) + ) if (stockTakeRecords.isEmpty()) { return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria")) } + val cacheBuildStartNs = System.nanoTime() + val adjustmentCache = buildBatchAdjustmentCache(stockTakeRecords) + logger.info( + "batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}", + adjustmentCache.inventoryLotLineByWarehouseLot.size, + adjustmentCache.inventoryLotById.size, + adjustmentCache.inventoryByItemId.size, + adjustmentCache.stockTakeLineByRecordId.size, + elapsedMs(cacheBuildStartNs) + ) var successCount = 0 var errorCount = 0 val errors = mutableListOf() val processedStockTakes = mutableSetOf>() + val prepareStartNs = System.nanoTime() + val recordsToPersist = mutableListOf() + val postPersistActions = mutableListOf>() + stockTakeRecords.forEach { record -> try { val qty: BigDecimal @@ -1901,27 +1936,8 @@ open fun batchSaveApproverStockTakeRecordsByIds( } } - stockTakeRecordRepository.save(record) - - if (varianceQty != BigDecimal.ZERO) { - try { - applyVarianceAdjustment(record.stockTake ?: stockTake, record, qty, varianceQty, request.approverId) - } catch (e: Exception) { - logger.error("Failed to apply variance adjustment for record ${record.id}", e) - errorCount++ - errors.add("Record ${record.id}: ${e.message}") - return@forEach - } - } else { - completeStockTakeLineForApproverNoVariance(record.stockTake ?: stockTake, record, qty) - } - - val stId = record.stockTake?.id - val section = record.stockTakeSection - if (stId != null && section != null) { - processedStockTakes.add(Pair(stId, section)) - } - successCount++ + recordsToPersist.add(record) + postPersistActions.add(Triple(record, qty, varianceQty)) } catch (e: Exception) { errorCount++ val errorMsg = "Error processing record ${record.id}: ${e.message}" @@ -1929,14 +1945,118 @@ open fun batchSaveApproverStockTakeRecordsByIds( logger.error(errorMsg, e) } } + logger.info( + "batchSaveApproverStockTakeRecordsByIds prepare completed: readyToPersist={}, precheckErrors={}, prepareMs={}", + recordsToPersist.size, + errorCount, + elapsedMs(prepareStartNs) + ) + + if (recordsToPersist.isNotEmpty()) { + val persistStartNs = System.nanoTime() + stockTakeRecordRepository.saveAll(recordsToPersist) + logger.info( + "batchSaveApproverStockTakeRecordsByIds persist completed: persisted={}, persistMs={}", + recordsToPersist.size, + elapsedMs(persistStartNs) + ) + + val adjustmentStartNs = System.nanoTime() + val runtimeCache = BatchAdjustmentRuntimeCache( + stockOutByStockTakeId = mutableMapOf(), + stockInByStockTakeId = mutableMapOf(), + runningLedgerBalanceByItemId = mutableMapOf() + ) + val adjustmentContext = StockTakeAdjustmentBatchContext() + var varianceCount = 0 + var noVarianceCount = 0 + var varianceMs = 0L + var noVarianceMs = 0L + postPersistActions.forEach { (record, qty, varianceQty) -> + try { + if (varianceQty != BigDecimal.ZERO) { + val varianceStartNs = System.nanoTime() + applyVarianceAdjustment( + record.stockTake ?: stockTake, + record, + qty, + varianceQty, + request.approverId, + adjustmentCache, + runtimeCache, + adjustmentContext + ) + varianceMs += elapsedMs(varianceStartNs) + varianceCount++ + } else { + val noVarianceStartNs = System.nanoTime() + completeStockTakeLineForApproverNoVariance( + record.stockTake ?: stockTake, + record, + qty, + adjustmentCache, + adjustmentContext + ) + noVarianceMs += elapsedMs(noVarianceStartNs) + noVarianceCount++ + } + val stId = record.stockTake?.id + val section = record.stockTakeSection + if (stId != null && section != null) { + processedStockTakes.add(Pair(stId, section)) + } + successCount++ + } catch (e: Exception) { + logger.error("Failed to apply inventory/line update for record ${record.id}", e) + errorCount++ + errors.add("Record ${record.id}: ${e.message}") + } + } + val flushStartNs = System.nanoTime() + flushStockTakeAdjustmentBatchContext(adjustmentContext) + val flushMs = elapsedMs(flushStartNs) + logger.info( + "batchSaveApproverStockTakeRecordsByIds adjustment completed: successSoFar={}, errorsSoFar={}, adjustmentMs={}, varianceCount={}, varianceMs={}, noVarianceCount={}, noVarianceMs={}, flushMs={}", + successCount, + errorCount, + elapsedMs(adjustmentStartNs), + varianceCount, + varianceMs, + noVarianceCount, + noVarianceMs, + flushMs + ) + logger.info( + "batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}", + runtimeCache.stockOutByStockTakeId.size, + runtimeCache.stockInByStockTakeId.size, + runtimeCache.runningLedgerBalanceByItemId.size, + adjustmentContext.stockTakeLineByRecordId.size, + adjustmentContext.stockOutLines.size, + adjustmentContext.stockInLines.size, + adjustmentContext.inventoryLotLineById.size, + adjustmentContext.stockLedgers.size + ) + } if (successCount > 0) { + val statusStartNs = System.nanoTime() processedStockTakes.forEach { (stId, section) -> checkAndUpdateStockTakeStatus(stId, section) } + logger.info( + "batchSaveApproverStockTakeRecordsByIds status update completed: stockTakes={}, statusMs={}", + processedStockTakes.size, + elapsedMs(statusStartNs) + ) } - println("batchSaveApproverStockTakeRecordsByIds completed: success=$successCount, errors=$errorCount") + logger.info( + "batchSaveApproverStockTakeRecordsByIds completed: success={}, errors={}, totalMs={}", + successCount, + errorCount, + elapsedMs(totalStartNs) + ) return BatchSaveApproverStockTakeRecordResponse( successCount = successCount, errorCount = errorCount, @@ -1948,10 +2068,19 @@ open fun batchSaveApproverStockTakeRecordsByIds( * stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。 */ private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): InventoryLotLine { + return resolveInventoryLotLineForStockTakeRecord(record, null) +} + +private fun resolveInventoryLotLineForStockTakeRecord( + record: StockTakeRecord, + cache: BatchAdjustmentCache? +): InventoryLotLine { val warehouseId = record.warehouse?.id ?: throw IllegalArgumentException("Warehouse not found on stock take record") val lotId = record.inventoryLotId ?: record.lotId ?: throw IllegalArgumentException("Inventory lot ID not found on stock take record") + val cacheKey = Pair(warehouseId, lotId) + cache?.inventoryLotLineByWarehouseLot?.get(cacheKey)?.let { return it } val lines = inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse( listOf(warehouseId), listOf(lotId) @@ -1971,10 +2100,14 @@ private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): private fun completeStockTakeLineForApproverNoVariance( stockTake: StockTake, stockTakeRecord: StockTakeRecord, - finalQty: BigDecimal + finalQty: BigDecimal, + cache: BatchAdjustmentCache? = null, + context: StockTakeAdjustmentBatchContext? = null ) { val rid = stockTakeRecord.id ?: return - val line = stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) ?: return + val line = cache?.stockTakeLineByRecordId?.get(rid) + ?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) + ?: return line.apply { this.stockTake = stockTake this.initialQty = this.initialQty ?: stockTakeRecord.bookQty @@ -1983,7 +2116,11 @@ private fun completeStockTakeLineForApproverNoVariance( this.completeDate = LocalDateTime.now() this.stockTakeRecord = stockTakeRecord } - stockTakeLineRepository.save(line) + if (context != null) { + context.stockTakeLineByRecordId[rid] = line + } else { + stockTakeLineRepository.save(line) + } } /** @@ -1997,23 +2134,29 @@ private fun applyVarianceAdjustment( stockTakeRecord: StockTakeRecord, finalQty: BigDecimal, varianceQty: BigDecimal, - approverId: Long? + approverId: Long?, + cache: BatchAdjustmentCache? = null, + runtimeCache: BatchAdjustmentRuntimeCache? = null, + context: StockTakeAdjustmentBatchContext? = null ) { if (varianceQty == BigDecimal.ZERO) return - val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord) + val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord, cache) - val inventoryLot = inventoryLotRepository.findByIdAndDeletedFalse( - inventoryLotLine.inventoryLot?.id ?: throw IllegalArgumentException("Inventory lot ID not found") - ) ?: throw IllegalArgumentException("Inventory lot not found") + val inventoryLotId = inventoryLotLine.inventoryLot?.id + ?: throw IllegalArgumentException("Inventory lot ID not found") + val inventoryLot = cache?.inventoryLotById?.get(inventoryLotId) + ?: inventoryLotRepository.findByIdAndDeletedFalse(inventoryLotId) + ?: throw IllegalArgumentException("Inventory lot not found") - val inventory = inventoryRepository.findByItemId( - inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found") - ).orElse(null) ?: throw IllegalArgumentException("Inventory not found for item") + 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") // 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建 val stockTakeLine = stockTakeRecord.id?.let { rid -> - stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) + cache?.stockTakeLineByRecordId?.get(rid) ?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) }?.also { existing -> existing.apply { this.stockTake = stockTake @@ -2033,7 +2176,12 @@ private fun applyVarianceAdjustment( this.completeDate = LocalDateTime.now() this.stockTakeRecord = stockTakeRecord } - stockTakeLineRepository.save(stockTakeLine) + val stockTakeRecordId = stockTakeRecord.id + if (context != null && stockTakeRecordId != null) { + context.stockTakeLineByRecordId[stockTakeRecordId] = stockTakeLine + } else { + stockTakeLineRepository.save(stockTakeLine) + } val zero = BigDecimal.ZERO @@ -2048,12 +2196,24 @@ private fun applyVarianceAdjustment( return } - var stockOut = stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!) - ?: StockOut().apply { - this.type = "stockTake" - this.status = "completed" - this.handler = approverId - }.also { stockOutRepository.save(it) } + val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found") + val stockOut = if (runtimeCache != null) { + runtimeCache.stockOutByStockTakeId.getOrPut(stockTakeId) { + stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) + ?: StockOut().apply { + this.type = "stockTake" + this.status = "completed" + this.handler = approverId + }.also { stockOutRepository.save(it) } + } + } else { + stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) + ?: StockOut().apply { + this.type = "stockTake" + this.status = "completed" + this.handler = approverId + }.also { stockOutRepository.save(it) } + } val stockOutLine = StockOutLine().apply { this.item = inventoryLot.item @@ -2063,15 +2223,21 @@ private fun applyVarianceAdjustment( this.status = "completed" this.type = "TKE" } - stockOutLineRepository.save(stockOutLine) + if (context != null) { + context.stockOutLines.add(stockOutLine) + } else { + stockOutLineRepository.save(stockOutLine) + } // 與 StockOutLineService.createStockLedgerForStockOut 一致:依上一筆 ledger balance 扣減, // 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。 val itemIdForLedger = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found for stock take ledger") - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) - val previousBalance = latestLedger?.balance - ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) + ?: run { + val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) + latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + } val newBalance = previousBalance - qtyToRemove.toDouble() val stockLedger = StockLedger().apply { @@ -2087,21 +2253,37 @@ private fun applyVarianceAdjustment( this.date = LocalDate.now() } - stockLedgerRepository.save(stockLedger) + if (context != null) { + context.stockLedgers.add(stockLedger) + } else { + stockLedgerRepository.save(stockLedger) + } + runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove) - val updateRequest = SaveInventoryLotLineRequest( - id = latestLine.id, - inventoryLotId = latestLine.inventoryLot?.id, - warehouseId = latestLine.warehouse?.id, - stockUomId = latestLine.stockUom?.id, - inQty = latestLine.inQty, - outQty = newOutQty, - holdQty = latestLine.holdQty, - status = latestLine.status?.value, - remarks = latestLine.remarks + latestLine.outQty = newOutQty + latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus( + latestLine.status, + latestLine.inQty, + latestLine.outQty, + latestLine.holdQty ) - inventoryLotLineService.saveInventoryLotLine(updateRequest) + if (context != null && latestLine.id != null) { + context.inventoryLotLineById[latestLine.id!!] = latestLine + } else { + val updateRequest = SaveInventoryLotLineRequest( + id = latestLine.id, + inventoryLotId = latestLine.inventoryLot?.id, + warehouseId = latestLine.warehouse?.id, + stockUomId = latestLine.stockUom?.id, + inQty = latestLine.inQty, + outQty = latestLine.outQty, + holdQty = latestLine.holdQty, + status = latestLine.status?.value, + remarks = latestLine.remarks + ) + inventoryLotLineService.saveInventoryLotLine(updateRequest) + } } // 3. variance > 0:入庫加在既有庫存行 inQty + StockLedger @@ -2110,12 +2292,24 @@ private fun applyVarianceAdjustment( val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow() val newInQty = (latestLine.inQty ?: zero).add(plusQty) - var stockIn = stockInRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!) - ?: StockIn().apply { - this.code = stockTake.code - this.status = "completed" - this.stockTake = stockTake - }.also { stockInRepository.save(it) } + val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found") + val stockIn = if (runtimeCache != null) { + runtimeCache.stockInByStockTakeId.getOrPut(stockTakeId) { + stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) + ?: StockIn().apply { + this.code = stockTake.code + this.status = "completed" + this.stockTake = stockTake + }.also { stockInRepository.save(it) } + } + } else { + stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId) + ?: StockIn().apply { + this.code = stockTake.code + this.status = "completed" + this.stockTake = stockTake + }.also { stockInRepository.save(it) } + } val stockInLine = StockInLine().apply { this.stockTakeLine = stockTakeLine @@ -2132,26 +2326,43 @@ private fun applyVarianceAdjustment( // 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突 this.inventoryLotLine = null } - stockInLineRepository.save(stockInLine) - - val updateRequest = SaveInventoryLotLineRequest( - id = latestLine.id, - inventoryLotId = latestLine.inventoryLot?.id, - warehouseId = latestLine.warehouse?.id, - stockUomId = latestLine.stockUom?.id, - inQty = newInQty, - outQty = latestLine.outQty, - holdQty = latestLine.holdQty, - status = latestLine.status?.value, - remarks = latestLine.remarks + if (context != null) { + context.stockInLines.add(stockInLine) + } else { + stockInLineRepository.save(stockInLine) + } + + latestLine.inQty = newInQty + latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus( + latestLine.status, + latestLine.inQty, + latestLine.outQty, + latestLine.holdQty ) - inventoryLotLineService.saveInventoryLotLine(updateRequest) + if (context != null && latestLine.id != null) { + context.inventoryLotLineById[latestLine.id!!] = latestLine + } else { + val updateRequest = SaveInventoryLotLineRequest( + id = latestLine.id, + inventoryLotId = latestLine.inventoryLot?.id, + warehouseId = latestLine.warehouse?.id, + stockUomId = latestLine.stockUom?.id, + inQty = latestLine.inQty, + outQty = latestLine.outQty, + holdQty = latestLine.holdQty, + status = latestLine.status?.value, + remarks = latestLine.remarks + ) + inventoryLotLineService.saveInventoryLotLine(updateRequest) + } val itemIdForLedger = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)") - val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) - val previousBalance = latestLedger?.balance - ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger) + ?: run { + val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger) + latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() + } val newBalance = previousBalance + plusQty.toDouble() val stockLedger = StockLedger().apply { @@ -2167,8 +2378,112 @@ private fun applyVarianceAdjustment( this.date = LocalDate.now() } - stockLedgerRepository.save(stockLedger) + if (context != null) { + context.stockLedgers.add(stockLedger) + } else { + stockLedgerRepository.save(stockLedger) + } + runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance) + } +} + +private data class BatchAdjustmentCache( + val inventoryLotLineByWarehouseLot: Map, InventoryLotLine>, + val inventoryLotById: Map, + val inventoryByItemId: Map, + val stockTakeLineByRecordId: Map +) + +private data class BatchAdjustmentRuntimeCache( + val stockOutByStockTakeId: MutableMap, + val stockInByStockTakeId: MutableMap, + val runningLedgerBalanceByItemId: MutableMap +) + +private data class StockTakeAdjustmentBatchContext( + val stockTakeLineByRecordId: MutableMap = LinkedHashMap(), + val stockOutLines: MutableList = mutableListOf(), + val stockInLines: MutableList = mutableListOf(), + val inventoryLotLineById: MutableMap = LinkedHashMap(), + val stockLedgers: MutableList = mutableListOf() +) + +private fun flushStockTakeAdjustmentBatchContext(context: StockTakeAdjustmentBatchContext) { + if (context.stockTakeLineByRecordId.isNotEmpty()) { + stockTakeLineRepository.saveAll(context.stockTakeLineByRecordId.values.toList()) + } + if (context.stockOutLines.isNotEmpty()) { + stockOutLineRepository.saveAll(context.stockOutLines) + } + if (context.stockInLines.isNotEmpty()) { + stockInLineRepository.saveAll(context.stockInLines) + } + if (context.inventoryLotLineById.isNotEmpty()) { + inventoryLotLineRepository.saveAll(context.inventoryLotLineById.values.toList()) + } + if (context.stockLedgers.isNotEmpty()) { + stockLedgerRepository.saveAll(context.stockLedgers) + } +} + +private fun buildBatchAdjustmentCache(records: List): BatchAdjustmentCache { + val pairs = records.mapNotNull { r -> + val warehouseId = r.warehouse?.id + val lotId = r.inventoryLotId ?: r.lotId + if (warehouseId != null && lotId != null) Pair(warehouseId, lotId) else null + }.toSet() + val warehouseIds = pairs.map { it.first }.toSet() + val lotIds = pairs.map { it.second }.toSet() + val lotLines = + if (warehouseIds.isNotEmpty() && lotIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(warehouseIds, lotIds) + } else { + emptyList() + } + val inventoryLotLineByWarehouseLot = lotLines + .groupBy { Pair(it.warehouse?.id ?: 0L, it.inventoryLot?.id ?: 0L) } + .mapValues { (_, lines) -> lines.maxByOrNull { it.id ?: 0L }!! } + + val inventoryLotIds = lotLines.mapNotNull { it.inventoryLot?.id }.distinct() + val inventoryLotById = + if (inventoryLotIds.isNotEmpty()) { + inventoryLotRepository.findAllByIdIn(inventoryLotIds).associateByNotNull { it.id } + } else { + emptyMap() + } + val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct() + val inventoryByItemId = + if (itemIds.isNotEmpty()) { + inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds) + .groupBy { it.item?.id ?: 0L } + .mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! } + } else { + emptyMap() + } + val recordIds = records.mapNotNull { it.id }.distinct() + val stockTakeLineByRecordId = + if (recordIds.isNotEmpty()) { + stockTakeLineRepository.findAllByStockTakeRecord_IdInAndDeletedIsFalse(recordIds) + .associateByNotNull { it.stockTakeRecord?.id } + } else { + emptyMap() + } + + return BatchAdjustmentCache( + inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot, + inventoryLotById = inventoryLotById, + inventoryByItemId = inventoryByItemId, + stockTakeLineByRecordId = stockTakeLineByRecordId + ) +} + +private inline fun Iterable.associateByNotNull(keySelector: (T) -> K?): Map { + val destination = LinkedHashMap() + for (element in this) { + val key = keySelector(element) ?: continue + destination[key] = element } + return destination } open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord { println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt index 66cd811..806d3e4 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt @@ -29,7 +29,10 @@ class StockTakeRecordController( @RequestParam(required = false, defaultValue = "0") pageNum: Int, @RequestParam(required = false, defaultValue = "6") pageSize: Int, @RequestParam(required = false) sectionDescription: String?, - @RequestParam(required = false) stockTakeSections: String? + @RequestParam(required = false) stockTakeSections: String?, + @RequestParam(required = false) status: String?, + @RequestParam(required = false) area: String?, + @RequestParam(required = false) storeId: String? ): RecordsRes { var all = stockOutRecordService.AllPickedStockTakeList() if (sectionDescription != null && sectionDescription != "All") { @@ -46,6 +49,28 @@ class StockTakeRecordController( } } } + if (!status.isNullOrBlank() && status != "All") { + val normalizedStatus = status.trim().lowercase() + val acceptedStatuses = when (normalizedStatus) { + "stocktaking" -> setOf("stocktaking", "processing", "in_progress") + else -> setOf(normalizedStatus) + } + all = all.filter { item -> + val itemStatus = item.status.trim().lowercase() + itemStatus in acceptedStatuses + } + } + if (!area.isNullOrBlank()) { + val areaKeyword = area.trim() + all = all.filter { it.warehouseArea?.contains(areaKeyword, ignoreCase = true) == true } + } + if (!storeId.isNullOrBlank() && storeId != "All") { + val storeIdKeyword = storeId.trim() + all = all.filter { + it.storeId?.equals(storeIdKeyword, ignoreCase = true) == true || + it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true + } + } val total = all.size val fromIndex = pageNum * pageSize val toIndex = minOf(fromIndex + pageSize, total) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt index 4c25691..3496f9d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt @@ -27,6 +27,8 @@ data class AllPickedStockTakeListReponse( @JsonFormat(pattern = "yyyy-MM-dd") val planStartDate: LocalDate?, val stockTakeSectionDescription: String?, + val warehouseArea: String?, + val storeId: String?, ) data class InventoryLotDetailResponse( val id: Long, diff --git a/src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql b/src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql new file mode 100644 index 0000000..a5be2c2 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql @@ -0,0 +1,8 @@ +--liquibase formatted sql + +--changeset Enson:20260429-01 +CREATE INDEX idx_stock_out_stockTakeId_deleted +ON stock_out (stockTakeId, deleted); + +CREATE INDEX idx_stock_take_line_record_deleted +ON stock_take_line (stockTakeRecordId, deleted); \ No newline at end of file