| @@ -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 | |||
| @@ -305,6 +305,8 @@ ORDER BY | |||
| fun searchStockTakeVarianceReportV2( | |||
| stockTakeRoundId: Long, | |||
| itemCode: String?, | |||
| storeId: String?, | |||
| status: String?, | |||
| ): List<Map<String, Any>> { | |||
| val countSql = """ | |||
| SELECT COUNT(*) AS c FROM stocktakerecord s | |||
| @@ -320,6 +322,34 @@ ORDER BY | |||
| val args = mutableMapOf<String, Any>() | |||
| 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), '%)') | |||
| @@ -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<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| @@ -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<ByteArray> { | |||
| val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId) | |||
| val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2( | |||
| stockTakeRoundId = stockTakeRoundId, | |||
| itemCode = itemCode, | |||
| storeId = storeId, | |||
| status = status, | |||
| ) | |||
| val excelBytes = createStockTakeVarianceExcel( | |||
| @@ -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 | |||
| ) | |||
| } | |||
| @@ -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<AllPickedStockTakeListReponse> { | |||
| 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) | |||
| @@ -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, | |||