| @@ -1508,7 +1508,12 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St | |||||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | ||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.inventoryLotLineId = ill.id AND sol.deleted = false | |||||
| -- no-lot 情況:ill.id 為 null 時,要匹配 sol.inventoryLotLineId IS NULL | |||||
| LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false | |||||
| AND ( | |||||
| sol.inventoryLotLineId = ill.id | |||||
| OR (sol.inventoryLotLineId IS NULL AND ill.id IS NULL) | |||||
| ) | |||||
| LEFT JOIN fpsmsdb.jo_pick_order jpo ON jpo.pick_order_id = po.id AND jpo.item_id = pol.itemId | LEFT JOIN fpsmsdb.jo_pick_order jpo ON jpo.pick_order_id = po.id AND jpo.item_id = pol.itemId | ||||
| LEFT JOIN fpsmsdb.item_uom iu ON iu.itemId = b.itemId AND iu.stockUnit = 1 | LEFT JOIN fpsmsdb.item_uom iu ON iu.itemId = b.itemId AND iu.stockUnit = 1 | ||||
| LEFT JOIN fpsmsdb.uom_conversion uc_fg ON uc_fg.id = iu.uomId | LEFT JOIN fpsmsdb.uom_conversion uc_fg ON uc_fg.id = iu.uomId | ||||
| @@ -1517,8 +1522,9 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St | |||||
| AND po.id = :pickOrderId | AND po.id = :pickOrderId | ||||
| AND pol.deleted = false | AND pol.deleted = false | ||||
| AND po.status = 'COMPLETED' | AND po.status = 'COMPLETED' | ||||
| AND ill.deleted = false | |||||
| AND il.deleted = false | |||||
| -- ill / il 可能為 null(no-lot),避免把 no-lot 記錄過濾掉 | |||||
| AND (ill.id IS NULL OR ill.deleted = false) | |||||
| AND (il.id IS NULL OR il.deleted = false) | |||||
| AND (spl.pickOrderLineId IS NOT NULL OR sol.pickOrderLineId IS NOT NULL) | AND (spl.pickOrderLineId IS NOT NULL OR sol.pickOrderLineId IS NOT NULL) | ||||
| ORDER BY | ORDER BY | ||||
| CASE WHEN sol.status = 'rejected' THEN 0 ELSE 1 END, | CASE WHEN sol.status = 'rejected' THEN 0 ELSE 1 END, | ||||
| @@ -1719,15 +1725,21 @@ open fun getCompletedJobOrderPickOrderLotDetailsForCompletedPick(pickOrderId: Lo | |||||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId | ||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.inventoryLotLineId = ill.id AND sol.deleted = false | |||||
| -- no-lot 情況:ill.id 為 null 時,要匹配 sol.inventoryLotLineId IS NULL 的记录 | |||||
| LEFT JOIN fpsmsdb.stock_out_line sol ON sol.pickOrderLineId = pol.id AND sol.deleted = false | |||||
| AND ( | |||||
| sol.inventoryLotLineId = ill.id | |||||
| OR (sol.inventoryLotLineId IS NULL AND ill.id IS NULL) | |||||
| ) | |||||
| LEFT JOIN fpsmsdb.jo_pick_order jpo ON jpo.pick_order_id = po.id AND jpo.item_id = pol.itemId | LEFT JOIN fpsmsdb.jo_pick_order jpo ON jpo.pick_order_id = po.id AND jpo.item_id = pol.itemId | ||||
| WHERE po.deleted = false | WHERE po.deleted = false | ||||
| AND po.id = :pickOrderId | AND po.id = :pickOrderId | ||||
| AND pol.deleted = false | AND pol.deleted = false | ||||
| AND po.status = 'COMPLETED' | AND po.status = 'COMPLETED' | ||||
| AND ill.deleted = false | |||||
| AND il.deleted = false | |||||
| -- ill / il 可能为 null(no-lot),避免把 no-lot 记录过滤掉 | |||||
| AND (ill.id IS NULL OR ill.deleted = false) | |||||
| AND (il.id IS NULL OR il.deleted = false) | |||||
| AND (spl.pickOrderLineId IS NOT NULL OR sol.pickOrderLineId IS NOT NULL) | AND (spl.pickOrderLineId IS NOT NULL OR sol.pickOrderLineId IS NOT NULL) | ||||
| ORDER BY | ORDER BY | ||||
| CASE WHEN sol.status = 'rejected' THEN 0 ELSE 1 END, | CASE WHEN sol.status = 'rejected' THEN 0 ELSE 1 END, | ||||
| @@ -1988,6 +2000,32 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||||
| // 获取 pick order lines | // 获取 pick order lines | ||||
| val pickOrderLines = pickOrderLineRepository.findAllByPickOrderId(pickOrder.id!!) | val pickOrderLines = pickOrderLineRepository.findAllByPickOrderId(pickOrder.id!!) | ||||
| .filter { it.deleted == false } | .filter { it.deleted == false } | ||||
| // Pre-calculate total available qty per item (for UI display). | |||||
| // Note: we align with pick suggestion logic: AVAILABLE status, not expired, remaining > 0. | |||||
| val itemIds = pickOrderLines.mapNotNull { it.item?.id }.distinct() | |||||
| val today = LocalDate.now() | |||||
| val totalAvailableQtyByItemId: Map<Long, Double> = 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 | // 获取所有 pick order line IDs | ||||
| val pickOrderLineIds = pickOrderLines.map { it.id!! } | val pickOrderLineIds = pickOrderLines.map { it.id!! } | ||||
| @@ -2205,6 +2243,7 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||||
| itemCode = item?.code, | itemCode = item?.code, | ||||
| itemName = item?.name, | itemName = item?.name, | ||||
| requiredQty = pol.qty?.toDouble(), | requiredQty = pol.qty?.toDouble(), | ||||
| totalAvailableQty = item?.id?.let { totalAvailableQtyByItemId[it] }, | |||||
| uomCode = uom?.code, | uomCode = uom?.code, | ||||
| uomDesc = uom?.udfudesc, | uomDesc = uom?.udfudesc, | ||||
| status = pol.status?.value, | status = pol.status?.value, | ||||
| @@ -2254,8 +2293,8 @@ open fun updateHandledByForItem(pickOrderId: Long, itemId: Long, userId: Long): | |||||
| } | } | ||||
| val joPickOrder = joPickOrderOpt.get() | 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 | 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) | joPickOrderRepository.save(joPickOrder) | ||||
| // Don't update other fields - only handledBy | // Don't update other fields - only handledBy | ||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| @@ -50,7 +50,8 @@ class JobOrderController( | |||||
| private val jobOrderBomMaterialService: JobOrderBomMaterialService, | private val jobOrderBomMaterialService: JobOrderBomMaterialService, | ||||
| private val jobOrderProcessService: JobOrderProcessService, | private val jobOrderProcessService: JobOrderProcessService, | ||||
| private val joPickOrderService: JoPickOrderService, | private val joPickOrderService: JoPickOrderService, | ||||
| private val productProcessService: ProductProcessService | |||||
| private val productProcessService: ProductProcessService, | |||||
| private val jobOrderCreationService: com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService | |||||
| ) { | ) { | ||||
| @GetMapping("/getRecordByPage") | @GetMapping("/getRecordByPage") | ||||
| @@ -94,14 +95,7 @@ class JobOrderController( | |||||
| @PostMapping("/manualCreate") | @PostMapping("/manualCreate") | ||||
| fun manualCreateJobOrder(@Valid @RequestBody request: CreateJobOrderRequest): MessageResponse { | 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}") | @GetMapping("/all-lots-hierarchical/{userId}") | ||||
| fun getAllJobOrderLotsHierarchical(@PathVariable userId: Long): JobOrderLotsHierarchicalResponse { | fun getAllJobOrderLotsHierarchical(@PathVariable userId: Long): JobOrderLotsHierarchicalResponse { | ||||
| @@ -97,6 +97,8 @@ data class PickOrderLineWithLotsResponse( | |||||
| val itemCode: String?, | val itemCode: String?, | ||||
| val itemName: String?, | val itemName: String?, | ||||
| val requiredQty: Double?, | 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 uomCode: String?, | ||||
| val uomDesc: String?, | val uomDesc: String?, | ||||
| val status: String?, | val status: String?, | ||||
| @@ -4,7 +4,8 @@ import com.ffii.core.support.AbstractRepository | |||||
| import com.ffii.fpsms.modules.master.entity.projections.BomCombo | import com.ffii.fpsms.modules.master.entity.projections.BomCombo | ||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.data.repository.query.Param | |||||
| @Repository | @Repository | ||||
| interface BomRepository : AbstractRepository<Bom, Long> { | interface BomRepository : AbstractRepository<Bom, Long> { | ||||
| fun findAllByDeletedIsFalse(): List<Bom> | fun findAllByDeletedIsFalse(): List<Bom> | ||||
| @@ -18,4 +19,13 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||||
| fun findBomComboByDeletedIsFalse(): List<BomCombo> | fun findBomComboByDeletedIsFalse(): List<BomCombo> | ||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | ||||
| fun findByCodeAndDeletedIsFalse(code: String): Bom? | 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<Long>): List<Long> | |||||
| fun findFirstByItemIdAndDescriptionIgnoreCaseAndDeletedIsFalse(itemId: Long, description: String): Bom? | |||||
| } | } | ||||
| @@ -81,10 +81,20 @@ open class ItemUomService( | |||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; | val stockUnit = findStockUnitByItemId(itemId) ?: return purchaseQty; | ||||
| val one = BigDecimal.ONE; | 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). */ | /** 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 purchaseUnit = findPurchaseUnitByItemId(itemId) ?: return stockQty | ||||
| val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty | val stockUnit = findStockUnitByItemId(itemId) ?: return stockQty | ||||
| val one = BigDecimal.ONE | 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 { | 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 stockUnit = findStockUnitByItemId(itemId) ?: return BigDecimal.ZERO; | ||||
| val one = BigDecimal.ONE; | 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 | // See if need to update the response | ||||
| @@ -59,6 +59,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||||
| import org.apache.poi.xssf.usermodel.XSSFPrintSetup | import org.apache.poi.xssf.usermodel.XSSFPrintSetup | ||||
| import java.io.ByteArrayOutputStream | import java.io.ByteArrayOutputStream | ||||
| import java.sql.Timestamp | import java.sql.Timestamp | ||||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderCreationService | |||||
| @Service | @Service | ||||
| open class ProductionScheduleService( | open class ProductionScheduleService( | ||||
| @@ -76,6 +77,7 @@ open class ProductionScheduleService( | |||||
| private val inventoryRepository: InventoryRepository, | private val inventoryRepository: InventoryRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val productProcessService: ProductProcessService, | private val productProcessService: ProductProcessService, | ||||
| private val jobOrderCreationService: JobOrderCreationService, | |||||
| private val settingsService: SettingsService, | private val settingsService: SettingsService, | ||||
| ) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>( | ) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>( | ||||
| jdbcDao, | jdbcDao, | ||||
| @@ -462,16 +464,10 @@ open class ProductionScheduleService( | |||||
| prodScheduleLineId = prodScheduleLine.id!! | 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++ | jobOrderCount++ | ||||
| //} | //} | ||||
| @@ -559,16 +555,10 @@ open class ProductionScheduleService( | |||||
| prodScheduleLineId = prodScheduleLine.id!! | 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() | |||||
| ) | |||||
| } | } | ||||
| @@ -3515,21 +3515,7 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||||
| WHEN sol.status = 'rejected' THEN 'rejected' | WHEN sol.status = 'rejected' THEN 'rejected' | ||||
| WHEN sol.status = 'created' THEN 'pending' | WHEN sol.status = 'created' THEN 'pending' | ||||
| ELSE 'pending' | ELSE 'pending' | ||||
| END as processingStatus, | |||||
| -- 新增:stockouts 专用字段(不依赖 ll) | |||||
| sol_any.id as stockOutLineId_any, | |||||
| sol_any.status as stockOutLineStatus_any, | |||||
| COALESCE(sol_any.qty, 0) as stockOutLineQty_any, | |||||
| sol_any.inventoryLotLineId as lotId_any, | |||||
| il_any.lotNo as lotNo_any, | |||||
| w_any.name as location_any, | |||||
| il_any.stockInLineId as stockInLineId_any, | |||||
| -- 仅当有 lot 时可计算,没 lot 时为 NULL | |||||
| CASE | |||||
| WHEN ill_any.id IS NULL THEN NULL | |||||
| ELSE (COALESCE(ill_any.inQty,0) - COALESCE(ill_any.outQty,0) - COALESCE(ill_any.holdQty,0)) | |||||
| END as availableQty_any | |||||
| END as processingStatus | |||||
| FROM fpsmsdb.pick_order po | FROM fpsmsdb.pick_order po | ||||
| JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false | JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false | ||||
| JOIN fpsmsdb.items i ON i.id = pol.itemId | JOIN fpsmsdb.items i ON i.id = pol.itemId | ||||
| @@ -3558,17 +3544,6 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||||
| LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = false | LEFT JOIN fpsmsdb.inventory_lot il ON il.id = ill.inventoryLotId AND il.deleted = false | ||||
| LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | LEFT JOIN fpsmsdb.warehouse w ON w.id = ill.warehouseId | ||||
| -- stockouts 用(不依赖 ll) | |||||
| LEFT JOIN fpsmsdb.stock_out_line sol_any | |||||
| ON sol_any.pickOrderLineId = pol.id | |||||
| AND sol_any.deleted = false | |||||
| LEFT JOIN fpsmsdb.inventory_lot_line ill_any | |||||
| ON ill_any.id = sol_any.inventoryLotLineId AND ill_any.deleted = false | |||||
| LEFT JOIN fpsmsdb.inventory_lot il_any | |||||
| ON il_any.id = ill_any.inventoryLotId AND il_any.deleted = false | |||||
| LEFT JOIN fpsmsdb.warehouse w_any | |||||
| ON w_any.id = ill_any.warehouseId | |||||
| WHERE po.id = :pickOrderId | WHERE po.id = :pickOrderId | ||||
| AND po.deleted = false | AND po.deleted = false | ||||
| ORDER BY | ORDER BY | ||||
| @@ -3629,27 +3604,27 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto | |||||
| emptyList() | emptyList() | ||||
| } | } | ||||
| // 新增:stockouts(包括 inventoryLotLineId 为 null 的出库行) | |||||
| // stockouts:包含所有出库行(即使 lot 为空) | |||||
| val stockouts = lineRows | |||||
| .filter { it["stockOutLineId_any"] != null } | |||||
| .distinctBy { row -> | |||||
| 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( | mapOf( | ||||
| "id" to lineId, | "id" to lineId, | ||||
| @@ -10,6 +10,7 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||||
| fun findByProductProcessCode(code: String): Optional<ProductProcess> | fun findByProductProcessCode(code: String): Optional<ProductProcess> | ||||
| fun findByJobOrder_Id(jobOrderId: Long): List<ProductProcess> | fun findByJobOrder_Id(jobOrderId: Long): List<ProductProcess> | ||||
| fun findByProductProcessCodeStartingWith(prefix: String): List<ProductProcess> | fun findByProductProcessCodeStartingWith(prefix: String): List<ProductProcess> | ||||
| fun findTopByProductProcessCodeStartingWithOrderByProductProcessCodeDesc(prefix: String): ProductProcess? | |||||
| fun findByIdAndDeletedIsFalse(id: Long): Optional<ProductProcess> | fun findByIdAndDeletedIsFalse(id: Long): Optional<ProductProcess> | ||||
| fun findAllByDeletedIsFalse(): List<ProductProcess> | fun findAllByDeletedIsFalse(): List<ProductProcess> | ||||
| fun findByBom_Id(bomId: Long): List<ProductProcess> | fun findByBom_Id(bomId: Long): List<ProductProcess> | ||||
| @@ -53,6 +53,7 @@ import java.math.RoundingMode | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.format.DateTimeFormatter | import java.time.format.DateTimeFormatter | ||||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | ||||
| import org.springframework.dao.DataIntegrityViolationException | |||||
| @Service | @Service | ||||
| @Transactional | @Transactional | ||||
| open class ProductProcessService( | open class ProductProcessService( | ||||
| @@ -161,11 +162,38 @@ open class ProductProcessService( | |||||
| private fun generateProductProcessCode(datePrefix: String): String { | private fun generateProductProcessCode(datePrefix: String): String { | ||||
| val searchPattern = "PP-$datePrefix-" | 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" | 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 <T> 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<ProductProcessLine> { | open fun getLines(productProcessId: Long): List<ProductProcessLine> { | ||||
| @@ -896,12 +924,10 @@ open class ProductProcessService( | |||||
| val bomProcess = bomProcessRepository.findByBomId(jobOrder?.bom?.id ?: 0L) | val bomProcess = bomProcessRepository.findByBomId(jobOrder?.bom?.id ?: 0L) | ||||
| val item = itemsRepository.findById(bom?.item?.id ?: 0L).orElse(null) | val item = itemsRepository.findById(bom?.item?.id ?: 0L).orElse(null) | ||||
| val datePrefix = (LocalDate.now()).format(DateTimeFormatter.ofPattern("yyyyMMdd")) | val datePrefix = (LocalDate.now()).format(DateTimeFormatter.ofPattern("yyyyMMdd")) | ||||
| val productProcessCode = generateProductProcessCode(datePrefix) | |||||
| val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") | val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") | ||||
| val productProcess = ProductProcess().apply { | val productProcess = ProductProcess().apply { | ||||
| this.jobOrder = jobOrder | this.jobOrder = jobOrder | ||||
| this.productProcessCode = productProcessCode | |||||
| this.item = item | this.item = item | ||||
| this.status = ProductProcessStatus.PENDING | this.status = ProductProcessStatus.PENDING | ||||
| this.date = jobOrder?.planEnd?.toLocalDate() | this.date = jobOrder?.planEnd?.toLocalDate() | ||||
| @@ -910,12 +936,16 @@ open class ProductProcessService( | |||||
| this.productionPriority = productionPriority | this.productionPriority = productionPriority | ||||
| } | } | ||||
| productProcessRepository.save(productProcess) | |||||
| val savedProcess = retryOnDuplicateProductProcessCode { | |||||
| val newCode = generateProductProcessCode(datePrefix) | |||||
| productProcess.productProcessCode = newCode | |||||
| productProcessRepository.saveAndFlush(productProcess) | |||||
| } | |||||
| bomProcess.forEach { bomProcess -> | bomProcess.forEach { bomProcess -> | ||||
| val process = processRepository.findById(bomProcess?.process?.id ?: 0L).orElse(null) | val process = processRepository.findById(bomProcess?.process?.id ?: 0L).orElse(null) | ||||
| val equipment = equipmentRepository.findById(bomProcess?.equipment?.id ?: 0L).orElse(null) | val equipment = equipmentRepository.findById(bomProcess?.equipment?.id ?: 0L).orElse(null) | ||||
| val productProcessLine = ProductProcessLine().apply { | val productProcessLine = ProductProcessLine().apply { | ||||
| this.productProcess = productProcess | |||||
| this.productProcess = savedProcess | |||||
| this.bomProcess = bomProcess | this.bomProcess = bomProcess | ||||
| // this.equipment = equipment | // this.equipment = equipment | ||||
| this.seqNo = bomProcess.seqNo ?: 0 | this.seqNo = bomProcess.seqNo ?: 0 | ||||
| @@ -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.SuggestedPickLotForPoRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotForPolRequest | import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotForPolRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotResponse | import com.ffii.fpsms.modules.stock.web.model.SuggestedPickLotResponse | ||||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.math.BigDecimal | import java.math.BigDecimal | ||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| @@ -47,6 +48,7 @@ open class SuggestedPickLotService( | |||||
| val stockOutLIneRepository: StockOutLIneRepository, | val stockOutLIneRepository: StockOutLIneRepository, | ||||
| val inventoryLotLineRepository: InventoryLotLineRepository, | val inventoryLotLineRepository: InventoryLotLineRepository, | ||||
| val pickOrderLineRepository: PickOrderLineRepository, | val pickOrderLineRepository: PickOrderLineRepository, | ||||
| val bomRepository: BomRepository, | |||||
| val inventoryLotLineService: InventoryLotLineService, | val inventoryLotLineService: InventoryLotLineService, | ||||
| val itemUomService: ItemUomService, | val itemUomService: ItemUomService, | ||||
| val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号 | val pickExecutionIssueRepository: PickExecutionIssueRepository, // 添加逗号 | ||||
| @@ -179,7 +181,7 @@ open class SuggestedPickLotService( | |||||
| val holdQtyInBaseUnits = holdQtyMap[lotLine.id] ?: zero | val holdQtyInBaseUnits = holdQtyMap[lotLine.id] ?: zero | ||||
| val availableQtyInSalesUnits = availableQtyInBaseUnits | val availableQtyInSalesUnits = availableQtyInBaseUnits | ||||
| .minus(holdQtyInBaseUnits) | .minus(holdQtyInBaseUnits) | ||||
| .divide(ratio, 2, RoundingMode.HALF_UP) | |||||
| .divide(ratio, 0, RoundingMode.HALF_UP) | |||||
| println("holdQtyMap[lotLine.id] ?: zero ${holdQtyMap[lotLine.id] ?: zero}") | println("holdQtyMap[lotLine.id] ?: zero ${holdQtyMap[lotLine.id] ?: zero}") | ||||
| @@ -265,6 +267,7 @@ open class SuggestedPickLotService( | |||||
| open fun suggestionForPickOrderLinesForJobOrder(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse { | open fun suggestionForPickOrderLinesForJobOrder(request: SuggestedPickLotForPolRequest): SuggestedPickLotResponse { | ||||
| val pols = request.pickOrderLines | val pols = request.pickOrderLines | ||||
| val itemIds = pols.mapNotNull { it.item?.id } | val itemIds = pols.mapNotNull { it.item?.id } | ||||
| val wipItemIdSet: Set<Long> = bomRepository.findWipItemIds(itemIds).toSet() | |||||
| val zero = BigDecimal.ZERO | val zero = BigDecimal.ZERO | ||||
| val one = BigDecimal.ONE | val one = BigDecimal.ONE | ||||
| val today = LocalDate.now() | val today = LocalDate.now() | ||||
| @@ -286,94 +289,67 @@ open class SuggestedPickLotService( | |||||
| // loop for suggest pick lot line | // loop for suggest pick lot line | ||||
| pols.forEach { 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 { | } 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 { | suggestedList += SuggestedPickLot().apply { | ||||
| type = SuggestedPickLotType.PICK_ORDER | type = SuggestedPickLotType.PICK_ORDER | ||||
| suggestedLotLine = inventoryLotLine | |||||
| suggestedLotLine = lot | |||||
| pickOrderLine = line | pickOrderLine = line | ||||
| qty = assignQtyInSalesUnits | |||||
| qty = assign | |||||
| } | } | ||||
| } | } | ||||
| // 如果仍有剩余 -> 给 null lot(你举例的场景会走到这里) | |||||
| if (remainingQtyToAllocate > zero) { | |||||
| if (remainingQtyToAllocate > BigDecimal.ZERO) { | |||||
| suggestedList += SuggestedPickLot().apply { | suggestedList += SuggestedPickLot().apply { | ||||
| type = SuggestedPickLotType.PICK_ORDER | type = SuggestedPickLotType.PICK_ORDER | ||||
| suggestedLotLine = null | suggestedLotLine = null | ||||