diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt index 701760d..88c9425 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -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( diff --git a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt index 3f1c503..83b4ce5 100644 --- a/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt +++ b/src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt index 5752b71..98d510f 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoPickOrderService.kt @@ -1320,13 +1320,13 @@ open fun getJobOrderListForPrintQrCode(date: LocalDate): List { 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): 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 diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt index e8d5d96..dca0b3c 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/JobOrderService.kt @@ -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 { @@ -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 -> diff --git a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt index 120ab5a..24b09c5 100644 --- a/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt +++ b/src/main/java/com/ffii/fpsms/modules/jobOrder/service/PSService.kt @@ -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> { 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> { 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()) } /** - * 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> { + fun sumDeliveryOrderQtyByItemAndDate( + fromDate: LocalDate, + toDate: LocalDate, + bomItemCodes: Set? = null, + ): List> { 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>() 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 -> diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt index 3307651..2148c9a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/Bom.kt @@ -53,8 +53,11 @@ open class Bom : BaseEntity() { @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() { @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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt index 4fa0e57..395e77e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterial.kt @@ -37,27 +37,6 @@ open class BomMaterial : BaseEntity() { @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) diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt index 44c8a69..9b5d5d3 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt @@ -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 { 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 + + @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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt index 3b47146..066644b 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomProcess.kt @@ -15,8 +15,8 @@ open class BomProcess : BaseEntity() { @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() { @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 diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt index 8d5654e..f3d4c9f 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt @@ -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 { fun findAllByDeletedIsFalse(): List @@ -21,6 +22,16 @@ interface BomRepository : AbstractRepository { ) fun findAllActiveWithItemAndUom(): List + @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 + fun findByIdAndDeletedIsFalse(id: Serializable): Bom? fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? @@ -37,15 +48,62 @@ interface BomRepository : AbstractRepository { 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 @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): List -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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/EquipmentRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/EquipmentRepository.kt index ff005da..ee737e1 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/EquipmentRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/EquipmentRepository.kt @@ -15,4 +15,6 @@ interface EquipmentRepository : AbstractRepository { fun findByNameAndDeletedIsFalse(name: String): Equipment?; fun findByDescriptionAndDeletedIsFalse(description: String): Equipment?; + + fun findFirstByDescriptionAndNameAndDeletedFalse(description: String, name: String): Equipment?; } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt index da77c0c..4b581e2 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemsRepository.kt @@ -10,6 +10,10 @@ import java.util.Optional interface ItemsRepository : AbstractRepository { fun findAllByDeletedFalse(): List; + fun findAllByIsBagTrueAndDeletedFalse(): List; + + fun findAllByCodeInAndDeletedFalse(codes: Collection): List; + fun findByIdAndDeletedFalse(id: Long): Items?; fun findByCodeAndTypeAndDeletedFalse(code: String, type: String): Items?; diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt index 8f4bb24..40d3717 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/projections/BomCombo.kt @@ -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?; diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomMaterialQtyService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomMaterialQtyService.kt new file mode 100644 index 0000000..f9e2697 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomMaterialQtyService.kt @@ -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(), + ) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyMigrationRunner.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyMigrationRunner.kt new file mode 100644 index 0000000..c1e7211 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyMigrationRunner.kt @@ -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) + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyService.kt new file mode 100644 index 0000000..6fa454c --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyService.kt @@ -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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomProcessConstants.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomProcessConstants.kt new file mode 100644 index 0000000..6b2937a --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomProcessConstants.kt @@ -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 = "包裝" +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomRecipeUomOptionsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomRecipeUomOptionsService.kt new file mode 100644 index 0000000..5f3f8fb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomRecipeUomOptionsService.kt @@ -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 { + 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() + val options = mutableListOf() + + 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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomScaleValidation.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomScaleValidation.kt new file mode 100644 index 0000000..9ae10ff --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomScaleValidation.kt @@ -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("色深請設定數值,不可為不適用") + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 41ef63f..d990d36 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -68,6 +68,9 @@ open class BomService( private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, private val objectMapper: ObjectMapper, private val settingsService: SettingsService, + private val bomMaterialQtyService: BomMaterialQtyService, + private val bomOutputQtyService: BomOutputQtyService, + private val bomRecipeUomOptionsService: BomRecipeUomOptionsService, @Value("\${bom.import.temp-dir:\${java.io.tmpdir}/fpsms-bom-import}") private val bomImportTempDir: String, ) { companion object { @@ -140,12 +143,12 @@ open class BomService( open fun findByItemId(itemId: Long): Bom? { return bomRepository.findAllByItemIdAndDeletedIsFalse(itemId) - .minByOrNull { if (it.description == "FG") 0 else 1 } + .minByOrNull { if (it.bomKind == "FG") 0 else 1 } ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() } open fun findByItemIdAndStatus(itemId: Long, status: BomStatus): Bom? { return bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status) - .minByOrNull { if (it.description == "FG") 0 else 1 } + .minByOrNull { if (it.bomKind == "FG") 0 else 1 } ?: bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status).firstOrNull() } @@ -187,7 +190,7 @@ open class BomService( this.item = item code = request.code name = request.name - description = request.description + bomKind = request.description?.trim()?.takeIf { it.isNotEmpty() } ?: bomKind outputQty = request.outputQty outputQtyUom = request.outputQtyUom yield = request.yield @@ -201,7 +204,7 @@ open class BomService( id = it.id, code = it.code, name = it.name, - description = it.description, + description = it.bomKind, outputQty = it.outputQty, outputQtyUom = it.outputQtyUom, yield = it.yield, @@ -227,9 +230,22 @@ open class BomService( ?: throw NotFoundException() // ---- update basic/score fields (only when provided) ---- - request.description?.let { bom.description = it } + request.description?.let { bom.bomKind = it } + val updatingHeaderQty = request.outputQty != null || request.outputQtyUom != null request.outputQty?.let { bom.outputQty = it } request.outputQtyUom?.let { bom.outputQtyUom = it } + if (updatingHeaderQty) { + // BOM header stored output qty is always base-unit. + // Keep Bom.uom synced to the item's base UOM to make base->stock display consistent. + val itemId = bom.item?.id + if (itemId != null) { + val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom + if (baseUom != null) { + bom.uom = baseUom + bom.outputQtyUom = baseUom.udfudesc + } + } + } request.yield?.let { bom.yield = it } request.isDark?.let { bom.isDark = it } @@ -259,7 +275,11 @@ open class BomService( val replaceProcesses = request.processes != null if (!replaceMaterials && !replaceProcesses) { + if (hasUnresolvedMaterialUomIssue(bom)) { + throw BadRequestException("BOM has unresolved material UOM issue. Please fix material recipe UOM first.") + } // Only basic/score fields changed: just recalc. + BomScaleValidation.validateBomScaleFieldsForEdit(bom) bom.baseScore = calculateBaseScore(bom) bomRepository.saveAndFlush(bom) return getBomDetail(bom.id!!) @@ -332,21 +352,6 @@ open class BomService( this.uom = baseUom this.uomName = baseUnitName - // Derived UOMs for display/logic. - this.saleQty = salesQty - this.salesUnit = salesUom - this.salesUnitCode = salesUnitCode - - this.baseQty = baseQty - this.baseUnit = baseUom.id!!.toInt() - .let { Integer.valueOf(it) } as? Integer - this.baseUnitName = baseUnitName - - this.stockQty = stockQty - this.stockUnit = stockUom.id!!.toInt() - .let { Integer.valueOf(it) } as? Integer - this.stockUnitName = stockUnitName - this.bom = bom } bom.bomMaterials.add(bomMaterial) @@ -403,6 +408,7 @@ open class BomService( } // ---- persist children first (so ids exist) ---- + BomScaleValidation.validateBomScaleFieldsForEdit(bom) bomRepository.saveAndFlush(bom) // ---- rebuild BomProcessMaterial mappings when children changed ---- @@ -602,12 +608,12 @@ open class BomService( } //////// -------------------------------- for excel import ------------------------------- ///////// - private fun saveBomEntity(req: ImportBomRequest): Bom { + private fun saveBomEntity(req: ImportBomRequest, revisionNo: Int): Bom { val item = itemsRepository.findByCodeAndDeletedFalse(req.code) ?: itemsRepository.findByNameAndDeletedFalse(req.name) - val uom = if (req.uomId != null) uomConversionRepository.findById(req.uomId!!).orElseThrow() else null - val fgDescription = req.description.trim().ifEmpty { "FG" } - val bom = bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(req.code, fgDescription) ?: Bom() - bom.apply { + val bomKind = normalizeBomKindForImport(req.description.ifBlank { null }) + val bom = Bom().apply { + this.revisionNo = revisionNo + this.status = BomStatus.ACTIVE this.isDark = req.isDark this.isFloat = req.isFloat this.isDense = req.isDense @@ -616,16 +622,22 @@ open class BomService( this.timeSequence = req.timeSequence this.complexity = req.complexity this.allergicSubstances = req.allergicSubstances - this.name = req.name - this.description = req.description + // Prefer Items master name by code; Excel 產品名稱 is not authoritative. + this.name = item?.name?.takeIf { it.isNotBlank() } ?: req.name + this.bomKind = bomKind this.item = item - this.outputQty = req.outputQty - this.outputQtyUom = uom?.udfudesc this.yield = req.yield - this.uom = uom -// this.excelUom = req.excelUom + } + val outputQty = req.outputQty + val uomId = req.uomId + if (outputQty != null && uomId != null) { + bomOutputQtyService.applyStockQtyToBom(bom, outputQty, uomId) + } else if (outputQty != null) { + bom.outputQty = outputQty + bom.outputQtyUom = req.outputQtyUom } bom.baseScore = calculateBaseScore(bom) + BomScaleValidation.validateBomScaleFields(bom) val savedBom = bomRepository.saveAndFlush(bom) // println("saved: ${savedBom.id}") return savedBom @@ -642,295 +654,21 @@ open class BomService( val bomId = req.bom?.id val bomCode = req.bom?.code - // ===== 新逻辑:使用 Excel column 6 的 saleQty 和 column 7 的 sales unit ===== - val excelSaleQty = req.saleQty // Excel 第6栏的 saleQty - val excelSalesUnitCode = req.salesUnitCode // Excel 第7栏的 sales unit code(字符串) - val excelSalesUnit = req.salesUnit // Excel 第7栏查找的 uom_conversion(可能为 null) - - var saleQty: BigDecimal? = excelSaleQty - var saleUnitId: Long? = null - var saleUnitCode: String? = null - - var baseQty: BigDecimal? = null - var baseUnit: Integer? = null - var baseUnitName: String? = null - - var stockQty: BigDecimal? = null - var stockUnit: Integer? = null - var stockUnitName: String? = null - - if (item?.id != null) { - val itemId = item.id!! + bomMaterial.apply { + this.item = req.item + this.itemName = req.item?.name + this.isConsumable = req.isConsumable + this.qty = req.qty + this.uom = req.uom + this.uomName = req.uomName + this.bom = req.bom + } + val itemId = item?.id + val recipeQty = req.qty + val recipeUom = req.uom + if (itemId != null && recipeQty != null && recipeUom != null) { try { - // ---- 1) 获取 item 的真实 sale unit ---- - val saleItemUom = itemUomService.findSalesUnitByItemId(itemId) - val itemSaleUnit = saleItemUom?.uom - - // ---- 2) 确定 sale unit(来自 Excel column 7)---- - if (excelSalesUnit != null) { - // Excel 找到了 uom_conversion - saleUnitId = excelSalesUnit.id - saleUnitCode = excelSalesUnit.udfudesc - - // 检查 Excel sales unit 与 item sale unit 是否匹配 - if (itemSaleUnit != null && excelSalesUnit.id != itemSaleUnit.id) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Excel sales unit (${excelSalesUnit.code}/${excelSalesUnit.udfudesc}) does not match item sale unit (${itemSaleUnit.code}/${itemSaleUnit.udfudesc})", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - } - } else { - // Excel 没有找到 uom_conversion,使用 Excel 数据本身 - saleUnitCode = excelSalesUnitCode - - if (excelSalesUnitCode != null) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Excel sales unit code '$excelSalesUnitCode' not found in uom_conversion, using Excel data as-is", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnitCode - ) - ) - } - } - - // ---- 3) 从 saleQty(sale unit)转换为 baseQty(base unit)---- - if (excelSaleQty != null && excelSalesUnit != null && excelSalesUnit.id != null) { - try { - // 先检查 item 是否有 base unit - val baseItemUom = itemUomService.findBaseUnitByItemId(itemId) - if (baseItemUom == null) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to baseQty: item base unit not found", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - } else { - val baseResult = itemUomService.convertUomByItem( - ConvertUomByItemRequest( - itemId = itemId, - qty = excelSaleQty, - uomId = excelSalesUnit.id!!, - targetUnit = "baseUnit" - ) - ) - baseQty = baseResult.newQty - - // 获取 base unit 信息 - baseUnit = baseItemUom.uom?.id?.toInt()?.let { Integer.valueOf(it) } as? Integer - baseUnitName = baseItemUom.uom?.udfudesc - - // 验证转换结果 - if (baseQty == null) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to baseQty: conversion returned null", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - } - } - } catch (e: IllegalArgumentException) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to baseQty: ${e.message ?: "IllegalArgumentException"}", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - println("【BOM Import Warning】bomCode=$bomCode, item=${item.code} 转 baseQty 失败: ${e.message}") - } catch (e: Exception) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to baseQty: ${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - println("【BOM Import Error】bomCode=$bomCode, item=${item.code} 转 baseQty 失败: ${e.message}") - } - } else if (excelSaleQty != null && excelSalesUnit == null) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to baseQty: Excel sales unit not found", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnitCode - ) - ) - } - - // ---- 4) 从 saleQty(sale unit)转换为 stockQty(stock unit)---- - if (excelSaleQty != null && excelSalesUnit != null && excelSalesUnit.id != null) { - try { - // 先检查 item 是否有 stock unit - val stockItemUom = itemUomService.findStockUnitByItemId(itemId) - if (stockItemUom == null) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to stockQty: item stock unit not found", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - } else { - val stockResult = itemUomService.convertUomByItem( - ConvertUomByItemRequest( - itemId = itemId, - qty = excelSaleQty, - uomId = excelSalesUnit.id!!, - targetUnit = "stockUnit" - ) - ) - stockQty = stockResult.newQty - - // 获取 stock unit 信息 - stockUnit = stockItemUom.uom?.id?.toInt()?.let { Integer.valueOf(it) } as? Integer - stockUnitName = stockItemUom.uom?.udfudesc - - // 验证转换结果 - if (stockQty == null) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to stockQty: conversion returned null", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - } - } - } catch (e: IllegalArgumentException) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to stockQty: ${e.message ?: "IllegalArgumentException"}", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - println("【BOM Import Warning】bomCode=$bomCode, item=${item.code} 转 stockQty 失败: ${e.message}") - } catch (e: Exception) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to stockQty: ${e.javaClass.simpleName} - ${e.message ?: "Unknown error"}", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - println("【BOM Import Error】bomCode=$bomCode, item=${item.code} 转 stockQty 失败: ${e.message}") - } - } else if (excelSaleQty != null && excelSalesUnit == null) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to stockQty: Excel sales unit not found", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnitCode - ) - ) - } - - // 最终检查:如果 stockQty 仍然为 null,记录问题 - if (stockQty == null && excelSaleQty != null && excelSalesUnit != null && excelSalesUnit.id != null) { - // 检查是否已经记录过这个问题(避免重复) - val alreadyReported = bomMaterialImportIssues.any { issue -> - issue.itemId == item.id && - issue.reason.contains("stockQty", ignoreCase = true) && - issue.srcQty == excelSaleQty - } - if (!alreadyReported) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = "Cannot convert saleQty to stockQty: conversion failed (final check)", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnit.code - ) - ) - } - } - - } catch (e: IllegalArgumentException) { - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item.id, - itemCode = item.code, - itemName = item.name, - reason = e.message ?: "UOM conversion error", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnitCode - ) - ) - println("【BOM Import Warning】bomCode=$bomCode, item=${item.code} 转 UOM 失败: ${e.message}") + bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom) } catch (e: Exception) { bomMaterialImportIssues.add( BomMaterialImportIssue( @@ -939,54 +677,12 @@ open class BomService( itemId = item.id, itemCode = item.code, itemName = item.name, - reason = "Unknown error: ${e.message}", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnitCode - ) + reason = "Cannot derive material qty from recipe: ${e.message}", + srcQty = recipeQty, + srcUomCode = recipeUom.code, + ), ) - println("【BOM Import Error】bomCode=$bomCode, item=${item.code}: ${e.message}") } - } else { - // item 缺失 - bomMaterialImportIssues.add( - BomMaterialImportIssue( - bomId = bomId, - bomCode = bomCode, - itemId = item?.id, - itemCode = item?.code, - itemName = item?.name, - reason = "Missing item for conversion", - srcQty = excelSaleQty, - srcUomCode = excelSalesUnitCode - ) - ) - } - - // ===== 写回 BomMaterial ===== - bomMaterial.apply { - this.item = req.item - this.itemName = req.item?.name - this.isConsumable = req.isConsumable - this.qty = req.qty // BOM 原始用量(column 2,保留) - this.uom = req.uom // BOM 原始 UOM(column 3,保留) - this.uomName = req.uomName - - // 新逻辑:使用 Excel column 6 的 saleQty 和 column 7 的 sales unit - this.saleQty = saleQty - this.salesUnit = excelSalesUnit // 使用 Excel 的 sales unit,不是 item 的 stock unit - this.salesUnitCode = saleUnitCode - - // 从 sale unit 转换为 base unit - this.baseQty = baseQty - this.baseUnit = baseUnit - this.baseUnitName = baseUnitName - - // 从 sale unit 转换为 stock unit(新增) - this.stockQty = stockQty - this.stockUnit = stockUnit - this.stockUnitName = stockUnitName - - this.bom = req.bom } return bomMaterialRepository.saveAndFlush(bomMaterial) } @@ -1107,7 +803,7 @@ open class BomService( } } - private fun importExcelBomBasicInfo(sheet: Sheet): Bom { + private fun importExcelBomBasicInfo(sheet: Sheet, revisionNo: Int): Bom { var request = ImportBomRequest( code = "", name = "", @@ -1245,7 +941,7 @@ open class BomService( startRowIndex = 0 } } - return saveBomEntity(request) + return saveBomEntity(request, revisionNo) } private fun generateNextTMPEquipmentCode(): String { @@ -1622,46 +1318,124 @@ open class BomService( return null } - private fun normalizeFgDescriptionForImport(description: String?): String = - description?.trim()?.takeIf { it.isNotEmpty() } ?: "FG" + private fun normalizeBomKindForImport(raw: String?): String { + val normalized = raw?.trim()?.uppercase() + return if (normalized == "WIP") "WIP" else "FG" + } - /** Soft-delete FG (code + Excel 種類) and WIP (code + WIP) rows before re-import. */ - private fun softDeleteExistingBomsForImport(code: String, fgDescription: String) { - val fgDesc = normalizeFgDescriptionForImport(fgDescription) - bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, fgDesc)?.id?.let { - softDeleteBomAndRelated(it) - } - bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, BOM_WIP_DESCRIPTION)?.id?.let { - softDeleteBomAndRelated(it) - } + private fun resolveNextRevisionNoForImport(code: String?, bomKind: String): Int { + val normalizedCode = code?.trim().orEmpty() + if (normalizedCode.isEmpty()) return 1 + val normalizedKind = normalizeBomKindForImport(bomKind) + val maxRev = bomRepository.maxRevisionNoByCodeAndBomKind(normalizedCode, normalizedKind) + val versionCount = bomRepository.countByCodeAndBomKindIgnoreCaseAndDeletedIsFalse( + normalizedCode, + normalizedKind, + ) + return maxOf(maxRev, versionCount.toInt()) + 1 } - private fun findFgBomIdForImport(code: String, fgDescription: String): Long? { - val fgDesc = normalizeFgDescriptionForImport(fgDescription) - return bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, fgDesc)?.id + private fun markActiveVersionsInactiveForImport(code: String?, bomKind: String) { + val normalizedCode = code?.trim().orEmpty() + if (normalizedCode.isEmpty()) return + bomRepository.markActiveVersionsInactiveByCodeAndBomKind( + code = normalizedCode, + bomKind = bomKind, + active = BomStatus.ACTIVE, + inactive = BomStatus.INACTIVE, + ) } - private fun softDeleteBomAndRelated(bomId: Long) { - val bom = bomRepository.findById(bomId).orElse(null) ?: return - bom.deleted = true - bomRepository.saveAndFlush(bom) - bomMaterialRepository.findAllByBomIdAndDeletedIsFalse(bomId).forEach { m -> - m.deleted = true - bomMaterialRepository.saveAndFlush(m) + private fun readBomProductNameFromSheet(sheet: Sheet): String? { + val r5Cell = sheet.getRow(4)?.getCell(17) ?: return null + return when { + r5Cell.cellType == CellType.BLANK -> null + r5Cell.cellType == CellType.STRING -> r5Cell.stringCellValue.trim().takeIf { it.isNotEmpty() } + r5Cell.cellType == CellType.FORMULA && r5Cell.cachedFormulaResultType == CellType.STRING -> + r5Cell.stringCellValue.trim().takeIf { it.isNotEmpty() } + else -> null } - val processes = bomProcessRepository.findByBomId(bomId) - val processIds = processes.mapNotNull { it.id } - if (processIds.isNotEmpty()) { - bomProcessMaterialRepository.findByBomProcess_IdIn(processIds).forEach { pm -> - pm.deleted = true - bomProcessMaterialRepository.saveAndFlush(pm) + } + + private fun parseImportBomRequestFromSheetV2(sheet: Sheet): ImportBomRequest { + val request = ImportBomRequest(code = "", name = "", description = "") + var rowIdx = 0 + val endRow = 10 + var colIdx = 0 + val endCol = 9 + + while (rowIdx != endRow || colIdx != endCol) { + val cell = sheet.getRow(rowIdx)?.getCell(colIdx) + if (cell != null && cell.cellType == CellType.STRING) { + val header = cell.stringCellValue.trim() + val valueCell = sheet.getRow(rowIdx + 1)?.getCell(colIdx) + when (header) { + "編號" -> request.code = valueCell?.stringCellValue?.trim().orEmpty() + "產品名稱" -> request.name = readBomProductNameFromSheet(sheet).orEmpty() + "種類" -> request.description = valueCell?.stringCellValue?.trim().orEmpty() + "份量 (Qty)" -> request.outputQty = valueCell?.numericCellValue?.toBigDecimal() + "單位" -> { + val rawUomCode = valueCell?.stringCellValue?.trim().orEmpty() + request.outputQtyUom = rawUomCode + request.uomId = uomConversionRepository.findByCodeAndDeletedFalse(rawUomCode)?.id + } + } + + val rightCell = sheet.getRow(rowIdx)?.getCell(colIdx + 1) + fun numericValue(default: Int = 0): Int = + if (rightCell?.cellType == CellType.NUMERIC) rightCell.numericCellValue.toInt() else default + + when { + header.contains("深淺") -> request.isDark = numericValue(0) + header.contains("浮沉") -> request.isFloat = numericValue(0) + header.contains("濃淡") -> request.isDense = numericValue(0) + header.contains("損耗率") -> request.scrapRate = numericValue(0) + header.contains("生產時段先後數值") -> request.timeSequence = numericValue(0) + header.contains("複雜度") -> request.complexity = numericValue(0) + header.contains("過敏原 (如有)") -> { + val raw = when { + rightCell == null -> "" + rightCell.cellType == CellType.NUMERIC -> rightCell.numericCellValue.toInt().toString() + rightCell.cellType == CellType.STRING -> rightCell.stringCellValue.trim() + else -> "" + } + request.allergicSubstances = when (raw) { + "Yes", "1", "1.0" -> 5 + else -> 0 + } + } + } + } + + if (rowIdx < endRow) { + rowIdx++ + } else if (colIdx < endCol) { + colIdx++ + rowIdx = 0 } } - processes.forEach { p -> - p.deleted = true - bomProcessRepository.saveAndFlush(p) - } + request.description = normalizeBomKindForImport(request.description) + return request + } + + private fun findActiveBomIdForImport(code: String, bomKind: String): Long? { + val normalizedCode = code.trim() + val normalizedKind = normalizeBomKindForImport(bomKind) + return bomRepository.findByCodeAndBomKindIgnoreCaseAndStatusAndDeletedIsFalse( + normalizedCode, + normalizedKind, + BomStatus.ACTIVE, + )?.id } + + private fun deactivateExistingBomsForImport(code: String, fgDescription: String?) { + val normalizedCode = code.trim() + if (normalizedCode.isEmpty()) return + val bomKind = normalizeBomKindForImport(fgDescription) + markActiveVersionsInactiveForImport(normalizedCode, bomKind) + markActiveVersionsInactiveForImport(normalizedCode, BOM_WIP_DESCRIPTION) + } + private fun updateJobOrderAndProductProcessBomReference(oldBomId: Long, newBomId: Long) { val newBom = bomRepository.findById(newBomId).orElse(null) ?: return jobOrderRepository.findByBom_Id(oldBomId).forEach { jo -> @@ -1673,6 +1447,7 @@ open class BomService( productProcessRepository.saveAndFlush(pp) } } + @Transactional open fun importBOM(batchId: String, items: List): ByteArray { bomMaterialImportIssues.clear() val batchDir = getBatchDir(batchId) @@ -1687,9 +1462,11 @@ open class BomService( .filter { itemFileNames.contains(it.fileName.toString()) } .forEach { path -> val filename = path.fileName.toString() - val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true - val isDrink = items.find { it.fileName == filename }?.isDrink == true - val isPowderMixture = items.find { it.fileName == filename }?.isPowderMixture == true + val itemReq = items.find { it.fileName == filename } + val isAlsoWip = itemReq?.isAlsoWip == true + val isDrink = itemReq?.isDrink == true + val isPowderMixture = itemReq?.isPowderMixture == true + val overrides = itemReq?.overrides println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip, isDrink=$isDrink, isPowderMixture=$isPowderMixture") try { FileInputStream(path.toFile()).use { input -> @@ -1697,14 +1474,32 @@ open class BomService( val sheet = workbook2.getSheet("食物成品 ") ?: workbook2.getSheet("食物成品") ?: workbook2.getSheetAt(0) + if (overrides != null) { + applyImportOverridesToSheet(sheet, overrides) + } val code = readBomCodeFromSheet(sheet) val fgDescription = readBomDescriptionFromSheet(sheet) + val bomKind = normalizeBomKindForImport(fgDescription) var oldBomId: Long? = null code?.let { c -> - oldBomId = findFgBomIdForImport(c, fgDescription ?: "FG") - softDeleteExistingBomsForImport(c, fgDescription ?: "FG") + oldBomId = findActiveBomIdForImport(c, bomKind) + deactivateExistingBomsForImport(c, fgDescription) + } + val revisionNo = resolveNextRevisionNoForImport(code, bomKind) + val bom = importExcelBomBasicInfo(sheet, revisionNo) + if (bom.putawayLocationCode.isNullOrBlank()) { + bom.putawayLocationCode = + bom.item?.LocationCode?.trim()?.takeIf { it.isNotEmpty() } + } + overrides?.putawayLocationCode?.trim()?.let { locationCode -> + if (locationCode.isNotEmpty()) { + bom.putawayLocationCode = locationCode + bom.item?.let { item -> + item.LocationCode = locationCode + itemsRepository.saveAndFlush(item) + } + } } - val bom = importExcelBomBasicInfo(sheet) bom.isDrink = isDrink bom.type = when { isDrink -> "Drink" @@ -1741,14 +1536,14 @@ open class BomService( ?: itemsRepository.saveAndFlush(Items().apply { code = wipCode name = fgBom.name - description = fgBom.description + description = fgBom.name type = ItemType.SFG.type m18LastModifyDate = LocalDateTime.now() }) val wipBom = Bom().apply { code = wipCode name = fgBom.name - description = fgBom.description + bomKind = fgBom.bomKind item = wipItem outputQty = fgBom.outputQty outputQtyUom = fgBom.outputQtyUom @@ -1770,14 +1565,14 @@ open class BomService( /** 方案 A:複製 FG BOM 為一筆相同 code、相同 item、description=WIP 的 BOM,並複製 materials 與 processes。 */ private fun createWipCopyFromFgBom(fgBom: Bom) { val code = fgBom.code ?: return - val existingWip = bomRepository.findByCodeAndDescriptionIgnoreCaseAndDeletedIsFalse(code, BOM_WIP_DESCRIPTION) - if (existingWip != null) { - softDeleteBomAndRelated(existingWip.id!!) - } + markActiveVersionsInactiveForImport(code, BOM_WIP_DESCRIPTION) + val wipRevisionNo = resolveNextRevisionNoForImport(code, BOM_WIP_DESCRIPTION) val wipBom = Bom().apply { this.code = code name = fgBom.name - description = BOM_WIP_DESCRIPTION + bomKind = BOM_WIP_DESCRIPTION + revisionNo = wipRevisionNo + status = BomStatus.ACTIVE item = fgBom.item outputQty = fgBom.outputQty outputQtyUom = fgBom.outputQtyUom @@ -1807,15 +1602,6 @@ open class BomService( qty = m.qty uom = m.uom uomName = m.uomName - saleQty = m.saleQty - salesUnit = m.salesUnit - salesUnitCode = m.salesUnitCode - baseQty = m.baseQty - baseUnit = m.baseUnit - baseUnitName = m.baseUnitName - stockQty = m.stockQty - stockUnit = m.stockUnit - stockUnitName = m.stockUnitName bom = wipBom m18Id = m.m18Id m18LastModifyDate = m.m18LastModifyDate ?: LocalDateTime.now() @@ -1862,7 +1648,7 @@ open class BomService( println("Total issues: ${bomMaterialImportIssues.size}") if (bomMaterialImportIssues.isEmpty()) { println("No issues.") - println("========================================================\n") + //println("========================================================\n") return } @@ -1889,7 +1675,7 @@ open class BomService( ) } - println("========================================================\n") + //println("========================================================\n") } fun loadBomFormatIssueLog(batchId: String, issueLogFileId: String): Path? { val dir = getBatchDir(batchId) @@ -1977,73 +1763,774 @@ open class BomService( issueLogFileId = issueLogId ) } - private fun isValidEquipmentType(value: String): Boolean { - val trimmed = value.trim() - if (trimmed.isEmpty()) return false - if (trimmed == "不合用" || trimmed == "不適用") return true - if (trimmed.contains(",")) return false // 新增:不允許逗號 - val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 - return regex.matches(trimmed) + + open fun previewImportBom(batchId: String, fileNames: List?): BomImportPreviewResponse { + val batchDir = getBatchDir(batchId) + if (!Files.isDirectory(batchDir)) { + return BomImportPreviewResponse(items = emptyList()) + } + val targetNames = fileNames?.map { it.trim() }?.filter { it.isNotEmpty() }?.toSet() + val lines = mutableListOf() + Files.list(batchDir).use { stream -> + stream + .filter { p -> p.toString().lowercase().endsWith(".xlsx") } + .filter { p -> targetNames == null || targetNames.contains(p.fileName.toString()) } + .sorted(Comparator.comparing { it.fileName.toString() }) + .forEach { path -> + val fileName = path.fileName.toString() + try { + FileInputStream(path.toFile()).use { input -> + val workbook: Workbook = XSSFWorkbook(input) + val sheet = resolveImportBomSheet(workbook) + val parsed = parseImportBomRequestFromSheetV2(sheet) + val itemByCode = parsed.code + .takeIf { it.isNotBlank() } + ?.let { code -> itemsRepository.findByCodeAndDeletedFalse(code) } + val materials = parseImportMaterialsFromSheet(sheet) + val processes = parseImportProcessesFromSheet(sheet) + lines += BomImportPreviewLine( + fileName = fileName, + code = parsed.code.takeIf { it.isNotBlank() }, + // Resolve display name from Items by code; do not rely on Excel 產品名稱. + name = itemByCode?.name?.takeIf { it.isNotBlank() } + ?: parsed.name.takeIf { it.isNotBlank() }, + bomKind = parsed.description.takeIf { it.isNotBlank() }, + outputQty = parsed.outputQty, + outputQtyUom = parsed.outputQtyUom, + isDark = parsed.isDark, + isFloat = parsed.isFloat, + isDense = parsed.isDense, + scrapRate = parsed.scrapRate, + timeSequence = parsed.timeSequence, + complexity = parsed.complexity, + allergicSubstances = parsed.allergicSubstances, + putawayLocationCode = itemByCode?.LocationCode, + materialCount = materials.size, + processCount = processes.size, + materials = materials, + processes = processes, + ) + workbook.close() + } + } catch (e: Exception) { + lines += BomImportPreviewLine( + fileName = fileName, + name = "解析失敗: ${e.message ?: e.javaClass.simpleName}", + ) + } + } + } + return BomImportPreviewResponse(items = lines) } - /** Cell 是否為非空字串(含 STRING / FORMULA 結果為字串) */ - private fun isNonEmptyStringCell(cell: org.apache.poi.ss.usermodel.Cell?): Boolean { - if (cell == null || cell.cellType == CellType.BLANK) return false - return when (cell.cellType) { - CellType.STRING -> cell.stringCellValue.trim().isNotEmpty() - CellType.FORMULA -> when (cell.cachedFormulaResultType) { - CellType.STRING -> cell.stringCellValue.trim().isNotEmpty() - else -> false + open fun revalidateImportBomItem( + batchId: String, + fileName: String, + overrides: BomImportItemOverrides?, + ): BomImportRevalidateResponse { + val trimmedName = fileName.trim() + if (trimmedName.isEmpty()) { + throw BadRequestException("fileName is required") + } + val problems = collectImportFormatIssuesForFile(batchId, trimmedName, overrides) + return BomImportRevalidateResponse(passed = problems.isEmpty(), problems = problems) + } + + open fun resolveImportMaterialItemContext( + itemCode: String, + currentUomCode: String?, + currentUomId: Long?, + ): BomImportMaterialItemContext { + val code = itemCode.trim() + if (code.isEmpty()) { + return BomImportMaterialItemContext(exists = false, itemCode = code) + } + val item = itemsRepository.findByCodeAndDeletedFalse(code) + ?: return BomImportMaterialItemContext(exists = false, itemCode = code) + val itemId = item.id + ?: return BomImportMaterialItemContext( + exists = true, + itemCode = item.code ?: code, + itemName = item.name, + ) + val resolvedCurrentUomId = currentUomId + ?: currentUomCode?.trim()?.takeIf { it.isNotEmpty() } + ?.let { uomConversionRepository.findByCodeAndDeletedFalse(it)?.id } + val availableRecipeUoms = bomRecipeUomOptionsService.buildAvailableRecipeUoms( + itemId, + resolvedCurrentUomId, + ) + return BomImportMaterialItemContext( + exists = true, + itemCode = item.code ?: code, + itemId = itemId, + itemName = item.name, + salesUomId = itemUomService.findSalesUnitByItemId(itemId)?.uom?.id, + stockUomId = itemUomService.findStockUnitByItemId(itemId)?.uom?.id, + baseUomId = itemUomService.findBaseUnitByItemId(itemId)?.uom?.id, + availableRecipeUoms = availableRecipeUoms, + ) + } + + open fun exportCorrectedImportBomExcel( + batchId: String, + fileName: String, + overrides: BomImportItemOverrides?, + ): BomImportExportCorrectedResult { + val trimmedName = fileName.trim() + if (trimmedName.isEmpty()) { + throw BadRequestException("fileName is required") + } + val path = resolveImportBomFilePath(batchId, trimmedName) + ?: throw BadRequestException("File not found: $trimmedName") + FileInputStream(path.toFile()).use { input -> + val workbook: Workbook = XSSFWorkbook(input) + try { + val sheet = resolveImportBomSheet(workbook) + if (overrides != null) { + applyImportOverridesToSheet(sheet, overrides) + } + val newVersionLabel = bumpImportExcelVersion(sheet) + clearImportExcelSignOffFields(sheet) + recalculateMaterialDerivedColumns(sheet) + clearProductRecipeMaterialCells(workbook) + val bytes = ByteArrayOutputStream().use { out -> + workbook.write(out) + out.toByteArray() + } + return BomImportExportCorrectedResult( + bytes = bytes, + downloadFileName = buildBumpedVersionFileName(trimmedName, newVersionLabel), + ) + } finally { + workbook.close() } - else -> false } } - private fun validateProcessLikeImport( - sheet: Sheet, + + private fun resolveImportBomFilePath(batchId: String, fileName: String): Path? { + val path = getBatchDir(batchId).resolve(fileName) + return if (Files.exists(path)) path else null + } + + private fun resolveImportBomSheet(workbook: Workbook): Sheet = + workbook.getSheet("食物成品 ") + ?: workbook.getSheet("食物成品") + ?: workbook.getSheetAt(0) + + private fun collectImportFormatIssuesForFile( + batchId: String, fileName: String, - issues: MutableList - ) { - val byProductItemCache = mutableMapOf() - val uomCache = mutableMapOf() - val itemStockUomCache = mutableMapOf() - var startRowIndex = 30 - var endRowIndex = 70 - var startColumnIndex = 0 - val endColumnIndex = 11 - - // 1) 找到 "工序" header - var headerFound = false - while (startRowIndex < endRowIndex) { - val tempRow = sheet.getRow(startRowIndex) - val tempCell = tempRow?.getCell(startColumnIndex) - if (tempCell != null && - tempCell.cellType == CellType.STRING && - tempCell.stringCellValue.trim() == "工序" - ) { - headerFound = true - startRowIndex += 2 - break + overrides: BomImportItemOverrides?, + ): List { + val path = resolveImportBomFilePath(batchId, fileName) + ?: throw BadRequestException("File not found: $fileName") + val issues = mutableListOf() + FileInputStream(path.toFile()).use { input -> + val workbook: Workbook = XSSFWorkbook(input) + try { + val sheet = resolveImportBomSheet(workbook) + if (overrides != null) { + applyImportOverridesToSheet(sheet, overrides) + } + validateBasicInfoSection(fileName, sheet, issues) + validateProcessLikeImport(sheet, fileName, issues) + validateMaterialLikeImport(sheet, fileName, issues) + } finally { + workbook.close() } - startRowIndex++ } - - if (!headerFound) { - issues += BomFormatIssue(fileName, "工序區:找不到『工序』表頭") - return + return issues.map { it.problem }.distinct().sorted() + } + + private fun readStringCellValue(cell: org.apache.poi.ss.usermodel.Cell?): String? { + if (cell == null || cell.cellType == CellType.BLANK) return null + return when (cell.cellType) { + CellType.STRING -> cell.stringCellValue.trim().takeIf { it.isNotEmpty() } + CellType.NUMERIC -> cell.numericCellValue.toString() + CellType.FORMULA -> when (cell.cachedFormulaResultType) { + CellType.STRING -> cell.stringCellValue.trim().takeIf { it.isNotEmpty() } + CellType.NUMERIC -> cell.numericCellValue.toString() + else -> null + } + else -> null } - - var searchRowIndex = startRowIndex - val maxSearchRow = 50 - while (searchRowIndex < maxSearchRow) { - val tempRow = sheet.getRow(searchRowIndex) - val tempCell = tempRow?.getCell(0) - if (tempCell == null || tempCell.cellType == CellType.BLANK) { - endRowIndex = searchRowIndex - break + } + + private fun getOrCreateCell(sheet: Sheet, rowIdx: Int, colIdx: Int): org.apache.poi.ss.usermodel.Cell { + val row = sheet.getRow(rowIdx) ?: sheet.createRow(rowIdx) + return row.getCell(colIdx) ?: row.createCell(colIdx) + } + + private fun applyImportOverridesToSheet(sheet: Sheet, overrides: BomImportItemOverrides) { + overrides.code?.trim()?.takeIf { it.isNotEmpty() }?.let { + setBasicInfoStringByHeader(sheet, "編號", it) + } + overrides.name?.trim()?.takeIf { it.isNotEmpty() }?.let { + val row = sheet.getRow(4) ?: sheet.createRow(4) + val cell = row.getCell(17) ?: row.createCell(17) + cell.setCellValue(it) + } + overrides.bomKind?.trim()?.takeIf { it.isNotEmpty() }?.let { + setBasicInfoStringByHeader(sheet, "種類", it) + } + overrides.outputQty?.let { + setBasicInfoNumericByHeader(sheet, "份量 (Qty)", it.toDouble()) + } + // W5 is the source for 單位 (G4/AD7/AC49 are formulas =W5). + // Must refresh formula caches — validateBasicInfo reads G4 cached result, not W5. + overrides.outputQtyUom?.trim()?.takeIf { it.isNotEmpty() }?.let { uom -> + getOrCreateCell(sheet, 4, 22).setCellValue(uom) + refreshOutputUomFormulaDependents(sheet) + } + overrides.isDark?.let { setBasicInfoScaleByHeaderContains(sheet, "深淺", it) } + overrides.isFloat?.let { setBasicInfoScaleByHeaderContains(sheet, "浮沉", it) } + overrides.isDense?.let { setBasicInfoScaleByHeaderContains(sheet, "濃淡", it) } + overrides.scrapRate?.let { setBasicInfoScaleByHeaderContains(sheet, "損耗率", it) } + overrides.timeSequence?.let { setBasicInfoScaleByHeaderContains(sheet, "生產時段先後數值", it) } + overrides.complexity?.let { setBasicInfoScaleByHeaderContains(sheet, "複雜度", it) } + overrides.allergicSubstances?.let { value -> setBasicInfoAllergicSubstances(sheet, value) } + overrides.materials?.takeIf { it.isNotEmpty() }?.let { applyMaterialOverridesToSheet(sheet, it) } + overrides.processes?.takeIf { it.isNotEmpty() }?.let { applyProcessOverridesToSheet(sheet, it) } + } + + /** + * Re-evaluate W5 dependents so POI cached reads match the new source. + * Chain: W5 → G4/AD7/AC49 → AD51 → Selling Price!B7 → E5 + */ + private fun refreshOutputUomFormulaDependents(sheet: Sheet) { + val workbook = sheet.workbook + val evaluator = workbook.creationHelper.createFormulaEvaluator() + // Order matters: evaluate upstream refs before downstream. + val foodSheetCells = listOf( + sheet.getRow(3)?.getCell(6), // G4 =W5 + sheet.getRow(6)?.getCell(29), // AD7 =W5 + sheet.getRow(48)?.getCell(28), // AC49 =W5 + sheet.getRow(50)?.getCell(29), // AD51 =AC49 + ) + for (cell in foodSheetCells) { + if (cell != null && cell.cellType == CellType.FORMULA) { + evaluator.evaluateFormulaCell(cell) } - searchRowIndex++ } - + val sellingSheet = workbook.getSheet("Selling Price (售價)") + if (sellingSheet != null) { + val sellingCells = listOf( + sellingSheet.getRow(6)?.getCell(1), // B7 ='食物成品 '!AD51 + sellingSheet.getRow(4)?.getCell(4), // E5 =B7 + ) + for (cell in sellingCells) { + if (cell != null && cell.cellType == CellType.FORMULA) { + evaluator.evaluateFormulaCell(cell) + } + } + } + } + + /** S6 version label: VX → V(X+1). Returns new label, or null if not parseable. */ + private fun bumpImportExcelVersion(sheet: Sheet): String? { + val current = readStringCellValue(sheet.getRow(5)?.getCell(18))?.trim().orEmpty() + val match = Regex("""(?i)v\s*(\d+)""").find(current) ?: return null + val next = match.groupValues[1].toInt() + 1 + val label = "V$next" + getOrCreateCell(sheet, 5, 18).setCellValue(label) + return label + } + + private fun clearImportExcelSignOffFields(sheet: Sheet) { + // R7 Prepared by, T7 Checked by, W7 Approved by, R8 Date + clearCellValue(sheet, 6, 17) + clearCellValue(sheet, 6, 19) + clearCellValue(sheet, 6, 22) + clearCellValue(sheet, 7, 17) + } + + private fun buildBumpedVersionFileName(originalFileName: String, newVersionLabel: String?): String { + val ext = originalFileName.substringAfterLast('.', "xlsx") + val base = originalFileName.substringBeforeLast('.') + if (newVersionLabel == null) { + return "$base.$ext" + } + val num = newVersionLabel.removePrefix("V").removePrefix("v") + val replaced = Regex("""(?i)v\.(\d+)""").replace(base) { "v.$num" } + return "$replaced.$ext" + } + + private fun clearCellValue(sheet: Sheet, rowIdx: Int, colIdx: Int) { + val cell = sheet.getRow(rowIdx)?.getCell(colIdx) ?: return + if (cell.cellType == CellType.BLANK) return + cell.setBlank() + } + + private fun clearCellIfHasContent(sheet: Sheet, rowIdx: Int, colIdx: Int) { + val cell = sheet.getRow(rowIdx)?.getCell(colIdx) ?: return + when (cell.cellType) { + CellType.BLANK -> return + CellType.STRING -> if (cell.stringCellValue.isBlank()) return + else -> Unit + } + cell.setBlank() + } + + /** + * Clear material content on 產品製方 (name/qty/uom/brand only; keep sequence numbers). + * Left: B/D/E/F ; Right: I/K/L/M. Only clear cells that already have content. + */ + private fun clearProductRecipeMaterialCells(workbook: Workbook) { + val sheet = workbook.getSheet("產品製方") ?: return + val leftCols = intArrayOf(1, 3, 4, 5) + val rightCols = intArrayOf(8, 10, 11, 12) + for (rowIdx in 14..28) { + for (col in leftCols) clearCellIfHasContent(sheet, rowIdx, col) + for (col in rightCols) clearCellIfHasContent(sheet, rowIdx, col) + } + } + + /** + * After overrides: for every material row, recompute + * 轉用 (matrix from Excel 轉用單位), 銷售 / 採購 (system convert, not Excel). + * + * Columns: C使用份量 D使用單位 E轉用份量 F轉用單位 G銷售份量 H銷售單位 I採購單價 J採購單位 + */ + private fun recalculateMaterialDerivedColumns(sheet: Sheet) { + val headerRowIndex = findMaterialHeaderRowIndex(sheet) ?: return + var rowIdx = headerRowIndex + 1 + val maxRowIndex = 200 + while (rowIdx < maxRowIndex) { + val row = sheet.getRow(rowIdx) ?: break + val firstCell = row.getCell(0) + if (firstCell == null || firstCell.cellType == CellType.BLANK) break + + val itemCode = readStringCellValue(firstCell) + val qtyCell = row.getCell(2) + val recipeQty = if (qtyCell != null && isNumericLike(qtyCell)) { + qtyCell.numericCellValue.toBigDecimal() + } else { + null + } + val recipeUomCode = readStringCellValue(row.getCell(3)) + val transferUomCode = readStringCellValue(row.getCell(5)) + + // 轉用: convert 配方數量 → Excel 轉用單位 via matrix; else null both + val fromMatrix = StandardUomMatrix.toMatrixUnit(recipeUomCode) + val toMatrix = StandardUomMatrix.toMatrixUnit(transferUomCode) + if (recipeQty != null && fromMatrix != null && toMatrix != null) { + val converted = StandardUomMatrix.convert(recipeQty, fromMatrix, toMatrix) + getOrCreateCell(sheet, rowIdx, 4).setCellValue(converted.toDouble()) + } else { + clearCellValue(sheet, rowIdx, 4) + clearCellValue(sheet, rowIdx, 5) + } + + val item = itemCode?.let { itemsRepository.findByCodeAndDeletedFalse(it) } + val itemId = item?.id + val recipeUom = recipeUomCode?.let { uomConversionRepository.findByCodeAndDeletedFalse(it) } + + // 銷售: 使用UOM → base → stock → sale (do not read Excel) + val derived = if (itemId != null && recipeQty != null && recipeUom != null) { + runCatching { bomMaterialQtyService.deriveFromRecipe(itemId, recipeQty, recipeUom) }.getOrNull() + } else { + null + } + if (derived?.saleQty != null) { + getOrCreateCell(sheet, rowIdx, 6).setCellValue(derived.saleQty!!.toDouble()) + val salesCode = derived.salesUnit?.code?.trim()?.takeIf { it.isNotEmpty() } + ?: derived.salesUnitCode?.trim()?.takeIf { it.isNotEmpty() } + if (salesCode != null) { + getOrCreateCell(sheet, rowIdx, 7).setCellValue(salesCode) + } else { + clearCellValue(sheet, rowIdx, 7) + } + } else { + clearCellValue(sheet, rowIdx, 6) + clearCellValue(sheet, rowIdx, 7) + } + + // 採購: unit + latest market unit price from master (do not read Excel) + val purchaseUom = itemId?.let { itemUomService.findPurchaseUnitByItemId(it) }?.uom + val purchaseCode = purchaseUom?.code?.trim()?.takeIf { it.isNotEmpty() } + ?: purchaseUom?.udfudesc?.trim()?.takeIf { it.isNotEmpty() } + if (purchaseCode != null) { + getOrCreateCell(sheet, rowIdx, 9).setCellValue(purchaseCode) + } else { + clearCellValue(sheet, rowIdx, 9) + } + val marketPrice = item?.latestMarketUnitPrice + if (marketPrice != null) { + getOrCreateCell(sheet, rowIdx, 8).setCellValue(marketPrice) + } else { + clearCellValue(sheet, rowIdx, 8) + } + + rowIdx++ + } + } + + private fun setBasicInfoStringByHeader(sheet: Sheet, headerText: String, value: String) { + for (rowIdx in 0..15) { + for (colIdx in 0..15) { + val cell = sheet.getRow(rowIdx)?.getCell(colIdx) ?: continue + if (cell.cellType != CellType.STRING) continue + if (cell.stringCellValue.trim() != headerText) continue + val existing = getBasicInfoValueCell(sheet, rowIdx, colIdx) + val targetRowIdx = existing?.rowIndex ?: (rowIdx + 1) + val targetColIdx = existing?.columnIndex ?: colIdx + getOrCreateCell(sheet, targetRowIdx, targetColIdx).setCellValue(value) + return + } + } + } + + private fun setBasicInfoNumericByHeader(sheet: Sheet, headerText: String, value: Double) { + for (rowIdx in 0..15) { + for (colIdx in 0..15) { + val cell = sheet.getRow(rowIdx)?.getCell(colIdx) ?: continue + if (cell.cellType != CellType.STRING) continue + if (cell.stringCellValue.trim() != headerText) continue + val existing = getBasicInfoValueCell(sheet, rowIdx, colIdx) + val targetRowIdx = existing?.rowIndex ?: (rowIdx + 1) + val targetColIdx = existing?.columnIndex ?: colIdx + getOrCreateCell(sheet, targetRowIdx, targetColIdx).setCellValue(value) + return + } + } + } + + private fun setBasicInfoScaleByHeaderContains(sheet: Sheet, headerContains: String, value: Int) { + for (rowIdx in 0..10) { + for (colIdx in 0..9) { + val cell = sheet.getRow(rowIdx)?.getCell(colIdx) ?: continue + if (cell.cellType != CellType.STRING) continue + if (!cell.stringCellValue.trim().contains(headerContains)) continue + getOrCreateCell(sheet, rowIdx, colIdx + 1).setCellValue(value.toDouble()) + return + } + } + } + + private fun setBasicInfoAllergicSubstances(sheet: Sheet, value: Int) { + for (rowIdx in 0..10) { + for (colIdx in 0..9) { + val cell = sheet.getRow(rowIdx)?.getCell(colIdx) ?: continue + if (cell.cellType != CellType.STRING) continue + if (!cell.stringCellValue.trim().contains("過敏原")) continue + getOrCreateCell(sheet, rowIdx, colIdx + 1).setCellValue(value.toDouble()) + return + } + } + } + + private fun findMaterialHeaderRowIndex(sheet: Sheet): Int? { + var searchRowIndex = 10 + val maxRowIndex = 200 + while (searchRowIndex < maxRowIndex) { + val cell = sheet.getRow(searchRowIndex)?.getCell(0) + if (cell != null && cell.cellType == CellType.STRING && cell.stringCellValue.trim() == "材料編號") { + return searchRowIndex + } + searchRowIndex++ + } + return null + } + + private fun applyMaterialOverridesToSheet(sheet: Sheet, overrides: List) { + val headerRowIndex = findMaterialHeaderRowIndex(sheet) ?: return + // Match by sheet row order (same order as preview/override list), so itemCode corrections work. + var rowIdx = headerRowIndex + 1 + var overrideIdx = 0 + val maxRowIndex = 200 + while (rowIdx < maxRowIndex && overrideIdx < overrides.size) { + val row = sheet.getRow(rowIdx) ?: break + val firstCell = row.getCell(0) + if (firstCell == null || firstCell.cellType == CellType.BLANK) break + val override = overrides[overrideIdx] + override.itemCode?.trim()?.takeIf { it.isNotEmpty() }?.let { + getOrCreateCell(sheet, rowIdx, 0).setCellValue(it) + } + override.itemName?.trim()?.takeIf { it.isNotEmpty() }?.let { + getOrCreateCell(sheet, rowIdx, 1).setCellValue(it) + } + override.qty?.let { + getOrCreateCell(sheet, rowIdx, 2).setCellValue(it.toDouble()) + } + val uomCode = override.uomCode?.trim()?.takeIf { it.isNotEmpty() } + ?: override.uomId?.let { uomConversionRepository.findById(it).orElse(null)?.code } + uomCode?.let { + getOrCreateCell(sheet, rowIdx, 3).setCellValue(it) + } + override.joinStepSeqNo?.let { + getOrCreateCell(sheet, rowIdx, 10).setCellValue(it.toDouble()) + } + overrideIdx++ + rowIdx++ + } + } + + private fun applyProcessOverridesToSheet(sheet: Sheet, overrides: List) { + var startRowIndex = 30 + val maxHeaderSearch = 70 + var headerFound = false + while (startRowIndex < maxHeaderSearch) { + val cell = sheet.getRow(startRowIndex)?.getCell(0) + if (cell != null && cell.cellType == CellType.STRING && cell.stringCellValue.trim() == "工序") { + headerFound = true + startRowIndex += 2 + break + } + startRowIndex++ + } + if (!headerFound) return + + val overrideBySeq = overrides.mapNotNull { line -> + val seq = line.seqNo ?: return@mapNotNull null + seq to line + }.toMap() + + var rowIdx = startRowIndex + val maxRowIndex = startRowIndex + 50 + while (rowIdx < maxRowIndex) { + val row = sheet.getRow(rowIdx) ?: break + val firstCell = row.getCell(0) + if (firstCell == null || firstCell.cellType == CellType.BLANK) break + val seqNo = when (firstCell.cellType) { + CellType.NUMERIC -> firstCell.numericCellValue.toLong() + CellType.STRING -> firstCell.stringCellValue.trim().toLongOrNull() + else -> null + } + if (seqNo == null) { + rowIdx++ + continue + } + val override = overrideBySeq[seqNo] + if (override == null) { + rowIdx++ + continue + } + override.seqNo?.let { + getOrCreateCell(sheet, rowIdx, 0).setCellValue(it.toDouble()) + } + val processLabel = override.processName?.trim()?.takeIf { it.isNotEmpty() } + ?: override.processCode?.trim()?.takeIf { it.isNotEmpty() }?.let { code -> + processRepository.findByCodeAndDeletedIsFalse(code)?.name ?: code + } + processLabel?.let { + getOrCreateCell(sheet, rowIdx, 1).setCellValue(it) + } + override.description?.let { + getOrCreateCell(sheet, rowIdx, 2).setCellValue(it) + } + val equipmentText = formatProcessEquipmentForExcel(override) + if (equipmentText != null) { + getOrCreateCell(sheet, rowIdx, 3).setCellValue(equipmentText) + } + override.durationInMinute?.let { + getOrCreateCell(sheet, rowIdx, 5).setCellValue(it.toDouble()) + } + override.prepTimeInMinute?.let { + getOrCreateCell(sheet, rowIdx, 11).setCellValue(it.toDouble()) + } + override.postProdTimeInMinute?.let { + getOrCreateCell(sheet, rowIdx, 12).setCellValue(it.toDouble()) + } + rowIdx++ + } + } + + private fun formatProcessEquipmentForExcel(line: BomImportPreviewProcessLine): String? { + val desc = line.equipmentDescription?.trim().orEmpty() + val name = line.equipmentName?.trim().orEmpty() + return when { + desc.isNotEmpty() && name.isNotEmpty() -> "$desc-$name" + name.isNotEmpty() -> name + desc.isNotEmpty() -> desc + else -> null + } + } + + private fun parseImportMaterialsFromSheet(sheet: Sheet): List { + val headerRowIndex = findMaterialHeaderRowIndex(sheet) ?: return emptyList() + val lines = mutableListOf() + var rowIdx = headerRowIndex + 1 + val maxRowIndex = 200 + while (rowIdx < maxRowIndex) { + val row = sheet.getRow(rowIdx) ?: break + val firstCell = row.getCell(0) + if (firstCell == null || firstCell.cellType == CellType.BLANK) break + + val itemCode = readStringCellValue(firstCell) + val itemName = readStringCellValue(row.getCell(1)) + val item = itemCode?.let { itemsRepository.findByCodeAndDeletedFalse(it) } + ?: itemName?.let { itemsRepository.findByNameAndDeletedFalse(it) } + val qtyCell = row.getCell(2) + val qty = if (qtyCell != null && isNumericLike(qtyCell)) qtyCell.numericCellValue.toBigDecimal() else null + val uomCode = readStringCellValue(row.getCell(3)) + val uom = uomCode?.let { uomConversionRepository.findByCodeAndDeletedFalse(it) } + val joinStepCell = row.getCell(10) + val joinStepSeqNo = if (joinStepCell != null && isNumericLike(joinStepCell)) { + joinStepCell.numericCellValue.toInt() + } else { + null + } + + val itemId = item?.id + val salesUomId = itemId?.let { itemUomService.findSalesUnitByItemId(it)?.uom?.id } + val stockUomId = itemId?.let { itemUomService.findStockUnitByItemId(it)?.uom?.id } + val baseUomId = itemId?.let { itemUomService.findBaseUnitByItemId(it)?.uom?.id } + val availableRecipeUoms = itemId?.let { + bomRecipeUomOptionsService.buildAvailableRecipeUoms(it, uom?.id) + } ?: emptyList() + val derived = if (itemId != null && qty != null && uom != null) { + runCatching { bomMaterialQtyService.deriveFromRecipe(itemId, qty, uom) }.getOrNull() + } else { + null + } + + lines += BomImportPreviewMaterialLine( + itemCode = itemCode, + itemName = itemName ?: item?.name, + itemId = itemId, + qty = qty, + uomCode = uomCode, + uomId = uom?.id, + salesUomId = salesUomId, + stockUomId = stockUomId, + baseUomId = baseUomId, + availableRecipeUoms = availableRecipeUoms, + baseQty = derived?.baseQty, + baseUom = derived?.baseUnitName, + stockQty = derived?.stockQty, + stockUom = derived?.stockUnitName, + joinStepSeqNo = joinStepSeqNo, + ) + rowIdx++ + } + return lines + } + + private fun parseImportProcessesFromSheet(sheet: Sheet): List { + var startRowIndex = 30 + val maxHeaderSearch = 70 + var headerFound = false + while (startRowIndex < maxHeaderSearch) { + val cell = sheet.getRow(startRowIndex)?.getCell(0) + if (cell != null && cell.cellType == CellType.STRING && cell.stringCellValue.trim() == "工序") { + headerFound = true + startRowIndex += 2 + break + } + startRowIndex++ + } + if (!headerFound) return emptyList() + + val lines = mutableListOf() + var rowIdx = startRowIndex + val maxRowIndex = startRowIndex + 50 + while (rowIdx < maxRowIndex) { + val row = sheet.getRow(rowIdx) ?: break + val firstCell = row.getCell(0) + if (firstCell == null || firstCell.cellType == CellType.BLANK) break + + val seqNo = when (firstCell.cellType) { + CellType.NUMERIC -> firstCell.numericCellValue.toLong() + CellType.STRING -> firstCell.stringCellValue.trim().toLongOrNull() + else -> null + } + val processName = readStringCellValue(row.getCell(1)) + val description = readStringCellValue(row.getCell(2)) + val equipmentRaw = readStringCellValue(row.getCell(3)) + val durationCell = row.getCell(5) + val prepCell = row.getCell(11) + val postCell = row.getCell(12) + val processCode = processName?.let { processRepository.findByNameAndDeletedIsFalse(it)?.code } + + lines += BomImportPreviewProcessLine( + seqNo = seqNo, + processCode = processCode, + processName = processName, + description = description, + equipmentDescription = equipmentRaw?.substringBefore("-")?.trim()?.takeIf { it.isNotEmpty() }, + equipmentName = equipmentRaw?.substringAfter("-", "")?.trim()?.takeIf { it.isNotEmpty() }, + durationInMinute = if (durationCell != null && isNumericLike(durationCell)) { + durationCell.numericCellValue.toInt() + } else { + null + }, + prepTimeInMinute = if (prepCell != null && isNumericLike(prepCell)) prepCell.numericCellValue.toInt() else null, + postProdTimeInMinute = if (postCell != null && isNumericLike(postCell)) postCell.numericCellValue.toInt() else null, + ) + rowIdx++ + } + return lines + } + + private fun isValidEquipmentType(value: String): Boolean { + val trimmed = value.trim() + if (trimmed.isEmpty()) return false + if (trimmed == "不合用" || trimmed == "不適用") return true + if (trimmed.contains(",")) return false // 新增:不允許逗號 + val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機 + return regex.matches(trimmed) + } + + /** Cell 是否為非空字串(含 STRING / FORMULA 結果為字串) */ + private fun isNonEmptyStringCell(cell: org.apache.poi.ss.usermodel.Cell?): Boolean { + if (cell == null || cell.cellType == CellType.BLANK) return false + return when (cell.cellType) { + CellType.STRING -> cell.stringCellValue.trim().isNotEmpty() + CellType.FORMULA -> when (cell.cachedFormulaResultType) { + CellType.STRING -> cell.stringCellValue.trim().isNotEmpty() + else -> false + } + else -> false + } + } + private fun validateProcessLikeImport( + sheet: Sheet, + fileName: String, + issues: MutableList + ) { + val byProductItemCache = mutableMapOf() + val processMasterCache = mutableMapOf() + val uomCache = mutableMapOf() + val itemStockUomCache = mutableMapOf() + var startRowIndex = 30 + var endRowIndex = 70 + var startColumnIndex = 0 + val endColumnIndex = 11 + + // 1) 找到 "工序" header + var headerFound = false + while (startRowIndex < endRowIndex) { + val tempRow = sheet.getRow(startRowIndex) + val tempCell = tempRow?.getCell(startColumnIndex) + if (tempCell != null && + tempCell.cellType == CellType.STRING && + tempCell.stringCellValue.trim() == "工序" + ) { + headerFound = true + startRowIndex += 2 + break + } + startRowIndex++ + } + + if (!headerFound) { + issues += BomFormatIssue(fileName, "工序區:找不到『工序』表頭") + return + } + + var searchRowIndex = startRowIndex + val maxSearchRow = 50 + while (searchRowIndex < maxSearchRow) { + val tempRow = sheet.getRow(searchRowIndex) + val tempCell = tempRow?.getCell(0) + if (tempCell == null || tempCell.cellType == CellType.BLANK) { + endRowIndex = searchRowIndex + break + } + searchRowIndex++ + } + while (startRowIndex != endRowIndex || startColumnIndex != endColumnIndex) { val tempRow = sheet.getRow(startRowIndex) val tempCell = tempRow?.getCell(startColumnIndex) @@ -2062,9 +2549,36 @@ open class BomService( } } 1 -> { - // Process Name — 必填,非空字串 + // Process Name — 必填,且必須存在於 Process 主檔 if (!isNonEmptyStringCell(tempCell)) { issues += BomFormatIssue(fileName, "工序區:第${rowNum}行『名稱』不可為空") + } else { + val processName = when (tempCell.cellType) { + CellType.STRING -> tempCell.stringCellValue.trim() + CellType.FORMULA -> + if (tempCell.cachedFormulaResultType == CellType.STRING) + tempCell.stringCellValue.trim() + else "" + else -> "" + } + if (processName.isNotEmpty()) { + val exists = processMasterCache.getOrPut(processName) { + processRepository.findByNameAndDeletedIsFalse(processName) != null || + processRepository.findByCodeAndDeletedIsFalse(processName) != null + } + if (!exists) { + val seqCell = tempRow?.getCell(0) + val seqLabel = if (seqCell != null && isNumericLike(seqCell)) { + "次序${seqCell.numericCellValue.toInt()}" + } else { + "第${rowNum}行" + } + issues += BomFormatIssue( + fileName, + "工序區:${seqLabel}『名稱』($processName) 在 工序 主檔找不到", + ) + } + } } } 2 -> { @@ -2240,8 +2754,10 @@ open class BomService( issues: MutableList ) { val startCol = 0 - val endCol = 10 val maxRowIndex = 200 + val itemCache = mutableMapOf() + val uomCache = mutableMapOf() + val baseUomCache = mutableMapOf() // 1) 找表头 var headerRowIndex = -1 @@ -2265,6 +2781,8 @@ open class BomService( } // 2) 从表头下一行开始,读到 col0 空白 + // 必填僅:材料編號 / 材料 / 使用份量 / 使用單位 / 加入步驟 + // 轉用、銷售、採購欄位不強制(bom_material 不持久化這些欄) var bomMatRowIdx = headerRowIndex + 1 while (bomMatRowIdx < maxRowIndex) { val row = sheet.getRow(bomMatRowIdx) ?: break @@ -2273,9 +2791,9 @@ open class BomService( break } - for (startColumnIndex in 0..endCol) { + val rowNum = bomMatRowIdx + 1 + for (startColumnIndex in listOf(0, 1, 2, 3, 10)) { val tempCell = row.getCell(startColumnIndex) - val rowNum = bomMatRowIdx + 1 when (startColumnIndex) { 0 -> { @@ -2298,36 +2816,6 @@ open class BomService( issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『使用單位』(欄4)不可為空") } } - 4 -> { - if (tempCell == null || !isNumericLike(tempCell)) { - issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位份量』(欄5)不可為空且須為數值") - } - } - 5 -> { - if (tempCell == null || !isNonEmptyStringCell(tempCell)) { - issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『轉用單位』(欄6)不可為空") - } - } - 6 -> { - if (tempCell == null || !isNumericLike(tempCell)) { - issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『份量(銷售單位)』(欄7)不可為空且須為數值") - } - } - 7 -> { - if (tempCell == null || !isNonEmptyStringCell(tempCell)) { - issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『銷售單位』(欄8)不可為空") - } - } - 8 -> { - if (tempCell == null || !isNumericLike(tempCell)) { - issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單價』(欄9)不可為空且須為數值") - } - } - 9 -> { - if (tempCell == null || !isNonEmptyStringCell(tempCell)) { - issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『採購單位』(欄10)不可為空") - } - } 10 -> { if (tempCell == null || !isNumericLike(tempCell)) { issues += BomFormatIssue(fileName, "材料區:第${rowNum}行『加入步驟』(欄11)不可為空且須為數值") @@ -2336,38 +2824,81 @@ open class BomService( } } - // DB / 轉換檢查(保留你原本逻辑) val codeCell = row.getCell(0) val uomCell = row.getCell(3) - val saleQtyCell = row.getCell(6) - val salesUnitCell = row.getCell(7) - val rowNum = bomMatRowIdx + 1 - val itemCode = codeCell?.stringCellValue?.trim().orEmpty() - if (itemCode.isNotEmpty()) { - val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) - if (item == null) { - issues += BomFormatIssue( - fileName, - "材料區:第${rowNum}行『材料編號』($itemCode) 在 Items 資料表找不到" - ) + val itemCode = when { + codeCell == null || codeCell.cellType == CellType.BLANK -> "" + codeCell.cellType == CellType.STRING -> codeCell.stringCellValue.trim() + codeCell.cellType == CellType.FORMULA && + codeCell.cachedFormulaResultType == CellType.STRING -> codeCell.stringCellValue.trim() + else -> "" + } + val useUomCode = when { + uomCell == null || uomCell.cellType == CellType.BLANK -> "" + uomCell.cellType == CellType.STRING -> uomCell.stringCellValue.trim() + uomCell.cellType == CellType.FORMULA && + uomCell.cachedFormulaResultType == CellType.STRING -> uomCell.stringCellValue.trim() + else -> "" + } + + val item = if (itemCode.isNotEmpty()) { + itemCache.getOrPut(itemCode) { + itemsRepository.findByCodeAndDeletedFalse(itemCode) } + } else { + null } - - val useUomCode = uomCell?.stringCellValue?.trim().orEmpty() - if (useUomCode.isNotEmpty()) { - val useUom = uomConversionRepository.findByCodeAndDeletedFalse(useUomCode) - if (useUom == null) { + if (itemCode.isNotEmpty() && item == null) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行『材料編號』($itemCode) 在 Items 資料表找不到", + ) + } + + val useUom = if (useUomCode.isNotEmpty()) { + uomCache.getOrPut(useUomCode) { + uomConversionRepository.findByCodeAndDeletedFalse(useUomCode) + } + } else { + null + } + if (useUomCode.isNotEmpty() && useUom == null) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行『使用單位』($useUomCode) 在 UOM 資料表找不到", + ) + } + + // 使用單位須可換算至品項 Base;若 ≠ Base,則 Base 必須屬於 standard matrix + val itemId = item?.id + if (itemId != null && useUom != null) { + val baseUom = baseUomCache.getOrPut(itemId) { + itemUomService.findBaseUnitByItemId(itemId)?.uom + } + if (baseUom == null) { issues += BomFormatIssue( fileName, - "材料區:第${rowNum}行『使用單位』($useUomCode) 在 UOM 資料表找不到" + "材料區:第${rowNum}行 品項($itemCode) 未設定 Base Unit,無法驗證『使用單位』", ) + } else if (useUom.id != baseUom.id) { + val baseCode = baseUom.code?.trim().orEmpty() + val baseInMatrix = StandardUomMatrix.toMatrixUnit(baseUom) != null + if (!baseInMatrix) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行『使用單位』($useUomCode) 與品項($itemCode) 基本單位($baseCode) 不同時," + + "品項 Base Unit 必須為 matrix(${StandardUomMatrix.STANDARD_CODES.joinToString("/")})", + ) + } else if (!bomMaterialQtyService.canConvertRecipeToBase(itemId, useUom, baseUom)) { + issues += BomFormatIssue( + fileName, + "材料區:第${rowNum}行『使用單位』($useUomCode) 無法換算至品項($itemCode) 基本單位($baseCode)", + ) + } } } - // 下面 sales unit / convert 檢查,直接沿用你原本 2136 行後的邏輯即可 - // (你可把原区块整段搬进来,row / rowNum 变量名一致即可) - bomMatRowIdx++ } } @@ -2397,6 +2928,42 @@ open class BomService( return null } + + /** + * BOM 產出單位須與成品貨品在 M18 主檔設定的庫存單位一致。 + */ + private fun validateBomOutputUomMatchesItemStock( + fileName: String, + sheet: Sheet, + uomCode: String, + issues: MutableList, + ): Boolean { + val itemCode = readBomCodeFromSheet(sheet)?.trim().orEmpty() + if (itemCode.isEmpty()) return true + + val item = itemsRepository.findByCodeAndDeletedFalse(itemCode) ?: return true + val itemId = item.id ?: return true + + val stockUom = itemUomService.findStockUnitByItemId(itemId)?.uom + if (stockUom == null) { + issues += BomFormatIssue( + fileName, + "基本資料:貨品($itemCode) 未設定庫存單位,無法驗證『單位』", + ) + return false + } + + val stockCode = stockUom.code?.trim().orEmpty() + if (!uomCode.equals(stockCode, ignoreCase = true)) { + val stockLabel = stockUom.udfudesc?.takeIf { it.isNotBlank() } ?: stockCode + issues += BomFormatIssue( + fileName, + "基本資料:『單位』($uomCode) 與貨品($itemCode) 庫存單位($stockCode / $stockLabel) 不一致", + ) + return false + } + return true + } /** * 檢查基本資料區(編號 / 產品名稱 / 份量 / 單位) @@ -2413,7 +2980,6 @@ open class BomService( val maxCol = 15 var codeHeaderFound = false - var nameHeaderFound = false var qtyHeaderFound = false var uomHeaderFound = false var timeSeqHeaderFound = false @@ -2424,7 +2990,6 @@ open class BomService( var ConcentrationHeaderFound = false var codeValueOk = false - var nameValueOk = false var qtyValueOk = false var uomValueOk = false var timeSeqValueOk = false @@ -2456,7 +3021,7 @@ for (r in 0..20) { } } } -println("=====================================") +//println("=====================================") for (rowIdx in 0 until maxRow) { val row = sheet.getRow(rowIdx) ?: continue for (colIdx in 0 until maxCol) { @@ -2484,33 +3049,20 @@ println("=====================================") if (v.isEmpty()) { issues += BomFormatIssue(fileName, "基本資料:『編號』欄位缺值或型別錯誤") } else { - codeValueOk = true + val item = itemsRepository.findByCodeAndDeletedFalse(v) + if (item == null) { + issues += BomFormatIssue( + fileName, + "基本資料:『編號』($v) 在 Items 資料表找不到", + ) + } else { + codeValueOk = true + } } } } - text == "產品名稱" -> { - nameHeaderFound = true - val r5Cell = sheet.getRow(4)?.getCell(17) - if (r5Cell == null || r5Cell.cellType == CellType.BLANK) { - issues += BomFormatIssue(fileName, "基本資料:『產品名稱』欄位缺值或型別錯誤") - } else { - val v = when (r5Cell.cellType) { - CellType.STRING -> r5Cell.stringCellValue.trim() - CellType.FORMULA -> - if (r5Cell.cachedFormulaResultType == CellType.STRING) - r5Cell.stringCellValue.trim() - else "" - - else -> "" - } - if (v.isEmpty()) { - issues += BomFormatIssue(fileName, "基本資料:『產品名稱』欄位缺值或型別錯誤") - } else { - nameValueOk = true - } - } - } + // 產品名稱:不檢查。BOM 名稱以編號對應 Items 主檔為準。 text == "份量 (Qty)" -> { qtyHeaderFound = true @@ -2543,7 +3095,7 @@ println("=====================================") val uom = uomConversionRepository.findByCodeAndDeletedFalse(uomCode) if (uom == null) { issues += BomFormatIssue(fileName, "基本資料:『單位』($uomCode) 在 UOM 資料表中找不到") - } else { + } else if (validateBomOutputUomMatchesItemStock(fileName, sheet, uomCode, issues)) { uomValueOk = true } } @@ -2703,9 +3255,6 @@ println("=====================================") if (!codeHeaderFound) { issues += BomFormatIssue(fileName, "基本資料:找不到『編號』欄位") } - if (!nameHeaderFound) { - issues += BomFormatIssue(fileName, "基本資料:找不到『產品名稱』欄位") - } if (!qtyHeaderFound) { issues += BomFormatIssue(fileName, "基本資料:找不到『份量 (Qty)』欄位") } @@ -2929,30 +3478,27 @@ println("=====================================") } } open fun getBomDetail(id: Long): BomDetailResponse { - // 1) 正確處理找不到 BOM 的情況(用 NotFoundException 或你專案慣例) val bom = bomRepository.findByIdAndDeletedIsFalse(id) ?: throw NotFoundException() - - // 2) 不要用不存在的 deleted 欄位過濾,直接 map 即可 - val materials = bom.bomMaterials - .map { m -> - BomMaterialDto( - itemCode = m.item?.code, - itemName = m.item?.name ?: m.itemName, - isConsumable = m.isConsumable, - baseQty = m.baseQty, - baseUom = m.baseUnitName, - stockQty = m.stockQty, - stockUom = m.stockUnitName, - salesQty = m.saleQty, - salesUom = m.salesUnitCode - ) - } - + + val processIds = bom.bomProcesses.mapNotNull { it.id } + val processMaterials = if (processIds.isNotEmpty()) { + bomProcessMaterialRepository.findByBomProcess_IdIn(processIds) + } else { + emptyList() + } + val linksByMaterialId = processMaterials.groupBy { it.bomMaterial?.id } + val processById = bom.bomProcesses.associateBy { it.id } + + val materials = bom.bomMaterials.map { m -> + mapBomMaterialDto(m, linksByMaterialId[m.id].orEmpty(), processById) + } + val processes = bom.bomProcesses .sortedBy { it.seqNo } .map { p -> BomProcessDto( + id = p.id, seqNo = p.seqNo, processCode = p.process?.code, processName = p.process?.name, @@ -2961,16 +3507,39 @@ println("=====================================") equipmentName = p.equipment?.name, durationInMinute = p.durationInMinute, prepTimeInMinute = p.prepTimeInMinute, - postProdTimeInMinute = p.postProdTimeInMinute + postProdTimeInMinute = p.postProdTimeInMinute, ) } - - // 3) 其餘維持不變即可 + + val itemId = bom.item?.id + val stockUom = itemId?.let { itemUomService.findStockUnitByItemId(it)?.uom } + val stockUomId = stockUom?.id + val stockUomLabel = stockUom?.udfudesc + val baseQty = bom.outputQty + val sourceUomId = bom.uom?.id + val outputQtyStock = if (itemId != null && baseQty != null && sourceUomId != null) { + try { + itemUomService.convertUomByItem( + ConvertUomByItemRequest( + itemId = itemId, + qty = baseQty, + uomId = sourceUomId, + targetUnit = "stockUnit", + ), + ).newQty + } catch (_: Exception) { + null + } + } else { + null + } + val outputQtyStockConvertible = outputQtyStock != null return BomDetailResponse( id = bom.id!!, + itemId = itemId, itemCode = bom.item?.code, itemName = bom.item?.name, - isDark = (bom.isDark ?: 0) != 0, + isDark = bom.isDark, isFloat = bom.isFloat, isDense = bom.isDense, isDrink = bom.isDrink, @@ -2980,12 +3549,385 @@ println("=====================================") timeSequence = bom.timeSequence, complexity = bom.complexity, baseScore = bom.baseScore?.toInt(), - description = bom.description, + description = bom.bomKind, + bomKind = bom.bomKind, + revisionNo = bom.revisionNo, outputQty = bom.outputQty, outputQtyUom = bom.outputQtyUom, + outputQtyStock = outputQtyStock, + outputQtyStockUom = stockUomLabel, + outputQtyStockUomId = stockUomId, + outputQtyStockConvertible = outputQtyStockConvertible, status = bom.status.value, + putawayLocationCode = bom.putawayLocationCode, + itemLocationCode = bom.item?.LocationCode, materials = materials, - processes = processes + processes = processes, + ) + } + + private fun formatBomProcessStepLabel(process: BomProcess?): String? { + val seq = process?.seqNo ?: return null + val name = process.process?.name?.trim().orEmpty() + return if (name.isNotEmpty()) "$seq. $name" else seq.toString() + } + + private fun mapBomMaterialDto( + m: BomMaterial, + links: List, + processById: Map, + ): BomMaterialDto { + val itemId = m.item?.id + val derived = bomMaterialQtyService.deriveForEntity(m) + val salesItemUom = itemId?.let { itemUomService.findSalesUnitByItemId(it) } + val stockItemUom = itemId?.let { itemUomService.findStockUnitByItemId(it) } + val baseItemUom = itemId?.let { itemUomService.findBaseUnitByItemId(it) } + val availableRecipeUoms = itemId?.let { + bomRecipeUomOptionsService.buildAvailableRecipeUoms(it, m.uom?.id) + } ?: emptyList() + val linkedProcesses = links.mapNotNull { link -> processById[link.bomProcess?.id] } + val processSteps = linkedProcesses.mapNotNull { formatBomProcessStepLabel(it) } + val processStepIds = linkedProcesses.mapNotNull { it.id } + + return BomMaterialDto( + id = m.id, + itemId = itemId, + itemCode = m.item?.code, + itemName = m.item?.name ?: m.itemName, + isConsumable = m.isConsumable, + recipeQty = m.qty, + recipeUom = m.uomName, + recipeUomId = m.uom?.id, + salesUomId = salesItemUom?.uom?.id, + stockUomId = stockItemUom?.uom?.id, + baseUomId = baseItemUom?.uom?.id, + availableRecipeUoms = availableRecipeUoms, + baseQty = derived?.baseQty, + baseUom = derived?.baseUnitName, + stockQty = derived?.stockQty, + stockUom = derived?.stockUnitName, + salesQty = derived?.saleQty, + salesUom = derived?.salesUnitCode, + processSteps = processSteps, + processStepIds = processStepIds, ) } + + @Transactional(readOnly = true) + open fun listBomCombos(includeInactive: Boolean): List { + val boms = if (includeInactive) { + bomRepository.findAllActiveWithItemAndUom() + } else { + bomRepository.findAllActiveWithItemAndUom().filter { it.status == BomStatus.ACTIVE } + } + return boms.map { bom -> + val display = bomOutputQtyService.displayAsStockUnit(bom) + val salesLabel = bom.item?.id?.let { itemId -> + itemUomService.findSalesUnitByItemId(itemId)?.uom?.udfudesc + }.orEmpty() + val rev = bom.revisionNo ?: 1 + val label = "${bom.code ?: ""} - ${bom.name ?: ""} - $salesLabel (V$rev)" + BomComboDto( + id = bom.id!!, + value = bom.id!!, + code = bom.code, + revisionNo = bom.revisionNo, + label = label, + outputQty = display.qty, + outputQtyUom = display.uomLabel, + bomKind = bom.bomKind, + description = bom.bomKind, + status = bom.status.value, + ) + } + } + + @Transactional(readOnly = true) + open fun listBomVersions(code: String, bomKind: String): List { + val normalizedCode = code.trim() + val normalizedKind = normalizeBomKindForImport(bomKind) + if (normalizedCode.isEmpty()) { + throw BadRequestException("BOM code is required") + } + return bomRepository + .findAllByCodeAndBomKindIgnoreCaseAndDeletedIsFalseOrderByRevisionNoAsc(normalizedCode, normalizedKind) + .map { bom -> + BomVersionSummary( + id = bom.id!!, + revisionNo = bom.revisionNo ?: 1, + status = bom.status.value, + bomKind = bom.bomKind, + outputQty = bom.outputQty, + outputQtyUom = bom.outputQtyUom, + ) + } + } + + @Transactional + open fun saveBomAsNewVersion(sourceBomId: Long, request: SaveBomAsNewVersionRequest): BomDetailResponse { + val source = bomRepository.findByIdAndDeletedIsFalse(sourceBomId) + ?: throw NotFoundException() + val code = source.code?.trim().orEmpty() + val bomKind = normalizeBomKindForImport(source.bomKind) + if (code.isEmpty()) { + throw BadRequestException("BOM code is required") + } + + val sourceMaterialsById = source.bomMaterials.associateBy { it.id } + val sourceMaterialsByCode = source.bomMaterials.associateBy { it.item?.code?.trim()?.lowercase() } + val hasMaterialChanges = hasMaterialChanges(source, request, sourceMaterialsById, sourceMaterialsByCode) + val hasPutawayChange = request.putawayLocationCode != null + val hasProcessChange = request.processes != null + if (!hasMaterialChanges && !hasPutawayChange && !hasProcessChange) { + throw BadRequestException("No changes detected; nothing to save as new version") + } + + val revisionNo = resolveNextRevisionNoForImport(code, bomKind) + markActiveVersionsInactiveForImport(code, bomKind) + + val newBom = Bom().apply { + this.code = code + name = source.name + this.bomKind = bomKind + this.revisionNo = revisionNo + status = BomStatus.ACTIVE + item = source.item + outputQty = source.outputQty + outputQtyUom = source.outputQtyUom + yield = source.yield + isDark = source.isDark + isFloat = source.isFloat + isDense = source.isDense + scrapRate = source.scrapRate + timeSequence = source.timeSequence + complexity = source.complexity + allergicSubstances = source.allergicSubstances + uom = source.uom + isDrink = source.isDrink + type = source.type + putawayLocationCode = when { + request.putawayLocationCode != null -> request.putawayLocationCode.takeIf { it.isNotEmpty() } + else -> source.putawayLocationCode + } + m18Id = null + } + newBom.baseScore = calculateBaseScore(newBom) + bomRepository.saveAndFlush(newBom) + + val materialLines = if (hasMaterialChanges) { + request.materials + } else { + source.bomMaterials.map { m -> + BomMaterialVersionEditLine( + sourceBomMaterialId = m.id, + itemCode = m.item?.code ?: "", + qty = m.qty ?: BigDecimal.ZERO, + uomId = m.uom?.id ?: throw BadRequestException("Material missing recipe UOM"), + isConsumable = m.isConsumable, + ) + } + } + if (materialLines.isEmpty()) { + throw BadRequestException("At least one material line is required") + } + + val newMaterials = materialLines.map { line -> + val item = itemsRepository.findByCodeAndDeletedFalse(line.itemCode.trim()) + ?: throw BadRequestException("Item not found: code=${line.itemCode}") + buildBomMaterialFromRecipe(newBom, item, line) + } + newMaterials.forEach { bomMaterialRepository.saveAndFlush(it) } + + val oldToNewProcess = if (hasProcessChange) { + buildProcessesForNewVersion(newBom, request.processes!!) + } else { + copyProcessesForNewVersion(sourceBomId, newBom) + } + + linkMaterialsToProcesses(newMaterials, materialLines, oldToNewProcess, source, sourceBomId) + + return getBomDetail(newBom.id!!) + } + + private fun copyProcessesForNewVersion(sourceBomId: Long, newBom: Bom): Map { + val oldToNewProcess = mutableMapOf() + val oldProcesses = bomProcessRepository.findAllByBomIdAndDeletedFalse(sourceBomId) + for (p in oldProcesses) { + val newP = BomProcess().apply { + process = p.process + equipment = p.equipment + description = p.description + seqNo = p.seqNo + durationInMinute = p.durationInMinute + prepTimeInMinute = p.prepTimeInMinute + postProdTimeInMinute = p.postProdTimeInMinute + bom = newBom + } + bomProcessRepository.saveAndFlush(newP) + p.id?.let { oldToNewProcess[it] = newP } + } + return oldToNewProcess + } + + private fun buildProcessesForNewVersion( + newBom: Bom, + processLines: List, + ): Map { + val result = mutableMapOf() + for (line in processLines) { + val process = when { + !line.processCode.isNullOrBlank() -> + processRepository.findByCodeAndDeletedIsFalse(line.processCode.trim()) + else -> null + } ?: throw BadRequestException("Process not found: code=${line.processCode}") + + val equipment = when { + !line.equipmentCode.isNullOrBlank() -> + equipmentRepository.findByCodeAndDeletedIsFalse(line.equipmentCode.trim()) + !line.equipmentName.isNullOrBlank() -> + equipmentRepository.findByNameAndDeletedIsFalse(line.equipmentName.trim()) + else -> null + } ?: line.equipmentDescription?.let { bomGetOrCreateEquipment(it) } + + val newP = BomProcess().apply { + this.process = process + this.equipment = equipment + description = line.description + seqNo = line.seqNo + durationInMinute = line.durationInMinute + prepTimeInMinute = line.prepTimeInMinute + postProdTimeInMinute = line.postProdTimeInMinute + bom = newBom + } + bomProcessRepository.saveAndFlush(newP) + line.seqNo?.let { result[it] = newP } + } + return result + } + + private fun linkMaterialsToProcesses( + newMaterials: List, + materialLines: List, + newProcessBySourceIdOrSeq: Map, + source: Bom, + sourceBomId: Long, + ) { + val newMaterialByItemCode = newMaterials.associateBy { it.item?.code?.trim()?.lowercase() } + val sourceProcesses = bomProcessRepository.findAllByBomIdAndDeletedFalse(sourceBomId) + val sourceProcessById = sourceProcesses.associateBy { it.id } + val oldProcessIds = sourceProcesses.mapNotNull { it.id } + val newProcessBySourceId = newProcessBySourceIdOrSeq + + materialLines.forEach { line -> + val newMaterial = newMaterialByItemCode[line.itemCode.trim().lowercase()] ?: return@forEach + val targetProcessIds = line.bomProcessIds + if (targetProcessIds.isNotEmpty()) { + for (processId in targetProcessIds) { + val newProcess = newProcessBySourceId[processId] + ?: sourceProcessById[processId]?.id?.let { newProcessBySourceId[it] } + ?: continue + val newPm = BomProcessMaterial().apply { + bomProcess = newProcess + bomMaterial = newMaterial + } + bomProcessMaterialRepository.saveAndFlush(newPm) + } + return@forEach + } + + val sourceMaterial = line.sourceBomMaterialId?.let { id -> + source.bomMaterials.firstOrNull { it.id == id } + } ?: source.bomMaterials.firstOrNull { + it.item?.code?.trim()?.equals(line.itemCode.trim(), ignoreCase = true) == true + } ?: return@forEach + + if (oldProcessIds.isEmpty()) return@forEach + val processMaterials = bomProcessMaterialRepository.findByBomProcess_IdIn(oldProcessIds) + for (pm in processMaterials) { + if (pm.bomMaterial?.id != sourceMaterial.id) continue + val oldProcessId = pm.bomProcess?.id ?: continue + val newProcess = newProcessBySourceId[oldProcessId] ?: continue + val newPm = BomProcessMaterial().apply { + bomProcess = newProcess + bomMaterial = newMaterial + } + bomProcessMaterialRepository.saveAndFlush(newPm) + } + } + } + + private fun hasMaterialChanges( + source: Bom, + request: SaveBomAsNewVersionRequest, + byId: Map, + byCode: Map, + ): Boolean { + if (request.materials.size != source.bomMaterials.size) return true + for (line in request.materials) { + val existing = line.sourceBomMaterialId?.let { byId[it] } + ?: byCode[line.itemCode.trim().lowercase()] + ?: return true + val sameQty = existing.qty?.compareTo(line.qty) == 0 + val sameUom = existing.uom?.id == line.uomId + val sameConsumable = + (existing.isConsumable ?: false) == (line.isConsumable ?: existing.isConsumable ?: false) + val sameProcessLinks = line.bomProcessIds.isEmpty() + if (!sameQty || !sameUom || !sameConsumable || !sameProcessLinks) return true + } + return false + } + + private fun hasUnresolvedMaterialUomIssue(bom: Bom): Boolean { + for (material in bom.bomMaterials) { + if (material.deleted == true) continue + val itemId = material.item?.id ?: return true + val qty = material.qty ?: return true + val recipeUom = material.uom ?: return true + val baseUom = itemUomService.findBaseUnitByItemId(itemId)?.uom ?: return true + if (recipeUom.id == baseUom.id) continue + try { + bomMaterialQtyService.deriveFromRecipe(itemId, qty, recipeUom) + } catch (_: Exception) { + return true + } + } + return false + } + + private fun buildBomMaterialFromRecipe( + bom: Bom, + item: Items, + line: BomMaterialVersionEditLine, + ): BomMaterial { + val recipeUom = uomConversionRepository.findById(line.uomId).orElseThrow { + BadRequestException("UOM not found: id=${line.uomId}") + } + bomMaterialQtyService.deriveFromRecipe(item.id!!, line.qty, recipeUom) + return BomMaterial().apply { + this.item = item + itemName = item.name + isConsumable = line.isConsumable ?: false + qty = line.qty + uom = recipeUom + uomName = recipeUom.udfudesc + this.bom = bom + m18LastModifyDate = LocalDateTime.now() + } + } + + @Transactional + open fun activateBomVersion(id: Long): BomDetailResponse { + val bom = bomRepository.findByIdAndDeletedIsFalse(id) + ?: throw NotFoundException() + val code = bom.code?.trim().orEmpty() + if (code.isEmpty()) { + throw BadRequestException("BOM code is required to activate version") + } + val bomKind = normalizeBomKindForImport(bom.bomKind) + markActiveVersionsInactiveForImport(code, bomKind) + bom.bomKind = bomKind + bom.status = BomStatus.ACTIVE + bomRepository.saveAndFlush(bom) + return getBomDetail(id) + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomUomAlignmentService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomUomAlignmentService.kt new file mode 100644 index 0000000..95f2f81 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomUomAlignmentService.kt @@ -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() + val materials = mutableListOf() + val skipped = mutableListOf() + val headerDone = mutableSetOf() + val itemUomCache = mutableMapOf() + + // 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 = 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, + ): 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 { + val warnings = mutableListOf() + 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 { + 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() + 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) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomCacheContext.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomCacheContext.kt new file mode 100644 index 0000000..85fc354 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomCacheContext.kt @@ -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>, + val uomById: Map, +) { + 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>, + uomById: Map, + ): ItemUomCacheContext = ItemUomCacheContext(uomsByItemId, uomById) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt index 275b80c..09ceb68 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemUomService.kt @@ -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, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index ce85564..d5cd526 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -266,6 +266,59 @@ open class ItemsService( return jdbcDao.queryForList(sql.toString(), args); } + open fun listBagItemsCombo(): List> { + 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 { + 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): List> { + 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): List> { try { println("=== Debug: getPickOrderItemsByPage in ItemsService ===") diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt index ee25ccb..564593b 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt @@ -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 { val issues = mutableListOf() 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, uomsByItemId: Map>, materialsByBomId: Map>, - uomCache: Map, + uomLookup: ItemUomCacheContext, ): Int { val headerKeys = mutableSetOf() val materialKeys = mutableSetOf() @@ -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, uomsByItemId: Map>, - uomCache: Map, + 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>, + uomLookup: ItemUomCacheContext, + ): List { + val hits = mutableListOf() + 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, uomsByItemId: Map>, - uomCache: Map = 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, ), ) diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt index 648b174..3952be7 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ProductionScheduleService.kt @@ -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( 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> = 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, + recipeUomById: Map, + ): 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() + 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, + ): 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> { @@ -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> { - - 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() + + 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( + "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> { - - 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() + + 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( + "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> { 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 diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt b/src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt new file mode 100644 index 0000000..a6f716e --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt @@ -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 = 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 = 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) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index 6721017..fe85685 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt @@ -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 { + // 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 { @@ -111,13 +159,7 @@ fun downloadBomFormatIssueLog( @GetMapping("/combo") fun getCombo( @RequestParam(defaultValue = "false") includeInactive: Boolean, - ): List { - return if (includeInactive) { - bomRepository.findBomComboByDeletedIsFalse() - } else { - bomRepository.findBomComboByDeletedIsFalseAndStatus(BomStatus.ACTIVE) - } - } + ): List = bomService.listBomCombos(includeInactive) @GetMapping("/combo/issues") fun getComboIssues(): List { @@ -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 { 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 = 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. diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt index ad812cd..ba8da36 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt @@ -45,6 +45,21 @@ class ItemsController( fun allConsumables(): List> { return itemsService.allConsumables() } + + @GetMapping("/bag-combo") + fun bagItemsCombo(): List> { + return itemsService.listBagItemsCombo() + } + + @GetMapping("/exists-by-code") + fun itemExistsByCode(@RequestParam code: String): Map { + return itemsService.itemExistsByCode(code) + } + + @PostMapping("/exists-by-codes") + fun itemsExistsByCodes(@RequestBody codes: List): List> { + return itemsService.itemsExistsByCodes(codes) + } // @GetMapping("/getRecordByPage") // fun getAllItemsByPage(@RequestBody filterRequest: HttpServletRequest): RecordsRes> { // val pageSize = filterRequest.getParameter("pageSize").toString().toInt(); // Default to 10 if not provided diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboDto.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboDto.kt new file mode 100644 index 0000000..20e0194 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboDto.kt @@ -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?, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomImportPreviewModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomImportPreviewModels.kt new file mode 100644 index 0000000..42d1af9 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomImportPreviewModels.kt @@ -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? = 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 = 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 = emptyList(), + val processes: List = emptyList(), +) + +data class BomImportPreviewResponse( + val items: List, +) + +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? = null, + val processes: List? = null, +) + +data class BomImportRevalidateRequest( + val batchId: String, + val fileName: String, + val overrides: BomImportItemOverrides? = null, +) + +data class BomImportRevalidateResponse( + val passed: Boolean, + val problems: List = 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 = emptyList(), +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomUomAlignmentModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomUomAlignmentModels.kt new file mode 100644 index 0000000..c42e855 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomUomAlignmentModels.kt @@ -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 = 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 = 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 = emptyList(), + val materials: List = emptyList(), + val skipped: List = emptyList(), +) + +data class BomUomAlignmentPreviewRequest( + val bomIds: List? = null, + val bomMaterialIds: List? = 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 = emptyList(), + val materials: List = emptyList(), +) + +data class BomUomAlignmentApplyResponse( + val headersUpdated: Int = 0, + val materialsUpdated: Int = 0, + val message: String? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomVersionModels.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomVersionModels.kt new file mode 100644 index 0000000..2ca2b03 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomVersionModels.kt @@ -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 = 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, + /** 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? = null, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt index 68567eb..84806e5 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt @@ -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? = null, diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt index b2130c6..9cb047a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt @@ -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 ) +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 = 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 = emptyList(), + val processStepIds: List = 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, val processes: List ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt index c38d793..ed22cfd 100644 --- a/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt +++ b/src/main/java/com/ffii/fpsms/modules/pickOrder/service/PickExecutionIssueService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt index c1a2429..2e06efb 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/entity/projections/ProductProcessInfo.kt @@ -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?, diff --git a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt index b789875..eafd8da 100644 --- a/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt +++ b/src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt @@ -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 { @@ -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 { + 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, ) } - } - - +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt index ed4fb6c..8c2f9b5 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/StockInLineInfo.kt @@ -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? diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 712ca3e..c1be17a 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt index 67d3f98..db6172f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt @@ -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(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 { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 03b76df..0e046f8 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -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 } diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt index 48692d9..fde90b9 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/SuggestedPickLotService.kt @@ -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) diff --git a/src/main/resources/db/changelog/changes/20260617_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260617_Enson/02_setting.sql new file mode 100644 index 0000000..5ac3b48 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260617_Enson/02_setting.sql @@ -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; \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260622_01_Enson/01_add_putaway_location_code_to_bom.sql b/src/main/resources/db/changelog/changes/20260622_01_Enson/01_add_putaway_location_code_to_bom.sql new file mode 100644 index 0000000..dea1767 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260622_01_Enson/01_add_putaway_location_code_to_bom.sql @@ -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; diff --git a/src/main/resources/db/changelog/changes/20260703_01_Enson/01_drop_bom_material_derived_qty_columns.sql b/src/main/resources/db/changelog/changes/20260703_01_Enson/01_drop_bom_material_derived_qty_columns.sql new file mode 100644 index 0000000..081b437 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260703_01_Enson/01_drop_bom_material_derived_qty_columns.sql @@ -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`; diff --git a/src/main/resources/db/changelog/changes/20260707_01_Enson/01_backfill_bom_revision_no.sql b/src/main/resources/db/changelog/changes/20260707_01_Enson/01_backfill_bom_revision_no.sql new file mode 100644 index 0000000..8ccfb9f --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260707_01_Enson/01_backfill_bom_revision_no.sql @@ -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; diff --git a/src/main/resources/db/changelog/changes/20260708_01_codex/01_create_index.sql b/src/main/resources/db/changelog/changes/20260708_01_codex/01_create_index.sql new file mode 100644 index 0000000..3293715 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260708_01_codex/01_create_index.sql @@ -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); \ No newline at end of file