| @@ -42,13 +42,15 @@ import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | |||||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | import com.ffii.fpsms.modules.master.entity.ItemsRepository | ||||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | import com.ffii.fpsms.modules.master.entity.ItemUomRespository | ||||
| import com.ffii.fpsms.modules.common.CodeGenerator | import com.ffii.fpsms.modules.common.CodeGenerator | ||||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutResult | |||||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | import com.ffii.fpsms.modules.stock.entity.StockLedger | ||||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | ||||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | ||||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | ||||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | ||||
| import org.springframework.beans.factory.annotation.Value | 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 | |||||
| @Service | @Service | ||||
| open class PickExecutionIssueService( | open class PickExecutionIssueService( | ||||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | private val pickExecutionIssueRepository: PickExecutionIssueRepository, | ||||
| @@ -2202,54 +2204,48 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse { | |||||
| open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { | open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { | ||||
| try { | try { | ||||
| val lotLines = inventoryLotLineRepository.findAllById(request.lotLineIds) | val lotLines = inventoryLotLineRepository.findAllById(request.lotLineIds) | ||||
| .filter { | |||||
| .filter { | |||||
| val lot = it.inventoryLot | val lot = it.inventoryLot | ||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| lot?.expiryDate != null && lot.expiryDate!!.isBefore(today) && | |||||
| lot?.expiryDate != null && | |||||
| lot.expiryDate!!.isBefore(today) && | |||||
| (it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO) | (it.inQty ?: BigDecimal.ZERO) != (it.outQty ?: BigDecimal.ZERO) | ||||
| } | } | ||||
| if (lotLines.isEmpty()) { | if (lotLines.isEmpty()) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| id = null, | |||||
| name = "Error", | |||||
| code = "EMPTY", | |||||
| type = "stock_issue", | |||||
| message = "No valid expiry items to submit", | |||||
| errorPosition = null | |||||
| id = null, name = "Error", code = "EMPTY", type = "stock_issue", | |||||
| message = "No valid expiry items to submit", errorPosition = null | |||||
| ) | ) | ||||
| } | } | ||||
| val handler = request.handler ?: SecurityUtils.getUser().orElse(null)?.id ?: 0L | val handler = request.handler ?: SecurityUtils.getUser().orElse(null)?.id ?: 0L | ||||
| lotLines.forEach { lotLine -> | |||||
| val remainingQty = (lotLine.inQty ?: BigDecimal.ZERO).subtract(lotLine.outQty ?: BigDecimal.ZERO) | |||||
| stockOutLineService.createStockOut( | |||||
| StockOutRequest( | |||||
| inventoryLotLineId = lotLine.id!!, | |||||
| qty = remainingQty.toDouble(), | |||||
| type = "Expiry" | |||||
| val batchReq = BatchStockOutRequest( | |||||
| type = "Expiry", | |||||
| handler = handler, | |||||
| lines = lotLines.map { ll -> | |||||
| val remaining = (ll.inQty ?: BigDecimal.ZERO).subtract(ll.outQty ?: BigDecimal.ZERO) | |||||
| BatchStockOutLineRequest( | |||||
| inventoryLotLineId = ll.id!!, | |||||
| qty = remaining.toDouble() | |||||
| ) | ) | ||||
| ) | |||||
| } | |||||
| } | |||||
| ) | |||||
| val result = stockOutLineService.createStockOutBatch(batchReq) | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = null, | |||||
| id = result.stockOutId, | |||||
| name = "Success", | name = "Success", | ||||
| code = "SUCCESS", | code = "SUCCESS", | ||||
| type = "stock_issue", | type = "stock_issue", | ||||
| message = "Successfully submitted ${lotLines.size} expiry item(s)", | |||||
| message = "Successfully submitted ${result.processedCount} expiry item(s), skipped ${result.skippedCount}", | |||||
| errorPosition = null | errorPosition = null | ||||
| ) | ) | ||||
| } catch (e: Exception) { | } catch (e: Exception) { | ||||
| return MessageResponse( | return MessageResponse( | ||||
| id = null, | |||||
| name = "Error", | |||||
| code = "ERROR", | |||||
| type = "stock_issue", | |||||
| message = "Failed to submit expiry items: ${e.message}", | |||||
| errorPosition = null | |||||
| id = null, name = "Error", code = "ERROR", type = "stock_issue", | |||||
| message = "Failed to submit expiry items: ${e.message}", errorPosition = null | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -262,4 +262,18 @@ WHERE ill.id = :id | |||||
| """ | """ | ||||
| ) | ) | ||||
| fun countByStatusAndDeletedIsFalse(@Param("status") status: InventoryLotLineStatus): Long | fun countByStatusAndDeletedIsFalse(@Param("status") status: InventoryLotLineStatus): Long | ||||
| @EntityGraph( | |||||
| type = EntityGraph.EntityGraphType.FETCH, | |||||
| attributePaths = ["inventoryLot", "inventoryLot.item", "warehouse"] | |||||
| ) | |||||
| @Query(""" | |||||
| SELECT ill | |||||
| FROM InventoryLotLine ill | |||||
| WHERE ill.id IN :ids | |||||
| AND ill.deleted = false | |||||
| """) | |||||
| fun findAllByIdInAndDeletedFalseWithRefs( | |||||
| @Param("ids") ids: Collection<Long> | |||||
| ): List<InventoryLotLine> | |||||
| } | } | ||||
| @@ -2223,4 +2223,119 @@ fun applyStockOutLineDelta( | |||||
| return savedSol | return savedSol | ||||
| } | } | ||||
| @Transactional(rollbackFor = [Exception::class]) | |||||
| open fun createStockOutBatch(request: BatchStockOutRequest): BatchStockOutResult { | |||||
| if (request.lines.isEmpty()) return BatchStockOutResult(null, 0, 0) | |||||
| val currentUserId = request.handler ?: SecurityUtils.getUser().orElseThrow().id ?: 0L | |||||
| val lotLineIds = request.lines.map { it.inventoryLotLineId }.distinct() | |||||
| // 1) 一次 preload lotLine + item + warehouse | |||||
| val lotLines = inventoryLotLineRepository | |||||
| .findAllByIdInAndDeletedFalseWithRefs(lotLineIds) | |||||
| val lotLineMap = lotLines.associateBy { it.id!! } | |||||
| // 2) 构造 header(每批一个,避免每笔新建) | |||||
| val stockOutHeader = StockOut().apply { | |||||
| this.type = request.type | |||||
| this.completeDate = LocalDateTime.now() | |||||
| this.handler = currentUserId | |||||
| this.status = StockOutStatus.COMPLETE.status | |||||
| } | |||||
| val savedHeader = stockOutRepository.save(stockOutHeader) | |||||
| val now = LocalDateTime.now() | |||||
| val today = LocalDate.now() | |||||
| val lotLinesToUpdate = mutableListOf<InventoryLotLine>() | |||||
| val stockOutLinesToInsert = mutableListOf<StockOutLine>() | |||||
| val ledgersToInsert = mutableListOf<StockLedger>() | |||||
| var skipped = 0 | |||||
| // 3) 内存校验 + 组装对象(不在循环里 findById / saveAndFlush) | |||||
| request.lines.forEach { lineReq -> | |||||
| val lotLine = lotLineMap[lineReq.inventoryLotLineId] | |||||
| if (lotLine == null) { | |||||
| skipped++ | |||||
| return@forEach | |||||
| } | |||||
| val qtyBd = BigDecimal.valueOf(lineReq.qty) | |||||
| if (qtyBd <= BigDecimal.ZERO) { | |||||
| skipped++ | |||||
| return@forEach | |||||
| } | |||||
| // 可用量校验(按你现有规则) | |||||
| val inQty = lotLine.inQty ?: BigDecimal.ZERO | |||||
| val outQty = lotLine.outQty ?: BigDecimal.ZERO | |||||
| val holdQty = lotLine.holdQty ?: BigDecimal.ZERO | |||||
| val available = inQty.subtract(outQty) // Expiry 通常吃剩余量,可视业务改成 -holdQty | |||||
| if (qtyBd > available) { | |||||
| skipped++ | |||||
| return@forEach | |||||
| } | |||||
| // 更新 lot line | |||||
| lotLine.outQty = outQty.add(qtyBd) | |||||
| lotLine.holdQty = holdQty.subtract(qtyBd).coerceAtLeast(BigDecimal.ZERO) | |||||
| lotLine.status = inventoryLotLineService.deriveInventoryLotLineStatus( | |||||
| lotLine.status, lotLine.inQty, lotLine.outQty, lotLine.holdQty | |||||
| ) | |||||
| lotLinesToUpdate += lotLine | |||||
| val item = lotLine.inventoryLot?.item ?: run { | |||||
| skipped++ | |||||
| return@forEach | |||||
| } | |||||
| // 组装 stock_out_line | |||||
| val sol = StockOutLine().apply { | |||||
| this.item = item | |||||
| this.qty = lineReq.qty | |||||
| this.stockOut = savedHeader | |||||
| this.inventoryLotLine = lotLine | |||||
| this.status = StockOutLineStatus.COMPLETE.status | |||||
| this.pickTime = now | |||||
| this.handledBy = currentUserId | |||||
| this.type = request.type | |||||
| } | |||||
| stockOutLinesToInsert += sol | |||||
| } | |||||
| // 4) 批量写(关键) | |||||
| inventoryLotLineRepository.saveAll(lotLinesToUpdate) | |||||
| val savedLines = stockOutLineRepository.saveAll(stockOutLinesToInsert) | |||||
| // 5) 批量组装 ledger(避免每笔 createStockLedgerForStockOut) | |||||
| savedLines.forEach { sol -> | |||||
| val item = sol.item ?: return@forEach | |||||
| val inv = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return@forEach | |||||
| val delta = BigDecimal.valueOf(sol.qty ?: 0.0) | |||||
| val prevBalance = inv.onHandQty?.toDouble() ?: 0.0 | |||||
| val newBalance = prevBalance - delta.toDouble() | |||||
| ledgersToInsert += StockLedger().apply { | |||||
| this.stockOutLine = sol | |||||
| this.inventory = inv | |||||
| this.inQty = null | |||||
| this.outQty = delta.toDouble() | |||||
| this.balance = newBalance | |||||
| this.type = request.type | |||||
| this.itemId = item.id | |||||
| this.itemCode = item.code | |||||
| this.uomId = itemUomRespository.findByItemIdAndStockUnitIsTrueAndDeletedIsFalse(item.id!!)?.uom?.id | |||||
| ?: inv.uom?.id | |||||
| this.date = today | |||||
| } | |||||
| } | |||||
| stockLedgerRepository.saveAll(ledgersToInsert) | |||||
| return BatchStockOutResult( | |||||
| stockOutId = savedHeader.id, | |||||
| processedCount = savedLines.size, | |||||
| skippedCount = skipped | |||||
| ) | |||||
| } | |||||
| } | } | ||||
| @@ -102,3 +102,19 @@ data class BatchScanRequest( | |||||
| val userId: Long, | val userId: Long, | ||||
| val lines: List<BatchScanLineRequest> | val lines: List<BatchScanLineRequest> | ||||
| ) | ) | ||||
| data class BatchStockOutRequest( | |||||
| val type: String, // "Expiry" / "Miss" / "Bad" | |||||
| val handler: Long?, | |||||
| val lines: List<BatchStockOutLineRequest> | |||||
| ) | |||||
| data class BatchStockOutLineRequest( | |||||
| val inventoryLotLineId: Long, | |||||
| val qty: Double | |||||
| ) | |||||
| data class BatchStockOutResult( | |||||
| val stockOutId: Long?, | |||||
| val processedCount: Int, | |||||
| val skippedCount: Int = 0 | |||||
| ) | |||||