lot expiry after create round before appove tonigh lot will expiry can not do expiry handle ,block while stock taking relatestockTakeUpdate
| @@ -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) | |||
| } | |||
| @@ -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 | |||
| ) | |||
| } | |||
| @@ -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, | |||
| @@ -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] < 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( | |||
| @@ -19,6 +19,8 @@ interface StockTakeRecordRepository : AbstractRepository<StockTakeRecord, Long> | |||
| warehouseIds: Collection<Long> | |||
| ): List<StockTakeRecord> | |||
| fun findAllByLotIdInAndDeletedIsFalse(lotIds: Collection<Long>): List<StockTakeRecord> | |||
| @Query( | |||
| """ | |||
| @@ -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> { | |||
| @@ -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 | |||