| @@ -21,7 +21,37 @@ interface StockTakeRecordRepository : AbstractRepository<StockTakeRecord, Long> | |||
| fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection<Long>): List<StockTakeRecord> | |||
| /** | |||
| * 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<BusyLotWarehouseKeyRow> | |||
| @Query( | |||
| """ | |||
| SELECT r | |||
| @@ -59,3 +89,8 @@ interface StockTakeRecordRepository : AbstractRepository<StockTakeRecord, Long> | |||
| } | |||
| interface BusyLotWarehouseKeyRow { | |||
| fun getLotId(): Long | |||
| fun getWarehouseId(): Long | |||
| } | |||
| @@ -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<StockTake, Long> { | |||
| @@ -11,6 +14,12 @@ interface StockTakeRepository : AbstractRepository<StockTake, Long> { | |||
| fun findAllByStockTakeRoundIdAndDeletedIsFalse(stockTakeRoundId: Long): List<StockTake> | |||
| fun findAllByDeletedIsFalseAndStatusIn(statuses: Collection<StockTakeStatus>): List<StockTake> | |||
| fun findAllByDeletedIsFalseAndStockTakeRoundIdIn(stockTakeRoundIds: Collection<Long>): List<StockTake> | |||
| fun findAllByDeletedIsFalseAndIdIn(ids: Collection<Long>): List<StockTake> | |||
| @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<StockTake, Long> { | |||
| ) | |||
| 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<Long> | |||
| @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<String> | |||
| @Query( | |||
| value = """ | |||
| SELECT DISTINCT stockTakeSection FROM stock_take | |||
| @@ -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<String>) { | |||
| 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}", | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -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<OpenStockTakeRow>): List<StockTakeRecord> { | |||
| 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() | |||
| @@ -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<Pair<Long, Long>> { | |||
| 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<StockTake>() | |||
| 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<StockTake> { | |||
| 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<String, Any> { | |||
| val rows = stockTakesForRound(roundId) | |||
| @@ -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() | |||
| } | |||
| } | |||
| @@ -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<String>? = 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<Long>, | |||
| inventoryLotLineIds: List<Long>? = null, | |||
| stockTakeRoundName: String? = null, | |||
| planStart: LocalDateTime? = null, | |||
| ): Map<String, String> { | |||
| 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<String, String>() | |||
| @@ -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 | |||
| @@ -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)); | |||