Procházet zdrojové kódy

Merge remote-tracking branch 'origin/production' into production

production
B.E.N.S.O.N před 1 týdnem
rodič
revize
0a992c381d
9 změnil soubory, kde provedl 532 přidání a 96 odebrání
  1. +48
    -8
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt
  2. +38
    -2
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  3. +8
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt
  4. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt
  5. +1
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt
  6. +400
    -85
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt
  7. +26
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt
  8. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt
  9. +8
    -0
      src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql

+ 48
- 8
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt Zobrazit soubor

@@ -1624,16 +1624,17 @@ open class PickOrderService(
}
throw IllegalStateException("Failed to generate unique delivery note code after $maxAttempts attempts")
}
/*
@Transactional(rollbackFor = [java.lang.Exception::class])
open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse {
try {
println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===")
println("consoCode: $consoCode")
// println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===")
// println("consoCode: $consoCode")

val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode)
if (stockOut == null) {
println("❌ No stock_out found for consoCode: $consoCode")
//println("❌ No stock_out found for consoCode: $consoCode")
return MessageResponse(
id = null,
name = "Stock out not found",
@@ -1680,13 +1681,13 @@ open class PickOrderService(
!(isComplete || isRejected || isPartiallyComplete)
}

println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}")
// println("📊 Stock out lines: ${stockOutLines.size}, Unfinished: ${unfinishedLines.size}")

if (unfinishedLines.isEmpty()) {
println(" All stock out lines completed, updating pick order statuses...")
return completeStockOut(consoCode)
} else {
println("⏳ Still have ${unfinishedLines.size} unfinished lines")
//println("⏳ Still have ${unfinishedLines.size} unfinished lines")
return MessageResponse(
id = stockOut.id,
name = stockOut.consoPickOrderCode ?: stockOut.deliveryOrderCode,
@@ -1710,8 +1711,47 @@ open class PickOrderService(
)
}
}

*/
@Transactional(readOnly = true)
open fun countUnfinishedLinesByConsoCode(consoCode: String): Int {
val sql = """
SELECT COUNT(1) AS unfinished_count
FROM stock_out_line sol
JOIN pick_order_line pol ON pol.id = sol.pickOrderLineId
JOIN pick_order po ON po.id = pol.poId
WHERE po.consoCode = :consoCode
AND sol.deleted = false
AND LOWER(TRIM(COALESCE(sol.status, ''))) NOT IN (
'completed',
'complete',
'rejected',
'partially_completed',
'partially_complete'
)
""".trimIndent()
val rows = jdbcDao.queryForList(sql, mapOf("consoCode" to consoCode))
val countAny = rows.firstOrNull()?.get("unfinished_count")
return when (countAny) {
is Number -> countAny.toInt()
is String -> countAny.toIntOrNull() ?: 0
else -> 0
}
}
@Transactional(rollbackFor = [Exception::class])
open fun checkAndCompletePickOrderByConsoCode(consoCode: String): MessageResponse {
val unfinished = countUnfinishedLinesByConsoCode(consoCode)
if (unfinished > 0) {
return MessageResponse(
id = null,
name = consoCode,
code = "NOT_COMPLETED",
type = "pickorder",
message = "Pick order not completed yet, $unfinished lines remaining",
errorPosition = null
)
}
return completeStockOut(consoCode)
}
open fun createGroup(name: String, targetDate: LocalDate, pickOrderId: Long?): PickOrderGroup {
val group = PickOrderGroup().apply {
this.name = name


+ 38
- 2
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt Zobrazit soubor

@@ -305,6 +305,8 @@ ORDER BY
fun searchStockTakeVarianceReportV2(
stockTakeRoundId: Long,
itemCode: String?,
storeId: String?,
status: String?,
): List<Map<String, Any>> {
val countSql = """
SELECT COUNT(*) AS c FROM stocktakerecord s
@@ -320,6 +322,34 @@ ORDER BY

val args = mutableMapOf<String, Any>()
args["stockTakeRoundId"] = stockTakeRoundId

val statusNormalized = status?.trim()?.lowercase().orEmpty()
// status 映射规则:
// - All/null:不过滤
// - pending:包含 pending/pass/notMatch
// - completed:只看 completed
val statusLatestSql = when (statusNormalized) {
"pending" -> """
AND str.status IN ('pending', 'pass', 'notMatch')
""".trimIndent()
"completed" -> """
AND str.status = 'completed'
""".trimIndent()
else -> ""
}

val storeIdSql = run {
val normalized = storeId?.trim()
if (normalized.isNullOrBlank() || normalized.equals("all", ignoreCase = true)) {
""
} else {
args["storeId"] = normalized
// DB 里 store_id 可能是 "2/F" 或 "2F";用 REPLACE 去斜線做匹配
"""
AND REPLACE(COALESCE(wh.store_id, ''), '/', '') = REPLACE(:storeId, '/', '')
""".trimIndent()
}
}
val itemCodeSql = buildMultiValueLikeClause(
itemCode,
"it.code",
@@ -345,10 +375,12 @@ latest_str AS (
str.approverStockTakeQty,
str.date AS strDate,
str.id,
str.approverTime
str.approverTime,
str.status AS stockTakeRecordStatus
FROM stocktakerecord str
WHERE str.deleted = 0
AND str.stockTakeRoundId = :stockTakeRoundId
$statusLatestSql
),
in_agg AS (
SELECT
@@ -443,7 +475,8 @@ data AS (
ls.approverStockTakeQty AS stkApproverQty,
ls.varianceQty AS stkVarianceQty,
ls.strDate AS stockTakeDateRaw,
ls.approverTime AS approvalDateTimeRaw
ls.approverTime AS approvalDateTimeRaw,
ls.stockTakeRecordStatus AS stockTakeRecordStatus
FROM latest_str ls
INNER JOIN inventory_lot il
ON ls.lotId = il.id
@@ -471,6 +504,7 @@ data AS (

WHERE 1=1
$itemCodeSql
$storeIdSql
)

SELECT
@@ -501,12 +535,14 @@ SELECT
END AS stockTakeQty,

CASE
WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0'
WHEN stkVarianceQty IS NULL THEN '0'
WHEN COALESCE(stkVarianceQty, 0) < 0 THEN CONCAT('(', FORMAT(-stkVarianceQty, 0), ')')
ELSE FORMAT(COALESCE(stkVarianceQty, 0), 0)
END AS variance,

CASE
WHEN COALESCE(stockTakeRecordStatus, '') <> 'completed' THEN '0%'
WHEN stkVarianceQty IS NULL THEN '0%'
WHEN COALESCE(stkBookQty, 0) = 0 THEN '0%'
WHEN (COALESCE(stkVarianceQty, 0) / stkBookQty) * 100 < 0 THEN CONCAT('(', FORMAT(-(COALESCE(stkVarianceQty, 0) / stkBookQty) * 100, 0), '%)')


+ 8
- 0
src/main/java/com/ffii/fpsms/modules/report/web/StockTakeVarianceReportController.kt Zobrazit soubor

@@ -143,6 +143,8 @@ class StockTakeVarianceReportController(
fun generateStockTakeVarianceReportV2(
@RequestParam stockTakeRoundId: Long,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false, name = "store_id") storeId: String?,
@RequestParam(required = false) status: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

@@ -169,6 +171,8 @@ class StockTakeVarianceReportController(
val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2(
stockTakeRoundId = stockTakeRoundId,
itemCode = itemCode,
storeId = storeId,
status = status,
)
val stockTakeDateDisplay = dbData
.mapNotNull { it["stockTakeDate"] as? String }
@@ -196,11 +200,15 @@ class StockTakeVarianceReportController(
fun exportStockTakeVarianceReportV2Excel(
@RequestParam stockTakeRoundId: Long,
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false, name = "store_id") storeId: String?,
@RequestParam(required = false) status: String?,
): ResponseEntity<ByteArray> {
val cap = stockTakeVarianceReportService.getStockTakeRoundCaption(stockTakeRoundId)
val dbData = stockTakeVarianceReportService.searchStockTakeVarianceReportV2(
stockTakeRoundId = stockTakeRoundId,
itemCode = itemCode,
storeId = storeId,
status = status,
)

val excelBytes = createStockTakeVarianceExcel(


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryRepository.kt Zobrazit soubor

@@ -40,6 +40,7 @@ interface InventoryRepository: AbstractRepository<Inventory, Long> {
fun findInventoryInfoByItemInAndDeletedIsFalse(items: List<Items>): List<InventoryInfo>

fun findByItemId(itemId: Long): Optional<Inventory>
fun findAllByItemIdInAndDeletedIsFalse(itemIds: Collection<Long>): List<Inventory>

@Query("SELECT i FROM Inventory i WHERE i.item.id = :itemId AND i.deleted = false")
fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Inventory>


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeLineRepository.kt Zobrazit soubor

@@ -9,6 +9,7 @@ interface StockTakeLineRepository : AbstractRepository<StockTakeLine, Long> {
fun findByIdAndDeletedIsFalse(id: Serializable): StockTakeLine?;
fun findByInventoryLotLineIdAndStockTakeIdAndDeletedIsFalse(inventoryLotLineId: Serializable, stockTakeId: Serializable): StockTakeLine?;
fun findByStockTakeRecord_IdAndDeletedIsFalse(stockTakeRecordId: Long): StockTakeLine?
fun findAllByStockTakeRecord_IdInAndDeletedIsFalse(stockTakeRecordIds: Collection<Long>): List<StockTakeLine>
fun findAllByStockTakeIdInAndInventoryLotLineIdInAndDeletedIsFalse(
stockTakeIds: Collection<Long>,
inventoryLotLineIds: Collection<Long>


+ 400
- 85
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Zobrazit soubor

@@ -422,6 +422,14 @@ open class StockTakeRecordService(
.mapNotNull { it.stockTakeSectionDescription } // 先去掉 null
.distinct() // 去重(防止误填多个不同值)
.firstOrNull()
val warehouseArea = warehouses
.mapNotNull { it.area }
.distinct()
.firstOrNull()
val storeId = warehouses
.mapNotNull { it.store_id }
.distinct()
.firstOrNull()

val roundIdForLatest = latestStockTake?.let { st -> st.stockTakeRoundId ?: st.id }
val roundRecordsForSection = if (latestStockTake != null && roundIdForLatest != null) {
@@ -483,7 +491,9 @@ open class StockTakeRecordService(
endTime = latestStockTake?.actualEnd,
ReStockTakeTrueFalse = reStockTakeTrueFalse,
planStartDate = latestStockTake?.planStart?.toLocalDate(),
stockTakeSectionDescription = sectionDescription
stockTakeSectionDescription = sectionDescription,
warehouseArea = warehouseArea,
storeId = storeId

)
)
@@ -804,7 +814,9 @@ open class StockTakeRecordService(
endTime = latestBaseStockTake.actualEnd,
ReStockTakeTrueFalse = anyNotMatch,
planStartDate = latestBaseStockTake.planStart?.toLocalDate(),
stockTakeSectionDescription = null
stockTakeSectionDescription = null,
warehouseArea = null,
storeId = null
)
)
}
@@ -839,7 +851,9 @@ open class StockTakeRecordService(
endTime = latestBaseStockTake.actualEnd,
ReStockTakeTrueFalse = false,
planStartDate = latestBaseStockTake.planStart?.toLocalDate(),
stockTakeSectionDescription = null
stockTakeSectionDescription = null,
warehouseArea = null,
storeId = null
)
}

@@ -1842,11 +1856,14 @@ if (itemParts.isNotEmpty()) {
open fun batchSaveApproverStockTakeRecordsByIds(
request: BatchSaveApproverStockTakeByIdsRequest
): BatchSaveApproverStockTakeRecordResponse {
println("batchSaveApproverStockTakeRecordsByIds called for stockTakeId: ${request.stockTakeId}, ids=${request.recordIds.size}")
val totalStartNs = System.nanoTime()
fun elapsedMs(startNs: Long): Long = (System.nanoTime() - startNs) / 1_000_000
logger.info("batchSaveApproverStockTakeRecordsByIds start: stockTakeId={}, ids={}", request.stockTakeId, request.recordIds.size)
if (request.recordIds.isEmpty()) {
return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No record IDs provided"))
}

val loadStartNs = System.nanoTime()
val user = userRepository.findById(request.approverId).orElse(null)
val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(request.stockTakeId)
?: throw IllegalArgumentException("Stock take not found: ${request.stockTakeId}")
@@ -1859,15 +1876,33 @@ open fun batchSaveApproverStockTakeRecordsByIds(
(it.pickerFirstStockTakeQty != null || it.pickerSecondStockTakeQty != null) &&
it.approverStockTakeQty == null
}
println("Found ${stockTakeRecords.size} records to process by IDs")
logger.info(
"batchSaveApproverStockTakeRecordsByIds load completed: candidates={}, loadMs={}",
stockTakeRecords.size,
elapsedMs(loadStartNs)
)
if (stockTakeRecords.isEmpty()) {
return BatchSaveApproverStockTakeRecordResponse(0, 0, listOf("No records found matching criteria"))
}
val cacheBuildStartNs = System.nanoTime()
val adjustmentCache = buildBatchAdjustmentCache(stockTakeRecords)
logger.info(
"batchSaveApproverStockTakeRecordsByIds cache build completed: lotLinePairs={}, lots={}, inventories={}, stockTakeLines={}, cacheBuildMs={}",
adjustmentCache.inventoryLotLineByWarehouseLot.size,
adjustmentCache.inventoryLotById.size,
adjustmentCache.inventoryByItemId.size,
adjustmentCache.stockTakeLineByRecordId.size,
elapsedMs(cacheBuildStartNs)
)

var successCount = 0
var errorCount = 0
val errors = mutableListOf<String>()
val processedStockTakes = mutableSetOf<Pair<Long, String>>()
val prepareStartNs = System.nanoTime()
val recordsToPersist = mutableListOf<StockTakeRecord>()
val postPersistActions = mutableListOf<Triple<StockTakeRecord, BigDecimal, BigDecimal>>()

stockTakeRecords.forEach { record ->
try {
val qty: BigDecimal
@@ -1901,27 +1936,8 @@ open fun batchSaveApproverStockTakeRecordsByIds(
}
}

stockTakeRecordRepository.save(record)

if (varianceQty != BigDecimal.ZERO) {
try {
applyVarianceAdjustment(record.stockTake ?: stockTake, record, qty, varianceQty, request.approverId)
} catch (e: Exception) {
logger.error("Failed to apply variance adjustment for record ${record.id}", e)
errorCount++
errors.add("Record ${record.id}: ${e.message}")
return@forEach
}
} else {
completeStockTakeLineForApproverNoVariance(record.stockTake ?: stockTake, record, qty)
}

val stId = record.stockTake?.id
val section = record.stockTakeSection
if (stId != null && section != null) {
processedStockTakes.add(Pair(stId, section))
}
successCount++
recordsToPersist.add(record)
postPersistActions.add(Triple(record, qty, varianceQty))
} catch (e: Exception) {
errorCount++
val errorMsg = "Error processing record ${record.id}: ${e.message}"
@@ -1929,14 +1945,118 @@ open fun batchSaveApproverStockTakeRecordsByIds(
logger.error(errorMsg, e)
}
}
logger.info(
"batchSaveApproverStockTakeRecordsByIds prepare completed: readyToPersist={}, precheckErrors={}, prepareMs={}",
recordsToPersist.size,
errorCount,
elapsedMs(prepareStartNs)
)

if (recordsToPersist.isNotEmpty()) {
val persistStartNs = System.nanoTime()
stockTakeRecordRepository.saveAll(recordsToPersist)
logger.info(
"batchSaveApproverStockTakeRecordsByIds persist completed: persisted={}, persistMs={}",
recordsToPersist.size,
elapsedMs(persistStartNs)
)

val adjustmentStartNs = System.nanoTime()
val runtimeCache = BatchAdjustmentRuntimeCache(
stockOutByStockTakeId = mutableMapOf(),
stockInByStockTakeId = mutableMapOf(),
runningLedgerBalanceByItemId = mutableMapOf()
)
val adjustmentContext = StockTakeAdjustmentBatchContext()
var varianceCount = 0
var noVarianceCount = 0
var varianceMs = 0L
var noVarianceMs = 0L
postPersistActions.forEach { (record, qty, varianceQty) ->
try {
if (varianceQty != BigDecimal.ZERO) {
val varianceStartNs = System.nanoTime()
applyVarianceAdjustment(
record.stockTake ?: stockTake,
record,
qty,
varianceQty,
request.approverId,
adjustmentCache,
runtimeCache,
adjustmentContext
)
varianceMs += elapsedMs(varianceStartNs)
varianceCount++
} else {
val noVarianceStartNs = System.nanoTime()
completeStockTakeLineForApproverNoVariance(
record.stockTake ?: stockTake,
record,
qty,
adjustmentCache,
adjustmentContext
)
noVarianceMs += elapsedMs(noVarianceStartNs)
noVarianceCount++
}
val stId = record.stockTake?.id
val section = record.stockTakeSection
if (stId != null && section != null) {
processedStockTakes.add(Pair(stId, section))
}
successCount++
} catch (e: Exception) {
logger.error("Failed to apply inventory/line update for record ${record.id}", e)
errorCount++
errors.add("Record ${record.id}: ${e.message}")
}
}
val flushStartNs = System.nanoTime()
flushStockTakeAdjustmentBatchContext(adjustmentContext)
val flushMs = elapsedMs(flushStartNs)
logger.info(
"batchSaveApproverStockTakeRecordsByIds adjustment completed: successSoFar={}, errorsSoFar={}, adjustmentMs={}, varianceCount={}, varianceMs={}, noVarianceCount={}, noVarianceMs={}, flushMs={}",
successCount,
errorCount,
elapsedMs(adjustmentStartNs),
varianceCount,
varianceMs,
noVarianceCount,
noVarianceMs,
flushMs
)
logger.info(
"batchSaveApproverStockTakeRecordsByIds runtime cache stats: stockOutHeads={}, stockInHeads={}, ledgerItems={}, batchedStockTakeLines={}, batchedOutLines={}, batchedInLines={}, batchedInventoryLotLines={}, batchedLedgers={}",
runtimeCache.stockOutByStockTakeId.size,
runtimeCache.stockInByStockTakeId.size,
runtimeCache.runningLedgerBalanceByItemId.size,
adjustmentContext.stockTakeLineByRecordId.size,
adjustmentContext.stockOutLines.size,
adjustmentContext.stockInLines.size,
adjustmentContext.inventoryLotLineById.size,
adjustmentContext.stockLedgers.size
)
}

if (successCount > 0) {
val statusStartNs = System.nanoTime()
processedStockTakes.forEach { (stId, section) ->
checkAndUpdateStockTakeStatus(stId, section)
}
logger.info(
"batchSaveApproverStockTakeRecordsByIds status update completed: stockTakes={}, statusMs={}",
processedStockTakes.size,
elapsedMs(statusStartNs)
)
}

println("batchSaveApproverStockTakeRecordsByIds completed: success=$successCount, errors=$errorCount")
logger.info(
"batchSaveApproverStockTakeRecordsByIds completed: success={}, errors={}, totalMs={}",
successCount,
errorCount,
elapsedMs(totalStartNs)
)
return BatchSaveApproverStockTakeRecordResponse(
successCount = successCount,
errorCount = errorCount,
@@ -1948,10 +2068,19 @@ open fun batchSaveApproverStockTakeRecordsByIds(
* stockTakeRecord 上存的是 inventory_lot.id(批次),不是 inventory_lot_line.id;用倉庫 + 批次找唯一庫存行。
*/
private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord): InventoryLotLine {
return resolveInventoryLotLineForStockTakeRecord(record, null)
}

private fun resolveInventoryLotLineForStockTakeRecord(
record: StockTakeRecord,
cache: BatchAdjustmentCache?
): InventoryLotLine {
val warehouseId = record.warehouse?.id
?: throw IllegalArgumentException("Warehouse not found on stock take record")
val lotId = record.inventoryLotId ?: record.lotId
?: throw IllegalArgumentException("Inventory lot ID not found on stock take record")
val cacheKey = Pair(warehouseId, lotId)
cache?.inventoryLotLineByWarehouseLot?.get(cacheKey)?.let { return it }
val lines = inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(
listOf(warehouseId),
listOf(lotId)
@@ -1971,10 +2100,14 @@ private fun resolveInventoryLotLineForStockTakeRecord(record: StockTakeRecord):
private fun completeStockTakeLineForApproverNoVariance(
stockTake: StockTake,
stockTakeRecord: StockTakeRecord,
finalQty: BigDecimal
finalQty: BigDecimal,
cache: BatchAdjustmentCache? = null,
context: StockTakeAdjustmentBatchContext? = null
) {
val rid = stockTakeRecord.id ?: return
val line = stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid) ?: return
val line = cache?.stockTakeLineByRecordId?.get(rid)
?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)
?: return
line.apply {
this.stockTake = stockTake
this.initialQty = this.initialQty ?: stockTakeRecord.bookQty
@@ -1983,7 +2116,11 @@ private fun completeStockTakeLineForApproverNoVariance(
this.completeDate = LocalDateTime.now()
this.stockTakeRecord = stockTakeRecord
}
stockTakeLineRepository.save(line)
if (context != null) {
context.stockTakeLineByRecordId[rid] = line
} else {
stockTakeLineRepository.save(line)
}
}

/**
@@ -1997,23 +2134,29 @@ private fun applyVarianceAdjustment(
stockTakeRecord: StockTakeRecord,
finalQty: BigDecimal,
varianceQty: BigDecimal,
approverId: Long?
approverId: Long?,
cache: BatchAdjustmentCache? = null,
runtimeCache: BatchAdjustmentRuntimeCache? = null,
context: StockTakeAdjustmentBatchContext? = null
) {
if (varianceQty == BigDecimal.ZERO) return

val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord)
val inventoryLotLine = resolveInventoryLotLineForStockTakeRecord(stockTakeRecord, cache)

val inventoryLot = inventoryLotRepository.findByIdAndDeletedFalse(
inventoryLotLine.inventoryLot?.id ?: throw IllegalArgumentException("Inventory lot ID not found")
) ?: throw IllegalArgumentException("Inventory lot not found")
val inventoryLotId = inventoryLotLine.inventoryLot?.id
?: throw IllegalArgumentException("Inventory lot ID not found")
val inventoryLot = cache?.inventoryLotById?.get(inventoryLotId)
?: inventoryLotRepository.findByIdAndDeletedFalse(inventoryLotId)
?: throw IllegalArgumentException("Inventory lot not found")

val inventory = inventoryRepository.findByItemId(
inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found")
).orElse(null) ?: throw IllegalArgumentException("Inventory not found for item")
val itemId = inventoryLot.item?.id ?: throw IllegalArgumentException("Item ID not found")
val inventory = cache?.inventoryByItemId?.get(itemId)
?: inventoryRepository.findByItemId(itemId).orElse(null)
?: throw IllegalArgumentException("Inventory not found for item")

// 1. 更新開輪預建的 StockTakeLine,或舊資料無預建時新建
val stockTakeLine = stockTakeRecord.id?.let { rid ->
stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)
cache?.stockTakeLineByRecordId?.get(rid) ?: stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)
}?.also { existing ->
existing.apply {
this.stockTake = stockTake
@@ -2033,7 +2176,12 @@ private fun applyVarianceAdjustment(
this.completeDate = LocalDateTime.now()
this.stockTakeRecord = stockTakeRecord
}
stockTakeLineRepository.save(stockTakeLine)
val stockTakeRecordId = stockTakeRecord.id
if (context != null && stockTakeRecordId != null) {
context.stockTakeLineByRecordId[stockTakeRecordId] = stockTakeLine
} else {
stockTakeLineRepository.save(stockTakeLine)
}

val zero = BigDecimal.ZERO

@@ -2048,12 +2196,24 @@ private fun applyVarianceAdjustment(
return
}

var stockOut = stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!)
?: StockOut().apply {
this.type = "stockTake"
this.status = "completed"
this.handler = approverId
}.also { stockOutRepository.save(it) }
val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found")
val stockOut = if (runtimeCache != null) {
runtimeCache.stockOutByStockTakeId.getOrPut(stockTakeId) {
stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockOut().apply {
this.type = "stockTake"
this.status = "completed"
this.handler = approverId
}.also { stockOutRepository.save(it) }
}
} else {
stockOutRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockOut().apply {
this.type = "stockTake"
this.status = "completed"
this.handler = approverId
}.also { stockOutRepository.save(it) }
}

val stockOutLine = StockOutLine().apply {
this.item = inventoryLot.item
@@ -2063,15 +2223,21 @@ private fun applyVarianceAdjustment(
this.status = "completed"
this.type = "TKE"
}
stockOutLineRepository.save(stockOutLine)
if (context != null) {
context.stockOutLines.add(stockOutLine)
} else {
stockOutLineRepository.save(stockOutLine)
}

// 與 StockOutLineService.createStockLedgerForStockOut 一致:依上一筆 ledger balance 扣減,
// 避免同一批多筆盤虧時每筆都用同一個 inventory.onHandQty 導致 balance 錯誤。
val itemIdForLedger = inventoryLot.item?.id
?: throw IllegalArgumentException("Item ID not found for stock take ledger")
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
val previousBalance = latestLedger?.balance
?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger)
?: run {
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}
val newBalance = previousBalance - qtyToRemove.toDouble()

val stockLedger = StockLedger().apply {
@@ -2087,21 +2253,37 @@ private fun applyVarianceAdjustment(
this.date = LocalDate.now()
}

stockLedgerRepository.save(stockLedger)
if (context != null) {
context.stockLedgers.add(stockLedger)
} else {
stockLedgerRepository.save(stockLedger)
}
runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance)

val newOutQty = (latestLine.outQty ?: zero).add(qtyToRemove)
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = latestLine.inQty,
outQty = newOutQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
latestLine.outQty = newOutQty
latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus(
latestLine.status,
latestLine.inQty,
latestLine.outQty,
latestLine.holdQty
)
inventoryLotLineService.saveInventoryLotLine(updateRequest)
if (context != null && latestLine.id != null) {
context.inventoryLotLineById[latestLine.id!!] = latestLine
} else {
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = latestLine.inQty,
outQty = latestLine.outQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
)
inventoryLotLineService.saveInventoryLotLine(updateRequest)
}
}

// 3. variance > 0:入庫加在既有庫存行 inQty + StockLedger
@@ -2110,12 +2292,24 @@ private fun applyVarianceAdjustment(
val latestLine = inventoryLotLineRepository.findById(inventoryLotLine.id!!).orElseThrow()
val newInQty = (latestLine.inQty ?: zero).add(plusQty)

var stockIn = stockInRepository.findByStockTakeIdAndDeletedFalse(stockTake.id!!)
?: StockIn().apply {
this.code = stockTake.code
this.status = "completed"
this.stockTake = stockTake
}.also { stockInRepository.save(it) }
val stockTakeId = stockTake.id ?: throw IllegalArgumentException("Stock take ID not found")
val stockIn = if (runtimeCache != null) {
runtimeCache.stockInByStockTakeId.getOrPut(stockTakeId) {
stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockIn().apply {
this.code = stockTake.code
this.status = "completed"
this.stockTake = stockTake
}.also { stockInRepository.save(it) }
}
} else {
stockInRepository.findByStockTakeIdAndDeletedFalse(stockTakeId)
?: StockIn().apply {
this.code = stockTake.code
this.status = "completed"
this.stockTake = stockTake
}.also { stockInRepository.save(it) }
}

val stockInLine = StockInLine().apply {
this.stockTakeLine = stockTakeLine
@@ -2132,26 +2326,43 @@ private fun applyVarianceAdjustment(
// 原入庫已綁定之 inventory_lot_line 與 SIL 多為 OneToOne;盤盈改加同一行 inQty,此處不綁 line 以免衝突
this.inventoryLotLine = null
}
stockInLineRepository.save(stockInLine)
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = newInQty,
outQty = latestLine.outQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
if (context != null) {
context.stockInLines.add(stockInLine)
} else {
stockInLineRepository.save(stockInLine)
}
latestLine.inQty = newInQty
latestLine.status = inventoryLotLineService.deriveInventoryLotLineStatus(
latestLine.status,
latestLine.inQty,
latestLine.outQty,
latestLine.holdQty
)
inventoryLotLineService.saveInventoryLotLine(updateRequest)
if (context != null && latestLine.id != null) {
context.inventoryLotLineById[latestLine.id!!] = latestLine
} else {
val updateRequest = SaveInventoryLotLineRequest(
id = latestLine.id,
inventoryLotId = latestLine.inventoryLot?.id,
warehouseId = latestLine.warehouse?.id,
stockUomId = latestLine.stockUom?.id,
inQty = latestLine.inQty,
outQty = latestLine.outQty,
holdQty = latestLine.holdQty,
status = latestLine.status?.value,
remarks = latestLine.remarks
)
inventoryLotLineService.saveInventoryLotLine(updateRequest)
}

val itemIdForLedger = inventoryLot.item?.id
?: throw IllegalArgumentException("Item ID not found for stock take ledger (in)")
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
val previousBalance = latestLedger?.balance
?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
val previousBalance = runtimeCache?.runningLedgerBalanceByItemId?.get(itemIdForLedger)
?: run {
val latestLedger = stockLedgerRepository.findFirstByItemIdAndDeletedFalseOrderByDateDescIdDesc(itemIdForLedger)
latestLedger?.balance ?: (inventory.onHandQty ?: BigDecimal.ZERO).toDouble()
}
val newBalance = previousBalance + plusQty.toDouble()

val stockLedger = StockLedger().apply {
@@ -2167,8 +2378,112 @@ private fun applyVarianceAdjustment(
this.date = LocalDate.now()
}

stockLedgerRepository.save(stockLedger)
if (context != null) {
context.stockLedgers.add(stockLedger)
} else {
stockLedgerRepository.save(stockLedger)
}
runtimeCache?.runningLedgerBalanceByItemId?.put(itemIdForLedger, newBalance)
}
}

private data class BatchAdjustmentCache(
val inventoryLotLineByWarehouseLot: Map<Pair<Long, Long>, InventoryLotLine>,
val inventoryLotById: Map<Long, InventoryLot>,
val inventoryByItemId: Map<Long, com.ffii.fpsms.modules.stock.entity.Inventory>,
val stockTakeLineByRecordId: Map<Long, StockTakeLine>
)

private data class BatchAdjustmentRuntimeCache(
val stockOutByStockTakeId: MutableMap<Long, StockOut>,
val stockInByStockTakeId: MutableMap<Long, StockIn>,
val runningLedgerBalanceByItemId: MutableMap<Long, Double>
)

private data class StockTakeAdjustmentBatchContext(
val stockTakeLineByRecordId: MutableMap<Long, StockTakeLine> = LinkedHashMap(),
val stockOutLines: MutableList<StockOutLine> = mutableListOf(),
val stockInLines: MutableList<StockInLine> = mutableListOf(),
val inventoryLotLineById: MutableMap<Long, InventoryLotLine> = LinkedHashMap(),
val stockLedgers: MutableList<StockLedger> = mutableListOf()
)

private fun flushStockTakeAdjustmentBatchContext(context: StockTakeAdjustmentBatchContext) {
if (context.stockTakeLineByRecordId.isNotEmpty()) {
stockTakeLineRepository.saveAll(context.stockTakeLineByRecordId.values.toList())
}
if (context.stockOutLines.isNotEmpty()) {
stockOutLineRepository.saveAll(context.stockOutLines)
}
if (context.stockInLines.isNotEmpty()) {
stockInLineRepository.saveAll(context.stockInLines)
}
if (context.inventoryLotLineById.isNotEmpty()) {
inventoryLotLineRepository.saveAll(context.inventoryLotLineById.values.toList())
}
if (context.stockLedgers.isNotEmpty()) {
stockLedgerRepository.saveAll(context.stockLedgers)
}
}

private fun buildBatchAdjustmentCache(records: List<StockTakeRecord>): BatchAdjustmentCache {
val pairs = records.mapNotNull { r ->
val warehouseId = r.warehouse?.id
val lotId = r.inventoryLotId ?: r.lotId
if (warehouseId != null && lotId != null) Pair(warehouseId, lotId) else null
}.toSet()
val warehouseIds = pairs.map { it.first }.toSet()
val lotIds = pairs.map { it.second }.toSet()
val lotLines =
if (warehouseIds.isNotEmpty() && lotIds.isNotEmpty()) {
inventoryLotLineRepository.findAllByWarehouseIdInAndInventoryLotIdInAndDeletedIsFalse(warehouseIds, lotIds)
} else {
emptyList()
}
val inventoryLotLineByWarehouseLot = lotLines
.groupBy { Pair(it.warehouse?.id ?: 0L, it.inventoryLot?.id ?: 0L) }
.mapValues { (_, lines) -> lines.maxByOrNull { it.id ?: 0L }!! }

val inventoryLotIds = lotLines.mapNotNull { it.inventoryLot?.id }.distinct()
val inventoryLotById =
if (inventoryLotIds.isNotEmpty()) {
inventoryLotRepository.findAllByIdIn(inventoryLotIds).associateByNotNull { it.id }
} else {
emptyMap()
}
val itemIds = inventoryLotById.values.mapNotNull { it.item?.id }.distinct()
val inventoryByItemId =
if (itemIds.isNotEmpty()) {
inventoryRepository.findAllByItemIdInAndDeletedIsFalse(itemIds)
.groupBy { it.item?.id ?: 0L }
.mapValues { (_, list) -> list.minByOrNull { it.id ?: Long.MAX_VALUE }!! }
} else {
emptyMap()
}
val recordIds = records.mapNotNull { it.id }.distinct()
val stockTakeLineByRecordId =
if (recordIds.isNotEmpty()) {
stockTakeLineRepository.findAllByStockTakeRecord_IdInAndDeletedIsFalse(recordIds)
.associateByNotNull { it.stockTakeRecord?.id }
} else {
emptyMap()
}

return BatchAdjustmentCache(
inventoryLotLineByWarehouseLot = inventoryLotLineByWarehouseLot,
inventoryLotById = inventoryLotById,
inventoryByItemId = inventoryByItemId,
stockTakeLineByRecordId = stockTakeLineByRecordId
)
}

private inline fun <T : Any, K : Any> Iterable<T>.associateByNotNull(keySelector: (T) -> K?): Map<K, T> {
val destination = LinkedHashMap<K, T>()
for (element in this) {
val key = keySelector(element) ?: continue
destination[key] = element
}
return destination
}
open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTakeRecord {
println("updateStockTakeRecordStatusToNotMatch called with stockTakeRecordId: $stockTakeRecordId")


+ 26
- 1
src/main/java/com/ffii/fpsms/modules/stock/web/StockTakeRecordController.kt Zobrazit soubor

@@ -29,7 +29,10 @@ class StockTakeRecordController(
@RequestParam(required = false, defaultValue = "0") pageNum: Int,
@RequestParam(required = false, defaultValue = "6") pageSize: Int,
@RequestParam(required = false) sectionDescription: String?,
@RequestParam(required = false) stockTakeSections: String?
@RequestParam(required = false) stockTakeSections: String?,
@RequestParam(required = false) status: String?,
@RequestParam(required = false) area: String?,
@RequestParam(required = false) storeId: String?
): RecordsRes<AllPickedStockTakeListReponse> {
var all = stockOutRecordService.AllPickedStockTakeList()
if (sectionDescription != null && sectionDescription != "All") {
@@ -46,6 +49,28 @@ 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
}
}
if (!area.isNullOrBlank()) {
val areaKeyword = area.trim()
all = all.filter { it.warehouseArea?.contains(areaKeyword, ignoreCase = true) == true }
}
if (!storeId.isNullOrBlank() && storeId != "All") {
val storeIdKeyword = storeId.trim()
all = all.filter {
it.storeId?.equals(storeIdKeyword, ignoreCase = true) == true ||
it.storeId?.contains(storeIdKeyword, ignoreCase = true) == true
}
}
val total = all.size
val fromIndex = pageNum * pageSize
val toIndex = minOf(fromIndex + pageSize, total)


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/web/model/StockTakeRecordReponse.kt Zobrazit soubor

@@ -27,6 +27,8 @@ data class AllPickedStockTakeListReponse(
@JsonFormat(pattern = "yyyy-MM-dd")
val planStartDate: LocalDate?,
val stockTakeSectionDescription: String?,
val warehouseArea: String?,
val storeId: String?,
)
data class InventoryLotDetailResponse(
val id: Long,


+ 8
- 0
src/main/resources/db/changelog/changes/20260429_01_Enson/01_alter_stock_take.sql Zobrazit soubor

@@ -0,0 +1,8 @@
--liquibase formatted sql

--changeset Enson:20260429-01
CREATE INDEX idx_stock_out_stockTakeId_deleted
ON stock_out (stockTakeId, deleted);

CREATE INDEX idx_stock_take_line_record_deleted
ON stock_take_line (stockTakeRecordId, deleted);

Načítá se…
Zrušit
Uložit