| @@ -45,6 +45,7 @@ import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderInfoResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.JobOrderBasicInfoResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.LotDetailResponse | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.StockOutLineDetailResponse | |||
| import com.ffii.fpsms.modules.stock.entity.enum.StockInLineStatus | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| @@ -1960,6 +1961,23 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||
| val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> | |||
| stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(polId) | |||
| } | |||
| // stockouts 可能包含不在 suggestedPickLots 內的 inventoryLotLineId,需補齊以便計算 location/availableQty | |||
| val stockOutInventoryLotLineIds = stockOutLinesByPickOrderLine.values | |||
| .flatten() | |||
| .mapNotNull { it.inventoryLotLineId } | |||
| .distinct() | |||
| val stockOutInventoryLotLines = if (stockOutInventoryLotLineIds.isNotEmpty()) { | |||
| inventoryLotLineRepository.findAllByIdIn(stockOutInventoryLotLineIds) | |||
| .filter { it.deleted == false } | |||
| } else { | |||
| emptyList() | |||
| } | |||
| val inventoryLotLineById = (inventoryLotLines + stockOutInventoryLotLines) | |||
| .filter { it.id != null } | |||
| .associateBy { it.id!! } | |||
| // 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId) | |||
| val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) { | |||
| @@ -2080,6 +2098,32 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||
| matchQty = jpo?.matchQty?.toDouble() | |||
| ) | |||
| } | |||
| // 构建 stockouts 数据:用于无 suggested lot / noLot 场景也能显示并闭环(submit 0) | |||
| val stockouts = (stockOutLinesByPickOrderLine[lineId] ?: emptyList()).map { sol -> | |||
| val illId = sol.inventoryLotLineId | |||
| val ill = if (illId != null) inventoryLotLineById[illId] else null | |||
| val lot = ill?.inventoryLot | |||
| val warehouse = ill?.warehouse | |||
| val availableQty = if (sol.status == "rejected") { | |||
| null | |||
| } else if (ill == null || ill.deleted == true) { | |||
| null | |||
| } else { | |||
| (ill.inQty ?: BigDecimal.ZERO) - (ill.outQty ?: BigDecimal.ZERO) - (ill.holdQty ?: BigDecimal.ZERO) | |||
| } | |||
| StockOutLineDetailResponse( | |||
| id = sol.id, | |||
| status = sol.status, | |||
| qty = sol.qty.toDouble(), | |||
| lotId = illId, | |||
| lotNo = sol.lotNo ?: lot?.lotNo, | |||
| location = warehouse?.code, | |||
| availableQty = availableQty?.toDouble(), | |||
| noLot = (illId == null) | |||
| ) | |||
| } | |||
| PickOrderLineWithLotsResponse( | |||
| id = pol.id!!, | |||
| @@ -2091,6 +2135,7 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||
| uomDesc = uom?.udfudesc, | |||
| status = pol.status?.value, | |||
| lots = lots, | |||
| stockouts = stockouts, | |||
| handler=handlerName | |||
| ) | |||
| } | |||
| @@ -96,9 +96,25 @@ data class PickOrderLineWithLotsResponse( | |||
| val uomDesc: String?, | |||
| val status: String?, | |||
| val lots: List<LotDetailResponse>, | |||
| val stockouts: List<StockOutLineDetailResponse> = emptyList(), | |||
| val handler: String? | |||
| ) | |||
| /** | |||
| * Stock-out line rows that should be shown even when there is no suggested lot. | |||
| * `noLot=true` indicates this line currently has no lot assigned / insufficient inventory lot. | |||
| */ | |||
| data class StockOutLineDetailResponse( | |||
| val id: Long?, | |||
| val status: String?, | |||
| val qty: Double?, | |||
| val lotId: Long?, | |||
| val lotNo: String?, | |||
| val location: String?, | |||
| val availableQty: Double?, | |||
| val noLot: Boolean | |||
| ) | |||
| data class LotDetailResponse( | |||
| val lotId: Long?, | |||
| val lotNo: String?, | |||
| @@ -4017,7 +4017,11 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto | |||
| // Fallback lotNo (req.newInventoryLotNo is non-null String in your model) | |||
| if (req.newInventoryLotNo.isNotBlank()) { | |||
| return inventoryLotLineRepository.findByLotNoAndItemId(req.newInventoryLotNo, itemId) | |||
| return inventoryLotLineRepository | |||
| .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( | |||
| req.newInventoryLotNo, | |||
| itemId | |||
| ) | |||
| } | |||
| return null | |||
| @@ -52,6 +52,12 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||
| AND ill.deleted = false | |||
| """) | |||
| fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLotLine? | |||
| // lotNo + itemId may not be unique (multiple warehouses/lines); pick one deterministically | |||
| fun findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( | |||
| lotNo: String, | |||
| itemId: Long | |||
| ): InventoryLotLine? | |||
| // InventoryLotLineRepository.kt 中添加 | |||
| @Query("SELECT ill FROM InventoryLotLine ill WHERE ill.warehouse.id IN :warehouseIds AND ill.deleted = false") | |||
| fun findAllByWarehouseIdInAndDeletedIsFalse(@Param("warehouseIds") warehouseIds: List<Long>): List<InventoryLotLine> | |||
| @@ -11,4 +11,6 @@ interface SuggestPickLotRepository : AbstractRepository<SuggestedPickLot, Long> | |||
| fun findFirstByPickOrderLineId(pickOrderLineId: Long): SuggestedPickLot? | |||
| fun findAllByPickOrderLineId(pickOrderLineId: Long): List<SuggestedPickLot> | |||
| fun findFirstByStockOutLineId(stockOutLineId: Long): SuggestedPickLot? | |||
| } | |||
| @@ -43,6 +43,8 @@ import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository | |||
| import java.time.LocalTime | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||
| @Service | |||
| open class StockOutLineService( | |||
| private val jdbcDao: JdbcDao, | |||
| @@ -53,7 +55,9 @@ open class StockOutLineService( | |||
| private val itemUomRespository: ItemUomRespository, | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val inventoryLotLineRepository: InventoryLotLineRepository, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| @Lazy private val suggestedPickLotService: SuggestedPickLotService, | |||
| private val suggestPickLotRepository: SuggestPickLotRepository, | |||
| private val inventoryLotRepository: InventoryLotRepository, | |||
| private val doPickOrderRepository: DoPickOrderRepository, | |||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | |||
| @@ -946,22 +950,62 @@ open fun updateStockOutLineStatusByQRCodeAndLotNo(request: UpdateStockOutLineSta | |||
| // Step 2: Get InventoryLotLine | |||
| val getInventoryLotLineStart = System.currentTimeMillis() | |||
| // 修复:从 stockOutLine.inventoryLotLine 获取 inventoryLot,而不是使用错误的参数 | |||
| val inventoryLotLine = stockOutLine.inventoryLotLine | |||
| // If StockOutLine has no lot (noLot row), resolve InventoryLotLine by scanned lotNo + itemId and bind it | |||
| var inventoryLotLine = stockOutLine.inventoryLotLine | |||
| if (inventoryLotLine == null) { | |||
| // Prefer stockInLineId from QR for deterministic binding | |||
| val resolved = if (request.stockInLineId != null && request.stockInLineId > 0) { | |||
| println(" Resolving InventoryLotLine by stockInLineId=${request.stockInLineId} ...") | |||
| val sil = stockInLineRepository.findById(request.stockInLineId).orElse(null) | |||
| val ill = sil?.inventoryLotLine | |||
| if (ill == null) { | |||
| println(" StockInLine ${request.stockInLineId} has no associated InventoryLotLine") | |||
| null | |||
| } else { | |||
| // item consistency guard | |||
| val illItemId = ill.inventoryLot?.item?.id | |||
| if (illItemId != null && illItemId != request.itemId) { | |||
| println(" InventoryLotLine item mismatch for stockInLineId=${request.stockInLineId}: $illItemId != ${request.itemId}") | |||
| null | |||
| } else { | |||
| ill | |||
| } | |||
| } | |||
| } else { | |||
| println(" StockOutLine has no associated InventoryLotLine, resolving by lotNo+itemId...") | |||
| inventoryLotLineRepository | |||
| .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( | |||
| request.inventoryLotNo, | |||
| request.itemId | |||
| ) | |||
| } | |||
| if (resolved == null) { | |||
| println(" Cannot resolve InventoryLotLine by lotNo=${request.inventoryLotNo}, itemId=${request.itemId}") | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "No inventory lot line", | |||
| code = "NO_INVENTORY_LOT_LINE", | |||
| type = "error", | |||
| message = "Cannot resolve InventoryLotLine (stockInLineId=${request.stockInLineId ?: "null"}, lotNo=${request.inventoryLotNo}, itemId=${request.itemId})", | |||
| errorPosition = null | |||
| ) | |||
| } | |||
| // Bind the lot line to this stockOutLine so subsequent operations can proceed | |||
| stockOutLine.inventoryLotLine = resolved | |||
| stockOutLine.item = stockOutLine.item ?: resolved.inventoryLot?.item | |||
| inventoryLotLine = resolved | |||
| // Also update SuggestedPickLot to point to the resolved lot line (so UI/holdQty logic matches DO confirmLotSubstitution) | |||
| val spl = suggestPickLotRepository.findFirstByStockOutLineId(stockOutLine.id!!) | |||
| if (spl != null) { | |||
| spl.suggestedLotLine = resolved | |||
| suggestPickLotRepository.saveAndFlush(spl) | |||
| } | |||
| } | |||
| val getInventoryLotLineTime = System.currentTimeMillis() - getInventoryLotLineStart | |||
| println("⏱️ [STEP 2] Get InventoryLotLine: ${getInventoryLotLineTime}ms") | |||
| if (inventoryLotLine == null) { | |||
| println(" StockOutLine has no associated InventoryLotLine") | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "No inventory lot line", | |||
| code = "NO_INVENTORY_LOT_LINE", | |||
| type = "error", | |||
| message = "StockOutLine ${request.stockOutLineId} has no associated InventoryLotLine", | |||
| errorPosition = null | |||
| ) | |||
| } | |||
| // inventoryLotLine is guaranteed non-null here | |||
| // Step 3: Get InventoryLot | |||
| val getInventoryLotStart = System.currentTimeMillis() | |||
| @@ -40,6 +40,7 @@ import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | |||
| import com.ffii.fpsms.modules.stock.entity.projection.StockOutLineInfo | |||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | |||
| import com.ffii.fpsms.modules.stock.web.model.StockOutStatus | |||
| import com.ffii.fpsms.modules.common.SecurityUtils | |||
| @Service | |||
| open class SuggestedPickLotService( | |||
| val suggestedPickLotRepository: SuggestPickLotRepository, | |||
| @@ -433,7 +434,32 @@ open class SuggestedPickLotService( | |||
| } | |||
| open fun saveAll(request: List<SuggestedPickLot>): List<SuggestedPickLot> { | |||
| return suggestedPickLotRepository.saveAllAndFlush(request) | |||
| val saved = suggestedPickLotRepository.saveAllAndFlush(request) | |||
| // For insufficient stock (suggestedLotLine == null), create a no-lot stock_out_line so UI can display & close the line. | |||
| // Also backfill SuggestedPickLot.stockOutLineId for downstream flows (e.g. hierarchical API -> stockouts). | |||
| val toBackfill = saved.filter { it.suggestedLotLine == null && it.pickOrderLine != null } | |||
| if (toBackfill.isNotEmpty()) { | |||
| val updated = mutableListOf<SuggestedPickLot>() | |||
| toBackfill.forEach { spl -> | |||
| val pickOrder = spl.pickOrderLine?.pickOrder | |||
| if (pickOrder == null) return@forEach | |||
| // Only create/backfill when stockOutLine is missing | |||
| if (spl.stockOutLine == null) { | |||
| val sol = createStockOutLineForSuggestion(spl, pickOrder) | |||
| if (sol != null) { | |||
| spl.stockOutLine = sol | |||
| updated.add(spl) | |||
| } | |||
| } | |||
| } | |||
| if (updated.isNotEmpty()) { | |||
| suggestedPickLotRepository.saveAllAndFlush(updated) | |||
| } | |||
| } | |||
| return saved | |||
| } | |||
| private fun createStockOutLineForSuggestion( | |||
| suggestion: SuggestedPickLot, | |||
| @@ -470,10 +496,13 @@ open class SuggestedPickLotService( | |||
| // Get or create StockOut | |||
| val stockOut = stockOutRepository.findByConsoPickOrderCode(pickOrder.consoCode ?: "") | |||
| .orElseGet { | |||
| val handlerId = pickOrder.assignTo?.id ?: SecurityUtils.getUser().orElse(null)?.id | |||
| require(handlerId != null) { "Cannot create StockOut: handlerId is null" } | |||
| val newStockOut = StockOut().apply { | |||
| this.consoPickOrderCode = pickOrder.consoCode ?: "" | |||
| this.type = pickOrderTypeValue // Use pick order type (do, job, material, etc.) | |||
| this.status = StockOutStatus.PENDING.status | |||
| this.handler = handlerId | |||
| } | |||
| stockOutRepository.save(newStockOut) | |||
| } | |||
| @@ -484,7 +513,8 @@ open class SuggestedPickLotService( | |||
| this.pickOrderLine = pickOrderLine | |||
| this.item = item | |||
| this.inventoryLotLine = null // No lot available | |||
| this.qty = (suggestion.qty ?: BigDecimal.ZERO).toDouble() | |||
| // qty on StockOutLine represents picked qty; for no-lot placeholder it must start from 0 | |||
| this.qty = 0.0 | |||
| this.status = StockOutLineStatus.PENDING.status | |||
| this.deleted = false | |||
| this.type = "Nor" | |||
| @@ -64,6 +64,7 @@ data class UpdateStockOutLineStatusRequest( | |||
| data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( | |||
| val pickOrderLineId: Long, | |||
| val inventoryLotNo: String, | |||
| val stockInLineId: Long? = null, | |||
| val stockOutLineId: Long, | |||
| val itemId: Long, | |||
| val status: String | |||