瀏覽代碼

update

stockTakeUpdate
CANCERYS\kw093 1 周之前
父節點
當前提交
ca0eb41ab5
共有 8 個檔案被更改,包括 390 行新增 和 140 行删除
  1. +36
    -1
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt
  2. +54
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt
  3. +25
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  4. +44
    -13
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeLocationGuard.kt
  5. +93
    -45
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  6. +36
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRoundAllocator.kt
  7. +87
    -79
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt
  8. +15
    -0
      src/main/resources/db/changelog/changes/20261026_stock_take_round_counter/01_create_stock_take_round_counter.sql

+ 36
- 1
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt 查看文件

@@ -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
}


+ 54
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRepository.kt 查看文件

@@ -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


+ 25
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt 查看文件

@@ -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}",
)
}
} }
} }




+ 44
- 13
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.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()


+ 93
- 45
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt 查看文件

@@ -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)


+ 36
- 0
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()
}
}

+ 87
- 79
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.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


+ 15
- 0
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));

Loading…
取消
儲存