| @@ -114,6 +114,13 @@ open class StockInLine : BaseEntity<Long>() { | |||||
| @Column(name = "remarks") | @Column(name = "remarks") | ||||
| open var remarks: String? = null | open var remarks: String? = null | ||||
| /** Set when job-order auto putaway is waiting for an open stock-take round to close. */ | |||||
| @Column(name = "pendingAutoPutawayWarehouseId") | |||||
| open var pendingAutoPutawayWarehouseId: Long? = null | |||||
| @Column(name = "pendingAutoPutawayQty", precision = 14, scale = 2) | |||||
| open var pendingAutoPutawayQty: BigDecimal? = null | |||||
| @Column(name = "type") | @Column(name = "type") | ||||
| open var type: String? = null | open var type: String? = null | ||||
| @@ -63,7 +63,9 @@ fun searchStockInLines( | |||||
| //AND sil.type IS NOT NULL | //AND sil.type IS NOT NULL | ||||
| @Query("SELECT sil FROM StockInLine sil WHERE sil.item.id IN :itemIds AND sil.deleted = false") | @Query("SELECT sil FROM StockInLine sil WHERE sil.item.id IN :itemIds AND sil.deleted = false") | ||||
| fun findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<StockInLine> | fun findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<StockInLine> | ||||
| fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? | |||||
| fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? | |||||
| fun findAllByPendingAutoPutawayWarehouseIdIsNotNullAndDeletedIsFalse(): List<StockInLine> | |||||
| /** Loads stock-in lines with item and purchaseOrderLine.item so bDesc/bDesc_en (item name) are available for M18 GRN. */ | /** Loads stock-in lines with item and purchaseOrderLine.item so bDesc/bDesc_en (item name) are available for M18 GRN. */ | ||||
| @Query(""" | @Query(""" | ||||
| @@ -21,4 +21,40 @@ interface StockTakeRepository : AbstractRepository<StockTake, Long> { | |||||
| "SELECT COALESCE(MAX(st.stockTakeRoundId), 0) FROM StockTake st WHERE st.deleted = false" | "SELECT COALESCE(MAX(st.stockTakeRoundId), 0) FROM StockTake st WHERE st.deleted = false" | ||||
| ) | ) | ||||
| fun findMaxStockTakeRoundId(): Long | fun findMaxStockTakeRoundId(): Long | ||||
| @Query( | |||||
| value = """ | |||||
| SELECT DISTINCT stockTakeSection FROM stock_take | |||||
| WHERE deleted = 0 | |||||
| AND status IN ('pending', 'stockTaking', 'approving') | |||||
| AND stockTakeSection IS NOT NULL | |||||
| AND stockTakeSection <> '' | |||||
| """, | |||||
| nativeQuery = true, | |||||
| ) | |||||
| fun findOpenStockTakeSections(): List<String> | |||||
| @Query( | |||||
| value = """ | |||||
| SELECT | |||||
| id AS id, | |||||
| stockTakeSection AS stockTakeSection, | |||||
| stockTakeRoundMode AS stockTakeRoundMode, | |||||
| stockTakeRoundId AS stockTakeRoundId | |||||
| FROM stock_take | |||||
| WHERE deleted = 0 | |||||
| AND status IN ('pending', 'stockTaking', 'approving') | |||||
| AND stockTakeSection IS NOT NULL | |||||
| AND stockTakeSection <> '' | |||||
| """, | |||||
| nativeQuery = true, | |||||
| ) | |||||
| fun findOpenStockTakeRows(): List<OpenStockTakeRow> | |||||
| } | |||||
| interface OpenStockTakeRow { | |||||
| fun getId(): Long | |||||
| fun getStockTakeSection(): String? | |||||
| fun getStockTakeRoundMode(): String? | |||||
| fun getStockTakeRoundId(): Long? | |||||
| } | } | ||||
| @@ -61,7 +61,8 @@ open class InventoryLotLineService( | |||||
| private val inventoryRepository: InventoryRepository, | private val inventoryRepository: InventoryRepository, | ||||
| private val printerService: PrinterService, | private val printerService: PrinterService, | ||||
| @Lazy | @Lazy | ||||
| private val jobOrderService: JobOrderService | |||||
| private val jobOrderService: JobOrderService, | |||||
| private val stockTakeLocationGuard: StockTakeLocationGuard, | |||||
| ) { | ) { | ||||
| @Transactional | @Transactional | ||||
| open fun markExpiredLotLinesAsUnavailable(): Int { | open fun markExpiredLotLinesAsUnavailable(): Int { | ||||
| @@ -387,6 +388,7 @@ open class InventoryLotLineService( | |||||
| open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse { | open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse { | ||||
| try { | try { | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | ||||
| stockTakeLocationGuard.assertLotLineMovable(inventoryLotLine) | |||||
| // Handle quantity updates based on operation | // Handle quantity updates based on operation | ||||
| var newHoldQty = inventoryLotLine.holdQty ?: BigDecimal.ZERO | var newHoldQty = inventoryLotLine.holdQty ?: BigDecimal.ZERO | ||||
| @@ -111,6 +111,7 @@ open class StockInLineService( | |||||
| @Value("\${scheduler.m18Grn.createEnabled:false}") private val m18GrnCreateEnabled: Boolean, | @Value("\${scheduler.m18Grn.createEnabled:false}") private val m18GrnCreateEnabled: Boolean, | ||||
| /** Recent window for PO stock-in alerts (pending / receiving) in nav / list UI. */ | /** Recent window for PO stock-in alerts (pending / receiving) in nav / list UI. */ | ||||
| @Value("\${fpsms.purchase-stock-in-alert.lookback-days:7}") private val purchaseStockInAlertLookbackDays: Int, | @Value("\${fpsms.purchase-stock-in-alert.lookback-days:7}") private val purchaseStockInAlertLookbackDays: Int, | ||||
| private val stockTakeLocationGuard: StockTakeLocationGuard, | |||||
| // private val bomOutputQtyService: BomOutputQtyService, | // private val bomOutputQtyService: BomOutputQtyService, | ||||
| ) : AbstractBaseEntityService<StockInLine, Long, StockInLineRepository>(jdbcDao, stockInLineRepository) { | ) : AbstractBaseEntityService<StockInLine, Long, StockInLineRepository>(jdbcDao, stockInLineRepository) { | ||||
| @@ -1032,6 +1033,10 @@ open class StockInLineService( | |||||
| } | } | ||||
| _logStep("apply_fields", _tApplyFields) | _logStep("apply_fields", _tApplyFields) | ||||
| if (request.status == StockInLineStatus.RECEIVED.status) { | if (request.status == StockInLineStatus.RECEIVED.status) { | ||||
| val held = holdAutoPutawayIfSectionInStockTake(request, stockInLine) | |||||
| if (held != null) { | |||||
| return held | |||||
| } | |||||
| val _tPutAwayTotal = System.nanoTime() | val _tPutAwayTotal = System.nanoTime() | ||||
| // Putaway | // Putaway | ||||
| val _tSaveLotLines = System.nanoTime() | val _tSaveLotLines = System.nanoTime() | ||||
| @@ -1439,8 +1444,99 @@ open class StockInLineService( | |||||
| logger.info("[SIL.ledger timing] TOTAL took ${String.format("%.3f", (System.nanoTime() - _t0) / 1_000_000.0)} ms") | logger.info("[SIL.ledger timing] TOTAL took ${String.format("%.3f", (System.nanoTime() - _t0) / 1_000_000.0)} ms") | ||||
| } | } | ||||
| /** | |||||
| * Job-order WIP / FG / FA auto putaway into a section that is still in an open stock-take round | |||||
| * is stored on the stock-in line and posted when that round closes. | |||||
| * Any other putaway into that section is rejected. | |||||
| */ | |||||
| private fun holdAutoPutawayIfSectionInStockTake( | |||||
| request: SaveStockInLineRequest, | |||||
| stockInLine: StockInLine, | |||||
| ): MessageResponse? { | |||||
| if (request.releaseHeldPutaway) return null | |||||
| if (request.stockTakeLineId != null || stockInLine.stockTakeLine != null) return null | |||||
| if (stockInLine.type.equals("TKE", ignoreCase = true)) return null | |||||
| val targets = (request.inventoryLotLines ?: emptyList()) | |||||
| .mapNotNull { line -> | |||||
| val warehouseId = line.warehouseId ?: return@mapNotNull null | |||||
| if (line.qty <= BigDecimal.ZERO) return@mapNotNull null | |||||
| warehouseId to line.qty | |||||
| } | |||||
| if (targets.isEmpty()) return null | |||||
| val itemId = stockInLine.item?.id ?: request.itemId | |||||
| val blocked = targets.firstOrNull { stockTakeLocationGuard.blockMessage(it.first, itemId) != null } | |||||
| ?: return null | |||||
| if (isJobOrderAutoPutaway(stockInLine) && targets.size == 1) { | |||||
| val (warehouseId, qty) = targets.first() | |||||
| stockInLine.pendingAutoPutawayWarehouseId = warehouseId | |||||
| stockInLine.pendingAutoPutawayQty = qty | |||||
| stockInLine.jobOrder?.let { jo -> | |||||
| jo.status = JobOrderStatus.COMPLETED | |||||
| jobOrderRepository.save(jo) | |||||
| } | |||||
| val saved = saveAndFlush(stockInLine) | |||||
| val lineInfo = stockInLineRepository.findStockInLineInfoByIdAndDeletedFalse(saved.id!!) | |||||
| return MessageResponse( | |||||
| id = saved.id, | |||||
| code = "PUTAWAY_HELD", | |||||
| name = null, | |||||
| type = "putaway_held", | |||||
| message = "盤點進行中,自動上架將於盤點結束後入庫", | |||||
| errorPosition = null, | |||||
| entity = listOf(lineInfo), | |||||
| ) | |||||
| } | |||||
| stockTakeLocationGuard.assertWarehouseMovable(blocked.first, itemId) | |||||
| return null | |||||
| } | |||||
| private fun isJobOrderAutoPutaway(stockInLine: StockInLine): Boolean { | |||||
| if (stockInLine.jobOrder == null) return false | |||||
| val bom = stockInLine.jobOrder?.bom?.description | |||||
| if (bom == "WIP" || bom == "FG") return true | |||||
| val itemNo = (stockInLine.itemNo ?: stockInLine.item?.code ?: "").uppercase() | |||||
| return itemNo.contains("FA") | |||||
| } | |||||
| /** Post auto putaways that were waiting on sections closed with this round. */ | |||||
| @Transactional | |||||
| open fun releasePendingAutoPutaways(sections: Collection<String>) { | |||||
| val wanted = sections.map { it.trim().lowercase() }.filter { it.isNotEmpty() }.toSet() | |||||
| if (wanted.isEmpty()) return | |||||
| val pending = stockInLineRepository.findAllByPendingAutoPutawayWarehouseIdIsNotNullAndDeletedIsFalse() | |||||
| for (sil in pending) { | |||||
| val warehouseId = sil.pendingAutoPutawayWarehouseId ?: continue | |||||
| val warehouse = warehouseRepository.findById(warehouseId).orElse(null) ?: continue | |||||
| val section = warehouse.stockTakeSection?.trim()?.lowercase() ?: continue | |||||
| if (section !in wanted) continue | |||||
| val qty = sil.pendingAutoPutawayQty ?: sil.acceptedQty ?: continue | |||||
| val itemId = sil.item?.id ?: continue | |||||
| sil.pendingAutoPutawayWarehouseId = null | |||||
| sil.pendingAutoPutawayQty = null | |||||
| saveAndFlush(sil) | |||||
| update( | |||||
| SaveStockInLineRequest( | |||||
| id = sil.id, | |||||
| itemId = itemId, | |||||
| acceptedQty = sil.acceptedQty ?: qty, | |||||
| acceptQty = qty, | |||||
| status = StockInLineStatus.RECEIVED.status, | |||||
| expiryDate = sil.expiryDate, | |||||
| receiptDate = sil.receiptDate?.toLocalDate(), | |||||
| productionDate = sil.productionDate?.toLocalDate(), | |||||
| warehouseId = warehouseId, | |||||
| releaseHeldPutaway = true, | |||||
| inventoryLotLines = listOf( | |||||
| SaveInventoryLotLineForSil(qty = qty, warehouseId = warehouseId), | |||||
| ), | |||||
| ) | |||||
| ) | |||||
| } | |||||
| } | |||||
| @Transactional | @Transactional | ||||
| open fun createStockIn(request: StockInRequest): StockInLine { | open fun createStockIn(request: StockInRequest): StockInLine { | ||||
| stockTakeLocationGuard.assertWarehouseMovable(request.warehouseId, request.itemId) | |||||
| // Step 1: Create a row of stock_in | // Step 1: Create a row of stock_in | ||||
| val prefix = "SI" | val prefix = "SI" | ||||
| val midfix = CodeGenerator.DEFAULT_MIDFIX | val midfix = CodeGenerator.DEFAULT_MIDFIX | ||||
| @@ -1531,6 +1627,9 @@ open class StockInLineService( | |||||
| request: StockInRequest, | request: StockInRequest, | ||||
| existingInventoryLotLine: InventoryLotLine | existingInventoryLotLine: InventoryLotLine | ||||
| ): StockInLine { | ): StockInLine { | ||||
| val movingItemId = existingInventoryLotLine.inventoryLot?.item?.id ?: request.itemId | |||||
| stockTakeLocationGuard.assertWarehouseMovable(existingInventoryLotLine.warehouse?.id, movingItemId) | |||||
| stockTakeLocationGuard.assertWarehouseMovable(request.warehouseId, movingItemId) | |||||
| // Step 1: Create StockIn | // Step 1: Create StockIn | ||||
| val prefix = "SI" | val prefix = "SI" | ||||
| val midfix = CodeGenerator.DEFAULT_MIDFIX | val midfix = CodeGenerator.DEFAULT_MIDFIX | ||||
| @@ -82,6 +82,7 @@ private val inventoryLotLineService: InventoryLotLineService, | |||||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | private val pickExecutionIssueRepository: PickExecutionIssueRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| @Lazy private val doReplenishmentService: DoReplenishmentService, | @Lazy private val doReplenishmentService: DoReplenishmentService, | ||||
| private val stockTakeLocationGuard: StockTakeLocationGuard, | |||||
| ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ||||
| @PersistenceContext | @PersistenceContext | ||||
| @@ -208,6 +209,7 @@ val existingStockOutLine = stockOutLineRepository.findByPickOrderLineIdAndInvent | |||||
| ) | ) | ||||
| val item = itemRepository.findById(pickOrderLine.item!!.id!!).orElseThrow() | val item = itemRepository.findById(pickOrderLine.item!!.id!!).orElseThrow() | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | ||||
| stockTakeLocationGuard.assertLotLineMovable(inventoryLotLine) | |||||
| val stockOutLine = StockOutLine() | val stockOutLine = StockOutLine() | ||||
| .apply { | .apply { | ||||
| @@ -257,6 +259,7 @@ fun handleQc(stockOutLine: StockOutLine, request: UpdateStockOutLineRequest): Li | |||||
| val zero = BigDecimal.ZERO | val zero = BigDecimal.ZERO | ||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val targetLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId!!).orElseThrow() | val targetLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId!!).orElseThrow() | ||||
| stockTakeLocationGuard.assertLotLineMovable(targetLotLine) | |||||
| val salesUnit = inventoryLotLine?.inventoryLot?.item?.id?.let {_itemId -> itemUomRespository.findByItemIdAndSalesUnitIsTrueAndDeletedIsFalse(_itemId) } | val salesUnit = inventoryLotLine?.inventoryLot?.item?.id?.let {_itemId -> itemUomRespository.findByItemIdAndSalesUnitIsTrueAndDeletedIsFalse(_itemId) } | ||||
| val ratio = 1.0 //(salesUnit?.ratioN ?: zero).divide(salesUnit?.ratioD ?: one).toDouble() | val ratio = 1.0 //(salesUnit?.ratioN ?: zero).divide(salesUnit?.ratioD ?: one).toDouble() | ||||
| val targetLotLineEntry = targetLotLine.apply { | val targetLotLineEntry = targetLotLine.apply { | ||||
| @@ -306,6 +309,7 @@ open fun createWithoutConso(request: CreateStockOutLineWithoutConsoRequest): Mes | |||||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow { | val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow { | ||||
| IllegalArgumentException("InventoryLotLine not found with ID: ${request.inventoryLotLineId}") | IllegalArgumentException("InventoryLotLine not found with ID: ${request.inventoryLotLineId}") | ||||
| } | } | ||||
| stockTakeLocationGuard.assertLotLineMovable(inventoryLotLine) | |||||
| println("Found inventoryLotLine: ${inventoryLotLine.id}") | println("Found inventoryLotLine: ${inventoryLotLine.id}") | ||||
| val stockOutLine = StockOutLine() | val stockOutLine = StockOutLine() | ||||
| @@ -490,6 +494,7 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| /** Batch pick: same qty rules as [InventoryLotLineService.updateInventoryLotLineQuantities] pick, without double findById / extra service layers. */ | /** Batch pick: same qty rules as [InventoryLotLineService.updateInventoryLotLineQuantities] pick, without double findById / extra service layers. */ | ||||
| private fun applyPickToInventoryLotLineInBatch(ill: InventoryLotLine, submitQty: BigDecimal) { | private fun applyPickToInventoryLotLineInBatch(ill: InventoryLotLine, submitQty: BigDecimal) { | ||||
| stockTakeLocationGuard.assertLotLineMovable(ill) | |||||
| val zero = BigDecimal.ZERO | val zero = BigDecimal.ZERO | ||||
| val newHold = (ill.holdQty ?: zero).minus(submitQty) | val newHold = (ill.holdQty ?: zero).minus(submitQty) | ||||
| val newOut = (ill.outQty ?: zero).plus(submitQty) | val newOut = (ill.outQty ?: zero).plus(submitQty) | ||||
| @@ -1673,6 +1678,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| */ | */ | ||||
| open fun createStockOutForStockTransfer(inventoryLotLineId: Long, qty: Double): StockOutLine { | open fun createStockOutForStockTransfer(inventoryLotLineId: Long, qty: Double): StockOutLine { | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(inventoryLotLineId).orElseThrow() | val inventoryLotLine = inventoryLotLineRepository.findById(inventoryLotLineId).orElseThrow() | ||||
| stockTakeLocationGuard.assertLotLineMovable(inventoryLotLine) | |||||
| val qtyBd = BigDecimal.valueOf(qty) | val qtyBd = BigDecimal.valueOf(qty) | ||||
| validateOutOnlyIncrease(inventoryLotLine, qtyBd, OutOnlyLotLineContext.STOCK_TRANSFER) | validateOutOnlyIncrease(inventoryLotLine, qtyBd, OutOnlyLotLineContext.STOCK_TRANSFER) | ||||
| applyOutOnlyIncreaseToLotLine(inventoryLotLine, qtyBd) | applyOutOnlyIncreaseToLotLine(inventoryLotLine, qtyBd) | ||||
| @@ -1688,6 +1694,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| */ | */ | ||||
| open fun createStockOutForAdjustment(request: StockOutRequest): StockOutLine { | open fun createStockOutForAdjustment(request: StockOutRequest): StockOutLine { | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | ||||
| if (!request.type.equals("TKE", ignoreCase = true)) { | |||||
| stockTakeLocationGuard.assertLotLineMovable(inventoryLotLine) | |||||
| } | |||||
| val qtyBd = BigDecimal.valueOf(request.qty) | val qtyBd = BigDecimal.valueOf(request.qty) | ||||
| validateOutOnlyIncrease(inventoryLotLine, qtyBd, OutOnlyLotLineContext.ADJUSTMENT) | validateOutOnlyIncrease(inventoryLotLine, qtyBd, OutOnlyLotLineContext.ADJUSTMENT) | ||||
| applyOutOnlyIncreaseToLotLine(inventoryLotLine, qtyBd) | applyOutOnlyIncreaseToLotLine(inventoryLotLine, qtyBd) | ||||
| @@ -1706,6 +1715,9 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||||
| */ | */ | ||||
| open fun createStockOutForRemoval(inventoryLotLineId: Long, type: String = "ADJ"): StockOutLine { | open fun createStockOutForRemoval(inventoryLotLineId: Long, type: String = "ADJ"): StockOutLine { | ||||
| val inventoryLotLine = inventoryLotLineRepository.findById(inventoryLotLineId).orElseThrow() | val inventoryLotLine = inventoryLotLineRepository.findById(inventoryLotLineId).orElseThrow() | ||||
| if (!type.equals("TKE", ignoreCase = true)) { | |||||
| stockTakeLocationGuard.assertLotLineMovable(inventoryLotLine) | |||||
| } | |||||
| val inQty = inventoryLotLine.inQty ?: BigDecimal.ZERO | val inQty = inventoryLotLine.inQty ?: BigDecimal.ZERO | ||||
| val outQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | val outQty = inventoryLotLine.outQty ?: BigDecimal.ZERO | ||||
| val deltaOut = inQty.subtract(outQty).coerceAtLeast(BigDecimal.ZERO) | val deltaOut = inQty.subtract(outQty).coerceAtLeast(BigDecimal.ZERO) | ||||
| @@ -2159,6 +2171,9 @@ fun applyStockOutLineDelta( | |||||
| if (!skipInventoryWrite) { | if (!skipInventoryWrite) { | ||||
| val lotLine = savedSol.inventoryLotLine | val lotLine = savedSol.inventoryLotLine | ||||
| if (lotLine != null) { | if (lotLine != null) { | ||||
| if (!postingType.equals("tke", ignoreCase = true)) { | |||||
| stockTakeLocationGuard.assertLotLineMovable(lotLine) | |||||
| } | |||||
| if (isIssuePosting) { | if (isIssuePosting) { | ||||
| val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) | val latestLotLine = inventoryLotLineRepository.findById(lotLine.id!!).orElse(null) | ||||
| if (latestLotLine != null) { | if (latestLotLine != null) { | ||||
| @@ -0,0 +1,73 @@ | |||||
| package com.ffii.fpsms.modules.stock.service | |||||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||||
| import com.ffii.fpsms.modules.stock.entity.StockTakeRecordRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.StockTakeRepository | |||||
| import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode | |||||
| import org.springframework.http.HttpStatus | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.web.server.ResponseStatusException | |||||
| /** | |||||
| * Section rounds block every item in that stock-take section. | |||||
| * Item rounds block only the items on that round's records. | |||||
| * Stock-take's own variance posting does not call this guard. | |||||
| */ | |||||
| @Service | |||||
| class StockTakeLocationGuard( | |||||
| private val stockTakeRepository: StockTakeRepository, | |||||
| private val stockTakeRecordRepository: StockTakeRecordRepository, | |||||
| private val warehouseRepository: WarehouseRepository, | |||||
| ) { | |||||
| /** Null when this warehouse and item can still move. */ | |||||
| fun blockMessage(warehouseId: Long?, itemId: Long?): String? { | |||||
| if (warehouseId == null || warehouseId <= 0L) return null | |||||
| val warehouse = warehouseRepository.findById(warehouseId).orElse(null) ?: return null | |||||
| val section = warehouse.stockTakeSection?.trim()?.takeIf { it.isNotBlank() } ?: return null | |||||
| val open = stockTakeRepository.findOpenStockTakeRows().filter { | |||||
| section.equals(it.getStockTakeSection()?.trim(), ignoreCase = true) | |||||
| } | |||||
| if (open.isEmpty()) return null | |||||
| if (open.any { !StockTakeRoundMode.ITEM.equals(it.getStockTakeRoundMode(), ignoreCase = true) }) { | |||||
| return "盤點進行中,區域 $section 暫停出入庫" | |||||
| } | |||||
| if (itemId == null || itemId <= 0L) return null | |||||
| val roundIds = open.mapNotNull { it.getStockTakeRoundId()?.takeIf { id -> id > 0L } }.distinct() | |||||
| val headerIds = open.map { it.getId() }.distinct() | |||||
| val records = buildList { | |||||
| if (roundIds.isNotEmpty()) { | |||||
| addAll(stockTakeRecordRepository.findAllByStockTakeRoundIdInAndDeletedIsFalse(roundIds)) | |||||
| } | |||||
| if (headerIds.isNotEmpty()) { | |||||
| addAll(stockTakeRecordRepository.findAllByStockTakeIdInAndDeletedIsFalse(headerIds)) | |||||
| } | |||||
| } | |||||
| val hit = records.firstOrNull { record -> | |||||
| record.itemId == itemId && | |||||
| section.equals(record.stockTakeSection?.trim(), ignoreCase = true) | |||||
| } ?: return null | |||||
| val itemLabel = recordLabel(hit.itemCode, hit.itemName) | |||||
| return "盤點進行中,貨品 $itemLabel 暫停出入庫" | |||||
| } | |||||
| fun assertWarehouseMovable(warehouseId: Long?, itemId: Long? = null) { | |||||
| val message = blockMessage(warehouseId, itemId) ?: return | |||||
| throw ResponseStatusException(HttpStatus.CONFLICT, message) | |||||
| } | |||||
| fun assertLotLineMovable(lotLine: InventoryLotLine?) { | |||||
| assertWarehouseMovable(lotLine?.warehouse?.id, lotLine?.inventoryLot?.item?.id) | |||||
| } | |||||
| private fun recordLabel(code: String?, name: String?): String { | |||||
| val itemCode = code?.trim().orEmpty() | |||||
| val itemName = name?.trim().orEmpty() | |||||
| return when { | |||||
| itemCode.isNotEmpty() && itemName.isNotEmpty() -> "$itemCode $itemName" | |||||
| itemCode.isNotEmpty() -> itemCode | |||||
| itemName.isNotEmpty() -> itemName | |||||
| else -> "此貨品" | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -11,6 +11,7 @@ import com.ffii.fpsms.modules.stock.enums.StockTakeRoundMode | |||||
| import com.ffii.fpsms.modules.stock.web.model.* | import com.ffii.fpsms.modules.stock.web.model.* | ||||
| import org.slf4j.Logger | import org.slf4j.Logger | ||||
| import org.slf4j.LoggerFactory | import org.slf4j.LoggerFactory | ||||
| import org.springframework.context.annotation.Lazy | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| @@ -66,7 +67,8 @@ open class StockTakeRecordService( | |||||
| val stockLedgerRepository: StockLedgerRepository, | val stockLedgerRepository: StockLedgerRepository, | ||||
| val inventoryRepository: InventoryRepository, | val inventoryRepository: InventoryRepository, | ||||
| val uomConversionRepository: UomConversionRepository, | val uomConversionRepository: UomConversionRepository, | ||||
| val itemsRepository: ItemsRepository | |||||
| val itemsRepository: ItemsRepository, | |||||
| @Lazy private val stockInLineService: StockInLineService, | |||||
| ) { | ) { | ||||
| private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) | private val logger: Logger = LoggerFactory.getLogger(StockTakeRecordService::class.java) | ||||
| @@ -1213,7 +1215,7 @@ open class StockTakeRecordService( | |||||
| } | } | ||||
| toRoundCard(roundKey, rows, records, floorsBySection) | toRoundCard(roundKey, rows, records, floorsBySection) | ||||
| } | } | ||||
| .sortedByDescending { it.stockTakeRoundId } | |||||
| .sortedBy { it.stockTakeRoundId } | |||||
| val thisMonth = java.time.YearMonth.now().toString() | val thisMonth = java.time.YearMonth.now().toString() | ||||
| val hasOpenRounds = cards.any { it.open } | val hasOpenRounds = cards.any { it.open } | ||||
| val completedYearMonths = ( | val completedYearMonths = ( | ||||
| @@ -1248,6 +1250,8 @@ open class StockTakeRecordService( | |||||
| } | } | ||||
| stockTakeRepository.save(st) | stockTakeRepository.save(st) | ||||
| } | } | ||||
| val sections = rows.mapNotNull { it.stockTakeSection?.trim()?.takeIf { section -> section.isNotBlank() } } | |||||
| stockInLineService.releasePendingAutoPutaways(sections) | |||||
| return mapOf( | return mapOf( | ||||
| "success" to true, | "success" to true, | ||||
| "stockTakeRoundId" to roundId, | "stockTakeRoundId" to roundId, | ||||
| @@ -60,6 +60,7 @@ open class SuggestedPickLotService( | |||||
| val itemRepository: ItemsRepository, | val itemRepository: ItemsRepository, | ||||
| val stockOutLineRepository: StockOutLIneRepository, | val stockOutLineRepository: StockOutLIneRepository, | ||||
| private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService, | ||||
| private val stockTakeLocationGuard: StockTakeLocationGuard, | |||||
| ) { | ) { | ||||
| // Calculation Available Qty / Remaining Qty | // Calculation Available Qty / Remaining Qty | ||||
| @@ -1035,6 +1036,7 @@ val basePickOrders = (listOf(pickOrder) + allCompetingPickOrders).distinctBy { i | |||||
| if (lotId != null && newHoldQty != null && newHoldQty > BigDecimal.ZERO) { | if (lotId != null && newHoldQty != null && newHoldQty > BigDecimal.ZERO) { | ||||
| val lot = inventoryLotLineRepository.findById(lotId).orElse(null) | val lot = inventoryLotLineRepository.findById(lotId).orElse(null) | ||||
| lot?.let { | lot?.let { | ||||
| stockTakeLocationGuard.assertLotLineMovable(it) | |||||
| val currentHoldQty = it.holdQty ?: BigDecimal.ZERO | val currentHoldQty = it.holdQty ?: BigDecimal.ZERO | ||||
| val existingHoldQty = existingHoldQtyMap[lotId] ?: BigDecimal.ZERO | val existingHoldQty = existingHoldQtyMap[lotId] ?: BigDecimal.ZERO | ||||
| val additionalHoldQty = newHoldQty.minus(existingHoldQty) | val additionalHoldQty = newHoldQty.minus(existingHoldQty) | ||||
| @@ -1892,6 +1894,7 @@ open fun updateSuggestedLotLineId(suggestedPickLotId: Long, newLotLineId: Long): | |||||
| } | } | ||||
| // Add hold quantity to new lot | // Add hold quantity to new lot | ||||
| stockTakeLocationGuard.assertLotLineMovable(newInventoryLotLine) | |||||
| val newHoldQty = (newInventoryLotLine.holdQty ?: BigDecimal.ZERO).plus(suggestedQty) | val newHoldQty = (newInventoryLotLine.holdQty ?: BigDecimal.ZERO).plus(suggestedQty) | ||||
| newInventoryLotLine.holdQty = newHoldQty | newInventoryLotLine.holdQty = newHoldQty | ||||
| inventoryLotLineRepository.save(newInventoryLotLine) | inventoryLotLineRepository.save(newInventoryLotLine) | ||||
| @@ -63,7 +63,9 @@ data class SaveStockInLineRequest( | |||||
| var escalationLog: SaveEscalationLogRequest? = null, | var escalationLog: SaveEscalationLogRequest? = null, | ||||
| var warehouseId: Long? = null, | var warehouseId: Long? = null, | ||||
| var rejectQty: BigDecimal? = null, | var rejectQty: BigDecimal? = null, | ||||
| var inventoryLotLines: List<SaveInventoryLotLineForSil>? = null | |||||
| var inventoryLotLines: List<SaveInventoryLotLineForSil>? = null, | |||||
| /** True when closing a stock-take round posts a putaway that was held for that round. */ | |||||
| var releaseHeldPutaway: Boolean = false, | |||||
| ) | ) | ||||
| data class SaveInventoryLotLineForSil ( | data class SaveInventoryLotLineForSil ( | ||||
| @@ -0,0 +1,18 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fpsms:20261013_close_march_open_stock_take_rounds | |||||
| -- Close leftover open stock_take headers (planStart Mar 2026). | |||||
| -- Apr–Sep 2026 was closed by 20261007_close_open_stock_take_rounds. | |||||
| -- 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-03-01 00:00:00' | |||||
| AND planStart < '2026-04-01 00:00:00'; | |||||
| @@ -0,0 +1,6 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset fpsms:20261013_stock_in_line_pending_auto_putaway | |||||
| ALTER TABLE stock_in_line | |||||
| ADD COLUMN pendingAutoPutawayWarehouseId BIGINT NULL, | |||||
| ADD COLUMN pendingAutoPutawayQty DECIMAL(14, 2) NULL; | |||||