From 3804d5e4fb9b45849d57971609163cc87234f7b6 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sat, 14 Mar 2026 18:20:36 +0800 Subject: [PATCH] update stock take search --- .../stock/service/StockTakeRecordService.kt | 266 ++++++++++++++++-- .../stock/web/StockTakeRecordController.kt | 54 +++- 2 files changed, 285 insertions(+), 35 deletions(-) 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 01d4a62..439204f 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 @@ -154,6 +154,10 @@ class StockTakeRecordService( } else { null } + val sectionDescription = warehouses + .mapNotNull { it.stockTakeSectionDescription } // 先去掉 null + .distinct() // 去重(防止误填多个不同值) + .firstOrNull() // 9. 计算 TotalItemNumber:获取该 section 下所有 InventoryLotLine,按 item 分组,计算不同的 item 数量 val totalItemNumber = inventoryLotLineRepository.countDistinctItemsByWarehouseIds(warehouseIds).toInt() val totalInventoryLotNumber = inventoryLotLineRepository.countAllByWarehouseIds(warehouseIds).toInt() @@ -185,7 +189,8 @@ class StockTakeRecordService( startTime = latestStockTake?.actualStart, endTime = latestStockTake?.actualEnd, ReStockTakeTrueFalse = reStockTakeTrueFalse, - planStartDate = latestStockTake?.planStart?.toLocalDate() + planStartDate = latestStockTake?.planStart?.toLocalDate(), + stockTakeSectionDescription = sectionDescription ) ) @@ -214,16 +219,36 @@ class StockTakeRecordService( // 2. 拿所有这些仓库下面的 lot line val inventoryLotLines = inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalse(warehouseIds) println("Found ${inventoryLotLines.size} inventory lot lines for ALL sections") - - // 3. 如果传了 stockTakeId,就把对应的 stockTakeRecord 预先查出来建 map(跟 section 版一样) + + // 3. 如果传了 stockTakeId,就把「同一轮」的所有 stockTake 找出来: + // 以该 stockTake 的 planStart 作为一轮的标识,取 planStart 相同的所有记录 + val roundStockTakeIds: Set = if (stockTakeId != null) { + val baseStockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId) + ?: throw IllegalArgumentException("Stock take not found: $stockTakeId") + val planStart = baseStockTake.planStart + + val roundStockTakes = if (planStart != null) { + stockTakeRepository.findAll() + .filter { !it.deleted && it.planStart == planStart } + } else { + listOf(baseStockTake) + } + + roundStockTakes.mapNotNull { it.id }.toSet() + } else { + emptySet() + } + + // 4. 如果有 stockTakeId,则预先把这一轮相关的 stockTakeRecord 查出来建 map(跟 section 版类似) val stockTakeRecordsMap = if (stockTakeId != null) { val allStockTakeRecords = stockTakeRecordRepository.findAll() .filter { !it.deleted && - it.stockTake?.id == stockTakeId && - it.warehouse?.id in warehouseIds + it.stockTake?.id != null && + it.stockTake!!.id!! in roundStockTakeIds && + it.warehouse?.id in warehouseIds } - + allStockTakeRecords.associateBy { Pair(it.lotId ?: 0L, it.warehouse?.id ?: 0L) } @@ -231,7 +256,7 @@ class StockTakeRecordService( emptyMap() } - // 4. 组装 InventoryLotDetailResponse(基本复制 section 版里的 map 那段) + // 5. 组装 InventoryLotDetailResponse(基本复制 section 版里的 map 那段) val allResults = inventoryLotLines.map { ill -> val inventoryLot = ill.inventoryLot val item = inventoryLot?.item @@ -245,12 +270,12 @@ class StockTakeRecordService( } else { null } - + val inventoryLotLineId = ill.id - val stockTakeLine = if (stockTakeId != null && inventoryLotLineId != null) { + val stockTakeLine = if (stockTakeRecord != null && stockTakeRecord.stockTake?.id != null && inventoryLotLineId != null) { stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse( inventoryLotLineId, - stockTakeId + stockTakeRecord.stockTake!!.id!! ) } else { null @@ -292,13 +317,21 @@ class StockTakeRecordService( ) } - // 5. 可选过滤:比如只保留 availableQty > 0 或有盘点记录的(跟 section 版一样) - val filteredResults = allResults.filter { response -> - val av = response.availableQty ?: BigDecimal.ZERO - av.compareTo(BigDecimal.ZERO) > 0 || response.stockTakeRecordId != null + // 6. 过滤结果: + // 如果带了 stockTakeId,表示只看这一轮盘点的记录,此时只保留「已有 stockTakeRecord 的行」(即 picker 已经盘点过的行) + // 如果没有带 stockTakeId,则沿用原逻辑:availableQty > 0 或已有盘点记录 + val filteredResults = if (stockTakeId != null) { + allResults.filter { response -> + response.stockTakeRecordId != null + } + } else { + allResults.filter { response -> + val av = response.availableQty ?: BigDecimal.ZERO + av.compareTo(BigDecimal.ZERO) > 0 || response.stockTakeRecordId != null + } } - // 6. 分页(和 section 版一模一样) + // 7. 分页(和 section 版一模一样) val pageable = PageRequest.of(pageNum, pageSize) val startIndex = pageable.offset.toInt() val endIndex = minOf(startIndex + pageSize, filteredResults.size) @@ -422,9 +455,44 @@ class StockTakeRecordService( } else { false } - // 9. 计算 TotalItemNumber:获取该 section 下所有 InventoryLotLine,按 item 分组,计算不同的 item 数量 - val totalItemNumber = inventoryLotLineRepository.countDistinctItemsByWarehouseIds(warehouseIds).toInt() - val totalInventoryLotNumber = inventoryLotLineRepository.countAllByWarehouseIds(warehouseIds).toInt() + // 9. 计算 TotalItemNumber / TotalInventoryLotNumber(只统计“需要盘点的行”): + // 规则与前端 Picker / Approver 明细一致: + // - availableQty > 0,或 + // - 在 latestStockTake 下已经有 stockTakeRecord(即本轮参与盘点的 lot) + val inventoryLotLinesForSection = + inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalse(warehouseIds) + + val relevantInventoryLotLines = inventoryLotLinesForSection.filter { ill -> + val inventoryLot = ill.inventoryLot + val warehouse = ill.warehouse + if (inventoryLot?.id == null || warehouse?.id == null || latestStockTake?.id == null) { + false + } else { + val inQty = ill.inQty ?: BigDecimal.ZERO + val outQty = ill.outQty ?: BigDecimal.ZERO + val holdQty = ill.holdQty ?: BigDecimal.ZERO + val availableQty = inQty.subtract(outQty).subtract(holdQty) + + val hasRecordForLatest = allStockTakeRecords.any { record -> + !record.deleted && + record.stockTake?.id == latestStockTake.id && + record.inventoryLotId == inventoryLot.id && + record.warehouse?.id == warehouse.id + } + availableQty.compareTo(BigDecimal.ZERO) > 0 || hasRecordForLatest + } + } + + val totalItemNumber = relevantInventoryLotLines + .mapNotNull { it.inventoryLot?.item?.id } + .distinct() + .count() + + val totalInventoryLotNumber = relevantInventoryLotLines.size + val sectionDescription = warehouses + .mapNotNull { it.stockTakeSectionDescription } + .distinct() + .firstOrNull() // 9. 使用 stockTakeSection 作为 stockTakeSession result.add( AllPickedStockTakeListReponse( @@ -441,7 +509,9 @@ class StockTakeRecordService( startTime = latestStockTake?.actualStart, endTime = latestStockTake?.actualEnd, ReStockTakeTrueFalse = reStockTakeTrueFalse, - planStartDate = latestStockTake?.planStart?.toLocalDate() + planStartDate = latestStockTake?.planStart?.toLocalDate(), + stockTakeSectionDescription = sectionDescription + ) ) } @@ -897,17 +967,36 @@ return RecordsRes(paginatedResult, filteredResults.size) it.stockTakeSection == stockTakeSection } - // 4. 检查是否所有 inventory lot lines 都有对应的记录 - val allLinesHaveRecords = inventoryLotLines.all { ill -> + // 4. 仅对「前端会显示的库存行」做检查: + // 规则与 getInventoryLotDetailsByStockTakeSection 一致: + // - availableQty > 0,或 + // - 已经有 stockTakeRecord(即使 availableQty 为 0) + val relevantInventoryLotLines = inventoryLotLines.filter { ill -> val inventoryLot = ill.inventoryLot val warehouse = ill.warehouse if (inventoryLot?.id == null || warehouse?.id == null) { false } else { - stockTakeRecords.any { record -> + val inQty = ill.inQty ?: BigDecimal.ZERO + val outQty = ill.outQty ?: BigDecimal.ZERO + val holdQty = ill.holdQty ?: BigDecimal.ZERO + val availableQty = inQty.subtract(outQty).subtract(holdQty) + + val hasRecord = stockTakeRecords.any { record -> record.inventoryLotId == inventoryLot.id && record.warehouse?.id == warehouse.id } + availableQty.compareTo(BigDecimal.ZERO) > 0 || hasRecord + } + } + + // 5. 检查这些「相关行」是否都有对应的记录 + val allLinesHaveRecords = relevantInventoryLotLines.all { ill -> + val inventoryLot = ill.inventoryLot + val warehouse = ill.warehouse + stockTakeRecords.any { record -> + record.inventoryLotId == inventoryLot?.id && + record.warehouse?.id == warehouse?.id } } @@ -1043,7 +1132,8 @@ open fun batchSaveApproverStockTakeRecords( !it.deleted && it.stockTake?.id == request.stockTakeId && it.stockTakeSection == request.stockTakeSection && - it.pickerFirstStockTakeQty != null && it.pickerFirstStockTakeQty!! > BigDecimal.ZERO && + // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) + (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && it.approverStockTakeQty == null // 只处理未审批的记录 } @@ -1081,7 +1171,8 @@ open fun batchSaveApproverStockTakeRecords( val tolerancePercent = request.variancePercentTolerance ?: BigDecimal.ZERO val shouldSkip = if (tolerancePercent.compareTo(BigDecimal.ZERO) <= 0) { - varianceQty.compareTo(BigDecimal.ZERO) != 0 // 原有逻辑:仅 variance=0 可保存 + // 0 或负数表示不过滤差异:所有已盘点记录都参与批量保存 + false } else { if (bookQty.compareTo(BigDecimal.ZERO) == 0) { varianceQty.compareTo(BigDecimal.ZERO) != 0 @@ -1141,6 +1232,133 @@ open fun batchSaveApproverStockTakeRecords( ) } +open fun batchSaveApproverStockTakeRecordsAll( + request: BatchSaveApproverStockTakeAllRequest +): BatchSaveApproverStockTakeRecordResponse { + println("batchSaveApproverStockTakeRecordsAll called for stockTakeId: ${request.stockTakeId}") + val user = userRepository.findById(request.approverId).orElse(null) + + val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId) + ?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}") + + // 以该 stockTake 的 planStart 作为一轮的标识,找到这一轮下所有的 stockTake(各个 section) + val planStart = stockTake.planStart + val roundStockTakeIds: Set = if (planStart != null) { + stockTakeRepository.findAll() + .filter { !it.deleted && it.planStart == planStart } + .mapNotNull { it.id } + .toSet() + } else { + listOfNotNull(stockTake.id).toSet() + } + + val stockTakeRecords = stockTakeRecordRepository.findAll() + .filter { + !it.deleted && + it.stockTake?.id != null && + it.stockTake!!.id!! in roundStockTakeIds && + // 只处理已经有 picker 盘点记录的行(第一或第二次盘点任意一个非 null 即视为已盘点) + (it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) && + it.approverStockTakeQty == null + } + + println("Found ${stockTakeRecords.size} records to process for round (all sections)") + + if (stockTakeRecords.isEmpty()) { + return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria")) + } + + var successCount = 0 + var errorCount = 0 + var skippedCount = 0 + val errors = mutableListOf() + + val processedStockTakes = mutableSetOf>() + + stockTakeRecords.forEach { record -> + try { + val qty: BigDecimal + val badQty: BigDecimal + + if (record.pickerSecondStockTakeQty != null && record.pickerSecondStockTakeQty!! > BigDecimal.ZERO) { + qty = record.pickerSecondStockTakeQty!! + badQty = record.pickerSecondBadQty ?: BigDecimal.ZERO + } else { + qty = record.pickerFirstStockTakeQty ?: BigDecimal.ZERO + badQty = record.pickerFirstBadQty ?: BigDecimal.ZERO + } + + val bookQty = record.bookQty ?: BigDecimal.ZERO + val varianceQty = qty.subtract(bookQty) + + val tolerancePercent = request.variancePercentTolerance ?: BigDecimal.ZERO + val shouldSkip = if (tolerancePercent.compareTo(BigDecimal.ZERO) <= 0) { + // 0 或负数表示不过滤差异:所有已盘点记录都参与批量保存 + false + } else { + if (bookQty.compareTo(BigDecimal.ZERO) == 0) { + varianceQty.compareTo(BigDecimal.ZERO) != 0 + } else { + val threshold = bookQty.abs() + .multiply(tolerancePercent) + .divide(BigDecimal("100"), 10, RoundingMode.HALF_UP) + varianceQty.abs().compareTo(threshold) > 0 + } + } + if (shouldSkip) { + skippedCount++ + println("Skipping record ${record.id}: |variance| > ${tolerancePercent}% of bookQty (variance=$varianceQty, bookQty=$bookQty)") + return@forEach + } + + record.apply { + this.approverId = request.approverId + this.approverName = user?.name + this.approverStockTakeQty = qty + this.approverBadQty = badQty + this.varianceQty = varianceQty + this.status = "completed" + } + + 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 + } + } + val stId = record.stockTake?.id + val section = record.stockTakeSection + if (stId != null && section != null) { + processedStockTakes.add(Pair(stId, section)) + } + successCount++ + } catch (e: Exception) { + errorCount++ + val errorMsg = "Error processing record ${record.id}: ${e.message}" + errors.add(errorMsg) + logger.error(errorMsg, e) + } + } + + if (successCount > 0) { + processedStockTakes.forEach { (stId, section) -> + checkAndUpdateStockTakeStatus(stId, section) + } + } + + println("batchSaveApproverStockTakeRecordsAll completed: success=$successCount, skipped=$skippedCount, errors=$errorCount") + return BatchSaveApproverStockTakeRecordResponse( + successCount = successCount, + errorCount = errorCount, + errors = errors + ) +} + /** * 根据 variance 调整库存并创建 Stock Ledger。 * 当 variance != 0 时:创建 StockTakeLine,并根据 variance 正负创建 StockIn/StockOut 及 Ledger。 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 cf27be7..fc809ae 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 @@ -27,20 +27,30 @@ class StockTakeRecordController( @GetMapping("/AllPickedStockOutRecordList") fun allPickedStockOutRecordList( @RequestParam(required = false, defaultValue = "0") pageNum: Int, - @RequestParam(required = false, defaultValue = "6") pageSize: Int + @RequestParam(required = false, defaultValue = "6") pageSize: Int, + @RequestParam(required = false) sectionDescription: String?, + @RequestParam(required = false) stockTakeSections: String? ): RecordsRes { - val all = stockOutRecordService.AllPickedStockTakeList() + var all = stockOutRecordService.AllPickedStockTakeList() + if (sectionDescription != null && sectionDescription != "All") { + all = all.filter { it.stockTakeSectionDescription == sectionDescription } + } + if (!stockTakeSections.isNullOrBlank()) { + val sections = stockTakeSections.split(",").map { it.trim() }.filter { it.isNotBlank() } + if (sections.isNotEmpty()) { + all = all.filter { session -> + sections.any { part -> + session.stockTakeSession.equals(part, ignoreCase = true) || + session.stockTakeSession.contains(part, ignoreCase = true) + } + } + } + } val total = all.size val fromIndex = pageNum * pageSize - val toIndex = kotlin.math.min(fromIndex + pageSize, total) - val pageList = - if (fromIndex >= total) emptyList() - else all.subList(fromIndex, toIndex) - - return RecordsRes( - pageList, - total - ) + val toIndex = minOf(fromIndex + pageSize, total) + val pageList = if (fromIndex >= total) emptyList() else all.subList(fromIndex, toIndex) + return RecordsRes(pageList, total) } @GetMapping("/approverInventoryLotDetailsAll") fun getApproverInventoryLotDetailsAll( @@ -205,6 +215,28 @@ fun getInventoryLotDetailsByStockTakeSection( )) } } + @PostMapping("/batchSaveApproverStockTakeRecordsAll") + fun batchSaveApproverStockTakeRecordsAll( + @RequestBody request: BatchSaveApproverStockTakeAllRequest + ): ResponseEntity { + return try { + val result = stockOutRecordService.batchSaveApproverStockTakeRecordsAll(request) + logger.info("Batch approver save all completed: success=${result.successCount}, errors=${result.errorCount}") + ResponseEntity.ok(result) + } catch (e: IllegalArgumentException) { + logger.warn("Validation error: ${e.message}") + ResponseEntity.badRequest().body(mapOf( + "error" to "VALIDATION_ERROR", + "message" to (e.message ?: "Validation failed") + )) + } catch (e: Exception) { + logger.error("Error batch saving approver stock take records all", e) + ResponseEntity.status(500).body(mapOf( + "error" to "INTERNAL_ERROR", + "message" to (e.message ?: "Failed to batch save approver stock take records all") + )) + } + } @PostMapping("/updateStockTakeRecordStatusToNotMatch") fun updateStockTakeRecordStatusToNotMatch( @RequestParam stockTakeRecordId: Long