diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt index cf2fab65..73d783a6 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt @@ -21,7 +21,37 @@ interface StockTakeRecordRepository : AbstractRepository fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection): List - + /** + * Open-round busy lot keys without loading full stock_take / stocktakerecord entities. + * Matches records by stockTakeRoundId or stockTakeId against open headers. + */ + @Query( + value = """ + SELECT DISTINCT r.lotId AS lotId, r.warehouseId AS warehouseId + FROM stocktakerecord r + WHERE r.deleted = 0 + AND r.lotId IS NOT NULL + AND r.warehouseId IS NOT NULL + AND ( + EXISTS ( + SELECT 1 FROM stock_take st + WHERE st.deleted = 0 + AND st.status IN ('pending', 'stockTaking', 'approving') + AND r.stockTakeRoundId IS NOT NULL + AND r.stockTakeRoundId = st.stockTakeRoundId + ) + OR EXISTS ( + SELECT 1 FROM stock_take st + WHERE st.deleted = 0 + AND st.status IN ('pending', 'stockTaking', 'approving') + AND r.stockTakeId = st.id + ) + ) + """, + nativeQuery = true, + ) + fun findBusyLotWarehouseKeysFromOpenRounds(): List + @Query( """ SELECT r @@ -59,3 +89,8 @@ interface StockTakeRecordRepository : AbstractRepository } +interface BusyLotWarehouseKeyRow { + fun getLotId(): Long + fun getWarehouseId(): Long +} + diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt index bc7aae92..d817e61e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt @@ -1,9 +1,12 @@ package com.ffii.fpsms.modules.stock.entity import com.ffii.core.support.AbstractRepository +import com.ffii.fpsms.modules.stock.enums.StockTakeStatus import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param import org.springframework.stereotype.Repository import java.io.Serializable +import java.time.LocalDateTime @Repository interface StockTakeRepository : AbstractRepository { @@ -11,6 +14,12 @@ interface StockTakeRepository : AbstractRepository { fun findAllByStockTakeRoundIdAndDeletedIsFalse(stockTakeRoundId: Long): List + fun findAllByDeletedIsFalseAndStatusIn(statuses: Collection): List + + fun findAllByDeletedIsFalseAndStockTakeRoundIdIn(stockTakeRoundIds: Collection): List + + fun findAllByDeletedIsFalseAndIdIn(ids: Collection): List + @Query(""" select st.code from StockTake st where st.code like :prefix% order by st.code desc limit 1 """) @@ -22,6 +31,51 @@ interface StockTakeRepository : AbstractRepository { ) fun findMaxStockTakeRoundId(): Long + /** 舊資料無 roundId 時:planStart 最新一筆的 COALESCE(roundId, id) */ + @Query( + """ + SELECT COALESCE(st.stockTakeRoundId, st.id) + FROM StockTake st + WHERE st.deleted = false + ORDER BY st.planStart DESC, st.id DESC + LIMIT 1 + """, + ) + fun findLatestRoundKeyByPlanStart(): Long? + + /** + * Completed rounds whose planStart falls in [start, end). + * Round key = COALESCE(stockTakeRoundId, id). + */ + @Query( + """ + SELECT DISTINCT COALESCE(st.stockTakeRoundId, st.id) + FROM StockTake st + WHERE st.deleted = false + AND st.status = :status + AND st.planStart >= :start + AND st.planStart < :end + """, + ) + fun findRoundKeysByStatusAndPlanStartRange( + @Param("status") status: StockTakeStatus, + @Param("start") start: LocalDateTime, + @Param("end") end: LocalDateTime, + ): List + + @Query( + value = """ + SELECT DISTINCT DATE_FORMAT(st.planStart, '%Y-%m') AS ym + FROM stock_take st + WHERE st.deleted = 0 + AND st.status = 'completed' + AND st.planStart IS NOT NULL + ORDER BY ym DESC + """, + nativeQuery = true, + ) + fun findCompletedYearMonths(): List + @Query( value = """ SELECT DISTINCT stockTakeSection FROM stock_take diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 11ef689d..374aa664 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -1543,7 +1543,11 @@ open class StockInLineService( return itemNo.contains("FA") } - /** Post auto putaways that were waiting on sections closed with this round. */ + /** + * Post auto putaways that were waiting on sections of a just-closed round. + * Only release when LocationGuard allows (other open rounds may still freeze the item/section). + * Keep pending fields if still blocked or if update fails (non-throwing MessageResponse). + */ @Transactional open fun releasePendingAutoPutaways(sections: Collection) { val wanted = sections.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet() @@ -1556,10 +1560,18 @@ open class StockInLineService( if (section !in wanted) continue val qty = sil.pendingAutoPutawayQty ?: sil.acceptedQty ?: continue val itemId = sil.item?.id ?: continue + // Round being closed is already COMPLETED; if another open round still blocks, keep pending + if (stockTakeLocationGuard.blockMessage(warehouseId, itemId) != null) { + logger.info( + "Skip release pending putaway silId=${sil.id}: still blocked by open stock take " + + "(warehouseId=$warehouseId, itemId=$itemId)", + ) + continue + } sil.pendingAutoPutawayWarehouseId = null sil.pendingAutoPutawayQty = null saveAndFlush(sil) - update( + val resp = update( SaveStockInLineRequest( id = sil.id, itemId = itemId, @@ -1576,6 +1588,17 @@ open class StockInLineService( ), ) ) + val ok = resp.type?.contains("success", ignoreCase = true) == true + if (!ok) { + // Restore pending so a later close / retry can pick it up (Path A fix) + sil.pendingAutoPutawayWarehouseId = warehouseId + sil.pendingAutoPutawayQty = qty + saveAndFlush(sil) + logger.warn( + "Restore pending putaway silId=${sil.id} after failed release: " + + "type=${resp.type}, message=${resp.message}", + ) + } } } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeLocationGuard.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeLocationGuard.kt index 0a7e042b..059a2407 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeLocationGuard.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeLocationGuard.kt @@ -2,6 +2,8 @@ package com.ffii.fpsms.modules.stock.service import com.ffii.fpsms.modules.master.entity.WarehouseRepository import com.ffii.fpsms.modules.stock.entity.InventoryLotLine +import com.ffii.fpsms.modules.stock.entity.OpenStockTakeRow +import com.ffii.fpsms.modules.stock.entity.StockTakeRecord import com.ffii.fpsms.modules.stock.entity.StockTakeRecordRepository import com.ffii.fpsms.modules.stock.entity.StockTakeRepository import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode @@ -10,8 +12,10 @@ import org.springframework.stereotype.Service import org.springframework.web.server.ResponseStatusException /** - * Section rounds block every item in that stock-take section. - * Item rounds block only the items on that round's records. + * Open **section** rounds block only items that appear on that round's stock-take records + * (a section round that skipped lots already on an item round does not freeze those items). + * Open **item** rounds block only items on their records. + * If a section-mode round has no records yet, freeze the whole section (conservative). * Stock-take's own variance posting does not call this guard. */ @Service @@ -29,21 +33,35 @@ class StockTakeLocationGuard( section.equals(it.getStockTakeSection()?.trim(), ignoreCase = true) } if (open.isEmpty()) return null - if (open.any { !StockTakeRoundMode.ITEM.equals(it.getStockTakeRoundMode(), ignoreCase = true) }) { - return "盤點進行中,區域 $section 暫停出入庫" + + val sectionModeOpens = open.filter { row -> + val mode = row.getStockTakeRoundMode()?.trim().orEmpty() + mode.isEmpty() || !StockTakeRoundMode.ITEM.equals(mode, ignoreCase = true) } - if (itemId == null || itemId <= 0L) return null - val roundIds = open.mapNotNull { it.getStockTakeRoundId()?.takeIf { id -> id > 0L } }.distinct() - val headerIds = open.map { it.getId() }.distinct() - val records = buildList { - if (roundIds.isNotEmpty()) { - addAll(stockTakeRecordRepository.findAllByStockTakeRoundIdInAndDeletedIsFalse(roundIds)) + val itemModeOpens = open.filter { row -> + StockTakeRoundMode.ITEM.equals(row.getStockTakeRoundMode()?.trim(), ignoreCase = true) + } + + if (sectionModeOpens.isNotEmpty()) { + val sectionRecords = loadRecords(sectionModeOpens).filter { rec -> + section.equals(rec.stockTakeSection?.trim(), ignoreCase = true) } - if (headerIds.isNotEmpty()) { - addAll(stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(headerIds)) + if (sectionRecords.isEmpty()) { + // Header created but placeholders not yet → freeze whole section + return "盤點進行中,區域 $section 暫停出入庫" + } + if (itemId == null || itemId <= 0L) { + return "盤點進行中,區域 $section 暫停出入庫" + } + if (sectionRecords.any { it.itemId == itemId }) { + return "盤點進行中,區域 $section 暫停出入庫" } } - val hit = records.firstOrNull { record -> + + if (itemModeOpens.isEmpty()) return null + if (itemId == null || itemId <= 0L) return null + val itemRecords = loadRecords(itemModeOpens) + val hit = itemRecords.firstOrNull { record -> record.itemId == itemId && section.equals(record.stockTakeSection?.trim(), ignoreCase = true) } ?: return null @@ -60,6 +78,19 @@ class StockTakeLocationGuard( assertWarehouseMovable(lotLine?.warehouse?.id, lotLine?.inventoryLot?.item?.id) } + private fun loadRecords(rows: List): List { + val roundIds = rows.mapNotNull { it.getStockTakeRoundId()?.takeIf { id -> id > 0L } }.distinct() + val headerIds = rows.map { it.getId() }.distinct() + return buildList { + if (roundIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findAllByStockTakeRoundIdInAndDeletedIsFalse(roundIds)) + } + if (headerIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(headerIds)) + } + } + } + private fun recordLabel(code: String?, name: String?): String { val itemCode = code?.trim().orEmpty() val itemName = name?.trim().orEmpty() 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 4b83d0db..ab9644a3 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 @@ -880,10 +880,7 @@ lot_root_origin AS ( if (maxRound > 0L) { return maxRound } - val latest = stockTakeRepository.findAll() - .filter { !it.deleted } - .maxByOrNull { it.planStart ?: LocalDateTime.MIN } - return latest?.let { st -> st.stockTakeRoundId ?: st.id } + return stockTakeRepository.findLatestRoundKeyByPlanStart() } open fun getGlobalLatestStockTakeRoundMeta(): LatestStockTakeRoundMetaResponse { @@ -1344,24 +1341,13 @@ lot_root_origin AS ( /** 所有 open 輪次中尚未刪除的盤點列 (lotId, warehouseId),供建立時避免重疊。 */ open fun busyOpenLotWarehouseKeys(): Set> { - val openHeaders = stockTakeRepository.findAll() - .filter { !it.deleted && it.status?.isOpen() == true } - if (openHeaders.isEmpty()) return emptySet() - val roundIds = openHeaders.mapNotNull { it.stockTakeRoundId?.takeIf { id -> id > 0L } }.distinct() - val headerIds = openHeaders.mapNotNull { it.id } - val records = buildList { - if (roundIds.isNotEmpty()) { - addAll(stockTakeRecordRepository.findAllByStockTakeRoundIdInAndDeletedIsFalse(roundIds)) - } - if (headerIds.isNotEmpty()) { - addAll(stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(headerIds)) + return stockTakeRecordRepository.findBusyLotWarehouseKeysFromOpenRounds() + .mapNotNull { row -> + val lotId = row.getLotId().takeIf { it > 0L } ?: return@mapNotNull null + val warehouseId = row.getWarehouseId().takeIf { it > 0L } ?: return@mapNotNull null + lotId to warehouseId } - } - return records.mapNotNull { rec -> - val lotId = rec.lotId ?: return@mapNotNull null - val warehouseId = rec.warehouse?.id ?: return@mapNotNull null - lotId to warehouseId - }.toSet() + .toSet() } /** PP/PF = root PO 前綴;other = 非 PP/PF(與庫存批次現況報告一致) */ @@ -1575,20 +1561,67 @@ ORDER BY it.code, wh.stockTakeSection, il.lotNo } open fun listStockTakeRoundCards(yearMonth: String?, view: String? = null): StockTakeRoundCardsRes { - val all = stockTakeRepository.findAll().filter { !it.deleted } - if (all.isEmpty()) { - val thisMonth = java.time.YearMonth.now().toString() + val thisMonth = java.time.YearMonth.now().toString() + val historyYm = yearMonth?.trim()?.takeIf { it.matches(Regex("\\d{4}-\\d{2}")) } ?: thisMonth + val viewNorm = view?.trim()?.lowercase().orEmpty() + + val openStatuses = listOf( + StockTakeStatus.PENDING, + StockTakeStatus.STOCKTAKING, + StockTakeStatus.APPROVING, + ) + val openHeaders = stockTakeRepository.findAllByDeletedIsFalseAndStatusIn(openStatuses) + val hasOpenRounds = openHeaders.isNotEmpty() + + val completedYearMonths = ( + stockTakeRepository.findCompletedYearMonths() + thisMonth + ).distinct().filter { it <= thisMonth }.sortedDescending() + + val needOpen = when (viewNorm) { + "open" -> true + "history" -> false + else -> hasOpenRounds + } + val needHistory = when (viewNorm) { + "open" -> false + "history" -> true + else -> !hasOpenRounds + } + + val headersForCards = mutableListOf() + if (needOpen) { + headersForCards.addAll(openHeaders) + } + if (needHistory) { + headersForCards.addAll(loadCompletedHeadersForYearMonth(historyYm)) + } + if (headersForCards.isEmpty()) { return StockTakeRoundCardsRes( - hasOpenRounds = false, + hasOpenRounds = hasOpenRounds, rounds = emptyList(), - completedYearMonths = listOf(thisMonth), + completedYearMonths = completedYearMonths.ifEmpty { listOf(thisMonth) }, ) } - val groups = all.groupBy { st -> st.stockTakeRoundId ?: st.id ?: 0L } + + val groups = headersForCards + .distinctBy { it.id } + .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 roundIds = groups.keys.toList() + val headerIds = headersForCards.mapNotNull { it.id }.distinct() + val records = buildList { + if (roundIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findAllByStockTakeRoundIdInAndDeletedIsFalse(roundIds)) + } + if (headerIds.isNotEmpty()) { + addAll(stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(headerIds)) + } + }.distinctBy { it.id } + val recordsByRound = records.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 @@ -1596,40 +1629,55 @@ ORDER BY it.code, wh.stockTakeSection, il.lotNo 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() + val rowHeaderIds = rows.mapNotNull { it.id }.toSet() + val roundRecords = recordsByRound[roundKey].orEmpty().ifEmpty { + records.filter { it.stockTake?.id in rowHeaderIds } } - toRoundCard(roundKey, rows, records, floorsBySection) - } - .sortedBy { 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() + toRoundCard(roundKey, rows, roundRecords, floorsBySection) + }.sortedBy { it.stockTakeRoundId } + val openRoundsSorted = run { val openCards = cards.filter { it.open } val thisMonthOpen = openCards.filter { it.yearMonth == thisMonth } thisMonthOpen + openCards.filter { it.yearMonth != thisMonth } } - val historyYm = yearMonth?.trim()?.takeIf { it.matches(Regex("\\d{4}-\\d{2}")) } ?: thisMonth val historyRounds = cards.filter { !it.open && it.yearMonth == historyYm } - val viewNorm = view?.trim()?.lowercase().orEmpty() val rounds = when (viewNorm) { "open" -> openRoundsSorted "history" -> historyRounds - // 舊行為:有進行中只回 open,否則回該月歷史(PickerCardList 等相容) else -> if (hasOpenRounds) openRoundsSorted else historyRounds } return StockTakeRoundCardsRes( hasOpenRounds = hasOpenRounds, rounds = rounds, - completedYearMonths = completedYearMonths, + completedYearMonths = completedYearMonths.ifEmpty { listOf(thisMonth) }, ) } + /** Load all section headers for completed rounds whose planStart falls in [yearMonth]. */ + private fun loadCompletedHeadersForYearMonth(yearMonth: String): List { + val ym = try { + java.time.YearMonth.parse(yearMonth) + } catch (_: Exception) { + return emptyList() + } + val start = ym.atDay(1).atStartOfDay() + val end = ym.plusMonths(1).atDay(1).atStartOfDay() + val roundKeys = stockTakeRepository.findRoundKeysByStatusAndPlanStartRange( + StockTakeStatus.COMPLETED, + start, + end, + ).filter { it > 0L }.distinct() + if (roundKeys.isEmpty()) return emptyList() + val byRoundId = stockTakeRepository.findAllByDeletedIsFalseAndStockTakeRoundIdIn(roundKeys) + val byId = stockTakeRepository.findAllByDeletedIsFalseAndIdIn(roundKeys) + return (byRoundId + byId) + .distinctBy { it.id } + .filter { it.status == StockTakeStatus.COMPLETED } + } + @Transactional open fun closeStockTakeRound(roundId: Long): Map { val rows = stockTakesForRound(roundId) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRoundAllocator.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRoundAllocator.kt new file mode 100644 index 00000000..f96acb3a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRoundAllocator.kt @@ -0,0 +1,36 @@ +package com.ffii.fpsms.modules.stock.service + +import com.ffii.core.support.JdbcDao +import org.springframework.stereotype.Service + +/** + * Allocates [stockTakeRoundId] under a DB row lock so concurrent create-round requests + * serialize until the surrounding transaction commits. + * + * Must be called inside an open Spring transaction (same TX as header/placeholder inserts). + */ +@Service +class StockTakeRoundAllocator( + private val jdbcDao: JdbcDao, +) { + fun allocateNextRoundId(): Long { + // Hold exclusive lock on the counter row until commit of the caller TX + val locked = jdbcDao.queryForInt( + "SELECT id FROM stock_take_round_counter WHERE id = 1 FOR UPDATE", + ) + if (locked != 1) { + jdbcDao.executeUpdate( + "INSERT INTO stock_take_round_counter (id, nextRoundId) VALUES (1, 0)", + ) + jdbcDao.queryForInt( + "SELECT id FROM stock_take_round_counter WHERE id = 1 FOR UPDATE", + ) + } + jdbcDao.executeUpdate( + "UPDATE stock_take_round_counter SET nextRoundId = nextRoundId + 1 WHERE id = 1", + ) + return jdbcDao.queryForDecimal( + "SELECT nextRoundId FROM stock_take_round_counter WHERE id = 1", + ).toLong() + } +} 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 59a81802..9e89d9c7 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 @@ -22,6 +22,7 @@ import org.apache.poi.ss.usermodel.Workbook import org.slf4j.Logger import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.math.BigDecimal import java.time.LocalDateTime import org.springframework.context.annotation.Lazy @@ -38,6 +39,7 @@ open class StockTakeService( val stockTakeLineService: StockTakeLineService, val inventoryLotLineRepository: InventoryLotLineRepository, val stockTakeRecordService: StockTakeRecordService, + val stockTakeRoundAllocator: StockTakeRoundAllocator, ) { companion object { private val log = LoggerFactory.getLogger(StockTakeService::class.java) @@ -242,7 +244,12 @@ open class StockTakeService( - fun createStockTakeForSections( + /** + * Create section-mode round. Serialized via [StockTakeRoundAllocator] so concurrent creates + * get distinct roundIds and re-check busy sections after the prior create commits. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun createStockTakeForSections( sectionsFilter: List? = null, stockTakeRoundName: String? = null, planStart: LocalDateTime? = null, @@ -276,66 +283,77 @@ open class StockTakeService( // 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection val batchPlanStart = planStart ?: LocalDateTime.now() val batchPlanEnd = batchPlanStart.plusDays(1) + // Lock counter first: concurrent creates wait here until this TX commits + val roundId = stockTakeRoundAllocator.allocateNextRoundId() + // Re-read busy under the create lock (sees previously committed open rounds) val busySections = stockTakeRecordService.busyStockTakeSections() .map { it.lowercase() } - .toSet() - // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) - val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 + .toMutableSet() val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } var placeholderStockTakerId: Long? = null distinctSections.forEach { section -> - if (busySections.contains(section.trim().lowercase())) { + val sectionKey = section.trim().lowercase() + if (sectionKey in busySections) { 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( - code = code, - planStart = batchPlanStart, - planEnd = batchPlanEnd, - actualStart = null, - actualEnd = null, - status = StockTakeStatus.PENDING.value, - remarks = null, - stockTakeSection = section, - stockTakeRoundId = roundId, - stockTakeRoundName = roundName, - stockTakeRoundMode = StockTakeRoundMode.SECTION, - ) - val savedStockTake = saveStockTake(saveStockTakeReq) - if (placeholderStockTakerId == null) { - placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId() - } - stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake( - savedStockTake, - placeholderStockTakerId!! - ) - result[section] = "Created: ${savedStockTake.code}" - log.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId") - } catch (e: Exception) { - result[section] = "Error: ${e.message}" - log.error("Error creating stock take for section $section: ${e.message}") + val code = assignStockTakeNo() + val saveStockTakeReq = 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.SECTION, + ) + val savedStockTake = saveStockTake(saveStockTakeReq) + // Same-batch: mark busy; concurrent creates wait on counter until this TX commits + busySections.add(sectionKey) + if (placeholderStockTakerId == null) { + placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId() } + stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake( + savedStockTake, + placeholderStockTakerId!! + ) + result[section] = "Created: ${savedStockTake.code}" + log.info("Created stock take for section $section: ${savedStockTake.code}, roundId=$roundId") } log.info("--------- End - Create Stock Take for Sections -------") return result } - fun createStockTakeForItems( + /** + * Create item-mode round. Same counter lock as section create so lot/section busy checks + * cannot race with another concurrent create. + */ + @Transactional(rollbackFor = [Exception::class]) + open fun createStockTakeForItems( itemIds: List, inventoryLotLineIds: List? = null, stockTakeRoundName: String? = null, planStart: LocalDateTime? = null, ): Map { log.info("--------- Start - Create Stock Take for Items -------") + val lineIds = inventoryLotLineIds.orEmpty().filter { it > 0L }.distinct() + val wantedItems = itemIds.filter { it > 0L }.distinct() + if (lineIds.isEmpty() && wantedItems.isEmpty()) { + return mapOf("_" to "Error: No items selected") + } + + // Serialize with section creates; busy re-read after lock + val roundId = stockTakeRoundAllocator.allocateNextRoundId() val busyLotKeys = stockTakeRecordService.busyOpenLotWarehouseKeys() val busySections = stockTakeRecordService.busyStockTakeSections() .map { it.lowercase() } .toSet() - val lineIds = inventoryLotLineIds.orEmpty().filter { it > 0L }.distinct() val lines = if (lineIds.isNotEmpty()) { inventoryLotLineRepository.findAllById(lineIds).filter { ill -> !ill.deleted && @@ -351,11 +369,7 @@ open class StockTakeService( lotId != null && warehouseId != null && (lotId to warehouseId) !in busyLotKeys } } else { - val wanted = itemIds.filter { it > 0L }.distinct() - if (wanted.isEmpty()) { - return mapOf("_" to "Error: No items selected") - } - inventoryLotLineRepository.findAllByItemIdIn(wanted).filter { ill -> + inventoryLotLineRepository.findAllByItemIdIn(wantedItems).filter { ill -> !ill.deleted && ill.status == InventoryLotLineStatus.AVAILABLE && ill.warehouse?.stockTakeSection?.trim()?.isNotBlank() == true @@ -382,7 +396,6 @@ open class StockTakeService( val bySection = lines.groupBy { it.warehouse!!.stockTakeSection!!.trim() } val batchPlanStart = planStart ?: LocalDateTime.now() val batchPlanEnd = batchPlanStart.plusDays(1) - val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } var placeholderStockTakerId: Long? = null val result = mutableMapOf() @@ -393,46 +406,41 @@ open class StockTakeService( log.info("Skipped item stock take for section $section: section round in progress") 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, - ) + 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() + } + // 有明確 lot line 時只建這些 lot(再經 busy 過濾);否則依 item 集合建 + if (lineIds.isNotEmpty()) { + stockTakeRecordService.createPlaceholderStockTakeRecordsForLotLines( + savedStockTake, + placeholderStockTakerId!!, + sectionLines.mapNotNull { it.id }, + ) + } else { + stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake( + savedStockTake, + placeholderStockTakerId!!, + itemIdFilter, ) - if (placeholderStockTakerId == null) { - placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId() - } - // 有明確 lot line 時只建這些 lot(再經 busy 過濾);否則依 item 集合建 - if (lineIds.isNotEmpty()) { - stockTakeRecordService.createPlaceholderStockTakeRecordsForLotLines( - savedStockTake, - placeholderStockTakerId!!, - sectionLines.mapNotNull { it.id }, - ) - } else { - stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake( - savedStockTake, - placeholderStockTakerId!!, - itemIdFilter, - ) - } - 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}") } + result[section] = "Created: ${savedStockTake.code} (${sectionLines.size} lots)" + log.info("Created item stock take for section $section: ${savedStockTake.code}, roundId=$roundId") } log.info("--------- End - Create Stock Take for Items -------") return result diff --git a/src/main/resources/db/changelog/changes/20261026_stock_take_round_counter/01_create_stock_take_round_counter.sql b/src/main/resources/db/changelog/changes/20261026_stock_take_round_counter/01_create_stock_take_round_counter.sql new file mode 100644 index 00000000..055a45d3 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20261026_stock_take_round_counter/01_create_stock_take_round_counter.sql @@ -0,0 +1,15 @@ +--liquibase formatted sql +--changeset fpsms:20261026_stock_take_round_counter + +-- Single-row counter: allocateNextRoundId() locks this row (FOR UPDATE) so concurrent +-- create-for-sections / create-for-items cannot share the same stockTakeRoundId or race busy checks. +CREATE TABLE IF NOT EXISTS stock_take_round_counter ( + id INT NOT NULL PRIMARY KEY, + nextRoundId BIGINT NOT NULL +); + +INSERT INTO stock_take_round_counter (id, nextRoundId) +SELECT 1, COALESCE(MAX(stockTakeRoundId), 0) +FROM stock_take +WHERE deleted = 0 +ON DUPLICATE KEY UPDATE nextRoundId = GREATEST(nextRoundId, VALUES(nextRoundId));