From daa7cbce6530e1563dabf5bd3b4981deacbae8bf Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Thu, 16 Jul 2026 12:17:07 +0800 Subject: [PATCH] Import Bom Excel check if excel have issue will show and can veiw import data and can edit correct, and till isuue fix other wise not allow save. issue import bom check those check If Is FG but item LocationCode is null. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bom detail can edit bom have version compare can switch version of bom and select version to active/inactive 排程 imporve efficent of predect排程, export excel function, and else BOM/貨品單位問題 if current active bom uom can not convert to item base uom, will be issue show in this page if current active bom's bommaterail qty can not convert to item base uom , will be issue show in this page have fix form to update bom/bom materail data to help convert to base uom correct jobOrder Create if Bom have 單位問題, will not allow create Job Order bom martail remove column base/stock/sale qty/uom bom qty/uom from store stock change to base And these two have realte convert function to convert back realte uom/qty need to use --- .../fpsms/m18/service/M18BomForShopService.kt | 35 +- .../modules/chart/service/ChartService.kt | 2 +- .../jobOrder/service/JoPickOrderService.kt | 10 +- .../service/JoWorkbenchMainService.kt | 4 +- .../service/JobOrderBomMaterialService.kt | 36 +- .../jobOrder/service/JobOrderService.kt | 22 +- .../modules/jobOrder/service/PSService.kt | 171 +- .../ffii/fpsms/modules/master/entity/Bom.kt | 11 +- .../modules/master/entity/BomMaterial.kt | 21 - .../master/entity/BomMaterialRepository.kt | 14 +- .../fpsms/modules/master/entity/BomProcess.kt | 10 +- .../modules/master/entity/BomRepository.kt | 64 +- .../master/entity/EquipmentRepository.kt | 2 + .../modules/master/entity/ItemsRepository.kt | 4 + .../master/entity/projections/BomCombo.kt | 8 +- .../master/service/BomMaterialQtyService.kt | 295 +++ .../service/BomOutputQtyMigrationRunner.kt | 36 + .../master/service/BomOutputQtyService.kt | 162 ++ .../master/service/BomProcessConstants.kt | 6 + .../service/BomRecipeUomOptionsService.kt | 82 + .../master/service/BomScaleValidation.kt | 70 + .../modules/master/service/BomService.kt | 2116 ++++++++++++----- .../master/service/BomUomAlignmentService.kt | 804 +++++++ .../master/service/ItemUomCacheContext.kt | 53 + .../modules/master/service/ItemUomService.kt | 123 +- .../modules/master/service/ItemsService.kt | 53 + .../master/service/MasterDataIssueService.kt | 359 +-- .../service/ProductionScheduleService.kt | 707 ++++-- .../master/service/StandardUomMatrix.kt | 74 + .../fpsms/modules/master/web/BomController.kt | 99 +- .../modules/master/web/ItemsController.kt | 15 + .../modules/master/web/models/BomComboDto.kt | 17 + .../web/models/BomImportPreviewModels.kt | 117 + .../web/models/BomUomAlignmentModels.kt | 153 ++ .../master/web/models/BomVersionModels.kt | 43 + .../master/web/models/EditBomRequest.kt | 1 + .../master/web/models/ItemUomRequest.kt | 42 +- .../service/PickExecutionIssueService.kt | 18 +- .../entity/projections/ProductProcessInfo.kt | 6 +- .../service/ProductProcessService.kt | 173 +- .../entity/projection/StockInLineInfo.kt | 2 +- .../stock/service/InventoryLotLineService.kt | 4 +- .../stock/service/StockInLineService.kt | 14 +- .../stock/service/StockOutLineService.kt | 10 +- .../stock/service/SuggestedPickLotService.kt | 6 +- .../changes/20260617_Enson/02_setting.sql | 28 + .../01_add_putaway_location_code_to_bom.sql | 10 + ..._drop_bom_material_derived_qty_columns.sql | 33 + .../01_backfill_bom_revision_no.sql | 22 + .../20260708_01_codex/01_create_index.sql | 16 + 50 files changed, 4894 insertions(+), 1289 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/BomMaterialQtyService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyMigrationRunner.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/BomOutputQtyService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/BomProcessConstants.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/BomRecipeUomOptionsService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/BomScaleValidation.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/BomUomAlignmentService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/ItemUomCacheContext.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/StandardUomMatrix.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboDto.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/BomImportPreviewModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/BomUomAlignmentModels.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/BomVersionModels.kt create mode 100644 src/main/resources/db/changelog/changes/20260617_Enson/02_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20260622_01_Enson/01_add_putaway_location_code_to_bom.sql create mode 100644 src/main/resources/db/changelog/changes/20260703_01_Enson/01_drop_bom_material_derived_qty_columns.sql create mode 100644 src/main/resources/db/changelog/changes/20260707_01_Enson/01_backfill_bom_revision_no.sql create mode 100644 src/main/resources/db/changelog/changes/20260708_01_codex/01_create_index.sql 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