| @@ -21,7 +21,37 @@ interface StockTakeRecordRepository : AbstractRepository<StockTakeRecord, Long> | |||||
| fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection<Long>): List<StockTakeRecord> | 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( | @Query( | ||||
| """ | """ | ||||
| SELECT r | 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 | package com.ffii.fpsms.modules.stock.entity | ||||
| import com.ffii.core.support.AbstractRepository | 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.jpa.repository.Query | ||||
| import org.springframework.data.repository.query.Param | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| import java.time.LocalDateTime | |||||
| @Repository | @Repository | ||||
| interface StockTakeRepository : AbstractRepository<StockTake, Long> { | interface StockTakeRepository : AbstractRepository<StockTake, Long> { | ||||
| @@ -11,6 +14,12 @@ interface StockTakeRepository : AbstractRepository<StockTake, Long> { | |||||
| fun findAllByStockTakeRoundIdAndDeletedIsFalse(stockTakeRoundId: Long): List<StockTake> | 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(""" | @Query(""" | ||||
| select st.code from StockTake st where st.code like :prefix% order by st.code desc limit 1 | 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 | 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( | @Query( | ||||
| value = """ | value = """ | ||||
| SELECT DISTINCT stockTakeSection FROM stock_take | SELECT DISTINCT stockTakeSection FROM stock_take | ||||
| @@ -1543,7 +1543,11 @@ open class StockInLineService( | |||||
| return itemNo.contains("FA") | 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 | @Transactional | ||||
| open fun releasePendingAutoPutaways(sections: Collection<String>) { | open fun releasePendingAutoPutaways(sections: Collection<String>) { | ||||
| val wanted = sections.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet() | val wanted = sections.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet() | ||||
| @@ -1556,10 +1560,18 @@ open class StockInLineService( | |||||
| if (section !in wanted) continue | if (section !in wanted) continue | ||||
| val qty = sil.pendingAutoPutawayQty ?: sil.acceptedQty ?: continue | val qty = sil.pendingAutoPutawayQty ?: sil.acceptedQty ?: continue | ||||
| val itemId = sil.item?.id ?: 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.pendingAutoPutawayWarehouseId = null | ||||
| sil.pendingAutoPutawayQty = null | sil.pendingAutoPutawayQty = null | ||||
| saveAndFlush(sil) | saveAndFlush(sil) | ||||
| update( | |||||
| val resp = update( | |||||
| SaveStockInLineRequest( | SaveStockInLineRequest( | ||||
| id = sil.id, | id = sil.id, | ||||
| itemId = itemId, | 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.master.entity.WarehouseRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | 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.StockTakeRecordRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockTakeRepository | import com.ffii.fpsms.modules.stock.entity.StockTakeRepository | ||||
| import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode | import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode | ||||
| @@ -10,8 +12,10 @@ import org.springframework.stereotype.Service | |||||
| import org.springframework.web.server.ResponseStatusException | 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. | * Stock-take's own variance posting does not call this guard. | ||||
| */ | */ | ||||
| @Service | @Service | ||||
| @@ -29,21 +33,35 @@ class StockTakeLocationGuard( | |||||
| section.equals(it.getStockTakeSection()?.trim(), ignoreCase = true) | section.equals(it.getStockTakeSection()?.trim(), ignoreCase = true) | ||||
| } | } | ||||
| if (open.isEmpty()) return null | 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 && | record.itemId == itemId && | ||||
| section.equals(record.stockTakeSection?.trim(), ignoreCase = true) | section.equals(record.stockTakeSection?.trim(), ignoreCase = true) | ||||
| } ?: return null | } ?: return null | ||||
| @@ -60,6 +78,19 @@ class StockTakeLocationGuard( | |||||
| assertWarehouseMovable(lotLine?.warehouse?.id, lotLine?.inventoryLot?.item?.id) | 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 { | private fun recordLabel(code: String?, name: String?): String { | ||||
| val itemCode = code?.trim().orEmpty() | val itemCode = code?.trim().orEmpty() | ||||
| val itemName = name?.trim().orEmpty() | val itemName = name?.trim().orEmpty() | ||||
| @@ -880,10 +880,7 @@ lot_root_origin AS ( | |||||
| if (maxRound > 0L) { | if (maxRound > 0L) { | ||||
| return maxRound | 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 { | open fun getGlobalLatestStockTakeRoundMeta(): LatestStockTakeRoundMetaResponse { | ||||
| @@ -1344,24 +1341,13 @@ lot_root_origin AS ( | |||||
| /** 所有 open 輪次中尚未刪除的盤點列 (lotId, warehouseId),供建立時避免重疊。 */ | /** 所有 open 輪次中尚未刪除的盤點列 (lotId, warehouseId),供建立時避免重疊。 */ | ||||
| open fun busyOpenLotWarehouseKeys(): Set<Pair<Long, Long>> { | 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(與庫存批次現況報告一致) */ | /** 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 { | 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( | return StockTakeRoundCardsRes( | ||||
| hasOpenRounds = false, | |||||
| hasOpenRounds = hasOpenRounds, | |||||
| rounds = emptyList(), | 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 } | .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() | val floorsBySection = warehouseRepository.findAllByDeletedIsFalse() | ||||
| .mapNotNull { w -> | .mapNotNull { w -> | ||||
| val section = w.stockTakeSection?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null | 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 | section to floor | ||||
| } | } | ||||
| .groupBy({ it.first }, { it.second }) | .groupBy({ it.first }, { it.second }) | ||||
| val cards = groups.map { (roundKey, rows) -> | 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 openRoundsSorted = run { | ||||
| val openCards = cards.filter { it.open } | val openCards = cards.filter { it.open } | ||||
| val thisMonthOpen = openCards.filter { it.yearMonth == thisMonth } | val thisMonthOpen = openCards.filter { it.yearMonth == thisMonth } | ||||
| 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 historyRounds = cards.filter { !it.open && it.yearMonth == historyYm } | ||||
| val viewNorm = view?.trim()?.lowercase().orEmpty() | |||||
| val rounds = when (viewNorm) { | val rounds = when (viewNorm) { | ||||
| "open" -> openRoundsSorted | "open" -> openRoundsSorted | ||||
| "history" -> historyRounds | "history" -> historyRounds | ||||
| // 舊行為:有進行中只回 open,否則回該月歷史(PickerCardList 等相容) | |||||
| else -> if (hasOpenRounds) openRoundsSorted else historyRounds | else -> if (hasOpenRounds) openRoundsSorted else historyRounds | ||||
| } | } | ||||
| return StockTakeRoundCardsRes( | return StockTakeRoundCardsRes( | ||||
| hasOpenRounds = hasOpenRounds, | hasOpenRounds = hasOpenRounds, | ||||
| rounds = rounds, | 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 | @Transactional | ||||
| open fun closeStockTakeRound(roundId: Long): Map<String, Any> { | open fun closeStockTakeRound(roundId: Long): Map<String, Any> { | ||||
| val rows = stockTakesForRound(roundId) | 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.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import org.springframework.context.annotation.Lazy | import org.springframework.context.annotation.Lazy | ||||
| @@ -38,6 +39,7 @@ open class StockTakeService( | |||||
| val stockTakeLineService: StockTakeLineService, | val stockTakeLineService: StockTakeLineService, | ||||
| val inventoryLotLineRepository: InventoryLotLineRepository, | val inventoryLotLineRepository: InventoryLotLineRepository, | ||||
| val stockTakeRecordService: StockTakeRecordService, | val stockTakeRecordService: StockTakeRecordService, | ||||
| val stockTakeRoundAllocator: StockTakeRoundAllocator, | |||||
| ) { | ) { | ||||
| companion object { | companion object { | ||||
| private val log = LoggerFactory.getLogger(StockTakeService::class.java) | 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, | sectionsFilter: List<String>? = null, | ||||
| stockTakeRoundName: String? = null, | stockTakeRoundName: String? = null, | ||||
| planStart: LocalDateTime? = null, | planStart: LocalDateTime? = null, | ||||
| @@ -276,66 +283,77 @@ open class StockTakeService( | |||||
| // 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection | // 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection | ||||
| val batchPlanStart = planStart ?: LocalDateTime.now() | val batchPlanStart = planStart ?: LocalDateTime.now() | ||||
| val batchPlanEnd = batchPlanStart.plusDays(1) | 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() | val busySections = stockTakeRecordService.busyStockTakeSections() | ||||
| .map { it.lowercase() } | .map { it.lowercase() } | ||||
| .toSet() | |||||
| // 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4) | |||||
| val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 | |||||
| .toMutableSet() | |||||
| val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } | val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } | ||||
| var placeholderStockTakerId: Long? = null | var placeholderStockTakerId: Long? = null | ||||
| distinctSections.forEach { section -> | distinctSections.forEach { section -> | ||||
| if (busySections.contains(section.trim().lowercase())) { | |||||
| val sectionKey = section.trim().lowercase() | |||||
| if (sectionKey in busySections) { | |||||
| result[section] = "Skipped: in progress" | result[section] = "Skipped: in progress" | ||||
| log.info("Skipped stock take for section $section: already in an open round") | log.info("Skipped stock take for section $section: already in an open round") | ||||
| return@forEach | 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 -------") | log.info("--------- End - Create Stock Take for Sections -------") | ||||
| return result | 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>, | itemIds: List<Long>, | ||||
| inventoryLotLineIds: List<Long>? = null, | inventoryLotLineIds: List<Long>? = null, | ||||
| stockTakeRoundName: String? = null, | stockTakeRoundName: String? = null, | ||||
| planStart: LocalDateTime? = null, | planStart: LocalDateTime? = null, | ||||
| ): Map<String, String> { | ): Map<String, String> { | ||||
| log.info("--------- Start - Create Stock Take for Items -------") | 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 busyLotKeys = stockTakeRecordService.busyOpenLotWarehouseKeys() | ||||
| val busySections = stockTakeRecordService.busyStockTakeSections() | val busySections = stockTakeRecordService.busyStockTakeSections() | ||||
| .map { it.lowercase() } | .map { it.lowercase() } | ||||
| .toSet() | .toSet() | ||||
| val lineIds = inventoryLotLineIds.orEmpty().filter { it > 0L }.distinct() | |||||
| val lines = if (lineIds.isNotEmpty()) { | val lines = if (lineIds.isNotEmpty()) { | ||||
| inventoryLotLineRepository.findAllById(lineIds).filter { ill -> | inventoryLotLineRepository.findAllById(lineIds).filter { ill -> | ||||
| !ill.deleted && | !ill.deleted && | ||||
| @@ -351,11 +369,7 @@ open class StockTakeService( | |||||
| lotId != null && warehouseId != null && (lotId to warehouseId) !in busyLotKeys | lotId != null && warehouseId != null && (lotId to warehouseId) !in busyLotKeys | ||||
| } | } | ||||
| } else { | } 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.deleted && | ||||
| ill.status == InventoryLotLineStatus.AVAILABLE && | ill.status == InventoryLotLineStatus.AVAILABLE && | ||||
| ill.warehouse?.stockTakeSection?.trim()?.isNotBlank() == true | ill.warehouse?.stockTakeSection?.trim()?.isNotBlank() == true | ||||
| @@ -382,7 +396,6 @@ open class StockTakeService( | |||||
| val bySection = lines.groupBy { it.warehouse!!.stockTakeSection!!.trim() } | val bySection = lines.groupBy { it.warehouse!!.stockTakeSection!!.trim() } | ||||
| val batchPlanStart = planStart ?: LocalDateTime.now() | val batchPlanStart = planStart ?: LocalDateTime.now() | ||||
| val batchPlanEnd = batchPlanStart.plusDays(1) | val batchPlanEnd = batchPlanStart.plusDays(1) | ||||
| val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1 | |||||
| val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } | val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() } | ||||
| var placeholderStockTakerId: Long? = null | var placeholderStockTakerId: Long? = null | ||||
| val result = mutableMapOf<String, String>() | 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") | log.info("Skipped item stock take for section $section: section round in progress") | ||||
| return@forEach | 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 -------") | log.info("--------- End - Create Stock Take for Items -------") | ||||
| return result | 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)); | |||||