From 8b4802eb31670067601cea0573471d2e2102c181 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 22 Oct 2026 16:23:45 +0800 Subject: [PATCH] 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 --- .../scheduler/service/SchedulerService.kt | 5 +- .../service/PickExecutionIssueService.kt | 71 +++++++++- .../web/models/SubmitIssueRequest.kt | 6 +- .../entity/InventoryLotLineRepository.kt | 24 ++++ .../stock/entity/StockTakeRecordRepository.kt | 2 + .../stock/service/InventoryLotLineService.kt | 17 ++- .../stock/service/StockTakeRecordService.kt | 133 +++++++++++++++++- 7 files changed, 242 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index 7322508c..0ac6aa97 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -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) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index 5fd8fcbe..821fec94 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -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): Set> { + 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 ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt index 049dd152..2dc80fe6 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/web/models/SubmitIssueRequest.kt @@ -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, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index bc738342..81b6fedd 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -287,6 +287,30 @@ WHERE ill.id = :id @Param("storeIds") storeIds: List ): List + /** + * Lot lines that the daily expiry job will mark unavailable + * ([InventoryLot.expiryDate] < 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 + @Modifying @Transactional @Query( diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt index b1aed946..cf2fab65 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/StockTakeRecordRepository.kt @@ -19,6 +19,8 @@ interface StockTakeRecordRepository : AbstractRepository warehouseIds: Collection ): List + fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection): List + @Query( """ diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index f28bc51d..4c64f86f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -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 { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt index 37966ce0..095e0eea 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockTakeRecordService.kt @@ -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): 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 { 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 { 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