if excel have issue will show and can veiw import data and can edit correct, and till isuue fix other wise not allow save. issue import bom check those check If Is FG but item LocationCode is null. Bom detail can edit bom have version compare can switch version of bom and select version to active/inactive 排程 imporve efficent of predect排程, export excel function, and else BOM/貨品單位問題 if current active bom uom can not convert to item base uom, will be issue show in this page if current active bom's bommaterail qty can not convert to item base uom , will be issue show in this page have fix form to update bom/bom materail data to help convert to base uom correct jobOrder Create if Bom have 單位問題, will not allow create Job Order bom martail remove column base/stock/sale qty/uom bom qty/uom from store stock change to base And these two have realte convert function to convert back realte uom/qty need to usebomUpdateTest
| @@ -150,7 +150,7 @@ open class M18BomForShopService( | |||
| udfEffectiveDate = udfEffectiveDate, | |||
| bomYield = bom.yield, | |||
| bomName = bom.name, | |||
| bomDescription = bom.description, | |||
| bomDescription = bom.bomKind, | |||
| flowTypeId = flowTypeId, | |||
| headerM18IdOverride = headerM18IdOverride, | |||
| bomM18Id = bom.m18Id?.takeIf { it > 0 }, | |||
| @@ -160,8 +160,8 @@ open class M18BomForShopService( | |||
| id = headerM18IdForRequest?.toString(), | |||
| code = headerCode, | |||
| beId = bomShopMainBeId, | |||
| desc = bom.name ?: bom.description, | |||
| descEn = bom.name ?: bom.description, | |||
| desc = bom.name ?: bom.bomKind, | |||
| descEn = bom.name ?: bom.bomKind, | |||
| udfBomCode = itemCode, | |||
| rev = rev, | |||
| udfUnit = udfUnit, | |||
| @@ -302,29 +302,12 @@ open class M18BomForShopService( | |||
| logger.warn("[M18 BOM] bom.item id missing; udfHarvest=outputQty only. bomId=${bom.id}") | |||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||
| } | |||
| val stockCode = itemUomService.findStockUnitByItemId(itemId)?.uom?.code?.trim().orEmpty() | |||
| if (stockCode.isEmpty()) { | |||
| logger.warn("[M18 BOM] stock UOM code missing for bom itemId=$itemId; udfHarvest=outputQty only. bomId=${bom.id}") | |||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||
| } | |||
| val match = bomItemStockUomPackCodeRegex.matchEntire(stockCode) | |||
| if (match == null) { | |||
| logger.warn( | |||
| "[M18 BOM] stock UOM code '$stockCode' does not match PREFIX+NUMBER+SUFFIX; " + | |||
| "udfHarvest=outputQty only. bomId=${bom.id} itemId=$itemId", | |||
| ) | |||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||
| } | |||
| val mult = match.groupValues[2].toBigDecimalOrNull() | |||
| if (mult == null || mult.compareTo(BigDecimal.ZERO) <= 0) { | |||
| logger.warn( | |||
| "[M18 BOM] invalid pack multiple in stock UOM code '$stockCode'; udfHarvest=outputQty only. bomId=${bom.id}", | |||
| ) | |||
| return outputQty.stripTrailingZeros().toPlainString() to null | |||
| } | |||
| val unitSuffix = match.groupValues[3] | |||
| val harvestQty = outputQty.multiply(mult).setScale(HARVEST_CALC_SCALE, RoundingMode.HALF_UP).stripTrailingZeros() | |||
| return harvestQty.toPlainString() to unitSuffix | |||
| // outputQty is stored in base unit; harvest = base qty, unit = base suffix or uom code tail. | |||
| val baseUom = bom.uom ?: itemUomService.findBaseUnitByItemId(itemId)?.uom | |||
| val harvestUnit = bom.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: baseUom?.udfShortDesc?.trim()?.takeIf { it.isNotEmpty() } | |||
| ?: baseUom?.code?.trim()?.takeIf { it.isNotEmpty() } | |||
| return outputQty.stripTrailingZeros().toPlainString() to harvestUnit | |||
| } | |||
| private fun toProductLine( | |||
| @@ -1497,7 +1497,7 @@ open class ChartService( | |||
| FROM stock_in_line sil | |||
| INNER JOIN job_order joSil ON joSil.id = sil.jobOrderId AND joSil.deleted = 0 | |||
| INNER JOIN bom b ON b.id = joSil.bomId AND b.deleted = 0 | |||
| AND UPPER(TRIM(COALESCE(b.description, ''))) IN ('FG', 'WIP') | |||
| AND UPPER(TRIM(COALESCE(b.bomKind, ''))) IN ('FG', 'WIP') | |||
| WHERE sil.deleted = 0 AND sil.jobOrderId IS NOT NULL | |||
| GROUP BY sil.jobOrderId | |||
| ) silAgg ON silAgg.joId = jo.id | |||
| @@ -1320,13 +1320,13 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri | |||
| val allStockInLines = stockInLineRepository.findByReceiptDateAndDeletedFalse(date) | |||
| println("📦 Found ${allStockInLines.size} StockInLines with receiptDate = $date") | |||
| println("📦 All StockInLines: ${allStockInLines.map { it.id }}, ${allStockInLines.map { it.jobOrder?.id }}") | |||
| println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.description }}") | |||
| println("📦 All StockInLines: ${allStockInLines.map { it.status }}, ${allStockInLines.map { it.jobOrder?.bom?.bomKind }}") | |||
| // 2. 进一步过滤:status = "received" 或 "completed",且 BOM type = "WIP" 或 "FG" | |||
| val stockInLines = allStockInLines.filter { | |||
| it.jobOrder != null | |||
| //it.status == "received" && // 只显示 QC 完成但尚未 putaway 的 | |||
| // it.jobOrder?.bom?.description in listOf("WIP", "FG") | |||
| // it.jobOrder?.bom?.bomKind in listOf("WIP", "FG") | |||
| } | |||
| println("✅ After filtering: ${stockInLines.size} StockInLines") | |||
| @@ -1348,7 +1348,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri | |||
| for (stockInLine in stockInLines) { | |||
| val jobOrder = jobOrders.find { it.id == stockInLine.jobOrder?.id } | |||
| if (jobOrder != null && jobOrder.bom?.description in listOf("WIP", "FG")) { | |||
| if (jobOrder != null && jobOrder.bom?.bomKind in listOf("WIP", "FG")) { | |||
| // Check if this job order is already in the result (to avoid duplicates) | |||
| val existing = result.find { | |||
| it.stockInLineId == stockInLine.id | |||
| @@ -1366,7 +1366,7 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List<JobOrderListForPri | |||
| finihedTime = jobOrder.planEnd ?: jobOrder.modified ?: LocalDateTime.now() | |||
| ) | |||
| result.add(response) | |||
| println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.description})") | |||
| println("✅ Added StockInLine ${stockInLine.id} (JobOrder ${jobOrder.id}, BOM: ${jobOrder.bom?.bomKind})") | |||
| } | |||
| } else { | |||
| println("❌ StockInLine ${stockInLine.id}: JobOrder ${stockInLine.jobOrder?.id} not found or BOM type not WIP/FG") | |||
| @@ -2382,7 +2382,7 @@ open fun getCompletedJobOrderPickOrders(completedDate: LocalDate?): List<Map<Str | |||
| jobOrderType = jobOrderType?.name, | |||
| itemId = item.id ?: 0L, | |||
| itemName = item.name ?: "", | |||
| bomDescription = bom?.description, | |||
| bomDescription = bom?.bomKind, | |||
| reqQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| //uomId = bom.outputQtyUom?.id : 0L, | |||
| uomId = 0, | |||
| @@ -193,8 +193,8 @@ open class JoWorkbenchMainService( | |||
| * Hierarchical pick UI for JO Workbench: available qty **in − out**; stockouts include **suggestedPickQty** when SPL matches SOL lot line. | |||
| */ | |||
| open fun getJobOrderLotsHierarchicalByPickOrderIdWorkbench(pickOrderId: Long): JobOrderLotsHierarchicalWorkbenchResponse { | |||
| println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | |||
| println("pickOrderId: $pickOrderId") | |||
| //println("=== JoWorkbenchMainService.getJobOrderLotsHierarchicalByPickOrderIdWorkbench ===") | |||
| //println("pickOrderId: $pickOrderId") | |||
| return try { | |||
| val pickOrder = pickOrderRepository.findById(pickOrderId).orElse(null) | |||
| @@ -8,6 +8,8 @@ import com.ffii.fpsms.modules.jobOrder.web.model.CreateJobOrderBomMaterialReques | |||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.master.service.BomMaterialQtyService | |||
| import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import org.springframework.stereotype.Service | |||
| import java.math.BigDecimal | |||
| @@ -20,22 +22,23 @@ open class JobOrderBomMaterialService( | |||
| private val jobOrderRepository: JobOrderRepository, | |||
| private val itemsRepository: ItemsRepository, | |||
| val itemUomService: ItemUomService, | |||
| private val uomConversionRepository: UomConversionRepository | |||
| private val uomConversionRepository: UomConversionRepository, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| ) { | |||
| open fun createJobOrderBomMaterialRequests(joId: Long): List<CreateJobOrderBomMaterialRequest> { | |||
| val zero = BigDecimal.ZERO | |||
| val jo = jobOrderRepository.findById(joId).getOrNull() ?: throw NoSuchElementException() | |||
| val proportion = (jo.reqQty ?: zero).divide(jo.bom?.outputQty ?: BigDecimal.ONE, 5, RoundingMode.HALF_UP) | |||
| val bomStockOutputQty = bomOutputQtyService.stockOutputQty(jo.bom) | |||
| val proportion = (jo.reqQty ?: zero).divide(bomStockOutputQty, 5, RoundingMode.HALF_UP) | |||
| val jobmRequests = jo.bom?.bomMaterials?.map { bm -> | |||
| // val salesUnit = bm.item?.id?.let { itemUomService.findSalesUnitByItemId(it) } | |||
| val stockUnit = bm.item?.id?.let { itemUomService.findStockUnitByItemId(it) } | |||
| CreateJobOrderBomMaterialRequest( | |||
| joId = joId, | |||
| itemId = bm.item?.id, | |||
| //reqQty = (bm.qty?.times(proportion) ?: zero).setScale(0,RoundingMode.CEILING), | |||
| reqQty = (bm.stockQty?.times(proportion) ?: zero).setScale(0, RoundingMode.CEILING), | |||
| uomId = stockUnit?.uom?.id | |||
| reqQty = bomMaterialQtyService.stockQtyForJob(bm, proportion), | |||
| uomId = bomMaterialQtyService.stockUomIdForJob(bm) ?: stockUnit?.uom?.id | |||
| ) | |||
| } ?: listOf() | |||
| @@ -43,10 +46,25 @@ open class JobOrderBomMaterialService( | |||
| } | |||
| fun createJobOrderBomMaterials(request: List<CreateJobOrderBomMaterialRequest>): MessageResponse { | |||
| val joIds = request.mapNotNull { it.joId }.distinct() | |||
| val itemIds = request.mapNotNull { it.itemId }.distinct() | |||
| val uomIds = request.mapNotNull { it.uomId }.distinct() | |||
| // 批量取回,避免在 map 裡逐筆 findById 造成 N+1。 | |||
| val joById = if (joIds.isNotEmpty()) { | |||
| jobOrderRepository.findAllById(joIds).associateBy { it.id } | |||
| } else emptyMap() | |||
| val itemById = if (itemIds.isNotEmpty()) { | |||
| itemsRepository.findAllById(itemIds).associateBy { it.id } | |||
| } else emptyMap() | |||
| val uomById = if (uomIds.isNotEmpty()) { | |||
| uomConversionRepository.findAllById(uomIds).associateBy { it.id } | |||
| } else emptyMap() | |||
| val joBomMaterials = request.map { req -> | |||
| val jo = req.joId?.let { jobOrderRepository.findById(it).getOrNull() } | |||
| val item = req.itemId?.let { itemsRepository.findById(it).getOrNull() } | |||
| val uom = req.uomId?.let { uomConversionRepository.findById(it).getOrNull() } | |||
| val jo = req.joId?.let { joById[it] } | |||
| val item = req.itemId?.let { itemById[it] } | |||
| val uom = req.uomId?.let { uomById[it] } | |||
| val roundedReqQty = req.reqQty?.setScale(0, RoundingMode.CEILING) | |||
| val statusEnum = JobOrderBomMaterialStatus.entries.find { it.value == req.status } | |||
| ?: JobOrderBomMaterialStatus.PENDING | |||
| @@ -69,6 +69,8 @@ import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.master.service.BomMaterialQtyService | |||
| import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | |||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | |||
| import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest | |||
| @@ -95,8 +97,9 @@ open class JobOrderService( | |||
| val productProcessRepository: ProductProcessRepository, | |||
| val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository, | |||
| val bomMaterialRepository: BomMaterialRepository, | |||
| val itemUomService: ItemUomService | |||
| val itemUomService: ItemUomService, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| ) { | |||
| open fun allJobOrdersByPage(request: SearchJobOrderInfoRequest): RecordsRes<JobOrderInfo> { | |||
| @@ -579,10 +582,14 @@ open class JobOrderService( | |||
| } | |||
| // ✅ 使用 stockReqQty (bomMaterial.saleQty) 和 stockUom (bomMaterial.salesUnit) | |||
| val stockReqQty = jobm.reqQty ?: bomMaterial?.stockQty ?: BigDecimal.ZERO | |||
| val stockUomId = bomMaterial?.stockUnit?.toLong() | |||
| ?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id // Fallback: 从 Item 获取库存单位 | |||
| ?: jobm.uom?.id // 最后的 fallback | |||
| val stockReqQty = jobm.reqQty | |||
| ?: bomMaterial?.let { bm -> | |||
| bomMaterialQtyService.stockQtyForJob(bm, BigDecimal.ONE) | |||
| } | |||
| ?: BigDecimal.ZERO | |||
| val stockUomId = bomMaterial?.let { bomMaterialQtyService.stockUomIdForJob(it) } | |||
| ?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id | |||
| ?: jobm.uom?.id | |||
| SavePickOrderLineRequest( | |||
| itemId = itemId, | |||
| @@ -1124,7 +1131,8 @@ open fun updateJoReqQty(request: UpdateJoReqQtyRequest): MessageResponse { | |||
| // 更新相关的 JobOrderBomMaterial 的 reqQty(根据新的比例重新计算) | |||
| val bom = jobOrder.bom | |||
| if (bom != null && bom.outputQty != null && bom.outputQty!! > BigDecimal.ZERO) { | |||
| val proportion = newReqQty.divide(bom.outputQty!!, 5, RoundingMode.HALF_UP) | |||
| val bomStockOutputQty = bomOutputQtyService.stockOutputQty(bom) | |||
| val proportion = newReqQty.divide(bomStockOutputQty, 5, RoundingMode.HALF_UP) | |||
| val jobOrderBomMaterials = jobOrderBomMaterialRepository.findAllByJobOrderId(jobOrder.id) | |||
| jobOrderBomMaterials.forEach { jobm -> | |||
| @@ -19,8 +19,13 @@ import java.math.RoundingMode | |||
| open class PSService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| /** Default: 6 days before today to 1 day after today. */ | |||
| /** | |||
| * FG BOM settings rows for 排期設定. | |||
| * Default date window: 6 days before today to 1 day after today. | |||
| * | |||
| * Performance: DO daily avg is pre-aggregated once (date-first via idx_do_deleted_eta), | |||
| * then joined — avoids per-item correlated scans of full delivery_order_line history. | |||
| */ | |||
| fun getItemDailyOut(fromDate: LocalDate? = null, toDate: LocalDate? = null): List<Map<String, Any>> { | |||
| val defaultToday = LocalDate.now() | |||
| val to = toDate ?: defaultToday.plusDays(1) | |||
| @@ -30,41 +35,59 @@ open class PSService( | |||
| "toDate" to to.toString(), | |||
| ) | |||
| val sql = """ | |||
| WITH do_daily AS ( | |||
| SELECT | |||
| dol.itemId, | |||
| DATE(do.estimatedArrivalDate) AS shipDate, | |||
| SUM(dol.qty) AS dayQty | |||
| FROM delivery_order do | |||
| INNER JOIN delivery_order_line dol | |||
| ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||
| WHERE do.deleted = 0 | |||
| AND do.estimatedArrivalDate >= :fromDate | |||
| AND do.estimatedArrivalDate <= :toDate | |||
| GROUP BY dol.itemId, DATE(do.estimatedArrivalDate) | |||
| ), | |||
| avg_daily AS ( | |||
| SELECT itemId, IFNULL(ROUND(AVG(dayQty)), 0) AS avgQtyLastMonth | |||
| FROM do_daily | |||
| GROUP BY itemId | |||
| ), | |||
| pending_job AS ( | |||
| SELECT bomId, SUM(reqQty) AS pendingJobQty | |||
| FROM job_order | |||
| WHERE status != 'completed' | |||
| GROUP BY bomId | |||
| ), | |||
| cot AS ( | |||
| SELECT | |||
| itemCode, | |||
| SUM(CASE WHEN systemType = 'coffee' THEN 1 ELSE 0 END) AS isCoffee, | |||
| SUM(CASE WHEN systemType = 'tea' THEN 1 ELSE 0 END) AS isTea, | |||
| SUM(CASE WHEN systemType = 'lemon' THEN 1 ELSE 0 END) AS isLemon | |||
| FROM coffee_or_tea | |||
| WHERE deleted = 0 | |||
| GROUP BY itemCode | |||
| ) | |||
| SELECT | |||
| (SELECT dailyQty FROM item_daily_out WHERE itemCode = items.code) AS dailyQty, | |||
| (SELECT | |||
| IFNULL(ROUND(AVG(d.dailyQty)), 0) | |||
| FROM | |||
| (SELECT | |||
| SUM(dol.qty) AS dailyQty | |||
| FROM | |||
| delivery_order_line dol | |||
| LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id | |||
| WHERE | |||
| do.deleted = 0 | |||
| AND dol.itemId = items.id | |||
| AND do.estimatedArrivalDate >= :fromDate AND do.estimatedArrivalDate <= :toDate | |||
| GROUP BY do.estimatedArrivalDate) AS d) AS avgQtyLastMonth, | |||
| (SELECT SUM(reqQty) FROM job_order WHERE bomId = bom.id AND status != 'completed') AS pendingJobQty, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'coffee' AND itemCode = items.code AND deleted = 0) AS isCoffee, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'tea' AND itemCode = items.code AND deleted = 0) AS isTea, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'lemon' AND itemCode = items.code AND deleted = 0) AS isLemon, | |||
| CASE WHEN item_fake_onhand.onHandQty IS NOT NULL THEN item_fake_onhand.onHandQty | |||
| ELSE inventory.onHandQty - 500 END AS stockQty, | |||
| ido.dailyQty AS dailyQty, | |||
| IFNULL(ad.avgQtyLastMonth, 0) AS avgQtyLastMonth, | |||
| pj.pendingJobQty AS pendingJobQty, | |||
| IFNULL(cot.isCoffee, 0) AS isCoffee, | |||
| IFNULL(cot.isTea, 0) AS isTea, | |||
| IFNULL(cot.isLemon, 0) AS isLemon, | |||
| CASE WHEN ifo.onHandQty IS NOT NULL THEN ifo.onHandQty | |||
| ELSE inventory.onHandQty - 500 END AS stockQty, | |||
| bom.baseScore, | |||
| bom.outputQty, | |||
| bom.outputQtyUom, | |||
| (SELECT udfudesc | |||
| FROM delivery_order_line | |||
| LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id | |||
| WHERE delivery_order_line.itemId = bom.itemId | |||
| LIMIT 1) AS doUom, | |||
| NULL AS doUom, | |||
| items.code AS itemCode, | |||
| items.name AS itemName, | |||
| uc_stock.udfudesc AS unit, | |||
| bom.description, | |||
| bom.bomKind, | |||
| inventory.onHandQty, | |||
| item_fake_onhand.onHandQty AS fakeOnHandQty, | |||
| ifo.onHandQty AS fakeOnHandQty, | |||
| bom.itemId, | |||
| bom.id AS bomId, | |||
| CASE WHEN bom.isDark = 5 THEN 11 WHEN bom.isDark = 3 THEN 6 WHEN bom.isDark = 1 THEN 2 ELSE 0 END AS markDark, | |||
| @@ -74,14 +97,19 @@ open class PSService( | |||
| bom.complexity AS markComplexity, | |||
| CASE WHEN bom.allergicSubstances = 5 THEN 11 ELSE 0 END AS markAS, | |||
| inventory.id AS inventoryId | |||
| FROM | |||
| bom | |||
| FROM bom | |||
| LEFT JOIN items ON bom.itemId = items.id | |||
| LEFT JOIN inventory ON items.id = inventory.itemId | |||
| LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode | |||
| LEFT JOIN item_fake_onhand ifo ON items.code = ifo.itemCode | |||
| LEFT JOIN item_daily_out ido ON items.code = ido.itemCode | |||
| LEFT JOIN item_uom iu ON iu.itemId = items.id AND iu.stockUnit = 1 | |||
| LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu.uomId | |||
| WHERE bom.deleted = 0 and bom.description = 'FG' | |||
| LEFT JOIN avg_daily ad ON ad.itemId = items.id | |||
| LEFT JOIN pending_job pj ON pj.bomId = bom.id | |||
| LEFT JOIN cot ON cot.itemCode = items.code | |||
| WHERE bom.deleted = 0 | |||
| AND bom.bomKind = 'FG' | |||
| ORDER BY items.code | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| } | |||
| @@ -177,7 +205,7 @@ open class PSService( | |||
| INNER JOIN ( | |||
| SELECT DISTINCT b.itemId | |||
| FROM bom b | |||
| WHERE b.deleted = 0 AND b.description = 'FG' | |||
| WHERE b.deleted = 0 AND b.bomKind = 'FG' | |||
| ) bom_items ON bom_items.itemId = i.itemId | |||
| LEFT JOIN ( | |||
| SELECT | |||
| @@ -269,49 +297,64 @@ open class PSService( | |||
| */ | |||
| fun listBomItemsWithStockUnit(): List<Map<String, Any>> { | |||
| val sql = """ | |||
| SELECT DISTINCT | |||
| SELECT | |||
| items.code AS itemCode, | |||
| items.name AS itemName, | |||
| uc_stock.udfudesc AS stockUnit | |||
| FROM bom | |||
| INNER JOIN items ON bom.itemId = items.id AND items.deleted = 0 | |||
| LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 | |||
| FROM ( | |||
| SELECT DISTINCT b.itemId | |||
| FROM bom b | |||
| WHERE b.deleted = 0 | |||
| ) bom_items | |||
| INNER JOIN items ON items.id = bom_items.itemId AND items.deleted = 0 | |||
| LEFT JOIN item_uom iu_stock ON iu_stock.itemId = items.id | |||
| AND iu_stock.stockUnit = 1 AND iu_stock.deleted = 0 | |||
| LEFT JOIN uom_conversion uc_stock ON uc_stock.id = iu_stock.uomId | |||
| WHERE bom.deleted = 0 | |||
| ORDER BY items.code | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, emptyMap<String, Any>()) | |||
| } | |||
| /** | |||
| * Sum of [delivery_order_line.qty] (already stored in stock unit) by item and ETA date. | |||
| * Only items that have a BOM. | |||
| * Sum of [delivery_order_line.qty] (stock unit) by item code and ETA date. | |||
| * When [bomItemCodes] is provided, only matching rows are returned (BOM filter in memory). | |||
| */ | |||
| fun sumDeliveryOrderQtyByItemAndDate(fromDate: LocalDate, toDate: LocalDate): List<Map<String, Any>> { | |||
| fun sumDeliveryOrderQtyByItemAndDate( | |||
| fromDate: LocalDate, | |||
| toDate: LocalDate, | |||
| bomItemCodes: Set<String>? = null, | |||
| ): List<Map<String, Any>> { | |||
| val args = mapOf( | |||
| "fromDate" to fromDate.toString(), | |||
| "toDate" to toDate.toString(), | |||
| "toDateExclusive" to toDate.plusDays(1).toString(), | |||
| ) | |||
| val sql = """ | |||
| WITH do_in_range AS ( | |||
| SELECT id, estimatedArrivalDate | |||
| FROM delivery_order | |||
| WHERE deleted = 0 | |||
| AND estimatedArrivalDate IS NOT NULL | |||
| AND estimatedArrivalDate >= :fromDate | |||
| AND estimatedArrivalDate < :toDateExclusive | |||
| ) | |||
| SELECT | |||
| items.code AS itemCode, | |||
| DATE(do.estimatedArrivalDate) AS shipDate, | |||
| SUM(COALESCE(dol.qty, 0)) AS qtySum | |||
| FROM delivery_order do | |||
| FROM do_in_range do | |||
| INNER JOIN delivery_order_line dol ON dol.deliveryOrderId = do.id AND dol.deleted = 0 | |||
| INNER JOIN items ON items.id = dol.itemId AND items.deleted = 0 | |||
| WHERE do.deleted = 0 | |||
| AND do.estimatedArrivalDate IS NOT NULL | |||
| AND DATE(do.estimatedArrivalDate) >= :fromDate | |||
| AND DATE(do.estimatedArrivalDate) <= :toDate | |||
| AND EXISTS ( | |||
| SELECT 1 FROM bom b | |||
| WHERE b.itemId = items.id AND b.deleted = 0 | |||
| ) | |||
| GROUP BY items.code, DATE(do.estimatedArrivalDate) | |||
| ORDER BY items.code, shipDate | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| val rows = jdbcDao.queryForList(sql, args) | |||
| if (bomItemCodes.isNullOrEmpty()) { | |||
| return rows | |||
| } | |||
| return rows.filter { row -> | |||
| val itemCode = row["itemCode"]?.toString() ?: return@filter false | |||
| itemCode in bomItemCodes | |||
| } | |||
| } | |||
| fun exportDeliveryOrderQtyByDateExcel(fromDate: LocalDate, toDate: LocalDate): ByteArray { | |||
| @@ -321,7 +364,8 @@ open class PSService( | |||
| val dates = generateSequence(fromDate) { it.plusDays(1) }.takeWhile { !it.isAfter(toDate) }.toList() | |||
| val items = listBomItemsWithStockUnit() | |||
| val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate) | |||
| val bomItemCodes = items.mapNotNull { it["itemCode"]?.toString() }.toSet() | |||
| val qtyRows = sumDeliveryOrderQtyByItemAndDate(fromDate, toDate, bomItemCodes) | |||
| val qtyByItemDate = mutableMapOf<String, MutableMap<LocalDate, BigDecimal>>() | |||
| qtyRows.forEach { row -> | |||
| @@ -345,10 +389,6 @@ open class PSService( | |||
| val textStyle = workbook.createCellStyle().apply { | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| val numberStyle = workbook.createCellStyle().apply { | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| dataFormat = workbook.createDataFormat().getFormat("#,##0") | |||
| } | |||
| val headerRow = sheet.createRow(0) | |||
| val headers = mutableListOf("Item Code", "Item Name", "UOM") | |||
| @@ -374,15 +414,20 @@ open class PSService( | |||
| dates.forEachIndexed { dateIdx, date -> | |||
| val qty = (dateQtyMap?.get(date) ?: BigDecimal.ZERO) | |||
| .setScale(0, RoundingMode.HALF_UP) | |||
| row.createCell(3 + dateIdx).apply { | |||
| setCellValue(qty.toLong().toDouble()) | |||
| cellStyle = numberStyle | |||
| } | |||
| row.createCell(3 + dateIdx).setCellValue(qty.toLong().toDouble()) | |||
| } | |||
| } | |||
| for (col in 0 until headers.size) { | |||
| sheet.autoSizeColumn(col) | |||
| sheet.setColumnWidth( | |||
| col, | |||
| when (col) { | |||
| 0 -> 4500 | |||
| 1 -> 9000 | |||
| 2 -> 3200 | |||
| else -> 3200 | |||
| }, | |||
| ) | |||
| } | |||
| ByteArrayOutputStream().use { out -> | |||
| @@ -53,8 +53,11 @@ open class Bom : BaseEntity<Long>() { | |||
| @Size(max = 100) | |||
| @NotNull | |||
| @Column(name = "description", nullable = false, length = 100) | |||
| open var description: String? = null | |||
| @Column(name = "bomKind", nullable = false, length = 100) | |||
| open var bomKind: String? = null | |||
| @Column(name = "revisionNo") | |||
| open var revisionNo: Int? = null | |||
| @Column(name = "outputQty", precision = 14, scale = 2) | |||
| open var outputQty: BigDecimal? = null | |||
| @@ -93,4 +96,8 @@ open class Bom : BaseEntity<Long>() { | |||
| @Column(name = "status", nullable = false, length = 20) | |||
| @Convert(converter = BomStatusConverter::class) | |||
| open var status: BomStatus = BomStatus.ACTIVE | |||
| @Size(max = 255) | |||
| @Column(name = "putawayLocationCode", length = 255) | |||
| open var putawayLocationCode: String? = null | |||
| } | |||
| @@ -37,27 +37,6 @@ open class BomMaterial : BaseEntity<Long>() { | |||
| @Column(name = "uomName", length = 100) | |||
| open var uomName: String? = null | |||
| @Column(name = "saleQty", precision = 14, scale = 2) | |||
| open var saleQty: BigDecimal? = null | |||
| @ManyToOne | |||
| @JoinColumn(name = "salesUnitId") | |||
| open var salesUnit: UomConversion? = null | |||
| @Column(name = "salesUnitCode") | |||
| open var salesUnitCode: String? = null | |||
| @Column(name = "baseQty", precision = 14, scale = 2) | |||
| open var baseQty: BigDecimal? = null | |||
| @Column(name = "baseUnit", nullable = false) | |||
| open var baseUnit: Integer? = null | |||
| @Column(name = "baseUnitName", length = 100) | |||
| open var baseUnitName: String? = null | |||
| @Column(name = "stockQty", precision = 14, scale = 2) | |||
| open var stockQty: BigDecimal? = null | |||
| @Column(name = "stockUnit", nullable = false) | |||
| open var stockUnit: Integer? = null | |||
| @Column(name = "stockUnitName", length = 100) | |||
| open var stockUnitName: String? = null | |||
| @NotNull | |||
| @ManyToOne(optional = false) | |||
| @JoinColumn(name = "bomId", nullable = false) | |||
| @@ -1,7 +1,9 @@ | |||
| package com.ffii.fpsms.modules.master.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.stereotype.Repository | |||
| import java.io.Serializable | |||
| @@ -24,9 +26,19 @@ interface BomMaterialRepository : AbstractRepository<BomMaterial, Long> { | |||
| JOIN FETCH bm.bom b | |||
| LEFT JOIN FETCH bm.item | |||
| LEFT JOIN FETCH bm.uom | |||
| LEFT JOIN FETCH bm.salesUnit | |||
| WHERE bm.deleted = false AND b.deleted = false | |||
| """, | |||
| ) | |||
| fun findAllActiveForActiveBoms(): List<BomMaterial> | |||
| @Query( | |||
| """ | |||
| SELECT bm FROM BomMaterial bm | |||
| JOIN FETCH bm.bom b | |||
| LEFT JOIN FETCH bm.item | |||
| LEFT JOIN FETCH bm.uom | |||
| WHERE bm.deleted = false AND b.deleted = false AND b.status = :status | |||
| """, | |||
| ) | |||
| fun findAllByBomStatusAndDeletedFalse(@Param("status") status: BomStatus): List<BomMaterial> | |||
| } | |||
| @@ -15,8 +15,8 @@ open class BomProcess : BaseEntity<Long>() { | |||
| @JoinColumn(name = "processId", nullable = false) | |||
| open var process: Process? = null | |||
| @ManyToOne | |||
| @JoinColumn(name = "equipmentId", nullable = false) | |||
| @ManyToOne(optional = true) | |||
| @JoinColumn(name = "equipmentId", nullable = true) | |||
| open var equipment: Equipment? = null | |||
| @Column(name = "description", nullable = false) | |||
| @@ -41,6 +41,12 @@ open class BomProcess : BaseEntity<Long>() { | |||
| @Column(name = "postProdTimeInMinute", nullable = false) | |||
| open var postProdTimeInMinute: Int? = null | |||
| @Column(name = "byProduct", nullable = true) | |||
| open var byProduct: String? = null | |||
| @Column(name = "byProductUom", nullable = true) | |||
| open var byProductUom: String? = null | |||
| @NotNull | |||
| @ManyToOne(optional = false) | |||
| @JsonBackReference | |||
| @@ -7,6 +7,7 @@ import org.springframework.stereotype.Repository | |||
| import java.io.Serializable | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.data.repository.query.Param | |||
| import org.springframework.data.jpa.repository.Modifying | |||
| @Repository | |||
| interface BomRepository : AbstractRepository<Bom, Long> { | |||
| fun findAllByDeletedIsFalse(): List<Bom> | |||
| @@ -21,6 +22,16 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||
| ) | |||
| fun findAllActiveWithItemAndUom(): List<Bom> | |||
| @Query( | |||
| """ | |||
| SELECT b FROM Bom b | |||
| LEFT JOIN FETCH b.item | |||
| LEFT JOIN FETCH b.uom | |||
| WHERE b.deleted = false AND b.status = :status | |||
| """, | |||
| ) | |||
| fun findAllByDeletedFalseAndStatusWithItemAndUom(@Param("status") status: BomStatus): List<Bom> | |||
| fun findByIdAndDeletedIsFalse(id: Serializable): Bom? | |||
| fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? | |||
| @@ -37,15 +48,62 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||
| fun findByCodeAndDeletedIsFalse(code: String): Bom? | |||
| fun findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code: String, description: String): Bom? | |||
| fun findByCodeAndBomKindIgnoreCaseAndDeletedIsFalse(code: String, bomKind: String): Bom? | |||
| fun findByCodeAndBomKindIgnoreCaseAndStatusAndDeletedIsFalse( | |||
| code: String, | |||
| bomKind: String, | |||
| status: BomStatus, | |||
| ): Bom? | |||
| fun countByCodeAndBomKindIgnoreCaseAndDeletedIsFalse(code: String, bomKind: String): Long | |||
| @Query( | |||
| """ | |||
| SELECT COALESCE(MAX(COALESCE(b.revisionNo, 0)), 0) | |||
| FROM Bom b | |||
| WHERE b.deleted = false | |||
| AND b.code = :code | |||
| AND lower(b.bomKind) = lower(:bomKind) | |||
| """, | |||
| ) | |||
| fun maxRevisionNoByCodeAndBomKind( | |||
| @Param("code") code: String, | |||
| @Param("bomKind") bomKind: String, | |||
| ): Int | |||
| fun findAllByCodeAndBomKindIgnoreCaseAndDeletedIsFalseOrderByRevisionNoAsc( | |||
| code: String, | |||
| bomKind: String, | |||
| ): List<Bom> | |||
| @Query(""" | |||
| select b.item.id | |||
| from Bom b | |||
| where b.deleted = false | |||
| and lower(b.description) = 'wip' | |||
| and lower(b.bomKind) = 'wip' | |||
| and b.item.id in :itemIds | |||
| """) | |||
| fun findWipItemIds(@Param("itemIds") itemIds: List<Long>): List<Long> | |||
| fun findFirstByItemIdAndDescriptionIgnoreCaseAndDeletedIsFalse(itemId: Long, description: String): Bom? | |||
| fun findFirstByItemIdAndBomKindIgnoreCaseAndDeletedIsFalse(itemId: Long, bomKind: String): Bom? | |||
| fun findTopByCodeAndBomKindIgnoreCaseAndDeletedIsFalseOrderByRevisionNoDesc(code: String, bomKind: String): Bom? | |||
| @Modifying(clearAutomatically = true, flushAutomatically = true) | |||
| @Query( | |||
| """ | |||
| update Bom b | |||
| set b.status = :inactive | |||
| where b.deleted = false | |||
| and b.code = :code | |||
| and lower(b.bomKind) = lower(:bomKind) | |||
| and b.status = :active | |||
| """, | |||
| ) | |||
| fun markActiveVersionsInactiveByCodeAndBomKind( | |||
| @Param("code") code: String, | |||
| @Param("bomKind") bomKind: String, | |||
| @Param("active") active: BomStatus = BomStatus.ACTIVE, | |||
| @Param("inactive") inactive: BomStatus = BomStatus.INACTIVE, | |||
| ): Int | |||
| } | |||
| @@ -15,4 +15,6 @@ interface EquipmentRepository : AbstractRepository<Equipment, Long> { | |||
| fun findByNameAndDeletedIsFalse(name: String): Equipment?; | |||
| fun findByDescriptionAndDeletedIsFalse(description: String): Equipment?; | |||
| fun findFirstByDescriptionAndNameAndDeletedFalse(description: String, name: String): Equipment?; | |||
| } | |||
| @@ -10,6 +10,10 @@ import java.util.Optional | |||
| interface ItemsRepository : AbstractRepository<Items, Long> { | |||
| fun findAllByDeletedFalse(): List<Items>; | |||
| fun findAllByIsBagTrueAndDeletedFalse(): List<Items>; | |||
| fun findAllByCodeInAndDeletedFalse(codes: Collection<String>): List<Items>; | |||
| fun findByIdAndDeletedFalse(id: Long): Items?; | |||
| fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; | |||
| @@ -7,11 +7,15 @@ interface BomCombo { | |||
| val id: Long; | |||
| @get:Value("#{target.id}") | |||
| val value: Long; | |||
| @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''}") | |||
| val code: String?; | |||
| val revisionNo: Int?; | |||
| @get:Value("#{target.code ?: ''} - #{target.name ?: ''} - #{target.item?.itemUoms?.^[salesUnit == true && deleted == false]?.uom?.udfudesc ?: ''} (V#{target.revisionNo != null ? target.revisionNo : 1})") | |||
| val label: String; | |||
| val outputQty: BigDecimal; | |||
| val outputQtyUom: String?; | |||
| @get:Value("#{target.description}") | |||
| @get:Value("#{target.bomKind}") | |||
| val bomKind: String?; | |||
| @get:Value("#{target.bomKind}") | |||
| val description: String?; | |||
| @get:Value("#{target.status?.value}") | |||
| val status: String?; | |||
| @@ -0,0 +1,295 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.core.exception.BadRequestException | |||
| import com.ffii.fpsms.modules.master.entity.BomMaterial | |||
| import com.ffii.fpsms.modules.master.entity.ItemUom | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | |||
| import java.math.BigDecimal | |||
| import java.math.RoundingMode | |||
| import org.springframework.stereotype.Service | |||
| data class BomMaterialDerivedQtys( | |||
| val saleQty: BigDecimal?, | |||
| val stockQty: BigDecimal?, | |||
| val baseQty: BigDecimal?, | |||
| val salesUnit: UomConversion?, | |||
| val salesUnitCode: String?, | |||
| val stockUnit: Integer?, | |||
| val stockUnitName: String?, | |||
| val baseUnit: Integer?, | |||
| val baseUnitName: String?, | |||
| ) | |||
| @Service | |||
| open class BomMaterialQtyService( | |||
| private val itemUomService: ItemUomService, | |||
| ) { | |||
| open fun deriveFromRecipe( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| ): BomMaterialDerivedQtys = deriveFromRecipeInternal(itemId, recipeQty, recipeUom, cache = null) | |||
| open fun deriveFromRecipe( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| cache: ItemUomCacheContext, | |||
| ): BomMaterialDerivedQtys = deriveFromRecipeInternal(itemId, recipeQty, recipeUom, cache) | |||
| private fun deriveFromRecipeInternal( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| cache: ItemUomCacheContext?, | |||
| ): BomMaterialDerivedQtys { | |||
| val baseItemUom = resolveBaseUnit(itemId, cache) | |||
| ?: throw BadRequestException("Item base unit not configured: itemId=$itemId") | |||
| val stockItemUom = resolveStockUnit(itemId, cache) | |||
| ?: throw BadRequestException("Item stock unit not configured: itemId=$itemId") | |||
| val salesItemUom = resolveSalesUnit(itemId, cache) | |||
| ?: throw BadRequestException("Item sales unit not configured: itemId=$itemId") | |||
| val baseUom = baseItemUom.uom | |||
| ?: throw BadRequestException("Base UOM conversion not configured: itemId=$itemId") | |||
| val stockUom = stockItemUom.uom | |||
| ?: throw BadRequestException("Stock UOM conversion not configured: itemId=$itemId") | |||
| val salesUom = salesItemUom.uom | |||
| ?: throw BadRequestException("Sales UOM conversion not configured: itemId=$itemId") | |||
| val computedBaseQty = convertRecipeToBase(itemId, recipeQty, recipeUom, baseUom, cache) | |||
| val computedStockQty = resolveStockQtyFromBaseRecipe( | |||
| itemId = itemId, | |||
| recipeQty = recipeQty, | |||
| recipeUom = recipeUom, | |||
| baseQty = computedBaseQty, | |||
| baseUom = baseUom, | |||
| stockUom = stockUom, | |||
| stockItemUom = stockItemUom, | |||
| salesUom = salesUom, | |||
| cache = cache, | |||
| ) | |||
| val convertRequest = ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = computedStockQty, | |||
| uomId = stockUom.id!!, | |||
| targetUnit = "salesUnit", | |||
| ) | |||
| val computedSaleQty = if (cache != null) { | |||
| itemUomService.convertUomByItem(convertRequest, cache).newQty | |||
| } else { | |||
| itemUomService.convertUomByItem(convertRequest).newQty | |||
| } | |||
| return BomMaterialDerivedQtys( | |||
| saleQty = computedSaleQty, | |||
| stockQty = computedStockQty, | |||
| baseQty = computedBaseQty, | |||
| salesUnit = salesUom, | |||
| salesUnitCode = salesUom.udfudesc, | |||
| stockUnit = stockUom.id!!.toInt().let { Integer.valueOf(it) } as? Integer, | |||
| stockUnitName = stockUom.udfudesc, | |||
| baseUnit = baseUom.id!!.toInt().let { Integer.valueOf(it) } as? Integer, | |||
| baseUnitName = baseUom.udfudesc, | |||
| ) | |||
| } | |||
| private fun resolveBaseUnit(itemId: Long, cache: ItemUomCacheContext?): ItemUom? = | |||
| cache?.baseUnit(itemId) ?: itemUomService.findBaseUnitByItemId(itemId) | |||
| private fun resolveStockUnit(itemId: Long, cache: ItemUomCacheContext?): ItemUom? = | |||
| cache?.stockUnit(itemId) ?: itemUomService.findStockUnitByItemId(itemId) | |||
| private fun resolveSalesUnit(itemId: Long, cache: ItemUomCacheContext?): ItemUom? = | |||
| cache?.salesUnit(itemId) ?: itemUomService.findSalesUnitByItemId(itemId) | |||
| open fun deriveForEntity(material: BomMaterial): BomMaterialDerivedQtys? { | |||
| val itemId = material.item?.id ?: return null | |||
| val recipeQty = material.qty ?: return null | |||
| val recipeUom = material.uom ?: return null | |||
| return try { | |||
| deriveFromRecipe(itemId, recipeQty, recipeUom) | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| } | |||
| open fun stockQtyForJob(material: BomMaterial, proportion: BigDecimal): BigDecimal { | |||
| val derived = deriveForEntity(material) ?: return BigDecimal.ZERO | |||
| val stockQty = derived.stockQty ?: return BigDecimal.ZERO | |||
| return stockQty.times(proportion).setScale(0, RoundingMode.CEILING) | |||
| } | |||
| open fun stockUomIdForJob(material: BomMaterial): Long? { | |||
| val itemId = material.item?.id | |||
| return deriveForEntity(material)?.stockUnit?.toLong() | |||
| ?: itemId?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id } | |||
| } | |||
| /** Whether current recipe UOM can be converted to item base (preview left-column base/stock). */ | |||
| open fun canConvertRecipeToBase( | |||
| itemId: Long, | |||
| recipeUom: UomConversion, | |||
| baseUom: UomConversion, | |||
| ): Boolean { | |||
| if (recipeUom.id == baseUom.id) return true | |||
| val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom) | |||
| val baseInMatrix = StandardUomMatrix.toMatrixUnit(baseUom) | |||
| if (recipeInMatrix != null && baseInMatrix != null) { | |||
| return try { | |||
| StandardUomMatrix.convertByUom(BigDecimal.ONE, recipeUom, baseUom) | |||
| true | |||
| } catch (_: Exception) { | |||
| false | |||
| } | |||
| } | |||
| if (recipeInMatrix != null && baseInMatrix == null) { | |||
| return false | |||
| } | |||
| return try { | |||
| itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = BigDecimal.ONE, | |||
| uomId = recipeUom.id!!, | |||
| targetUnit = "baseUnit", | |||
| ), | |||
| ) | |||
| true | |||
| } catch (_: Exception) { | |||
| false | |||
| } | |||
| } | |||
| /** Matrix recipe (e.g. LB) → item stock unit via gram bridge and item_uom ratioN. */ | |||
| open fun convertMatrixRecipeToStock( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| ): BigDecimal = convertMatrixRecipeToStock(itemId, recipeQty, recipeUom, cache = null) | |||
| private fun convertMatrixRecipeToStock( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| cache: ItemUomCacheContext?, | |||
| ): BigDecimal { | |||
| val stockItemUom = resolveStockUnit(itemId, cache) | |||
| ?: throw BadRequestException("Item stock unit not configured: itemId=$itemId") | |||
| val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom) | |||
| ?: run { | |||
| val request = ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = recipeQty, | |||
| uomId = recipeUom.id!!, | |||
| targetUnit = "stockUnit", | |||
| ) | |||
| return if (cache != null) { | |||
| itemUomService.convertUomByItem(request, cache).newQty | |||
| } else { | |||
| itemUomService.convertUomByItem(request).newQty | |||
| } | |||
| } | |||
| val gramQty = StandardUomMatrix.convert( | |||
| recipeQty, | |||
| recipeInMatrix, | |||
| StandardUomMatrix.MatrixUnit.G, | |||
| ) | |||
| val ratioN = stockItemUom.ratioN ?: BigDecimal.ONE | |||
| val ratioD = stockItemUom.ratioD ?: BigDecimal.ONE | |||
| return gramQty.multiply(ratioD).divide(ratioN, 2, RoundingMode.HALF_UP) | |||
| } | |||
| private fun resolveStockQtyFromBaseRecipe( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| baseQty: BigDecimal, | |||
| baseUom: UomConversion, | |||
| stockUom: UomConversion, | |||
| stockItemUom: ItemUom, | |||
| salesUom: UomConversion, | |||
| cache: ItemUomCacheContext?, | |||
| ): BigDecimal { | |||
| if (recipeUom.id == baseUom.id) { | |||
| stockQtyViaLbPerPack(recipeQty, stockUom)?.let { return it } | |||
| } | |||
| if (StandardUomMatrix.toMatrixUnit(recipeUom) != null && recipeUom.id != baseUom.id) { | |||
| return convertMatrixRecipeToStock(itemId, recipeQty, recipeUom, cache) | |||
| } | |||
| val saleRequest = ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = baseQty, | |||
| uomId = baseUom.id!!, | |||
| targetUnit = "salesUnit", | |||
| ) | |||
| val saleQty = if (cache != null) { | |||
| itemUomService.convertUomByItem(saleRequest, cache).newQty | |||
| } else { | |||
| itemUomService.convertUomByItem(saleRequest).newQty | |||
| } | |||
| val stockRequest = ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = saleQty, | |||
| uomId = salesUom.id!!, | |||
| targetUnit = "stockUnit", | |||
| ) | |||
| return if (cache != null) { | |||
| itemUomService.convertUomByItem(stockRequest, cache).newQty | |||
| } else { | |||
| itemUomService.convertUomByItem(stockRequest).newQty | |||
| } | |||
| } | |||
| /** e.g. 250 (lb-count base PACK) / 50 lb per PACK50LB → 5 stock packs. */ | |||
| private fun stockQtyViaLbPerPack(recipeQty: BigDecimal, stockUom: UomConversion): BigDecimal? { | |||
| val unit2 = stockUom.unit2?.trim()?.uppercase() ?: return null | |||
| if (unit2 != "LB" && unit2 != "LBS") return null | |||
| val lbPerPack = stockUom.unit2Qty ?: return null | |||
| if (lbPerPack <= 0) return null | |||
| return recipeQty.divide(BigDecimal.valueOf(lbPerPack), 2, RoundingMode.HALF_UP) | |||
| } | |||
| private fun convertRecipeToBase( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| baseUom: UomConversion, | |||
| cache: ItemUomCacheContext?, | |||
| ): BigDecimal { | |||
| if (recipeUom.id == baseUom.id) { | |||
| return recipeQty | |||
| } | |||
| val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom) | |||
| val baseInMatrix = StandardUomMatrix.toMatrixUnit(baseUom) | |||
| if (recipeInMatrix != null && baseInMatrix != null) { | |||
| return StandardUomMatrix.convertByUom(recipeQty, recipeUom, baseUom) | |||
| } | |||
| if (recipeInMatrix != null && baseInMatrix == null) { | |||
| return recipeQty | |||
| } | |||
| return try { | |||
| val request = ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = recipeQty, | |||
| uomId = recipeUom.id | |||
| ?: throw BadRequestException("Recipe UOM id not found: itemId=$itemId"), | |||
| targetUnit = "baseUnit", | |||
| ) | |||
| if (cache != null) { | |||
| itemUomService.convertUomByItem(request, cache).newQty | |||
| } else { | |||
| itemUomService.convertUomByItem(request).newQty | |||
| } | |||
| } catch (e: Exception) { | |||
| throw BadRequestException( | |||
| "Recipe UOM cannot convert to item base unit: itemId=$itemId, " + | |||
| "recipe=${recipeUom.code}, base=${baseUom.code}. ${e.message ?: ""}".trim(), | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,36 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.boot.context.event.ApplicationReadyEvent | |||
| import org.springframework.context.event.EventListener | |||
| import org.springframework.stereotype.Component | |||
| import org.springframework.transaction.annotation.Transactional | |||
| /** | |||
| * Converts legacy BOM header output qty (stock/sales unit) to base unit on startup. | |||
| * Idempotent: rows already on base unit are skipped. | |||
| */ | |||
| @Component | |||
| open class BomOutputQtyMigrationRunner( | |||
| private val bomRepository: BomRepository, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| ) { | |||
| private val logger = LoggerFactory.getLogger(BomOutputQtyMigrationRunner::class.java) | |||
| @EventListener(ApplicationReadyEvent::class) | |||
| @Transactional | |||
| open fun migrateLegacyBomOutputToBase() { | |||
| val boms = bomRepository.findAllByDeletedIsFalse() | |||
| var updated = 0 | |||
| for (bom in boms) { | |||
| if (bomOutputQtyService.migrateLegacyOutputToBase(bom)) { | |||
| bomRepository.saveAndFlush(bom) | |||
| updated++ | |||
| } | |||
| } | |||
| if (updated > 0) { | |||
| logger.info("BOM output qty migration: converted {} header(s) to base unit storage", updated) | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,162 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.core.exception.BadRequestException | |||
| import com.ffii.fpsms.modules.master.entity.Bom | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | |||
| import java.math.BigDecimal | |||
| import java.math.RoundingMode | |||
| import org.springframework.stereotype.Service | |||
| /** Display/stock-facing output qty + label (UI, job-order multipliers, proportions). */ | |||
| data class BomOutputDisplay( | |||
| val qty: BigDecimal, | |||
| val uomLabel: String?, | |||
| val uomId: Long?, | |||
| ) | |||
| /** Persisted base-unit output qty written to [Bom.outputQty] / [Bom.uom]. */ | |||
| data class BomOutputPersisted( | |||
| val qty: BigDecimal, | |||
| val uomLabel: String?, | |||
| val uom: UomConversion?, | |||
| ) | |||
| /** | |||
| * BOM header output qty is stored in **base unit** on [Bom]. | |||
| * UI and job-order flows use **stock unit** via [displayAsStockUnit] / [stockOutputQty]. | |||
| */ | |||
| @Service | |||
| open class BomOutputQtyService( | |||
| private val itemUomService: ItemUomService, | |||
| ) { | |||
| private val calcScale = 10 | |||
| open fun isStoredAsBaseUnit(bom: Bom): Boolean { | |||
| val itemId = bom.item?.id ?: return false | |||
| val baseUomId = itemUomService.findBaseUnitByItemId(itemId)?.uom?.id ?: return false | |||
| return bom.uom?.id == baseUomId | |||
| } | |||
| /** Stock/sales qty from UI or Excel → persist on [Bom] as base unit. */ | |||
| open fun applyStockQtyToBom(bom: Bom, stockQty: BigDecimal, sourceUomId: Long) { | |||
| val itemId = bom.item?.id | |||
| ?: throw BadRequestException("BOM 未關聯貨品,無法換算產出數量") | |||
| val persisted = persistFromSourceUnit(itemId, stockQty, sourceUomId) | |||
| bom.outputQty = persisted.qty | |||
| bom.outputQtyUom = persisted.uomLabel | |||
| bom.uom = persisted.uom | |||
| } | |||
| /** Stock/sales qty from UI or Excel → base values for DB. */ | |||
| open fun persistFromSourceUnit( | |||
| itemId: Long, | |||
| sourceQty: BigDecimal, | |||
| sourceUomId: Long, | |||
| ): BomOutputPersisted { | |||
| val baseItemUom = itemUomService.findBaseUnitByItemId(itemId) | |||
| ?: throw BadRequestException("貨品未設定 base unit: itemId=$itemId") | |||
| val baseUom = baseItemUom.uom | |||
| ?: throw BadRequestException("貨品 base unit 換算未設定: itemId=$itemId") | |||
| val baseQty = itemUomService.convertQtyToBaseQtyPrecise(itemId, sourceUomId, sourceQty) | |||
| ?: throw BadRequestException("無法將產出數量換算為 base unit: itemId=$itemId, uomId=$sourceUomId") | |||
| return BomOutputPersisted( | |||
| qty = baseQty.setScale(2, RoundingMode.HALF_UP), | |||
| uomLabel = baseUom.udfudesc, | |||
| uom = baseUom, | |||
| ) | |||
| } | |||
| /** Persist when qty is already expressed in base unit (e.g. M18 udfHarvest). */ | |||
| open fun applyBaseQtyToBom(bom: Bom, baseQty: BigDecimal) { | |||
| val itemId = bom.item?.id | |||
| ?: throw BadRequestException("BOM 未關聯貨品,無法設定產出數量") | |||
| val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom | |||
| ?: throw BadRequestException("貨品未設定 base unit: itemId=$itemId") | |||
| bom.outputQty = baseQty.setScale(2, RoundingMode.HALF_UP) | |||
| bom.outputQtyUom = baseUom.udfudesc | |||
| bom.uom = baseUom | |||
| } | |||
| /** DB base → stock unit for display and job-order math. */ | |||
| open fun displayAsStockUnit(bom: Bom): BomOutputDisplay { | |||
| val itemId = bom.item?.id | |||
| val baseQty = bom.outputQty | |||
| if (itemId == null || baseQty == null) { | |||
| return BomOutputDisplay( | |||
| qty = baseQty ?: BigDecimal.ZERO, | |||
| uomLabel = bom.outputQtyUom, | |||
| uomId = bom.uom?.id, | |||
| ) | |||
| } | |||
| val baseUomId = bom.uom?.id | |||
| ?: itemUomService.findBaseUnitByItemId(itemId)?.uom?.id | |||
| if (baseUomId == null || baseQty.compareTo(BigDecimal.ZERO) == 0) { | |||
| val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom | |||
| return BomOutputDisplay(baseQty, stockUom?.udfudesc ?: bom.outputQtyUom, stockUom?.id) | |||
| } | |||
| return try { | |||
| val converted = itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = baseQty, | |||
| uomId = baseUomId, | |||
| targetUnit = "stockUnit", | |||
| ), | |||
| ) | |||
| BomOutputDisplay( | |||
| qty = converted.newQty, | |||
| uomLabel = converted.udfudesc, | |||
| uomId = itemUomService.findStockUnitByItemId(itemId)?.uom?.id, | |||
| ) | |||
| } catch (_: Exception) { | |||
| BomOutputDisplay(baseQty, bom.outputQtyUom, baseUomId) | |||
| } | |||
| } | |||
| /** Stock-unit batch output qty for proportions / scheduling (falls back to stored qty). */ | |||
| open fun stockOutputQty(bom: Bom?): BigDecimal { | |||
| if (bom == null) return BigDecimal.ONE | |||
| val display = displayAsStockUnit(bom) | |||
| val qty = display.qty | |||
| return if (qty > BigDecimal.ZERO) qty else (bom.outputQty ?: BigDecimal.ONE) | |||
| } | |||
| open fun stockOutputUomLabel(bom: Bom?): String? = bom?.let { displayAsStockUnit(it).uomLabel } | |||
| /** | |||
| * One-time style migration helper: convert legacy sales/stock-stored rows to base. | |||
| * Returns true when the entity was updated. | |||
| */ | |||
| open fun migrateLegacyOutputToBase(bom: Bom): Boolean { | |||
| val itemId = bom.item?.id ?: return false | |||
| val baseItemUom = itemUomService.findBaseUnitByItemId(itemId) ?: return false | |||
| val baseUom = baseItemUom.uom ?: return false | |||
| val baseUomId = baseUom.id ?: return false | |||
| if (bom.uom?.id == baseUomId) return false | |||
| val qty = bom.outputQty ?: return false | |||
| val outputDesc = bom.outputQtyUom?.trim().orEmpty() | |||
| val baseDesc = baseUom.udfudesc?.trim().orEmpty() | |||
| val baseShort = baseUom.udfShortDesc?.trim().orEmpty() | |||
| val qtyAlreadyInBase = outputDesc.isNotEmpty() && | |||
| (outputDesc.equals(baseDesc, ignoreCase = true) || outputDesc.equals(baseShort, ignoreCase = true)) | |||
| if (qtyAlreadyInBase) { | |||
| bom.uom = baseUom | |||
| bom.outputQtyUom = baseUom.udfudesc | |||
| return true | |||
| } | |||
| val sourceUomId = bom.uom?.id | |||
| ?: itemUomService.findSalesUnitByItemId(itemId)?.uom?.id | |||
| ?: itemUomService.findStockUnitByItemId(itemId)?.uom?.id | |||
| ?: return false | |||
| val baseQty = itemUomService.convertQtyToBaseQtyPrecise(itemId, sourceUomId, qty) ?: return false | |||
| bom.outputQty = baseQty.setScale(2, RoundingMode.HALF_UP) | |||
| bom.outputQtyUom = baseUom.udfudesc | |||
| bom.uom = baseUom | |||
| return true | |||
| } | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| object BomProcessConstants { | |||
| /** Fixed `process.code` for packaging steps (工序說明 = bag items). */ | |||
| const val PACKAGING_PROCESS_CODE = "包裝" | |||
| } | |||
| @@ -0,0 +1,82 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||
| import com.ffii.fpsms.modules.master.web.models.BomMaterialUomOption | |||
| import org.springframework.stereotype.Service | |||
| /** | |||
| * Recipe UOM dropdown options for BOM materials. | |||
| * If item base is in [StandardUomMatrix], offer standard matrix units (+ current when needed). | |||
| * Otherwise only the item base unit is allowed (no legacy/current recipe UOM). | |||
| * | |||
| * Important: when Excel/current recipe UOM is non-matrix (e.g. PCS) but item base IS in matrix | |||
| * (e.g. G), still offer the full matrix list so the user can correct to a convertible unit. | |||
| */ | |||
| @Service | |||
| open class BomRecipeUomOptionsService( | |||
| private val itemUomService: ItemUomService, | |||
| private val uomConversionRepository: UomConversionRepository, | |||
| ) { | |||
| open fun buildAvailableRecipeUoms(itemId: Long, currentRecipeUomId: Long?): List<BomMaterialUomOption> { | |||
| val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom | |||
| // Base not in matrix → only M18 base unit is selectable | |||
| if (baseUom != null && StandardUomMatrix.toMatrixUnit(baseUom) == null) { | |||
| val baseId = baseUom.id ?: return emptyList() | |||
| return listOf( | |||
| BomMaterialUomOption( | |||
| uomId = baseId, | |||
| label = baseUom.udfudesc?.takeIf { it.isNotBlank() } ?: baseUom.code ?: "-", | |||
| code = baseUom.code, | |||
| ), | |||
| ) | |||
| } | |||
| // Base in matrix (or missing): offer full matrix list | |||
| val seen = linkedSetOf<Long>() | |||
| val options = mutableListOf<BomMaterialUomOption>() | |||
| for (code in StandardUomMatrix.STANDARD_CODES) { | |||
| val uom = uomConversionRepository.findByCodeAndDeletedFalse(code) ?: continue | |||
| val id = uom.id ?: continue | |||
| if (seen.add(id)) { | |||
| options.add( | |||
| BomMaterialUomOption( | |||
| uomId = id, | |||
| label = uom.udfudesc?.takeIf { it.isNotBlank() } ?: code, | |||
| code = uom.code, | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| baseUom?.let { base -> | |||
| val id = base.id ?: return@let | |||
| if (id !in seen) { | |||
| options.add( | |||
| BomMaterialUomOption( | |||
| uomId = id, | |||
| label = base.udfudesc?.takeIf { it.isNotBlank() } ?: base.code ?: "-", | |||
| code = base.code, | |||
| ), | |||
| ) | |||
| seen.add(id) | |||
| } | |||
| } | |||
| // Keep invalid Excel current (e.g. PCS) visible so user can see and change it | |||
| if (currentRecipeUomId != null && currentRecipeUomId !in seen) { | |||
| uomConversionRepository.findByIdAndDeletedFalse(currentRecipeUomId)?.let { uom -> | |||
| options.add( | |||
| BomMaterialUomOption( | |||
| uomId = currentRecipeUomId, | |||
| label = uom.udfudesc?.takeIf { it.isNotBlank() } ?: uom.code ?: "-", | |||
| code = uom.code, | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| return options | |||
| } | |||
| } | |||
| @@ -0,0 +1,70 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.core.exception.BadRequestException | |||
| import com.ffii.fpsms.modules.master.entity.Bom | |||
| /** | |||
| * Allowed BOM basic-info scale values. | |||
| * Must stay aligned with frontend [bomAttributeScales.ts] and Excel import format checks. | |||
| */ | |||
| object BomScaleValidation { | |||
| private val ALLOWED_DARK = setOf(0, 1, 2, 3, 4, 5) | |||
| private val ALLOWED_FLOAT = setOf(0, 3, 5) | |||
| private val ALLOWED_DENSE = setOf(0, 3, 5) | |||
| private val ALLOWED_TIME_SEQUENCE = setOf(0, 1, 5) | |||
| private val ALLOWED_COMPLEXITY = setOf(0, 3, 5, 10) | |||
| private val ALLOWED_ALLERGIC = setOf(0, 5) | |||
| fun validateBomScaleFields(bom: Bom) { | |||
| bom.isDark?.let { validateDark(it) } | |||
| bom.isFloat?.let { validateFloat(it) } | |||
| bom.isDense?.let { validateDense(it) } | |||
| bom.timeSequence?.let { validateTimeSequence(it) } | |||
| bom.complexity?.let { validateComplexity(it) } | |||
| bom.allergicSubstances?.let { validateAllergic(it) } | |||
| } | |||
| fun validateDark(value: Int) { | |||
| if (value !in ALLOWED_DARK) { | |||
| throw BadRequestException("基本資料:『顔色深淺度』必須為 1~5 的數值") | |||
| } | |||
| } | |||
| fun validateFloat(value: Int) { | |||
| if (value !in ALLOWED_FLOAT) { | |||
| throw BadRequestException("基本資料:『浮沉』必須為 0、3 或 5") | |||
| } | |||
| } | |||
| fun validateDense(value: Int) { | |||
| if (value !in ALLOWED_DENSE) { | |||
| throw BadRequestException("基本資料:『濃淡程度』必須為 0、3 或 5") | |||
| } | |||
| } | |||
| fun validateTimeSequence(value: Int) { | |||
| if (value !in ALLOWED_TIME_SEQUENCE) { | |||
| throw BadRequestException("基本資料:『生產時段先後數值』必須為 0、1 或 5") | |||
| } | |||
| } | |||
| fun validateComplexity(value: Int) { | |||
| if (value !in ALLOWED_COMPLEXITY) { | |||
| throw BadRequestException("基本資料:『生產複雜度』必須為 0、3、5 或 10") | |||
| } | |||
| } | |||
| fun validateAllergic(value: Int) { | |||
| if (value !in ALLOWED_ALLERGIC) { | |||
| throw BadRequestException("基本資料:『過敏原』應為數值 0 或 5") | |||
| } | |||
| } | |||
| /** Edit BOM: scored attributes cannot remain 0 (不適用). */ | |||
| fun validateBomScaleFieldsForEdit(bom: Bom) { | |||
| validateBomScaleFields(bom) | |||
| if (bom.isDark == null || bom.isDark == 0) { | |||
| throw BadRequestException("色深請設定數值,不可為不適用") | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,804 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.fpsms.modules.master.entity.Bom | |||
| import com.ffii.fpsms.modules.master.entity.BomMaterial | |||
| import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | |||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||
| import com.ffii.fpsms.modules.master.web.models.* | |||
| import com.ffii.core.exception.BadRequestException | |||
| import java.math.BigDecimal | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| @Service | |||
| open class BomUomAlignmentService( | |||
| private val bomRepository: BomRepository, | |||
| private val bomMaterialRepository: BomMaterialRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val uomConversionService: UomConversionService, | |||
| private val uomConversionRepository: UomConversionRepository, | |||
| private val masterDataIssueService: MasterDataIssueService, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| private val bomRecipeUomOptionsService: BomRecipeUomOptionsService, | |||
| ) { | |||
| private data class ItemUomSet( | |||
| val sales: UomConversion?, | |||
| val stock: UomConversion?, | |||
| val base: UomConversion?, | |||
| ) | |||
| @Transactional(readOnly = true) | |||
| open fun previewAlignment(request: BomUomAlignmentPreviewRequest): BomUomAlignmentPreviewResponse { | |||
| val filterBomIds = request.bomIds?.toSet() | |||
| val filterMaterialIds = request.bomMaterialIds?.toSet() | |||
| val headers = mutableListOf<BomHeaderUomAlignmentPreview>() | |||
| val materials = mutableListOf<BomMaterialUomAlignmentPreview>() | |||
| val skipped = mutableListOf<BomUomAlignmentSkipped>() | |||
| val headerDone = mutableSetOf<Long>() | |||
| val itemUomCache = mutableMapOf<Long, ItemUomSet>() | |||
| // Fast path: when row scope is provided, avoid full master-data issue scan. | |||
| if (filterBomIds != null || filterMaterialIds != null) { | |||
| if (filterMaterialIds == null) { | |||
| for (bomId in filterBomIds.orEmpty()) { | |||
| if (!headerDone.add(bomId)) continue | |||
| val bom = bomRepository.findById(bomId).orElse(null) ?: continue | |||
| buildHeaderPreview(bom)?.let { headers.add(it) } | |||
| ?: skipped.add( | |||
| BomUomAlignmentSkipped( | |||
| bomId = bomId, | |||
| bomCode = bom.code, | |||
| reason = "無法建立 BOM 表頭預覽", | |||
| ), | |||
| ) | |||
| } | |||
| } | |||
| val scopedMaterials = | |||
| when { | |||
| filterMaterialIds != null -> | |||
| filterMaterialIds.mapNotNull { id -> | |||
| bomMaterialRepository.findByIdAndDeletedIsFalse(id) | |||
| } | |||
| filterBomIds != null -> | |||
| filterBomIds.flatMap { bomId -> | |||
| bomMaterialRepository.findAllByBomIdAndDeletedIsFalse(bomId) | |||
| } | |||
| else -> emptyList() | |||
| } | |||
| for (material in scopedMaterials) { | |||
| val bomId = material.bom?.id ?: continue | |||
| if (filterBomIds != null && bomId !in filterBomIds) continue | |||
| buildMaterialPreview(material, itemUomCache)?.let { materials.add(it) } | |||
| ?: skipped.add( | |||
| BomUomAlignmentSkipped( | |||
| bomId = bomId, | |||
| bomCode = material.bom?.code, | |||
| bomMaterialId = material.id, | |||
| itemCode = material.item?.code, | |||
| reason = "無法建立原料預覽(缺少可換算單位或換算失敗)", | |||
| ), | |||
| ) | |||
| } | |||
| return BomUomAlignmentPreviewResponse( | |||
| headers = headers.sortedBy { it.bomCode?.uppercase() ?: "" }, | |||
| materials = materials.sortedWith( | |||
| compareBy( | |||
| { it.itemCode?.uppercase() ?: "" }, | |||
| { it.bomCode?.uppercase() ?: "" }, | |||
| ), | |||
| ), | |||
| skipped = skipped, | |||
| ) | |||
| } | |||
| val issues = masterDataIssueService.findBomMasterDataIssues() | |||
| val headerBomIds = issues | |||
| .filter { | |||
| it.issueCode == "BOM_OUTPUT_UOM_MISMATCH_BASE" || | |||
| it.issueCode == "BOM_OUTPUT_UOM_TEXT_DRIFT" | |||
| } | |||
| .mapNotNull { it.bomId } | |||
| .toSet() | |||
| val materialIds = issues | |||
| .filter { | |||
| it.issueCode == "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE" || | |||
| it.issueCode == "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX" || | |||
| it.issueCode == "BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX" || | |||
| it.issueCode == "BOM_MATERIAL_DERIVE_FAILED" | |||
| } | |||
| .mapNotNull { it.bomMaterialId } | |||
| .toSet() | |||
| for (bomId in headerBomIds) { | |||
| if (filterMaterialIds != null) continue | |||
| if (filterBomIds != null && bomId !in filterBomIds) continue | |||
| if (!headerDone.add(bomId)) continue | |||
| val bom = bomRepository.findById(bomId).orElse(null) ?: continue | |||
| buildHeaderPreview(bom)?.let { headers.add(it) } | |||
| ?: skipped.add( | |||
| BomUomAlignmentSkipped( | |||
| bomId = bomId, | |||
| bomCode = bom.code, | |||
| reason = "無法建立 BOM 表頭預覽", | |||
| ), | |||
| ) | |||
| } | |||
| for (materialId in materialIds) { | |||
| if (filterMaterialIds != null && materialId !in filterMaterialIds) continue | |||
| val material = bomMaterialRepository.findByIdAndDeletedIsFalse(materialId) ?: continue | |||
| val bomId = material.bom?.id ?: continue | |||
| if (filterMaterialIds == null && filterBomIds != null && bomId !in filterBomIds) continue | |||
| buildMaterialPreview(material, itemUomCache)?.let { materials.add(it) } | |||
| ?: skipped.add( | |||
| BomUomAlignmentSkipped( | |||
| bomId = bomId, | |||
| bomCode = material.bom?.code, | |||
| bomMaterialId = materialId, | |||
| itemCode = material.item?.code, | |||
| reason = "無法建立原料預覽(缺少可換算單位或換算失敗)", | |||
| ), | |||
| ) | |||
| } | |||
| return BomUomAlignmentPreviewResponse( | |||
| headers = headers.sortedBy { it.bomCode?.uppercase() ?: "" }, | |||
| materials = materials.sortedWith( | |||
| compareBy( | |||
| { it.itemCode?.uppercase() ?: "" }, | |||
| { it.bomCode?.uppercase() ?: "" }, | |||
| ), | |||
| ), | |||
| skipped = skipped, | |||
| ) | |||
| } | |||
| @Transactional(readOnly = true) | |||
| open fun recalculateMaterialQty(request: BomMaterialQtyRecalculateRequest): BomMaterialQtyRecalculateResponse { | |||
| val itemId = request.itemId | |||
| val qty = request.qty | |||
| return when (request.anchor) { | |||
| BomMaterialQtyAnchor.SALE_QTY -> { | |||
| val stockQty = convertSaleToStockQty(itemId, qty, request.salesUomId, request.stockUomId) | |||
| val baseQty = convertQty(itemId, qty, request.salesUomId, "baseUnit") | |||
| BomMaterialQtyRecalculateResponse( | |||
| saleQty = qty, | |||
| stockQty = stockQty, | |||
| baseQty = baseQty, | |||
| saleUom = uomConversionService.findById(request.salesUomId)?.udfudesc, | |||
| stockUom = uomConversionService.findById(request.stockUomId)?.udfudesc, | |||
| baseUom = uomConversionService.findById(request.baseUomId)?.udfudesc, | |||
| ) | |||
| } | |||
| BomMaterialQtyAnchor.STOCK_QTY -> { | |||
| val saleQty = convertStockToSaleQty(itemId, qty, request.salesUomId, request.stockUomId) | |||
| val baseQty = convertQty(itemId, qty, request.stockUomId, "baseUnit") | |||
| BomMaterialQtyRecalculateResponse( | |||
| saleQty = saleQty, | |||
| stockQty = qty, | |||
| baseQty = baseQty, | |||
| saleUom = uomConversionService.findById(request.salesUomId)?.udfudesc, | |||
| stockUom = uomConversionService.findById(request.stockUomId)?.udfudesc, | |||
| baseUom = uomConversionService.findById(request.baseUomId)?.udfudesc, | |||
| ) | |||
| } | |||
| BomMaterialQtyAnchor.BASE_QTY -> { | |||
| val saleQty = convertQty(itemId, qty, request.baseUomId, "salesUnit") | |||
| val stockQty = convertSaleToStockQty(itemId, saleQty, request.salesUomId, request.stockUomId) | |||
| BomMaterialQtyRecalculateResponse( | |||
| saleQty = saleQty, | |||
| stockQty = stockQty, | |||
| baseQty = qty, | |||
| saleUom = uomConversionService.findById(request.salesUomId)?.udfudesc, | |||
| stockUom = uomConversionService.findById(request.stockUomId)?.udfudesc, | |||
| baseUom = uomConversionService.findById(request.baseUomId)?.udfudesc, | |||
| ) | |||
| } | |||
| BomMaterialQtyAnchor.RECIPE_QTY -> { | |||
| val recipeUomId = request.recipeUomId | |||
| ?: throw BadRequestException("recipeUomId is required for RECIPE_QTY anchor") | |||
| val recipeUom = uomConversionService.findById(recipeUomId) | |||
| ?: throw BadRequestException("Recipe UOM not found: id=$recipeUomId") | |||
| val derived = bomMaterialQtyService.deriveFromRecipe(request.itemId, qty, recipeUom) | |||
| BomMaterialQtyRecalculateResponse( | |||
| saleQty = derived.saleQty, | |||
| stockQty = derived.stockQty, | |||
| baseQty = derived.baseQty, | |||
| saleUom = derived.salesUnitCode, | |||
| stockUom = derived.stockUnitName, | |||
| baseUom = derived.baseUnitName, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @Transactional | |||
| open fun applyAlignment(request: BomUomAlignmentApplyRequest): BomUomAlignmentApplyResponse { | |||
| var headersUpdated = 0 | |||
| var materialsUpdated = 0 | |||
| for (header in request.headers) { | |||
| val bom = bomRepository.findById(header.bomId).orElse(null) ?: continue | |||
| if (header.outputQtyStock != null && header.stockUomId != null) { | |||
| bomOutputQtyService.applyStockQtyToBom(bom, header.outputQtyStock!!, header.stockUomId!!) | |||
| } else if (header.outputQty != null) { | |||
| bomOutputQtyService.applyBaseQtyToBom(bom, header.outputQty!!) | |||
| } else if (header.uomId != null) { | |||
| val uom = uomConversionService.findById(header.uomId) ?: continue | |||
| bom.uom = uom | |||
| bom.outputQtyUom = uom.udfudesc | |||
| } | |||
| bomRepository.saveAndFlush(bom) | |||
| headersUpdated++ | |||
| } | |||
| for (mat in request.materials) { | |||
| val material = bomMaterialRepository.findByIdAndDeletedIsFalse(mat.bomMaterialId) ?: continue | |||
| mat.qty?.let { material.qty = it } | |||
| mat.uomId?.let { id -> | |||
| material.uom = uomConversionService.findById(id) | |||
| material.uomName = material.uom?.udfudesc | |||
| } | |||
| bomMaterialRepository.saveAndFlush(material) | |||
| materialsUpdated++ | |||
| } | |||
| return BomUomAlignmentApplyResponse( | |||
| headersUpdated = headersUpdated, | |||
| materialsUpdated = materialsUpdated, | |||
| message = "Success", | |||
| ) | |||
| } | |||
| private fun buildHeaderPreview(bom: Bom): BomHeaderUomAlignmentPreview? { | |||
| val bomId = bom.id ?: return null | |||
| val itemId = bom.item?.id | |||
| if (itemId == null) { | |||
| return BomHeaderUomAlignmentPreview( | |||
| bomId = bomId, | |||
| bomCode = bom.code, | |||
| bomName = bom.name, | |||
| canApply = false, | |||
| skipReason = "BOM 未關聯貨品", | |||
| beforeOutputQty = bom.outputQty, | |||
| afterOutputQty = bom.outputQty, | |||
| beforeUom = toLabel(bom.uom), | |||
| afterUom = null, | |||
| ) | |||
| } | |||
| val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom | |||
| val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom | |||
| if (baseUom?.id == null) { | |||
| return BomHeaderUomAlignmentPreview( | |||
| bomId = bomId, | |||
| bomCode = bom.code, | |||
| bomName = bom.name, | |||
| itemId = itemId, | |||
| canApply = false, | |||
| skipReason = "成品未設定 base unit", | |||
| beforeOutputQty = bom.outputQty, | |||
| afterOutputQty = bom.outputQty, | |||
| beforeUom = toLabel(bom.uom), | |||
| afterUom = null, | |||
| beforeStockUom = toLabel(stockUom), | |||
| ) | |||
| } | |||
| val baseLabel = toLabel(baseUom) | |||
| val stockLabel = toLabel(stockUom) | |||
| val storedAsBase = bomOutputQtyService.isStoredAsBaseUnit(bom) | |||
| if (storedAsBase) { | |||
| val stockDisplay = bomOutputQtyService.displayAsStockUnit(bom) | |||
| val afterBaseQty = | |||
| resolveBaseOutputQtyFromStock(itemId, stockDisplay.qty, stockUom?.id) | |||
| ?: bom.outputQty | |||
| return BomHeaderUomAlignmentPreview( | |||
| bomId = bomId, | |||
| bomCode = bom.code, | |||
| bomName = bom.name, | |||
| itemId = itemId, | |||
| canApply = false, | |||
| beforeOutputQty = bom.outputQty, | |||
| afterOutputQty = afterBaseQty, | |||
| beforeOutputQtyStock = stockDisplay.qty, | |||
| afterOutputQtyStock = stockDisplay.qty, | |||
| beforeUom = baseLabel, | |||
| afterUom = baseLabel, | |||
| beforeBaseUom = baseLabel, | |||
| afterBaseUom = baseLabel, | |||
| beforeStockUom = stockLabel, | |||
| afterStockUom = stockLabel, | |||
| ) | |||
| } | |||
| val sourceQty = bom.outputQty | |||
| val sourceUomId = bom.uom?.id | |||
| val legacyUomLabel = toLabel(bom.uom) | |||
| val afterStockQty = resolveStockOutputFromSource(itemId, sourceQty, sourceUomId, stockUom?.id) | |||
| val afterBaseQty = resolveBaseOutputQty(bom, itemId, baseUom.id!!) | |||
| return BomHeaderUomAlignmentPreview( | |||
| bomId = bomId, | |||
| bomCode = bom.code, | |||
| bomName = bom.name, | |||
| itemId = itemId, | |||
| canApply = bom.uom?.id != baseUom.id, | |||
| beforeOutputQty = sourceQty, | |||
| afterOutputQty = afterBaseQty, | |||
| beforeOutputQtyStock = null, | |||
| afterOutputQtyStock = afterStockQty, | |||
| beforeUom = legacyUomLabel, | |||
| afterUom = baseLabel, | |||
| beforeBaseUom = legacyUomLabel, | |||
| afterBaseUom = baseLabel, | |||
| beforeStockUom = null, | |||
| afterStockUom = stockLabel, | |||
| ) | |||
| } | |||
| @Transactional(readOnly = true) | |||
| open fun recalculateHeaderOutputQty( | |||
| request: BomHeaderOutputQtyRecalculateRequest, | |||
| ): BomHeaderOutputQtyRecalculateResponse { | |||
| val baseUomRow = itemUomService.findBaseUnitByItemId(request.itemId)?.uom | |||
| val baseUomLabel = baseUomRow?.udfudesc | |||
| val baseUomId = baseUomRow?.id | |||
| val baseQty = convertQty( | |||
| request.itemId, | |||
| request.stockQty, | |||
| request.stockUomId, | |||
| "baseUnit", | |||
| ) | |||
| return BomHeaderOutputQtyRecalculateResponse( | |||
| stockQty = request.stockQty, | |||
| baseQty = baseQty, | |||
| baseUom = baseUomLabel, | |||
| baseUomId = baseUomId, | |||
| ) | |||
| } | |||
| /** Align base reference qty with stock-unit row (same path as apply + header recalculate). */ | |||
| private fun resolveBaseOutputQtyFromStock( | |||
| itemId: Long, | |||
| stockQty: BigDecimal?, | |||
| stockUomId: Long?, | |||
| ): BigDecimal? { | |||
| if (stockQty == null || stockUomId == null) return null | |||
| return try { | |||
| convertQty(itemId, stockQty, stockUomId, "baseUnit") | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| } | |||
| /** Legacy BOM source qty (Excel/packaging UOM) → item stock unit for alignment preview. */ | |||
| private fun resolveStockOutputFromSource( | |||
| itemId: Long, | |||
| sourceQty: BigDecimal?, | |||
| sourceUomId: Long?, | |||
| stockUomId: Long?, | |||
| ): BigDecimal? { | |||
| if (sourceQty == null || sourceUomId == null || stockUomId == null) return null | |||
| return try { | |||
| convertQty(itemId, sourceQty, sourceUomId, "stockUnit") | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| } | |||
| /** Convert BOM stored output qty to item base unit (fallback when stock conversion unavailable). */ | |||
| private fun resolveBaseOutputQty(bom: Bom, itemId: Long, baseUomId: Long): BigDecimal? { | |||
| val storedQty = bom.outputQty ?: return null | |||
| val sourceUomId = bom.uom?.id ?: return storedQty | |||
| if (sourceUomId == baseUomId) return storedQty | |||
| return try { | |||
| bomOutputQtyService.persistFromSourceUnit(itemId, storedQty, sourceUomId).qty | |||
| } catch (_: Exception) { | |||
| storedQty | |||
| } | |||
| } | |||
| private fun buildMaterialPreview( | |||
| material: BomMaterial, | |||
| itemUomCache: MutableMap<Long, ItemUomSet> = mutableMapOf(), | |||
| ): BomMaterialUomAlignmentPreview? { | |||
| val bomMaterialId = material.id ?: return null | |||
| val itemId = material.item?.id ?: return null | |||
| val bom = material.bom | |||
| val uoms = itemUomCache.getOrPut(itemId) { | |||
| ItemUomSet( | |||
| sales = itemUomService.findSalesUnitByItemId(itemId)?.uom, | |||
| stock = itemUomService.findStockUnitByItemId(itemId)?.uom, | |||
| base = itemUomService.findBaseUnitByItemId(itemId)?.uom, | |||
| ) | |||
| } | |||
| val salesUom = uoms.sales | |||
| val stockUom = uoms.stock | |||
| val baseUom = uoms.base | |||
| if (salesUom?.id == null || stockUom?.id == null || baseUom?.id == null) { | |||
| return BomMaterialUomAlignmentPreview( | |||
| bomId = bom?.id ?: 0L, | |||
| bomCode = bom?.code, | |||
| bomName = bom?.name, | |||
| bomMaterialId = bomMaterialId, | |||
| itemId = itemId, | |||
| itemCode = material.item?.code, | |||
| itemName = material.item?.name ?: material.itemName, | |||
| canApply = false, | |||
| skipReason = "貨品缺少 M18 銷售/庫存/基本單位", | |||
| beforeRecipeQty = beforeRecipe(material), | |||
| ) | |||
| } | |||
| val recipeQty = material.qty | |||
| val basePreview = BomMaterialUomAlignmentPreview( | |||
| bomId = bom?.id ?: 0L, | |||
| bomCode = bom?.code, | |||
| bomName = bom?.name, | |||
| bomMaterialId = bomMaterialId, | |||
| itemId = itemId, | |||
| itemCode = material.item?.code, | |||
| itemName = material.item?.name ?: material.itemName, | |||
| beforeSaleQty = null, | |||
| beforeStockQty = resolveBeforeStockQty( | |||
| itemId, recipeQty = material.qty, recipeUom = material.uom, baseUom, stockUom, | |||
| ), | |||
| beforeBaseQty = resolveBeforeBaseQty( | |||
| itemId, recipeQty = material.qty, recipeUom = material.uom, baseUom, | |||
| ), | |||
| beforeRecipeQty = beforeRecipe(material), | |||
| ) | |||
| if (recipeQty == null) { | |||
| return basePreview.copy( | |||
| canApply = false, | |||
| skipReason = "配方用量未設定", | |||
| warnings = collectNullBomQtyWarnings(material), | |||
| ) | |||
| } | |||
| val availableRecipeUoms = buildAlignmentRecipeUoms( | |||
| itemId, | |||
| material.uom?.id, | |||
| recipeQty, | |||
| baseUom, | |||
| ) | |||
| if (availableRecipeUoms.isEmpty()) { | |||
| return enrichWithDisplayAfterPreview( | |||
| basePreview.copy( | |||
| canApply = false, | |||
| skipReason = "無可用配方單位選項", | |||
| warnings = collectNullBomQtyWarnings(material), | |||
| availableRecipeUoms = availableRecipeUoms, | |||
| ), | |||
| itemId, | |||
| recipeQty, | |||
| salesUom, | |||
| stockUom, | |||
| baseUom, | |||
| ) | |||
| } | |||
| val suggestedRecipeUomId = resolveSuggestedRecipeUomId( | |||
| itemId = itemId, | |||
| recipeQty = recipeQty, | |||
| currentRecipeUom = material.uom, | |||
| baseUom = baseUom, | |||
| salesUom = salesUom, | |||
| availableRecipeUoms = availableRecipeUoms, | |||
| ) | |||
| if (suggestedRecipeUomId == null) { | |||
| return enrichWithDisplayAfterPreview( | |||
| basePreview.copy( | |||
| canApply = false, | |||
| skipReason = "配方單位無法換算至 M18 單位", | |||
| warnings = collectNullBomQtyWarnings(material), | |||
| availableRecipeUoms = availableRecipeUoms, | |||
| ), | |||
| itemId, | |||
| recipeQty, | |||
| salesUom, | |||
| stockUom, | |||
| baseUom, | |||
| ) | |||
| } | |||
| val suggestedUom = uomConversionService.findById(suggestedRecipeUomId) | |||
| ?: return enrichWithDisplayAfterPreview( | |||
| basePreview.copy( | |||
| canApply = false, | |||
| skipReason = "建議配方單位不存在", | |||
| availableRecipeUoms = availableRecipeUoms, | |||
| ), | |||
| itemId, | |||
| recipeQty, | |||
| salesUom, | |||
| stockUom, | |||
| baseUom, | |||
| ) | |||
| val suggestedRecipeQty = resolveSuggestedRecipeQty( | |||
| itemId = itemId, | |||
| originalRecipeQty = recipeQty, | |||
| currentRecipeUom = material.uom, | |||
| suggestedUom = suggestedUom, | |||
| baseUom = baseUom, | |||
| salesUom = salesUom, | |||
| ) | |||
| val derived = try { | |||
| bomMaterialQtyService.deriveFromRecipe(itemId, suggestedRecipeQty, suggestedUom) | |||
| } catch (e: Exception) { | |||
| return enrichWithDisplayAfterPreview( | |||
| basePreview.copy( | |||
| canApply = false, | |||
| skipReason = e.message ?: "換算失敗", | |||
| warnings = collectNullBomQtyWarnings(material), | |||
| availableRecipeUoms = availableRecipeUoms, | |||
| suggestedRecipeUomId = suggestedRecipeUomId, | |||
| ), | |||
| itemId, | |||
| recipeQty, | |||
| salesUom, | |||
| stockUom, | |||
| baseUom, | |||
| ) | |||
| } | |||
| return basePreview.copy( | |||
| canApply = true, | |||
| warnings = collectNullBomQtyWarnings(material), | |||
| availableRecipeUoms = availableRecipeUoms, | |||
| suggestedRecipeUomId = suggestedRecipeUomId, | |||
| afterSaleQty = BomUomQtyValue(derived.saleQty, toLabel(salesUom)), | |||
| afterStockQty = BomUomQtyValue(derived.stockQty, toLabel(stockUom)), | |||
| afterBaseQty = BomUomQtyValue( | |||
| if (suggestedUom.id == baseUom.id) suggestedRecipeQty else derived.baseQty, | |||
| toLabel(baseUom), | |||
| ), | |||
| afterRecipeQty = BomUomQtyValue(suggestedRecipeQty, toLabel(suggestedUom)), | |||
| ) | |||
| } | |||
| private fun resolveBeforeBaseQty( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal?, | |||
| recipeUom: UomConversion?, | |||
| baseUom: UomConversion?, | |||
| ): BomUomQtyValue? { | |||
| if (recipeQty == null || recipeUom == null || baseUom == null) return null | |||
| if (!bomMaterialQtyService.canConvertRecipeToBase(itemId, recipeUom, baseUom)) return null | |||
| return try { | |||
| val derived = bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom) | |||
| BomUomQtyValue(derived.baseQty, toLabel(baseUom)) | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| } | |||
| private fun resolveBeforeStockQty( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal?, | |||
| recipeUom: UomConversion?, | |||
| baseUom: UomConversion?, | |||
| stockUom: UomConversion?, | |||
| ): BomUomQtyValue? { | |||
| if (recipeQty == null || recipeUom == null || baseUom == null || stockUom == null) return null | |||
| if (!bomMaterialQtyService.canConvertRecipeToBase(itemId, recipeUom, baseUom)) return null | |||
| return try { | |||
| val derived = bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom) | |||
| BomUomQtyValue(derived.stockQty, toLabel(stockUom)) | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| } | |||
| /** When aligning to M18 base, convert legacy qty (e.g. 23 PCS) to base grams via sales UOM. */ | |||
| private fun resolveSuggestedRecipeQty( | |||
| itemId: Long, | |||
| originalRecipeQty: BigDecimal, | |||
| currentRecipeUom: UomConversion?, | |||
| suggestedUom: UomConversion, | |||
| baseUom: UomConversion, | |||
| salesUom: UomConversion?, | |||
| ): BigDecimal { | |||
| if (suggestedUom.id != baseUom.id) return originalRecipeQty | |||
| if (currentRecipeUom?.id == baseUom.id) return originalRecipeQty | |||
| currentRecipeUom?.let { current -> | |||
| if (canDeriveFromRecipe(itemId, originalRecipeQty, current)) { | |||
| return bomMaterialQtyService.deriveFromRecipe(itemId, originalRecipeQty, current).baseQty | |||
| ?: originalRecipeQty | |||
| } | |||
| } | |||
| salesUom?.let { sales -> | |||
| if (canDeriveFromRecipe(itemId, originalRecipeQty, sales)) { | |||
| return bomMaterialQtyService.deriveFromRecipe(itemId, originalRecipeQty, sales).baseQty | |||
| ?: originalRecipeQty | |||
| } | |||
| } | |||
| return originalRecipeQty | |||
| } | |||
| private fun resolveSuggestedRecipeUomId( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| currentRecipeUom: UomConversion?, | |||
| baseUom: UomConversion, | |||
| salesUom: UomConversion?, | |||
| availableRecipeUoms: List<BomMaterialUomOption>, | |||
| ): Long? { | |||
| // Item base not in standard matrix → recipe must follow M18 base unit (e.g. PCS not G). | |||
| if (StandardUomMatrix.toMatrixUnit(baseUom) == null) { | |||
| baseUom.id?.let { id -> | |||
| if (availableRecipeUoms.any { it.uomId == id }) return id | |||
| } | |||
| } | |||
| // M18 fallback: legacy non-matrix recipe → base unit is the only recipe option. | |||
| if (availableRecipeUoms.size == 1) { | |||
| return availableRecipeUoms.first().uomId | |||
| } | |||
| currentRecipeUom?.let { current -> | |||
| if (canDeriveFromRecipe(itemId, recipeQty, current)) return current.id | |||
| } | |||
| baseUom.id?.let { id -> | |||
| if (availableRecipeUoms.any { it.uomId == id }) return id | |||
| } | |||
| salesUom?.id?.let { salesId -> | |||
| if (availableRecipeUoms.any { it.uomId == salesId }) { | |||
| val uom = uomConversionService.findById(salesId) | |||
| if (uom != null && canDeriveFromRecipe(itemId, recipeQty, uom)) return salesId | |||
| } | |||
| } | |||
| for (opt in availableRecipeUoms) { | |||
| val uom = uomConversionService.findById(opt.uomId) ?: continue | |||
| if (canDeriveFromRecipe(itemId, recipeQty, uom)) return opt.uomId | |||
| } | |||
| return null | |||
| } | |||
| private fun canDeriveFromRecipe(itemId: Long, recipeQty: BigDecimal, recipeUom: UomConversion): Boolean = | |||
| try { | |||
| bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom) | |||
| true | |||
| } catch (_: Exception) { | |||
| false | |||
| } | |||
| private fun collectNullBomQtyWarnings(material: BomMaterial): List<String> { | |||
| val warnings = mutableListOf<String>() | |||
| if (material.qty == null) warnings.add("BOM_RECIPE_QTY_NOT_SET") | |||
| if (material.uom?.id == null) warnings.add("BOM_RECIPE_UOM_NOT_SET") | |||
| return warnings | |||
| } | |||
| private fun convertSaleToStockQty( | |||
| itemId: Long, | |||
| saleQty: BigDecimal, | |||
| salesUomId: Long, | |||
| stockUomId: Long, | |||
| ): BigDecimal = | |||
| if (salesUomId == stockUomId) saleQty | |||
| else convertQty(itemId, saleQty, salesUomId, "stockUnit") | |||
| private fun convertStockToSaleQty( | |||
| itemId: Long, | |||
| stockQty: BigDecimal, | |||
| salesUomId: Long, | |||
| stockUomId: Long, | |||
| ): BigDecimal = | |||
| if (salesUomId == stockUomId) stockQty | |||
| else convertQty(itemId, stockQty, stockUomId, "salesUnit") | |||
| private fun convertQty(itemId: Long, qty: BigDecimal, uomId: Long, targetUnit: String): BigDecimal = | |||
| itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = qty, | |||
| uomId = uomId, | |||
| targetUnit = targetUnit, | |||
| ), | |||
| ).newQty | |||
| private fun beforeRecipe(material: BomMaterial): BomUomQtyValue = | |||
| BomUomQtyValue(material.qty, toLabel(material.uom)) | |||
| /** | |||
| * Recipe UOM options for alignment. | |||
| * Item base not in matrix → only M18 base is selectable. | |||
| * Legacy non-matrix recipe that cannot convert → only M18 base. | |||
| */ | |||
| private fun buildAlignmentRecipeUoms( | |||
| itemId: Long, | |||
| currentRecipeUomId: Long?, | |||
| recipeQty: BigDecimal, | |||
| baseUom: UomConversion?, | |||
| ): List<BomMaterialUomOption> { | |||
| if (baseUom?.id != null && StandardUomMatrix.toMatrixUnit(baseUom) == null) { | |||
| val label = toLabel(baseUom)?.label ?: baseUom.code ?: "-" | |||
| return listOf( | |||
| BomMaterialUomOption( | |||
| uomId = baseUom.id!!, | |||
| label = label, | |||
| code = baseUom.code, | |||
| ), | |||
| ) | |||
| } | |||
| val currentUom = currentRecipeUomId?.let { uomConversionService.findById(it) } | |||
| if ( | |||
| currentUom != null && | |||
| StandardUomMatrix.toMatrixUnit(currentUom) == null && | |||
| baseUom?.id != null && | |||
| !canDeriveFromRecipe(itemId, recipeQty, currentUom) | |||
| ) { | |||
| val label = toLabel(baseUom)?.label ?: baseUom.code ?: "-" | |||
| return listOf( | |||
| BomMaterialUomOption( | |||
| uomId = baseUom.id!!, | |||
| label = label, | |||
| code = baseUom.code, | |||
| ), | |||
| ) | |||
| } | |||
| return bomRecipeUomOptionsService.buildAvailableRecipeUoms(itemId, currentRecipeUomId) | |||
| } | |||
| /** Populate read-only after columns for blocked rows (M18 suggestion preview). */ | |||
| private fun enrichWithDisplayAfterPreview( | |||
| preview: BomMaterialUomAlignmentPreview, | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| salesUom: UomConversion, | |||
| stockUom: UomConversion, | |||
| baseUom: UomConversion, | |||
| ): BomMaterialUomAlignmentPreview { | |||
| if (preview.afterRecipeQty != null) return preview | |||
| val candidates = linkedSetOf<UomConversion>() | |||
| preview.suggestedRecipeUomId?.let { uomConversionService.findById(it) }?.let { candidates.add(it) } | |||
| salesUom.let { candidates.add(it) } | |||
| baseUom.let { candidates.add(it) } | |||
| stockUom.let { candidates.add(it) } | |||
| for (uom in candidates) { | |||
| try { | |||
| val derived = bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, uom) | |||
| return preview.copy( | |||
| afterRecipeQty = BomUomQtyValue(recipeQty, toLabel(uom)), | |||
| afterBaseQty = BomUomQtyValue( | |||
| if (uom.id == baseUom.id) recipeQty else derived.baseQty, | |||
| toLabel(baseUom), | |||
| ), | |||
| afterStockQty = BomUomQtyValue(derived.stockQty, toLabel(stockUom)), | |||
| afterSaleQty = BomUomQtyValue(derived.saleQty, toLabel(salesUom)), | |||
| ) | |||
| } catch (_: Exception) { | |||
| continue | |||
| } | |||
| } | |||
| return preview | |||
| } | |||
| private fun toLabel(uom: UomConversion?): BomUomLabel? { | |||
| if (uom == null) return null | |||
| val code = uom.code?.trim().orEmpty() | |||
| val desc = uom.udfudesc?.trim().orEmpty() | |||
| val label = when { | |||
| code.isNotEmpty() && desc.isNotEmpty() -> "$code / $desc" | |||
| desc.isNotEmpty() -> desc | |||
| code.isNotEmpty() -> code | |||
| else -> "-" | |||
| } | |||
| return BomUomLabel(uomId = uom.id, code = code.ifEmpty { null }, udfudesc = desc.ifEmpty { null }, label = label) | |||
| } | |||
| } | |||
| @@ -0,0 +1,53 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.fpsms.modules.master.entity.ItemUom | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| /** | |||
| * In-memory item_uom / uom_conversion lookups for bulk master-data scans. | |||
| * Avoids per-row repository calls when [uomsByItemId] is preloaded. | |||
| */ | |||
| data class ItemUomCacheContext( | |||
| val uomsByItemId: Map<Long, List<ItemUom>>, | |||
| val uomById: Map<Long, UomConversion>, | |||
| ) { | |||
| fun baseUnit(itemId: Long): ItemUom? = | |||
| uomsByItemId[itemId]?.firstOrNull { it.baseUnit == true } | |||
| fun stockUnit(itemId: Long): ItemUom? = | |||
| uomsByItemId[itemId]?.firstOrNull { it.stockUnit == true } | |||
| fun salesUnit(itemId: Long): ItemUom? = | |||
| uomsByItemId[itemId]?.firstOrNull { it.salesUnit == true } | |||
| fun purchaseUnit(itemId: Long): ItemUom? = | |||
| uomsByItemId[itemId]?.firstOrNull { it.purchaseUnit == true } | |||
| fun pickingUnit(itemId: Long): ItemUom? = | |||
| uomsByItemId[itemId]?.firstOrNull { it.pickingUnit == true } | |||
| fun byItemAndUomId(itemId: Long, uomId: Long): ItemUom? = | |||
| uomsByItemId[itemId]?.firstOrNull { it.uom?.id == uomId } | |||
| fun targetUnit(itemId: Long, targetUnit: String): ItemUom? = | |||
| when (targetUnit.lowercase()) { | |||
| "baseunit", "base_unit" -> baseUnit(itemId) | |||
| "stockunit", "stock_unit" -> stockUnit(itemId) | |||
| "purchaseunit", "purchase_unit" -> purchaseUnit(itemId) | |||
| "salesunit", "sales_unit" -> salesUnit(itemId) | |||
| "pickingunit", "picking_unit" -> pickingUnit(itemId) | |||
| else -> null | |||
| } | |||
| fun uom(id: Long): UomConversion? = uomById[id] | |||
| fun itemCode(itemId: Long): String? = | |||
| uomsByItemId[itemId]?.firstOrNull()?.item?.code | |||
| companion object { | |||
| fun from( | |||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||
| uomById: Map<Long, UomConversion>, | |||
| ): ItemUomCacheContext = ItemUomCacheContext(uomsByItemId, uomById) | |||
| } | |||
| } | |||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.fpsms.modules.master.entity.ItemUom | |||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| import com.ffii.fpsms.modules.master.web.models.* | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| @@ -346,20 +347,48 @@ open class ItemUomService( | |||
| } | |||
| open fun convertUomByItem(request: ConvertUomByItemRequest): ConvertUomByItemResponse { | |||
| open fun convertUomByItem(request: ConvertUomByItemRequest): ConvertUomByItemResponse = | |||
| convertUomByItemInternal(request, cache = null) | |||
| open fun convertUomByItem( | |||
| request: ConvertUomByItemRequest, | |||
| cache: ItemUomCacheContext, | |||
| ): ConvertUomByItemResponse = convertUomByItemInternal(request, cache) | |||
| private fun convertUomByItemInternal( | |||
| request: ConvertUomByItemRequest, | |||
| cache: ItemUomCacheContext?, | |||
| ): ConvertUomByItemResponse { | |||
| fun baseUnit(itemId: Long): ItemUom? = | |||
| cache?.baseUnit(itemId) ?: findBaseUnitByItemId(itemId) | |||
| fun targetItemUom(itemId: Long, targetUnit: String): ItemUom? = | |||
| cache?.targetUnit(itemId, targetUnit) ?: findTargetItemUom(itemId, targetUnit) | |||
| fun sourceItemUom(itemId: Long, uomId: Long): ItemUom? = | |||
| cache?.byItemAndUomId(itemId, uomId) | |||
| ?: itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(itemId, uomId) | |||
| fun uomById(id: Long): UomConversion? = | |||
| cache?.uom(id) ?: uomConversionService.findById(id) | |||
| fun itemCode(itemId: Long): String = | |||
| cache?.itemCode(itemId) ?: itemsRepository.findById(itemId).getOrNull()?.code | |||
| ?: itemId.toString() | |||
| // Special case: Direct conversion from KG (784) to gram (4) or vice versa | |||
| // This handles cases where ItemUom records might not exist | |||
| if (request.uomId == 784L && request.targetUnit.lowercase() == "baseunit") { | |||
| // Check if base unit is gram (id=4) | |||
| val baseUnitItemUom = findBaseUnitByItemId(request.itemId) | |||
| val baseUnitItemUom = baseUnit(request.itemId) | |||
| val baseUnitUomId = baseUnitItemUom?.uom?.id | |||
| if (baseUnitUomId == 4L) { | |||
| // Direct conversion: 1 KG = 1000 grams | |||
| val convertedQty = request.qty.multiply(BigDecimal(1000)) | |||
| val gramUom = uomConversionService.findById(4L) | |||
| val gramUom = uomById(4L) | |||
| ?: throw IllegalArgumentException("UomConversion not found for id=4 (gram)") | |||
| return ConvertUomByItemResponse( | |||
| newQty = convertedQty, | |||
| udfudesc = gramUom.udfudesc, | |||
| @@ -367,18 +396,18 @@ open class ItemUomService( | |||
| ) | |||
| } | |||
| } | |||
| if (request.uomId == 784L) { | |||
| val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit) | |||
| val targetUomId = targetItemUom?.uom?.id | |||
| val targetItemUomRow = targetItemUom(request.itemId, request.targetUnit) | |||
| val targetUomId = targetItemUomRow?.uom?.id | |||
| if (targetUomId == 18L) { | |||
| // Direct conversion: 1 KG = 1000 milliliters (assuming density = 1 g/ml for water-based products) | |||
| // Note: This is a general conversion, actual conversion may vary by product density | |||
| val convertedQty = request.qty.multiply(BigDecimal(1000)) | |||
| val milliliterUom = uomConversionService.findById(19L) | |||
| val milliliterUom = uomById(19L) | |||
| ?: throw IllegalArgumentException("UomConversion not found for id=19 (milliliter)") | |||
| return ConvertUomByItemResponse( | |||
| newQty = convertedQty, | |||
| udfudesc = milliliterUom.udfudesc, | |||
| @@ -386,33 +415,33 @@ open class ItemUomService( | |||
| ) | |||
| } | |||
| } | |||
| // Find source ItemUom by itemId and uomId | |||
| var sourceItemUom = itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(request.itemId, request.uomId) | |||
| var resolvedSourceItemUom = sourceItemUom(request.itemId, request.uomId) | |||
| // Special handling: If source is KG (784) but ItemUom doesn't exist, try indirect conversion via baseUnit | |||
| if (sourceItemUom == null && request.uomId == 784L) { | |||
| val baseUnitItemUom = findBaseUnitByItemId(request.itemId) | |||
| if (resolvedSourceItemUom == null && request.uomId == 784L) { | |||
| val baseUnitItemUom = baseUnit(request.itemId) | |||
| val baseUnitUomId = baseUnitItemUom?.uom?.id | |||
| // If base unit is gram (id=4), convert KG -> G first, then G -> target | |||
| if (baseUnitUomId == 4L) { | |||
| // Step 1: Convert KG to G (baseUnit) | |||
| val baseQty = request.qty.multiply(BigDecimal(1000)) | |||
| // Step 2: Convert from baseUnit to targetUnit | |||
| val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit) | |||
| val targetItemUomRow = targetItemUom(request.itemId, request.targetUnit) | |||
| ?: throw IllegalArgumentException("Target ItemUom not found for itemId=${request.itemId}, targetUnit=${request.targetUnit}") | |||
| val targetRatioN = targetItemUom.ratioN ?: BigDecimal.ONE | |||
| val targetRatioD = targetItemUom.ratioD ?: BigDecimal.ONE | |||
| val targetRatioN = targetItemUomRow.ratioN ?: BigDecimal.ONE | |||
| val targetRatioD = targetItemUomRow.ratioD ?: BigDecimal.ONE | |||
| // Convert base (G) to target: baseQty * ratioD / ratioN | |||
| val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP) | |||
| val uomConversion = targetItemUom.uom | |||
| val uomConversion = targetItemUomRow.uom | |||
| ?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom") | |||
| return ConvertUomByItemResponse( | |||
| newQty = newQty, | |||
| udfudesc = uomConversion.udfudesc, | |||
| @@ -420,43 +449,37 @@ open class ItemUomService( | |||
| ) | |||
| } | |||
| } | |||
| // var sourceItemUom = itemUomRespository.findFirstByItemIdAndUomIdAndDeletedIsFalse(request.itemId, request.uomId) | |||
| // 這裡先查 items、uom_conversion,準備錯誤訊息要用的 code | |||
| val item = itemsRepository.findById(request.itemId).getOrNull() | |||
| val itemCode = item?.code ?: request.itemId.toString() | |||
| val uom = uomConversionService.findById(request.uomId) | |||
| val uom = uomById(request.uomId) | |||
| val uomCode = uom?.code ?: request.uomId.toString() | |||
| // If still no source ItemUom found, throw error | |||
| sourceItemUom ?: throw IllegalArgumentException( | |||
| "Source ItemUom not found for items.code=$itemCode, uom_conversion.code=$uomCode" | |||
| resolvedSourceItemUom ?: throw IllegalArgumentException( | |||
| "Source ItemUom not found for items.code=${itemCode(request.itemId)}, uom_conversion.code=$uomCode" | |||
| ) | |||
| // Find target ItemUom by itemId and targetUnit(這段可視需求決定要不要也查 code) | |||
| val targetItemUom = findTargetItemUom(request.itemId, request.targetUnit) | |||
| // Find target ItemUom by itemId and targetUnit | |||
| val targetItemUomRow = targetItemUom(request.itemId, request.targetUnit) | |||
| ?: throw IllegalArgumentException( | |||
| "Target ItemUom not found for items.code=$itemCode, targetUnit=${request.targetUnit}" | |||
| "Target ItemUom not found for items.code=${itemCode(request.itemId)}, targetUnit=${request.targetUnit}" | |||
| ) | |||
| // Convert quantity using ratioN/ratioD via base unit | |||
| val one = BigDecimal.ONE | |||
| val sourceRatioN = sourceItemUom.ratioN ?: one | |||
| val sourceRatioD = sourceItemUom.ratioD ?: one | |||
| val targetRatioN = targetItemUom.ratioN ?: one | |||
| val targetRatioD = targetItemUom.ratioD ?: one | |||
| val sourceRatioN = resolvedSourceItemUom.ratioN ?: one | |||
| val sourceRatioD = resolvedSourceItemUom.ratioD ?: one | |||
| val targetRatioN = targetItemUomRow.ratioN ?: one | |||
| val targetRatioD = targetItemUomRow.ratioD ?: one | |||
| // Convert source qty to base: qty * ratioN / ratioD | |||
| val baseQty = request.qty.multiply(sourceRatioN).divide(sourceRatioD, 2, RoundingMode.UP) | |||
| // Convert base to target: baseQty * ratioD / ratioN | |||
| val newQty = baseQty.multiply(targetRatioD).divide(targetRatioN, 2, RoundingMode.UP) | |||
| // Get UomConversion from target ItemUom | |||
| val uomConversion = targetItemUom.uom | |||
| val uomConversion = targetItemUomRow.uom | |||
| ?: throw IllegalArgumentException("Target UomConversion not found for target ItemUom") | |||
| return ConvertUomByItemResponse( | |||
| newQty = newQty, | |||
| udfudesc = uomConversion.udfudesc, | |||
| @@ -266,6 +266,59 @@ open class ItemsService( | |||
| return jdbcDao.queryForList(sql.toString(), args); | |||
| } | |||
| open fun listBagItemsCombo(): List<Map<String, String>> { | |||
| return itemsRepository.findAllByIsBagTrueAndDeletedFalse() | |||
| .mapNotNull { item -> | |||
| val code = item.code?.trim().orEmpty() | |||
| if (code.isEmpty()) return@mapNotNull null | |||
| mapOf( | |||
| "code" to code, | |||
| "name" to item.name?.trim().orEmpty(), | |||
| ) | |||
| } | |||
| .sortedBy { it["code"] } | |||
| } | |||
| open fun itemExistsByCode(code: String): Map<String, Any?> { | |||
| val trimmed = code.trim() | |||
| if (trimmed.isEmpty()) { | |||
| return mapOf("exists" to false, "code" to trimmed) | |||
| } | |||
| val item = itemsRepository.findByCodeAndDeletedFalse(trimmed) | |||
| return if (item != null) { | |||
| mapOf( | |||
| "exists" to true, | |||
| "code" to item.code, | |||
| "name" to item.name, | |||
| ) | |||
| } else { | |||
| mapOf("exists" to false, "code" to trimmed) | |||
| } | |||
| } | |||
| open fun itemsExistsByCodes(codes: List<String>): List<Map<String, Any?>> { | |||
| val trimmedByInputIndex = codes.map { it.trim() } | |||
| // 預覽校驗只需要去重後查一次,再回填每個 code 的 exists/name。 | |||
| val uniqueCodes = trimmedByInputIndex.filter { it.isNotEmpty() }.distinct() | |||
| if (uniqueCodes.isEmpty()) return emptyList() | |||
| val items = itemsRepository.findAllByCodeInAndDeletedFalse(uniqueCodes) | |||
| val itemByCode = items.associateBy { it.code } | |||
| return uniqueCodes.map { code -> | |||
| val item = itemByCode[code] | |||
| if (item != null) { | |||
| mapOf( | |||
| "exists" to true, | |||
| "code" to item.code, | |||
| "name" to item.name, | |||
| ) | |||
| } else { | |||
| mapOf("exists" to false, "code" to code) | |||
| } | |||
| } | |||
| } | |||
| open fun getPickOrderItemsByPage(args: Map<String, Any>): List<Map<String, Any>> { | |||
| try { | |||
| println("=== Debug: getPickOrderItemsByPage in ItemsService ===") | |||
| @@ -9,9 +9,12 @@ import com.ffii.fpsms.modules.master.entity.Items | |||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | |||
| import com.ffii.fpsms.modules.master.entity.ItemsRepository | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||
| import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | |||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse | |||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryResponse | |||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryTiming | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDateTime | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| @@ -23,7 +26,14 @@ open class MasterDataIssueService( | |||
| private val itemsRepository: ItemsRepository, | |||
| private val itemUomRespository: ItemUomRespository, | |||
| private val uomConversionService: UomConversionService, | |||
| private val itemUomService: ItemUomService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| ) { | |||
| private data class BomMaterialIssueHit( | |||
| val issueCode: String, | |||
| val expectedValue: String? = null, | |||
| val actualValue: String? = null, | |||
| ) | |||
| data class UnitHealth( | |||
| val value: String?, | |||
| val correct: Boolean, | |||
| @@ -52,15 +62,16 @@ open class MasterDataIssueService( | |||
| open fun findBomMasterDataIssues(): List<MasterDataIssueResponse> { | |||
| val issues = mutableListOf<MasterDataIssueResponse>() | |||
| val uomsByItemId = loadActiveUomsByItemId() | |||
| val materials = bomMaterialRepository.findAllActiveForActiveBoms() | |||
| val materials = bomMaterialRepository.findAllByBomStatusAndDeletedFalse(BomStatus.ACTIVE) | |||
| val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } | |||
| val uomCache = buildUomCache(uomsByItemId, emptyMap(), materials) | |||
| val boms = bomRepository.findAllActiveWithItemAndUom() | |||
| val uomLookup = ItemUomCacheContext.from(uomsByItemId, uomCache) | |||
| val boms = bomRepository.findAllByDeletedFalseAndStatusWithItemAndUom(BomStatus.ACTIVE) | |||
| for (bom in boms) { | |||
| collectBomHeaderIssues(bom, issues, uomsByItemId) | |||
| val bomId = bom.id ?: continue | |||
| for (material in materialsByBomId[bomId].orEmpty()) { | |||
| collectBomMaterialIssues(bom, material, issues, uomsByItemId, uomCache) | |||
| collectBomMaterialIssues(bom, material, issues, uomsByItemId, uomLookup) | |||
| } | |||
| } | |||
| return sortIssues(issues) | |||
| @@ -105,11 +116,11 @@ open class MasterDataIssueService( | |||
| } | |||
| val materials = | |||
| if (includeTiming) { | |||
| timed { bomMaterialRepository.findAllActiveForActiveBoms() } | |||
| timed { bomMaterialRepository.findAllByBomStatusAndDeletedFalse(BomStatus.ACTIVE) } | |||
| .also { loadMaterialsMs = it.second } | |||
| .first | |||
| } else { | |||
| bomMaterialRepository.findAllActiveForActiveBoms() | |||
| bomMaterialRepository.findAllByBomStatusAndDeletedFalse(BomStatus.ACTIVE) | |||
| } | |||
| val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } | |||
| val uomCache = | |||
| @@ -120,19 +131,22 @@ open class MasterDataIssueService( | |||
| } else { | |||
| buildUomCache(uomsByItemId, deletedUomsByItemId, materials) | |||
| } | |||
| val uomLookup = ItemUomCacheContext.from(uomsByItemId, uomCache) | |||
| val boms = | |||
| if (includeTiming) { | |||
| timed { bomRepository.findAllActiveWithItemAndUom() }.also { loadBomsMs = it.second }.first | |||
| timed { bomRepository.findAllByDeletedFalseAndStatusWithItemAndUom(BomStatus.ACTIVE) } | |||
| .also { loadBomsMs = it.second } | |||
| .first | |||
| } else { | |||
| bomRepository.findAllActiveWithItemAndUom() | |||
| bomRepository.findAllByDeletedFalseAndStatusWithItemAndUom(BomStatus.ACTIVE) | |||
| } | |||
| val bomGroupCount = | |||
| if (includeTiming) { | |||
| timed { | |||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache) | |||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomLookup) | |||
| }.also { scanBomTabMs = it.second }.first | |||
| } else { | |||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache) | |||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomLookup) | |||
| } | |||
| val items = | |||
| if (includeTiming) { | |||
| @@ -188,7 +202,7 @@ open class MasterDataIssueService( | |||
| boms: List<Bom>, | |||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||
| materialsByBomId: Map<Long, List<BomMaterial>>, | |||
| uomCache: Map<Long, UomConversion>, | |||
| uomLookup: ItemUomCacheContext, | |||
| ): Int { | |||
| val headerKeys = mutableSetOf<String>() | |||
| val materialKeys = mutableSetOf<String>() | |||
| @@ -196,7 +210,7 @@ open class MasterDataIssueService( | |||
| scanBomHeaderGroupKeys(bom, headerKeys, uomsByItemId) | |||
| val bomId = bom.id ?: continue | |||
| for (material in materialsByBomId[bomId].orEmpty()) { | |||
| scanBomMaterialGroupKeys(bom, material, materialKeys, uomsByItemId, uomCache) | |||
| scanBomMaterialGroupKeys(bom, material, materialKeys, uomsByItemId, uomLookup) | |||
| } | |||
| } | |||
| return headerKeys.size + materialKeys.size | |||
| @@ -233,7 +247,6 @@ open class MasterDataIssueService( | |||
| var hasIssue = false | |||
| if (bom.code.isNullOrBlank()) hasIssue = true | |||
| if (bom.name.isNullOrBlank()) hasIssue = true | |||
| val item = bom.item | |||
| val itemId = item?.id | |||
| @@ -241,10 +254,19 @@ open class MasterDataIssueService( | |||
| if (item == null || item.deleted == true) { | |||
| hasIssue = true | |||
| } else { | |||
| val bomCodeNorm = bom.code?.trim().orEmpty() | |||
| val itemCodeNorm = item.code?.trim().orEmpty() | |||
| if ( | |||
| bomCodeNorm.isNotEmpty() && | |||
| itemCodeNorm.isNotEmpty() && | |||
| !bomCodeNorm.equals(itemCodeNorm, ignoreCase = true) | |||
| ) { | |||
| hasIssue = true | |||
| } | |||
| if (itemHasAnyUnitIssue(item, uomsByItemId, unitSnapshot)) hasIssue = true | |||
| val bomUom = bom.uom | |||
| val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom | |||
| if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) hasIssue = true | |||
| val baseConv = findBaseUnitRow(item.id!!, uomsByItemId)?.uom | |||
| if (bomUom != null && baseConv != null && bomUom.id != baseConv.id) hasIssue = true | |||
| if (bomUom != null) { | |||
| val uomDesc = bomUom.udfudesc?.trim().orEmpty() | |||
| val outputDesc = bom.outputQtyUom?.trim().orEmpty() | |||
| @@ -261,10 +283,9 @@ open class MasterDataIssueService( | |||
| material: BomMaterial, | |||
| materialKeys: MutableSet<String>, | |||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||
| uomCache: Map<Long, UomConversion>, | |||
| uomLookup: ItemUomCacheContext, | |||
| ) { | |||
| val matItem = material.item | |||
| val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId) | |||
| val itemId = matItem?.id | |||
| val itemCode = matItem?.code | |||
| val bomMaterialId = material.id | |||
| @@ -280,60 +301,157 @@ open class MasterDataIssueService( | |||
| materialKeys.add("$itemKey|$patternKey") | |||
| } | |||
| if (matItem == null || matItem.deleted == true) { | |||
| addMaterialKey("BOM_MATERIAL_MISSING_ITEM") | |||
| return | |||
| for (hit in findBomMaterialRecipeIssues(material, uomsByItemId, uomLookup)) { | |||
| addMaterialKey(hit.issueCode, hit.expectedValue, hit.actualValue) | |||
| } | |||
| } | |||
| private fun materialPatternKey(issueCode: String, expected: String?, actual: String?): String { | |||
| val exp = expected ?: "" | |||
| val act = actual ?: "" | |||
| return when (issueCode) { | |||
| "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE", | |||
| "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX", | |||
| -> "recipe:$exp|$act" | |||
| else -> "$issueCode|$exp|$act" | |||
| } | |||
| } | |||
| val matUom = material.uom | |||
| if (matUom != null && matUom.deleted == true) { | |||
| addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "uomId=${matUom.id}") | |||
| /** | |||
| * Recipe-qty model: validate material.uom + qty can derive sale/stock/base (see [BomMaterialQtyService]). | |||
| * Item-tab unit gaps are not duplicated here when item base is missing. | |||
| */ | |||
| private fun findBomMaterialRecipeIssues( | |||
| material: BomMaterial, | |||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||
| uomLookup: ItemUomCacheContext, | |||
| ): List<BomMaterialIssueHit> { | |||
| val hits = mutableListOf<BomMaterialIssueHit>() | |||
| val matItem = material.item | |||
| if (matItem == null || matItem.deleted == true) { | |||
| hits += BomMaterialIssueHit("BOM_MATERIAL_MISSING_ITEM") | |||
| return hits | |||
| } | |||
| val salesUnitFk = material.salesUnit | |||
| if (salesUnitFk != null && salesUnitFk.deleted == true) { | |||
| addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "salesUnitId=${salesUnitFk.id}") | |||
| val itemId = matItem.id ?: return hits | |||
| if (material.qty == null) { | |||
| hits += BomMaterialIssueHit("BOM_MATERIAL_MISSING_QTY") | |||
| } | |||
| val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom | |||
| if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) { | |||
| addMaterialKey( | |||
| "BOM_MATERIAL_SALES_UOM_MISMATCH", | |||
| formatUom(itemSales), | |||
| formatUom(salesUnitFk), | |||
| val recipeUom = material.uom | |||
| if (recipeUom == null) { | |||
| hits += BomMaterialIssueHit("BOM_MATERIAL_MISSING_RECIPE_UOM") | |||
| return hits | |||
| } | |||
| if (recipeUom.deleted == true) { | |||
| hits += BomMaterialIssueHit( | |||
| "BOM_MATERIAL_UOM_FK_INVALID", | |||
| actualValue = "uomId=${recipeUom.id}", | |||
| ) | |||
| return hits | |||
| } | |||
| val itemBase = findBaseUnitRow(itemId, uomsByItemId)?.uom | |||
| if (itemBase?.id == null) { | |||
| return hits | |||
| } | |||
| evaluateRecipeToBaseIssue(itemId, recipeUom, itemBase, uomLookup)?.let { hits += it } | |||
| val qty = material.qty | |||
| val recipeMatchesBase = recipeUom.id == itemBase.id | |||
| if ( | |||
| qty != null && | |||
| !recipeMatchesBase && | |||
| hits.none { it.issueCode == "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE" || it.issueCode == "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX" } | |||
| ) { | |||
| try { | |||
| bomMaterialQtyService.deriveFromRecipe(itemId, qty, recipeUom, uomLookup) | |||
| } catch (e: Exception) { | |||
| val classified = classifyBomMaterialDeriveFailure(e) | |||
| if (hits.none { it.issueCode == classified.issueCode }) { | |||
| hits += classified | |||
| } | |||
| } | |||
| } | |||
| val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom | |||
| val matBaseId = material.baseUnit?.toLong() | |||
| if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) { | |||
| addMaterialKey( | |||
| "BOM_MATERIAL_BASE_UOM_MISMATCH", | |||
| formatUom(itemBase), | |||
| formatUomById(matBaseId, uomCache), | |||
| return hits | |||
| } | |||
| private fun evaluateRecipeToBaseIssue( | |||
| itemId: Long, | |||
| recipeUom: UomConversion, | |||
| itemBase: UomConversion, | |||
| uomLookup: ItemUomCacheContext, | |||
| ): BomMaterialIssueHit? { | |||
| if (recipeUom.id == itemBase.id) { | |||
| return null | |||
| } | |||
| val recipeInMatrix = StandardUomMatrix.toMatrixUnit(recipeUom) | |||
| val baseInMatrix = StandardUomMatrix.toMatrixUnit(itemBase) | |||
| if (recipeInMatrix != null && baseInMatrix != null) { | |||
| return try { | |||
| StandardUomMatrix.convertByUom(BigDecimal.ONE, recipeUom, itemBase) | |||
| null | |||
| } catch (_: Exception) { | |||
| BomMaterialIssueHit( | |||
| "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE", | |||
| expectedValue = formatUom(itemBase), | |||
| actualValue = formatUom(recipeUom), | |||
| ) | |||
| } | |||
| } | |||
| if (recipeInMatrix != null && baseInMatrix == null) { | |||
| return BomMaterialIssueHit( | |||
| "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX", | |||
| expectedValue = formatUom(itemBase), | |||
| actualValue = formatUom(recipeUom), | |||
| ) | |||
| } | |||
| val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom | |||
| val matStockId = material.stockUnit?.toLong() | |||
| if (matStockId != null && itemStock != null && matStockId != itemStock.id) { | |||
| addMaterialKey( | |||
| "BOM_MATERIAL_STOCK_UOM_MISMATCH", | |||
| formatUom(itemStock), | |||
| formatUomById(matStockId, uomCache), | |||
| return try { | |||
| itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = BigDecimal.ONE, | |||
| uomId = recipeUom.id!!, | |||
| targetUnit = "baseUnit", | |||
| ), | |||
| uomLookup, | |||
| ) | |||
| null | |||
| } catch (_: Exception) { | |||
| BomMaterialIssueHit( | |||
| "BOM_MATERIAL_RECIPE_TO_BASE_NOT_CONVERTIBLE", | |||
| expectedValue = formatUom(itemBase), | |||
| actualValue = formatUom(recipeUom), | |||
| ) | |||
| } | |||
| } | |||
| private fun materialPatternKey(issueCode: String, expected: String?, actual: String?): String { | |||
| val exp = expected ?: "" | |||
| val act = actual ?: "" | |||
| return when (issueCode) { | |||
| "BOM_MATERIAL_SALES_UOM_MISMATCH", | |||
| "BOM_MATERIAL_STOCK_UOM_MISMATCH", | |||
| -> "uom:$exp|$act" | |||
| "BOM_MATERIAL_BASE_UOM_MISMATCH" -> "base:$exp|$act" | |||
| else -> "$issueCode|$exp|$act" | |||
| private fun classifyBomMaterialDeriveFailure(e: Exception): BomMaterialIssueHit { | |||
| val msg = e.message.orEmpty() | |||
| return when { | |||
| msg.contains("Recipe UOM is not in standard matrix", ignoreCase = true) -> | |||
| BomMaterialIssueHit( | |||
| "BOM_MATERIAL_RECIPE_UOM_NOT_IN_MATRIX", | |||
| actualValue = msg.take(300), | |||
| ) | |||
| msg.contains("Item base UOM is not in standard matrix", ignoreCase = true) -> | |||
| BomMaterialIssueHit( | |||
| "BOM_MATERIAL_ITEM_BASE_NOT_IN_MATRIX", | |||
| expectedValue = StandardUomMatrix.STANDARD_CODES.joinToString(", "), | |||
| actualValue = msg.take(300), | |||
| ) | |||
| else -> | |||
| BomMaterialIssueHit( | |||
| "BOM_MATERIAL_DERIVE_FAILED", | |||
| actualValue = msg.take(300).ifBlank { e.javaClass.simpleName }, | |||
| ) | |||
| } | |||
| } | |||
| @@ -412,7 +530,6 @@ open class MasterDataIssueService( | |||
| } | |||
| for (material in materials) { | |||
| put(material.uom) | |||
| put(material.salesUnit) | |||
| } | |||
| return map | |||
| } | |||
| @@ -539,7 +656,7 @@ open class MasterDataIssueService( | |||
| val bomId = bom.id ?: return | |||
| val bomCode = bom.code | |||
| val bomName = bom.name | |||
| val description = bom.description | |||
| val description = bom.bomKind | |||
| val ctx = IssueContext( | |||
| scope = "BOM", | |||
| bomId = bomId, | |||
| @@ -551,9 +668,6 @@ open class MasterDataIssueService( | |||
| if (bomCode.isNullOrBlank()) { | |||
| issues.add(issue(ctx, null, "MISSING_BOM_CODE")) | |||
| } | |||
| if (bomName.isNullOrBlank()) { | |||
| issues.add(issue(ctx, null, "MISSING_BOM_NAME")) | |||
| } | |||
| val item = bom.item | |||
| val itemId = item?.id | |||
| @@ -563,19 +677,40 @@ open class MasterDataIssueService( | |||
| return | |||
| } | |||
| val bomCodeNorm = bomCode?.trim().orEmpty() | |||
| val itemCodeNorm = item.code?.trim().orEmpty() | |||
| if ( | |||
| bomCodeNorm.isNotEmpty() && | |||
| itemCodeNorm.isNotEmpty() && | |||
| !bomCodeNorm.equals(itemCodeNorm, ignoreCase = true) | |||
| ) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| item.id, | |||
| "BOM_ITEM_CODE_MISMATCH", | |||
| itemCode = item.code, | |||
| itemName = item.name, | |||
| expectedValue = bomCodeNorm, | |||
| actualValue = itemCodeNorm, | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| ) | |||
| } | |||
| collectItemUomIssues(item, ctx, issues, uomsByItemId, unitSnapshot = unitSnapshot) | |||
| val bomUom = bom.uom | |||
| val salesConv = findSalesUnitRow(item.id!!, uomsByItemId)?.uom | |||
| if (bomUom != null && salesConv != null && bomUom.id != salesConv.id) { | |||
| val baseConv = findBaseUnitRow(item.id!!, uomsByItemId)?.uom | |||
| if (bomUom != null && baseConv != null && bomUom.id != baseConv.id) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| item.id, | |||
| "BOM_OUTPUT_UOM_MISMATCH_SALES", | |||
| "BOM_OUTPUT_UOM_MISMATCH_BASE", | |||
| itemCode = item.code, | |||
| itemName = item.name, | |||
| expectedValue = formatUom(salesConv), | |||
| expectedValue = formatUom(baseConv), | |||
| actualValue = formatUom(bomUom), | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| @@ -606,7 +741,7 @@ open class MasterDataIssueService( | |||
| material: BomMaterial, | |||
| issues: MutableList<MasterDataIssueResponse>, | |||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||
| uomCache: Map<Long, UomConversion> = emptyMap(), | |||
| uomLookup: ItemUomCacheContext, | |||
| ) { | |||
| val bomId = bom.id ?: return | |||
| val materialId = material.id | |||
| @@ -616,104 +751,24 @@ open class MasterDataIssueService( | |||
| bomCode = bom.code, | |||
| bomName = bom.name, | |||
| bomMaterialId = materialId, | |||
| description = bom.description, | |||
| description = bom.bomKind, | |||
| ) | |||
| val matItem = material.item | |||
| val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId) | |||
| if (matItem == null || matItem.deleted == true) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| matItem?.id, | |||
| "BOM_MATERIAL_MISSING_ITEM", | |||
| itemCode = matItem?.code, | |||
| itemName = matItem?.name ?: material.itemName, | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| ) | |||
| return | |||
| } | |||
| // Item master UOM gaps belong on the Item tab only — not repeated per BOM here. | |||
| val matItemCode = matItem.code | |||
| val matItemName = matItem.name?.takeIf { it.isNotBlank() } ?: material.itemName | |||
| val matUom = material.uom | |||
| if (matUom != null && matUom.deleted == true) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| matItem.id, | |||
| "BOM_MATERIAL_UOM_FK_INVALID", | |||
| itemCode = matItemCode, | |||
| itemName = matItemName, | |||
| actualValue = "uomId=${matUom.id}", | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| ) | |||
| } | |||
| val salesUnitFk = material.salesUnit | |||
| if (salesUnitFk != null && salesUnitFk.deleted == true) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| matItem.id, | |||
| "BOM_MATERIAL_UOM_FK_INVALID", | |||
| itemCode = matItemCode, | |||
| itemName = matItemName, | |||
| actualValue = "salesUnitId=${salesUnitFk.id}", | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| ) | |||
| } | |||
| val matItemCode = matItem?.code | |||
| val matItemName = matItem?.name?.takeIf { it.isNotBlank() } ?: material.itemName | |||
| val itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom | |||
| if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) { | |||
| for (hit in findBomMaterialRecipeIssues(material, uomsByItemId, uomLookup)) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| matItem.id, | |||
| "BOM_MATERIAL_SALES_UOM_MISMATCH", | |||
| itemCode = matItemCode, | |||
| itemName = matItemName, | |||
| expectedValue = formatUom(itemSales), | |||
| actualValue = formatUom(salesUnitFk), | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| ) | |||
| } | |||
| val itemBase = findBaseUnitRow(matItem.id!!, uomsByItemId)?.uom | |||
| val matBaseId = material.baseUnit?.toLong() | |||
| if (matBaseId != null && itemBase != null && matBaseId != itemBase.id) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| matItem.id, | |||
| "BOM_MATERIAL_BASE_UOM_MISMATCH", | |||
| itemCode = matItemCode, | |||
| itemName = matItemName, | |||
| expectedValue = formatUom(itemBase), | |||
| actualValue = formatUomById(matBaseId, uomCache), | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| ) | |||
| } | |||
| val itemStock = findStockUnitRow(matItem.id!!, uomsByItemId)?.uom | |||
| val matStockId = material.stockUnit?.toLong() | |||
| if (matStockId != null && itemStock != null && matStockId != itemStock.id) { | |||
| issues.add( | |||
| issue( | |||
| ctx, | |||
| matItem.id, | |||
| "BOM_MATERIAL_STOCK_UOM_MISMATCH", | |||
| matItem?.id, | |||
| hit.issueCode, | |||
| itemCode = matItemCode, | |||
| itemName = matItemName, | |||
| expectedValue = formatUom(itemStock), | |||
| actualValue = formatUomById(matStockId, uomCache), | |||
| expectedValue = hit.expectedValue, | |||
| actualValue = hit.actualValue, | |||
| unitSnapshot = unitSnapshot, | |||
| ), | |||
| ) | |||
| @@ -79,6 +79,10 @@ open class ProductionScheduleService( | |||
| private val productProcessService: ProductProcessService, | |||
| private val jobOrderCreationService: JobOrderCreationService, | |||
| private val settingsService: SettingsService, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| private val uomConversionRepository: UomConversionRepository, | |||
| private val itemUomRespository: ItemUomRespository, | |||
| ) : AbstractBaseEntityService<ProductionSchedule, Long, ProductionScheduleRepository>( | |||
| jdbcDao, | |||
| productionScheduleRepository | |||
| @@ -434,9 +438,9 @@ open class ProductionScheduleService( | |||
| val bom = bomService.findByItemId(itemId) | |||
| ?: throw NoSuchElementException("BOM not found for Item ID $itemId.") | |||
| // 4. Update Prod Schedule Line fields (keep prodQty = total = outputQty * batchNeed) | |||
| val outputQtyDouble = bom.outputQty?.toDouble() | |||
| ?: throw IllegalStateException("BOM output quantity is null for Item ID $itemId.") | |||
| // 4. Update Prod Schedule Line fields (keep prodQty = total = stock outputQty * batchNeed) | |||
| val stockOutputQty = bomOutputQtyService.stockOutputQty(bom) | |||
| val outputQtyDouble = stockOutputQty.toDouble() | |||
| prodScheduleLine.apply { | |||
| prodQty = outputQtyDouble * batchNeed.toLong().coerceAtLeast(0) | |||
| approverId = approver?.id | |||
| @@ -452,9 +456,10 @@ open class ProductionScheduleService( | |||
| //repeat(prodScheduleLine.needNoOfJobOrder) { | |||
| // 6. Create Job Order | |||
| val produceAt = prodScheduleLine.productionSchedule?.produceAt | |||
| val stockBatchQty = bomOutputQtyService.stockOutputQty(bom) | |||
| val joRequest = CreateJobOrderRequest( | |||
| bomId = bom.id, // bom is guaranteed non-null here | |||
| reqQty = bom.outputQty?.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())), | |||
| reqQty = stockBatchQty.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())), | |||
| approverId = approver?.id, | |||
| planStart = produceAt, | |||
| @@ -543,9 +548,10 @@ open class ProductionScheduleService( | |||
| val approver = SecurityUtils.getUser().getOrNull() // Get approver once | |||
| val produceAt = prodScheduleLine.productionSchedule?.produceAt | |||
| val stockBatchQty = bomOutputQtyService.stockOutputQty(bom) | |||
| val joRequest = CreateJobOrderRequest( | |||
| bomId = bom.id, // bom is guaranteed non-null here | |||
| reqQty = bom.outputQty?.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())), | |||
| reqQty = stockBatchQty.multiply(BigDecimal.valueOf(prodScheduleLine.batchNeed.toLong())), | |||
| approverId = approver?.id, | |||
| planStart = produceAt, | |||
| @@ -633,118 +639,109 @@ open class ProductionScheduleService( | |||
| val args = mapOf("fromDate" to fromDate.toString(), "toDate" to toDate.toString()) | |||
| val sql = """ | |||
| SELECT | |||
| i.outputQty, | |||
| WITH do_daily_qty AS ( | |||
| SELECT | |||
| dol.itemId, | |||
| do.estimatedArrivalDate, | |||
| SUM(dol.qty) AS dailyQty | |||
| FROM delivery_order_line dol | |||
| LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id | |||
| WHERE do.deleted = 0 | |||
| AND do.estimatedArrivalDate >= :fromDate | |||
| AND do.estimatedArrivalDate <= :toDate | |||
| GROUP BY dol.itemId, do.estimatedArrivalDate | |||
| ), | |||
| avg_daily AS ( | |||
| SELECT itemId, ROUND(AVG(dailyQty)) AS avgQtyLastMonth | |||
| FROM do_daily_qty | |||
| GROUP BY itemId | |||
| ), | |||
| pending_job AS ( | |||
| SELECT bomId, SUM(reqQty) AS pendingJobQty | |||
| FROM job_order | |||
| WHERE status != 'completed' | |||
| GROUP BY bomId | |||
| ) | |||
| SELECT | |||
| i.outputQty, | |||
| i.avgQtyLastMonth, | |||
| -- (i.onHandQty -500) * 1.0 / i.avgQtyLastMonth as daysLeft, | |||
| 0 as needQty, | |||
| 0 AS needQty, | |||
| i.stockQty, | |||
| -- CASE | |||
| -- WHEN stockQty * 1.0 / i.avgQtyLastMonth <= 1.9 THEN | |||
| -- CEIL((i.avgQtyLastMonth * 2.6 - stockQty) / i.outputQty) | |||
| -- ELSE 0 | |||
| -- END AS needNoOfJobOrder, | |||
| -- The first date only consider how many batch pending for job order | |||
| 0 AS needNoOfJobOrder, | |||
| 0 AS batchNeed, | |||
| i.pendingJobQty, | |||
| ((i.stockQty * 1.0) + ifnull(i.pendingJobQty, 0) ) / i.avgQtyLastMonth as daysLeft, | |||
| ifnull(i.baseScore, 0) as priority, | |||
| -- 25 + 25 + markDark + markFloat + markDense + markAS + markTimeSequence + markComplexity as priority, | |||
| ((i.stockQty * 1.0) + IFNULL(i.pendingJobQty, 0)) / i.avgQtyLastMonth AS daysLeft, | |||
| IFNULL(i.baseScore, 0) AS priority, | |||
| i.* | |||
| FROM | |||
| (SELECT | |||
| COALESCE( | |||
| (SELECT dailyQty FROM item_daily_out WHERE itemCode = items.code), | |||
| (SELECT | |||
| ROUND(AVG(d.dailyQty)) | |||
| FROM | |||
| (SELECT | |||
| SUM(dol.qty) AS dailyQty | |||
| FROM | |||
| delivery_order_line dol | |||
| LEFT JOIN delivery_order do ON dol.deliveryOrderId = do.id | |||
| WHERE | |||
| do.deleted = 0 and | |||
| dol.itemId = items.id | |||
| AND do.estimatedArrivalDate >= :fromDate AND do.estimatedArrivalDate <= :toDate | |||
| GROUP BY do.estimatedArrivalDate) AS d) | |||
| ) AS avgQtyLastMonth, | |||
| (select sum(reqQty) from job_order where bomId = bom.id and status != 'completed') AS pendingJobQty, | |||
| (select count(1) from coffee_or_tea where systemType = 'coffee' and itemCode = items.code) as isCoffee, | |||
| (select count(1) from coffee_or_tea where systemType = 'tea' and itemCode = items.code) as isTea, | |||
| (select count(1) from coffee_or_tea where systemType = 'lemon' and itemCode = items.code) as isLemon, | |||
| CASE WHEN item_fake_onhand.onHandQty is not null THEN item_fake_onhand.onHandQty | |||
| ELSE inventory.onHandQty END AS stockQty, | |||
| bom.baseScore, | |||
| bom.outputQty, | |||
| bom.outputQtyUom, | |||
| (SELECT | |||
| udfudesc | |||
| FROM | |||
| delivery_order_line | |||
| LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id | |||
| WHERE | |||
| delivery_order_line.itemId = bom.itemId | |||
| LIMIT 1) AS doUom, | |||
| items.code, | |||
| items.name, | |||
| bom.description, | |||
| inventory.onHandQty, | |||
| bom.itemId, | |||
| bom.id AS bomId, | |||
| CASE WHEN bom.isDark = 5 THEN 11 | |||
| WHEN bom.isDark = 3 THEN 6 | |||
| WHEN bom.isDark = 1 THEN 2 | |||
| ELSE 0 END as markDark, | |||
| CASE WHEN bom.isFloat = 5 THEN 11 | |||
| WHEN bom.isFloat = 3 THEN 6 | |||
| WHEN bom.isFloat = 1 THEN 2 | |||
| ELSE 0 END as markFloat, | |||
| CASE WHEN bom.isDense = 5 THEN 11 | |||
| WHEN bom.isDense = 3 THEN 6 | |||
| WHEN bom.isDense = 1 THEN 2 | |||
| ELSE 0 END as markDense, | |||
| bom.timeSequence as markTimeSequence, | |||
| bom.complexity as markComplexity, | |||
| CASE WHEN bom.allergicSubstances = 5 THEN 11 | |||
| ELSE 0 END as markAS, | |||
| inventory.id AS inventoryId | |||
| FROM | |||
| bom | |||
| FROM ( | |||
| SELECT | |||
| COALESCE(ido.dailyQty, ad.avgQtyLastMonth) AS avgQtyLastMonth, | |||
| pj.pendingJobQty, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'coffee' AND itemCode = items.code) AS isCoffee, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'tea' AND itemCode = items.code) AS isTea, | |||
| (SELECT COUNT(1) FROM coffee_or_tea WHERE systemType = 'lemon' AND itemCode = items.code) AS isLemon, | |||
| CASE WHEN item_fake_onhand.onHandQty IS NOT NULL THEN item_fake_onhand.onHandQty | |||
| ELSE inventory.onHandQty END AS stockQty, | |||
| bom.baseScore, | |||
| bom.outputQty, | |||
| bom.outputQtyUom, | |||
| ( | |||
| SELECT udfudesc | |||
| FROM delivery_order_line | |||
| LEFT JOIN uom_conversion ON delivery_order_line.uomId = uom_conversion.id | |||
| WHERE delivery_order_line.itemId = bom.itemId | |||
| LIMIT 1 | |||
| ) AS doUom, | |||
| items.code, | |||
| items.name, | |||
| bom.bomKind, | |||
| inventory.onHandQty, | |||
| bom.itemId, | |||
| bom.id AS bomId, | |||
| CASE WHEN bom.isDark = 5 THEN 11 | |||
| WHEN bom.isDark = 3 THEN 6 | |||
| WHEN bom.isDark = 1 THEN 2 | |||
| ELSE 0 END AS markDark, | |||
| CASE WHEN bom.isFloat = 5 THEN 11 | |||
| WHEN bom.isFloat = 3 THEN 6 | |||
| WHEN bom.isFloat = 1 THEN 2 | |||
| ELSE 0 END AS markFloat, | |||
| CASE WHEN bom.isDense = 5 THEN 11 | |||
| WHEN bom.isDense = 3 THEN 6 | |||
| WHEN bom.isDense = 1 THEN 2 | |||
| ELSE 0 END AS markDense, | |||
| bom.timeSequence AS markTimeSequence, | |||
| bom.complexity AS markComplexity, | |||
| CASE WHEN bom.allergicSubstances = 5 THEN 11 | |||
| ELSE 0 END AS markAS, | |||
| inventory.id AS inventoryId | |||
| FROM bom | |||
| LEFT JOIN items ON bom.itemId = items.id | |||
| LEFT JOIN inventory ON items.id = inventory.itemId | |||
| left join item_fake_onhand on items.code = item_fake_onhand.itemCode | |||
| WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active' | |||
| -- and bom.itemId != 16771 | |||
| ) AS i | |||
| WHERE 1 | |||
| and i.avgQtyLastMonth is not null | |||
| and i.onHandQty is not null | |||
| -- and (i.onHandQty -500) * 1.0 / i.avgQtyLastMonth <= 1.9 | |||
| -- and avgQtyLastMonth - (onHandQty - 500) > 0 | |||
| LEFT JOIN item_fake_onhand ON items.code = item_fake_onhand.itemCode | |||
| LEFT JOIN item_daily_out ido ON ido.itemCode = items.code | |||
| LEFT JOIN avg_daily ad ON ad.itemId = items.id | |||
| LEFT JOIN pending_job pj ON pj.bomId = bom.id | |||
| WHERE bom.deleted = 0 | |||
| AND bom.bomKind = 'FG' | |||
| AND bom.status = 'active' | |||
| ) AS i | |||
| WHERE i.avgQtyLastMonth IS NOT NULL | |||
| AND i.onHandQty IS NOT NULL | |||
| """.trimIndent() | |||
| val rows: List<Map<String, Any>> = jdbcDao.queryForList(sql, args) | |||
| return rows.map { row -> | |||
| val itemId = (row["itemId"] as Number).toLong() | |||
| val bom = bomService.findByItemId(itemId) | |||
| val stockOutputQty = bom?.let { bomOutputQtyService.stockOutputQty(it).toDouble() } | |||
| ?: (row["outputQty"] as Number).toDouble() | |||
| NeedQtyRecord().apply { | |||
| name = row["name"] as String | |||
| id = (row["itemId"] as Number).toLong() | |||
| id = itemId | |||
| needQty = (row["needQty"] as Number).toDouble() | |||
| outputQty = (row["outputQty"] as Number).toDouble() | |||
| outputQty = stockOutputQty | |||
| stockQty = (row["stockQty"] as Number).toDouble() | |||
| avgQtyLastMonth = (row["avgQtyLastMonth"] as Number).toDouble() | |||
| needNoOfJobOrder = (row["needNoOfJobOrder"] as Number).toLong() | |||
| @@ -1752,6 +1749,138 @@ open class ProductionScheduleService( | |||
| } | |||
| } | |||
| private fun exportLatestPsByProduceAtCte(): String = """ | |||
| latest_ps AS ( | |||
| SELECT MAX(id) AS id, produceAt | |||
| FROM production_schedule | |||
| WHERE produceAt >= :fromDate | |||
| GROUP BY produceAt | |||
| ) | |||
| """.trimIndent() | |||
| private fun exportLatestPsByDayCte(): String = """ | |||
| latest_ps AS ( | |||
| SELECT MAX(id) AS id, DATE(produceAt) AS produceDay | |||
| FROM production_schedule | |||
| WHERE produceAt >= DATE_ADD(:fromDate, INTERVAL 1 DAY) | |||
| AND produceAt < DATE_ADD(:fromDate, INTERVAL 8 DAY) | |||
| GROUP BY DATE(produceAt) | |||
| ) | |||
| """.trimIndent() | |||
| private data class RecipeBaseQtyKey( | |||
| val itemId: Long, | |||
| val recipeUomId: Long, | |||
| val recipeQty: BigDecimal, | |||
| ) | |||
| private fun buildExportUomLookup( | |||
| materialItemIds: Set<Long>, | |||
| recipeUomById: Map<Long, UomConversion>, | |||
| ): ItemUomCacheContext { | |||
| if (materialItemIds.isEmpty()) { | |||
| return ItemUomCacheContext.from(emptyMap(), recipeUomById) | |||
| } | |||
| val uomsByItemId = itemUomRespository.findAllActiveWithUom() | |||
| .mapNotNull { row -> | |||
| val itemId = row.item?.id ?: return@mapNotNull null | |||
| if (itemId !in materialItemIds) return@mapNotNull null | |||
| itemId to row | |||
| } | |||
| .groupBy({ it.first }, { it.second }) | |||
| val uomById = mutableMapOf<Long, UomConversion>() | |||
| uomsByItemId.values.flatten().forEach { row -> | |||
| row.uom?.id?.let { uomById[it] = row.uom!! } | |||
| } | |||
| recipeUomById.values.forEach { uom -> uom.id?.let { uomById[it] = uom } } | |||
| return ItemUomCacheContext.from(uomsByItemId, uomById) | |||
| } | |||
| private fun cachedRecipeBaseQty( | |||
| itemId: Long, | |||
| recipeQty: BigDecimal, | |||
| recipeUom: UomConversion, | |||
| uomLookup: ItemUomCacheContext, | |||
| memo: MutableMap<RecipeBaseQtyKey, BigDecimal?>, | |||
| ): BigDecimal? { | |||
| val recipeUomId = recipeUom.id ?: return null | |||
| val key = RecipeBaseQtyKey(itemId, recipeUomId, recipeQty.stripTrailingZeros()) | |||
| memo[key]?.let { return it } | |||
| val result = try { | |||
| bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom, uomLookup).baseQty | |||
| } catch (_: Exception) { | |||
| null | |||
| } | |||
| memo[key] = result | |||
| return result | |||
| } | |||
| private val exportPurchasedQtyFutureCte = """ | |||
| purchased_qty_future AS ( | |||
| SELECT | |||
| pol.itemId, | |||
| SUM(pol.qty) AS purchasedQty | |||
| FROM purchase_order_line pol | |||
| INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId | |||
| WHERE po.completeDate IS NULL | |||
| AND po.estimatedArrivalDate >= CURDATE() | |||
| GROUP BY pol.itemId | |||
| ) | |||
| """.trimIndent() | |||
| private val exportPurchaseUomCte = """ | |||
| purchase_uom AS ( | |||
| SELECT | |||
| iu.itemId, | |||
| (iu.ratioD / iu.ratioN) AS purchaseRatio, | |||
| uc.code AS purchaseUnit | |||
| FROM item_uom iu | |||
| LEFT JOIN uom_conversion uc ON uc.id = iu.uomId | |||
| WHERE iu.purchaseUnit = 1 AND iu.deleted = 0 | |||
| ) | |||
| """.trimIndent() | |||
| private val exportLatestSupplierUomCte = """ | |||
| latest_supplier_uom AS ( | |||
| SELECT x.materialId, x.uomIdM18 | |||
| FROM ( | |||
| SELECT | |||
| pol.itemId AS materialId, | |||
| pol.uomIdM18, | |||
| ROW_NUMBER() OVER ( | |||
| PARTITION BY pol.itemId | |||
| ORDER BY COALESCE(po.orderDate, po.created) DESC, pol.id DESC | |||
| ) AS rn | |||
| FROM purchase_order_line pol | |||
| JOIN purchase_order po ON po.id = pol.purchaseOrderId | |||
| WHERE pol.deleted = 0 | |||
| AND po.deleted = 0 | |||
| AND pol.uomIdM18 IS NOT NULL | |||
| ) x | |||
| WHERE x.rn = 1 | |||
| ) | |||
| """.trimIndent() | |||
| private val exportPurchasedQtyFromDateCte = """ | |||
| purchased_qty_from_date AS ( | |||
| SELECT | |||
| pol.itemId, | |||
| CEIL(SUM( | |||
| pol.qty * (itum.ratioN / itum.ratioD) | |||
| * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)) | |||
| )) AS purchasedQty | |||
| FROM purchase_order_line pol | |||
| INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId | |||
| INNER JOIN item_uom itum ON itum.itemId = pol.itemId AND itum.purchaseUnit = 1 | |||
| LEFT JOIN latest_supplier_uom lsu ON lsu.materialId = pol.itemId | |||
| LEFT JOIN item_uom ium18 ON ium18.itemId = pol.itemId | |||
| AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0 | |||
| WHERE po.completeDate IS NULL | |||
| AND po.estimatedArrivalDate >= :fromDate | |||
| GROUP BY pol.itemId | |||
| ) | |||
| """.trimIndent() | |||
| //====================細排相關 START====================// | |||
| open fun searchExportProdSchedule(fromDate: LocalDate): List<Map<String, Any>> { | |||
| @@ -1760,186 +1889,246 @@ open class ProductionScheduleService( | |||
| ) | |||
| val sql = """ | |||
| select | |||
| it.code as itemCode, | |||
| it.name as itemName, | |||
| WITH ${exportLatestPsByProduceAtCte()} | |||
| SELECT | |||
| it.code AS itemCode, | |||
| it.name AS itemName, | |||
| ps.produceAt, | |||
| psl.* | |||
| from production_schedule_line psl | |||
| left join production_schedule ps on psl.prodScheduleId = ps.id | |||
| left join items it on psl.itemId = it.id | |||
| where ps.produceAt >= :fromDate | |||
| and ps.id = (select id from production_schedule where produceAt = ps.produceAt order by id desc limit 1) | |||
| order by ps.produceAt asc, psl.itemPriority desc, itemCode asc | |||
| """; | |||
| psl.* | |||
| FROM production_schedule_line psl | |||
| INNER JOIN latest_ps lps ON lps.id = psl.prodScheduleId | |||
| INNER JOIN production_schedule ps ON ps.id = lps.id | |||
| LEFT JOIN items it ON psl.itemId = it.id | |||
| WHERE ps.produceAt >= :fromDate | |||
| ORDER BY ps.produceAt ASC, psl.itemPriority DESC, itemCode ASC | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args); | |||
| } | |||
| open fun searchExportProdScheduleMaterial(fromDate: LocalDate): List<Map<String, Any>> { | |||
| val args = mapOf( | |||
| "fromDate" to fromDate, | |||
| ) | |||
| val args = mapOf("fromDate" to fromDate) | |||
| val sql = """ | |||
| select | |||
| group_concat(distinct it.code) as itemCode, | |||
| group_concat(distinct it.name) as itemName, | |||
| itm.code as matCode, | |||
| itm.name as matName, | |||
| sum(bm.qty * psl.batchNeed) as totalMatQtyNeed, | |||
| WITH ${exportLatestPsByProduceAtCte()}, | |||
| $exportPurchasedQtyFutureCte, | |||
| $exportPurchaseUomCte | |||
| SELECT | |||
| it.code AS fgItemCode, | |||
| it.name AS fgItemName, | |||
| itm.id AS materialId, | |||
| itm.code AS matCode, | |||
| itm.name AS matName, | |||
| bm.qty AS recipeQty, | |||
| bm.uomId AS recipeUomId, | |||
| bm.uomName AS recipeUomName, | |||
| psl.batchNeed AS batchNeed, | |||
| iv.onHandQty, | |||
| iv.unavailableQty, | |||
| (select sum(qty) from purchase_order_line | |||
| left join purchase_order on purchase_order_line.purchaseOrderId = purchase_order.id | |||
| where purchase_order_line.itemId = itm.id and date(purchase_order.estimatedArrivalDate) >= date(now()) and purchase_order.completeDate is null) as purchasedQty, | |||
| bm.uomName, | |||
| (select ratioD/ratioN from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0 | |||
| ) as purchaseRatio, | |||
| (select ceil(ratioD/ratioN*sum(bm.qty * psl.batchNeed)) from item_uom where purchaseUnit = 1 and itemId = it.id and item_uom.deleted = 0 | |||
| ) as purchaseQtyNeed, | |||
| (select code from item_uom | |||
| left join uom_conversion on item_uom.uomId = uom_conversion.id | |||
| where item_uom.purchaseUnit = 1 and item_uom.itemId = it.id and item_uom.deleted = 0 | |||
| ) as purchaseUnit, | |||
| min(ps.produceAt) as toProdcueFrom, | |||
| max(ps.produceAt) as toProdcueAt, | |||
| ucs.udfudesc as stockUnit, | |||
| psl.* | |||
| from production_schedule_line psl | |||
| left join production_schedule ps on psl.prodScheduleId = ps.id | |||
| left join items it on psl.itemId = it.id | |||
| left join bom on it.id = bom.itemId | |||
| left join bom_material bm on bom.id = bm.bomId | |||
| left join items itm on bm.itemId = itm.id | |||
| left join inventory iv on itm.id = iv.itemId | |||
| left join item_uom ius on itm.id = ius.itemId and ius.stockUnit = 1 | |||
| left join uom_conversion ucs on ius.uomId = ucs.id | |||
| where ps.produceAt >= :fromDate | |||
| and ps.id = (select id from production_schedule where produceAt = ps.produceAt order by id desc limit 1) | |||
| -- This is water | |||
| pqf.purchasedQty, | |||
| ucs.udfudesc AS stockUnit, | |||
| pu.purchaseRatio, | |||
| pu.purchaseUnit | |||
| FROM production_schedule_line psl | |||
| INNER JOIN latest_ps lps ON lps.id = psl.prodScheduleId | |||
| INNER JOIN production_schedule ps ON ps.id = lps.id | |||
| LEFT JOIN items it ON psl.itemId = it.id | |||
| LEFT JOIN bom ON it.id = bom.itemId | |||
| LEFT JOIN bom_material bm ON bom.id = bm.bomId | |||
| LEFT JOIN items itm ON bm.itemId = itm.id | |||
| LEFT JOIN inventory iv ON itm.id = iv.itemId | |||
| LEFT JOIN item_uom ius ON itm.id = ius.itemId AND ius.stockUnit = 1 | |||
| LEFT JOIN uom_conversion ucs ON ius.uomId = ucs.id | |||
| LEFT JOIN purchased_qty_future pqf ON pqf.itemId = itm.id | |||
| LEFT JOIN purchase_uom pu ON pu.itemId = itm.id | |||
| WHERE ps.produceAt >= :fromDate | |||
| AND it.code != 'GI3236' | |||
| -- order by ps.produceAt asc, psl.itemPriority desc, itemCode asc | |||
| group by matCode | |||
| """; | |||
| AND bm.itemId IS NOT NULL | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args); | |||
| val rows = jdbcDao.queryForList(sql, args) | |||
| if (rows.isEmpty()) { | |||
| return emptyList() | |||
| } | |||
| val recipeUomIds = rows.mapNotNull { asLong(it["recipeUomId"]) }.distinct() | |||
| val recipeUomById = uomConversionRepository.findAllByIdInAndDeletedFalse(recipeUomIds) | |||
| .associateBy { it.id!! } | |||
| val materialItemIds = rows.mapNotNull { asLong(it["materialId"]) }.toSet() | |||
| val uomLookup = buildExportUomLookup(materialItemIds, recipeUomById) | |||
| val recipeBaseQtyMemo = mutableMapOf<RecipeBaseQtyKey, BigDecimal?>() | |||
| return rows.groupBy { it["matCode"]?.toString().orEmpty() } | |||
| .map { (_, materialRows) -> | |||
| val first = materialRows.first() | |||
| val materialId = asLong(first["materialId"]) | |||
| val purchaseRatio = asBigDecimal(first["purchaseRatio"]) | |||
| val totalMatQtyNeed = materialRows.fold(BigDecimal.ZERO) { acc, row -> | |||
| val recipeQty = asBigDecimal(row["recipeQty"]) | |||
| val batchNeed = asBigDecimal(row["batchNeed"]) | |||
| acc + recipeQty.multiply(batchNeed) | |||
| } | |||
| val baseNeedSum = materialRows.fold(BigDecimal.ZERO) { acc, row -> | |||
| val matId = asLong(row["materialId"]) ?: return@fold acc | |||
| val recipeQty = asBigDecimal(row["recipeQty"]) | |||
| val batchNeed = asBigDecimal(row["batchNeed"]) | |||
| val recipeUomId = asLong(row["recipeUomId"]) | |||
| val recipeUom = recipeUomId?.let { recipeUomById[it] } ?: return@fold acc | |||
| val baseQty = cachedRecipeBaseQty(matId, recipeQty, recipeUom, uomLookup, recipeBaseQtyMemo) | |||
| ?: return@fold acc | |||
| acc + baseQty.multiply(batchNeed) | |||
| } | |||
| val purchaseQtyNeed = baseNeedSum.multiply(purchaseRatio).setScale(0, RoundingMode.CEILING) | |||
| val fgCodes = materialRows.mapNotNull { it["fgItemCode"]?.toString() }.distinct().sorted() | |||
| val fgNames = materialRows.mapNotNull { it["fgItemName"]?.toString() }.distinct().sorted() | |||
| linkedMapOf<String, Any>( | |||
| "itemCode" to fgCodes.joinToString(","), | |||
| "itemName" to fgNames.joinToString(","), | |||
| "matCode" to (first["matCode"]?.toString() ?: ""), | |||
| "matName" to (first["matName"]?.toString() ?: ""), | |||
| "totalMatQtyNeed" to totalMatQtyNeed, | |||
| "onHandQty" to asBigDecimal(first["onHandQty"]), | |||
| "unavailableQty" to asBigDecimal(first["unavailableQty"]), | |||
| "purchasedQty" to asBigDecimal(first["purchasedQty"]), | |||
| "uomName" to (first["recipeUomName"]?.toString() ?: ""), | |||
| "purchaseRatio" to purchaseRatio, | |||
| "purchaseQtyNeed" to purchaseQtyNeed, | |||
| "purchaseUnit" to (first["purchaseUnit"]?.toString() ?: ""), | |||
| "stockUnit" to (first["stockUnit"]?.toString() ?: ""), | |||
| "materialId" to (materialId ?: 0L), | |||
| ) | |||
| } | |||
| .sortedBy { it["matCode"]?.toString().orEmpty() } | |||
| } | |||
| open fun searchExportDailyMaterial(fromDate: LocalDate): List<Map<String, Any>> { | |||
| val args = mapOf( | |||
| "fromDate" to fromDate, | |||
| ) | |||
| val args = mapOf("fromDate" to fromDate) | |||
| val sql = """ | |||
| WITH latest_supplier_uom AS ( | |||
| SELECT x.materialId, x.uomIdM18 | |||
| FROM ( | |||
| SELECT | |||
| pol.itemId AS materialId, | |||
| pol.uomIdM18, | |||
| ROW_NUMBER() OVER ( | |||
| PARTITION BY pol.itemId | |||
| ORDER BY COALESCE(po.orderDate, po.created) DESC, pol.id DESC | |||
| ) AS rn | |||
| FROM purchase_order_line pol | |||
| JOIN purchase_order po ON po.id = pol.purchaseOrderId | |||
| WHERE pol.deleted = 0 | |||
| AND po.deleted = 0 | |||
| AND pol.uomIdM18 IS NOT NULL | |||
| ) x | |||
| WHERE x.rn = 1 | |||
| ), | |||
| daily_needs AS ( | |||
| SELECT | |||
| itm.id AS materialId, | |||
| itm.code AS matCode, | |||
| itm.name AS matName, | |||
| COALESCE(uomM18.code, uomP.code) AS uom, | |||
| ceil((iv.onHandQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) as onHandQty, | |||
| ceil((iv.unavailableQty * (itsm.ratioN / itsm.ratioD)) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) as unavailableQty, | |||
| COALESCE(( | |||
| SELECT ceil(SUM(pol.qty * (itum.ratioN / itum.ratioD) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)))) | |||
| FROM purchase_order_line pol | |||
| JOIN purchase_order po ON pol.purchaseOrderId = po.id | |||
| WHERE pol.itemId = itm.id | |||
| AND DATE(po.estimatedArrivalDate) >= :fromDate | |||
| AND po.completeDate IS NULL | |||
| ), 0) AS purchasedQty, | |||
| DATE(ps.produceAt) AS produceDate, | |||
| ceil( SUM(bm.baseQty * psl.batchNeed) * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)) ) AS qtyNeeded | |||
| FROM production_schedule_line psl | |||
| JOIN production_schedule ps ON psl.prodScheduleId = ps.id | |||
| JOIN items it ON psl.itemId = it.id | |||
| JOIN bom ON it.id = bom.itemId | |||
| JOIN bom_material bm ON bom.id = bm.bomId | |||
| JOIN items itm ON bm.itemId = itm.id | |||
| JOIN item_uom itum ON itm.id = itum.itemId and itum.purchaseUnit = 1 | |||
| JOIN uom_conversion uomP ON itum.uomId = uomP.id | |||
| LEFT JOIN latest_supplier_uom lsu ON lsu.materialId = itm.id | |||
| LEFT JOIN item_uom ium18 ON ium18.itemId = itm.id AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0 | |||
| LEFT JOIN uom_conversion uomM18 ON uomM18.id = ium18.uomId | |||
| JOIN item_uom itsm ON itm.id = itsm.itemId and itsm.stockUnit = 1 | |||
| LEFT JOIN inventory iv ON itm.id = iv.itemId | |||
| WHERE DATE(ps.produceAt) >= DATE_ADD(:fromDate, INTERVAL 1 DAY) | |||
| AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY) | |||
| AND ps.id = ( | |||
| SELECT ps2.id | |||
| FROM production_schedule ps2 | |||
| WHERE DATE(ps2.produceAt) = DATE(ps.produceAt) | |||
| ORDER BY ps2.id DESC | |||
| LIMIT 1 | |||
| ) | |||
| AND bm.itemId IS NOT NULL | |||
| AND itm.code != 'GI3236' | |||
| GROUP BY | |||
| itm.id, | |||
| DATE(ps.produceAt) | |||
| WITH $exportLatestSupplierUomCte, | |||
| ${exportLatestPsByDayCte()}, | |||
| $exportPurchasedQtyFromDateCte | |||
| SELECT | |||
| itm.id AS materialId, | |||
| itm.code AS matCode, | |||
| itm.name AS matName, | |||
| COALESCE(uomM18.code, uomP.code) AS uom, | |||
| CEIL((iv.onHandQty * (itsm.ratioN / itsm.ratioD)) | |||
| * (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN))) AS onHandQty, | |||
| COALESCE(pqd.purchasedQty, 0) AS purchasedQty, | |||
| DATE(ps.produceAt) AS produceDate, | |||
| bm.qty AS recipeQty, | |||
| bm.uomId AS recipeUomId, | |||
| psl.batchNeed AS batchNeed, | |||
| (COALESCE(ium18.ratioD, itum.ratioD) / COALESCE(ium18.ratioN, itum.ratioN)) AS purchaseRatio | |||
| FROM production_schedule_line psl | |||
| INNER JOIN latest_ps lps ON lps.id = psl.prodScheduleId | |||
| INNER JOIN production_schedule ps ON ps.id = lps.id | |||
| JOIN items it ON psl.itemId = it.id | |||
| JOIN bom ON it.id = bom.itemId | |||
| JOIN bom_material bm ON bom.id = bm.bomId | |||
| JOIN items itm ON bm.itemId = itm.id | |||
| JOIN item_uom itum ON itm.id = itum.itemId AND itum.purchaseUnit = 1 | |||
| JOIN uom_conversion uomP ON itum.uomId = uomP.id | |||
| LEFT JOIN latest_supplier_uom lsu ON lsu.materialId = itm.id | |||
| LEFT JOIN item_uom ium18 ON ium18.itemId = itm.id AND ium18.uomId = lsu.uomIdM18 AND ium18.deleted = 0 | |||
| LEFT JOIN uom_conversion uomM18 ON uomM18.id = ium18.uomId | |||
| JOIN item_uom itsm ON itm.id = itsm.itemId AND itsm.stockUnit = 1 | |||
| LEFT JOIN inventory iv ON itm.id = iv.itemId | |||
| LEFT JOIN purchased_qty_from_date pqd ON pqd.itemId = itm.id | |||
| WHERE DATE(ps.produceAt) >= DATE_ADD(:fromDate, INTERVAL 1 DAY) | |||
| AND DATE(ps.produceAt) < DATE_ADD(:fromDate, INTERVAL 8 DAY) | |||
| AND bm.itemId IS NOT NULL | |||
| AND itm.code != 'GI3236' | |||
| """.trimIndent() | |||
| val rows = jdbcDao.queryForList(sql, args) | |||
| if (rows.isEmpty()) { | |||
| return emptyList() | |||
| } | |||
| val recipeUomIds = rows.mapNotNull { asLong(it["recipeUomId"]) }.distinct() | |||
| val recipeUomById = uomConversionRepository.findAllByIdInAndDeletedFalse(recipeUomIds) | |||
| .associateBy { it.id!! } | |||
| val materialItemIds = rows.mapNotNull { asLong(it["materialId"]) }.toSet() | |||
| val uomLookup = buildExportUomLookup(materialItemIds, recipeUomById) | |||
| val recipeBaseQtyMemo = mutableMapOf<RecipeBaseQtyKey, BigDecimal?>() | |||
| val qtyNeededByMaterialDate = rows.groupBy { row -> | |||
| asLong(row["materialId"]) to toLocalDate(row["produceDate"]) | |||
| }.mapValues { (_, groupRows) -> | |||
| val purchaseRatio = asBigDecimal(groupRows.first()["purchaseRatio"]) | |||
| val baseNeedSum = groupRows.fold(BigDecimal.ZERO) { acc, row -> | |||
| val materialId = asLong(row["materialId"]) ?: return@fold acc | |||
| val recipeQty = asBigDecimal(row["recipeQty"]) | |||
| val batchNeed = asBigDecimal(row["batchNeed"]) | |||
| val recipeUomId = asLong(row["recipeUomId"]) | |||
| val recipeUom = recipeUomId?.let { recipeUomById[it] } ?: return@fold acc | |||
| val baseQty = cachedRecipeBaseQty(materialId, recipeQty, recipeUom, uomLookup, recipeBaseQtyMemo) | |||
| ?: return@fold acc | |||
| acc + baseQty.multiply(batchNeed) | |||
| } | |||
| baseNeedSum.multiply(purchaseRatio).setScale(0, RoundingMode.CEILING) | |||
| } | |||
| return rows.groupBy { asLong(it["materialId"]) } | |||
| .mapNotNull { (materialId, materialRows) -> | |||
| if (materialId == null) return@mapNotNull null | |||
| val first = materialRows.first() | |||
| val onHandQty = asBigDecimal(first["onHandQty"]) | |||
| val purchasedQty = asBigDecimal(first["purchasedQty"]) | |||
| val dayNeeds = (1..7).associate { offset -> | |||
| val produceDate = fromDate.plusDays(offset.toLong()) | |||
| val qty = qtyNeededByMaterialDate[materialId to produceDate] ?: BigDecimal.ZERO | |||
| "day$offset" to qty | |||
| } | |||
| val totalNeed7Days = dayNeeds.values.fold(BigDecimal.ZERO, BigDecimal::add) | |||
| val grossAvailable = onHandQty.add(purchasedQty) | |||
| linkedMapOf<String, Any>( | |||
| "matCode" to (first["matCode"]?.toString() ?: ""), | |||
| "matName" to (first["matName"]?.toString() ?: ""), | |||
| "uom" to (first["uom"]?.toString() ?: ""), | |||
| "currentStock" to onHandQty, | |||
| "incomingPO" to purchasedQty, | |||
| "grossAvailable" to grossAvailable, | |||
| ).apply { | |||
| putAll(dayNeeds) | |||
| put("totalNeed7Days", totalNeed7Days) | |||
| put("netAfter7Days", grossAvailable.subtract(totalNeed7Days)) | |||
| } | |||
| } | |||
| .sortedWith( | |||
| compareBy<Map<String, Any>> { asBigDecimal(it["netAfter7Days"]) } | |||
| .thenByDescending { asBigDecimal(it["totalNeed7Days"]) }, | |||
| ) | |||
| } | |||
| SELECT | |||
| matCode, | |||
| matName, | |||
| uom, | |||
| onHandQty AS currentStock, | |||
| purchasedQty AS incomingPO, | |||
| (onHandQty + purchasedQty) AS grossAvailable, | |||
| -- Dynamic columns for next 7 days | |||
| COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 1 DAY) THEN qtyNeeded END), 0) AS day1, | |||
| COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 2 DAY) THEN qtyNeeded END), 0) AS day2, | |||
| COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 3 DAY) THEN qtyNeeded END), 0) AS day3, | |||
| COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 4 DAY) THEN qtyNeeded END), 0) AS day4, | |||
| COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 5 DAY) THEN qtyNeeded END), 0) AS day5, | |||
| COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 6 DAY) THEN qtyNeeded END), 0) AS day6, | |||
| COALESCE(MAX(CASE WHEN produceDate = DATE_ADD(:fromDate, INTERVAL 7 DAY) THEN qtyNeeded END), 0) AS day7, | |||
| -- Total and net | |||
| COALESCE(SUM(qtyNeeded), 0) AS totalNeed7Days, | |||
| (onHandQty + purchasedQty - COALESCE(SUM(qtyNeeded), 0)) AS netAfter7Days | |||
| FROM daily_needs | |||
| GROUP BY | |||
| materialId, matCode, matName, uom, onHandQty, purchasedQty | |||
| ORDER BY netAfter7Days ASC, totalNeed7Days DESC; | |||
| """; | |||
| private fun asBigDecimal(value: Any?): BigDecimal = when (value) { | |||
| null -> BigDecimal.ZERO | |||
| is BigDecimal -> value | |||
| is Number -> BigDecimal.valueOf(value.toDouble()) | |||
| else -> BigDecimal(value.toString()) | |||
| } | |||
| return jdbcDao.queryForList(sql, args); | |||
| private fun asLong(value: Any?): Long? = when (value) { | |||
| null -> null | |||
| is Long -> value | |||
| is Number -> value.toLong() | |||
| else -> value.toString().toLongOrNull() | |||
| } | |||
| private fun toLocalDate(value: Any?): LocalDate? = when (value) { | |||
| null -> null | |||
| is LocalDate -> value | |||
| is java.sql.Date -> value.toLocalDate() | |||
| is Timestamp -> value.toLocalDateTime().toLocalDate() | |||
| else -> value.toString().take(10).let { runCatching { LocalDate.parse(it) }.getOrNull() } | |||
| } | |||
| @Transactional | |||
| @@ -0,0 +1,74 @@ | |||
| package com.ffii.fpsms.modules.master.service | |||
| import com.ffii.core.exception.BadRequestException | |||
| import com.ffii.fpsms.modules.master.entity.UomConversion | |||
| import java.math.BigDecimal | |||
| import java.math.RoundingMode | |||
| /** | |||
| * Standard recipe-unit conversion matrix (KG / L / LB / OZ / CATTY / G / ML). | |||
| * All units normalize via gram-equivalent factors derived from the business matrix. | |||
| */ | |||
| object StandardUomMatrix { | |||
| enum class MatrixUnit(val code: String) { | |||
| KG("KG"), | |||
| L("L"), | |||
| LB("LB"), | |||
| OZ("OZ"), | |||
| CATTY("CATTY"), | |||
| G("G"), | |||
| ML("ML"), | |||
| } | |||
| private val gramsPerUnit: Map<MatrixUnit, BigDecimal> = mapOf( | |||
| MatrixUnit.KG to BigDecimal("1000"), | |||
| MatrixUnit.L to BigDecimal("1000"), | |||
| MatrixUnit.LB to BigDecimal("453.592"), | |||
| MatrixUnit.OZ to BigDecimal("28.35"), | |||
| MatrixUnit.CATTY to BigDecimal("604.790"), | |||
| MatrixUnit.G to BigDecimal.ONE, | |||
| MatrixUnit.ML to BigDecimal.ONE, | |||
| ) | |||
| val STANDARD_CODES: List<String> = MatrixUnit.entries.map { it.code } | |||
| fun toMatrixUnit(uomCode: String?): MatrixUnit? { | |||
| val normalized = uomCode?.trim()?.uppercase() ?: return null | |||
| return when (normalized) { | |||
| "KG" -> MatrixUnit.KG | |||
| "L", "LITTER", "LITRE", "LITER" -> MatrixUnit.L | |||
| "LB" -> MatrixUnit.LB | |||
| "OZ" -> MatrixUnit.OZ | |||
| "CATTY" -> MatrixUnit.CATTY | |||
| "G", "G (BASE UNIT)" -> MatrixUnit.G | |||
| "ML" -> MatrixUnit.ML | |||
| else -> null | |||
| } | |||
| } | |||
| fun toMatrixUnit(uom: UomConversion?): MatrixUnit? = toMatrixUnit(uom?.code) | |||
| fun convert(qty: BigDecimal, from: MatrixUnit, to: MatrixUnit): BigDecimal { | |||
| if (from == to) return qty.setScale(2, RoundingMode.HALF_UP) | |||
| val fromGrams = gramsPerUnit[from] | |||
| ?: throw BadRequestException("Unsupported matrix unit: ${from.code}") | |||
| val toGrams = gramsPerUnit[to] | |||
| ?: throw BadRequestException("Unsupported matrix unit: ${to.code}") | |||
| return qty.multiply(fromGrams) | |||
| .divide(toGrams, 10, RoundingMode.HALF_UP) | |||
| .setScale(2, RoundingMode.HALF_UP) | |||
| } | |||
| fun convertByUom(qty: BigDecimal, fromUom: UomConversion, toUom: UomConversion): BigDecimal { | |||
| val from = toMatrixUnit(fromUom) | |||
| ?: throw BadRequestException( | |||
| "Recipe UOM is not in standard matrix: code=${fromUom.code}", | |||
| ) | |||
| val to = toMatrixUnit(toUom) | |||
| ?: throw BadRequestException( | |||
| "Item base UOM is not in standard matrix: code=${toUom.code}. " + | |||
| "Base must be one of ${STANDARD_CODES.joinToString(", ")}", | |||
| ) | |||
| return convert(qty, from, to) | |||
| } | |||
| } | |||
| @@ -3,7 +3,7 @@ package com.ffii.fpsms.modules.master.web | |||
| import com.ffii.fpsms.modules.master.entity.Bom | |||
| import com.ffii.fpsms.modules.master.entity.BomRepository | |||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||
| import com.ffii.fpsms.modules.master.entity.projections.BomCombo | |||
| import com.ffii.fpsms.modules.master.web.models.BomComboDto | |||
| import com.ffii.fpsms.modules.master.service.BomService | |||
| import org.springframework.core.io.ByteArrayResource | |||
| import org.springframework.core.io.Resource | |||
| @@ -26,6 +26,12 @@ import jakarta.servlet.http.Part | |||
| import com.ffii.fpsms.modules.master.web.models.BomFormatCheckResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomUploadResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomImportPreviewRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomImportPreviewResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomImportRevalidateRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomImportRevalidateResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomImportExportCorrectedRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomImportMaterialItemContext | |||
| import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | |||
| import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | |||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | |||
| @@ -33,6 +39,17 @@ import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | |||
| import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse | |||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse | |||
| import com.ffii.fpsms.modules.master.service.MasterDataIssueService | |||
| import com.ffii.fpsms.modules.master.service.BomUomAlignmentService | |||
| import com.ffii.fpsms.modules.master.web.models.BomMaterialQtyRecalculateRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomMaterialQtyRecalculateResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomHeaderOutputQtyRecalculateRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomHeaderOutputQtyRecalculateResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentApplyRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentApplyResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentPreviewRequest | |||
| import com.ffii.fpsms.modules.master.web.models.BomUomAlignmentPreviewResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomVersionSummary | |||
| import com.ffii.fpsms.modules.master.web.models.SaveBomAsNewVersionRequest | |||
| import com.ffii.core.exception.BadRequestException | |||
| import java.util.logging.Logger | |||
| import java.nio.file.Files | |||
| @@ -44,6 +61,7 @@ class BomController ( | |||
| val bomService: BomService, | |||
| private val bomRepository: BomRepository, | |||
| private val masterDataIssueService: MasterDataIssueService, | |||
| private val bomUomAlignmentService: BomUomAlignmentService, | |||
| ) { | |||
| private val log = LoggerFactory.getLogger(BomController::class.java) | |||
| @@ -95,6 +113,36 @@ fun downloadBomFormatIssueLog( | |||
| fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse { | |||
| return bomService.checkBomExcelFormat(request.batchId) | |||
| } | |||
| @PostMapping("/import-bom/preview") | |||
| fun previewImportBom(@RequestBody request: BomImportPreviewRequest): BomImportPreviewResponse { | |||
| return bomService.previewImportBom(request.batchId, request.fileNames) | |||
| } | |||
| @PostMapping("/import-bom/revalidate") | |||
| fun revalidateImportBomItem(@RequestBody request: BomImportRevalidateRequest): BomImportRevalidateResponse { | |||
| return bomService.revalidateImportBomItem(request.batchId, request.fileName, request.overrides) | |||
| } | |||
| @PostMapping("/import-bom/export-corrected") | |||
| fun exportCorrectedImportBomExcel(@RequestBody request: BomImportExportCorrectedRequest): ResponseEntity<Resource> { | |||
| // Do not put Chinese filename in Content-Disposition (Tomcat rejects non-Latin-1 headers). | |||
| // Frontend names the download from the original upload fileName (v.X bump). | |||
| val result = bomService.exportCorrectedImportBomExcel(request.batchId, request.fileName, request.overrides) | |||
| return ResponseEntity.ok() | |||
| .header(HttpHeaders.CONTENT_DISPOSITION, "attachment") | |||
| .header(HttpHeaders.CONTENT_TYPE, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") | |||
| .body(ByteArrayResource(result.bytes)) | |||
| } | |||
| @GetMapping("/import-bom/material-item-context") | |||
| fun importBomMaterialItemContext( | |||
| @RequestParam itemCode: String, | |||
| @RequestParam(required = false) currentUomCode: String?, | |||
| @RequestParam(required = false) currentUomId: Long?, | |||
| ): BomImportMaterialItemContext { | |||
| return bomService.resolveImportMaterialItemContext(itemCode, currentUomCode, currentUomId) | |||
| } | |||
| /* | |||
| @GetMapping("/import-bom/format-check/progress") | |||
| fun getBomFormatCheckProgress(@RequestParam batchId: String): ResponseEntity<BomExcelCheckProgress> { | |||
| @@ -111,13 +159,7 @@ fun downloadBomFormatIssueLog( | |||
| @GetMapping("/combo") | |||
| fun getCombo( | |||
| @RequestParam(defaultValue = "false") includeInactive: Boolean, | |||
| ): List<BomCombo> { | |||
| return if (includeInactive) { | |||
| bomRepository.findBomComboByDeletedIsFalse() | |||
| } else { | |||
| bomRepository.findBomComboByDeletedIsFalseAndStatus(BomStatus.ACTIVE) | |||
| } | |||
| } | |||
| ): List<BomComboDto> = bomService.listBomCombos(includeInactive) | |||
| @GetMapping("/combo/issues") | |||
| fun getComboIssues(): List<MasterDataIssueResponse> { | |||
| @@ -129,6 +171,30 @@ fun downloadBomFormatIssueLog( | |||
| return masterDataIssueService.findBomMasterDataIssues(); | |||
| } | |||
| @PostMapping("/master-data/uom-alignment/preview") | |||
| fun previewBomUomAlignment( | |||
| @RequestBody(required = false) request: BomUomAlignmentPreviewRequest?, | |||
| ): BomUomAlignmentPreviewResponse = | |||
| bomUomAlignmentService.previewAlignment(request ?: BomUomAlignmentPreviewRequest()) | |||
| @PostMapping("/master-data/uom-alignment/recalculate") | |||
| fun recalculateBomMaterialQty( | |||
| @RequestBody request: BomMaterialQtyRecalculateRequest, | |||
| ): BomMaterialQtyRecalculateResponse = | |||
| bomUomAlignmentService.recalculateMaterialQty(request) | |||
| @PostMapping("/master-data/uom-alignment/recalculate-header") | |||
| fun recalculateBomHeaderOutputQty( | |||
| @RequestBody request: BomHeaderOutputQtyRecalculateRequest, | |||
| ): BomHeaderOutputQtyRecalculateResponse = | |||
| bomUomAlignmentService.recalculateHeaderOutputQty(request) | |||
| @PostMapping("/master-data/uom-alignment/apply") | |||
| fun applyBomUomAlignment( | |||
| @RequestBody request: BomUomAlignmentApplyRequest, | |||
| ): BomUomAlignmentApplyResponse = | |||
| bomUomAlignmentService.applyAlignment(request) | |||
| @PostMapping("/import-bom") | |||
| fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | |||
| val reportResult = bomService.importBOM(payload.batchId, payload.items) | |||
| @@ -153,11 +219,28 @@ fun downloadBomFormatIssueLog( | |||
| return bomService.findBomSummaryByItemCode(code.trim()) | |||
| } | |||
| @GetMapping("/versions") | |||
| fun listBomVersions( | |||
| @RequestParam code: String, | |||
| @RequestParam bomKind: String, | |||
| ): List<BomVersionSummary> = bomService.listBomVersions(code, bomKind) | |||
| @PostMapping("/{id}/save-as-new-version") | |||
| fun saveBomAsNewVersion( | |||
| @PathVariable id: Long, | |||
| @RequestBody request: SaveBomAsNewVersionRequest, | |||
| ): BomDetailResponse = bomService.saveBomAsNewVersion(id, request) | |||
| @GetMapping("/{id}/detail") | |||
| fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | |||
| return bomService.getBomDetail(id) | |||
| } | |||
| @PostMapping("/{id}/activate-version") | |||
| fun activateBomVersion(@PathVariable id: Long): BomDetailResponse { | |||
| return bomService.activateBomVersion(id) | |||
| } | |||
| /** | |||
| * Edit BOM (basic fields + materials + process lines). | |||
| * baseScore is recalculated on server. | |||
| @@ -45,6 +45,21 @@ class ItemsController( | |||
| fun allConsumables(): List<Map<String, Any>> { | |||
| return itemsService.allConsumables() | |||
| } | |||
| @GetMapping("/bag-combo") | |||
| fun bagItemsCombo(): List<Map<String, String>> { | |||
| return itemsService.listBagItemsCombo() | |||
| } | |||
| @GetMapping("/exists-by-code") | |||
| fun itemExistsByCode(@RequestParam code: String): Map<String, Any?> { | |||
| return itemsService.itemExistsByCode(code) | |||
| } | |||
| @PostMapping("/exists-by-codes") | |||
| fun itemsExistsByCodes(@RequestBody codes: List<String>): List<Map<String, Any?>> { | |||
| return itemsService.itemsExistsByCodes(codes) | |||
| } | |||
| // @GetMapping("/getRecordByPage") | |||
| // fun getAllItemsByPage(@RequestBody filterRequest: HttpServletRequest): RecordsRes<Map<String, Any>> { | |||
| // val pageSize = filterRequest.getParameter("pageSize").toString().toInt(); // Default to 10 if not provided | |||
| @@ -0,0 +1,17 @@ | |||
| package com.ffii.fpsms.modules.master.web.models | |||
| import java.math.BigDecimal | |||
| /** BOM combo row with output qty/uom in stock unit for UI. */ | |||
| data class BomComboDto( | |||
| val id: Long, | |||
| val value: Long, | |||
| val code: String?, | |||
| val revisionNo: Int?, | |||
| val label: String, | |||
| val outputQty: BigDecimal, | |||
| val outputQtyUom: String?, | |||
| val bomKind: String?, | |||
| val description: String?, | |||
| val status: String?, | |||
| ) | |||
| @@ -0,0 +1,117 @@ | |||
| package com.ffii.fpsms.modules.master.web.models | |||
| import java.math.BigDecimal | |||
| data class BomImportPreviewRequest( | |||
| val batchId: String, | |||
| val fileNames: List<String>? = null, | |||
| ) | |||
| data class BomImportPreviewMaterialLine( | |||
| val itemCode: String? = null, | |||
| val itemName: String? = null, | |||
| val itemId: Long? = null, | |||
| val qty: BigDecimal? = null, | |||
| val uomCode: String? = null, | |||
| val uomId: Long? = null, | |||
| val salesUomId: Long? = null, | |||
| val stockUomId: Long? = null, | |||
| val baseUomId: Long? = null, | |||
| val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(), | |||
| val baseQty: BigDecimal? = null, | |||
| val baseUom: String? = null, | |||
| val stockQty: BigDecimal? = null, | |||
| val stockUom: String? = null, | |||
| val joinStepSeqNo: Int? = null, | |||
| ) | |||
| data class BomImportPreviewProcessLine( | |||
| val seqNo: Long? = null, | |||
| val processCode: String? = null, | |||
| val processName: String? = null, | |||
| val description: String? = null, | |||
| val byProduct: String? = null, | |||
| val byProductUom: String? = null, | |||
| val equipmentDescription: String? = null, | |||
| val equipmentName: String? = null, | |||
| val durationInMinute: Int? = null, | |||
| val prepTimeInMinute: Int? = null, | |||
| val postProdTimeInMinute: Int? = null, | |||
| ) | |||
| data class BomImportPreviewLine( | |||
| val fileName: String, | |||
| val code: String? = null, | |||
| val name: String? = null, | |||
| val bomKind: String? = null, | |||
| val outputQty: BigDecimal? = null, | |||
| val outputQtyUom: String? = null, | |||
| val isDark: Int? = null, | |||
| val isFloat: Int? = null, | |||
| val isDense: Int? = null, | |||
| val scrapRate: Int? = null, | |||
| val timeSequence: Int? = null, | |||
| val complexity: Int? = null, | |||
| val allergicSubstances: Int? = null, | |||
| val putawayLocationCode: String? = null, | |||
| val materialCount: Int = 0, | |||
| val processCount: Int = 0, | |||
| val materials: List<BomImportPreviewMaterialLine> = emptyList(), | |||
| val processes: List<BomImportPreviewProcessLine> = emptyList(), | |||
| ) | |||
| data class BomImportPreviewResponse( | |||
| val items: List<BomImportPreviewLine>, | |||
| ) | |||
| data class BomImportItemOverrides( | |||
| val code: String? = null, | |||
| val name: String? = null, | |||
| val bomKind: String? = null, | |||
| val outputQty: BigDecimal? = null, | |||
| val outputQtyUom: String? = null, | |||
| val isDark: Int? = null, | |||
| val isFloat: Int? = null, | |||
| val isDense: Int? = null, | |||
| val scrapRate: Int? = null, | |||
| val timeSequence: Int? = null, | |||
| val complexity: Int? = null, | |||
| val allergicSubstances: Int? = null, | |||
| val putawayLocationCode: String? = null, | |||
| val materials: List<BomImportPreviewMaterialLine>? = null, | |||
| val processes: List<BomImportPreviewProcessLine>? = null, | |||
| ) | |||
| data class BomImportRevalidateRequest( | |||
| val batchId: String, | |||
| val fileName: String, | |||
| val overrides: BomImportItemOverrides? = null, | |||
| ) | |||
| data class BomImportRevalidateResponse( | |||
| val passed: Boolean, | |||
| val problems: List<String> = emptyList(), | |||
| ) | |||
| data class BomImportExportCorrectedRequest( | |||
| val batchId: String, | |||
| val fileName: String, | |||
| val overrides: BomImportItemOverrides? = null, | |||
| ) | |||
| data class BomImportExportCorrectedResult( | |||
| val bytes: ByteArray, | |||
| val downloadFileName: String, | |||
| ) | |||
| /** Resolve item + recipe UOM options when user corrects material itemCode in import preview. */ | |||
| data class BomImportMaterialItemContext( | |||
| val exists: Boolean, | |||
| val itemCode: String, | |||
| val itemId: Long? = null, | |||
| val itemName: String? = null, | |||
| val salesUomId: Long? = null, | |||
| val stockUomId: Long? = null, | |||
| val baseUomId: Long? = null, | |||
| val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(), | |||
| ) | |||
| @@ -0,0 +1,153 @@ | |||
| package com.ffii.fpsms.modules.master.web.models | |||
| import java.math.BigDecimal | |||
| data class BomUomLabel( | |||
| val uomId: Long? = null, | |||
| val code: String? = null, | |||
| val udfudesc: String? = null, | |||
| val label: String? = null, | |||
| ) | |||
| data class BomUomQtyValue( | |||
| val qty: BigDecimal? = null, | |||
| val uom: BomUomLabel? = null, | |||
| ) | |||
| data class BomHeaderUomAlignmentPreview( | |||
| val bomId: Long, | |||
| val bomCode: String?, | |||
| val bomName: String?, | |||
| val itemId: Long? = null, | |||
| val canApply: Boolean = true, | |||
| val skipReason: String? = null, | |||
| val beforeOutputQty: BigDecimal? = null, | |||
| val afterOutputQty: BigDecimal? = null, | |||
| val beforeOutputQtyStock: BigDecimal? = null, | |||
| val afterOutputQtyStock: BigDecimal? = null, | |||
| /** Current BOM stored UOM (may differ from item base). */ | |||
| val beforeUom: BomUomLabel? = null, | |||
| /** Target base UOM after fix (same as [afterBaseUom]). */ | |||
| val afterUom: BomUomLabel? = null, | |||
| val beforeBaseUom: BomUomLabel? = null, | |||
| val afterBaseUom: BomUomLabel? = null, | |||
| val beforeStockUom: BomUomLabel? = null, | |||
| val afterStockUom: BomUomLabel? = null, | |||
| ) | |||
| data class BomMaterialUomAlignmentPreview( | |||
| val bomId: Long, | |||
| val bomCode: String?, | |||
| val bomName: String?, | |||
| val bomMaterialId: Long, | |||
| val itemId: Long?, | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val canApply: Boolean = true, | |||
| val skipReason: String? = null, | |||
| /** e.g. UOM_CONVERSION_ASSUMED_1_TO_1, BOM_SALE_QTY_NOT_SET, BOM_STOCK_QTY_NOT_SET, BOM_BASE_QTY_NOT_SET */ | |||
| val warnings: List<String> = emptyList(), | |||
| val beforeSaleQty: BomUomQtyValue? = null, | |||
| val afterSaleQty: BomUomQtyValue? = null, | |||
| val beforeStockQty: BomUomQtyValue? = null, | |||
| val afterStockQty: BomUomQtyValue? = null, | |||
| val beforeBaseQty: BomUomQtyValue? = null, | |||
| val afterBaseQty: BomUomQtyValue? = null, | |||
| val beforeRecipeQty: BomUomQtyValue? = null, | |||
| val afterRecipeQty: BomUomQtyValue? = null, | |||
| val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(), | |||
| val suggestedRecipeUomId: Long? = null, | |||
| ) | |||
| data class BomUomAlignmentSkipped( | |||
| val bomId: Long? = null, | |||
| val bomCode: String? = null, | |||
| val bomMaterialId: Long? = null, | |||
| val itemCode: String? = null, | |||
| val reason: String, | |||
| ) | |||
| data class BomUomAlignmentPreviewResponse( | |||
| val headers: List<BomHeaderUomAlignmentPreview> = emptyList(), | |||
| val materials: List<BomMaterialUomAlignmentPreview> = emptyList(), | |||
| val skipped: List<BomUomAlignmentSkipped> = emptyList(), | |||
| ) | |||
| data class BomUomAlignmentPreviewRequest( | |||
| val bomIds: List<Long>? = null, | |||
| val bomMaterialIds: List<Long>? = null, | |||
| ) | |||
| enum class BomMaterialQtyAnchor { | |||
| SALE_QTY, | |||
| STOCK_QTY, | |||
| BASE_QTY, | |||
| RECIPE_QTY, | |||
| } | |||
| data class BomMaterialQtyRecalculateRequest( | |||
| val itemId: Long, | |||
| val anchor: BomMaterialQtyAnchor, | |||
| val qty: BigDecimal, | |||
| val salesUomId: Long, | |||
| val stockUomId: Long, | |||
| val baseUomId: Long, | |||
| val recipeUomId: Long? = null, | |||
| ) | |||
| data class BomMaterialQtyRecalculateResponse( | |||
| val saleQty: BigDecimal? = null, | |||
| val stockQty: BigDecimal? = null, | |||
| val baseQty: BigDecimal? = null, | |||
| val saleUom: String? = null, | |||
| val stockUom: String? = null, | |||
| val baseUom: String? = null, | |||
| ) | |||
| data class BomHeaderOutputQtyRecalculateRequest( | |||
| val itemId: Long, | |||
| val stockQty: BigDecimal, | |||
| val stockUomId: Long, | |||
| ) | |||
| data class BomHeaderOutputQtyRecalculateResponse( | |||
| val stockQty: BigDecimal? = null, | |||
| val baseQty: BigDecimal? = null, | |||
| /** Target/base-unit UOM label for UI display & persistence. */ | |||
| val baseUom: String? = null, | |||
| val baseUomId: Long? = null, | |||
| ) | |||
| data class BomHeaderUomAlignmentApply( | |||
| val bomId: Long, | |||
| /** Base-unit qty (legacy); prefer [outputQtyStock] for UI stock input. */ | |||
| val outputQty: BigDecimal? = null, | |||
| /** Base UOM id for UOM-only alignment when no qty change. */ | |||
| val uomId: Long, | |||
| /** Stock-unit output qty from alignment UI. */ | |||
| val outputQtyStock: BigDecimal? = null, | |||
| val stockUomId: Long? = null, | |||
| ) | |||
| data class BomMaterialUomAlignmentApply( | |||
| val bomMaterialId: Long, | |||
| val saleQty: BigDecimal? = null, | |||
| val salesUomId: Long? = null, | |||
| val stockQty: BigDecimal? = null, | |||
| val stockUomId: Long? = null, | |||
| val baseQty: BigDecimal? = null, | |||
| val baseUomId: Long? = null, | |||
| val qty: BigDecimal? = null, | |||
| val uomId: Long? = null, | |||
| ) | |||
| data class BomUomAlignmentApplyRequest( | |||
| val headers: List<BomHeaderUomAlignmentApply> = emptyList(), | |||
| val materials: List<BomMaterialUomAlignmentApply> = emptyList(), | |||
| ) | |||
| data class BomUomAlignmentApplyResponse( | |||
| val headersUpdated: Int = 0, | |||
| val materialsUpdated: Int = 0, | |||
| val message: String? = null, | |||
| ) | |||
| @@ -0,0 +1,43 @@ | |||
| package com.ffii.fpsms.modules.master.web.models | |||
| import java.math.BigDecimal | |||
| data class BomVersionSummary( | |||
| val id: Long, | |||
| val revisionNo: Int, | |||
| val status: String, | |||
| val bomKind: String?, | |||
| val outputQty: BigDecimal?, | |||
| val outputQtyUom: String?, | |||
| ) | |||
| data class BomMaterialVersionEditLine( | |||
| val sourceBomMaterialId: Long? = null, | |||
| val itemCode: String, | |||
| val qty: BigDecimal, | |||
| val uomId: Long, | |||
| val isConsumable: Boolean? = null, | |||
| val bomProcessIds: List<Long> = emptyList(), | |||
| ) | |||
| data class BomProcessVersionEditLine( | |||
| val seqNo: Long? = null, | |||
| val processCode: String, | |||
| val description: String? = null, | |||
| val byProduct: String? = null, | |||
| val byProductUom: String? = null, | |||
| val equipmentDescription: String? = null, | |||
| val equipmentName: String? = null, | |||
| val equipmentCode: String? = null, | |||
| val durationInMinute: Int? = null, | |||
| val prepTimeInMinute: Int? = null, | |||
| val postProdTimeInMinute: Int? = null, | |||
| ) | |||
| data class SaveBomAsNewVersionRequest( | |||
| val materials: List<BomMaterialVersionEditLine>, | |||
| /** When non-null, set on new BOM revision (empty string clears). When null, copy from source. */ | |||
| val putawayLocationCode: String? = null, | |||
| /** When non-null and changed vs source, rebuild bom_process on the new revision. */ | |||
| val processes: List<BomProcessVersionEditLine>? = null, | |||
| ) | |||
| @@ -31,6 +31,7 @@ data class EditBomRequest( | |||
| val isDrink: Boolean? = null, | |||
| val isPowderMixture: Boolean? = null, | |||
| val status: String? = null, | |||
| val putawayLocationCode: String? = null, | |||
| // children | |||
| val materials: List<EditBomMaterialRequest>? = null, | |||
| @@ -66,6 +66,7 @@ data class ImportBomItemRequest( | |||
| val isAlsoWip: Boolean = false, | |||
| val isDrink: Boolean = false, | |||
| val isPowderMixture: Boolean = false, | |||
| val overrides: BomImportItemOverrides? = null, | |||
| ) | |||
| data class ImportBomRequestPayload( | |||
| @@ -73,27 +74,47 @@ data class ImportBomRequestPayload( | |||
| val items: List<ImportBomItemRequest> | |||
| ) | |||
| data class BomMaterialUomOption( | |||
| val uomId: Long, | |||
| val label: String, | |||
| val code: String? = null, | |||
| ) | |||
| data class BomMaterialDto( | |||
| val id: Long? = null, | |||
| val itemId: Long? = null, | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val isConsumable: Boolean?, | |||
| val recipeQty: BigDecimal? = null, | |||
| val recipeUom: String? = null, | |||
| val recipeUomId: Long? = null, | |||
| val salesUomId: Long? = null, | |||
| val stockUomId: Long? = null, | |||
| val baseUomId: Long? = null, | |||
| val availableRecipeUoms: List<BomMaterialUomOption> = emptyList(), | |||
| val baseQty: BigDecimal?, | |||
| val baseUom: String?, | |||
| val stockQty: BigDecimal?, | |||
| val stockUom: String?, | |||
| val salesQty: BigDecimal?, | |||
| val salesUom: String? | |||
| val salesUom: String?, | |||
| val processSteps: List<String> = emptyList(), | |||
| val processStepIds: List<Long> = emptyList(), | |||
| ) | |||
| data class BomProcessDto( | |||
| val id: Long? = null, | |||
| val seqNo: Long?, | |||
| val processCode: String?, | |||
| val processName: String?, | |||
| val processDescription: String?, | |||
| val byProduct: String? = null, | |||
| val byProductUom: String? = null, | |||
| // For display we prefer equipment.code (stable identifier). Keep equipmentName for backward compatibility. | |||
| val equipmentCode: String? = null, | |||
| val equipmentName: String? = null, | |||
| val equipmentDescription: String? = null, | |||
| val durationInMinute: Int?, | |||
| val prepTimeInMinute: Int?, | |||
| val postProdTimeInMinute: Int? | |||
| @@ -107,9 +128,10 @@ data class BomExcelCheckProgress( | |||
| ) | |||
| data class BomDetailResponse( | |||
| val id: Long, | |||
| val itemId: Long?, | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| val isDark: Boolean?, | |||
| val isDark: Int?, | |||
| val isFloat: Int?, | |||
| val isDense: Int?, | |||
| val isDrink: Boolean?, | |||
| @@ -120,9 +142,23 @@ data class BomDetailResponse( | |||
| val complexity: Int?, | |||
| val baseScore: Int?, | |||
| val description: String?, | |||
| val bomKind: String?, | |||
| val revisionNo: Int?, | |||
| /** Stored base qty on BOM header. */ | |||
| val outputQty: BigDecimal?, | |||
| /** Stored base qty UOM label on BOM header. */ | |||
| val outputQtyUom: String?, | |||
| /** Stock-facing output qty preview; null when conversion fails. */ | |||
| val outputQtyStock: BigDecimal? = null, | |||
| /** Stock unit label for output qty display/input. */ | |||
| val outputQtyStockUom: String? = null, | |||
| /** Stock unit id used by recalculate-header API. */ | |||
| val outputQtyStockUomId: Long? = null, | |||
| /** True when stock-facing output qty is convertible from stored base qty. */ | |||
| val outputQtyStockConvertible: Boolean = true, | |||
| val status: String?, | |||
| val putawayLocationCode: String? = null, | |||
| val itemLocationCode: String? = null, | |||
| val materials: List<BomMaterialDto>, | |||
| val processes: List<BomProcessDto> | |||
| ) | |||
| @@ -51,6 +51,7 @@ import com.ffii.fpsms.modules.stock.entity.SuggestPickLotRepository | |||
| import org.springframework.beans.factory.annotation.Value | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.BatchStockOutLineRequest | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| @Service | |||
| open class PickExecutionIssueService( | |||
| private val pickExecutionIssueRepository: PickExecutionIssueRepository, | |||
| @@ -60,6 +61,7 @@ open class PickExecutionIssueService( | |||
| private val suggestedPickLotService: SuggestedPickLotService, | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val jdbcDao: JdbcDao, | |||
| private val itemUomService: ItemUomService, | |||
| private val stockOutRepository: StockOutRepository, | |||
| private val stockOutLineService: StockOutLineService, | |||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||
| @@ -861,7 +863,7 @@ private fun handleMissItemWithPartialPick(request: PickExecutionIssueRequest, ac | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| if (inventoryLotLine != null) { | |||
| @@ -927,7 +929,7 @@ private fun handleMissItemOnly(request: PickExecutionIssueRequest, missQty: BigD | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(lotId).orElse(null) | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| if (inventoryLotLine != null) { | |||
| @@ -1129,7 +1131,7 @@ private fun handleBothMissAndBadItem(request: PickExecutionIssueRequest, missQty | |||
| val itemId = request.itemId ?: return | |||
| // ✅ 修复:在更新 lot line 之前获取 inventory 的 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| // ✅ 修复:更新 stock_out_line,但不要累积 qty | |||
| @@ -2308,7 +2310,7 @@ private fun updateLotLineAfterIssue(lotLineId: Long, qty: BigDecimal, isMissItem | |||
| private fun updateInventoryAfterLotLineChange(lotLine: InventoryLotLine) { | |||
| try { | |||
| val item = lotLine.inventoryLot?.item ?: return | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return | |||
| // 使用 SQL 查询计算所有相关 lot lines 的总和 | |||
| val sql = """ | |||
| @@ -2383,7 +2385,7 @@ private fun createStockLedgerForStockOut( | |||
| if (balance < 0) 0.0 else balance | |||
| } else { | |||
| // 重新查询 inventory 以获取触发器更新后的最新 onHandQty | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return | |||
| val currentOnHandQty = (inventory.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| val newBalance = currentOnHandQty - outQty | |||
| if (newBalance < 0) 0.0 else newBalance | |||
| @@ -2392,7 +2394,7 @@ private fun createStockLedgerForStockOut( | |||
| // 使用传入的 ledgerType,如果没有则使用 stockOutLine.type | |||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return | |||
| val stockLedger = StockLedger().apply { | |||
| this.stockOutLine = stockOutLine | |||
| @@ -2781,7 +2783,7 @@ open fun submitIssueWithQty(request: SubmitIssueWithQtyRequest): MessageResponse | |||
| } | |||
| // ✅ 修复:在创建和保存 stockOutLine 之前获取 inventory 的当前 onHandQty | |||
| val inventoryBeforeUpdate = inventoryRepository.findByItemId(request.itemId).orElse(null) | |||
| val inventoryBeforeUpdate = itemUomService.findInventoryForItemBaseUom(request.itemId) | |||
| val onHandQtyBeforeUpdate = (inventoryBeforeUpdate?.onHandQty ?: BigDecimal.ZERO).toDouble() | |||
| println("=== submitIssueWithQty: Before update ===") | |||
| @@ -2878,7 +2880,7 @@ private fun createStockLedgerForStockOutWithBalance( | |||
| // Use传入的 ledgerType,如果没有则使用 stockOutLine.type | |||
| val ledgerTypeToUse = ledgerType ?: stockOutLine.type ?: "Nor" | |||
| val inventory = inventoryRepository.findByItemId(item.id!!).orElse(null) ?: return | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(item.id!!) ?: return | |||
| val stockLedger = StockLedger().apply { | |||
| this.stockOutLine = stockOutLine | |||
| @@ -24,12 +24,12 @@ data class ProductProcessInfo( | |||
| val jobOrderStatus: String?, | |||
| val jobType: String?, | |||
| val bomDescription: String?, | |||
| val isDark: String?, | |||
| val isDark: Int?, | |||
| val isDense: Int?, | |||
| val isFloat: String?, | |||
| val isFloat: Int?, | |||
| val scrapRate: Int?, | |||
| //val matchStatus: String?, | |||
| val allergicSubstance: String?, | |||
| val allergicSubstance: Int?, | |||
| val itemId: Long?, | |||
| val itemCode: String?, | |||
| val itemName: String?, | |||
| @@ -30,6 +30,8 @@ import com.ffii.fpsms.modules.master.entity.Equipment | |||
| import com.ffii.fpsms.modules.master.entity.BomProcess | |||
| import com.ffii.fpsms.modules.master.entity.Bom | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.service.BomMaterialQtyService | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobOrderBomMaterialRepository | |||
| import com.ffii.fpsms.modules.master.service.ProductionScheduleService | |||
| @@ -44,6 +46,8 @@ import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | |||
| import com.ffii.fpsms.modules.productProcess.entity.ProductionProcessIssue | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JobTypeRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine | |||
| import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository | |||
| import java.time.ZoneOffset | |||
| import com.ffii.fpsms.modules.jobOrder.entity.JoPickOrderRepository | |||
| import com.ffii.fpsms.modules.jobOrder.enums.JoPickOrderStatus | |||
| @@ -77,11 +81,14 @@ open class ProductProcessService( | |||
| private val equipmentDetailRepository: EquipmentDetailRepository, | |||
| private val jobTypeRepository: JobTypeRepository, | |||
| private val pickOrderRepository: PickOrderRepository, | |||
| private val pickOrderLineRepository: PickOrderLineRepository, | |||
| private val joPickOrderRepository: JoPickOrderRepository, | |||
| private val itemUomRepository: ItemUomRespository, | |||
| private val uomConversionRepository: UomConversionRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val stockInLineRepository: StockInLineRepository | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomMaterialQtyService: BomMaterialQtyService, | |||
| ) { | |||
| open fun findAll(pageable: Pageable): Page<ProductProcess> { | |||
| @@ -604,34 +611,27 @@ open class ProductProcessService( | |||
| val (stockQty, _) = stockQtyMap[itemId] ?: (BigDecimal.ZERO to null) | |||
| stockQty.toInt() | |||
| } | |||
| // 获取 productionPriority | |||
| val itemId = jobOrder?.bom?.item?.id | |||
| val planEndDate = jobOrder?.planEnd?.toLocalDate() | |||
| fun calculateColourScore(value: Int?): String { | |||
| return when (value) { | |||
| 0 -> "淺" | |||
| 1 -> "深" | |||
| else -> "" | |||
| } | |||
| val isPlanning = jobOrder?.status == JobOrderStatus.PLANNING | |||
| val pickOrderLinesByItemId = if (!isPlanning) { | |||
| loadPickOrderLinesByItemId(joid) | |||
| } else { | |||
| emptyMap() | |||
| } | |||
| fun calculateAllergicSubstanceScore(value: Int?): String { | |||
| return when (value) { | |||
| 0 -> "No" | |||
| 5 -> "Yes" | |||
| else -> "N/A" | |||
| val jobOrderReqQty = jobOrder?.reqQty | |||
| val bomStockOutputQty = bomOutputQtyService.stockOutputQty(jobOrder?.bom) | |||
| val proportion = | |||
| if (jobOrderReqQty != null && bomStockOutputQty > BigDecimal.ZERO) { | |||
| jobOrderReqQty.divide(bomStockOutputQty, 5, RoundingMode.UP) | |||
| } else { | |||
| BigDecimal.ONE | |||
| } | |||
| } | |||
| fun calculateFloatScore(value: Int?): String { | |||
| return when (value) { | |||
| 0 -> "沉" | |||
| 1 -> "浮" | |||
| else -> "" | |||
| } | |||
| } | |||
| // 获取 productionPriority | |||
| val itemId = jobOrder?.bom?.item?.id | |||
| val planEndDate = jobOrder?.planEnd?.toLocalDate() | |||
| return productProcesses.map { process -> | |||
| @@ -647,19 +647,19 @@ open class ProductProcessService( | |||
| jobOrderCode = jobOrder?.code ?: "", | |||
| jobOrderStatus = jobOrder?.status?.value ?: "", | |||
| jobType = jobType?.name ?: "", | |||
| bomDescription = bom?.description ?: "", | |||
| bomDescription = bom?.bomKind ?: "", | |||
| itemId = bom?.item?.id ?: 0, | |||
| itemCode = bom?.item?.code ?: "", | |||
| itemName = bom?.item?.name ?: "", | |||
| timeSequence = bom?.timeSequence ?: 0, | |||
| complexity = bom?.complexity ?: 0, | |||
| bomBaseQty = bom.outputQty ?: BigDecimal.ZERO, | |||
| isDark = calculateColourScore(bom?.isDark ?: 0), | |||
| isDense = bom?.isDense ?: 0, | |||
| isFloat = calculateFloatScore(bom?.isFloat ?: 0), | |||
| isDark = bom?.isDark, | |||
| isDense = bom?.isDense, | |||
| isFloat = bom?.isFloat, | |||
| scrapRate = bom?.scrapRate ?: -1, | |||
| allergicSubstance = calculateAllergicSubstanceScore(bom?.allergicSubstances), | |||
| outputQtyUom = bom?.outputQtyUom ?: "", | |||
| allergicSubstance = bom?.allergicSubstances, | |||
| outputQtyUom = bomOutputQtyService.stockOutputUomLabel(bom) ?: "", | |||
| //matchStatus = joPickOrders?.matchStatus?.value?:"", | |||
| //outputQty = bom?.outputQty?.toInt()?:0, | |||
| outputQty = jobOrder?.reqQty?.toInt() ?: 0, | |||
| @@ -737,7 +737,9 @@ open class ProductProcessService( | |||
| // ✅ 获取 req UOM - 对于 reqQty,使用 bomMaterial.uom(BOM 的 UOM) | |||
| // ✅ 对于 stockReqQty,使用 line.uom(库存单位,已按比例调整) | |||
| val reqUomId = bomMaterial?.uom?.id ?: line.uom?.id ?: 0L // BOM 的 UOM | |||
| val stockReqUomId = line.uom?.id ?: bomMaterial?.stockUnit?.toLong() ?: 0L // 库存单位 UOM | |||
| val stockReqUomId = line.uom?.id | |||
| ?: bomMaterial?.let { bomMaterialQtyService.stockUomIdForJob(it) } | |||
| ?: 0L // 库存单位 UOM | |||
| val reqUom = reqUomId.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) } | |||
| val uomName = reqUom?.udfudesc | |||
| @@ -745,32 +747,45 @@ open class ProductProcessService( | |||
| val stockReqUom = | |||
| stockReqUomId.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) } | |||
| val stockUomName = stockReqUom?.udfudesc | |||
| var stockUomName = stockReqUom?.udfudesc | |||
| val stockUom = | |||
| stockUomId?.takeIf { it > 0 }?.let { uomConversionRepository.findByIdAndDeletedFalse(it) } | |||
| val stockUomNameForStock = stockUom?.udfudesc | |||
| println("=== Quantity Calculation for Item: ${line.item?.code} (id=$itemId) ===") | |||
| println("JobOrderBomMaterial reqQty (adjusted, stock unit): $actualReqQty in UOM: ${stockReqUom?.udfudesc} (id=$stockReqUomId)") | |||
| println("BomMaterial qty (base): ${bomMaterial?.qty}, stockQty (base): ${bomMaterial?.stockQty}") | |||
| println("Original stockQty: $stockQtyValue in UOM: $stockUomNameForStock (id=$stockUomId)") | |||
| val jobOrder = jobOrderRepository.findById(joid).orElse(null) | |||
| val jobOrderReqQty = jobOrder?.reqQty | |||
| val bomOutputQty = jobOrder?.bom?.outputQty | |||
| val proportion = | |||
| if (jobOrderReqQty != null && bomOutputQty != null && bomOutputQty > BigDecimal.ZERO) { | |||
| jobOrderReqQty.divide(bomOutputQty, 5, RoundingMode.UP) | |||
| // println("=== Quantity Calculation for Item: ${line.item?.code} (id=$itemId) ===") | |||
| // println("JobOrder status: ${jobOrder?.status?.value}, usePickOrderLine=${!isPlanning}") | |||
| // println("JobOrderBomMaterial reqQty (adjusted, stock unit): $actualReqQty in UOM: ${stockReqUom?.udfudesc} (id=$stockReqUomId)") | |||
| // println("BomMaterial qty (base): ${bomMaterial?.qty}, stockQty (base): ${bomMaterial?.stockQty}") | |||
| // println("Original stockQty: $stockQtyValue in UOM: $stockUomNameForStock (id=$stockUomId)") | |||
| val pickOrderLine = pickOrderLinesByItemId[itemId] | |||
| var effectiveStockReqUomId = stockReqUomId | |||
| val reqQtyInBomUnit: BigDecimal | |||
| val stockReqQtyInStockUnit: BigDecimal | |||
| if (pickOrderLine != null) { | |||
| val polQty = pickOrderLine.qty ?: BigDecimal.ZERO | |||
| val polUomId = pickOrderLine.uom?.id ?: 0L | |||
| stockReqQtyInStockUnit = polQty | |||
| effectiveStockReqUomId = polUomId.takeIf { it > 0 } ?: stockReqUomId | |||
| stockUomName = pickOrderLine.uom?.udfudesc | |||
| ?: effectiveStockReqUomId.takeIf { it > 0 } | |||
| ?.let { uomConversionRepository.findByIdAndDeletedFalse(it)?.udfudesc } | |||
| ?: stockUomName | |||
| reqQtyInBomUnit = if (polQty > BigDecimal.ZERO && polUomId > 0 && reqUomId > 0) { | |||
| convertQtyBetweenUomIds(itemId, polQty, polUomId, reqUomId) ?: polQty | |||
| } else { | |||
| BigDecimal.ONE | |||
| polQty | |||
| } | |||
| // ✅ reqQty 使用 bomMaterial.qty * proportion(BOM 单位) | |||
| val reqQtyInBomUnit = (bomMaterial?.qty?.times(proportion) ?: BigDecimal.ZERO) | |||
| // ✅ stockReqQty 使用 bomMaterial.stockQty * proportion(库存单位,已按比例调整) | |||
| val stockReqQtyInStockUnit = (bomMaterial?.stockQty?.times(proportion) ?: BigDecimal.ZERO) | |||
| // println("Using PickOrderLine qty=$polQty uomId=$polUomId -> reqQtyInBomUnit=$reqQtyInBomUnit") | |||
| } else { | |||
| reqQtyInBomUnit = bomMaterial?.qty?.times(proportion) ?: BigDecimal.ZERO | |||
| stockReqQtyInStockUnit = bomMaterial?.let { | |||
| bomMaterialQtyService.stockQtyForJob(it, proportion) | |||
| } ?: BigDecimal.ZERO | |||
| } | |||
| // ✅ Convert reqQty (BOM unit) to base unit | |||
| val baseReqQtyResult = if (reqUomId > 0 && reqQtyInBomUnit > BigDecimal.ZERO) { | |||
| @@ -840,13 +855,13 @@ open class ProductProcessService( | |||
| val baseStockShortUom = baseShortUom | |||
| // ✅ 计算 baseStockReqQty(stockReqQty 转换为 base unit) | |||
| val baseStockReqQtyResult = if (stockReqUomId > 0 && stockReqQtyInStockUnit > BigDecimal.ZERO) { | |||
| val baseStockReqQtyResult = if (effectiveStockReqUomId > 0 && stockReqQtyInStockUnit > BigDecimal.ZERO) { | |||
| try { | |||
| val result = itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = stockReqQtyInStockUnit, // 库存单位数量 | |||
| uomId = stockReqUomId, | |||
| qty = stockReqQtyInStockUnit, | |||
| uomId = effectiveStockReqUomId, | |||
| targetUnit = "baseUnit" | |||
| ) | |||
| ) | |||
| @@ -898,7 +913,7 @@ open class ProductProcessService( | |||
| stockQty = stockQty, | |||
| // ✅ stockReqQty:使用 bomMaterial.stockQty * proportion(库存单位,已按比例调整) | |||
| stockReqQty = if (stockReqUomId in decimalUomIds) { | |||
| stockReqQty = if (effectiveStockReqUomId in decimalUomIds) { | |||
| stockReqQtyInStockUnit.toDouble() | |||
| } else { | |||
| stockReqQtyInStockUnit.setScale(0, RoundingMode.UP).toDouble() | |||
| @@ -920,6 +935,48 @@ open class ProductProcessService( | |||
| } | |||
| } | |||
| private fun loadPickOrderLinesByItemId(jobOrderId: Long): Map<Long, PickOrderLine> { | |||
| val pickOrder = pickOrderRepository.findTopByJobOrder_IdOrderByCreatedDesc(jobOrderId) | |||
| ?: pickOrderRepository.findAllByJobOrder_Id(jobOrderId).firstOrNull() | |||
| ?: return emptyMap() | |||
| val pickOrderId = pickOrder.id ?: return emptyMap() | |||
| return pickOrderLineRepository.findAllByPickOrderIdAndDeletedFalse(pickOrderId) | |||
| .mapNotNull { line -> | |||
| val itemId = line.item?.id ?: return@mapNotNull null | |||
| itemId to line | |||
| } | |||
| .toMap() | |||
| } | |||
| private fun convertQtyBetweenUomIds( | |||
| itemId: Long, | |||
| qty: BigDecimal, | |||
| sourceUomId: Long, | |||
| targetUomId: Long, | |||
| ): BigDecimal? { | |||
| if (qty <= BigDecimal.ZERO || sourceUomId <= 0 || targetUomId <= 0) return null | |||
| if (sourceUomId == targetUomId) return qty | |||
| return try { | |||
| val baseResult = itemUomService.convertUomByItem( | |||
| ConvertUomByItemRequest( | |||
| itemId = itemId, | |||
| qty = qty, | |||
| uomId = sourceUomId, | |||
| targetUnit = "baseUnit", | |||
| ), | |||
| ) | |||
| val targetItemUom = itemUomRepository.findFirstByItemIdAndUomIdAndDeletedIsFalse(itemId, targetUomId) | |||
| ?: return null | |||
| val one = BigDecimal.ONE | |||
| val targetRatioN = targetItemUom.ratioN ?: one | |||
| val targetRatioD = targetItemUom.ratioD ?: one | |||
| baseResult.newQty.multiply(targetRatioD).divide(targetRatioN, 10, RoundingMode.HALF_UP) | |||
| } catch (e: Exception) { | |||
| println("convertQtyBetweenUomIds failed itemId=$itemId: ${e.message}") | |||
| null | |||
| } | |||
| } | |||
| open fun createProductProcessByJobOrderId(jobOrderId: Long, productionPriority: Int?): MessageResponse { | |||
| val jobOrder = jobOrderRepository.findById(jobOrderId).orElse(null) | |||
| val bom = bomRepository.findById(jobOrder?.bom?.id ?: 0L).orElse(null) | |||
| @@ -1459,7 +1516,7 @@ open class ProductProcessService( | |||
| assignedTo = pickOrder?.assignTo?.id, | |||
| itemName = productProcesses.item?.name, | |||
| itemCode = productProcesses.item?.code, | |||
| bomDescription = productProcesses.bom?.description, | |||
| bomDescription = productProcesses.bom?.bomKind, | |||
| pickOrderId = pickOrder?.id, | |||
| pickOrderStatus = pickOrder?.status?.value, | |||
| jobOrderId = productProcesses.jobOrder?.id, | |||
| @@ -1755,7 +1812,7 @@ open class ProductProcessService( | |||
| assignedTo = pickOrder?.assignTo?.id, | |||
| itemName = productProcess.item?.name, | |||
| itemCode = productProcess.item?.code, | |||
| bomDescription = productProcess.bom?.description, | |||
| bomDescription = productProcess.bom?.bomKind, | |||
| pickOrderId = pickOrder?.id, | |||
| pickOrderStatus = pickOrder?.status?.value, | |||
| jobOrderId = productProcess.jobOrder?.id, | |||
| @@ -2737,6 +2794,4 @@ open class ProductProcessService( | |||
| errorPosition = null, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -57,7 +57,7 @@ interface StockInLineInfo { | |||
| @get:Value("#{target.item?.type}") | |||
| val itemType: String? | |||
| val dnNo: String? | |||
| @get:Value("#{target.jobOrder?.bom?.description}") | |||
| @get:Value("#{target.jobOrder?.bom?.bomKind}") | |||
| val bomDescription: String? | |||
| @get:Value("#{target.escalationLog.^[status.value == 'pending']?.handler?.id}") | |||
| val handlerId: Long? | |||
| @@ -43,6 +43,7 @@ import com.ffii.fpsms.modules.stock.web.model.SameItemLotInfo | |||
| import com.ffii.fpsms.modules.stock.web.model.WorkbenchItemLotsResponse | |||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest | |||
| @@ -59,6 +60,7 @@ open class InventoryLotLineService( | |||
| private val itemUomRespository: ItemUomRespository, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val printerService: PrinterService, | |||
| @Lazy | |||
| private val jobOrderService: JobOrderService | |||
| @@ -228,7 +230,7 @@ open class InventoryLotLineService( | |||
| } | |||
| // Update the inventory table | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| if (inventory != null) { | |||
| inventory.onHoldQty = onHoldQty | |||
| inventory.unavailableQty = unavailableQty | |||
| @@ -31,7 +31,7 @@ import com.ffii.fpsms.modules.jobOrder.enums.JobOrderStatus | |||
| import com.ffii.fpsms.modules.master.entity.ItemUomRespository | |||
| import com.ffii.fpsms.modules.master.entity.WarehouseRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| //import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.service.BomOutputQtyService | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder | |||
| import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLine | |||
| @@ -111,7 +111,7 @@ open class StockInLineService( | |||
| @Value("\${scheduler.m18Grn.createEnabled:false}") private val m18GrnCreateEnabled: Boolean, | |||
| /** Recent window for PO stock-in alerts (pending / receiving) in nav / list UI. */ | |||
| @Value("\${fpsms.purchase-stock-in-alert.lookback-days:7}") private val purchaseStockInAlertLookbackDays: Int, | |||
| // private val bomOutputQtyService: BomOutputQtyService, | |||
| private val bomOutputQtyService: BomOutputQtyService, | |||
| ) : AbstractBaseEntityService<StockInLine, Long, StockInLineRepository>(jdbcDao, stockInLineRepository) { | |||
| private val logger = LoggerFactory.getLogger(StockInLineService::class.java) | |||
| @@ -425,8 +425,8 @@ open class StockInLineService( | |||
| } | |||
| // Set demandQty based on source | |||
| if (jo != null && jo?.bom != null) { | |||
| //this.demandQty = bomOutputQtyService.stockOutputQty(jo?.bom) | |||
| this.demandQty = jo?.bom?.outputQty | |||
| this.demandQty = bomOutputQtyService.stockOutputQty(jo?.bom) | |||
| } else if (pol != null && item.id != null) { | |||
| val m18UomId = pol.uomM18?.id | |||
| val qtyM18 = pol.qtyM18 | |||
| @@ -1003,8 +1003,8 @@ open class StockInLineService( | |||
| // Set demandQty based on source | |||
| if (this.jobOrder != null && this.jobOrder?.bom != null) { | |||
| // For job orders, demandQty comes from BOM's outputQty | |||
| //this.demandQty = this.jobOrder?.bom?.let { bomOutputQtyService.stockOutputQty(it) } ?: this.demandQty | |||
| this.demandQty = this.jobOrder?.bom?.outputQty ?: this.demandQty | |||
| this.demandQty = this.jobOrder?.bom?.let { bomOutputQtyService.stockOutputQty(it) } ?: this.demandQty | |||
| //this.demandQty = this.jobOrder?.bom?.outputQty ?: this.demandQty | |||
| } else if (this.purchaseOrderLine != null && this.item?.id != null) { | |||
| val itemId = this.item!!.id!! | |||
| val pol = this.purchaseOrderLine!! | |||
| @@ -1088,7 +1088,7 @@ open class StockInLineService( | |||
| if (putAwayStockQty >= requiredStockQty) { | |||
| val _tStatus = System.nanoTime() | |||
| stockInLine.apply { | |||
| val isWipJobOrder = stockInLine.jobOrder?.bom?.description == "WIP" | |||
| val isWipJobOrder = stockInLine.jobOrder?.bom?.bomKind == "WIP" | |||
| this.status = if (isWipJobOrder) { | |||
| StockInLineStatus.COMPLETE.status | |||
| } else { | |||
| @@ -266,7 +266,8 @@ fun handleQc(stockOutLine: StockOutLine, request: UpdateStockOutLineRequest): Li | |||
| } | |||
| inventoryLotLineRepository.save(targetLotLineEntry) | |||
| // update inventory | |||
| val inventory = inventoryRepository.findByItemId(request.itemId).orElseThrow() | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(request.itemId) | |||
| ?: throw IllegalArgumentException("Inventory not found for itemId=${request.itemId}") | |||
| val inventoryEntry = inventory.apply { | |||
| this.onHandQty = this.onHandQty!!.minus((request.qty.div(ratio)).toBigDecimal()) | |||
| this.onHoldQty = this.onHoldQty!!.minus((request.qty.div(ratio)).toBigDecimal()) | |||
| @@ -863,7 +864,7 @@ private fun updateInventoryTableAfterLotRejection(inventoryLotLine: InventoryLot | |||
| println("Calculated unavailableQty: $unavailableQty") | |||
| // Update the inventory table | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| if (inventory != null) { | |||
| println("Found inventory record: ${inventory.id}") | |||
| println("Current inventory - onHoldQty: ${inventory.onHoldQty}, unavailableQty: ${inventory.unavailableQty}") | |||
| @@ -956,7 +957,8 @@ open fun batchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| inventoryLotLineRepository.save(lotLine) | |||
| // 更新 inventory | |||
| val inv = inventoryRepository.findByItemId(lotLine.inventoryLot?.item?.id!!).orElseThrow() | |||
| val inv = itemUomService.findInventoryForItemBaseUom(lotLine.inventoryLot?.item?.id!!) | |||
| ?: throw IllegalStateException("Inventory not found for itemId=${lotLine.inventoryLot?.item?.id}") | |||
| inv.apply { | |||
| this.onHandQty = (this.onHandQty ?: zero) - baseQty | |||
| this.onHoldQty = (this.onHoldQty ?: zero) - baseQty | |||
| @@ -1273,7 +1275,7 @@ open fun newBatchSubmit(request: QrPickBatchSubmitRequest): MessageResponse { | |||
| try { | |||
| // 1) Bulk load lot lines(下方會用 lotLines 取批次/袋裝等) | |||
| // 注意:勿呼叫 inventoryRepository.findByItemId —— 若歷史資料同一 item 有多筆 inventory, | |||
| // 注意:勿呼叫 itemUomService.findInventoryForItemBaseUom —— 若歷史資料同一 item 有多筆 inventory, | |||
| // Spring Data 的 Optional 單筆查詢會拋 NonUniqueResultException(「unique result: 2」)。 | |||
| // 先前預載的 inventories 從未使用(save 已註解),已移除以避免批量提交無故失敗。 | |||
| val lotLineIds = request.lines.mapNotNull { it.inventoryLotLineId } | |||
| @@ -1089,7 +1089,7 @@ val basePickOrders = (listOf(pickOrder) + allCompetingPickOrders).distinctBy { i | |||
| remainingQty | |||
| } | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| if (inventory != null) { | |||
| inventory.onHoldQty = onHoldQty | |||
| inventory.unavailableQty = unavailableQty | |||
| @@ -1354,7 +1354,7 @@ private fun updateInventoryTableAfterResuggest(pickOrder: PickOrder) { | |||
| } | |||
| // Update the inventory table | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| if (inventory != null) { | |||
| inventory.onHoldQty = onHoldQty | |||
| inventory.unavailableQty = unavailableQty | |||
| @@ -1791,7 +1791,7 @@ private fun handleBrokenItem(inventoryLotLine: InventoryLotLine, failQty: BigDec | |||
| // 2. 更新库存表的不可用数量 | |||
| val itemId = inventoryLotLine.inventoryLot?.item?.id | |||
| if (itemId != null) { | |||
| val inventory = inventoryRepository.findByItemId(itemId).orElse(null) | |||
| val inventory = itemUomService.findInventoryForItemBaseUom(itemId) | |||
| if (inventory != null) { | |||
| val currentUnavailableQty = inventory.unavailableQty ?: BigDecimal.ZERO | |||
| inventory.unavailableQty = currentUnavailableQty.plus(failQty) | |||
| @@ -0,0 +1,28 @@ | |||
| --liquibase formatted sql | |||
| -- Add BOM versioning columns | |||
| --changeset Enson:20260617-01 | |||
| ALTER TABLE bom | |||
| ADD COLUMN bomKind VARCHAR(100) DEFAULT NULL, | |||
| ADD COLUMN revisionNo INT DEFAULT NULL; | |||
| -- Backfill bomKind from description for existing rows | |||
| --changeset Enson:20260617-02 | |||
| UPDATE bom | |||
| SET bomKind = description | |||
| WHERE bomKind IS NULL OR TRIM(bomKind) = ''; | |||
| -- Backfill revisionNo for existing rows | |||
| --changeset Enson:20260617-03 | |||
| UPDATE bom | |||
| SET revisionNo = 1 | |||
| WHERE revisionNo IS NULL OR revisionNo <= 0; | |||
| -- Drop duplicate bomKind column (data lives in description) | |||
| --changeset Enson:20260617-04 | |||
| ALTER TABLE bom DROP COLUMN bomKind; | |||
| -- Rename description -> bomKind (single FG/WIP column) | |||
| --changeset Enson:20260617-05 | |||
| ALTER TABLE bom CHANGE COLUMN description bomKind VARCHAR(100) NOT NULL; | |||
| @@ -0,0 +1,10 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset Enson:add_putaway_location_code_to_bom | |||
| ALTER TABLE `fpsmsdb`.`bom` | |||
| ADD COLUMN `putawayLocationCode` VARCHAR(255) NULL AFTER `status`; | |||
| UPDATE `fpsmsdb`.`bom` b | |||
| INNER JOIN `fpsmsdb`.`items` i ON i.id = b.itemId AND i.deleted = 0 | |||
| SET b.putawayLocationCode = NULLIF(TRIM(i.LocationCode), '') | |||
| WHERE b.deleted = 0; | |||
| @@ -0,0 +1,33 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset Enson:drop_bom_material_derived_qty_columns | |||
| -- preconditions onFail:MARK_RAN | |||
| -- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'bom_material' AND column_name = 'saleQty' | |||
| SET @fk_sales_unit := ( | |||
| SELECT CONSTRAINT_NAME | |||
| FROM information_schema.KEY_COLUMN_USAGE | |||
| WHERE TABLE_SCHEMA = DATABASE() | |||
| AND TABLE_NAME = 'bom_material' | |||
| AND COLUMN_NAME = 'salesUnitId' | |||
| AND REFERENCED_TABLE_NAME IS NOT NULL | |||
| LIMIT 1 | |||
| ); | |||
| SET @sql_drop_fk := IF( | |||
| @fk_sales_unit IS NOT NULL, | |||
| CONCAT('ALTER TABLE `bom_material` DROP FOREIGN KEY `', @fk_sales_unit, '`'), | |||
| 'SELECT 1' | |||
| ); | |||
| PREPARE stmt_drop_fk FROM @sql_drop_fk; | |||
| EXECUTE stmt_drop_fk; | |||
| DEALLOCATE PREPARE stmt_drop_fk; | |||
| ALTER TABLE `bom_material` | |||
| DROP COLUMN `saleQty`, | |||
| DROP COLUMN `salesUnitId`, | |||
| DROP COLUMN `salesUnitCode`, | |||
| DROP COLUMN `stockQty`, | |||
| DROP COLUMN `stockUnit`, | |||
| DROP COLUMN `stockUnitName`, | |||
| DROP COLUMN `baseQty`, | |||
| DROP COLUMN `baseUnit`, | |||
| DROP COLUMN `baseUnitName`; | |||
| @@ -0,0 +1,22 @@ | |||
| --liquibase formatted sql | |||
| -- Backfill NULL revisionNo for non-deleted BOM rows (per code + bomKind). | |||
| --changeset Enson:20260707-01 | |||
| UPDATE bom b | |||
| INNER JOIN ( | |||
| SELECT b2.id, | |||
| COALESCE(( | |||
| SELECT MAX(COALESCE(b3.revisionNo, 0)) | |||
| FROM bom b3 | |||
| WHERE b3.deleted = 0 | |||
| AND b3.code = b2.code | |||
| AND b3.bomKind = b2.bomKind | |||
| ), 0) + ROW_NUMBER() OVER ( | |||
| PARTITION BY b2.code, b2.bomKind | |||
| ORDER BY b2.created, b2.id | |||
| ) AS new_rev | |||
| FROM bom b2 | |||
| WHERE b2.deleted = 0 | |||
| AND (b2.revisionNo IS NULL OR b2.revisionNo <= 0) | |||
| ) ranked ON b.id = ranked.id | |||
| SET b.revisionNo = ranked.new_rev; | |||
| @@ -0,0 +1,16 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset codex:create_ps_query_indexes_20260708 | |||
| CREATE INDEX idx_bom_kind_status_deleted_itemid ON bom (bomKind, status, deleted, itemId); | |||
| -- delivery 平均值子查詢 | |||
| CREATE INDEX idx_dol_delivery_item ON delivery_order_line (deliveryOrderId, itemId); | |||
| CREATE INDEX idx_do_deleted_eta_id ON delivery_order (deleted, estimatedArrivalDate, id); | |||
| -- pending job 子查詢 | |||
| CREATE INDEX idx_job_order_bomid_status ON job_order (bomId, status); | |||
| --changeset codex:20260708-01 | |||
| -- 匯出排程:最新排程與 PO 預聚合 | |||
| CREATE INDEX idx_ps_produceat_id ON production_schedule (produceAt, id); | |||
| CREATE INDEX idx_po_complete_eta ON purchase_order (completeDate, estimatedArrivalDate); | |||
| CREATE INDEX idx_pol_item_po ON purchase_order_line (itemId, purchaseOrderId); | |||