| @@ -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.ItemUomRespository | |||
| 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.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository | |||
| import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus | |||
| 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 | |||
| @Service | |||
| open class PickExecutionIssueService( | |||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | |||
| @@ -2202,54 +2204,48 @@ open fun batchSubmitBadItem(request: BatchSubmitIssueRequest): MessageResponse { | |||
| open fun batchSubmitExpiryItem(request: BatchSubmitExpiryRequest): MessageResponse { | |||
| try { | |||
| val lotLines = inventoryLotLineRepository.findAllById(request.lotLineIds) | |||
| .filter { | |||
| .filter { | |||
| val lot = it.inventoryLot | |||
| 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) | |||
| } | |||
| if (lotLines.isEmpty()) { | |||
| 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 | |||
| 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( | |||
| id = null, | |||
| id = result.stockOutId, | |||
| name = "Success", | |||
| code = "SUCCESS", | |||
| type = "stock_issue", | |||
| message = "Successfully submitted ${lotLines.size} expiry item(s)", | |||
| message = "Successfully submitted ${result.processedCount} expiry item(s), skipped ${result.skippedCount}", | |||
| errorPosition = null | |||
| ) | |||
| } catch (e: Exception) { | |||
| 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 | |||
| @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 | |||
| } | |||
| @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 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 | |||
| ) | |||