Explorar el Código

update muti round & create round by item

stockTakeUpdate
CANCERYS\kw093 hace 3 semanas
padre
commit
e933684a54
Se han modificado 12 ficheros con 689 adiciones y 33 borrados
  1. +3
    -0
      src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java
  2. +5
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt
  3. +9
    -1
      src/main/java/com/ffii/fpsms/modules/stock/enums/StockTakeEnum.kt
  4. +447
    -18
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  5. +86
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt
  6. +14
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeController.kt
  7. +63
    -13
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt
  8. +12
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt
  9. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockTakeRequest.kt
  10. +27
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  11. +17
    -0
      src/main/resources/db/changelog/changes/20261007_close_stock_take_rounds/01_close_open_rounds.sql
  12. +5
    -0
      src/main/resources/db/changelog/changes/20261007_stock_take_round_mode/01_add_round_mode.sql

+ 3
- 0
src/main/java/com/ffii/fpsms/config/security/SecurityConfig.java Ver fichero

@@ -121,6 +121,9 @@ public class SecurityConfig {
.hasAuthority("INVENTORY_ADJUST")
.requestMatchers(HttpMethod.GET, "/stockAdjustment/latestRemarks")
.hasAuthority("INVENTORY_ADJUST")
/* Close stock take round: ADMIN only (no @PreAuthorize on Kotlin controllers). */
.requestMatchers(HttpMethod.POST, "/stockTakeRecord/closeRound")
.hasAuthority("ADMIN")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace")
.hasAuthority("ITEM_TRACING")
.requestMatchers(HttpMethod.GET, "/inventoryLotLine/trace/location/**")


+ 5
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTake.kt Ver fichero

@@ -49,4 +49,9 @@ open class StockTake: BaseEntity<Long>() {
@Size(max = 255)
@Column(name = "stockTakeRoundName", length = 255)
open var stockTakeRoundName: String? = null

/** section = 依區域建立;item = 依貨品建立。同一輪不可混用。 */
@Size(max = 20)
@Column(name = "stockTakeRoundMode", length = 20)
open var stockTakeRoundMode: String? = null
}

+ 9
- 1
src/main/java/com/ffii/fpsms/modules/stock/enums/StockTakeEnum.kt Ver fichero

@@ -4,5 +4,13 @@ enum class StockTakeStatus(val value: String) {
PENDING("pending"),
STOCKTAKING("stockTaking"),
APPROVING("approving"),
COMPLETED("completed"),
COMPLETED("completed");

fun isOpen(): Boolean =
this == PENDING || this == STOCKTAKING || this == APPROVING
}

object StockTakeRoundMode {
const val SECTION = "section"
const val ITEM = "item"
}

+ 447
- 18
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Ver fichero

@@ -7,6 +7,7 @@ import com.ffii.fpsms.modules.stock.entity.StockTakeRepository
import com.ffii.fpsms.modules.stock.entity.StockTakeRecordRepository
import com.ffii.fpsms.modules.stock.entity.StockTakeRecord
import com.ffii.fpsms.modules.stock.enums.StockTakeStatus
import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode
import com.ffii.fpsms.modules.stock.web.model.*
import org.slf4j.Logger
import org.slf4j.LoggerFactory
@@ -81,7 +82,11 @@ open class StockTakeRecordService(
* 建立該 section 的 stock_take 後,依當下「可用」庫存行預先建立本輪全部盤點明細(pending),盤點員儲存時改為更新。
*/
@Transactional
open fun createPlaceholderStockTakeRecordsForStockTake(stockTake: StockTake, placeholderStockTakerId: Long) {
open fun createPlaceholderStockTakeRecordsForStockTake(
stockTake: StockTake,
placeholderStockTakerId: Long,
itemIds: Set<Long>? = null,
) {
val section = stockTake.stockTakeSection ?: return
val warehouses = warehouseRepository.findAllByStockTakeSectionAndDeletedIsFalse(section)
val warehouseIds = warehouses.mapNotNull { it.id }
@@ -92,6 +97,9 @@ open class StockTakeRecordService(

val inventoryLotLines =
inventoryLotLineRepository.findAllByWarehouseIdInAndDeletedIsFalseAndStatusIsAvailable(warehouseIds)
.filter { ill ->
itemIds == null || (ill.inventoryLot?.item?.id?.let { id -> id in itemIds } == true)
}
if (inventoryLotLines.isEmpty()) return

val toSave = ArrayList<StockTakeRecord>(inventoryLotLines.size)
@@ -401,7 +409,13 @@ open class StockTakeRecordService(
}
}

open fun AllPickedStockTakeList(): List<AllPickedStockTakeListReponse> {
open fun AllPickedStockTakeList(targetRoundKey: Long? = null): List<AllPickedStockTakeListReponse> {
if (targetRoundKey != null && targetRoundKey > 0L) {
val roundHeaders = stockTakesForRound(targetRoundKey)
if (roundHeaders.any { it.stockTakeRoundMode == StockTakeRoundMode.ITEM }) {
return listItemCardsForRound(targetRoundKey, roundHeaders)
}
}
// 1. 获取所有不同的 stockTakeSection(从 warehouse 表)
val allWarehouses = warehouseRepository.findAllByDeletedIsFalse()
val distinctSections = allWarehouses
@@ -418,13 +432,20 @@ open class StockTakeRecordService(
.filter { it.stockTakeSection != null && !it.stockTakeSection!!.isBlank() }
.groupBy { it.stockTakeSection!! }

val allStockTakes = stockTakeRepository.findAll()
.filter { !it.deleted }
.groupBy { it.stockTakeSection }

val scopedRound = targetRoundKey != null && targetRoundKey > 0L
val allStockTakes = (
if (scopedRound) {
stockTakeRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(targetRoundKey!!)
} else {
stockTakeRepository.findAll().filter { !it.deleted }
}
).groupBy { it.stockTakeSection }

val allStockTakeRecords = stockTakeRecordRepository.findAll()
.filter { !it.deleted }
val allStockTakeRecords = if (scopedRound) {
stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(targetRoundKey!!)
} else {
stockTakeRecordRepository.findAll().filter { !it.deleted }
}


val recordsByWarehouseId = allStockTakeRecords
@@ -433,7 +454,7 @@ open class StockTakeRecordService(

val result = mutableListOf<AllPickedStockTakeListReponse>()
var idCounter = 1L
val globalLatestRoundKey = resolveGlobalLatestStockTakeRoundKey()
val globalLatestRoundKey = targetRoundKey ?: resolveGlobalLatestStockTakeRoundKey()

// 3. 为每个 stockTakeSection 创建一个卡片
distinctSections.forEach { stockTakeSection ->
@@ -456,10 +477,22 @@ open class StockTakeRecordService(
val lastStockTakeDate = completedStockTakes.firstOrNull()?.actualEnd


val latestStockTake = resolveLatestStockTakeForSection(
stockTakesForSection,
globalLatestRoundKey
)
val latestStockTake = if (targetRoundKey != null && targetRoundKey > 0L) {
stockTakesForSection.filter { st ->
(st.stockTakeRoundId ?: st.id) == targetRoundKey
}.maxWithOrNull(
compareBy<StockTake> { it.actualStart ?: it.planStart ?: LocalDateTime.MIN }
.thenBy { it.id ?: 0L }
)
} else {
resolveLatestStockTakeForSection(
stockTakesForSection,
globalLatestRoundKey
)
}
if (targetRoundKey != null && targetRoundKey > 0L && latestStockTake == null) {
return@forEach
}

val status = if (latestStockTake != null) {
latestStockTake.status?.value ?: "pending"
@@ -578,6 +611,91 @@ open class StockTakeRecordService(
return result.sortedBy { it.stockTakeSession }
}

/** 只留仍對得到未刪除庫存行的明細,避免舊佔位列把貨品狀態和批數算歪。 */
private fun liveStockTakeRecords(records: List<StockTakeRecord>): List<StockTakeRecord> {
val lotIds = records.mapNotNull { it.lotId }.distinct()
val warehouseIds = records.mapNotNull { it.warehouse?.id }.distinct()
if (lotIds.isEmpty() || warehouseIds.isEmpty()) return emptyList()
val livePairs = inventoryLotLineRepository
.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(warehouseIds, lotIds)
.map { (it.inventoryLot?.id ?: 0L) to (it.warehouse?.id ?: 0L) }
.toSet()
return records.filter { rec ->
val lotId = rec.lotId ?: return@filter false
val warehouseId = rec.warehouse?.id ?: return@filter false
livePairs.contains(lotId to warehouseId)
}
}

private fun listItemCardsForRound(
roundKey: Long,
headers: List<StockTake>,
): List<AllPickedStockTakeListReponse> {
val records = liveStockTakeRecords(
stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundKey)
.filter { !it.deleted }
)
val byItem = records.groupBy { it.itemId ?: 0L }.filterKeys { it > 0L }
val planStartDate = headers.mapNotNull { it.planStart }.maxOrNull()?.toLocalDate()
val headerBySection = headers.associateBy { it.stockTakeSection }
return byItem.entries.mapIndexed { idx, (itemId, recs) ->
val code = recs.firstOrNull { !it.itemCode.isNullOrBlank() }?.itemCode ?: "#$itemId"
val name = recs.firstOrNull { !it.itemName.isNullOrBlank() }?.itemName
val total = recs.size
val taken = recs.count { it.pickerFirstStockTakeQty != null }
val notMatch = recs.any { it.status == "notMatch" }
val sections = recs.mapNotNull { it.stockTakeSection?.trim()?.takeIf { s -> s.isNotBlank() } }.distinct()
val involvedHeaders = sections.mapNotNull { headerBySection[it] }.ifEmpty { headers }
val open = involvedHeaders.any { it.status?.isOpen() == true }
val allCounted = recs.isNotEmpty() && recs.all {
it.pickerFirstStockTakeQty != null || it.status == "pass" || it.status == "completed"
}
val allApproved = recs.isNotEmpty() && recs.all {
it.status == "completed" || it.approverTime != null
}
val anyCounted = recs.any {
it.pickerFirstStockTakeQty != null || it.status == "pass" || it.status == "completed"
}
val statusValue = when {
!open -> StockTakeStatus.COMPLETED.value
notMatch -> StockTakeStatus.STOCKTAKING.value
allApproved -> StockTakeStatus.COMPLETED.value
allCounted -> StockTakeStatus.APPROVING.value
anyCounted -> StockTakeStatus.STOCKTAKING.value
else -> StockTakeStatus.PENDING.value
}
val floors = recs.mapNotNull { it.warehouse?.store_id?.trim()?.takeIf { f -> f.isNotBlank() } }
val mainFloor = floors.groupingBy { it }.eachCount().maxByOrNull { it.value }?.key
val representative = involvedHeaders.maxByOrNull { it.id ?: 0L } ?: headers.first()
AllPickedStockTakeListReponse(
id = (idx + 1).toLong(),
stockTakeSession = code,
lastStockTakeDate = representative.actualStart?.toLocalDate(),
status = statusValue,
currentStockTakeItemNumber = taken,
totalInventoryLotNumber = total,
stockTakeId = representative.id ?: 0L,
stockTakeRoundId = roundKey,
stockTakerName = recs.mapNotNull { it.stockTakerName }.lastOrNull(),
TotalItemNumber = 1,
approverName = recs.mapNotNull { it.approverName }.lastOrNull(),
startTime = representative.actualStart,
endTime = if (allCounted) {
recs.mapNotNull { it.stockTakeEndTime }.maxOrNull() ?: representative.actualEnd
} else {
representative.actualEnd
},
ReStockTakeTrueFalse = notMatch,
planStartDate = planStartDate,
stockTakeSectionDescription = name,
warehouseArea = recs.mapNotNull { it.warehouse?.area }.distinct().joinToString(", ").ifBlank { null },
storeId = mainFloor,
listMode = StockTakeRoundMode.ITEM,
itemId = itemId,
)
}.sortedBy { it.stockTakeSession }
}

/**
* 區域內用於卡片展示的 stock_take:若該區域參與全系統最新輪次,優先取該輪次內最新一筆;
* 否則退回區域內 planStart/actualStart 最新一筆(供未開 onlyLatestRound 時顯示舊輪)。
@@ -1019,6 +1137,299 @@ open class StockTakeRecordService(
)
}

open fun getApproverStockTakeHeaderForRound(roundId: Long): AllPickedStockTakeListReponse? {
val rows = stockTakesForRound(roundId)
if (rows.isEmpty()) return null
val representative = rows.maxWithOrNull(
compareBy<StockTake> { it.actualStart ?: it.planStart ?: LocalDateTime.MIN }
.thenBy { it.id ?: 0L }
) ?: return null
val roundKey = representative.stockTakeRoundId ?: representative.id ?: roundId
val planStartDate = rows.mapNotNull { it.planStart }.maxOrNull()?.toLocalDate()
val open = rows.any { it.status?.isOpen() == true }
val statusValue = when {
open && rows.any { it.status == StockTakeStatus.APPROVING } -> StockTakeStatus.APPROVING.value
open && rows.any { it.status == StockTakeStatus.STOCKTAKING } -> StockTakeStatus.STOCKTAKING.value
open -> StockTakeStatus.PENDING.value
else -> StockTakeStatus.COMPLETED.value
}
return AllPickedStockTakeListReponse(
id = 1L,
stockTakeSession = representative.stockTakeRoundName ?: "",
lastStockTakeDate = representative.actualStart?.toLocalDate(),
status = statusValue,
currentStockTakeItemNumber = 0,
totalInventoryLotNumber = 0,
stockTakeId = representative.id ?: 0,
stockTakeRoundId = roundKey,
stockTakerName = null,
approverName = null,
TotalItemNumber = 0,
startTime = representative.actualStart,
endTime = representative.actualEnd,
ReStockTakeTrueFalse = false,
planStartDate = planStartDate,
stockTakeSectionDescription = null,
warehouseArea = null,
storeId = null
)
}

open fun busyStockTakeSections(): List<String> {
return stockTakeRepository.findAll()
.filter { !it.deleted && it.status?.isOpen() == true }
.mapNotNull { it.stockTakeSection?.trim() }
.filter { it.isNotBlank() }
.distinct()
.sorted()
}

open fun listStockTakeRoundCards(yearMonth: String?): StockTakeRoundCardsRes {
val all = stockTakeRepository.findAll().filter { !it.deleted }
if (all.isEmpty()) {
val thisMonth = java.time.YearMonth.now().toString()
return StockTakeRoundCardsRes(
hasOpenRounds = false,
rounds = emptyList(),
completedYearMonths = listOf(thisMonth),
)
}
val groups = all.groupBy { st -> st.stockTakeRoundId ?: st.id ?: 0L }
.filterKeys { it > 0L }
val recordsByRound = stockTakeRecordRepository.findAll()
.filter { !it.deleted }
.groupBy { rec -> rec.stockTakeRoundId ?: rec.stockTake?.id ?: 0L }
val floorsBySection = warehouseRepository.findAllByDeletedIsFalse()
.mapNotNull { w ->
val section = w.stockTakeSection?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val floor = w.store_id?.trim()?.takeIf { it.isNotBlank() }
section to floor
}
.groupBy({ it.first }, { it.second })
val cards = groups.map { (roundKey, rows) ->
val headerIds = rows.mapNotNull { it.id }.toSet()
val records = recordsByRound[roundKey].orEmpty().ifEmpty {
recordsByRound.filterKeys { it in headerIds }.values.flatten()
}
toRoundCard(roundKey, rows, records, floorsBySection)
}
.sortedByDescending { it.stockTakeRoundId }
val thisMonth = java.time.YearMonth.now().toString()
val hasOpenRounds = cards.any { it.open }
val completedYearMonths = (
cards.filter { !it.open }.map { it.yearMonth } + thisMonth
).distinct().filter { it <= thisMonth }.sortedDescending()
val rounds = if (hasOpenRounds) {
val openCards = cards.filter { it.open }
val thisMonthOpen = openCards.filter { it.yearMonth == thisMonth }
thisMonthOpen + openCards.filter { it.yearMonth != thisMonth }
} else {
val ym = yearMonth?.trim()?.takeIf { it.matches(Regex("\\d{4}-\\d{2}")) } ?: thisMonth
cards.filter { !it.open && it.yearMonth == ym }
}
return StockTakeRoundCardsRes(
hasOpenRounds = hasOpenRounds,
rounds = rounds,
completedYearMonths = completedYearMonths,
)
}

@Transactional
open fun closeStockTakeRound(roundId: Long): Map<String, Any> {
val rows = stockTakesForRound(roundId)
if (rows.isEmpty()) {
return mapOf("success" to false, "message" to "Stock take round not found: $roundId")
}
val now = LocalDateTime.now()
rows.forEach { st ->
st.status = StockTakeStatus.COMPLETED
if (st.actualEnd == null) {
st.actualEnd = now
}
stockTakeRepository.save(st)
}
return mapOf(
"success" to true,
"stockTakeRoundId" to roundId,
"closedCount" to rows.size,
)
}

private fun stockTakesForRound(roundId: Long): List<StockTake> {
val byRound = stockTakeRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId)
if (byRound.isNotEmpty()) return byRound
return try {
val one = stockTakeRepository.findByIdAndDeletedIsFalse(roundId)
if (one != null) listOf(one) else emptyList()
} catch (_: Exception) {
emptyList()
}
}

private fun toRoundCard(
roundKey: Long,
rows: List<StockTake>,
records: List<StockTakeRecord>,
floorsBySection: Map<String, List<String?>>,
): StockTakeRoundCardResponse {
val representative = rows.maxWithOrNull(
compareBy<StockTake> { it.actualStart ?: it.planStart ?: LocalDateTime.MIN }
.thenBy { it.id ?: 0L }
) ?: rows.first()
val planStart = rows.mapNotNull { it.planStart }.maxOrNull()
val yearMonth = if (planStart != null) {
"%04d-%02d".format(planStart.year, planStart.monthValue)
} else {
java.time.YearMonth.now().toString()
}
val open = rows.any { it.status?.isOpen() == true }
val statusValue = when {
open && rows.any { it.status == StockTakeStatus.APPROVING } -> StockTakeStatus.APPROVING.value
open && rows.any { it.status == StockTakeStatus.STOCKTAKING } -> StockTakeStatus.STOCKTAKING.value
open -> StockTakeStatus.PENDING.value
else -> StockTakeStatus.COMPLETED.value
}
val name = rows.mapNotNull { it.stockTakeRoundName?.trim()?.takeIf { n -> n.isNotBlank() } }.firstOrNull()
val sections = rows.mapNotNull { it.stockTakeSection?.trim()?.takeIf { s -> s.isNotBlank() } }.distinct()
val floors = sections.flatMap { sec -> floorsBySection[sec].orEmpty().mapNotNull { it } }
val mainFloor = floors.groupingBy { it }.eachCount().maxWithOrNull(
compareBy<Map.Entry<String, Int>> { it.value }.thenByDescending { it.key }
)?.key
val itemMode = rows.any { it.stockTakeRoundMode == StockTakeRoundMode.ITEM }
val countedRecords = if (itemMode) liveStockTakeRecords(records) else records
val totalLots = countedRecords.size
val stockTakenCount: Int
val approvedCount: Int
if (itemMode) {
stockTakenCount = countedRecords.count {
it.pickerFirstStockTakeQty != null || it.status == "pass" || it.status == "completed"
}
approvedCount = countedRecords.count {
it.status == "completed" || it.approverStockTakeQty != null || it.approverTime != null
}
} else {
val bySection = records.groupBy { it.stockTakeSection?.trim()?.lowercase() }
fun sectionComplete(section: String, approved: Boolean): Boolean {
val group = bySection[section.lowercase()].orEmpty()
if (group.isEmpty()) return false
return if (approved) {
group.all { it.approverStockTakeQty != null || it.approverTime != null }
} else {
group.all { it.pickerFirstStockTakeQty != null }
}
}
stockTakenCount = sections.count { sectionComplete(it, false) }
approvedCount = sections.count { sectionComplete(it, true) }
}
return StockTakeRoundCardResponse(
stockTakeRoundId = roundKey,
stockTakeRoundName = name,
stockTakeId = representative.id ?: 0L,
planStartDate = planStart?.toLocalDate(),
yearMonth = yearMonth,
open = open,
status = statusValue,
sectionCount = sections.size,
totalItemNumber = countedRecords.mapNotNull { it.itemId }.distinct().size,
totalInventoryLotNumber = totalLots,
stockTakenCount = stockTakenCount,
approvedCount = approvedCount,
mainFloor = mainFloor,
stockTakeRoundMode = rows.mapNotNull { it.stockTakeRoundMode }.firstOrNull(),
)
}

open fun getInventoryLotDetailsByItem(
itemId: Long,
stockTakeRoundId: Long,
pageNum: Int = 0,
pageSize: Int = 10,
notMatchOnly: Boolean = false,
): RecordsRes<InventoryLotDetailResponse> {
val records = stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(stockTakeRoundId)
.filter {
!it.deleted &&
it.itemId == itemId &&
(!notMatchOnly || it.status == "notMatch")
}
if (records.isEmpty()) {
return RecordsRes(emptyList(), 0)
}
val lotIds = records.mapNotNull { it.lotId }.toSet()
val whIds = records.mapNotNull { it.warehouse?.id }.toSet()
val illList =
if (lotIds.isNotEmpty() && whIds.isNotEmpty()) {
inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(whIds, lotIds)
} else {
emptyList()
}
val illByPair = illList.associateBy {
Pair(it.inventoryLot?.id ?: 0L, it.warehouse?.id ?: 0L)
}
val allResults = records.mapNotNull { rec ->
val ill = illByPair[Pair(rec.lotId ?: 0L, rec.warehouse?.id ?: 0L)]
?: return@mapNotNull null
val inventoryLot = ill.inventoryLot
val item = inventoryLot?.item
val warehouse = ill.warehouse
val availableQty = (ill.inQty ?: BigDecimal.ZERO)
.subtract(ill.outQty ?: BigDecimal.ZERO)
val stId = rec.stockTake?.id
val illId = ill.id
val stockTakeLine =
if (stId != null && illId != null) {
stockTakeLineRepository.findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(illId, stId)
} else {
null
}
InventoryLotDetailResponse(
id = ill.id ?: 0L,
inventoryLotId = inventoryLot?.id ?: 0L,
itemId = item?.id ?: rec.itemId ?: 0L,
itemCode = item?.code ?: rec.itemCode,
itemName = item?.name ?: rec.itemName,
lotNo = inventoryLot?.lotNo,
expiryDate = inventoryLot?.expiryDate,
storeId = warehouse?.store_id,
productionDate = inventoryLot?.productionDate,
stockInDate = inventoryLot?.stockInDate,
inQty = ill.inQty,
remarks = rec.remarks,
outQty = ill.outQty,
holdQty = ill.holdQty,
availableQty = availableQty,
uom = ill.stockUom?.uom?.udfudesc,
uomShortDesc = ill.stockUom?.uom?.udfShortDesc,
warehouseCode = warehouse?.code,
warehouseArea = warehouse?.area,
warehouseName = warehouse?.name,
status = ill.status?.name,
warehouseSlot = warehouse?.slot,
warehouse = warehouse?.warehouse,
varianceQty = rec.varianceQty,
stockTakeRecordId = rec.id,
stockTakeRecordStatus = rec.status,
firstStockTakeQty = rec.pickerFirstStockTakeQty,
secondStockTakeQty = rec.pickerSecondStockTakeQty,
firstBadQty = rec.pickerFirstBadQty,
secondBadQty = rec.pickerSecondBadQty,
approverQty = rec.approverStockTakeQty,
approverBadQty = rec.approverBadQty,
finalQty = stockTakeLine?.finalQty,
bookQty = rec.bookQty,
stockTakeSection = rec.stockTakeSection ?: warehouse?.stockTakeSection,
stockTakeSectionDescription = warehouse?.stockTakeSectionDescription,
stockTakerName = rec.stockTakerName,
stockTakeEndTime = rec.stockTakeEndTime,
approverTime = rec.approverTime,
)
}.sortedWith(compareBy({ it.storeId }, { it.warehouseCode }, { it.lotNo }))
val startIndex = pageNum * pageSize
val endIndex = minOf(startIndex + pageSize, allResults.size)
val page = if (startIndex < allResults.size) allResults.subList(startIndex, endIndex) else emptyList()
return RecordsRes(page, allResults.size)
}

open fun getInventoryLotDetailsByWarehouseCode(warehouseCode: String): List<InventoryLotDetailResponse> {
println("getInventoryLotDetailsByWarehouseCode called with code: $warehouseCode")

@@ -1425,8 +1836,9 @@ open class StockTakeRecordService(
}

val stockTakeSection = savedRecord.stockTakeSection
val statusStockTakeId = savedRecord.stockTake?.id ?: stockTakeId
if (stockTakeSection != null) {
checkAndUpdateStockTakeStatus(stockTakeId, stockTakeSection)
checkAndUpdateStockTakeStatus(statusStockTakeId, stockTakeSection)
}

return toSaveStockTakeRecordResponse(savedRecord)
@@ -1463,8 +1875,19 @@ open class StockTakeRecordService(
}
}

if (successCount > 0 && request.stockTakeSection.isNotBlank()) {
checkAndUpdateStockTakeStatus(request.stockTakeId, request.stockTakeSection)
if (successCount > 0) {
val recIds = request.records.mapNotNull { it.stockTakeRecordId }
val recs = if (recIds.isEmpty()) emptyList() else stockTakeRecordRepository.findAllById(recIds)
val headers = recs.mapNotNull { it.stockTake }.distinctBy { it.id }
if (headers.isNotEmpty()) {
headers.forEach { st ->
val section = st.stockTakeSection ?: return@forEach
val id = st.id ?: return@forEach
checkAndUpdateStockTakeStatus(id, section)
}
} else if (request.stockTakeSection.isNotBlank()) {
checkAndUpdateStockTakeStatus(request.stockTakeId, request.stockTakeSection)
}
}

return BatchSaveStockTakeRecordResponse(
@@ -2762,6 +3185,7 @@ private inline fun <T : Any, K : Any> Iterable<T>.associateByNotNull(keySelector
}
return destination
}
@Transactional
open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord {
println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId")
@@ -2771,8 +3195,13 @@ open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTa
stockTakeRecord.apply {
this.status = "notMatch"
}
return stockTakeRecordRepository.save(stockTakeRecord)
val saved = stockTakeRecordRepository.save(stockTakeRecord)
val stockTake = saved.stockTake
if (stockTake != null && !stockTake.deleted) {
stockTake.status = StockTakeStatus.STOCKTAKING
stockTakeRepository.save(stockTake)
}
return saved
}

open fun getInventoryLotDetailsByStockTakeSectionNotMatch(


+ 86
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeService.kt Ver fichero

@@ -11,7 +11,9 @@ import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository
import com.ffii.fpsms.modules.stock.entity.StockTake
import com.ffii.fpsms.modules.stock.entity.StockTakeRepository
import com.ffii.fpsms.modules.stock.enums.StockTakeLineStatus
import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode
import com.ffii.fpsms.modules.stock.enums.StockTakeStatus
import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus
import com.ffii.fpsms.modules.stock.web.model.*
import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.CellType
@@ -23,7 +25,6 @@ import org.springframework.stereotype.Service
import java.math.BigDecimal
import java.time.LocalDateTime
import org.springframework.context.annotation.Lazy
import org.springframework.transaction.annotation.Transactional

@Service
open class StockTakeService(
@@ -65,6 +66,7 @@ open class StockTakeService(
request.stockTakeSection?.let { stockTake.stockTakeSection = it } // 添加此行
request.stockTakeRoundId?.let { stockTake.stockTakeRoundId = it }
request.stockTakeRoundName?.let { stockTake.stockTakeRoundName = it }
request.stockTakeRoundMode?.let { stockTake.stockTakeRoundMode = it }

return stockTakeRepository.save(stockTake)
}
@@ -274,11 +276,19 @@ open class StockTakeService(
// 移除 null section 处理逻辑,因为 warehouse 表中没有 null 的 stockTakeSection
val batchPlanStart = planStart ?: LocalDateTime.now()
val batchPlanEnd = batchPlanStart.plusDays(1)
val busySections = stockTakeRecordService.busyStockTakeSections()
.map { it.lowercase() }
.toSet()
// 輪次序號:每批次共用同一個遞增 roundId,與各筆 stock_take 主鍵脫鉤(避免第二輪變成 4,4,4)
val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1
val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() }
var placeholderStockTakerId: Long? = null
distinctSections.forEach { section ->
if (busySections.contains(section.trim().lowercase())) {
result[section] = "Skipped: in progress"
log.info("Skipped stock take for section $section: already in an open round")
return@forEach
}
try {
val code = assignStockTakeNo()
val saveStockTakeReq = SaveStockTakeRequest(
@@ -292,6 +302,7 @@ open class StockTakeService(
stockTakeSection = section,
stockTakeRoundId = roundId,
stockTakeRoundName = roundName,
stockTakeRoundMode = StockTakeRoundMode.SECTION,
)
val savedStockTake = saveStockTake(saveStockTakeReq)
if (placeholderStockTakerId == null) {
@@ -311,4 +322,78 @@ open class StockTakeService(
log.info("--------- End - Create Stock Take for Sections -------")
return result
}

fun createStockTakeForItems(
itemIds: List<Long>,
stockTakeRoundName: String? = null,
planStart: LocalDateTime? = null,
): Map<String, String> {
log.info("--------- Start - Create Stock Take for Items -------")
val wanted = itemIds.filter { it > 0L }.distinct()
if (wanted.isEmpty()) {
return mapOf("_" to "Error: No items selected")
}
val lines = inventoryLotLineRepository.findAllByItemIdIn(wanted).filter { ill ->
!ill.deleted &&
ill.status == InventoryLotLineStatus.AVAILABLE &&
ill.warehouse?.stockTakeSection?.trim()?.isNotBlank() == true
}.filter { ill ->
val availableQty = (ill.inQty ?: BigDecimal.ZERO)
.subtract(ill.outQty ?: BigDecimal.ZERO)
availableQty.compareTo(BigDecimal.ZERO) > 0
}
if (lines.isEmpty()) {
return mapOf("_" to "Error: No available lots for selected items")
}
val bySection = lines.groupBy { it.warehouse!!.stockTakeSection!!.trim() }
val batchPlanStart = planStart ?: LocalDateTime.now()
val batchPlanEnd = batchPlanStart.plusDays(1)
val busySections = stockTakeRecordService.busyStockTakeSections()
.map { it.lowercase() }
.toSet()
val roundId = stockTakeRepository.findMaxStockTakeRoundId() + 1
val roundName = stockTakeRoundName?.trim()?.takeIf { it.isNotBlank() }
var placeholderStockTakerId: Long? = null
val result = mutableMapOf<String, String>()
bySection.forEach { (section, sectionLines) ->
if (busySections.contains(section.lowercase())) {
result[section] = "Skipped: in progress"
log.info("Skipped item stock take for section $section: already in an open round")
return@forEach
}
try {
val code = assignStockTakeNo()
val savedStockTake = saveStockTake(
SaveStockTakeRequest(
code = code,
planStart = batchPlanStart,
planEnd = batchPlanEnd,
actualStart = null,
actualEnd = null,
status = StockTakeStatus.PENDING.value,
remarks = null,
stockTakeSection = section,
stockTakeRoundId = roundId,
stockTakeRoundName = roundName,
stockTakeRoundMode = StockTakeRoundMode.ITEM,
)
)
if (placeholderStockTakerId == null) {
placeholderStockTakerId = stockTakeRecordService.resolvePlaceholderStockTakerId()
}
stockTakeRecordService.createPlaceholderStockTakeRecordsForStockTake(
savedStockTake,
placeholderStockTakerId!!,
wanted.toSet(),
)
result[section] = "Created: ${savedStockTake.code} (${sectionLines.size} lots)"
log.info("Created item stock take for section $section: ${savedStockTake.code}, roundId=$roundId")
} catch (e: Exception) {
result[section] = "Error: ${e.message}"
log.error("Error creating item stock take for section $section: ${e.message}")
}
}
log.info("--------- End - Create Stock Take for Items -------")
return result
}
}

+ 14
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeController.kt Ver fichero

@@ -1,6 +1,7 @@
package com.ffii.fpsms.modules.stock.web

import com.ffii.fpsms.modules.stock.service.StockTakeService
import com.ffii.fpsms.modules.stock.web.model.CreateStockTakeForItemsRequest
import com.ffii.fpsms.modules.stock.web.model.CreateStockTakeForSectionsRequest
import jakarta.servlet.http.HttpServletRequest
import org.apache.poi.ss.usermodel.Workbook
@@ -65,4 +66,17 @@ class StockTakeController(
return ResponseEntity.ok(result)
}

@PostMapping("/createForItems")
fun createStockTakeForItems(
@RequestBody body: CreateStockTakeForItemsRequest,
): ResponseEntity<Map<String, String>> {
val itemIds = body.itemIds.orEmpty().filter { it > 0L }.distinct()
val result = stockTakeService.createStockTakeForItems(
itemIds = itemIds,
stockTakeRoundName = body.stockTakeRoundName,
planStart = body.planStart,
)
return ResponseEntity.ok(result)
}

}

+ 63
- 13
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt Ver fichero

@@ -33,9 +33,12 @@ class StockTakeRecordController(
@RequestParam(required = false) status: String?,
@RequestParam(required = false) area: String?,
@RequestParam(required = false) storeId: String?,
@RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean
@RequestParam(required = false, defaultValue = "false") onlyLatestRound: Boolean,
@RequestParam(required = false) stockTakeRoundId: Long?
): RecordsRes<AllPickedStockTakeListReponse> {
var all = stockOutRecordService.AllPickedStockTakeList()
var all = stockOutRecordService.AllPickedStockTakeList(
targetRoundKey = if (stockTakeRoundId != null && stockTakeRoundId > 0L) stockTakeRoundId else null
)
if (sectionDescription != null && sectionDescription != "All") {
all = all.filter { it.stockTakeSectionDescription == sectionDescription }
}
@@ -52,13 +55,17 @@ class StockTakeRecordController(
}
if (!status.isNullOrBlank() && status != "All") {
val normalizedStatus = status.trim().lowercase()
val acceptedStatuses = when (normalizedStatus) {
"stocktaking" -> setOf("stocktaking", "processing", "in_progress")
else -> setOf(normalizedStatus)
}
all = all.filter { item ->
val itemStatus = item.status.trim().lowercase()
itemStatus in acceptedStatuses
all = if (normalizedStatus == "notmatch") {
all.filter { it.ReStockTakeTrueFalse }
} else {
val acceptedStatuses = when (normalizedStatus) {
"stocktaking" -> setOf("stocktaking", "processing", "in_progress")
else -> setOf(normalizedStatus)
}
all.filter { item ->
val itemStatus = item.status.trim().lowercase()
itemStatus in acceptedStatuses
}
}
}
if (!area.isNullOrBlank()) {
@@ -72,7 +79,7 @@ class StockTakeRecordController(
it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true
}
}
if (onlyLatestRound) {
if (onlyLatestRound && (stockTakeRoundId == null || stockTakeRoundId <= 0L)) {
val latestRoundKey = stockOutRecordService.resolveGlobalLatestStockTakeRoundKey()
all = if (latestRoundKey == null || latestRoundKey <= 0L) {
emptyList()
@@ -170,11 +177,37 @@ class StockTakeRecordController(
return stockOutRecordService.AllApproverStockTakeList()
}
@GetMapping("/LatestApproverStockTakeHeader")
fun latestApproverStockTakeHeader(): ResponseEntity<AllPickedStockTakeListReponse> {
val header = stockOutRecordService.getLatestApproverStockTakeHeader()
?: return ResponseEntity.notFound().build()
fun latestApproverStockTakeHeader(
@RequestParam(required = false) stockTakeRoundId: Long?
): ResponseEntity<AllPickedStockTakeListReponse> {
val header = if (stockTakeRoundId != null && stockTakeRoundId > 0L) {
stockOutRecordService.getApproverStockTakeHeaderForRound(stockTakeRoundId)
} else {
stockOutRecordService.getLatestApproverStockTakeHeader()
} ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(header)
}

@GetMapping("/stockTakeRoundCards")
fun stockTakeRoundCards(
@RequestParam(required = false) yearMonth: String?
): StockTakeRoundCardsRes {
return stockOutRecordService.listStockTakeRoundCards(yearMonth)
}

@GetMapping("/busyStockTakeSections")
fun busyStockTakeSections(): List<String> {
return stockOutRecordService.busyStockTakeSections()
}

@PostMapping("/closeRound")
fun closeStockTakeRound(
@RequestBody body: CloseStockTakeRoundRequest
): Map<String, Any> {
val roundId = body.stockTakeRoundId
?: throw IllegalArgumentException("stockTakeRoundId is required")
return stockOutRecordService.closeStockTakeRound(roundId)
}
@GetMapping("/inventoryLotDetailsBySectionNotMatch")
fun getInventoryLotDetailsByStockTakeSectionNotMatch(
@RequestParam stockTakeSection: String,
@@ -200,6 +233,23 @@ class StockTakeRecordController(
return stockOutRecordService.getInventoryLotDetailsByWarehouseCode(warehouseCode)
}
@GetMapping("/inventoryLotDetailsByItem")
fun getInventoryLotDetailsByItem(
@RequestParam itemId: Long,
@RequestParam stockTakeRoundId: Long,
@RequestParam(required = false, defaultValue = "0") pageNum: Int,
@RequestParam(required = false, defaultValue = "10") pageSize: Int,
@RequestParam(required = false, defaultValue = "false") notMatchOnly: Boolean
): RecordsRes<InventoryLotDetailResponse> {
return stockOutRecordService.getInventoryLotDetailsByItem(
itemId,
stockTakeRoundId,
pageNum,
pageSize,
notMatchOnly
)
}

@GetMapping("/inventoryLotDetailsBySection")
fun getInventoryLotDetailsByStockTakeSection(
@RequestParam stockTakeSection: String,


+ 12
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/CreateStockTakeForSectionsRequest.kt Ver fichero

@@ -9,3 +9,15 @@ data class CreateStockTakeForSectionsRequest(
val stockTakeRoundName: String? = null,
val planStart: LocalDateTime? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class CloseStockTakeRoundRequest(
val stockTakeRoundId: Long? = null,
)

@JsonIgnoreProperties(ignoreUnknown = true)
data class CreateStockTakeForItemsRequest(
val itemIds: List<Long>? = null,
val stockTakeRoundName: String? = null,
val planStart: LocalDateTime? = null,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/SaveStockTakeRequest.kt Ver fichero

@@ -14,4 +14,5 @@ data class SaveStockTakeRequest(
val stockTakeSection: String?=null,
val stockTakeRoundId: Long? = null,
val stockTakeRoundName: String? = null,
val stockTakeRoundMode: String? = null,
)

+ 27
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt Ver fichero

@@ -12,6 +12,31 @@ data class LatestStockTakeRoundMetaResponse(
val planStartDate: LocalDate?,
)

data class StockTakeRoundCardResponse(
val stockTakeRoundId: Long,
val stockTakeRoundName: String?,
val stockTakeId: Long,
@JsonFormat(pattern = "yyyy-MM-dd")
val planStartDate: LocalDate?,
val yearMonth: String,
val open: Boolean,
val status: String,
val sectionCount: Int,
val totalItemNumber: Int = 0,
val totalInventoryLotNumber: Int = 0,
val stockTakenCount: Int = 0,
val approvedCount: Int = 0,
val mainFloor: String? = null,
val reStockTakeTrueFalse: Boolean = false,
val stockTakeRoundMode: String? = null,
)

data class StockTakeRoundCardsRes(
val hasOpenRounds: Boolean,
val rounds: List<StockTakeRoundCardResponse>,
val completedYearMonths: List<String>,
)

data class AllPickedStockTakeListReponse(
val id: Long,
val stockTakeSession: String,
@@ -35,6 +60,8 @@ data class AllPickedStockTakeListReponse(
val stockTakeSectionDescription: String?,
val warehouseArea: String?,
val storeId: String?,
val listMode: String = "section",
val itemId: Long? = null,
)
data class InventoryLotDetailResponse(
val id: Long,


+ 17
- 0
src/main/resources/db/changelog/changes/20261007_close_stock_take_rounds/01_close_open_rounds.sql Ver fichero

@@ -0,0 +1,17 @@
--liquibase formatted sql
--changeset fpsms:20261007_close_open_stock_take_rounds

-- Close leftover open stock_take headers (planStart Apr–Sep 2026).
-- Same as Admin close round: status → completed; fill actualEnd if null.
-- Does not change stock_take_record / stock_take_line.

UPDATE stock_take
SET
status = 'completed',
actualEnd = IFNULL(actualEnd, NOW()),
modified = NOW(),
modifiedBy = 'sql-close-rounds'
WHERE deleted = 0
AND status IN ('pending', 'stockTaking', 'approving')
AND planStart >= '2026-04-01 00:00:00'
AND planStart < '2026-10-01 00:00:00';

+ 5
- 0
src/main/resources/db/changelog/changes/20261007_stock_take_round_mode/01_add_round_mode.sql Ver fichero

@@ -0,0 +1,5 @@
--liquibase formatted sql
--changeset fpsms:20261007_stock_take_round_mode

ALTER TABLE stock_take
ADD COLUMN stockTakeRoundMode VARCHAR(20) NULL DEFAULT 'section';

Cargando…
Cancelar
Guardar