diff --git a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java index b18b8477..71fdf200 100644 --- a/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java +++ b/src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java @@ -121,6 +121,9 @@ public class SecurityConfig { .hasAuthority("INVENTORY_ADJUST") .requestMatchers(HttpMethod.GET, "/stockAdjustment/latestRemarks") .hasAuthority("INVENTORY_ADJUST") + /* Close stock take round: ADMIN only (no @PreAuthorize on Kotlin controllers). */ + .requestMatchers(HttpMethod.POST, "/stockTakeRecord/closeRound") + .hasAuthority("ADMIN") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace") .hasAuthority("ITEM_TRACING") .requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**") diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt index 4d867866..244353a9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt @@ -49,4 +49,9 @@ open class StockTake: BaseEntity() { @Size(max = 255) @Column(name = "stockTakeRoundName", length = 255) open var stockTakeRoundName: String? = null + + /** section = 依區域建立;item = 依貨品建立。同一輪不可混用。 */ + @Size(max = 20) + @Column(name = "stockTakeRoundMode", length = 20) + open var stockTakeRoundMode: String? = null } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/enums/StockTakeEnum.kt b/src/main/java/com/ffii/fpsms/modules/stock/enums/StockTakeEnum.kt index 8417ac90..93fd44ba 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/enums/StockTakeEnum.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/enums/StockTakeEnum.kt @@ -4,5 +4,13 @@ enum class StockTakeStatus(val value: String) { PENDING("pending"), STOCKTAKING("stockTaking"), APPROVING("approving"), - COMPLETED("completed"), + COMPLETED("completed"); + + fun isOpen(): Boolean = + this == PENDING || this == STOCKTAKING || this == APPROVING +} + +object StockTakeRoundMode { + const val SECTION = "section" + const val ITEM = "item" } \ No newline at end of file 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 2fdbbb0c..1d13fc9e 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 @@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.stock.entity.StockTakeRepository import com.ffii.fpsms.modules.stock.entity.StockTakeRecordRepository import com.ffii.fpsms.modules.stock.entity.StockTakeRecord import com.ffii.fpsms.modules.stock.enums.StockTakeStatus +import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode import com.ffii.fpsms.modules.stock.web.model.* import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -81,7 +82,11 @@ open class StockTakeRecordService( * 建立該 section 的 stock_take 後,依當下「可用」庫存行預先建立本輪全部盤點明細(pending),盤點員儲存時改為更新。 */ @Transactional - open fun createPlaceholderStockTakeRecordsForStockTake(stockTake: StockTake, placeholderStockTakerId: Long) { + open fun createPlaceholderStockTakeRecordsForStockTake( + stockTake: StockTake, + placeholderStockTakerId: Long, + itemIds: Set? = null, + ) { val section = stockTake.stockTakeSection ?: return val warehouses = warehouseRepository.findAllByStockTakeSectionAndDeletedIsFalse(section) val warehouseIds = warehouses.mapNotNull { it.id } @@ -92,6 +97,9 @@ open class StockTakeRecordService( val inventoryLotLines = inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalseAndStatusIsAvailable(warehouseIds) + .filter { ill -> + itemIds == null || (ill.inventoryLot?.item?.id?.let { id -> id in itemIds } == true) + } if (inventoryLotLines.isEmpty()) return val toSave = ArrayList(inventoryLotLines.size) @@ -401,7 +409,13 @@ open class StockTakeRecordService( } } - open fun AllPickedStockTakeList(): List { + open fun AllPickedStockTakeList(targetRoundKey: Long? = null): List { + if (targetRoundKey != null && targetRoundKey > 0L) { + val roundHeaders = stockTakesForRound(targetRoundKey) + if (roundHeaders.any { it.stockTakeRoundMode == StockTakeRoundMode.ITEM }) { + return listItemCardsForRound(targetRoundKey, roundHeaders) + } + } // 1. 获取所有不同的 stockTakeSection(从 warehouse 表) val allWarehouses = warehouseRepository.findAllByDeletedIsFalse() val distinctSections = allWarehouses @@ -418,13 +432,20 @@ open class StockTakeRecordService( .filter { it.stockTakeSection != null && !it.stockTakeSection!!.isBlank() } .groupBy { it.stockTakeSection!! } - val allStockTakes = stockTakeRepository.findAll() - .filter { !it.deleted } - .groupBy { it.stockTakeSection } - + val scopedRound = targetRoundKey != null && targetRoundKey > 0L + val allStockTakes = ( + if (scopedRound) { + stockTakeRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(targetRoundKey!!) + } else { + stockTakeRepository.findAll().filter { !it.deleted } + } + ).groupBy { it.stockTakeSection } - val allStockTakeRecords = stockTakeRecordRepository.findAll() - .filter { !it.deleted } + val allStockTakeRecords = if (scopedRound) { + stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(targetRoundKey!!) + } else { + stockTakeRecordRepository.findAll().filter { !it.deleted } + } val recordsByWarehouseId = allStockTakeRecords @@ -433,7 +454,7 @@ open class StockTakeRecordService( val result = mutableListOf() var idCounter = 1L - val globalLatestRoundKey = resolveGlobalLatestStockTakeRoundKey() + val globalLatestRoundKey = targetRoundKey ?: resolveGlobalLatestStockTakeRoundKey() // 3. 为每个 stockTakeSection 创建一个卡片 distinctSections.forEach { stockTakeSection -> @@ -456,10 +477,22 @@ open class StockTakeRecordService( val lastStockTakeDate = completedStockTakes.firstOrNull()?.actualEnd - val latestStockTake = resolveLatestStockTakeForSection( - stockTakesForSection, - globalLatestRoundKey - ) + val latestStockTake = if (targetRoundKey != null && targetRoundKey > 0L) { + stockTakesForSection.filter { st -> + (st.stockTakeRoundId ?: st.id) == targetRoundKey + }.maxWithOrNull( + compareBy { it.actualStart ?: it.planStart ?: LocalDateTime.MIN } + .thenBy { it.id ?: 0L } + ) + } else { + resolveLatestStockTakeForSection( + stockTakesForSection, + globalLatestRoundKey + ) + } + if (targetRoundKey != null && targetRoundKey > 0L && latestStockTake == null) { + return@forEach + } val status = if (latestStockTake != null) { latestStockTake.status?.value ?: "pending" @@ -578,6 +611,91 @@ open class StockTakeRecordService( return result.sortedBy { it.stockTakeSession } } + /** 只留仍對得到未刪除庫存行的明細,避免舊佔位列把貨品狀態和批數算歪。 */ + private fun liveStockTakeRecords(records: List): List { + val lotIds = records.mapNotNull { it.lotId }.distinct() + val warehouseIds = records.mapNotNull { it.warehouse?.id }.distinct() + if (lotIds.isEmpty() || warehouseIds.isEmpty()) return emptyList() + val livePairs = inventoryLotLineRepository + .findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(warehouseIds, lotIds) + .map { (it.inventoryLot?.id ?: 0L) to (it.warehouse?.id ?: 0L) } + .toSet() + return records.filter { rec -> + val lotId = rec.lotId ?: return@filter false + val warehouseId = rec.warehouse?.id ?: return@filter false + livePairs.contains(lotId to warehouseId) + } + } + + private fun listItemCardsForRound( + roundKey: Long, + headers: List, + ): List { + val records = liveStockTakeRecords( + stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundKey) + .filter { !it.deleted } + ) + val byItem = records.groupBy { it.itemId ?: 0L }.filterKeys { it > 0L } + val planStartDate = headers.mapNotNull { it.planStart }.maxOrNull()?.toLocalDate() + val headerBySection = headers.associateBy { it.stockTakeSection } + return byItem.entries.mapIndexed { idx, (itemId, recs) -> + val code = recs.firstOrNull { !it.itemCode.isNullOrBlank() }?.itemCode ?: "#$itemId" + val name = recs.firstOrNull { !it.itemName.isNullOrBlank() }?.itemName + val total = recs.size + val taken = recs.count { it.pickerFirstStockTakeQty != null } + val notMatch = recs.any { it.status == "notMatch" } + val sections = recs.mapNotNull { it.stockTakeSection?.trim()?.takeIf { s -> s.isNotBlank() } }.distinct() + val involvedHeaders = sections.mapNotNull { headerBySection[it] }.ifEmpty { headers } + val open = involvedHeaders.any { it.status?.isOpen() == true } + val allCounted = recs.isNotEmpty() && recs.all { + it.pickerFirstStockTakeQty != null || it.status == "pass" || it.status == "completed" + } + val allApproved = recs.isNotEmpty() && recs.all { + it.status == "completed" || it.approverTime != null + } + val anyCounted = recs.any { + it.pickerFirstStockTakeQty != null || it.status == "pass" || it.status == "completed" + } + val statusValue = when { + !open -> StockTakeStatus.COMPLETED.value + notMatch -> StockTakeStatus.STOCKTAKING.value + allApproved -> StockTakeStatus.COMPLETED.value + allCounted -> StockTakeStatus.APPROVING.value + anyCounted -> StockTakeStatus.STOCKTAKING.value + else -> StockTakeStatus.PENDING.value + } + val floors = recs.mapNotNull { it.warehouse?.store_id?.trim()?.takeIf { f -> f.isNotBlank() } } + val mainFloor = floors.groupingBy { it }.eachCount().maxByOrNull { it.value }?.key + val representative = involvedHeaders.maxByOrNull { it.id ?: 0L } ?: headers.first() + AllPickedStockTakeListReponse( + id = (idx + 1).toLong(), + stockTakeSession = code, + lastStockTakeDate = representative.actualStart?.toLocalDate(), + status = statusValue, + currentStockTakeItemNumber = taken, + totalInventoryLotNumber = total, + stockTakeId = representative.id ?: 0L, + stockTakeRoundId = roundKey, + stockTakerName = recs.mapNotNull { it.stockTakerName }.lastOrNull(), + TotalItemNumber = 1, + approverName = recs.mapNotNull { it.approverName }.lastOrNull(), + startTime = representative.actualStart, + endTime = if (allCounted) { + recs.mapNotNull { it.stockTakeEndTime }.maxOrNull() ?: representative.actualEnd + } else { + representative.actualEnd + }, + ReStockTakeTrueFalse = notMatch, + planStartDate = planStartDate, + stockTakeSectionDescription = name, + warehouseArea = recs.mapNotNull { it.warehouse?.area }.distinct().joinToString(", ").ifBlank { null }, + storeId = mainFloor, + listMode = StockTakeRoundMode.ITEM, + itemId = itemId, + ) + }.sortedBy { it.stockTakeSession } + } + /** * 區域內用於卡片展示的 stock_take:若該區域參與全系統最新輪次,優先取該輪次內最新一筆; * 否則退回區域內 planStart/actualStart 最新一筆(供未開 onlyLatestRound 時顯示舊輪)。 @@ -1019,6 +1137,299 @@ open class StockTakeRecordService( ) } + open fun getApproverStockTakeHeaderForRound(roundId: Long): AllPickedStockTakeListReponse? { + val rows = stockTakesForRound(roundId) + if (rows.isEmpty()) return null + val representative = rows.maxWithOrNull( + compareBy { it.actualStart ?: it.planStart ?: LocalDateTime.MIN } + .thenBy { it.id ?: 0L } + ) ?: return null + val roundKey = representative.stockTakeRoundId ?: representative.id ?: roundId + val planStartDate = rows.mapNotNull { it.planStart }.maxOrNull()?.toLocalDate() + val open = rows.any { it.status?.isOpen() == true } + val statusValue = when { + open && rows.any { it.status == StockTakeStatus.APPROVING } -> StockTakeStatus.APPROVING.value + open && rows.any { it.status == StockTakeStatus.STOCKTAKING } -> StockTakeStatus.STOCKTAKING.value + open -> StockTakeStatus.PENDING.value + else -> StockTakeStatus.COMPLETED.value + } + return AllPickedStockTakeListReponse( + id = 1L, + stockTakeSession = representative.stockTakeRoundName ?: "", + lastStockTakeDate = representative.actualStart?.toLocalDate(), + status = statusValue, + currentStockTakeItemNumber = 0, + totalInventoryLotNumber = 0, + stockTakeId = representative.id ?: 0, + stockTakeRoundId = roundKey, + stockTakerName = null, + approverName = null, + TotalItemNumber = 0, + startTime = representative.actualStart, + endTime = representative.actualEnd, + ReStockTakeTrueFalse = false, + planStartDate = planStartDate, + stockTakeSectionDescription = null, + warehouseArea = null, + storeId = null + ) + } + + open fun busyStockTakeSections(): List { + return stockTakeRepository.findAll() + .filter { !it.deleted && it.status?.isOpen() == true } + .mapNotNull { it.stockTakeSection?.trim() } + .filter { it.isNotBlank() } + .distinct() + .sorted() + } + + open fun listStockTakeRoundCards(yearMonth: String?): StockTakeRoundCardsRes { + val all = stockTakeRepository.findAll().filter { !it.deleted } + if (all.isEmpty()) { + val thisMonth = java.time.YearMonth.now().toString() + return StockTakeRoundCardsRes( + hasOpenRounds = false, + rounds = emptyList(), + completedYearMonths = listOf(thisMonth), + ) + } + val groups = all.groupBy { st -> st.stockTakeRoundId ?: st.id ?: 0L } + .filterKeys { it > 0L } + val recordsByRound = stockTakeRecordRepository.findAll() + .filter { !it.deleted } + .groupBy { rec -> rec.stockTakeRoundId ?: rec.stockTake?.id ?: 0L } + val floorsBySection = warehouseRepository.findAllByDeletedIsFalse() + .mapNotNull { w -> + val section = w.stockTakeSection?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val floor = w.store_id?.trim()?.takeIf { it.isNotBlank() } + section to floor + } + .groupBy({ it.first }, { it.second }) + val cards = groups.map { (roundKey, rows) -> + val headerIds = rows.mapNotNull { it.id }.toSet() + val records = recordsByRound[roundKey].orEmpty().ifEmpty { + recordsByRound.filterKeys { it in headerIds }.values.flatten() + } + toRoundCard(roundKey, rows, records, floorsBySection) + } + .sortedByDescending { it.stockTakeRoundId } + val thisMonth = java.time.YearMonth.now().toString() + val hasOpenRounds = cards.any { it.open } + val completedYearMonths = ( + cards.filter { !it.open }.map { it.yearMonth } + thisMonth + ).distinct().filter { it <= thisMonth }.sortedDescending() + val rounds = if (hasOpenRounds) { + val openCards = cards.filter { it.open } + val thisMonthOpen = openCards.filter { it.yearMonth == thisMonth } + thisMonthOpen + openCards.filter { it.yearMonth != thisMonth } + } else { + val ym = yearMonth?.trim()?.takeIf { it.matches(Regex("\\d{4}-\\d{2}")) } ?: thisMonth + cards.filter { !it.open && it.yearMonth == ym } + } + return StockTakeRoundCardsRes( + hasOpenRounds = hasOpenRounds, + rounds = rounds, + completedYearMonths = completedYearMonths, + ) + } + + @Transactional + open fun closeStockTakeRound(roundId: Long): Map { + val rows = stockTakesForRound(roundId) + if (rows.isEmpty()) { + return mapOf("success" to false, "message" to "Stock take round not found: $roundId") + } + val now = LocalDateTime.now() + rows.forEach { st -> + st.status = StockTakeStatus.COMPLETED + if (st.actualEnd == null) { + st.actualEnd = now + } + stockTakeRepository.save(st) + } + return mapOf( + "success" to true, + "stockTakeRoundId" to roundId, + "closedCount" to rows.size, + ) + } + + private fun stockTakesForRound(roundId: Long): List { + val byRound = stockTakeRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId) + if (byRound.isNotEmpty()) return byRound + return try { + val one = stockTakeRepository.findByIdAndDeletedIsFalse(roundId) + if (one != null) listOf(one) else emptyList() + } catch (_: Exception) { + emptyList() + } + } + + private fun toRoundCard( + roundKey: Long, + rows: List, + records: List, + floorsBySection: Map>, + ): StockTakeRoundCardResponse { + val representative = rows.maxWithOrNull( + compareBy { it.actualStart ?: it.planStart ?: LocalDateTime.MIN } + .thenBy { it.id ?: 0L } + ) ?: rows.first() + val planStart = rows.mapNotNull { it.planStart }.maxOrNull() + val yearMonth = if (planStart != null) { + "%04d-%02d".format(planStart.year, planStart.monthValue) + } else { + java.time.YearMonth.now().toString() + } + val open = rows.any { it.status?.isOpen() == true } + val statusValue = when { + open && rows.any { it.status == StockTakeStatus.APPROVING } -> StockTakeStatus.APPROVING.value + open && rows.any { it.status == StockTakeStatus.STOCKTAKING } -> StockTakeStatus.STOCKTAKING.value + open -> StockTakeStatus.PENDING.value + else -> StockTakeStatus.COMPLETED.value + } + val name = rows.mapNotNull { it.stockTakeRoundName?.trim()?.takeIf { n -> n.isNotBlank() } }.firstOrNull() + val sections = rows.mapNotNull { it.stockTakeSection?.trim()?.takeIf { s -> s.isNotBlank() } }.distinct() + val floors = sections.flatMap { sec -> floorsBySection[sec].orEmpty().mapNotNull { it } } + val mainFloor = floors.groupingBy { it }.eachCount().maxWithOrNull( + compareBy> { it.value }.thenByDescending { it.key } + )?.key + val itemMode = rows.any { it.stockTakeRoundMode == StockTakeRoundMode.ITEM } + val countedRecords = if (itemMode) liveStockTakeRecords(records) else records + val totalLots = countedRecords.size + val stockTakenCount: Int + val approvedCount: Int + if (itemMode) { + stockTakenCount = countedRecords.count { + it.pickerFirstStockTakeQty != null || it.status == "pass" || it.status == "completed" + } + approvedCount = countedRecords.count { + it.status == "completed" || it.approverStockTakeQty != null || it.approverTime != null + } + } else { + val bySection = records.groupBy { it.stockTakeSection?.trim()?.lowercase() } + fun sectionComplete(section: String, approved: Boolean): Boolean { + val group = bySection[section.lowercase()].orEmpty() + if (group.isEmpty()) return false + return if (approved) { + group.all { it.approverStockTakeQty != null || it.approverTime != null } + } else { + group.all { it.pickerFirstStockTakeQty != null } + } + } + stockTakenCount = sections.count { sectionComplete(it, false) } + approvedCount = sections.count { sectionComplete(it, true) } + } + return StockTakeRoundCardResponse( + stockTakeRoundId = roundKey, + stockTakeRoundName = name, + stockTakeId = representative.id ?: 0L, + planStartDate = planStart?.toLocalDate(), + yearMonth = yearMonth, + open = open, + status = statusValue, + sectionCount = sections.size, + totalItemNumber = countedRecords.mapNotNull { it.itemId }.distinct().size, + totalInventoryLotNumber = totalLots, + stockTakenCount = stockTakenCount, + approvedCount = approvedCount, + mainFloor = mainFloor, + stockTakeRoundMode = rows.mapNotNull { it.stockTakeRoundMode }.firstOrNull(), + ) + } + + open fun getInventoryLotDetailsByItem( + itemId: Long, + stockTakeRoundId: Long, + pageNum: Int = 0, + pageSize: Int = 10, + notMatchOnly: Boolean = false, + ): RecordsRes { + val records = stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(stockTakeRoundId) + .filter { + !it.deleted && + it.itemId == itemId && + (!notMatchOnly || it.status == "notMatch") + } + if (records.isEmpty()) { + return RecordsRes(emptyList(), 0) + } + val lotIds = records.mapNotNull { it.lotId }.toSet() + val whIds = records.mapNotNull { it.warehouse?.id }.toSet() + val illList = + if (lotIds.isNotEmpty() && whIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(whIds, lotIds) + } else { + emptyList() + } + val illByPair = illList.associateBy { + Pair(it.inventoryLot?.id ?: 0L, it.warehouse?.id ?: 0L) + } + val allResults = records.mapNotNull { rec -> + val ill = illByPair[Pair(rec.lotId ?: 0L, rec.warehouse?.id ?: 0L)] + ?: return@mapNotNull null + val inventoryLot = ill.inventoryLot + val item = inventoryLot?.item + val warehouse = ill.warehouse + val availableQty = (ill.inQty ?: BigDecimal.ZERO) + .subtract(ill.outQty ?: BigDecimal.ZERO) + val stId = rec.stockTake?.id + val illId = ill.id + val stockTakeLine = + if (stId != null && illId != null) { + stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(illId, stId) + } else { + null + } + InventoryLotDetailResponse( + id = ill.id ?: 0L, + inventoryLotId = inventoryLot?.id ?: 0L, + itemId = item?.id ?: rec.itemId ?: 0L, + itemCode = item?.code ?: rec.itemCode, + itemName = item?.name ?: rec.itemName, + lotNo = inventoryLot?.lotNo, + expiryDate = inventoryLot?.expiryDate, + storeId = warehouse?.store_id, + productionDate = inventoryLot?.productionDate, + stockInDate = inventoryLot?.stockInDate, + inQty = ill.inQty, + remarks = rec.remarks, + outQty = ill.outQty, + holdQty = ill.holdQty, + availableQty = availableQty, + uom = ill.stockUom?.uom?.udfudesc, + uomShortDesc = ill.stockUom?.uom?.udfShortDesc, + warehouseCode = warehouse?.code, + warehouseArea = warehouse?.area, + warehouseName = warehouse?.name, + status = ill.status?.name, + warehouseSlot = warehouse?.slot, + warehouse = warehouse?.warehouse, + varianceQty = rec.varianceQty, + stockTakeRecordId = rec.id, + stockTakeRecordStatus = rec.status, + firstStockTakeQty = rec.pickerFirstStockTakeQty, + secondStockTakeQty = rec.pickerSecondStockTakeQty, + firstBadQty = rec.pickerFirstBadQty, + secondBadQty = rec.pickerSecondBadQty, + approverQty = rec.approverStockTakeQty, + approverBadQty = rec.approverBadQty, + finalQty = stockTakeLine?.finalQty, + bookQty = rec.bookQty, + stockTakeSection = rec.stockTakeSection ?: warehouse?.stockTakeSection, + stockTakeSectionDescription = warehouse?.stockTakeSectionDescription, + stockTakerName = rec.stockTakerName, + stockTakeEndTime = rec.stockTakeEndTime, + approverTime = rec.approverTime, + ) + }.sortedWith(compareBy({ it.storeId }, { it.warehouseCode }, { it.lotNo })) + val startIndex = pageNum * pageSize + val endIndex = minOf(startIndex + pageSize, allResults.size) + val page = if (startIndex < allResults.size) allResults.subList(startIndex, endIndex) else emptyList() + return RecordsRes(page, allResults.size) + } + open fun getInventoryLotDetailsByWarehouseCode(warehouseCode: String): List { println("getInventoryLotDetailsByWarehouseCode called with code: $warehouseCode") @@ -1425,8 +1836,9 @@ open class StockTakeRecordService( } val stockTakeSection = savedRecord.stockTakeSection + val statusStockTakeId = savedRecord.stockTake?.id ?: stockTakeId if (stockTakeSection != null) { - checkAndUpdateStockTakeStatus(stockTakeId, stockTakeSection) + checkAndUpdateStockTakeStatus(statusStockTakeId, stockTakeSection) } return toSaveStockTakeRecordResponse(savedRecord) @@ -1463,8 +1875,19 @@ open class StockTakeRecordService( } } - if (successCount > 0 && request.stockTakeSection.isNotBlank()) { - checkAndUpdateStockTakeStatus(request.stockTakeId, request.stockTakeSection) + if (successCount > 0) { + val recIds = request.records.mapNotNull { it.stockTakeRecordId } + val recs = if (recIds.isEmpty()) emptyList() else stockTakeRecordRepository.findAllById(recIds) + val headers = recs.mapNotNull { it.stockTake }.distinctBy { it.id } + if (headers.isNotEmpty()) { + headers.forEach { st -> + val section = st.stockTakeSection ?: return@forEach + val id = st.id ?: return@forEach + checkAndUpdateStockTakeStatus(id, section) + } + } else if (request.stockTakeSection.isNotBlank()) { + checkAndUpdateStockTakeStatus(request.stockTakeId, request.stockTakeSection) + } } return BatchSaveStockTakeRecordResponse( @@ -2762,6 +3185,7 @@ private inline fun Iterable.associateByNotNull(keySelector } return destination } +@Transactional open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord { println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId") @@ -2771,8 +3195,13 @@ open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTa stockTakeRecord.apply { this.status = "notMatch" } - - return stockTakeRecordRepository.save(stockTakeRecord) + val saved = stockTakeRecordRepository.save(stockTakeRecord) + val stockTake = saved.stockTake + if (stockTake != null && !stockTake.deleted) { + stockTake.status = StockTakeStatus.STOCKTAKING + stockTakeRepository.save(stockTake) + } + return saved } open fun getInventoryLotDetailsByStockTakeSectionNotMatch( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt index 2a136e31..a3b5489d 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt @@ -11,7 +11,9 @@ import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository import com.ffii.fpsms.modules.stock.entity.StockTake import com.ffii.fpsms.modules.stock.entity.StockTakeRepository import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatus +import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode import com.ffii.fpsms.modules.stock.enums.StockTakeStatus +import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus import com.ffii.fpsms.modules.stock.web.model.* import org.apache.poi.ss.usermodel.Cell import org.apache.poi.ss.usermodel.CellType @@ -23,7 +25,6 @@ import org.springframework.stereotype.Service import java.math.BigDecimal import java.time.LocalDateTime import org.springframework.context.annotation.Lazy -import org.springframework.transaction.annotation.Transactional @Service open class StockTakeService( @@ -65,6 +66,7 @@ open class StockTakeService( request.stockTakeSection?.let { stockTake.stockTakeSection = it } // 添加此行 request.stockTakeRoundId?.let { stockTake.stockTakeRoundId = it } request.stockTakeRoundName?.let { stockTake.stockTakeRoundName = it } + request.stockTakeRoundMode?.let { stockTake.stockTakeRoundMode = it } return stockTakeRepository.save(stockTake) } @@ -274,11 +276,19 @@ open class StockTakeService( // 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection val batchPlanStart = planStart ?: LocalDateTime.now() val batchPlanEnd = batchPlanStart.plusDays(1) + val busySections = stockTakeRecordService.busyStockTakeSections() + .map { it.lowercase() } + .toSet() // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } var placeholderStockTakerId: Long? = null distinctSections.forEach { section -> + if (busySections.contains(section.trim().lowercase())) { + result[section] = "Skipped: in progress" + log.info("Skipped stock take for section $section: already in an open round") + return@forEach + } try { val code = assignStockTakeNo() val saveStockTakeReq = SaveStockTakeRequest( @@ -292,6 +302,7 @@ open class StockTakeService( stockTakeSection = section, stockTakeRoundId = roundId, stockTakeRoundName = roundName, + stockTakeRoundMode = StockTakeRoundMode.SECTION, ) val savedStockTake = saveStockTake(saveStockTakeReq) if (placeholderStockTakerId == null) { @@ -311,4 +322,78 @@ open class StockTakeService( log.info("--------- End - Create Stock Take for Sections -------") return result } + + fun createStockTakeForItems( + itemIds: List, + stockTakeRoundName: String? = null, + planStart: LocalDateTime? = null, + ): Map { + log.info("--------- Start - Create Stock Take for Items -------") + val wanted = itemIds.filter { it > 0L }.distinct() + if (wanted.isEmpty()) { + return mapOf("_" to "Error: No items selected") + } + val lines = inventoryLotLineRepository.findAllByItemIdIn(wanted).filter { ill -> + !ill.deleted && + ill.status == InventoryLotLineStatus.AVAILABLE && + ill.warehouse?.stockTakeSection?.trim()?.isNotBlank() == true + }.filter { ill -> + val availableQty = (ill.inQty ?: BigDecimal.ZERO) + .subtract(ill.outQty ?: BigDecimal.ZERO) + availableQty.compareTo(BigDecimal.ZERO) > 0 + } + if (lines.isEmpty()) { + return mapOf("_" to "Error: No available lots for selected items") + } + val bySection = lines.groupBy { it.warehouse!!.stockTakeSection!!.trim() } + val batchPlanStart = planStart ?: LocalDateTime.now() + val batchPlanEnd = batchPlanStart.plusDays(1) + val busySections = stockTakeRecordService.busyStockTakeSections() + .map { it.lowercase() } + .toSet() + val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 + val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } + var placeholderStockTakerId: Long? = null + val result = mutableMapOf() + bySection.forEach { (section, sectionLines) -> + if (busySections.contains(section.lowercase())) { + result[section] = "Skipped: in progress" + log.info("Skipped item stock take for section $section: already in an open round") + return@forEach + } + try { + val code = assignStockTakeNo() + val savedStockTake = saveStockTake( + SaveStockTakeRequest( + code = code, + planStart = batchPlanStart, + planEnd = batchPlanEnd, + actualStart = null, + actualEnd = null, + status = StockTakeStatus.PENDING.value, + remarks = null, + stockTakeSection = section, + stockTakeRoundId = roundId, + stockTakeRoundName = roundName, + stockTakeRoundMode = StockTakeRoundMode.ITEM, + ) + ) + if (placeholderStockTakerId == null) { + placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId() + } + stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake( + savedStockTake, + placeholderStockTakerId!!, + wanted.toSet(), + ) + result[section] = "Created: ${savedStockTake.code} (${sectionLines.size} lots)" + log.info("Created item stock take for section $section: ${savedStockTake.code}, roundId=$roundId") + } catch (e: Exception) { + result[section] = "Error: ${e.message}" + log.error("Error creating item stock take for section $section: ${e.message}") + } + } + log.info("--------- End - Create Stock Take for Items -------") + return result + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeController.kt index aaab628b..dae4ec44 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeController.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.stock.web import com.ffii.fpsms.modules.stock.service.StockTakeService +import com.ffii.fpsms.modules.stock.web.model.CreateStockTakeForItemsRequest import com.ffii.fpsms.modules.stock.web.model.CreateStockTakeForSectionsRequest import jakarta.servlet.http.HttpServletRequest import org.apache.poi.ss.usermodel.Workbook @@ -65,4 +66,17 @@ class StockTakeController( return ResponseEntity.ok(result) } + @PostMapping("/createForItems") + fun createStockTakeForItems( + @RequestBody body: CreateStockTakeForItemsRequest, + ): ResponseEntity> { + val itemIds = body.itemIds.orEmpty().filter { it > 0L }.distinct() + val result = stockTakeService.createStockTakeForItems( + itemIds = itemIds, + stockTakeRoundName = body.stockTakeRoundName, + planStart = body.planStart, + ) + return ResponseEntity.ok(result) + } + } \ No newline at end of file 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 fd2cf94e..d24bfef4 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 @@ -33,9 +33,12 @@ class StockTakeRecordController( @RequestParam(required = false) status: String?, @RequestParam(required = false) area: String?, @RequestParam(required = false) storeId: String?, - @RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean + @RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean, + @RequestParam(required = false) stockTakeRoundId: Long? ): RecordsRes { - var all = stockOutRecordService.AllPickedStockTakeList() + var all = stockOutRecordService.AllPickedStockTakeList( + targetRoundKey = if (stockTakeRoundId != null && stockTakeRoundId > 0L) stockTakeRoundId else null + ) if (sectionDescription != null && sectionDescription != "All") { all = all.filter { it.stockTakeSectionDescription == sectionDescription } } @@ -52,13 +55,17 @@ 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 + all = if (normalizedStatus == "notmatch") { + all.filter { it.ReStockTakeTrueFalse } + } else { + val acceptedStatuses = when (normalizedStatus) { + "stocktaking" -> setOf("stocktaking", "processing", "in_progress") + else -> setOf(normalizedStatus) + } + all.filter { item -> + val itemStatus = item.status.trim().lowercase() + itemStatus in acceptedStatuses + } } } if (!area.isNullOrBlank()) { @@ -72,7 +79,7 @@ class StockTakeRecordController( it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true } } - if (onlyLatestRound) { + if (onlyLatestRound && (stockTakeRoundId == null || stockTakeRoundId <= 0L)) { val latestRoundKey = stockOutRecordService.resolveGlobalLatestStockTakeRoundKey() all = if (latestRoundKey == null || latestRoundKey <= 0L) { emptyList() @@ -170,11 +177,37 @@ class StockTakeRecordController( return stockOutRecordService.AllApproverStockTakeList() } @GetMapping("/LatestApproverStockTakeHeader") - fun latestApproverStockTakeHeader(): ResponseEntity { - val header = stockOutRecordService.getLatestApproverStockTakeHeader() - ?: return ResponseEntity.notFound().build() + fun latestApproverStockTakeHeader( + @RequestParam(required = false) stockTakeRoundId: Long? + ): ResponseEntity { + val header = if (stockTakeRoundId != null && stockTakeRoundId > 0L) { + stockOutRecordService.getApproverStockTakeHeaderForRound(stockTakeRoundId) + } else { + stockOutRecordService.getLatestApproverStockTakeHeader() + } ?: return ResponseEntity.notFound().build() return ResponseEntity.ok(header) } + + @GetMapping("/stockTakeRoundCards") + fun stockTakeRoundCards( + @RequestParam(required = false) yearMonth: String? + ): StockTakeRoundCardsRes { + return stockOutRecordService.listStockTakeRoundCards(yearMonth) + } + + @GetMapping("/busyStockTakeSections") + fun busyStockTakeSections(): List { + return stockOutRecordService.busyStockTakeSections() + } + + @PostMapping("/closeRound") + fun closeStockTakeRound( + @RequestBody body: CloseStockTakeRoundRequest + ): Map { + val roundId = body.stockTakeRoundId + ?: throw IllegalArgumentException("stockTakeRoundId is required") + return stockOutRecordService.closeStockTakeRound(roundId) + } @GetMapping("/inventoryLotDetailsBySectionNotMatch") fun getInventoryLotDetailsByStockTakeSectionNotMatch( @RequestParam stockTakeSection: String, @@ -200,6 +233,23 @@ class StockTakeRecordController( return stockOutRecordService.getInventoryLotDetailsByWarehouseCode(warehouseCode) } + @GetMapping("/inventoryLotDetailsByItem") + fun getInventoryLotDetailsByItem( + @RequestParam itemId: Long, + @RequestParam stockTakeRoundId: Long, + @RequestParam(required = false, defaultValue = "0") pageNum: Int, + @RequestParam(required = false, defaultValue = "10") pageSize: Int, + @RequestParam(required = false, defaultValue = "false") notMatchOnly: Boolean + ): RecordsRes { + return stockOutRecordService.getInventoryLotDetailsByItem( + itemId, + stockTakeRoundId, + pageNum, + pageSize, + notMatchOnly + ) + } + @GetMapping("/inventoryLotDetailsBySection") fun getInventoryLotDetailsByStockTakeSection( @RequestParam stockTakeSection: String, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt index f4811236..e20b711e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt @@ -9,3 +9,15 @@ data class CreateStockTakeForSectionsRequest( val stockTakeRoundName: String? = null, val planStart: LocalDateTime? = null, ) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CloseStockTakeRoundRequest( + val stockTakeRoundId: Long? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class CreateStockTakeForItemsRequest( + val itemIds: List? = null, + val stockTakeRoundName: String? = null, + val planStart: LocalDateTime? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockTakeRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockTakeRequest.kt index cdc21e5f..6f88dc69 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockTakeRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockTakeRequest.kt @@ -14,4 +14,5 @@ data class SaveStockTakeRequest( val stockTakeSection: String?=null, val stockTakeRoundId: Long? = null, val stockTakeRoundName: String? = null, + val stockTakeRoundMode: String? = null, ) 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 d96bf61f..4a608c2d 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 @@ -12,6 +12,31 @@ data class LatestStockTakeRoundMetaResponse( val planStartDate: LocalDate?, ) +data class StockTakeRoundCardResponse( + val stockTakeRoundId: Long, + val stockTakeRoundName: String?, + val stockTakeId: Long, + @JsonFormat(pattern = "yyyy-MM-dd") + val planStartDate: LocalDate?, + val yearMonth: String, + val open: Boolean, + val status: String, + val sectionCount: Int, + val totalItemNumber: Int = 0, + val totalInventoryLotNumber: Int = 0, + val stockTakenCount: Int = 0, + val approvedCount: Int = 0, + val mainFloor: String? = null, + val reStockTakeTrueFalse: Boolean = false, + val stockTakeRoundMode: String? = null, +) + +data class StockTakeRoundCardsRes( + val hasOpenRounds: Boolean, + val rounds: List, + val completedYearMonths: List, +) + data class AllPickedStockTakeListReponse( val id: Long, val stockTakeSession: String, @@ -35,6 +60,8 @@ data class AllPickedStockTakeListReponse( val stockTakeSectionDescription: String?, val warehouseArea: String?, val storeId: String?, + val listMode: String = "section", + val itemId: Long? = null, ) data class InventoryLotDetailResponse( val id: Long, diff --git a/src/main/resources/db/changelog/changes/20261007_close_stock_take_rounds/01_close_open_rounds.sql b/src/main/resources/db/changelog/changes/20261007_close_stock_take_rounds/01_close_open_rounds.sql new file mode 100644 index 00000000..cea7ffb7 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261007_close_stock_take_rounds/01_close_open_rounds.sql @@ -0,0 +1,17 @@ +--liquibase formatted sql +--changeset fpsms:20261007_close_open_stock_take_rounds + +-- Close leftover open stock_take headers (planStart Apr–Sep 2026). +-- Same as Admin close round: status → completed; fill actualEnd if null. +-- Does not change stock_take_record / stock_take_line. + +UPDATE stock_take +SET + status = 'completed', + actualEnd = IFNULL(actualEnd, NOW()), + modified = NOW(), + modifiedBy = 'sql-close-rounds' +WHERE deleted = 0 + AND status IN ('pending', 'stockTaking', 'approving') + AND planStart >= '2026-04-01 00:00:00' + AND planStart < '2026-10-01 00:00:00'; diff --git a/src/main/resources/db/changelog/changes/20261007_stock_take_round_mode/01_add_round_mode.sql b/src/main/resources/db/changelog/changes/20261007_stock_take_round_mode/01_add_round_mode.sql new file mode 100644 index 00000000..87d9f8a1 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261007_stock_take_round_mode/01_add_round_mode.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql +--changeset fpsms:20261007_stock_take_round_mode + +ALTER TABLE stock_take +ADD COLUMN stockTakeRoundMode VARCHAR(20) NULL DEFAULT 'section';