| @@ -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.JobOrderBasicInfoResponse | ||||
| import com.ffii.fpsms.modules.jobOrder.web.model.PickOrderLineWithLotsResponse | 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.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.enum.StockInLineStatus | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| @@ -1960,6 +1961,23 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||||
| val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> | val stockOutLinesByPickOrderLine = pickOrderLineIds.associateWith { polId -> | ||||
| stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(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) | // 获取 stock in lines 通过 inventoryLotLineId(用于填充 stockInLineId) | ||||
| val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) { | val stockInLinesByInventoryLotLineId = if (inventoryLotLineIds.isNotEmpty()) { | ||||
| @@ -2080,6 +2098,32 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||||
| matchQty = jpo?.matchQty?.toDouble() | 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( | PickOrderLineWithLotsResponse( | ||||
| id = pol.id!!, | id = pol.id!!, | ||||
| @@ -2091,6 +2135,7 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||||
| uomDesc = uom?.udfudesc, | uomDesc = uom?.udfudesc, | ||||
| status = pol.status?.value, | status = pol.status?.value, | ||||
| lots = lots, | lots = lots, | ||||
| stockouts = stockouts, | |||||
| handler=handlerName | handler=handlerName | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -96,9 +96,25 @@ data class PickOrderLineWithLotsResponse( | |||||
| val uomDesc: String?, | val uomDesc: String?, | ||||
| val status: String?, | val status: String?, | ||||
| val lots: List<LotDetailResponse>, | val lots: List<LotDetailResponse>, | ||||
| val stockouts: List<StockOutLineDetailResponse> = emptyList(), | |||||
| val handler: String? | 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( | data class LotDetailResponse( | ||||
| val lotId: Long?, | val lotId: Long?, | ||||
| val lotNo: String?, | 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) | // Fallback lotNo (req.newInventoryLotNo is non-null String in your model) | ||||
| if (req.newInventoryLotNo.isNotBlank()) { | if (req.newInventoryLotNo.isNotBlank()) { | ||||
| return inventoryLotLineRepository.findByLotNoAndItemId(req.newInventoryLotNo, itemId) | |||||
| return inventoryLotLineRepository | |||||
| .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( | |||||
| req.newInventoryLotNo, | |||||
| itemId | |||||
| ) | |||||
| } | } | ||||
| return null | return null | ||||
| @@ -52,6 +52,12 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||||
| AND ill.deleted = false | AND ill.deleted = false | ||||
| """) | """) | ||||
| fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLotLine? | 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 中添加 | // InventoryLotLineRepository.kt 中添加 | ||||
| @Query("SELECT ill FROM InventoryLotLine ill WHERE ill.warehouse.id IN :warehouseIds AND ill.deleted = false") | @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> | fun findAllByWarehouseIdInAndDeletedIsFalse(@Param("warehouseIds") warehouseIds: List<Long>): List<InventoryLotLine> | ||||
| @@ -11,4 +11,6 @@ interface SuggestPickLotRepository : AbstractRepository<SuggestedPickLot, Long> | |||||
| fun findFirstByPickOrderLineId(pickOrderLineId: Long): SuggestedPickLot? | fun findFirstByPickOrderLineId(pickOrderLineId: Long): SuggestedPickLot? | ||||
| fun findAllByPickOrderLineId(pickOrderLineId: Long): List<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.stock.entity.InventoryRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository | import com.ffii.fpsms.modules.pickOrder.entity.PickExecutionIssueRepository | ||||
| import java.time.LocalTime | import java.time.LocalTime | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||||
| @Service | @Service | ||||
| open class StockOutLineService( | open class StockOutLineService( | ||||
| private val jdbcDao: JdbcDao, | private val jdbcDao: JdbcDao, | ||||
| @@ -53,7 +55,9 @@ open class StockOutLineService( | |||||
| private val itemUomRespository: ItemUomRespository, | private val itemUomRespository: ItemUomRespository, | ||||
| private val pickOrderRepository: PickOrderRepository, | private val pickOrderRepository: PickOrderRepository, | ||||
| private val inventoryLotLineRepository: InventoryLotLineRepository, | private val inventoryLotLineRepository: InventoryLotLineRepository, | ||||
| private val stockInLineRepository: StockInLineRepository, | |||||
| @Lazy private val suggestedPickLotService: SuggestedPickLotService, | @Lazy private val suggestedPickLotService: SuggestedPickLotService, | ||||
| private val suggestPickLotRepository: SuggestPickLotRepository, | |||||
| private val inventoryLotRepository: InventoryLotRepository, | private val inventoryLotRepository: InventoryLotRepository, | ||||
| private val doPickOrderRepository: DoPickOrderRepository, | private val doPickOrderRepository: DoPickOrderRepository, | ||||
| private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | private val doPickOrderRecordRepository: DoPickOrderRecordRepository, | ||||
| @@ -946,22 +950,62 @@ open fun updateStockOutLineStatusByQRCodeAndLotNo(request: UpdateStockOutLineSta | |||||
| // Step 2: Get InventoryLotLine | // Step 2: Get InventoryLotLine | ||||
| val getInventoryLotLineStart = System.currentTimeMillis() | 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 | val getInventoryLotLineTime = System.currentTimeMillis() - getInventoryLotLineStart | ||||
| println("⏱️ [STEP 2] Get InventoryLotLine: ${getInventoryLotLineTime}ms") | 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 | // Step 3: Get InventoryLot | ||||
| val getInventoryLotStart = System.currentTimeMillis() | 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.projection.StockOutLineInfo | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | ||||
| import com.ffii.fpsms.modules.stock.web.model.StockOutStatus | import com.ffii.fpsms.modules.stock.web.model.StockOutStatus | ||||
| import com.ffii.fpsms.modules.common.SecurityUtils | |||||
| @Service | @Service | ||||
| open class SuggestedPickLotService( | open class SuggestedPickLotService( | ||||
| val suggestedPickLotRepository: SuggestPickLotRepository, | val suggestedPickLotRepository: SuggestPickLotRepository, | ||||
| @@ -433,7 +434,32 @@ open class SuggestedPickLotService( | |||||
| } | } | ||||
| open fun saveAll(request: List<SuggestedPickLot>): List<SuggestedPickLot> { | 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( | private fun createStockOutLineForSuggestion( | ||||
| suggestion: SuggestedPickLot, | suggestion: SuggestedPickLot, | ||||
| @@ -470,10 +496,13 @@ open class SuggestedPickLotService( | |||||
| // Get or create StockOut | // Get or create StockOut | ||||
| val stockOut = stockOutRepository.findByConsoPickOrderCode(pickOrder.consoCode ?: "") | val stockOut = stockOutRepository.findByConsoPickOrderCode(pickOrder.consoCode ?: "") | ||||
| .orElseGet { | .orElseGet { | ||||
| val handlerId = pickOrder.assignTo?.id ?: SecurityUtils.getUser().orElse(null)?.id | |||||
| require(handlerId != null) { "Cannot create StockOut: handlerId is null" } | |||||
| val newStockOut = StockOut().apply { | val newStockOut = StockOut().apply { | ||||
| this.consoPickOrderCode = pickOrder.consoCode ?: "" | this.consoPickOrderCode = pickOrder.consoCode ?: "" | ||||
| this.type = pickOrderTypeValue // Use pick order type (do, job, material, etc.) | this.type = pickOrderTypeValue // Use pick order type (do, job, material, etc.) | ||||
| this.status = StockOutStatus.PENDING.status | this.status = StockOutStatus.PENDING.status | ||||
| this.handler = handlerId | |||||
| } | } | ||||
| stockOutRepository.save(newStockOut) | stockOutRepository.save(newStockOut) | ||||
| } | } | ||||
| @@ -484,7 +513,8 @@ open class SuggestedPickLotService( | |||||
| this.pickOrderLine = pickOrderLine | this.pickOrderLine = pickOrderLine | ||||
| this.item = item | this.item = item | ||||
| this.inventoryLotLine = null // No lot available | 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.status = StockOutLineStatus.PENDING.status | ||||
| this.deleted = false | this.deleted = false | ||||
| this.type = "Nor" | this.type = "Nor" | ||||
| @@ -64,6 +64,7 @@ data class UpdateStockOutLineStatusRequest( | |||||
| data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( | data class UpdateStockOutLineStatusByQRCodeAndLotNoRequest( | ||||
| val pickOrderLineId: Long, | val pickOrderLineId: Long, | ||||
| val inventoryLotNo: String, | val inventoryLotNo: String, | ||||
| val stockInLineId: Long? = null, | |||||
| val stockOutLineId: Long, | val stockOutLineId: Long, | ||||
| val itemId: Long, | val itemId: Long, | ||||
| val status: String | val status: String | ||||