# Conflicts: # src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.ktmaster
| @@ -462,18 +462,17 @@ open class M18DeliveryOrderService( | |||||
| } | } | ||||
| // End of save. Check result | // End of save. Check result | ||||
| // logger.info("Total Success (${doRefType}) (${successList.size}): $successList") | |||||
| logger.info("Total Success (${doRefType}) (${successList.size})") | logger.info("Total Success (${doRefType}) (${successList.size})") | ||||
| // if (failList.size > 0) { | |||||
| logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") | logger.error("Total Fail (${doRefType}) (${failList.size}): $failList") | ||||
| // } | |||||
| // logger.info("Total Success (${doLineRefType}) (${successDetailList.size}): $successDetailList") | |||||
| logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") | logger.info("Total Success (${doLineRefType}) (${successDetailList.size})") | ||||
| // if (failDetailList.size > 0) { | |||||
| logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") | logger.error("Total Fail (${doLineRefType}) (${failDetailList.size}): $failDetailList") | ||||
| // logger.error("Total Fail M18 Items (${doLineRefType}) (${failItemDetailList.distinct().size}): ${failItemDetailList.distinct()}") | |||||
| // } | |||||
| val feeMarked = deliveryOrderLineService.markDeletedLinesWithFeeItems() | |||||
| if (feeMarked > 0) { | |||||
| logger.info("Marked $feeMarked DO line(s) as deleted (isFee items).") | |||||
| } | |||||
| logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | logger.info("--------------------------------------------End - Saving M18 Delivery Order--------------------------------------------") | ||||
| return SyncResult( | return SyncResult( | ||||
| @@ -1,10 +1,17 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.entity | package com.ffii.fpsms.modules.deliveryOrder.entity | ||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| @Repository | @Repository | ||||
| interface DeliveryOrderLineRepository : AbstractRepository<DeliveryOrderLine, Long> { | interface DeliveryOrderLineRepository : AbstractRepository<DeliveryOrderLine, Long> { | ||||
| fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): DeliveryOrderLine? | fun findByM18DataLogIdAndDeletedIsFalse(m18datalogId: Serializable): DeliveryOrderLine? | ||||
| @Query( | |||||
| "SELECT dol FROM DeliveryOrderLine dol " + | |||||
| "WHERE dol.deleted = false AND dol.item IS NOT NULL AND dol.item.isFee = true" | |||||
| ) | |||||
| fun findAllByDeletedIsFalseAndItemIsFeeTrue(): List<DeliveryOrderLine> | |||||
| } | } | ||||
| @@ -68,4 +68,13 @@ open class DeliveryOrderLineService( | |||||
| return savedDeliveryOrderLine | return savedDeliveryOrderLine | ||||
| } | } | ||||
| open fun markDeletedLinesWithFeeItems(): Int { | |||||
| val feeLines = deliveryOrderLineRepository.findAllByDeletedIsFalseAndItemIsFeeTrue() | |||||
| feeLines.forEach { line -> | |||||
| line.deleted = true | |||||
| deliveryOrderLineRepository.saveAndFlush(line) | |||||
| } | |||||
| return feeLines.size | |||||
| } | |||||
| } | } | ||||
| @@ -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?, | ||||
| @@ -92,10 +92,18 @@ open class PickExecutionIssueService( | |||||
| println(" issueCategory: ${request.issueCategory}") | println(" issueCategory: ${request.issueCategory}") | ||||
| println("========================================") | println("========================================") | ||||
| // 1. 检查是否已经存在相同的 pick execution issue 记录 | |||||
| // 1. 解析 lot: | |||||
| // request.lotId 在前端目前传的是 inventory_lot_line.id(用于 SOL 关联/计算 bookQty 等) | |||||
| // 但 pick_execution_issue.lot_id 在 DB 上外键指向 inventory_lot.id | |||||
| val inventoryLotLine = request.lotId?.let { | |||||
| inventoryLotLineRepository.findById(it).orElse(null) | |||||
| } | |||||
| val inventoryLotIdForIssue = inventoryLotLine?.inventoryLot?.id | |||||
| // 2. 检查是否已经存在相同的 pick execution issue 记录(以 inventory_lot.id 去重) | |||||
| val existingIssues = pickExecutionIssueRepository.findByPickOrderLineIdAndLotIdAndDeletedFalse( | val existingIssues = pickExecutionIssueRepository.findByPickOrderLineIdAndLotIdAndDeletedFalse( | ||||
| request.pickOrderLineId, | |||||
| request.lotId ?: 0L | |||||
| request.pickOrderLineId, | |||||
| inventoryLotIdForIssue ?: 0L | |||||
| ) | ) | ||||
| println("Checking for existing issues...") | println("Checking for existing issues...") | ||||
| @@ -119,12 +127,8 @@ open class PickExecutionIssueService( | |||||
| val pickOrder = pickOrderRepository.findById(request.pickOrderId).orElse(null) | val pickOrder = pickOrderRepository.findById(request.pickOrderId).orElse(null) | ||||
| println("Pick order: id=${pickOrder?.id}, code=${pickOrder?.code}, type=${pickOrder?.type?.value}") | println("Pick order: id=${pickOrder?.id}, code=${pickOrder?.code}, type=${pickOrder?.type?.value}") | ||||
| // 2. 获取 inventory_lot_line 并计算账面数量 (bookQty) | |||||
| val inventoryLotLine = request.lotId?.let { | |||||
| inventoryLotLineRepository.findById(it).orElse(null) | |||||
| } | |||||
| println("Inventory lot line: id=${inventoryLotLine?.id}, lotNo=${inventoryLotLine?.inventoryLot?.lotNo}") | |||||
| // 3. 计算账面数量 (bookQty)(用 inventory_lot_line 快照) | |||||
| println("Inventory lot line: id=${inventoryLotLine?.id}, lotNo=${inventoryLotLine?.inventoryLot?.lotNo}, inventoryLotId=${inventoryLotIdForIssue}") | |||||
| // 计算账面数量(创建 issue 时的快照) | // 计算账面数量(创建 issue 时的快照) | ||||
| val bookQty = if (inventoryLotLine != null) { | val bookQty = if (inventoryLotLine != null) { | ||||
| @@ -138,13 +142,47 @@ open class PickExecutionIssueService( | |||||
| BigDecimal.ZERO | BigDecimal.ZERO | ||||
| } | } | ||||
| // 3. 获取数量值 | |||||
| // 4. 获取数量值 | |||||
| val requiredQty = request.requiredQty ?: BigDecimal.ZERO | val requiredQty = request.requiredQty ?: BigDecimal.ZERO | ||||
| val actualPickQty = request.actualPickQty ?: BigDecimal.ZERO | val actualPickQty = request.actualPickQty ?: BigDecimal.ZERO | ||||
| val missQty = request.missQty ?: BigDecimal.ZERO | val missQty = request.missQty ?: BigDecimal.ZERO | ||||
| val badItemQty = request.badItemQty ?: BigDecimal.ZERO | val badItemQty = request.badItemQty ?: BigDecimal.ZERO | ||||
| val badReason = request.badReason ?: "quantity_problem" | val badReason = request.badReason ?: "quantity_problem" | ||||
| val relatedStockOutLines = stockOutLineRepository | |||||
| .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | |||||
| request.pickOrderLineId, | |||||
| request.lotId ?: 0L | |||||
| ) | |||||
| val currentStatus = relatedStockOutLines.firstOrNull()?.status ?: "" | |||||
| if (currentStatus.equals("pending", ignoreCase = true) | |||||
| && actualPickQty > BigDecimal.ZERO | |||||
| && missQty == BigDecimal.ZERO | |||||
| && badItemQty == BigDecimal.ZERO | |||||
| ) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Invalid issue for pending stock out line", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_issue", | |||||
| message = "Cannot submit only actual pick qty when stock out line is pending. Please rescan the lot or use normal pick flow.", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| val lotRemainAvailable = bookQty // 当前 lot 剩余 | |||||
| val maxAllowed = requiredQty + lotRemainAvailable | |||||
| if (actualPickQty > maxAllowed) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Actual pick qty too large", | |||||
| code = "ERROR", | |||||
| type = "pick_execution_issue", | |||||
| message = "Actual pick qty cannot exceed required qty plus lot remaining available.", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| println("=== Quantity Summary ===") | println("=== Quantity Summary ===") | ||||
| println(" Required Qty: $requiredQty") | println(" Required Qty: $requiredQty") | ||||
| println(" Actual Pick Qty: $actualPickQty") | println(" Actual Pick Qty: $actualPickQty") | ||||
| @@ -153,7 +191,7 @@ open class PickExecutionIssueService( | |||||
| println(" Bad Reason: $badReason") | println(" Bad Reason: $badReason") | ||||
| println(" Book Qty: $bookQty") | println(" Book Qty: $bookQty") | ||||
| // 4. 计算 issueQty(实际的问题数量) | |||||
| // 5. 计算 issueQty(实际的问题数量) | |||||
| val issueQty = when { | val issueQty = when { | ||||
| // Bad item 或 bad package:一律用用户输入的 bad 数量,不用 bookQty - actualPickQty | // Bad item 或 bad package:一律用用户输入的 bad 数量,不用 bookQty - actualPickQty | ||||
| badItemQty > BigDecimal.ZERO -> { | badItemQty > BigDecimal.ZERO -> { | ||||
| @@ -179,10 +217,12 @@ open class PickExecutionIssueService( | |||||
| println("=== Final IssueQty Calculation ===") | println("=== Final IssueQty Calculation ===") | ||||
| println(" Calculated IssueQty: $issueQty") | println(" Calculated IssueQty: $issueQty") | ||||
| println("================================================") | println("================================================") | ||||
| // 5. 创建 pick execution issue 记录 | |||||
| println("=== Processing Logic Selection ===") | |||||
| // 6. 创建 pick execution issue 记录 | |||||
| val issueNo = generateIssueNo() | val issueNo = generateIssueNo() | ||||
| println("Generated issue number: $issueNo") | println("Generated issue number: $issueNo") | ||||
| val lotNoForIssue = request.lotNo ?: inventoryLotLine?.inventoryLot?.lotNo | |||||
| val pickExecutionIssue = PickExecutionIssue( | val pickExecutionIssue = PickExecutionIssue( | ||||
| id = null, | id = null, | ||||
| @@ -200,8 +240,8 @@ open class PickExecutionIssueService( | |||||
| itemId = request.itemId, | itemId = request.itemId, | ||||
| itemCode = request.itemCode, | itemCode = request.itemCode, | ||||
| itemDescription = request.itemDescription, | itemDescription = request.itemDescription, | ||||
| lotId = request.lotId, | |||||
| lotNo = request.lotNo, | |||||
| lotId = inventoryLotIdForIssue, | |||||
| lotNo = lotNoForIssue, | |||||
| storeLocation = request.storeLocation, | storeLocation = request.storeLocation, | ||||
| requiredQty = request.requiredQty, | requiredQty = request.requiredQty, | ||||
| actualPickQty = request.actualPickQty, | actualPickQty = request.actualPickQty, | ||||
| @@ -230,7 +270,7 @@ open class PickExecutionIssueService( | |||||
| println(" Handle Status: ${savedIssue.handleStatus}") | println(" Handle Status: ${savedIssue.handleStatus}") | ||||
| println(" Issue Qty: ${savedIssue.issueQty}") | println(" Issue Qty: ${savedIssue.issueQty}") | ||||
| // 6. NEW: Update inventory_lot_line.issueQty | |||||
| // 7. NEW: Update inventory_lot_line.issueQty(仍然用 lotLineId) | |||||
| if (request.lotId != null && inventoryLotLine != null) { | if (request.lotId != null && inventoryLotLine != null) { | ||||
| println("Updating inventory_lot_line.issueQty...") | println("Updating inventory_lot_line.issueQty...") | ||||
| // ✅ 修改:如果只有 missQty,不更新 issueQty | // ✅ 修改:如果只有 missQty,不更新 issueQty | ||||
| @@ -270,95 +310,19 @@ open class PickExecutionIssueService( | |||||
| } | } | ||||
| } | } | ||||
| // 7. 获取相关数据用于后续处理 | |||||
| val actualPickQtyForProcessing = request.actualPickQty ?: BigDecimal.ZERO | |||||
| val missQtyForProcessing = request.missQty ?: BigDecimal.ZERO | |||||
| val badItemQtyForProcessing = request.badItemQty ?: BigDecimal.ZERO | |||||
| val lotId = request.lotId | |||||
| val itemId = request.itemId | |||||
| println("=== Processing Logic Selection ===") | |||||
| println("Actual Pick Qty: $actualPickQtyForProcessing") | |||||
| println("Miss Qty: $missQtyForProcessing") | |||||
| println("Bad Item Qty: $badItemQtyForProcessing") | |||||
| println("Bad Reason: ${request.badReason}") | |||||
| println("Lot ID: $lotId") | |||||
| println("Item ID: $itemId") | |||||
| println("================================================") | |||||
| // 8. 新的统一处理逻辑(根据 badReason 决定处理方式) | |||||
| when { | |||||
| // 情况1: 只有 miss item (actualPickQty = 0, missQty > 0, badItemQty = 0) | |||||
| actualPickQtyForProcessing == BigDecimal.ZERO && missQtyForProcessing > BigDecimal.ZERO && badItemQtyForProcessing == BigDecimal.ZERO -> { | |||||
| println("→ Handling: Miss Item Only") | |||||
| handleMissItemOnly(request, missQtyForProcessing) | |||||
| } | |||||
| // 情况2: 只有 bad item (badItemQty > 0, missQty = 0) | |||||
| badItemQtyForProcessing > BigDecimal.ZERO && missQtyForProcessing == BigDecimal.ZERO -> { | |||||
| println("→ Handling: Bad Item Only") | |||||
| // NEW: Check bad reason | |||||
| if (request.badReason == "package_problem") { | |||||
| println(" Bad reason is 'package_problem' - calling handleBadItemPackageProblem") | |||||
| handleBadItemPackageProblem(request, badItemQtyForProcessing) | |||||
| } else { | |||||
| println(" Bad reason is 'quantity_problem' - calling handleBadItemOnly") | |||||
| // quantity_problem or default: handle as normal bad item | |||||
| handleBadItemOnly(request, badItemQtyForProcessing) | |||||
| } | |||||
| } | |||||
| // 情况3: 既有 miss item 又有 bad item | |||||
| missQtyForProcessing > BigDecimal.ZERO && badItemQtyForProcessing > BigDecimal.ZERO -> { | |||||
| println("→ Handling: Both Miss and Bad Item") | |||||
| // NEW: Check bad reason | |||||
| if (request.badReason == "package_problem") { | |||||
| println(" Bad reason is 'package_problem' - calling handleBothMissAndBadItemPackageProblem") | |||||
| handleBothMissAndBadItemPackageProblem(request, missQtyForProcessing, badItemQtyForProcessing) | |||||
| } else { | |||||
| println(" Bad reason is 'quantity_problem' - calling handleBothMissAndBadItem") | |||||
| handleBothMissAndBadItem(request, missQtyForProcessing, badItemQtyForProcessing) | |||||
| } | |||||
| } | |||||
| // 情况4: 有 miss item 的情况(无论 actualPickQty 是多少) | |||||
| missQtyForProcessing > BigDecimal.ZERO -> { | |||||
| println("→ Handling: Miss Item With Partial Pick") | |||||
| handleMissItemWithPartialPick(request, actualPickQtyForProcessing, missQtyForProcessing) | |||||
| } | |||||
| // 情况5: 正常拣货 (actualPickQty > 0, 没有 miss 或 bad item) | |||||
| actualPickQtyForProcessing > BigDecimal.ZERO -> { | |||||
| println("→ Handling: Normal Pick") | |||||
| handleNormalPick(request, actualPickQtyForProcessing) | |||||
| } | |||||
| else -> { | |||||
| println("⚠️ Unknown case: actualPickQty=$actualPickQtyForProcessing, missQty=$missQtyForProcessing, badItemQty=$badItemQtyForProcessing") | |||||
| } | |||||
| } | |||||
| val pickOrderForCompletion = pickOrderRepository.findById(request.pickOrderId).orElse(null) | |||||
| val consoCode = pickOrderForCompletion?.consoCode | |||||
| if (!consoCode.isNullOrBlank()) { | |||||
| // 优先走原来的 consoCode 逻辑(兼容已有 DO 流程) | |||||
| println("🔍 Checking if pick order $consoCode should be completed after lot rejection...") | |||||
| try { | |||||
| checkAndCompletePickOrder(consoCode) | |||||
| } catch (e: Exception) { | |||||
| println("⚠️ Error checking pick order completion by consoCode: ${e.message}") | |||||
| } | |||||
| } else if (pickOrderForCompletion != null) { | |||||
| // 🔁 没有 consoCode 的情况:改用 pickOrderId 去检查是否可以完结 | |||||
| println("🔍 Checking if pick order ${pickOrderForCompletion.code} (ID=${pickOrderForCompletion.id}) " + | |||||
| "should be completed after lot rejection (no consoCode)...") | |||||
| try { | |||||
| checkAndCompletePickOrderByPickOrderId(pickOrderForCompletion.id!!) | |||||
| } catch (e: Exception) { | |||||
| println("⚠️ Error checking pick order completion by pickOrderId: ${e.message}") | |||||
| } | |||||
| // 7. 按规则:issue form 只记录问题 +(可选)把 SOL 标记为 checked | |||||
| val stockOutLines = stockOutLineRepository | |||||
| .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | |||||
| request.pickOrderLineId, | |||||
| request.lotId ?: 0L | |||||
| ) | |||||
| stockOutLines.forEach { sol -> | |||||
| sol.status = "checked" | |||||
| sol.modified = LocalDateTime.now() | |||||
| sol.modifiedBy = "system" | |||||
| stockOutLineRepository.save(sol) | |||||
| } | } | ||||
| stockOutLineRepository.flush() | |||||
| println("=== recordPickExecutionIssue: SUCCESS ===") | println("=== recordPickExecutionIssue: SUCCESS ===") | ||||
| println("Issue ID: ${savedIssue.id}, Issue No: ${savedIssue.issueNo}") | println("Issue ID: ${savedIssue.id}, Issue No: ${savedIssue.issueNo}") | ||||
| @@ -385,6 +349,23 @@ open class PickExecutionIssueService( | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| private fun handleAllZeroMarkCompleted(request: PickExecutionIssueRequest) { | |||||
| val stockOutLines = stockOutLineRepository | |||||
| .findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | |||||
| request.pickOrderLineId, | |||||
| request.lotId ?: 0L | |||||
| ) | |||||
| stockOutLines.forEach { sol -> | |||||
| // issue form 不完结,只标记 checked,让 submit/batch submit 决定 completed(允许 0) | |||||
| sol.status = "checked" | |||||
| sol.modified = LocalDateTime.now() | |||||
| sol.modifiedBy = "system" | |||||
| stockOutLineRepository.save(sol) | |||||
| println("All-zero case: mark stock out line ${sol.id} as checked (qty kept as ${sol.qty})") | |||||
| } | |||||
| stockOutLineRepository.flush() | |||||
| } | |||||
| private fun generateIssueNo(): String { | private fun generateIssueNo(): String { | ||||
| val now = LocalDateTime.now() | val now = LocalDateTime.now() | ||||
| val yearMonth = now.format(java.time.format.DateTimeFormatter.ofPattern("yyMM")) | val yearMonth = now.format(java.time.format.DateTimeFormatter.ofPattern("yyMM")) | ||||
| @@ -717,34 +698,18 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac | |||||
| // ✅ 修改:不更新 unavailableQty(因为不 reject lot) | // ✅ 修改:不更新 unavailableQty(因为不 reject lot) | ||||
| // ✅ 修改:不 reject stock_out_line,根据 actualPickQty 设置状态 | |||||
| // ✅ 按规则:issue form 不负责完结/数量提交,只记录问题 +(可选)把 SOL 标记为 checked | |||||
| val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | val stockOutLines = stockOutLineRepository.findByPickOrderLineIdAndInventoryLotLineIdAndDeletedFalse( | ||||
| request.pickOrderLineId, | request.pickOrderLineId, | ||||
| request.lotId ?: 0L | request.lotId ?: 0L | ||||
| ) | ) | ||||
| stockOutLines.forEach { stockOutLine -> | stockOutLines.forEach { stockOutLine -> | ||||
| val requiredQty = request.requiredQty?.toDouble() ?: 0.0 | |||||
| val actualPickQtyDouble = actualPickQty.toDouble() | |||||
| // 设置状态:如果 actualPickQty >= requiredQty,则为 completed,否则为 partially_completed | |||||
| val newStatus = if (actualPickQtyDouble >= requiredQty) { | |||||
| "completed" | |||||
| } else { | |||||
| "partially_completed" | |||||
| } | |||||
| stockOutLine.status = newStatus | |||||
| stockOutLine.qty = actualPickQtyDouble | |||||
| stockOutLine.status = "checked" | |||||
| stockOutLine.modified = LocalDateTime.now() | stockOutLine.modified = LocalDateTime.now() | ||||
| stockOutLine.modifiedBy = "system" | stockOutLine.modifiedBy = "system" | ||||
| val savedStockOutLine = stockOutLineRepository.saveAndFlush(stockOutLine) | |||||
| println("Updated stock out line ${stockOutLine.id} status to: ${newStatus} (NOT rejected)") | |||||
| // ✅ 修复:使用更新前的 onHandQty 计算 balance | |||||
| val balance = onHandQtyBeforeUpdate - actualPickQtyDouble | |||||
| createStockLedgerForStockOut(savedStockOutLine, "Nor", balance) | |||||
| stockOutLineRepository.saveAndFlush(stockOutLine) | |||||
| println("Issue form: marked stock out line ${stockOutLine.id} as checked (no completion)") | |||||
| } | } | ||||
| // ✅ 修复:检查 pick order line 是否应该标记为完成 | // ✅ 修复:检查 pick order line 是否应该标记为完成 | ||||
| @@ -21,8 +21,10 @@ import com.ffii.fpsms.modules.pickOrder.enums.PickOrderStatus | |||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroup | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroup | ||||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroupRepository | import com.ffii.fpsms.modules.pickOrder.entity.PickOrderGroupRepository | ||||
| import com.ffii.fpsms.modules.pickOrder.web.models.* | import com.ffii.fpsms.modules.pickOrder.web.models.* | ||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOut | import com.ffii.fpsms.modules.stock.entity.StockOut | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLine | |||||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.StockOutRepository | import com.ffii.fpsms.modules.stock.entity.StockOutRepository | ||||
| import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | import com.ffii.fpsms.modules.stock.entity.enum.InventoryLotLineStatus | ||||
| @@ -1458,7 +1460,7 @@ open class PickOrderService( | |||||
| println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") | println("=== DEBUG: checkAndCompletePickOrderByConsoCode ===") | ||||
| println("consoCode: $consoCode") | println("consoCode: $consoCode") | ||||
| val stockOut = stockOutRepository.findByConsoPickOrderCode(consoCode).orElse(null) | |||||
| val stockOut = stockOutRepository.findFirstByConsoPickOrderCodeOrderByIdDesc(consoCode) | |||||
| if (stockOut == null) { | if (stockOut == null) { | ||||
| println("❌ No stock_out found for consoCode: $consoCode") | println("❌ No stock_out found for consoCode: $consoCode") | ||||
| return MessageResponse( | return MessageResponse( | ||||
| @@ -3357,286 +3359,7 @@ ORDER BY | |||||
| val enrichedResults = filteredResults | val enrichedResults = filteredResults | ||||
| return enrichedResults | return enrichedResults | ||||
| } | } | ||||
| // 修改后的逻辑 | |||||
| /* | |||||
| open fun getAllPickOrderLotsWithDetailsHierarchicalold(userId: Long): Map<String, Any?> { | |||||
| println("=== Debug: getAllPickOrderLotsWithDetailsHierarchical (Repository-based) ===") | |||||
| println("userId filter: $userId") | |||||
| val user = userService.find(userId).orElse(null) | |||||
| if (user == null) { | |||||
| println("❌ User not found: $userId") | |||||
| return emptyMap() | |||||
| } | |||||
| // Step 1:直接按 handledBy 查当前用户的活动 do_pick_order(一个 ticket) | |||||
| val activeTicketStatuses = listOf("released", "picking") // 如果你用的是 DoPickOrderStatus 枚举,也可以改成 List<DoPickOrderStatus> | |||||
| val doPickOrder = doPickOrderRepository | |||||
| .findFirstByHandledByAndDeletedFalseAndTicketStatusIn(user.id!!, activeTicketStatuses) | |||||
| if (doPickOrder == null) { | |||||
| println("❌ No active do_pick_order found for handledBy user $userId") | |||||
| return mapOf( | |||||
| "fgInfo" to null, | |||||
| "pickOrders" to emptyList<Any>() | |||||
| ) | |||||
| } | |||||
| val doPickOrderId = doPickOrder.id!! | |||||
| println(" Using do_pick_order ID (by handledBy): $doPickOrderId") | |||||
| // Step 2:用这个 do_pick_orderId 查对应的 do_pick_order_line / pick_order | |||||
| val allDoPickOrderLines = doPickOrderLineRepository.findByDoPickOrderIdAndDeletedFalse(doPickOrderId) | |||||
| val allPickOrderIdsForThisTicket = allDoPickOrderLines.mapNotNull { it.pickOrderId }.distinct() | |||||
| println(" Found ${allPickOrderIdsForThisTicket.size} pick orders in this do_pick_order (including completed)") | |||||
| // Step 3:加载这些 pick orders(包括 COMPLETED) | |||||
| val pickOrders = pickOrderRepository.findAllById(allPickOrderIdsForThisTicket) | |||||
| .filter { | |||||
| it.deleted == false && | |||||
| it.assignTo?.id == userId && | |||||
| it.type?.value == "do" | |||||
| } | |||||
| println(" Loaded ${pickOrders.size} pick orders (including completed)") | |||||
| // Step 4:原来你从 3413 行开始的收集所有 line / lots 的逻辑,全部保留 | |||||
| val allPickOrderLineIds = pickOrders.flatMap { it.pickOrderLines }.mapNotNull { it.id } | |||||
| val allSuggestions = suggestPickLotRepository.findAllByPickOrderLineIdIn(allPickOrderLineIds) | |||||
| val allStockOutLines = allPickOrderLineIds.flatMap { lineId -> | |||||
| stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(lineId) | |||||
| } | |||||
| val suggestionsByLineId = allSuggestions.groupBy { spl: SuggestedPickLot -> | |||||
| spl.pickOrderLine?.id | |||||
| } | |||||
| val stockOutLinesByLineId = allStockOutLines.groupBy { sol: StockOutLineInfo -> | |||||
| sol.pickOrderLineId | |||||
| } | |||||
| val allPickOrderLines = mutableListOf<Map<String, Any?>>() | |||||
| val lineCountsPerPickOrder = mutableListOf<Int>() | |||||
| val pickOrderIdsList = mutableListOf<Long>() | |||||
| val pickOrderCodesList = mutableListOf<String>() | |||||
| val doOrderIdsList = mutableListOf<Long>() | |||||
| val deliveryOrderCodesList = mutableListOf<String>() | |||||
| pickOrders.forEach { po -> | |||||
| pickOrderIdsList.add(po.id!!) | |||||
| pickOrderCodesList.add(po.code ?: "") | |||||
| val doOrderId = po.deliveryOrder?.id | |||||
| if (doOrderId != null) doOrderIdsList.add(doOrderId) | |||||
| deliveryOrderCodesList.add(po.deliveryOrder?.code ?: "") | |||||
| val lines = po.pickOrderLines.filter { !it.deleted } | |||||
| val lineDtos = po.pickOrderLines | |||||
| .filter { !it.deleted } | |||||
| .map { pol -> | |||||
| val lineId = pol.id | |||||
| val item = pol.item | |||||
| val uom = pol.uom | |||||
| // 获取该 line 的 suggestions 和 stock out lines | |||||
| val suggestions = lineId?.let { suggestionsByLineId[it] } ?: emptyList() | |||||
| val stockOutLines = lineId?.let { stockOutLinesByLineId[it] } ?: emptyList() | |||||
| // 构建 lots(合并相同 lot 的多个 suggestions) | |||||
| val lotMap = mutableMapOf<Long?, LotDetailResponse>() | |||||
| suggestions.forEach { spl -> | |||||
| val ill = spl.suggestedLotLine | |||||
| if (ill != null && ill.id != null) { | |||||
| val illId = ill.id!! | |||||
| val illEntity = inventoryLotLinesMap[illId] ?: ill | |||||
| val il = illEntity.inventoryLot | |||||
| val w = illEntity.warehouse | |||||
| val isExpired = il?.expiryDate?.let { exp -> exp.isBefore(today) } == true | |||||
| val availableQty = (illEntity.inQty ?: zero) | |||||
| .minus(illEntity.outQty ?: zero) | |||||
| .minus(illEntity.holdQty ?: zero) | |||||
| // 查找对应的 stock out line | |||||
| val stockOutLine = stockOutLines.find { sol -> | |||||
| sol.inventoryLotLineId == illId | |||||
| } | |||||
| // 计算 actualPickQty | |||||
| val actualPickQty = stockOutLine?.qty?.let { numToBigDecimal(it as? Number) } | |||||
| if (lotMap.containsKey(illId)) { | |||||
| // 合并 requiredQty | |||||
| val existing = lotMap[illId]!! | |||||
| val newRequiredQty = (existing.requiredQty ?: zero).plus(spl.qty ?: zero) | |||||
| lotMap[illId] = existing.copy(requiredQty = newRequiredQty) | |||||
| } else { | |||||
| lotMap[illId] = LotDetailResponse( | |||||
| id = illId, | |||||
| lotNo = il?.lotNo, | |||||
| expiryDate = il?.expiryDate, | |||||
| location = w?.code, | |||||
| stockUnit = illEntity.stockUom?.uom?.udfudesc ?: uom?.udfudesc ?: "N/A", | |||||
| availableQty = availableQty, | |||||
| requiredQty = spl.qty, | |||||
| actualPickQty = actualPickQty, | |||||
| inQty = illEntity.inQty, | |||||
| outQty = illEntity.outQty, | |||||
| holdQty = illEntity.holdQty, | |||||
| lotStatus = illEntity.status?.value, | |||||
| lotAvailability = when { | |||||
| isExpired -> "expired" | |||||
| stockOutLine?.status == "rejected" -> "rejected" | |||||
| availableQty <= zero -> "insufficient_stock" | |||||
| illEntity.status?.value == "unavailable" -> "status_unavailable" | |||||
| else -> "available" | |||||
| }, | |||||
| processingStatus = when { | |||||
| stockOutLine?.status == "completed" -> "completed" | |||||
| stockOutLine?.status == "rejected" -> "rejected" | |||||
| else -> "pending" | |||||
| }, | |||||
| suggestedPickLotId = spl.id, | |||||
| stockOutLineId = stockOutLine?.id, | |||||
| stockOutLineStatus = stockOutLine?.status, | |||||
| stockOutLineQty = stockOutLine?.qty?.let { numToBigDecimal(it as? Number) }, | |||||
| router = RouterInfoResponse( | |||||
| id = null, | |||||
| index = w?.order.toString(), | |||||
| route = w?.code, | |||||
| area = w?.code | |||||
| ) | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| val lots = lotMap.values.toList() | |||||
| // 构建 stockouts(包括没有 lot 的) | |||||
| val stockouts = stockOutLines.map { sol -> | |||||
| val illId = sol.inventoryLotLineId | |||||
| val ill = illId?.let { inventoryLotLinesMap[it] } | |||||
| val il = ill?.inventoryLot | |||||
| val w = ill?.warehouse | |||||
| val available = if (ill == null) null else | |||||
| (ill.inQty ?: zero) | |||||
| .minus(ill.outQty ?: zero) | |||||
| .minus(ill.holdQty ?: zero) | |||||
| StockOutDetailResponse( | |||||
| id = sol.id, | |||||
| status = sol.status, | |||||
| qty = sol.qty?.let { numToBigDecimal(it as? Number) }, | |||||
| lotId = ill?.id, | |||||
| lotNo = il?.lotNo ?: "", | |||||
| location = w?.code ?: "", | |||||
| availableQty = available, | |||||
| noLot = (ill == null) | |||||
| ) | |||||
| } | |||||
| PickOrderLineDetailResponse( | |||||
| id = lineId, | |||||
| requiredQty = pol.qty, | |||||
| status = pol.status?.value, | |||||
| item = ItemInfoResponse( | |||||
| id = item?.id, | |||||
| code = item?.code, | |||||
| name = item?.name, | |||||
| uomCode = uom?.code, | |||||
| uomDesc = uom?.udfudesc, | |||||
| uomShortDesc = uom?.udfShortDesc | |||||
| ), | |||||
| lots = lots, | |||||
| stockouts = stockouts | |||||
| ) | |||||
| } | |||||
| lineCountsPerPickOrder.add(lineDtos.size) | |||||
| allPickOrderLines.addAll(lineDtos) | |||||
| } | |||||
| // 排序、fgInfo、mergedPickOrder 这些也全部沿用你当前代码,只要用上面定义好的 doPickOrder/doPickOrderId 即可: | |||||
| allPickOrderLines.sortWith(compareBy( | |||||
| { line -> | |||||
| val lots = line["lots"] as? List<Map<String, Any?>> | |||||
| val firstLot = lots?.firstOrNull() | |||||
| val router = firstLot?.get("router") as? Map<String, Any?> | |||||
| val indexValue = router?.get("index") | |||||
| val floorSortValue = when (indexValue) { | |||||
| is String -> { | |||||
| val parts = indexValue.split("-") | |||||
| if (parts.isNotEmpty()) { | |||||
| val floorPart = parts[0].uppercase() | |||||
| when (floorPart) { | |||||
| "1F" -> 1 | |||||
| "2F", "4F" -> 2 | |||||
| else -> 3 | |||||
| } | |||||
| } else 3 | |||||
| } | |||||
| else -> 3 | |||||
| } | |||||
| floorSortValue | |||||
| }, | |||||
| { line -> | |||||
| val lots = line["lots"] as? List<Map<String, Any?>> | |||||
| val firstLot = lots?.firstOrNull() | |||||
| val router = firstLot?.get("router") as? Map<String, Any?> | |||||
| val indexValue = router?.get("index") | |||||
| when (indexValue) { | |||||
| is Number -> indexValue.toInt() | |||||
| is String -> { | |||||
| val parts = indexValue.split("-") | |||||
| if (parts.size > 1) { | |||||
| parts.last().toIntOrNull() ?: 999999 | |||||
| } else { | |||||
| indexValue.toIntOrNull() ?: 999999 | |||||
| } | |||||
| } | |||||
| else -> 999999 | |||||
| } | |||||
| } | |||||
| )) | |||||
| val fgInfo = mapOf( | |||||
| "doPickOrderId" to doPickOrderId, | |||||
| "ticketNo" to doPickOrder.ticketNo, | |||||
| "storeId" to doPickOrder.storeId, | |||||
| "shopCode" to doPickOrder.shopCode, | |||||
| "shopName" to doPickOrder.shopName, | |||||
| "truckLanceCode" to doPickOrder.truckLanceCode, | |||||
| "departureTime" to doPickOrder.truckDepartureTime?.toString() | |||||
| ) | |||||
| val mergedPickOrder = if (pickOrders.isNotEmpty()) { | |||||
| val firstPickOrder = pickOrders.first() | |||||
| mapOf( | |||||
| "pickOrderIds" to pickOrderIdsList, | |||||
| "pickOrderCodes" to pickOrderCodesList, | |||||
| "doOrderIds" to doOrderIdsList, | |||||
| "deliveryOrderCodes" to deliveryOrderCodesList, | |||||
| "lineCountsPerPickOrder" to lineCountsPerPickOrder, | |||||
| "consoCodes" to pickOrders.mapNotNull { it.consoCode }.distinct(), | |||||
| "status" to doPickOrder.ticketStatus?.value, | |||||
| "targetDate" to firstPickOrder.targetDate?.toLocalDate()?.toString(), | |||||
| "pickOrderLines" to allPickOrderLines | |||||
| ) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| return mapOf( | |||||
| "fgInfo" to fgInfo, | |||||
| "pickOrders" to listOfNotNull(mergedPickOrder) | |||||
| ) | |||||
| } | |||||
| */ | |||||
| open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, Any?> { | open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, Any?> { | ||||
| println("=== Debug: getAllPickOrderLotsWithDetailsHierarchical (NEW STRUCTURE) ===") | println("=== Debug: getAllPickOrderLotsWithDetailsHierarchical (NEW STRUCTURE) ===") | ||||
| println("userId filter: $userId") | println("userId filter: $userId") | ||||
| @@ -4159,112 +3882,202 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @Transactional(rollbackFor = [java.lang.Exception::class]) | @Transactional(rollbackFor = [java.lang.Exception::class]) | ||||
| open fun confirmLotSubstitution(req: LotSubstitutionConfirmRequest): MessageResponse { | open fun confirmLotSubstitution(req: LotSubstitutionConfirmRequest): MessageResponse { | ||||
| val zero = BigDecimal.ZERO | val zero = BigDecimal.ZERO | ||||
| // Validate pick order line | // Validate pick order line | ||||
| val pol = req.pickOrderLineId.let { pickOrderLineRepository.findById(it).orElse(null) } | |||||
| val pol = pickOrderLineRepository.findById(req.pickOrderLineId).orElse(null) | |||||
| ?: return MessageResponse( | ?: return MessageResponse( | ||||
| id = null, name = "Pick order line not found", code = "ERROR", type = "pickorder", | id = null, name = "Pick order line not found", code = "ERROR", type = "pickorder", | ||||
| message = "Pick order line ${req.pickOrderLineId} not found", errorPosition = null | message = "Pick order line ${req.pickOrderLineId} not found", errorPosition = null | ||||
| ) | ) | ||||
| val polItemId = pol.item?.id | val polItemId = pol.item?.id | ||||
| if (polItemId == null) { | |||||
| return MessageResponse( | |||||
| ?: return MessageResponse( | |||||
| id = null, name = "Item not found", code = "ERROR", type = "pickorder", | id = null, name = "Item not found", code = "ERROR", type = "pickorder", | ||||
| message = "Pick order line item is null", errorPosition = null | message = "Pick order line item is null", errorPosition = null | ||||
| ) | ) | ||||
| } | |||||
| // ✅ 根据 lotNo 和 itemId 查找新的 InventoryLotLine | |||||
| val newIll = when { | |||||
| // 优先使用 stockInLineId(更可靠) | |||||
| req.newStockInLineId != null && req.newStockInLineId > 0 -> { | |||||
| // 通过 stockInLineId 查找 InventoryLot | |||||
| val inventoryLot = inventoryLotRepository.findByStockInLineIdAndDeletedFalse(req.newStockInLineId) | |||||
| ?: return MessageResponse( | |||||
| id = null, name = "Inventory lot not found", code = "ERROR", type = "pickorder", | |||||
| message = "Inventory lot with stockInLineId ${req.newStockInLineId} not found", | |||||
| errorPosition = null | |||||
| ) | |||||
| // 通过 InventoryLot 和 itemId 查找 InventoryLotLine | |||||
| val lotLines = inventoryLotLineRepository.findAllByInventoryLotId(inventoryLot.id!!) | |||||
| .filter { it.inventoryLot?.item?.id == polItemId && !it.deleted } | |||||
| if (lotLines.isEmpty()) { | |||||
| return MessageResponse( | |||||
| id = null, name = "Lot line not found", code = "ERROR", type = "pickorder", | |||||
| message = "Inventory lot line with stockInLineId ${req.newStockInLineId} and itemId ${polItemId} not found", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| // 如果有多个,取第一个(通常应该只有一个) | |||||
| lotLines.first() | |||||
| } | |||||
| // 兼容旧方式:使用 lotNo | |||||
| req.newInventoryLotNo != null && req.newInventoryLotNo.isNotBlank() -> { | |||||
| inventoryLotLineRepository.findByLotNoAndItemId(req.newInventoryLotNo, polItemId) | |||||
| ?: return MessageResponse( | |||||
| id = null, name = "New lot line not found", code = "ERROR", type = "pickorder", | |||||
| message = "Inventory lot line with lotNo '${req.newInventoryLotNo}' and itemId ${polItemId} not found", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| else -> { | |||||
| return MessageResponse( | |||||
| id = null, name = "Invalid request", code = "ERROR", type = "pickorder", | |||||
| message = "Either newStockInLineId or newInventoryLotNo must be provided", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| } | |||||
| // Find new InventoryLotLine (from stockInLineId first, fallback lotNo) | |||||
| val newIll = resolveNewInventoryLotLine(req, polItemId) | |||||
| ?: return MessageResponse( | |||||
| id = null, name = "New lot line not found", code = "ERROR", type = "pickorder", | |||||
| message = "Cannot resolve new inventory lot line", errorPosition = null | |||||
| ) | |||||
| // Item consistency check (应该已经通过上面的查询保证了,但再次确认) | |||||
| // Item consistency check | |||||
| val newItemId = newIll.inventoryLot?.item?.id | val newItemId = newIll.inventoryLot?.item?.id | ||||
| if (newItemId == null || polItemId != newItemId) { | |||||
| if (newItemId == null || newItemId != polItemId) { | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = null, name = "Item mismatch", code = "ERROR", type = "pickorder", | id = null, name = "Item mismatch", code = "ERROR", type = "pickorder", | ||||
| message = "New lot line item does not match pick order line item", errorPosition = null | message = "New lot line item does not match pick order line item", errorPosition = null | ||||
| ) | ) | ||||
| } | } | ||||
| val newIllId = newIll.id ?: return MessageResponse( | |||||
| id = null, name = "Invalid lot line", code = "ERROR", type = "pickorder", | |||||
| message = "New inventory lot line has no ID", errorPosition = null | |||||
| ) | |||||
| val newLotNo = newIll.inventoryLot?.lotNo ?: req.newInventoryLotNo ?: "unknown" | |||||
| // 1) Update suggested pick lot (if provided): move holdQty from old ILL to new ILL and re-point the suggestion | |||||
| if (req.originalSuggestedPickLotId != null && req.originalSuggestedPickLotId > 0) { | |||||
| // ✅ 使用 repository 而不是 SQL | |||||
| val originalSpl = suggestPickLotRepository.findById(req.originalSuggestedPickLotId).orElse(null) | |||||
| if (originalSpl != null) { | |||||
| val oldIll = originalSpl.suggestedLotLine | |||||
| val qty = originalSpl.qty ?: zero | |||||
| // Resolve SuggestedPickLot: | |||||
| // - If originalSuggestedPickLotId provided: use it | |||||
| // - Else (1:1 assumption): find by pickOrderLineId (optionally also by stockOutLineId if you add repository method) | |||||
| val spl = if (req.originalSuggestedPickLotId != null && req.originalSuggestedPickLotId > 0) { | |||||
| suggestPickLotRepository.findById(req.originalSuggestedPickLotId).orElse(null) | |||||
| } else { | |||||
| // 1:1 assumption fallback (you need a repository method; replace with your actual one) | |||||
| // e.g. suggestPickLotRepository.findFirstByPickOrderLineIdAndDeletedFalseOrderByIdDesc(req.pickOrderLineId) | |||||
| suggestPickLotRepository.findFirstByPickOrderLineId(req.pickOrderLineId) | |||||
| } | |||||
| if (oldIll != null && oldIll.id != newIllId) { | |||||
| // Decrease hold on old, increase on new | |||||
| oldIll.holdQty = (oldIll.holdQty ?: zero).minus(qty).max(zero) | |||||
| inventoryLotLineRepository.save(oldIll) | |||||
| newIll.holdQty = (newIll.holdQty ?: zero).plus(qty) | |||||
| inventoryLotLineRepository.save(newIll) | |||||
| } | |||||
| if (spl == null) { | |||||
| return MessageResponse( | |||||
| id = null, name = "Suggested pick lot not found", code = "ERROR", type = "pickorder", | |||||
| message = "SuggestedPickLot not found for pickOrderLineId=${req.pickOrderLineId}", errorPosition = null | |||||
| ) | |||||
| } | |||||
| val qtyToHold = spl.qty ?: zero | |||||
| if (qtyToHold.compareTo(zero) <= 0) { | |||||
| return MessageResponse( | |||||
| id = null, name = "Invalid qty", code = "ERROR", type = "pickorder", | |||||
| message = "SuggestedPickLot qty is invalid: $qtyToHold", errorPosition = null | |||||
| ) | |||||
| } | |||||
| // Availability check on newIll BEFORE updates | |||||
| val inQty = newIll.inQty ?: zero | |||||
| val outQty = newIll.outQty ?: zero | |||||
| val holdQty = newIll.holdQty ?: zero | |||||
| val issueQty = newIll.issueQty ?: zero | |||||
| val available = inQty.subtract(outQty).subtract(holdQty).subtract(issueQty) | |||||
| if (available.compareTo(qtyToHold) < 0) { | |||||
| return MessageResponse( | |||||
| id = null, name = "Insufficient lot qty", code = "REJECT", type = "pickorder", | |||||
| message = "Reject switch lot: available=$available < required=$qtyToHold", errorPosition = null | |||||
| ) | |||||
| } | |||||
| // ✅ 使用 repository 更新 suggestion | |||||
| originalSpl.suggestedLotLine = newIll | |||||
| suggestPickLotRepository.save(originalSpl) | |||||
| val oldIll = spl.suggestedLotLine | |||||
| // Load stock out line (if provided) to decide "bind vs split" | |||||
| val existingSol = if (req.stockOutLineId != null && req.stockOutLineId > 0) { | |||||
| stockOutLIneRepository.findById(req.stockOutLineId).orElse(null) | |||||
| } else null | |||||
| val pickedQty = existingSol?.qty?.let { numToBigDecimal(it as? Number) } ?: zero | |||||
| // Switch lot rule: | |||||
| // - actual pick == 0: replace/bind (no new line) | |||||
| // - actual pick > 0: split remaining qty into a NEW suggested pick lot + stock out line | |||||
| if (pickedQty.compareTo(zero) > 0) { | |||||
| val remaining = qtyToHold.subtract(pickedQty) | |||||
| if (remaining.compareTo(zero) <= 0) { | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "No remaining qty", | |||||
| code = "REJECT", | |||||
| type = "pickorder", | |||||
| message = "Reject switch lot: picked=$pickedQty already >= required=$qtyToHold", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| // Move HOLD for remaining qty (old -> new) | |||||
| if (oldIll != null && oldIll.id != null && oldIll.id != newIll.id) { | |||||
| val oldHold = oldIll.holdQty ?: zero | |||||
| val newOldHold = oldHold.subtract(remaining) | |||||
| oldIll.holdQty = if (newOldHold.compareTo(zero) < 0) zero else newOldHold | |||||
| inventoryLotLineRepository.save(oldIll) | |||||
| val newHold = (newIll.holdQty ?: zero).add(remaining) | |||||
| newIll.holdQty = newHold | |||||
| inventoryLotLineRepository.save(newIll) | |||||
| } | |||||
| if (oldIll == null) { | |||||
| val newHold = (newIll.holdQty ?: zero).add(remaining) | |||||
| newIll.holdQty = newHold | |||||
| inventoryLotLineRepository.save(newIll) | |||||
| } | |||||
| // Lock current suggestion qty to the picked qty (picked part stays on oldIll) | |||||
| spl.qty = pickedQty | |||||
| suggestPickLotRepository.saveAndFlush(spl) | |||||
| // Create a NEW stock out line + suggestion for the remaining qty | |||||
| val stockOut: StockOut = if (existingSol != null) { | |||||
| existingSol.stockOut ?: throw IllegalStateException("Existing StockOutLine has null stockOut") | |||||
| } else { | |||||
| val consoCode = pol.pickOrder?.consoCode ?: "" | |||||
| val existing = stockOutRepository.findByConsoPickOrderCode(consoCode).orElse(null) | |||||
| if (existing != null) { | |||||
| existing | |||||
| } else { | |||||
| val handlerId = pol.pickOrder?.assignTo?.id ?: SecurityUtils.getUser().orElse(null)?.id | |||||
| require(handlerId != null) { "Cannot create StockOut: handlerId is null" } | |||||
| val newStockOut = StockOut().apply { | |||||
| this.consoPickOrderCode = consoCode | |||||
| this.type = pol.pickOrder?.type?.value ?: "" | |||||
| this.status = StockOutStatus.PENDING.status | |||||
| this.handler = handlerId | |||||
| } | |||||
| stockOutRepository.save(newStockOut) | |||||
| } | |||||
| } | |||||
| val newSol = StockOutLine().apply { | |||||
| this.stockOut = stockOut | |||||
| this.pickOrderLine = pol | |||||
| this.item = pol.item | |||||
| this.inventoryLotLine = newIll | |||||
| this.qty = 0.0 | |||||
| this.status = StockOutLineStatus.CHECKED.status | |||||
| this.startTime = LocalDateTime.now() | |||||
| this.type = existingSol?.type ?: "Nor" | |||||
| } | |||||
| stockOutLIneRepository.saveAndFlush(newSol) | |||||
| val newSpl = SuggestedPickLot().apply { | |||||
| this.type = spl.type | |||||
| this.pickOrderLine = pol | |||||
| this.suggestedLotLine = newIll | |||||
| this.stockOutLine = newSol | |||||
| this.qty = remaining | |||||
| this.pickSuggested = spl.pickSuggested | |||||
| } | } | ||||
| suggestPickLotRepository.saveAndFlush(newSpl) | |||||
| val newLotNo = newIll.inventoryLot?.lotNo ?: req.newInventoryLotNo | |||||
| return MessageResponse( | |||||
| id = null, | |||||
| name = "Lot substitution confirmed (split line)", | |||||
| code = "SUCCESS", | |||||
| type = "pickorder", | |||||
| message = "Picked=$pickedQty, created new line for remaining=$remaining on lotNo '$newLotNo'", | |||||
| errorPosition = null | |||||
| ) | |||||
| } | |||||
| // If oldIll exists and different: move hold old -> new | |||||
| if (oldIll != null && oldIll.id != null && oldIll.id != newIll.id) { | |||||
| val oldHold = oldIll.holdQty ?: zero | |||||
| val newOldHold = oldHold.subtract(qtyToHold) | |||||
| oldIll.holdQty = if (newOldHold.compareTo(zero) < 0) zero else newOldHold | |||||
| inventoryLotLineRepository.save(oldIll) | |||||
| val newHold = (newIll.holdQty ?: zero).add(qtyToHold) | |||||
| newIll.holdQty = newHold | |||||
| inventoryLotLineRepository.save(newIll) | |||||
| } | |||||
| // If first bind (oldIll == null): just hold on new | |||||
| if (oldIll == null) { | |||||
| val newHold = (newIll.holdQty ?: zero).add(qtyToHold) | |||||
| newIll.holdQty = newHold | |||||
| inventoryLotLineRepository.save(newIll) | |||||
| } | } | ||||
| // 2) Update stock out line (if provided): re-point to new ILL; keep qty and status unchanged | |||||
| // Point suggestion to new lot line | |||||
| spl.suggestedLotLine = newIll | |||||
| suggestPickLotRepository.save(spl) | |||||
| // Update stock out line if provided | |||||
| if (req.stockOutLineId != null && req.stockOutLineId > 0) { | if (req.stockOutLineId != null && req.stockOutLineId > 0) { | ||||
| val sol = stockOutLIneRepository.findById(req.stockOutLineId).orElse(null) | val sol = stockOutLIneRepository.findById(req.stockOutLineId).orElse(null) | ||||
| if (sol != null) { | if (sol != null) { | ||||
| @@ -4274,15 +4087,43 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto | |||||
| } | } | ||||
| } | } | ||||
| val newLotNo = newIll.inventoryLot?.lotNo ?: req.newInventoryLotNo | |||||
| return MessageResponse( | return MessageResponse( | ||||
| id = null, | id = null, | ||||
| name = "Lot substitution confirmed", | name = "Lot substitution confirmed", | ||||
| code = "SUCCESS", | code = "SUCCESS", | ||||
| type = "pickorder", | type = "pickorder", | ||||
| message = "Updated suggestion and stock out line to new lot line with lotNo '${newLotNo}'", | |||||
| errorPosition = null | |||||
| message = "Updated suggestion and stock out line to new lot line with lotNo '$newLotNo'", | |||||
| errorPosition = null | |||||
| ) | ) | ||||
| } | } | ||||
| private fun resolveNewInventoryLotLine( | |||||
| req: LotSubstitutionConfirmRequest, | |||||
| itemId: Long | |||||
| ): InventoryLotLine? { | |||||
| // Prefer stockInLineId | |||||
| if (req.newStockInLineId != null && req.newStockInLineId > 0) { | |||||
| val inventoryLot = inventoryLotRepository.findByStockInLineIdAndDeletedFalse(req.newStockInLineId) | |||||
| ?: return null | |||||
| val lotLines = inventoryLotLineRepository.findAllByInventoryLotId(inventoryLot.id!!) | |||||
| .filter { it.inventoryLot?.item?.id == itemId && !it.deleted } | |||||
| return lotLines.firstOrNull() | |||||
| } | |||||
| // Fallback lotNo (req.newInventoryLotNo is non-null String in your model) | |||||
| if (req.newInventoryLotNo.isNotBlank()) { | |||||
| return inventoryLotLineRepository | |||||
| .findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( | |||||
| req.newInventoryLotNo, | |||||
| itemId | |||||
| ) | |||||
| } | |||||
| return null | |||||
| } | |||||
| open fun getCompletedDoPickOrders( | open fun getCompletedDoPickOrders( | ||||
| userId: Long, | userId: Long, | ||||
| @@ -815,6 +815,107 @@ fun searchMaterialStockOutTraceabilityReport( | |||||
| return jdbcDao.queryForList(sql, args) | return jdbcDao.queryForList(sql, args) | ||||
| } | } | ||||
| /** | |||||
| * GRN (Goods Received Note) report: stock-in lines with PO/delivery note, filterable by receipt date range and item code. | |||||
| * Returns rows for Excel export: poCode, deliveryNoteNo, receiptDate, itemCode, itemName, acceptedQty, demandQty, uom, etc. | |||||
| */ | |||||
| fun searchGrnReport( | |||||
| receiptDateStart: String?, | |||||
| receiptDateEnd: String?, | |||||
| itemCode: String? | |||||
| ): List<Map<String, Any?>> { | |||||
| val args = mutableMapOf<String, Any>() | |||||
| val receiptDateStartSql = if (!receiptDateStart.isNullOrBlank()) { | |||||
| val formatted = receiptDateStart.replace("/", "-") | |||||
| args["receiptDateStart"] = formatted | |||||
| "AND DATE(sil.receiptDate) >= DATE(:receiptDateStart)" | |||||
| } else "" | |||||
| val receiptDateEndSql = if (!receiptDateEnd.isNullOrBlank()) { | |||||
| val formatted = receiptDateEnd.replace("/", "-") | |||||
| args["receiptDateEnd"] = formatted | |||||
| "AND DATE(sil.receiptDate) <= DATE(:receiptDateEnd)" | |||||
| } else "" | |||||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) | |||||
| val sql = """ | |||||
| SELECT | |||||
| po.code AS poCode, | |||||
| CASE | |||||
| WHEN sil.dnNo = 'DN00000' OR sil.dnNo IS NULL THEN '' | |||||
| ELSE sil.dnNo | |||||
| END AS deliveryNoteNo, | |||||
| DATE_FORMAT(sil.receiptDate, '%Y-%m-%d') AS receiptDate, | |||||
| COALESCE(it.code, '') AS itemCode, | |||||
| COALESCE(it.name, '') AS itemName, | |||||
| COALESCE(sil.acceptedQty, 0) AS acceptedQty, | |||||
| COALESCE(sil.demandQty, 0) AS demandQty, | |||||
| COALESCE(uc_stock.udfudesc, uc_pol.udfudesc, '') AS uom, | |||||
| COALESCE(uc_pol.udfudesc, '') AS purchaseUomDesc, | |||||
| COALESCE(uc_stock.udfudesc, '') AS stockUomDesc, | |||||
| COALESCE(sil.productLotNo, '') AS productLotNo, | |||||
| DATE_FORMAT(sil.expiryDate, '%Y-%m-%d') AS expiryDate, | |||||
| COALESCE(sp.code, '') AS supplierCode, | |||||
| COALESCE(sp.name, '') AS supplier, | |||||
| COALESCE(sil.status, '') AS status, | |||||
| MAX(grn.m18_record_id) AS grnId | |||||
| FROM stock_in_line sil | |||||
| LEFT JOIN items it ON sil.itemId = it.id | |||||
| LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id | |||||
| LEFT JOIN shop sp ON po.supplierId = sp.id | |||||
| LEFT JOIN purchase_order_line pol ON sil.purchaseOrderLineId = pol.id | |||||
| LEFT JOIN uom_conversion uc_pol ON pol.uomId = uc_pol.id | |||||
| LEFT JOIN item_uom iu_stock ON it.id = iu_stock.itemId AND iu_stock.stockUnit = true AND iu_stock.deleted = false | |||||
| LEFT JOIN uom_conversion uc_stock ON iu_stock.uomId = uc_stock.id | |||||
| LEFT JOIN m18_goods_receipt_note_log grn | |||||
| ON grn.stock_in_line_id = sil.id | |||||
| WHERE sil.deleted = false | |||||
| AND sil.receiptDate IS NOT NULL | |||||
| AND sil.purchaseOrderId IS NOT NULL | |||||
| $receiptDateStartSql | |||||
| $receiptDateEndSql | |||||
| $itemCodeSql | |||||
| GROUP BY | |||||
| po.code, | |||||
| deliveryNoteNo, | |||||
| receiptDate, | |||||
| itemCode, | |||||
| itemName, | |||||
| acceptedQty, | |||||
| demandQty, | |||||
| uom, | |||||
| purchaseUomDesc, | |||||
| stockUomDesc, | |||||
| productLotNo, | |||||
| expiryDate, | |||||
| supplierCode, | |||||
| supplier, | |||||
| status | |||||
| ORDER BY sil.receiptDate, po.code, sil.id | |||||
| """.trimIndent() | |||||
| val rows = jdbcDao.queryForList(sql, args) | |||||
| return rows.map { row -> | |||||
| mapOf( | |||||
| "poCode" to row["poCode"], | |||||
| "deliveryNoteNo" to row["deliveryNoteNo"], | |||||
| "receiptDate" to row["receiptDate"], | |||||
| "itemCode" to row["itemCode"], | |||||
| "itemName" to row["itemName"], | |||||
| "acceptedQty" to (row["acceptedQty"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), | |||||
| "receivedQty" to (row["acceptedQty"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), | |||||
| "demandQty" to (row["demandQty"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), | |||||
| "uom" to row["uom"], | |||||
| "purchaseUomDesc" to row["purchaseUomDesc"], | |||||
| "stockUomDesc" to row["stockUomDesc"], | |||||
| "productLotNo" to row["productLotNo"], | |||||
| "expiryDate" to row["expiryDate"], | |||||
| "supplierCode" to row["supplierCode"], | |||||
| "supplier" to row["supplier"], | |||||
| "status" to row["status"], | |||||
| "grnId" to row["grnId"] | |||||
| ) | |||||
| } | |||||
| } | |||||
| /** | /** | ||||
| * Queries the database for Stock Balance Report data (one summarized row per item). | * Queries the database for Stock Balance Report data (one summarized row per item). | ||||
| * Uses stock_ledger with report period (fromDate/toDate): opening = before fromDate, cum in/out = in period, current = up to toDate. | * Uses stock_ledger with report period (fromDate/toDate): opening = before fromDate, cum in/out = in period, current = up to toDate. | ||||
| @@ -320,4 +320,18 @@ class ReportController( | |||||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | ||||
| } | } | ||||
| /** | |||||
| * GRN (Goods Received Note) report data for Excel export. | |||||
| * Query by receipt date range and optional item code. Returns JSON { "rows": [ ... ] }. | |||||
| */ | |||||
| @GetMapping("/grn-report") | |||||
| fun getGrnReport( | |||||
| @RequestParam(required = false) receiptDateStart: String?, | |||||
| @RequestParam(required = false) receiptDateEnd: String?, | |||||
| @RequestParam(required = false) itemCode: String? | |||||
| ): Map<String, Any> { | |||||
| val rows = reportService.searchGrnReport(receiptDateStart, receiptDateEnd, itemCode) | |||||
| return mapOf("rows" to rows) | |||||
| } | |||||
| } | } | ||||
| @@ -53,6 +53,7 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||||
| """) | """) | ||||
| fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLotLine? | fun findByLotNoAndItemId(lotNo: String, itemId: Long): InventoryLotLine? | ||||
| <<<<<<< HEAD | |||||
| @Query(""" | @Query(""" | ||||
| SELECT DISTINCT ill.inventoryLot.item.id | SELECT DISTINCT ill.inventoryLot.item.id | ||||
| FROM InventoryLotLine ill | FROM InventoryLotLine ill | ||||
| @@ -60,6 +61,13 @@ interface InventoryLotLineRepository : AbstractRepository<InventoryLotLine, Long | |||||
| AND ill.inventoryLot.lotNo = :lotNo | AND ill.inventoryLot.lotNo = :lotNo | ||||
| """) | """) | ||||
| fun findDistinctItemIdsByLotNo(@Param("lotNo") lotNo: String): List<Long> | fun findDistinctItemIdsByLotNo(@Param("lotNo") lotNo: String): List<Long> | ||||
| ======= | |||||
| // lotNo + itemId may not be unique (multiple warehouses/lines); pick one deterministically | |||||
| fun findFirstByInventoryLotLotNoAndInventoryLotItemIdAndDeletedFalseOrderByIdDesc( | |||||
| lotNo: String, | |||||
| itemId: Long | |||||
| ): InventoryLotLine? | |||||
| >>>>>>> 9760717ed6a5c59383467921464fb2b89a7f85a8 | |||||
| // 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> | ||||
| @@ -7,5 +7,7 @@ import java.util.Optional | |||||
| @Repository | @Repository | ||||
| interface StockOutRepository: AbstractRepository<StockOut, Long> { | interface StockOutRepository: AbstractRepository<StockOut, Long> { | ||||
| fun findByConsoPickOrderCode(consoPickOrderCode: String) : Optional<StockOut> | fun findByConsoPickOrderCode(consoPickOrderCode: String) : Optional<StockOut> | ||||
| // consoPickOrderCode 可能在 DB 中存在重复,避免 single-result exception | |||||
| fun findFirstByConsoPickOrderCodeOrderByIdDesc(consoPickOrderCode: String): StockOut? | |||||
| fun findByStockTakeIdAndDeletedFalse(stockTakeId: Long): StockOut? | fun findByStockTakeIdAndDeletedFalse(stockTakeId: Long): StockOut? | ||||
| } | } | ||||
| @@ -8,7 +8,9 @@ import org.springframework.stereotype.Repository | |||||
| interface SuggestPickLotRepository : AbstractRepository<SuggestedPickLot, Long> { | interface SuggestPickLotRepository : AbstractRepository<SuggestedPickLot, Long> { | ||||
| fun findAllByPickOrderLineIn(lines: List<PickOrderLine>): List<SuggestedPickLot> | fun findAllByPickOrderLineIn(lines: List<PickOrderLine>): List<SuggestedPickLot> | ||||
| fun findAllByPickOrderLineIdIn(pickOrderLineIds: List<Long>): List<SuggestedPickLot> | fun findAllByPickOrderLineIdIn(pickOrderLineIds: List<Long>): List<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, | ||||
| @@ -68,6 +72,35 @@ private val inventoryLotLineService: InventoryLotLineService, | |||||
| private val inventoryRepository: InventoryRepository, | private val inventoryRepository: InventoryRepository, | ||||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository | private val pickExecutionIssueRepository: PickExecutionIssueRepository | ||||
| ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ): AbstractBaseEntityService<StockOutLine, Long, StockOutLIneRepository>(jdbcDao, stockOutLineRepository) { | ||||
| private fun isEndStatus(status: String?): Boolean { | |||||
| val s = status?.trim()?.lowercase() ?: return false | |||||
| return s == "completed" || s == "rejected" || s == "partially_completed" | |||||
| } | |||||
| @Transactional | |||||
| private fun tryCompletePickOrderLine(pickOrderLineId: Long) { | |||||
| val sols = stockOutLineRepository.findAllByPickOrderLineIdAndDeletedFalse(pickOrderLineId) | |||||
| if (sols.isEmpty()) return | |||||
| val allEnded = sols.all { isEndStatus(it.status) } | |||||
| if (!allEnded) return | |||||
| val pol = pickOrderLineRepository.findById(pickOrderLineId).orElse(null) ?: return | |||||
| if (pol.status != PickOrderLineStatus.COMPLETED) { | |||||
| pol.status = PickOrderLineStatus.COMPLETED | |||||
| pickOrderLineRepository.save(pol) | |||||
| } | |||||
| // Optionally bubble up to pick order completion (safe no-op if not ready) | |||||
| val consoCode = pol.pickOrder?.consoCode | |||||
| if (!consoCode.isNullOrBlank()) { | |||||
| try { | |||||
| pickOrderService.checkAndCompletePickOrderByConsoCode(consoCode) | |||||
| } catch (e: Exception) { | |||||
| println("⚠️ Error checking pick order completion for consoCode=$consoCode: ${e.message}") | |||||
| } | |||||
| } | |||||
| } | |||||
| @Throws(IOException::class) | @Throws(IOException::class) | ||||
| @Transactional | @Transactional | ||||
| open fun findAllByStockOutId(stockOutId: Long): List<StockOutLine> { | open fun findAllByStockOutId(stockOutId: Long): List<StockOutLine> { | ||||
| @@ -620,6 +653,10 @@ private fun getStockOutIdFromPickOrderLine(pickOrderLineId: Long): Long { | |||||
| } | } | ||||
| val savedStockOutLine = stockOutLineRepository.saveAndFlush(stockOutLine) | val savedStockOutLine = stockOutLineRepository.saveAndFlush(stockOutLine) | ||||
| println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") | println("Updated StockOutLine: ${savedStockOutLine.id} with status: ${savedStockOutLine.status}") | ||||
| // If this stock out line is in end status, try completing its pick order line | |||||
| if (isEndStatus(savedStockOutLine.status)) { | |||||
| savedStockOutLine.pickOrderLine?.id?.let { tryCompletePickOrderLine(it) } | |||||
| } | |||||
| try { | try { | ||||
| val item = savedStockOutLine.item | val item = savedStockOutLine.item | ||||
| val inventoryLotLine = savedStockOutLine.inventoryLotLine | val inventoryLotLine = savedStockOutLine.inventoryLotLine | ||||
| @@ -946,22 +983,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 | ||||