| @@ -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.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.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 | |||
| @@ -1517,8 +1522,9 @@ open fun getCompletedJobOrderPickOrderLotDetails(pickOrderId: Long): List<Map<St | |||
| AND po.id = :pickOrderId | |||
| AND pol.deleted = false | |||
| 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) | |||
| ORDER BY | |||
| 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.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 | |||
| WHERE po.deleted = false | |||
| AND po.id = :pickOrderId | |||
| AND pol.deleted = false | |||
| 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) | |||
| ORDER BY | |||
| CASE WHEN sol.status = 'rejected' THEN 0 ELSE 1 END, | |||
| @@ -1988,6 +2000,32 @@ open fun getJobOrderLotsHierarchicalByPickOrderId(pickOrderId: Long): JobOrderLo | |||
| // 获取 pick order lines | |||
| val pickOrderLines = pickOrderLineRepository.findAllByPickOrderId(pickOrder.id!!) | |||
| .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 | |||
| 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 | |||
| @@ -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 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 { | |||
| @@ -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?, | |||
| @@ -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<Bom, Long> { | |||
| fun findAllByDeletedIsFalse(): List<Bom> | |||
| @@ -18,4 +19,13 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||
| fun findBomComboByDeletedIsFalse(): List<BomCombo> | |||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<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 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 | |||
| @@ -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<ProductionSchedule, Long, ProductionScheduleRepository>( | |||
| 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() | |||
| ) | |||
| } | |||
| @@ -3515,21 +3515,7 @@ open fun getAllPickOrderLotsWithDetailsHierarchical(userId: Long): Map<String, A | |||
| WHEN sol.status = 'rejected' THEN 'rejected' | |||
| WHEN sol.status = 'created' THEN '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 | |||
| JOIN fpsmsdb.pick_order_line pol ON pol.poId = po.id AND pol.deleted = false | |||
| 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.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 | |||
| AND po.deleted = false | |||
| ORDER BY | |||
| @@ -3629,27 +3604,27 @@ println("DEBUG sol polIds in linesResults: " + linesResults.mapNotNull { it["sto | |||
| 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( | |||
| "id" to lineId, | |||
| @@ -10,6 +10,7 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||
| fun findByProductProcessCode(code: String): Optional<ProductProcess> | |||
| fun findByJobOrder_Id(jobOrderId: Long): List<ProductProcess> | |||
| fun findByProductProcessCodeStartingWith(prefix: String): List<ProductProcess> | |||
| fun findTopByProductProcessCodeStartingWithOrderByProductProcessCodeDesc(prefix: String): ProductProcess? | |||
| fun findByIdAndDeletedIsFalse(id: Long): Optional<ProductProcess> | |||
| fun findAllByDeletedIsFalse(): List<ProductProcess> | |||
| fun findByBom_Id(bomId: Long): List<ProductProcess> | |||
| @@ -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 <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> { | |||
| @@ -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 | |||
| @@ -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<Long> = 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 | |||