Ver a proveniência

remove bar from round card lsit

lot expiry after create round before appove
tonigh lot will expiry can not do expiry handle ,block while stock taking relate
stockTakeUpdate
CANCERYS\kw093 há 1 semana
ascendente
cometimento
8b4802eb31
7 ficheiros alterados com 242 adições e 16 eliminações
  1. +4
    -1
      src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt
  2. +65
    -6
      src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt
  3. +5
    -1
      src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt
  4. +24
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  5. +2
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt
  6. +16
    -1
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  7. +126
    -7
      src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt

+ 4
- 1
src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt Ver ficheiro

@@ -582,7 +582,10 @@ open class SchedulerService(
open fun markExpiredInventoryLotLinesAsUnavailable() {
try {
val updatedRows = inventoryLotLineService.markExpiredLotLinesAsUnavailable()
logger.info("Scheduler - Inventory lot expiry update done, rows updated=$updatedRows")
logger.info(
"Scheduler - Inventory lot expiry update done, rows updated=$updatedRows " +
"(also soft-deletes open stock-take records for those lots)"
)
} catch (e: Exception) {
logger.error("Scheduler - Inventory lot expiry update failed: ${e.message}", e)
}


+ 65
- 6
src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt Ver ficheiro

@@ -52,6 +52,7 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository
import org.springframework.beans.factory.annotation.Value
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest
import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest
import com.ffii.fpsms.modules.stock.entity.StockTakeRecordRepository
@Service
open class PickExecutionIssueService(
private val pickExecutionIssueRepository: PickExecutionIssueRepository,
@@ -72,7 +73,8 @@ open class PickExecutionIssueService(
private val stockLedgerRepository: StockLedgerRepository,
private val deliveryOrderRepository: DeliveryOrderRepository,
private val suggestedPickLotRepository: SuggestPickLotRepository,
private val doPickOrderRepository: DoPickOrderRepository
private val doPickOrderRepository: DoPickOrderRepository,
private val stockTakeRecordRepository: StockTakeRecordRepository,

) {
// 配置属性:是否在批次拒绝时自动重新建议批次(默认为 false,不自动重新建议)
@@ -1833,11 +1835,30 @@ open fun getExpiryItemList(
itemName = normalizedItemName,
lotNo = normalizedLotNo,
)

val openStockTakePairs = openStockTakeLotWarehousePairs(
lotLines.mapNotNull { it.inventoryLot?.id }
)
return lotLines.map { lotLine ->
val lot = lotLine.inventoryLot
val item = lot?.item // Get item from inventoryLot
val expiry = lot?.expiryDate
val dueOrPast = expiry != null && !expiry.isAfter(today)
val expiresToday = expiry != null && expiry.isEqual(today)
val lotId = lot?.id
val warehouseId = lotLine.warehouse?.id
val stockTakeBlocked = expiresToday &&
lotId != null &&
warehouseId != null &&
(lotId to warehouseId) in openStockTakePairs
val canHandle = dueOrPast && !stockTakeBlocked
val blockKey = when {
canHandle -> null
expiry == null || expiry.isAfter(today) -> "FUTURE"
stockTakeBlocked -> "STOCK_TAKE_OPEN"
else -> null
}
ExpiryItemResponse(
id = lotLine.id ?: 0L,
itemId = item?.id ?: 0L,
@@ -1849,11 +1870,36 @@ open fun getExpiryItemList(
expiryDate = expiry,
remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO),
uomDesc = lotLine.stockUom?.uom?.udfudesc,
canHandle = expiry != null && !expiry.isAfter(today),
canHandle = canHandle,
canHandleBlockKey = blockKey,
)
}
}

/** Open-round stock take records keyed by (inventoryLotId, warehouseId). */
private fun openStockTakeLotWarehousePairs(lotIds: Collection<Long>): Set<Pair<Long, Long>> {
val ids = lotIds.filter { it > 0L }.distinct()
if (ids.isEmpty()) return emptySet()
return stockTakeRecordRepository.findAllByLotIdInAndDeletedIsFalse(ids)
.mapNotNull { rec ->
val header = rec.stockTake ?: return@mapNotNull null
if (header.deleted || header.status?.isOpen() != true) return@mapNotNull null
val lotId = rec.lotId ?: return@mapNotNull null
val warehouseId = rec.warehouse?.id ?: return@mapNotNull null
lotId to warehouseId
}
.toSet()
}

/** 今日到期且仍在 open 盤點中 → 不可過期處置(已過期仍可處置)。 */
private fun isExpiresTodayBlockedByOpenStockTake(lotLine: InventoryLotLine, today: LocalDate = LocalDate.now()): Boolean {
val expiry = lotLine.inventoryLot?.expiryDate ?: return false
if (!expiry.isEqual(today)) return false
val lotId = lotLine.inventoryLot?.id ?: return false
val warehouseId = lotLine.warehouse?.id ?: return false
return (lotId to warehouseId) in openStockTakeLotWarehousePairs(listOf(lotId))
}

@Transactional(rollbackFor = [Exception::class])
open fun submitMissItem(request: SubmitIssueRequest): MessageResponse {
try {
@@ -2030,6 +2076,17 @@ open fun submitExpiryItem(request: SubmitExpiryRequest): MessageResponse {
errorPosition = null
)
}

if (isExpiresTodayBlockedByOpenStockTake(lotLine, today)) {
return MessageResponse(
id = null,
name = "Error",
code = "STOCK_TAKE_OPEN",
type = "stock_issue",
message = "Expires today and is in an open stock take; cannot dispose until stock take finishes or the lot leaves the round",
errorPosition = null
)
}
val inQty = lotLine.inQty ?: BigDecimal.ZERO
val outQty = lotLine.outQty ?: BigDecimal.ZERO
@@ -2214,19 +2271,21 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse {
@Transactional(rollbackFor = [Exception::class])
open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse {
try {
val today = LocalDate.now()
val lotLines = inventoryLotLineRepository.findAllById(request.lotLineIds)
.filter {
val lot = it.inventoryLot
val today = LocalDate.now()
lot?.expiryDate != null &&
lot.expiryDate!!.let { !it.isAfter(today) } &&
(it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO)
lot.expiryDate!!.let { d -> !d.isAfter(today) } &&
(it.inQty ?: BigDecimal.ZERO) > (it.outQty ?: BigDecimal.ZERO) &&
!isExpiresTodayBlockedByOpenStockTake(it, today)
}

if (lotLines.isEmpty()) {
return MessageResponse(
id = null, name = "Error", code = "EMPTY", type = "stock_issue",
message = "No valid expiry items to submit", errorPosition = null
message = "No valid expiry items to submit (today-expiry lots in open stock take are blocked)",
errorPosition = null
)
}



+ 5
- 1
src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt Ver ficheiro

@@ -32,8 +32,12 @@ data class ExpiryItemResponse(
val expiryDate: LocalDate?,
val remainingQty: BigDecimal,
val uomDesc: String?,
/** True when expiryDate is today or earlier. */
/** True when expiryDate is today or earlier, and not blocked by open stock take (today-expiry). */
val canHandle: Boolean,
/**
* When [canHandle] is false: `FUTURE` (not yet due) or `STOCK_TAKE_OPEN` (expires today but in open stock take).
*/
val canHandleBlockKey: String? = null,
)
data class LotIssueDetailRequest(
val lotId: Long,


+ 24
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt Ver ficheiro

@@ -287,6 +287,30 @@ WHERE ill.id = :id
@Param("storeIds") storeIds: List<String>
): List<InventoryLotLine>

/**
* Lot lines that the daily expiry job will mark unavailable
* ([InventoryLot.expiryDate] &lt; today and status not yet unavailable).
*/
@EntityGraph(
type = EntityGraph.EntityGraphType.FETCH,
attributePaths = ["inventoryLot", "warehouse"]
)
@Query(
"""
SELECT ill FROM InventoryLotLine ill
JOIN ill.inventoryLot il
WHERE ill.deleted = false
AND il.deleted = false
AND il.expiryDate IS NOT NULL
AND il.expiryDate < :today
AND (ill.status IS NULL OR ill.status <> :unavailable)
"""
)
fun findExpiredLotLinesNotYetUnavailable(
@Param("today") today: LocalDate,
@Param("unavailable") unavailable: InventoryLotLineStatus,
): List<InventoryLotLine>

@Modifying
@Transactional
@Query(


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt Ver ficheiro

@@ -19,6 +19,8 @@ interface StockTakeRecordRepository : AbstractRepository<StockTakeRecord, Long>
warehouseIds: Collection<Long>
): List<StockTakeRecord>

fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection<Long>): List<StockTakeRecord>

@Query(
"""


+ 16
- 1
src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt Ver ficheiro

@@ -64,10 +64,25 @@ open class InventoryLotLineService(
@Lazy
private val jobOrderService: JobOrderService,
private val stockTakeLocationGuard: StockTakeLocationGuard,
@Lazy
private val stockTakeRecordService: StockTakeRecordService,
) {
/**
* Daily job: mark expired lot lines UNAVAILABLE, then drop matching open stock-take
* placeholders so picker/approver and section status no longer see those lots.
*/
@Transactional
open fun markExpiredLotLinesAsUnavailable(): Int {
return inventoryLotLineRepository.markExpiredLotLinesAsUnavailable()
val today = LocalDate.now()
val candidates = inventoryLotLineRepository.findExpiredLotLinesNotYetUnavailable(
today,
InventoryLotLineStatus.UNAVAILABLE,
)
val updatedRows = inventoryLotLineRepository.markExpiredLotLinesAsUnavailable()
if (candidates.isNotEmpty()) {
stockTakeRecordService.softDeleteOpenStockTakeRecordsForExpiredLotLines(candidates)
}
return updatedRows
}

open fun findById(id: Long): Optional<InventoryLotLine> {


+ 126
- 7
src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt Ver ficheiro

@@ -496,8 +496,17 @@ open class StockTakeRecordService(
return@forEach
}

// 修復:曾用整輪代表 id 升錯單頭時,本區明細已全 completed 卻仍停在 approving
val status = if (latestStockTake != null) {
latestStockTake.status?.value ?: "pending"
val headerId = latestStockTake.id
if (headerId != null && latestStockTake.status == StockTakeStatus.APPROVING) {
checkAndUpdateStockTakeStatus(headerId, stockTakeSection)
stockTakeRepository.findByIdAndDeletedIsFalse(headerId)?.status?.value
?: latestStockTake.status?.value
?: "pending"
} else {
latestStockTake.status?.value ?: "pending"
}
} else {
"no_cycle"
}
@@ -1186,6 +1195,61 @@ open class StockTakeRecordService(
.sorted()
}

/**
* 排程將過期 lot line 標成 UNAVAILABLE 後呼叫:對仍 open 的盤點輪次,
* soft-delete 對應且尚未 completed 的 [StockTakeRecord]/[StockTakeLine],
* 再重算受影響區域單頭狀態。
*/
@Transactional
open fun softDeleteOpenStockTakeRecordsForExpiredLotLines(lotLines: List<InventoryLotLine>): Int {
if (lotLines.isEmpty()) return 0
val pairs = lotLines.mapNotNull { ill ->
val lotId = ill.inventoryLot?.id ?: return@mapNotNull null
val warehouseId = ill.warehouse?.id ?: return@mapNotNull null
lotId to warehouseId
}.toSet()
if (pairs.isEmpty()) return 0

val lotIds = pairs.map { it.first }.distinct()
val toRemove = stockTakeRecordRepository.findAllByLotIdInAndDeletedIsFalse(lotIds).filter { rec ->
val lotId = rec.lotId ?: return@filter false
val warehouseId = rec.warehouse?.id ?: return@filter false
if ((lotId to warehouseId) !in pairs) return@filter false
if (rec.status.equals("completed", ignoreCase = true)) return@filter false
val header = rec.stockTake ?: return@filter false
!header.deleted && header.status?.isOpen() == true
}
if (toRemove.isEmpty()) return 0

val recordIds = toRemove.mapNotNull { it.id }
toRemove.forEach { it.deleted = true }
stockTakeRecordRepository.saveAll(toRemove)

if (recordIds.isNotEmpty()) {
val lines = stockTakeLineRepository.findAllByStockTakeRecord_IdInAndDeletedIsFalse(recordIds)
lines.forEach { it.deleted = true }
if (lines.isNotEmpty()) {
stockTakeLineRepository.saveAll(lines)
}
}

val headersToRefresh = toRemove.mapNotNull { rec ->
val stId = rec.stockTake?.id ?: return@mapNotNull null
val section = rec.stockTakeSection?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
stId to section
}.distinct()
headersToRefresh.forEach { (stId, section) ->
checkAndUpdateStockTakeStatus(stId, section)
}

logger.info(
"softDeleteOpenStockTakeRecordsForExpiredLotLines: removed {} record(s), refreshed {} section header(s)",
toRemove.size,
headersToRefresh.size,
)
return toRemove.size
}

open fun listStockTakeRoundCards(yearMonth: String?): StockTakeRoundCardsRes {
val all = stockTakeRepository.findAll().filter { !it.deleted }
if (all.isEmpty()) {
@@ -1843,6 +1907,13 @@ open class StockTakeRecordService(
this.stockTakerName = user?.name
this.stockTakeEndTime = java.time.LocalDateTime.now()
this.varianceQty = varianceQty
// 防舊資料:重新盤點後不得沿用先前核准結果
this.approverId = null
this.approverName = null
this.approverStockTakeQty = null
this.approverBadQty = null
this.approverTime = null
this.lastSelect = null
}
existingRecord
}
@@ -2098,10 +2169,14 @@ open class StockTakeRecordService(

/**
* FP-MTMS Version Checklist | Functions Ref. No. 70 | v1.0.0 | 2026-08-31
*
* 依 [stockTakeSection] 在同一輪次內解析「該區域」單頭再升狀態。
* 不可直接信任傳入的 stockTakeId:Overall 審核會傳整輪代表 id,若寫錯單頭會造成
* 本區已全數 completed 卻仍停在 approving(狀態寫到別區)。
*/
open fun checkAndUpdateStockTakeStatus(stockTakeId: Long, stockTakeSection: String): Map<String, Any> {
try {
val stockTake = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId)
val stockTakeHint = stockTakeRepository.findByIdAndDeletedIsFalse(stockTakeId)
?: return mapOf("success" to false, "message" to "Stock take not found")

// 1. 获取该 section 下的所有 warehouse
@@ -2112,7 +2187,21 @@ open class StockTakeRecordService(

val warehouseIds = warehouses.mapNotNull { it.id }.toSet()

val roundId = stockTake.stockTakeRoundId ?: stockTake.id
val roundId = stockTakeHint.stockTakeRoundId ?: stockTakeHint.id
?: return mapOf("success" to false, "message" to "Stock take id is null")

val sectionHeaders = stockTakeRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId)
.filter { !it.deleted && it.stockTakeSection == stockTakeSection }
val stockTake = sectionHeaders.maxWithOrNull(
compareBy<StockTake> { it.actualStart ?: it.planStart ?: LocalDateTime.MIN }
.thenBy { it.id ?: 0L }
) ?: stockTakeHint.takeIf { it.stockTakeSection == stockTakeSection }
?: return mapOf(
"success" to false,
"message" to "Stock take header not found for section $stockTakeSection in round $roundId"
)

val sectionHeaderId = stockTake.id
?: return mapOf("success" to false, "message" to "Stock take id is null")

var stockTakeRecords = stockTakeRecordRepository.findAllByStockTakeRoundIdAndDeletedIsFalse(roundId)
@@ -2126,7 +2215,7 @@ open class StockTakeRecordService(
stockTakeRecords = stockTakeRecordRepository.findAll()
.filter {
!it.deleted &&
it.stockTake?.id == stockTakeId &&
it.stockTake?.id == sectionHeaderId &&
it.warehouse?.id in warehouseIds &&
it.stockTakeSection == stockTakeSection
}
@@ -2144,7 +2233,10 @@ open class StockTakeRecordService(
stockTake.planEnd = now
stockTake.actualEnd = now
stockTakeRepository.save(stockTake)
println("Stock take $stockTakeId status updated to COMPLETED - all records are completed")
println(
"Stock take $sectionHeaderId (section=$stockTakeSection, hintId=$stockTakeId) " +
"status updated to COMPLETED - all records are completed"
)
return mapOf(
"success" to true,
"message" to "Stock take status updated to COMPLETED",
@@ -2155,7 +2247,10 @@ open class StockTakeRecordService(
stockTake.actualEnd = now
stockTakeRepository.save(stockTake)

println("Stock take $stockTakeId status updated to APPROVING - all records are pass")
println(
"Stock take $sectionHeaderId (section=$stockTakeSection, hintId=$stockTakeId) " +
"status updated to APPROVING - all records are pass"
)
return mapOf(
"success" to true,
"message" to "Stock take status updated to APPROVING",
@@ -2272,7 +2367,9 @@ open class StockTakeRecordService(
}
val stockTakeSection = savedRecord.stockTakeSection
if (stockTakeSection != null) {
checkAndUpdateStockTakeStatus(stockTakeId, stockTakeSection)
// 用明細所屬區域單頭 id(Overall 審核傳入的可能是整輪代表 id)
val statusHeaderId = savedRecord.stockTake?.id ?: stockTakeId
checkAndUpdateStockTakeStatus(statusHeaderId, stockTakeSection)
}

return savedRecord
@@ -3248,8 +3345,30 @@ open fun updateStockTakeRecordStatusToNotMatch(stockTakeRecordId: Long): StockTa
stockTakeRecord.apply {
this.status = "notMatch"
// 重新盤點後必須再審核;清掉舊核准欄位,避免 UI 仍顯示在 Approved、單頭卻升不了 completed
this.approverId = null
this.approverName = null
this.approverStockTakeQty = null
this.approverBadQty = null
this.approverTime = null
this.lastSelect = null
this.pickerSecondStockTakeQty = null
this.pickerSecondBadQty = null
this.stockTakeEndTime = null
}
val saved = stockTakeRecordRepository.save(stockTakeRecord)

saved.id?.let { rid ->
stockTakeLineRepository.findByStockTakeRecord_IdAndDeletedIsFalse(rid)?.let { line ->
line.apply {
this.finalQty = null
this.status = StockTakeLineStatus.PENDING
this.completeDate = null
}
stockTakeLineRepository.save(line)
}
}

val stockTake = saved.stockTake
if (stockTake != null && !stockTake.deleted) {
stockTake.status = StockTakeStatus.STOCKTAKING


Carregando…
Cancelar
Guardar