diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index efc861c..c88bec6 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -1508,7 +1508,12 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List 0. + val itemIds = pickOrderLines.mapNotNull { it.item?.id }.distinct() + val today = LocalDate.now() + val totalAvailableQtyByItemId: Map = if (itemIds.isNotEmpty()) { + inventoryLotLineRepository.findAllByItemIdIn(itemIds) + .asSequence() + .filter { it.deleted == false } + .filter { it.status == InventoryLotLineStatus.AVAILABLE } // entity enum + .filter { ill -> + val exp = ill.inventoryLot?.expiryDate + exp == null || !exp.isBefore(today) + } + .mapNotNull { ill -> + val iid = ill.inventoryLot?.item?.id ?: return@mapNotNull null + val remaining = (ill.inQty ?: BigDecimal.ZERO) + .minus(ill.outQty ?: BigDecimal.ZERO) + .minus(ill.holdQty ?: BigDecimal.ZERO) + if (remaining > BigDecimal.ZERO) iid to remaining else null + } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, qtys) -> qtys.fold(BigDecimal.ZERO) { acc, q -> acc + q }.toDouble() } + } else { + emptyMap() + } // 获取所有 pick order line IDs val pickOrderLineIds = pickOrderLines.map { it.id!! } @@ -2205,6 +2243,7 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo itemCode = item?.code, itemName = item?.name, requiredQty = pol.qty?.toDouble(), + totalAvailableQty = item?.id?.let { totalAvailableQtyByItemId[it] }, uomCode = uom?.code, uomDesc = uom?.udfudesc, status = pol.status?.value, @@ -2254,8 +2293,8 @@ open fun updateHandledByForItem(pickOrderId: Long, itemId: Long, userId: Long): } val joPickOrder = joPickOrderOpt.get() - if (userId != null && joPickOrder.matchingBy != null) { - val existingOperatorId = joPickOrder.matchingBy + if (userId != null && joPickOrder.handledBy != null) { + val existingOperatorId = joPickOrder.handledBy val newOperatorId = userId // 如果不是同一个用户,拒绝更新 @@ -2270,7 +2309,8 @@ open fun updateHandledByForItem(pickOrderId: Long, itemId: Long, userId: Long): ) } } - joPickOrder.matchingBy = userId + // Update the displayed operator: jo_pick_order.handled_by + joPickOrder.handledBy = userId joPickOrderRepository.save(joPickOrder) // Don't update other fields - only handledBy diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderCreationService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderCreationService.kt new file mode 100644 index 0000000..981d8e4 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderCreationService.kt @@ -0,0 +1,32 @@ +package com.ffii.fpsms.modules.jobOrder.service + +import com.ffii.fpsms.modules.jobOrder.web.model.CreateJobOrderRequest +import com.ffii.fpsms.modules.master.web.models.MessageResponse +import com.ffii.fpsms.modules.productProcess.service.ProductProcessService +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +open class JobOrderCreationService( + private val jobOrderService: JobOrderService, + private val jobOrderBomMaterialService: JobOrderBomMaterialService, + private val jobOrderProcessService: JobOrderProcessService, + private val productProcessService: ProductProcessService, +) { + /** + * 建立 JO 並完成所有關聯資料(BOM materials / processes / productprocess + lines)。 + * 任一步失敗會整體回滾,避免留下「JO 有、productprocess 沒有」的半成品。 + */ + @Transactional(rollbackFor = [Exception::class]) + open fun createJobOrderWithFullFlow(request: CreateJobOrderRequest, productionPriority: Int?): MessageResponse { + val jo = jobOrderService.createJobOrder(request) + val joId = jo.id ?: throw IllegalStateException("Job Order creation failed: returned object ID is null.") + + jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(joId) + jobOrderProcessService.createJobOrderProcessesByJoId(joId) + productProcessService.createProductProcessByJobOrderId(joId, productionPriority) + + return jo + } +} + diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt index 106c668..855904e 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/JobOrderController.kt @@ -50,7 +50,8 @@ class JobOrderController( private val jobOrderBomMaterialService: JobOrderBomMaterialService, private val jobOrderProcessService: JobOrderProcessService, private val joPickOrderService: JoPickOrderService, - private val productProcessService: ProductProcessService + private val productProcessService: ProductProcessService, + private val jobOrderCreationService: com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService ) { @GetMapping("/getRecordByPage") @@ -94,14 +95,7 @@ class JobOrderController( @PostMapping("/manualCreate") fun manualCreateJobOrder(@Valid @RequestBody request: CreateJobOrderRequest): MessageResponse { - val jo = jobOrderService.createJobOrder(request) - if (jo.id == null) { - throw NoSuchElementException() - } - jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(jo.id) - jobOrderProcessService.createJobOrderProcessesByJoId(jo.id) - productProcessService.createProductProcessByJobOrderId(jo.id, request.productionPriority) - return jo + return jobOrderCreationService.createJobOrderWithFullFlow(request, request.productionPriority) } @GetMapping("/all-lots-hierarchical/{userId}") fun getAllJobOrderLotsHierarchical(@PathVariable userId: Long): JobOrderLotsHierarchicalResponse { diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt index 7cb0a99..515b87b 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/CreateJobOrderRequest.kt @@ -97,6 +97,8 @@ data class PickOrderLineWithLotsResponse( val itemCode: String?, val itemName: String?, val requiredQty: Double?, + // Total available qty across all inventory lot lines for this item (used by JO pick UI) + val totalAvailableQty: Double? = null, val uomCode: String?, val uomDesc: String?, val status: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt index b4ce099..ed5d8d4 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt @@ -4,7 +4,8 @@ import com.ffii.core.support.AbstractRepository import com.ffii.fpsms.modules.master.entity.projections.BomCombo import org.springframework.stereotype.Repository import java.io.Serializable - +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param @Repository interface BomRepository : AbstractRepository { fun findAllByDeletedIsFalse(): List @@ -18,4 +19,13 @@ interface BomRepository : AbstractRepository { fun findBomComboByDeletedIsFalse(): List fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List fun findByCodeAndDeletedIsFalse(code: String): Bom? + @Query(""" + select b.item.id + from Bom b + where b.deleted = false + and lower(b.description) = 'wip' + and b.item.id in :itemIds + """) + fun findWipItemIds(@Param("itemIds") itemIds: List): List +fun findFirstByItemIdAndDescriptionIgnoreCaseAndDeletedIsFalse(itemId: Long, description: String): Bom? } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index b24f9b2..e8ecaf1 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -81,10 +81,20 @@ open class ItemUomService( val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; val one = BigDecimal.ONE; - val baseQty = purchaseQty.multiply(purchaseUnit.ratioN ?: one).divide(purchaseUnit.ratioD ?: one, 2, RoundingMode.UP) - val stockQty = baseQty.multiply(stockUnit.ratioD ?: one).divide(stockUnit.ratioN ?: one, 2, RoundingMode.UP) + // IMPORTANT: + // Use high precision for intermediate steps, and round to integer only at the end. + // Rounding during intermediate steps (scale=0 + UP) can cause drift (e.g. 2 -> 11 -> 3). + val calcScale = 10 - return stockQty; + val baseQty = purchaseQty + .multiply(purchaseUnit.ratioN ?: one) + .divide(purchaseUnit.ratioD ?: one, calcScale, RoundingMode.HALF_UP) + + val stockQty = baseQty + .multiply(stockUnit.ratioD ?: one) + .divide(stockUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP) + + return stockQty.setScale(0, RoundingMode.UP) } /** Inverse of convertPurchaseQtyToStockQty: stock qty -> purchase qty (for PO-origin StockInLine display). */ @@ -92,9 +102,17 @@ open class ItemUomService( val purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty val one = BigDecimal.ONE - val baseQty = stockQty.multiply(stockUnit.ratioN ?: one).divide(stockUnit.ratioD ?: one, 2, RoundingMode.UP) - val purchaseQty = baseQty.multiply(purchaseUnit.ratioD ?: one).divide(purchaseUnit.ratioN ?: one, 2, RoundingMode.UP) - return purchaseQty + val calcScale = 10 + + val baseQty = stockQty + .multiply(stockUnit.ratioN ?: one) + .divide(stockUnit.ratioD ?: one, calcScale, RoundingMode.HALF_UP) + + val purchaseQty = baseQty + .multiply(purchaseUnit.ratioD ?: one) + .divide(purchaseUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP) + + return purchaseQty.setScale(0, RoundingMode.UP) } open fun convertQtyToStockQty(itemId: Long, uomId: Long, sourceQty: BigDecimal): BigDecimal { @@ -103,10 +121,17 @@ open class ItemUomService( val stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO; val one = BigDecimal.ONE; - val baseQty = sourceQty.multiply(itemUom?.ratioN ?: one).divide(itemUom?.ratioD ?: one, 2, RoundingMode.UP) - val stockQty = baseQty.multiply(stockUnit.ratioD ?: one).divide(stockUnit.ratioN ?: one, 2, RoundingMode.UP) + val calcScale = 10 + + val baseQty = sourceQty + .multiply(itemUom.ratioN ?: one) + .divide(itemUom.ratioD ?: one, calcScale, RoundingMode.HALF_UP) + + val stockQty = baseQty + .multiply(stockUnit.ratioD ?: one) + .divide(stockUnit.ratioN ?: one, calcScale, RoundingMode.HALF_UP) - return stockQty; + return stockQty.setScale(0, RoundingMode.UP) } // See if need to update the response diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt index 9b450ca..e97d453 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt @@ -59,6 +59,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.xssf.usermodel.XSSFPrintSetup import java.io.ByteArrayOutputStream import java.sql.Timestamp +import com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService @Service open class ProductionScheduleService( @@ -76,6 +77,7 @@ open class ProductionScheduleService( private val inventoryRepository: InventoryRepository, private val itemUomService: ItemUomService, private val productProcessService: ProductProcessService, + private val jobOrderCreationService: JobOrderCreationService, private val settingsService: SettingsService, ) : AbstractBaseEntityService( jdbcDao, @@ -462,16 +464,10 @@ open class ProductionScheduleService( prodScheduleLineId = prodScheduleLine.id!! ) - // Assuming createJobOrder returns the created Job Order (jo) - val jo = jobOrderService.createJobOrder(joRequest) - - val createdJobOrderId = jo.id - ?: throw IllegalStateException("Job Order creation failed: returned object ID is null.") - - // 7. Create related job order data - jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(createdJobOrderId) - jobOrderProcessService.createJobOrderProcessesByJoId(createdJobOrderId) - productProcessService.createProductProcessByJobOrderId(createdJobOrderId, prodScheduleLine.itemPriority.toInt()) + val jo = jobOrderCreationService.createJobOrderWithFullFlow( + request = joRequest, + productionPriority = prodScheduleLine.itemPriority.toInt() + ) jobOrderCount++ //} @@ -559,16 +555,10 @@ open class ProductionScheduleService( prodScheduleLineId = prodScheduleLine.id!! ) - // Assuming createJobOrder returns the created Job Order (jo) - val jo = jobOrderService.createJobOrder(joRequest) - - val createdJobOrderId = jo.id - ?: throw IllegalStateException("Job Order creation failed: returned object ID is null.") - - // 7. Create related job order data - jobOrderBomMaterialService.createJobOrderBomMaterialsByJoId(createdJobOrderId) - jobOrderProcessService.createJobOrderProcessesByJoId(createdJobOrderId) - productProcessService.createProductProcessByJobOrderId(createdJobOrderId, prodScheduleLine.itemPriority.toInt()) + jobOrderCreationService.createJobOrderWithFullFlow( + request = joRequest, + productionPriority = prodScheduleLine.itemPriority.toInt() + ) } diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt index 70f86df..e65908b 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickOrderService.kt @@ -3515,21 +3515,7 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map - Pair(row["stockOutLineId_any"], row["lotId_any"]) - } - .map { row -> - val noLot = (row["lotId_any"] == null) - mapOf( - "id" to row["stockOutLineId_any"], - "status" to row["stockOutLineStatus_any"], - "qty" to row["stockOutLineQty_any"], - "lotId" to row["lotId_any"], - "lotNo" to (row["lotNo_any"] ?: ""), - "location" to (row["location_any"] ?: ""), - "availableQty" to row["availableQty_any"], - "stockInLineId" to row["stockInLineId_any"], - "noLot" to noLot - ) - } + // stockouts:包含该 pick order line 的所有出库行(包含 noLot) + // 注意:之前用 sol_any 会导致行被重复 N 次(N=该行 stock_out_line 数量),进而前端合并 requiredQty 时被累加。 + // 这里直接用同一行上的 sol 字段构建 stockouts,避免笛卡尔积。 + val stockouts = lineRows + .filter { it["stockOutLineId"] != null } + .distinctBy { row -> row["stockOutLineId"] } + .map { row -> + val lotId = row["lotId"] + val noLot = (lotId == null) + mapOf( + "id" to row["stockOutLineId"], + "status" to row["stockOutLineStatus"], + "qty" to row["stockOutLineQty"], + "lotId" to lotId, + "lotNo" to (row["lotNo"] ?: ""), + "location" to (row["location"] ?: ""), + "availableQty" to row["availableQty"], + "stockInLineId" to row["stockInLineId"], + "noLot" to noLot + ) + } mapOf( "id" to lineId, diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt index cd38182..1fb49af 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessRepository.kt @@ -10,6 +10,7 @@ interface ProductProcessRepository : JpaRepository, JpaSpe fun findByProductProcessCode(code: String): Optional fun findByJobOrder_Id(jobOrderId: Long): List fun findByProductProcessCodeStartingWith(prefix: String): List + fun findTopByProductProcessCodeStartingWithOrderByProductProcessCodeDesc(prefix: String): ProductProcess? fun findByIdAndDeletedIsFalse(id: Long): Optional fun findAllByDeletedIsFalse(): List fun findByBom_Id(bomId: Long): List diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index b6acaa1..25d99b0 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -53,6 +53,7 @@ import java.math.RoundingMode import java.time.LocalDate import java.time.format.DateTimeFormatter import com.ffii.fpsms.modules.stock.entity.StockInLineRepository +import org.springframework.dao.DataIntegrityViolationException @Service @Transactional open class ProductProcessService( @@ -161,11 +162,38 @@ open class ProductProcessService( private fun generateProductProcessCode(datePrefix: String): String { val searchPattern = "PP-$datePrefix-" - val existingCodes = productProcessRepository.findByProductProcessCodeStartingWith(searchPattern) - val nextNumber = (existingCodes.size + 1).toString().padStart(3, '0') + val latestCode = productProcessRepository + .findTopByProductProcessCodeStartingWithOrderByProductProcessCodeDesc(searchPattern) + ?.productProcessCode + + val latestNo = latestCode + ?.substringAfterLast("-", missingDelimiterValue = "") + ?.toIntOrNull() + ?: 0 + + val nextNumber = (kotlin.math.max(0, latestNo) + 1).toString().padStart(3, '0') return "$searchPattern$nextNumber" } + private fun isDuplicateProductProcessCode(e: DataIntegrityViolationException): Boolean { + val msg = (e.rootCause?.message ?: e.message ?: "").lowercase() + return msg.contains("duplicate entry") && msg.contains("productprocesscode") + } + + private inline fun retryOnDuplicateProductProcessCode(maxRetries: Int = 8, block: () -> T): T { + var last: DataIntegrityViolationException? = null + repeat(maxRetries) { attempt -> + try { + return block() + } catch (e: DataIntegrityViolationException) { + if (!isDuplicateProductProcessCode(e)) throw e + last = e + println("⚠️ Duplicate productProcessCode, retrying... attempt=${attempt + 1}/$maxRetries") + } + } + throw last ?: IllegalStateException("Duplicate productProcessCode after retries") + } + // 添加:查询工序的所有步骤 open fun getLines(productProcessId: Long): List { @@ -896,12 +924,10 @@ open class ProductProcessService( val bomProcess = bomProcessRepository.findByBomId(jobOrder?.bom?.id ?: 0L) val item = itemsRepository.findById(bom?.item?.id ?: 0L).orElse(null) val datePrefix = (LocalDate.now()).format(DateTimeFormatter.ofPattern("yyyyMMdd")) - val productProcessCode = generateProductProcessCode(datePrefix) val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val productProcess = ProductProcess().apply { this.jobOrder = jobOrder - this.productProcessCode = productProcessCode this.item = item this.status = ProductProcessStatus.PENDING this.date = jobOrder?.planEnd?.toLocalDate() @@ -910,12 +936,16 @@ open class ProductProcessService( this.productionPriority = productionPriority } - productProcessRepository.save(productProcess) + val savedProcess = retryOnDuplicateProductProcessCode { + val newCode = generateProductProcessCode(datePrefix) + productProcess.productProcessCode = newCode + productProcessRepository.saveAndFlush(productProcess) + } bomProcess.forEach { bomProcess -> val process = processRepository.findById(bomProcess?.process?.id ?: 0L).orElse(null) val equipment = equipmentRepository.findById(bomProcess?.equipment?.id ?: 0L).orElse(null) val productProcessLine = ProductProcessLine().apply { - this.productProcess = productProcess + this.productProcess = savedProcess this.bomProcess = bomProcess // this.equipment = equipment this.seqNo = bomProcess.seqNo ?: 0 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index 832e29a..6a93518 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt @@ -17,6 +17,7 @@ import com.ffii.fpsms.modules.stock.web.model.SaveSuggestedPickLotRequest import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotForPoRequest import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotForPolRequest import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotResponse +import com.ffii.fpsms.modules.master.entity.BomRepository import org.springframework.stereotype.Service import java.math.BigDecimal import java.time.LocalDate @@ -47,6 +48,7 @@ open class SuggestedPickLotService( val stockOutLIneRepository: StockOutLIneRepository, val inventoryLotLineRepository: InventoryLotLineRepository, val pickOrderLineRepository: PickOrderLineRepository, + val bomRepository: BomRepository, val inventoryLotLineService: InventoryLotLineService, val itemUomService: ItemUomService, val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号 @@ -179,7 +181,7 @@ open class SuggestedPickLotService( val holdQtyInBaseUnits = holdQtyMap[lotLine.id] ?: zero val availableQtyInSalesUnits = availableQtyInBaseUnits .minus(holdQtyInBaseUnits) - .divide(ratio, 2, RoundingMode.HALF_UP) + .divide(ratio, 0, RoundingMode.HALF_UP) println("holdQtyMap[lotLine.id] ?: zero ${holdQtyMap[lotLine.id] ?: zero}") @@ -265,6 +267,7 @@ open class SuggestedPickLotService( open fun suggestionForPickOrderLinesForJobOrder(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse { val pols = request.pickOrderLines val itemIds = pols.mapNotNull { it.item?.id } + val wipItemIdSet: Set = bomRepository.findWipItemIds(itemIds).toSet() val zero = BigDecimal.ZERO val one = BigDecimal.ONE val today = LocalDate.now() @@ -286,94 +289,67 @@ open class SuggestedPickLotService( // loop for suggest pick lot line pols.forEach { line -> - val ratio = one - - // === 新增:如果是 DO pick order,则按 DO 的 preferredFloor 过滤 lot === - // preferredFloor 规则:supplier.code == "P06B" -> 4F,否则 2F - val pickOrder = line.pickOrder - val isDoPickOrder = pickOrder?.type?.value == "do" || pickOrder?.type?.value == "delivery_order" - - val doPreferredFloor: String? = if (isDoPickOrder) { - val supplierCode = pickOrder?.deliveryOrder?.supplier?.code - when (supplierCode) { - "P06B" -> "4F" - "P07", "P06D" -> "2F" - else -> null // 其他供应商不限定 2F/4F - } + val itemId = line.item?.id + val isWipItem = itemId != null && wipItemIdSet.contains(itemId) + + val lotInfos = availableInventoryLotLines[itemId].orEmpty() + val lotIds = lotInfos.mapNotNull { it.id } + // 仅一次查询,避免后续重复 findById + val lotEntities = if (lotIds.isNotEmpty()) inventoryLotLineRepository.findAllById(lotIds) else emptyList() + val lotEntityById = lotEntities.associateBy { it.id } + + // 计算“默认优先仓位”(短期固定,后续可从 Items/配置取) + val preferredWarehouseCode = if (isWipItem) "2F-W200-#A-00" else null + + // 分组:先默认仓位,再其他;组内保持 FEFO 顺序(lotInfos 已按 expiry 排过序) + val (preferred, others) = if (!preferredWarehouseCode.isNullOrBlank()) { + lotInfos.partition { li -> lotEntityById[li.id]?.warehouse?.code == preferredWarehouseCode } } else { - null + Pair(emptyList(), lotInfos) } - - val lotLines = availableInventoryLotLines[line.item?.id].orEmpty() - - // 计算 remaining qty - val stockOutLines = stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(line.id!!) - val totalPickedQty = stockOutLines - .filter { - it.status == "completed" || - it.status == "partially_completed" || - (it.status == "rejected" && (it.qty ?: zero) > zero) - } - .sumOf { it.qty ?: zero } - - val requiredQty = line.qty ?: zero - val remainingQty = requiredQty.minus(totalPickedQty) - - var remainingQtyToAllocate = remainingQty - - lotLines.forEachIndexed { _, lotLine -> - if (remainingQtyToAllocate <= zero) return@forEachIndexed - - // 拿 entity 是因为 projection 的 warehouse 没有 store_id 字段 - val inventoryLotLine = lotLine.id?.let { inventoryLotLineService.findById(it).getOrNull() } - ?: return@forEachIndexed - - val warehouseStoreId = inventoryLotLine.warehouse?.store_id - val warehouseCode = inventoryLotLine.warehouse?.code - - // 规则 1:排除指定 warehouse code - if (warehouseCode != null && excludedWarehouseCodes.contains(warehouseCode)) { - return@forEachIndexed - } - - // 规则 2:原有逻辑:跳过 3F - val isJobOrder = pickOrder?.type?.value == "jo" || pickOrder?.type?.value == "jo" - if (warehouseStoreId == "3F" && !isJobOrder) { - return@forEachIndexed - } - - // 规则 3:新增逻辑:DO 订单必须匹配楼层,否则不建议 - // 例:doPreferredFloor=2F,但 lot 在 4F => 跳过 - if (doPreferredFloor != null && warehouseStoreId != doPreferredFloor) { - return@forEachIndexed - } - if (doPreferredFloor != null && warehouseStoreId != "1F" && warehouseStoreId != doPreferredFloor) { - return@forEachIndexed - } - val availableQtyInBaseUnits = calculateRemainingQtyForInfo(lotLine) - val holdQtyInBaseUnits = holdQtyMap[lotLine.id] ?: zero - val availableQtyInSalesUnits = availableQtyInBaseUnits - .minus(holdQtyInBaseUnits) - .divide(ratio, 2, RoundingMode.HALF_UP) - - if (availableQtyInSalesUnits <= zero) return@forEachIndexed - - val assignQtyInSalesUnits = minOf(availableQtyInSalesUnits, remainingQtyToAllocate) - remainingQtyToAllocate = remainingQtyToAllocate.minus(assignQtyInSalesUnits) - - val assignQtyInBaseUnits = assignQtyInSalesUnits.multiply(ratio) - holdQtyMap[lotLine.id] = (holdQtyMap[lotLine.id] ?: zero).plus(assignQtyInBaseUnits) - + val orderedLotInfos = preferred + others + + // 分配时直接用 entity(不再 findById) + var remainingQtyToAllocate = (line.qty ?: BigDecimal.ZERO).minus( + stockOutLIneRepository.findAllByPickOrderLineIdAndDeletedFalse(line.id!!) + .filter { it.status == "completed" || it.status == "partially_completed" || (it.status == "rejected" && (it.qty ?: BigDecimal.ZERO) > BigDecimal.ZERO) } + .sumOf { it.qty ?: BigDecimal.ZERO } + ) + + for (lotInfo in orderedLotInfos) { + if (remainingQtyToAllocate <= BigDecimal.ZERO) break + val lot = lotEntityById[lotInfo.id] ?: continue + + val warehouseCode = lot.warehouse?.code + val storeId = lot.warehouse?.store_id + + // 既有规则 + if (warehouseCode != null && excludedWarehouseCodes.contains(warehouseCode)) continue + val isJobOrder = line.pickOrder?.type?.value == "jo" + if (storeId == "3F" && !isJobOrder) continue + + val availableQtyInBase = (lot.inQty ?: BigDecimal.ZERO) + .minus(lot.outQty ?: BigDecimal.ZERO) + .minus(lot.holdQty ?: BigDecimal.ZERO) + val holdQtyInBase = holdQtyMap[lotInfo.id] ?: BigDecimal.ZERO + val availableInSales = availableQtyInBase.minus(holdQtyInBase) // ratio 目前为 1 + + if (availableInSales <= BigDecimal.ZERO) continue + + val assign = availableInSales.min(remainingQtyToAllocate) + remainingQtyToAllocate = remainingQtyToAllocate.minus(assign) + + holdQtyMap[lotInfo.id] = (holdQtyMap[lotInfo.id] ?: BigDecimal.ZERO).plus(assign) + suggestedList += SuggestedPickLot().apply { type = SuggestedPickLotType.PICK_ORDER - suggestedLotLine = inventoryLotLine + suggestedLotLine = lot pickOrderLine = line - qty = assignQtyInSalesUnits + qty = assign } } - - // 如果仍有剩余 -> 给 null lot(你举例的场景会走到这里) - if (remainingQtyToAllocate > zero) { + + if (remainingQtyToAllocate > BigDecimal.ZERO) { suggestedList += SuggestedPickLot().apply { type = SuggestedPickLotType.PICK_ORDER suggestedLotLine = null