item/bom uom issue page new bad item hnadle page do if uomid same qty =m18qtyproduction
| @@ -407,14 +407,27 @@ open class M18DeliveryOrderService( | |||||
| itemUomService.findByM18Id(line.unitId) | itemUomService.findByM18Id(line.unitId) | ||||
| } | } | ||||
| val m18UomId = itemUom?.uom?.id | |||||
| val sourceQty = line.qty | |||||
| val stockQty = | |||||
| if (itemId != null && m18UomId != null && m18UomId == stockUomId) { | |||||
| // M18 line unit is already the stock unit — skip ratio conversion | |||||
| // (avoids bad qty when item_uom ratioN/ratioD hold spec numbers like 350g). | |||||
| sourceQty | |||||
| } else if (itemId != null && m18UomId != null) { | |||||
| itemUomService.convertQtyToStockQty(itemId, m18UomId, sourceQty) | |||||
| } else { | |||||
| sourceQty | |||||
| } | |||||
| val saveDeliveryOrderLineRequest = SaveDeliveryOrderLineRequest( | val saveDeliveryOrderLineRequest = SaveDeliveryOrderLineRequest( | ||||
| id = existingDeliveryOrderLine?.id, | id = existingDeliveryOrderLine?.id, | ||||
| itemId = itemId, | itemId = itemId, | ||||
| uomIdM18 = itemUom?.uom?.id, | |||||
| uomIdM18 = m18UomId, | |||||
| uomId= stockUomId, | uomId= stockUomId, | ||||
| deliveryOrderId = deliveryOrderId, | deliveryOrderId = deliveryOrderId, | ||||
| qtyM18 = line.qty, | |||||
| qty = itemUomService.convertQtyToStockQty(itemId?:0, itemUom?.uom?.id?: 0, line.qty), | |||||
| qtyM18 = sourceQty, | |||||
| qty = stockQty, | |||||
| up = line.up, | up = line.up, | ||||
| price = line.amt, | price = line.amt, | ||||
| // m18CurrencyId = mainpo.curId, | // m18CurrencyId = mainpo.curId, | ||||
| @@ -1,6 +1,7 @@ | |||||
| package com.ffii.fpsms.modules.master.entity | package com.ffii.fpsms.modules.master.entity | ||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import org.springframework.data.jpa.repository.Query | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| @@ -15,4 +16,17 @@ interface BomMaterialRepository : AbstractRepository<BomMaterial, Long> { | |||||
| fun findAllByBomIdAndDeletedIsFalse(bomId: Long): List<BomMaterial> | fun findAllByBomIdAndDeletedIsFalse(bomId: Long): List<BomMaterial> | ||||
| fun findByBomIdAndItemId(bomId: Long, itemId: Long): BomMaterial? | fun findByBomIdAndItemId(bomId: Long, itemId: Long): BomMaterial? | ||||
| /** Single round-trip for master-data scans (avoids per-bom N+1). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT bm FROM BomMaterial bm | |||||
| JOIN FETCH bm.bom b | |||||
| LEFT JOIN FETCH bm.item | |||||
| LEFT JOIN FETCH bm.uom | |||||
| LEFT JOIN FETCH bm.salesUnit | |||||
| WHERE bm.deleted = false AND b.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllActiveForActiveBoms(): List<BomMaterial> | |||||
| } | } | ||||
| @@ -10,6 +10,16 @@ import org.springframework.data.repository.query.Param | |||||
| interface BomRepository : AbstractRepository<Bom, Long> { | interface BomRepository : AbstractRepository<Bom, Long> { | ||||
| fun findAllByDeletedIsFalse(): List<Bom> | fun findAllByDeletedIsFalse(): List<Bom> | ||||
| @Query( | |||||
| """ | |||||
| SELECT b FROM Bom b | |||||
| LEFT JOIN FETCH b.item | |||||
| LEFT JOIN FETCH b.uom | |||||
| WHERE b.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllActiveWithItemAndUom(): List<Bom> | |||||
| fun findByIdAndDeletedIsFalse(id: Serializable): Bom? | fun findByIdAndDeletedIsFalse(id: Serializable): Bom? | ||||
| fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? | fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? | ||||
| @@ -9,6 +9,28 @@ import java.io.Serializable | |||||
| interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | ||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List<ItemUom> | fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List<ItemUom> | ||||
| /** All active item_uom rows with uom_conversion (single round-trip for master-data scans). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT iu FROM ItemUom iu | |||||
| JOIN FETCH iu.uom | |||||
| JOIN FETCH iu.item i | |||||
| WHERE iu.deleted = false AND i.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllActiveWithUom(): List<ItemUom> | |||||
| /** Soft-deleted item_uom rows (for master-data issue snapshots). */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT iu FROM ItemUom iu | |||||
| JOIN FETCH iu.uom | |||||
| JOIN FETCH iu.item i | |||||
| WHERE iu.deleted = true AND i.deleted = false | |||||
| """, | |||||
| ) | |||||
| fun findAllDeletedWithUom(): List<ItemUom> | |||||
| fun findByIdAndDeletedIsFalse(id: Serializable): ItemUom? | fun findByIdAndDeletedIsFalse(id: Serializable): ItemUom? | ||||
| fun findByM18IdAndDeletedIsFalse(m18Id: Serializable): ItemUom? | fun findByM18IdAndDeletedIsFalse(m18Id: Serializable): ItemUom? | ||||
| @@ -59,6 +59,7 @@ open class BomService( | |||||
| private val equipmentDetailRepository: EquipmentDetailRepository, | private val equipmentDetailRepository: EquipmentDetailRepository, | ||||
| private val bomWeightingScoreRepository: BomWeightingScoreRepository, | private val bomWeightingScoreRepository: BomWeightingScoreRepository, | ||||
| private val itemUomService: ItemUomService, | private val itemUomService: ItemUomService, | ||||
| private val masterDataIssueService: MasterDataIssueService, | |||||
| private val jobOrderRepository: JobOrderRepository, | private val jobOrderRepository: JobOrderRepository, | ||||
| private val productProcessRepository: ProductProcessRepository, | private val productProcessRepository: ProductProcessRepository, | ||||
| private val m18BomForShopService: M18BomForShopService, | private val m18BomForShopService: M18BomForShopService, | ||||
| @@ -118,6 +119,11 @@ open class BomService( | |||||
| return bomRepository.findAll() | return bomRepository.findAll() | ||||
| } | } | ||||
| /** @deprecated Use [MasterDataIssueService.findBomMasterDataIssues]; kept for /bom/combo/issues. */ | |||||
| @Transactional(readOnly = true) | |||||
| open fun findComboIssues(): List<MasterDataIssueResponse> = | |||||
| masterDataIssueService.findBomMasterDataIssues() | |||||
| open fun findById(id: Long): Bom? { | open fun findById(id: Long): Bom? { | ||||
| return bomRepository.findByIdAndDeletedIsFalse(id) | return bomRepository.findByIdAndDeletedIsFalse(id) | ||||
| } | } | ||||
| @@ -223,6 +229,13 @@ open class BomService( | |||||
| request.timeSequence?.let { bom.timeSequence = it } | request.timeSequence?.let { bom.timeSequence = it } | ||||
| request.complexity?.let { bom.complexity = it } | request.complexity?.let { bom.complexity = it } | ||||
| request.isDrink?.let { bom.isDrink = it } | request.isDrink?.let { bom.isDrink = it } | ||||
| if (request.isDrink != null || request.isPowderMixture != null) { | |||||
| bom.type = when { | |||||
| bom.isDrink == true -> "Drink" | |||||
| request.isPowderMixture == true -> "Powder_Mixture" | |||||
| else -> "Other" | |||||
| } | |||||
| } | |||||
| val replaceMaterials = request.materials != null | val replaceMaterials = request.materials != null | ||||
| val replaceProcesses = request.processes != null | val replaceProcesses = request.processes != null | ||||
| @@ -1610,8 +1623,9 @@ open class BomService( | |||||
| .forEach { path -> | .forEach { path -> | ||||
| val filename = path.fileName.toString() | val filename = path.fileName.toString() | ||||
| val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true | val isAlsoWip = items.find { it.fileName == filename }?.isAlsoWip == true | ||||
| val isDrink= items.find { it.fileName == filename }?.isDrink == true | |||||
| println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip") | |||||
| val isDrink = items.find { it.fileName == filename }?.isDrink == true | |||||
| val isPowderMixture = items.find { it.fileName == filename }?.isPowderMixture == true | |||||
| println("importBOM: filename='$filename', isAlsoWip=$isAlsoWip, isDrink=$isDrink, isPowderMixture=$isPowderMixture") | |||||
| try { | try { | ||||
| FileInputStream(path.toFile()).use { input -> | FileInputStream(path.toFile()).use { input -> | ||||
| val workbook2: Workbook = XSSFWorkbook(input) | val workbook2: Workbook = XSSFWorkbook(input) | ||||
| @@ -1628,6 +1642,11 @@ open class BomService( | |||||
| } | } | ||||
| val bom = importExcelBomBasicInfo(sheet) | val bom = importExcelBomBasicInfo(sheet) | ||||
| bom.isDrink = isDrink | bom.isDrink = isDrink | ||||
| bom.type = when { | |||||
| isDrink -> "Drink" | |||||
| isPowderMixture -> "Powder_Mixture" | |||||
| else -> "Other" | |||||
| } | |||||
| bomRepository.saveAndFlush(bom) | bomRepository.saveAndFlush(bom) | ||||
| importExcelBomProcess(bom, sheet) | importExcelBomProcess(bom, sheet) | ||||
| importExcelBomMaterial(bom, sheet) | importExcelBomMaterial(bom, sheet) | ||||
| @@ -1679,6 +1698,7 @@ open class BomService( | |||||
| allergicSubstances = fgBom.allergicSubstances | allergicSubstances = fgBom.allergicSubstances | ||||
| uom = fgBom.uom | uom = fgBom.uom | ||||
| isDrink = fgBom.isDrink | isDrink = fgBom.isDrink | ||||
| type = fgBom.type | |||||
| } | } | ||||
| wipBom.baseScore = calculateBaseScore(wipBom) | wipBom.baseScore = calculateBaseScore(wipBom) | ||||
| bomRepository.saveAndFlush(wipBom) | bomRepository.saveAndFlush(wipBom) | ||||
| @@ -1702,6 +1722,7 @@ open class BomService( | |||||
| allergicSubstances = fgBom.allergicSubstances | allergicSubstances = fgBom.allergicSubstances | ||||
| uom = fgBom.uom | uom = fgBom.uom | ||||
| isDrink = fgBom.isDrink | isDrink = fgBom.isDrink | ||||
| type = fgBom.type | |||||
| } | } | ||||
| wipBom.baseScore = calculateBaseScore(wipBom) | wipBom.baseScore = calculateBaseScore(wipBom) | ||||
| bomRepository.saveAndFlush(wipBom) | bomRepository.saveAndFlush(wipBom) | ||||
| @@ -2884,6 +2905,7 @@ println("=====================================") | |||||
| isFloat = bom.isFloat, | isFloat = bom.isFloat, | ||||
| isDense = bom.isDense, | isDense = bom.isDense, | ||||
| isDrink = bom.isDrink, | isDrink = bom.isDrink, | ||||
| isPowderMixture = bom.type?.equals("Powder_Mixture", ignoreCase = true) == true, | |||||
| scrapRate = bom.scrapRate, | scrapRate = bom.scrapRate, | ||||
| allergicSubstances = bom.allergicSubstances, | allergicSubstances = bom.allergicSubstances, | ||||
| timeSequence = bom.timeSequence, | timeSequence = bom.timeSequence, | ||||
| @@ -0,0 +1,888 @@ | |||||
| 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.ItemUom | |||||
| 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.web.models.MasterDataIssueResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryTiming | |||||
| import java.time.LocalDateTime | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| @Service | |||||
| open class MasterDataIssueService( | |||||
| private val bomRepository: BomRepository, | |||||
| private val bomMaterialRepository: BomMaterialRepository, | |||||
| private val itemsRepository: ItemsRepository, | |||||
| private val itemUomRespository: ItemUomRespository, | |||||
| private val uomConversionService: UomConversionService, | |||||
| ) { | |||||
| data class UnitHealth( | |||||
| val value: String?, | |||||
| val correct: Boolean, | |||||
| val modifiedAt: String?, | |||||
| /** OK | MISSING | DELETED */ | |||||
| val status: String, | |||||
| ) | |||||
| data class UnitSnapshot( | |||||
| val base: UnitHealth, | |||||
| val stock: UnitHealth, | |||||
| val purchase: UnitHealth, | |||||
| val sales: UnitHealth, | |||||
| ) | |||||
| data class IssueContext( | |||||
| val scope: String, | |||||
| val bomId: Long? = null, | |||||
| val bomCode: String? = null, | |||||
| val bomName: String? = null, | |||||
| val bomMaterialId: Long? = null, | |||||
| val description: String? = null, | |||||
| ) | |||||
| @Transactional(readOnly = true) | |||||
| open fun findBomMasterDataIssues(): List<MasterDataIssueResponse> { | |||||
| val issues = mutableListOf<MasterDataIssueResponse>() | |||||
| val uomsByItemId = loadActiveUomsByItemId() | |||||
| val materials = bomMaterialRepository.findAllActiveForActiveBoms() | |||||
| val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } | |||||
| val uomCache = buildUomCache(uomsByItemId, emptyMap(), materials) | |||||
| val boms = bomRepository.findAllActiveWithItemAndUom() | |||||
| 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) | |||||
| } | |||||
| } | |||||
| return sortIssues(issues) | |||||
| } | |||||
| @Transactional(readOnly = true) | |||||
| open fun findItemMasterDataIssues(): List<MasterDataIssueResponse> { | |||||
| val issues = mutableListOf<MasterDataIssueResponse>() | |||||
| val uomsByItemId = loadActiveUomsByItemId() | |||||
| val deletedUomsByItemId = loadDeletedUomsByItemId() | |||||
| val items = itemsRepository.findAllByDeletedFalse() | |||||
| val ctx = IssueContext(scope = "ITEM") | |||||
| for (item in items) { | |||||
| collectItemUomIssues(item, ctx, issues, uomsByItemId, deletedUomsByItemId) | |||||
| } | |||||
| return sortIssues(issues) | |||||
| } | |||||
| @Transactional(readOnly = true) | |||||
| open fun findMasterDataIssuesSummary(includeTiming: Boolean = false): MasterDataIssueSummaryResponse { | |||||
| val totalStart = System.nanoTime() | |||||
| var loadActiveUomsMs = 0L | |||||
| var loadDeletedUomsMs = 0L | |||||
| var loadMaterialsMs = 0L | |||||
| var buildUomCacheMs = 0L | |||||
| var loadBomsMs = 0L | |||||
| var scanBomTabMs = 0L | |||||
| var loadItemsMs = 0L | |||||
| var scanItemTabMs = 0L | |||||
| val uomsByItemId = | |||||
| if (includeTiming) { | |||||
| timed { loadActiveUomsByItemId() }.also { loadActiveUomsMs = it.second }.first | |||||
| } else { | |||||
| loadActiveUomsByItemId() | |||||
| } | |||||
| val deletedUomsByItemId = | |||||
| if (includeTiming) { | |||||
| timed { loadDeletedUomsByItemId() }.also { loadDeletedUomsMs = it.second }.first | |||||
| } else { | |||||
| loadDeletedUomsByItemId() | |||||
| } | |||||
| val materials = | |||||
| if (includeTiming) { | |||||
| timed { bomMaterialRepository.findAllActiveForActiveBoms() } | |||||
| .also { loadMaterialsMs = it.second } | |||||
| .first | |||||
| } else { | |||||
| bomMaterialRepository.findAllActiveForActiveBoms() | |||||
| } | |||||
| val materialsByBomId = materials.groupBy { it.bom?.id ?: -1L } | |||||
| val uomCache = | |||||
| if (includeTiming) { | |||||
| timed { buildUomCache(uomsByItemId, deletedUomsByItemId, materials) } | |||||
| .also { buildUomCacheMs = it.second } | |||||
| .first | |||||
| } else { | |||||
| buildUomCache(uomsByItemId, deletedUomsByItemId, materials) | |||||
| } | |||||
| val boms = | |||||
| if (includeTiming) { | |||||
| timed { bomRepository.findAllActiveWithItemAndUom() }.also { loadBomsMs = it.second }.first | |||||
| } else { | |||||
| bomRepository.findAllActiveWithItemAndUom() | |||||
| } | |||||
| val bomGroupCount = | |||||
| if (includeTiming) { | |||||
| timed { | |||||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache) | |||||
| }.also { scanBomTabMs = it.second }.first | |||||
| } else { | |||||
| scanBomTabGroupCount(boms, uomsByItemId, materialsByBomId, uomCache) | |||||
| } | |||||
| val items = | |||||
| if (includeTiming) { | |||||
| timed { itemsRepository.findAllByDeletedFalse() }.also { loadItemsMs = it.second }.first | |||||
| } else { | |||||
| itemsRepository.findAllByDeletedFalse() | |||||
| } | |||||
| val itemGroupCount = | |||||
| if (includeTiming) { | |||||
| timed { scanItemTabGroupCount(items, uomsByItemId, deletedUomsByItemId) } | |||||
| .also { scanItemTabMs = it.second } | |||||
| .first | |||||
| } else { | |||||
| scanItemTabGroupCount(items, uomsByItemId, deletedUomsByItemId) | |||||
| } | |||||
| val timing = | |||||
| if (includeTiming) { | |||||
| MasterDataIssueSummaryTiming( | |||||
| totalMs = (System.nanoTime() - totalStart) / 1_000_000, | |||||
| loadActiveUomsMs = loadActiveUomsMs, | |||||
| loadDeletedUomsMs = loadDeletedUomsMs, | |||||
| loadMaterialsMs = loadMaterialsMs, | |||||
| buildUomCacheMs = buildUomCacheMs, | |||||
| loadBomsMs = loadBomsMs, | |||||
| scanBomTabMs = scanBomTabMs, | |||||
| loadItemsMs = loadItemsMs, | |||||
| scanItemTabMs = scanItemTabMs, | |||||
| ) | |||||
| } else { | |||||
| null | |||||
| } | |||||
| return MasterDataIssueSummaryResponse( | |||||
| bomGroupCount = bomGroupCount, | |||||
| itemGroupCount = itemGroupCount, | |||||
| totalGroupCount = bomGroupCount + itemGroupCount, | |||||
| timing = timing, | |||||
| ) | |||||
| } | |||||
| private inline fun <T> timed(block: () -> T): Pair<T, Long> { | |||||
| val start = System.nanoTime() | |||||
| val result = block() | |||||
| return result to (System.nanoTime() - start) / 1_000_000 | |||||
| } | |||||
| /** | |||||
| * Fast BOM-tab group count: one bom fetch, one material batch, no issue DTOs. | |||||
| * Group keys match [groupBomTabIssues] on the frontend. | |||||
| */ | |||||
| private fun scanBomTabGroupCount( | |||||
| boms: List<Bom>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| materialsByBomId: Map<Long, List<BomMaterial>>, | |||||
| uomCache: Map<Long, UomConversion>, | |||||
| ): Int { | |||||
| val headerKeys = mutableSetOf<String>() | |||||
| val materialKeys = mutableSetOf<String>() | |||||
| for (bom in boms) { | |||||
| scanBomHeaderGroupKeys(bom, headerKeys, uomsByItemId) | |||||
| val bomId = bom.id ?: continue | |||||
| for (material in materialsByBomId[bomId].orEmpty()) { | |||||
| scanBomMaterialGroupKeys(bom, material, materialKeys, uomsByItemId, uomCache) | |||||
| } | |||||
| } | |||||
| return headerKeys.size + materialKeys.size | |||||
| } | |||||
| /** Fast item-tab group count; skips PICKING-only items (matches UI filter). */ | |||||
| private fun scanItemTabGroupCount( | |||||
| items: List<Items>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ): Int { | |||||
| val keys = mutableSetOf<String>() | |||||
| for (item in items) { | |||||
| if (!itemHasNonPickingTabIssue(item, uomsByItemId, deletedUomsByItemId)) continue | |||||
| val itemId = item.id | |||||
| val key = | |||||
| if (itemId != null) { | |||||
| "item:$itemId" | |||||
| } else { | |||||
| "code:${item.code?.trim().orEmpty().ifBlank { "unknown" }}" | |||||
| } | |||||
| keys.add(key) | |||||
| } | |||||
| return keys.size | |||||
| } | |||||
| private fun scanBomHeaderGroupKeys( | |||||
| bom: Bom, | |||||
| headerKeys: MutableSet<String>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ) { | |||||
| val bomId = bom.id ?: return | |||||
| val bomKey = "bom:$bomId" | |||||
| var hasIssue = false | |||||
| if (bom.code.isNullOrBlank()) hasIssue = true | |||||
| if (bom.name.isNullOrBlank()) hasIssue = true | |||||
| val item = bom.item | |||||
| val itemId = item?.id | |||||
| val unitSnapshot = buildUnitSnapshot(itemId, uomsByItemId) | |||||
| if (item == null || item.deleted == true) { | |||||
| hasIssue = true | |||||
| } else { | |||||
| 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 | |||||
| if (bomUom != null) { | |||||
| val uomDesc = bomUom.udfudesc?.trim().orEmpty() | |||||
| val outputDesc = bom.outputQtyUom?.trim().orEmpty() | |||||
| if (outputDesc.isNotEmpty() && uomDesc.isNotEmpty() && !outputDesc.equals(uomDesc, ignoreCase = true)) { | |||||
| hasIssue = true | |||||
| } | |||||
| } | |||||
| } | |||||
| if (hasIssue) headerKeys.add(bomKey) | |||||
| } | |||||
| private fun scanBomMaterialGroupKeys( | |||||
| bom: Bom, | |||||
| material: BomMaterial, | |||||
| materialKeys: MutableSet<String>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| uomCache: Map<Long, UomConversion>, | |||||
| ) { | |||||
| val matItem = material.item | |||||
| val unitSnapshot = buildUnitSnapshot(matItem?.id, uomsByItemId) | |||||
| val itemId = matItem?.id | |||||
| val itemCode = matItem?.code | |||||
| val bomMaterialId = material.id | |||||
| fun addMaterialKey(issueCode: String, expected: String? = null, actual: String? = null) { | |||||
| val itemKey = | |||||
| if (itemId != null) { | |||||
| "i:$itemId" | |||||
| } else { | |||||
| "ic:${itemCode?.trim().orEmpty().ifBlank { bomMaterialId?.toString() ?: "unknown" }}" | |||||
| } | |||||
| val patternKey = materialPatternKey(issueCode, expected, actual) | |||||
| materialKeys.add("$itemKey|$patternKey") | |||||
| } | |||||
| if (matItem == null || matItem.deleted == true) { | |||||
| addMaterialKey("BOM_MATERIAL_MISSING_ITEM") | |||||
| return | |||||
| } | |||||
| val matUom = material.uom | |||||
| if (matUom != null && matUom.deleted == true) { | |||||
| addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "uomId=${matUom.id}") | |||||
| } | |||||
| val salesUnitFk = material.salesUnit | |||||
| if (salesUnitFk != null && salesUnitFk.deleted == true) { | |||||
| addMaterialKey("BOM_MATERIAL_UOM_FK_INVALID", actual = "salesUnitId=${salesUnitFk.id}") | |||||
| } | |||||
| 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 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), | |||||
| ) | |||||
| } | |||||
| 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), | |||||
| ) | |||||
| } | |||||
| } | |||||
| 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 itemHasNonPickingTabIssue( | |||||
| item: Items, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ): Boolean { | |||||
| val itemId = item.id ?: return false | |||||
| val snapshot = buildUnitSnapshot(itemId, uomsByItemId, deletedUomsByItemId) ?: return true | |||||
| val allUoms = uomsForItem(itemId, uomsByItemId) | |||||
| for (unitKey in ITEM_TAB_UNIT_KEYS) { | |||||
| if (itemUnitWouldIssue(item, allUoms, unitKey, snapshot)) return true | |||||
| } | |||||
| return false | |||||
| } | |||||
| private fun itemHasAnyUnitIssue( | |||||
| item: Items, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ): Boolean { | |||||
| val itemId = item.id ?: return false | |||||
| val allUoms = uomsForItem(itemId, uomsByItemId) | |||||
| for (unitKey in ALL_UNIT_KEYS) { | |||||
| if (itemUnitWouldIssue(item, allUoms, unitKey, unitSnapshot)) return true | |||||
| } | |||||
| return false | |||||
| } | |||||
| private fun itemUnitWouldIssue( | |||||
| item: Items, | |||||
| allUoms: List<ItemUom>, | |||||
| unitKey: String, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ): Boolean { | |||||
| val flag = unitFlag(unitKey) | |||||
| val rows = allUoms.filter(flag) | |||||
| return when { | |||||
| rows.isEmpty() -> true | |||||
| rows.size > 1 -> true | |||||
| else -> uomRowInvalid(rows.first()) | |||||
| } | |||||
| } | |||||
| private fun uomRowInvalid(row: ItemUom): Boolean { | |||||
| val uom = row.uom | |||||
| return uom == null || uom.deleted == true | |||||
| } | |||||
| private fun unitFlag(unitKey: String): (ItemUom) -> Boolean = | |||||
| when (unitKey) { | |||||
| "BASE" -> { it -> it.baseUnit == true } | |||||
| "SALES" -> { it -> it.salesUnit == true } | |||||
| "STOCK" -> { it -> it.stockUnit == true } | |||||
| "PURCHASE" -> { it -> it.purchaseUnit == true } | |||||
| "PICKING" -> { it -> it.pickingUnit == true } | |||||
| else -> { _ -> false } | |||||
| } | |||||
| private fun buildUomCache( | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>>, | |||||
| materials: List<BomMaterial> = emptyList(), | |||||
| ): Map<Long, UomConversion> { | |||||
| val map = mutableMapOf<Long, UomConversion>() | |||||
| fun put(uom: UomConversion?) { | |||||
| val id = uom?.id ?: return | |||||
| map[id] = uom | |||||
| } | |||||
| for (rows in uomsByItemId.values) { | |||||
| for (row in rows) put(row.uom) | |||||
| } | |||||
| for (rows in deletedUomsByItemId.values) { | |||||
| for (row in rows) put(row.uom) | |||||
| } | |||||
| for (material in materials) { | |||||
| put(material.uom) | |||||
| put(material.salesUnit) | |||||
| } | |||||
| return map | |||||
| } | |||||
| companion object { | |||||
| private val ITEM_TAB_UNIT_KEYS = listOf("BASE", "SALES", "STOCK", "PURCHASE") | |||||
| private val ALL_UNIT_KEYS = listOf("BASE", "SALES", "STOCK", "PURCHASE", "PICKING") | |||||
| } | |||||
| /** One query: all active item_uom + uom_conversion, grouped by itemId. */ | |||||
| private fun loadActiveUomsByItemId(): Map<Long, List<ItemUom>> { | |||||
| val grouped = mutableMapOf<Long, MutableList<ItemUom>>() | |||||
| for (row in itemUomRespository.findAllActiveWithUom()) { | |||||
| val itemId = row.item?.id | |||||
| ?: continue | |||||
| grouped.computeIfAbsent(itemId) { mutableListOf() }.add(row) | |||||
| } | |||||
| return grouped | |||||
| } | |||||
| private fun loadDeletedUomsByItemId(): Map<Long, List<ItemUom>> { | |||||
| val grouped = mutableMapOf<Long, MutableList<ItemUom>>() | |||||
| for (row in itemUomRespository.findAllDeletedWithUom()) { | |||||
| val itemId = row.item?.id ?: continue | |||||
| grouped.computeIfAbsent(itemId) { mutableListOf() }.add(row) | |||||
| } | |||||
| return grouped | |||||
| } | |||||
| private fun uomsForItem(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): List<ItemUom> = | |||||
| uomsByItemId[itemId] ?: emptyList() | |||||
| private fun rowModifiedTime(row: ItemUom): LocalDateTime? = | |||||
| row.modified ?: row.m18LastModifyDate | |||||
| private fun newestRow(rows: List<ItemUom>): ItemUom? = | |||||
| rows.maxByOrNull { rowModifiedTime(it) ?: LocalDateTime.MIN } | |||||
| private fun findSalesUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? = | |||||
| uomsForItem(itemId, uomsByItemId).firstOrNull { it.salesUnit == true } | |||||
| private fun findStockUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? = | |||||
| uomsForItem(itemId, uomsByItemId).firstOrNull { it.stockUnit == true } | |||||
| private fun findBaseUnitRow(itemId: Long, uomsByItemId: Map<Long, List<ItemUom>>): ItemUom? = | |||||
| uomsForItem(itemId, uomsByItemId).firstOrNull { it.baseUnit == true } | |||||
| private fun evaluateUnitHealth( | |||||
| activeUoms: List<ItemUom>, | |||||
| deletedUoms: List<ItemUom>, | |||||
| flag: (ItemUom) -> Boolean, | |||||
| ): UnitHealth { | |||||
| val active = activeUoms.filter(flag) | |||||
| when { | |||||
| active.size == 1 -> { | |||||
| val row = active.first() | |||||
| val uom = row.uom | |||||
| if (uom == null || uom.deleted == true) { | |||||
| return UnitHealth( | |||||
| value = formatUom(uom).takeIf { uom != null }, | |||||
| correct = false, | |||||
| modifiedAt = rowModifiedTime(row)?.toString(), | |||||
| status = "MISSING", | |||||
| ) | |||||
| } | |||||
| return UnitHealth( | |||||
| value = formatUom(uom), | |||||
| correct = true, | |||||
| modifiedAt = rowModifiedTime(row)?.toString(), | |||||
| status = "OK", | |||||
| ) | |||||
| } | |||||
| active.size > 1 -> { | |||||
| val row = newestRow(active) | |||||
| return UnitHealth( | |||||
| value = row?.let { formatUom(it.uom) }, | |||||
| correct = false, | |||||
| modifiedAt = active.mapNotNull { rowModifiedTime(it) }.maxOrNull()?.toString(), | |||||
| status = "MISSING", | |||||
| ) | |||||
| } | |||||
| else -> { | |||||
| val deleted = deletedUoms.filter(flag) | |||||
| if (deleted.isNotEmpty()) { | |||||
| val row = newestRow(deleted)!! | |||||
| return UnitHealth( | |||||
| value = formatUom(row.uom), | |||||
| correct = false, | |||||
| modifiedAt = rowModifiedTime(row)?.toString(), | |||||
| status = "DELETED", | |||||
| ) | |||||
| } | |||||
| return UnitHealth( | |||||
| value = null, | |||||
| correct = false, | |||||
| modifiedAt = null, | |||||
| status = "MISSING", | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun buildUnitSnapshot( | |||||
| itemId: Long?, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>> = emptyMap(), | |||||
| ): UnitSnapshot? { | |||||
| if (itemId == null) return null | |||||
| val active = uomsForItem(itemId, uomsByItemId) | |||||
| val deleted = uomsForItem(itemId, deletedUomsByItemId) | |||||
| return UnitSnapshot( | |||||
| base = evaluateUnitHealth(active, deleted) { it.baseUnit == true }, | |||||
| stock = evaluateUnitHealth(active, deleted) { it.stockUnit == true }, | |||||
| purchase = evaluateUnitHealth(active, deleted) { it.purchaseUnit == true }, | |||||
| sales = evaluateUnitHealth(active, deleted) { it.salesUnit == true }, | |||||
| ) | |||||
| } | |||||
| private fun collectBomHeaderIssues( | |||||
| bom: Bom, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| ) { | |||||
| val bomId = bom.id ?: return | |||||
| val bomCode = bom.code | |||||
| val bomName = bom.name | |||||
| val description = bom.description | |||||
| val ctx = IssueContext( | |||||
| scope = "BOM", | |||||
| bomId = bomId, | |||||
| bomCode = bomCode, | |||||
| bomName = bomName, | |||||
| description = description, | |||||
| ) | |||||
| 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 | |||||
| val unitSnapshot = buildUnitSnapshot(itemId, uomsByItemId) | |||||
| if (item == null || item.deleted == true) { | |||||
| issues.add(issue(ctx, itemId, "MISSING_ITEM", unitSnapshot = unitSnapshot)) | |||||
| return | |||||
| } | |||||
| 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) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "BOM_OUTPUT_UOM_MISMATCH_SALES", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| expectedValue = formatUom(salesConv), | |||||
| actualValue = formatUom(bomUom), | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| if (bomUom != null) { | |||||
| val uomDesc = bomUom.udfudesc?.trim().orEmpty() | |||||
| val outputDesc = bom.outputQtyUom?.trim().orEmpty() | |||||
| if (outputDesc.isNotEmpty() && uomDesc.isNotEmpty() && !outputDesc.equals(uomDesc, ignoreCase = true)) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "BOM_OUTPUT_UOM_TEXT_DRIFT", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| expectedValue = uomDesc, | |||||
| actualValue = outputDesc, | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| } | |||||
| private fun collectBomMaterialIssues( | |||||
| bom: Bom, | |||||
| material: BomMaterial, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| uomCache: Map<Long, UomConversion> = emptyMap(), | |||||
| ) { | |||||
| val bomId = bom.id ?: return | |||||
| val materialId = material.id | |||||
| val ctx = IssueContext( | |||||
| scope = "BOM_MATERIAL", | |||||
| bomId = bomId, | |||||
| bomCode = bom.code, | |||||
| bomName = bom.name, | |||||
| bomMaterialId = materialId, | |||||
| description = bom.description, | |||||
| ) | |||||
| 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 itemSales = findSalesUnitRow(matItem.id!!, uomsByItemId)?.uom | |||||
| if (salesUnitFk != null && itemSales != null && salesUnitFk.id != itemSales.id) { | |||||
| 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", | |||||
| itemCode = matItemCode, | |||||
| itemName = matItemName, | |||||
| expectedValue = formatUom(itemStock), | |||||
| actualValue = formatUomById(matStockId, uomCache), | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun collectItemUomIssues( | |||||
| item: Items, | |||||
| ctx: IssueContext, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| uomsByItemId: Map<Long, List<ItemUom>>, | |||||
| deletedUomsByItemId: Map<Long, List<ItemUom>> = emptyMap(), | |||||
| unitSnapshot: UnitSnapshot? = null, | |||||
| ) { | |||||
| val itemId = item.id ?: return | |||||
| val allUoms = uomsForItem(itemId, uomsByItemId) | |||||
| val snapshot = unitSnapshot ?: buildUnitSnapshot(itemId, uomsByItemId, deletedUomsByItemId) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.baseUnit == true }, "BASE", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.salesUnit == true }, "SALES", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.stockUnit == true }, "STOCK", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.pickingUnit == true }, "PICKING", snapshot) | |||||
| checkUnitType(item, ctx, issues, allUoms, { it.purchaseUnit == true }, "PURCHASE", snapshot) | |||||
| } | |||||
| private fun unitHealthForKey(snapshot: UnitSnapshot?, unitKey: String): UnitHealth? = | |||||
| when (unitKey) { | |||||
| "BASE" -> snapshot?.base | |||||
| "STOCK" -> snapshot?.stock | |||||
| "PURCHASE" -> snapshot?.purchase | |||||
| "SALES" -> snapshot?.sales | |||||
| else -> null | |||||
| } | |||||
| private fun checkUnitType( | |||||
| item: Items, | |||||
| ctx: IssueContext, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| allUoms: List<ItemUom>, | |||||
| flag: (ItemUom) -> Boolean, | |||||
| unitKey: String, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ) { | |||||
| val rows = allUoms.filter(flag) | |||||
| when { | |||||
| rows.isEmpty() -> { | |||||
| val health = unitHealthForKey(unitSnapshot, unitKey) | |||||
| val issueCode = | |||||
| if (health?.status == "DELETED") "DELETED_${unitKey}_UOM" else "MISSING_${unitKey}_UOM" | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| issueCode, | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| rows.size > 1 -> { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "MULTIPLE_${unitKey}_UOM", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| actualValue = "count=${rows.size}", | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| rows.forEach { row -> checkUomConversion(item, ctx, issues, row, unitKey, unitSnapshot) } | |||||
| } | |||||
| else -> checkUomConversion(item, ctx, issues, rows.first(), unitKey, unitSnapshot) | |||||
| } | |||||
| } | |||||
| private fun checkUomConversion( | |||||
| item: Items, | |||||
| ctx: IssueContext, | |||||
| issues: MutableList<MasterDataIssueResponse>, | |||||
| row: ItemUom, | |||||
| unitKey: String, | |||||
| unitSnapshot: UnitSnapshot?, | |||||
| ) { | |||||
| val uom = row.uom | |||||
| if (uom == null || uom.deleted == true) { | |||||
| issues.add( | |||||
| issue( | |||||
| ctx, | |||||
| item.id, | |||||
| "MISSING_${unitKey}_UOM_CONVERSION", | |||||
| itemCode = item.code, | |||||
| itemName = item.name, | |||||
| unitSnapshot = unitSnapshot, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| private fun issue( | |||||
| ctx: IssueContext, | |||||
| itemId: Long?, | |||||
| issueCode: String, | |||||
| itemCode: String? = null, | |||||
| itemName: String? = null, | |||||
| expectedValue: String? = null, | |||||
| actualValue: String? = null, | |||||
| unitSnapshot: UnitSnapshot? = null, | |||||
| ): MasterDataIssueResponse = | |||||
| MasterDataIssueResponse( | |||||
| scope = ctx.scope, | |||||
| bomId = ctx.bomId, | |||||
| bomCode = ctx.bomCode, | |||||
| bomName = ctx.bomName, | |||||
| bomMaterialId = ctx.bomMaterialId, | |||||
| itemId = itemId, | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| description = ctx.description, | |||||
| issueCode = issueCode, | |||||
| expectedValue = expectedValue, | |||||
| actualValue = actualValue, | |||||
| baseUnitValue = unitSnapshot?.base?.value, | |||||
| stockUnitValue = unitSnapshot?.stock?.value, | |||||
| purchaseUnitValue = unitSnapshot?.purchase?.value, | |||||
| salesUnitValue = unitSnapshot?.sales?.value, | |||||
| baseUnitCorrect = unitSnapshot?.base?.correct, | |||||
| stockUnitCorrect = unitSnapshot?.stock?.correct, | |||||
| purchaseUnitCorrect = unitSnapshot?.purchase?.correct, | |||||
| salesUnitCorrect = unitSnapshot?.sales?.correct, | |||||
| baseUnitModifiedAt = unitSnapshot?.base?.modifiedAt, | |||||
| stockUnitModifiedAt = unitSnapshot?.stock?.modifiedAt, | |||||
| purchaseUnitModifiedAt = unitSnapshot?.purchase?.modifiedAt, | |||||
| salesUnitModifiedAt = unitSnapshot?.sales?.modifiedAt, | |||||
| baseUnitStatus = unitSnapshot?.base?.status, | |||||
| stockUnitStatus = unitSnapshot?.stock?.status, | |||||
| purchaseUnitStatus = unitSnapshot?.purchase?.status, | |||||
| salesUnitStatus = unitSnapshot?.sales?.status, | |||||
| ) | |||||
| private fun formatUom(uom: UomConversion?): String { | |||||
| if (uom == null) return "-" | |||||
| val code = uom.code?.trim().orEmpty() | |||||
| val desc = uom.udfudesc?.trim().orEmpty() | |||||
| return when { | |||||
| code.isNotEmpty() && desc.isNotEmpty() -> "$code / $desc" | |||||
| desc.isNotEmpty() -> desc | |||||
| code.isNotEmpty() -> code | |||||
| else -> "-" | |||||
| } | |||||
| } | |||||
| private fun formatUomById(uomId: Long?, uomCache: Map<Long, UomConversion> = emptyMap()): String { | |||||
| if (uomId == null) return "-" | |||||
| val uom = uomCache[uomId] ?: uomConversionService.findById(uomId) | |||||
| return formatUom(uom) | |||||
| } | |||||
| private fun sortIssues(issues: List<MasterDataIssueResponse>): List<MasterDataIssueResponse> = | |||||
| issues.sortedWith( | |||||
| compareBy( | |||||
| { it.scope }, | |||||
| { it.bomCode?.uppercase() ?: "" }, | |||||
| { it.itemCode?.uppercase() ?: "" }, | |||||
| { it.issueCode }, | |||||
| { it.bomId ?: 0L }, | |||||
| { it.itemId ?: 0L }, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| @@ -30,6 +30,8 @@ import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | import com.ffii.fpsms.modules.master.web.models.EditBomRequest | ||||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | 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.BomIdByItemCodeResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse | |||||
| import com.ffii.fpsms.modules.master.service.MasterDataIssueService | |||||
| import com.ffii.core.exception.BadRequestException | import com.ffii.core.exception.BadRequestException | ||||
| import java.util.logging.Logger | import java.util.logging.Logger | ||||
| import java.nio.file.Files | import java.nio.file.Files | ||||
| @@ -38,7 +40,9 @@ import org.springframework.core.io.FileSystemResource | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/bom") | @RequestMapping("/bom") | ||||
| class BomController ( | class BomController ( | ||||
| val bomService: BomService, private val bomRepository: BomRepository, | |||||
| val bomService: BomService, | |||||
| private val bomRepository: BomRepository, | |||||
| private val masterDataIssueService: MasterDataIssueService, | |||||
| ) { | ) { | ||||
| private val log = LoggerFactory.getLogger(BomController::class.java) | private val log = LoggerFactory.getLogger(BomController::class.java) | ||||
| @@ -108,6 +112,16 @@ fun downloadBomFormatIssueLog( | |||||
| return bomRepository.findBomComboByDeletedIsFalse(); | return bomRepository.findBomComboByDeletedIsFalse(); | ||||
| } | } | ||||
| @GetMapping("/combo/issues") | |||||
| fun getComboIssues(): List<MasterDataIssueResponse> { | |||||
| return bomService.findComboIssues(); | |||||
| } | |||||
| @GetMapping("/master-data/issues") | |||||
| fun getBomMasterDataIssues(): List<MasterDataIssueResponse> { | |||||
| return masterDataIssueService.findBomMasterDataIssues(); | |||||
| } | |||||
| @PostMapping("/import-bom") | @PostMapping("/import-bom") | ||||
| fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | ||||
| val reportResult = bomService.importBOM(payload.batchId, payload.items) | val reportResult = bomService.importBOM(payload.batchId, payload.items) | ||||
| @@ -6,6 +6,8 @@ import com.ffii.core.utils.CriteriaArgsBuilder | |||||
| import com.ffii.core.utils.PagingUtils | import com.ffii.core.utils.PagingUtils | ||||
| import com.ffii.fpsms.modules.master.entity.Items | import com.ffii.fpsms.modules.master.entity.Items | ||||
| import com.ffii.fpsms.modules.master.service.ItemsService | import com.ffii.fpsms.modules.master.service.ItemsService | ||||
| import com.ffii.fpsms.modules.master.service.MasterDataIssueService | |||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse | |||||
| import com.ffii.fpsms.modules.master.web.models.ItemWithQcResponse | import com.ffii.fpsms.modules.master.web.models.ItemWithQcResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | import com.ffii.fpsms.modules.master.web.models.MessageResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.NewItemRequest | import com.ffii.fpsms.modules.master.web.models.NewItemRequest | ||||
| @@ -24,7 +26,8 @@ import org.slf4j.LoggerFactory | |||||
| @RestController | @RestController | ||||
| @RequestMapping("/items") | @RequestMapping("/items") | ||||
| class ItemsController( | class ItemsController( | ||||
| private val itemsService: ItemsService | |||||
| private val itemsService: ItemsService, | |||||
| private val masterDataIssueService: MasterDataIssueService, | |||||
| ) { | ) { | ||||
| private val logger: Logger = LoggerFactory.getLogger(ItemsController::class.java) | private val logger: Logger = LoggerFactory.getLogger(ItemsController::class.java) | ||||
| @@ -33,6 +36,11 @@ class ItemsController( | |||||
| return itemsService.allItems() | return itemsService.allItems() | ||||
| } | } | ||||
| @GetMapping("/master-data/issues") | |||||
| fun getItemMasterDataIssues(): List<MasterDataIssueResponse> { | |||||
| return masterDataIssueService.findItemMasterDataIssues() | |||||
| } | |||||
| @GetMapping("/consumables") | @GetMapping("/consumables") | ||||
| fun allConsumables(): List<Map<String, Any>> { | fun allConsumables(): List<Map<String, Any>> { | ||||
| return itemsService.allConsumables() | return itemsService.allConsumables() | ||||
| @@ -0,0 +1,20 @@ | |||||
| package com.ffii.fpsms.modules.master.web | |||||
| import com.ffii.fpsms.modules.master.service.MasterDataIssueService | |||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueSummaryResponse | |||||
| import org.springframework.web.bind.annotation.GetMapping | |||||
| import org.springframework.web.bind.annotation.RequestMapping | |||||
| import org.springframework.web.bind.annotation.RequestParam | |||||
| import org.springframework.web.bind.annotation.RestController | |||||
| @RestController | |||||
| @RequestMapping("/master-data") | |||||
| class MasterDataIssueController( | |||||
| private val masterDataIssueService: MasterDataIssueService, | |||||
| ) { | |||||
| @GetMapping("/issues/summary") | |||||
| fun getIssuesSummary( | |||||
| @RequestParam(required = false, defaultValue = "false") includeTiming: Boolean, | |||||
| ): MasterDataIssueSummaryResponse = | |||||
| masterDataIssueService.findMasterDataIssuesSummary(includeTiming) | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| package com.ffii.fpsms.modules.master.web.models | |||||
| data class BomComboIssueResponse( | |||||
| val bomId: Long, | |||||
| val bomCode: String?, | |||||
| val bomName: String?, | |||||
| val itemId: Long?, | |||||
| val description: String?, | |||||
| val issueCode: String, | |||||
| ) | |||||
| @@ -29,6 +29,7 @@ data class EditBomRequest( | |||||
| val timeSequence: Int? = null, | val timeSequence: Int? = null, | ||||
| val complexity: Int? = null, | val complexity: Int? = null, | ||||
| val isDrink: Boolean? = null, | val isDrink: Boolean? = null, | ||||
| val isPowderMixture: Boolean? = null, | |||||
| // children | // children | ||||
| val materials: List<EditBomMaterialRequest>? = null, | val materials: List<EditBomMaterialRequest>? = null, | ||||
| @@ -64,7 +64,8 @@ data class BomFormatCheckResponse( | |||||
| data class ImportBomItemRequest( | data class ImportBomItemRequest( | ||||
| val fileName: String, | val fileName: String, | ||||
| val isAlsoWip: Boolean = false, | val isAlsoWip: Boolean = false, | ||||
| val isDrink: Boolean = false | |||||
| val isDrink: Boolean = false, | |||||
| val isPowderMixture: Boolean = false, | |||||
| ) | ) | ||||
| data class ImportBomRequestPayload( | data class ImportBomRequestPayload( | ||||
| @@ -112,6 +113,7 @@ data class BomDetailResponse( | |||||
| val isFloat: Int?, | val isFloat: Int?, | ||||
| val isDense: Int?, | val isDense: Int?, | ||||
| val isDrink: Boolean?, | val isDrink: Boolean?, | ||||
| val isPowderMixture: Boolean?, | |||||
| val scrapRate: Int?, | val scrapRate: Int?, | ||||
| val allergicSubstances: Int?, | val allergicSubstances: Int?, | ||||
| val timeSequence: Int?, | val timeSequence: Int?, | ||||
| @@ -0,0 +1,33 @@ | |||||
| package com.ffii.fpsms.modules.master.web.models | |||||
| data class MasterDataIssueResponse( | |||||
| val scope: String, | |||||
| val bomId: Long? = null, | |||||
| val bomCode: String? = null, | |||||
| val bomName: String? = null, | |||||
| val bomMaterialId: Long? = null, | |||||
| val itemId: Long? = null, | |||||
| val itemCode: String? = null, | |||||
| val itemName: String? = null, | |||||
| val description: String? = null, | |||||
| val issueCode: String, | |||||
| val expectedValue: String? = null, | |||||
| val actualValue: String? = null, | |||||
| val baseUnitValue: String? = null, | |||||
| val stockUnitValue: String? = null, | |||||
| val purchaseUnitValue: String? = null, | |||||
| val salesUnitValue: String? = null, | |||||
| val baseUnitCorrect: Boolean? = null, | |||||
| val stockUnitCorrect: Boolean? = null, | |||||
| val purchaseUnitCorrect: Boolean? = null, | |||||
| val salesUnitCorrect: Boolean? = null, | |||||
| val baseUnitModifiedAt: String? = null, | |||||
| val stockUnitModifiedAt: String? = null, | |||||
| val purchaseUnitModifiedAt: String? = null, | |||||
| val salesUnitModifiedAt: String? = null, | |||||
| /** OK | MISSING | DELETED */ | |||||
| val baseUnitStatus: String? = null, | |||||
| val stockUnitStatus: String? = null, | |||||
| val purchaseUnitStatus: String? = null, | |||||
| val salesUnitStatus: String? = null, | |||||
| ) | |||||
| @@ -0,0 +1,9 @@ | |||||
| package com.ffii.fpsms.modules.master.web.models | |||||
| data class MasterDataIssueSummaryResponse( | |||||
| val bomGroupCount: Int, | |||||
| val itemGroupCount: Int, | |||||
| val totalGroupCount: Int, | |||||
| /** Present when summary is requested with {@code ?includeTiming=true}. */ | |||||
| val timing: MasterDataIssueSummaryTiming? = null, | |||||
| ) | |||||
| @@ -0,0 +1,14 @@ | |||||
| package com.ffii.fpsms.modules.master.web.models | |||||
| /** Phase timings for POSTMAN / profiling when {@code includeTiming=true} on summary API. */ | |||||
| data class MasterDataIssueSummaryTiming( | |||||
| val totalMs: Long, | |||||
| val loadActiveUomsMs: Long, | |||||
| val loadDeletedUomsMs: Long, | |||||
| val loadMaterialsMs: Long, | |||||
| val buildUomCacheMs: Long, | |||||
| val loadBomsMs: Long, | |||||
| val scanBomTabMs: Long, | |||||
| val loadItemsMs: Long, | |||||
| val scanItemTabMs: Long, | |||||
| ) | |||||
| @@ -59,6 +59,47 @@ WHERE ill.id = :id | |||||
| @Query("select ill from InventoryLotLine ill where :id is null or ill.inventoryLot.item.id = :id order by ill.id desc") | @Query("select ill from InventoryLotLine ill where :id is null or ill.inventoryLot.item.id = :id order by ill.id desc") | ||||
| fun findInventoryLotLineInfoByItemId(id: Long?, pageable: Pageable): Page<InventoryLotLineInfo> | fun findInventoryLotLineInfoByItemId(id: Long?, pageable: Pageable): Page<InventoryLotLineInfo> | ||||
| @Query(""" | |||||
| SELECT ill FROM InventoryLotLine ill | |||||
| JOIN ill.inventoryLot il | |||||
| WHERE ill.deleted = false | |||||
| AND (:itemId IS NULL OR il.item.id = :itemId) | |||||
| AND (il.expiryDate IS NULL OR il.expiryDate >= :today) | |||||
| AND COALESCE(ill.inQty, 0) > COALESCE(ill.outQty, 0) | |||||
| AND ill.status IN :statuses | |||||
| ORDER BY ill.id DESC | |||||
| """) | |||||
| fun findStockIssueBadItemLotLinesByItemId( | |||||
| @Param("itemId") itemId: Long?, | |||||
| @Param("today") today: LocalDate, | |||||
| @Param("statuses") statuses: List<InventoryLotLineStatus>, | |||||
| pageable: Pageable, | |||||
| ): Page<InventoryLotLineInfo> | |||||
| @Query(""" | |||||
| SELECT ill FROM InventoryLotLine ill | |||||
| JOIN ill.inventoryLot il | |||||
| JOIN il.item i | |||||
| WHERE ill.deleted = false | |||||
| AND (:itemCode IS NULL OR :itemCode = '' OR LOWER(i.code) LIKE LOWER(CONCAT('%', :itemCode, '%'))) | |||||
| AND (:itemName IS NULL OR :itemName = '' OR LOWER(i.name) LIKE LOWER(CONCAT('%', :itemName, '%'))) | |||||
| AND (:itemType IS NULL OR :itemType = '' OR i.type = :itemType) | |||||
| AND (:lotNo IS NULL OR :lotNo = '' OR LOWER(il.lotNo) LIKE LOWER(CONCAT('%', :lotNo, '%'))) | |||||
| AND (il.expiryDate IS NULL OR il.expiryDate >= :today) | |||||
| AND COALESCE(ill.inQty, 0) > COALESCE(ill.outQty, 0) | |||||
| AND ill.status IN :statuses | |||||
| ORDER BY i.code ASC, il.lotNo ASC, ill.id DESC | |||||
| """) | |||||
| fun searchStockIssueBadItemLotLines( | |||||
| @Param("itemCode") itemCode: String?, | |||||
| @Param("itemName") itemName: String?, | |||||
| @Param("itemType") itemType: String?, | |||||
| @Param("lotNo") lotNo: String?, | |||||
| @Param("today") today: LocalDate, | |||||
| @Param("statuses") statuses: List<InventoryLotLineStatus>, | |||||
| pageable: Pageable, | |||||
| ): Page<InventoryLotLineInfo> | |||||
| @Query(""" | @Query(""" | ||||
| select | select | ||||
| i.id as id, | i.id as id, | ||||
| @@ -18,11 +18,13 @@ import org.springframework.context.annotation.Lazy | |||||
| import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SaveInventoryLotLineRequest | import com.ffii.fpsms.modules.stock.web.model.SaveInventoryLotLineRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SearchInventoryLotLineInfoRequest | import com.ffii.fpsms.modules.stock.web.model.SearchInventoryLotLineInfoRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SearchStockIssueBadItemLotLineRequest | |||||
| import kotlinx.serialization.encodeToString | import kotlinx.serialization.encodeToString | ||||
| import kotlinx.serialization.json.Json | import kotlinx.serialization.json.Json | ||||
| import net.sf.jasperreports.engine.JasperCompileManager | import net.sf.jasperreports.engine.JasperCompileManager | ||||
| import org.springframework.core.io.ClassPathResource | import org.springframework.core.io.ClassPathResource | ||||
| import org.springframework.data.domain.PageRequest | import org.springframework.data.domain.PageRequest | ||||
| import java.time.LocalDate | |||||
| import org.springframework.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import org.springframework.transaction.annotation.Transactional | import org.springframework.transaction.annotation.Transactional | ||||
| import java.io.FileNotFoundException | import java.io.FileNotFoundException | ||||
| @@ -77,13 +79,42 @@ open class InventoryLotLineService( | |||||
| open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes<InventoryLotLineInfo> { | open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes<InventoryLotLineInfo> { | ||||
| val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10); | val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 10); | ||||
| val response = inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, pageable) | |||||
| val response = if (request.stockIssueBadItem == true) { | |||||
| inventoryLotLineRepository.findStockIssueBadItemLotLinesByItemId( | |||||
| request.itemId, | |||||
| LocalDate.now(), | |||||
| listOf(InventoryLotLineStatus.AVAILABLE, InventoryLotLineStatus.UNAVAILABLE), | |||||
| pageable, | |||||
| ) | |||||
| } else { | |||||
| inventoryLotLineRepository.findInventoryLotLineInfoByItemId(request.itemId, pageable) | |||||
| } | |||||
| val records = response.content | val records = response.content | ||||
| val total = response.totalElements | val total = response.totalElements | ||||
| return RecordsRes<InventoryLotLineInfo>(records, total.toInt()); | return RecordsRes<InventoryLotLineInfo>(records, total.toInt()); | ||||
| } | } | ||||
| open fun searchStockIssueBadItemLotLines( | |||||
| request: SearchStockIssueBadItemLotLineRequest, | |||||
| ): RecordsRes<InventoryLotLineInfo> { | |||||
| val pageable = PageRequest.of(request.pageNum ?: 0, request.pageSize ?: 20) | |||||
| val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val itemType = request.itemType?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val response = inventoryLotLineRepository.searchStockIssueBadItemLotLines( | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| itemType = itemType, | |||||
| lotNo = lotNo, | |||||
| today = LocalDate.now(), | |||||
| statuses = listOf(InventoryLotLineStatus.AVAILABLE, InventoryLotLineStatus.UNAVAILABLE), | |||||
| pageable = pageable, | |||||
| ) | |||||
| return RecordsRes(response.content, response.totalElements.toInt()) | |||||
| } | |||||
| /** | /** | ||||
| * Same rules as [saveInventoryLotLine]: only stay AVAILABLE if previously AVAILABLE and remaining > 0. | * Same rules as [saveInventoryLotLine]: only stay AVAILABLE if previously AVAILABLE and remaining > 0. | ||||
| */ | */ | ||||
| @@ -184,11 +215,11 @@ open class InventoryLotLineService( | |||||
| } | } | ||||
| // Calculate onHoldQty (sum of holdQty from available lots only) | // Calculate onHoldQty (sum of holdQty from available lots only) | ||||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE.value) | |||||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||||
| .sumOf { it.holdQty ?: BigDecimal.ZERO } | .sumOf { it.holdQty ?: BigDecimal.ZERO } | ||||
| // Calculate unavailableQty (sum of inQty from unavailable lots only) | // Calculate unavailableQty (sum of inQty from unavailable lots only) | ||||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE.value) | |||||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.UNAVAILABLE) | |||||
| .sumOf { | .sumOf { | ||||
| val inQty = it.inQty ?: BigDecimal.ZERO | val inQty = it.inQty ?: BigDecimal.ZERO | ||||
| val outQty = it.outQty ?: BigDecimal.ZERO | val outQty = it.outQty ?: BigDecimal.ZERO | ||||
| @@ -0,0 +1,190 @@ | |||||
| package com.ffii.fpsms.modules.stock.service | |||||
| import com.ffii.core.response.RecordsRes | |||||
| import com.ffii.fpsms.modules.master.entity.UomConversionRepository | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.StockLedgerRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.StockOutRepository | |||||
| import com.ffii.fpsms.modules.stock.entity.StockLedger | |||||
| import com.ffii.fpsms.modules.stock.web.model.HandleBadItemRequest | |||||
| import com.ffii.fpsms.modules.stock.web.model.SearchStockIssueRecordRequest | |||||
| import com.ffii.fpsms.modules.stock.web.model.StockIssueHandleRecordResponse | |||||
| import com.ffii.fpsms.modules.stock.web.model.StockOutRequest | |||||
| import com.ffii.fpsms.modules.user.service.UserService | |||||
| import com.ffii.fpsms.modules.common.SecurityUtils | |||||
| import org.springframework.data.domain.PageRequest | |||||
| import org.springframework.stereotype.Service | |||||
| import org.springframework.transaction.annotation.Transactional | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| @Service | |||||
| open class StockIssueService( | |||||
| private val inventoryLotLineRepository: InventoryLotLineRepository, | |||||
| private val stockOutLineService: StockOutLineService, | |||||
| private val stockLedgerRepository: StockLedgerRepository, | |||||
| private val uomConversionRepository: UomConversionRepository, | |||||
| private val userService: UserService, | |||||
| private val stockOutRepository: StockOutRepository, | |||||
| ) { | |||||
| @Transactional(rollbackFor = [Exception::class]) | |||||
| open fun handleBadItem(request: HandleBadItemRequest): MessageResponse { | |||||
| try { | |||||
| val lotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElse(null) | |||||
| ?: return error("NOT_FOUND", "Lot line not found") | |||||
| val inQty = lotLine.inQty ?: BigDecimal.ZERO | |||||
| val outQty = lotLine.outQty ?: BigDecimal.ZERO | |||||
| val holdQty = lotLine.holdQty ?: BigDecimal.ZERO | |||||
| val available = inQty.subtract(outQty).subtract(holdQty) | |||||
| val qtyBd = BigDecimal.valueOf(request.qty) | |||||
| if (qtyBd <= BigDecimal.ZERO) { | |||||
| return error("INVALID_QTY", "Quantity must be greater than zero") | |||||
| } | |||||
| if (qtyBd > available) { | |||||
| return error("QTY_EXCEEDS_AVAILABLE", "Quantity exceeds available quantity ($available)") | |||||
| } | |||||
| val savedStockOutLine = stockOutLineService.createStockOut( | |||||
| StockOutRequest( | |||||
| inventoryLotLineId = request.inventoryLotLineId, | |||||
| qty = request.qty, | |||||
| type = "Bad", | |||||
| ), | |||||
| ) | |||||
| val stockOut = savedStockOutLine.stockOut | |||||
| if (stockOut != null) { | |||||
| val remarks = request.remarks?.trim()?.takeIf { it.isNotEmpty() } | |||||
| if (remarks != null) { | |||||
| stockOut.remarks = remarks | |||||
| } | |||||
| val handlerId = request.handler ?: SecurityUtils.getUser().orElse(null)?.id | |||||
| if (handlerId != null) { | |||||
| stockOut.handler = handlerId | |||||
| } | |||||
| stockOutRepository.saveAndFlush(stockOut) | |||||
| } | |||||
| return MessageResponse( | |||||
| id = savedStockOutLine.stockOut?.id, | |||||
| name = "Success", | |||||
| code = "SUCCESS", | |||||
| type = "stock_issue", | |||||
| message = "Successfully handled bad item", | |||||
| errorPosition = null, | |||||
| ) | |||||
| } catch (e: Exception) { | |||||
| return error("ERROR", "Failed to handle bad item: ${e.message}") | |||||
| } | |||||
| } | |||||
| open fun getBadItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes<StockIssueHandleRecordResponse> { | |||||
| return searchHandleRecords(request, "Bad") | |||||
| } | |||||
| open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes<StockIssueHandleRecordResponse> { | |||||
| val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate) | |||||
| val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val pageable = PageRequest.of( | |||||
| request.pageNum.coerceAtLeast(0), | |||||
| request.pageSize.coerceIn(1, 200), | |||||
| ) | |||||
| val page = stockLedgerRepository.findExpiryItemHandleRecords( | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| lotNo = lotNo, | |||||
| startDate = startDate, | |||||
| endDateExclusive = endDateExclusive, | |||||
| pageable = pageable, | |||||
| ) | |||||
| val records = page.content.map { ledger -> toRecordResponse(ledger) } | |||||
| return RecordsRes(records, page.totalElements.toInt()) | |||||
| } | |||||
| private fun searchHandleRecords( | |||||
| request: SearchStockIssueRecordRequest, | |||||
| type: String, | |||||
| ): RecordsRes<StockIssueHandleRecordResponse> { | |||||
| val (startDate, endDateExclusive) = resolveDateRange(request.startDate, request.endDate) | |||||
| val itemCode = request.itemCode?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val itemName = request.itemName?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() } | |||||
| val pageable = PageRequest.of( | |||||
| request.pageNum.coerceAtLeast(0), | |||||
| request.pageSize.coerceIn(1, 200), | |||||
| ) | |||||
| val page = stockLedgerRepository.findStockIssueHandleRecords( | |||||
| type = type, | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| lotNo = lotNo, | |||||
| startDate = startDate, | |||||
| endDateExclusive = endDateExclusive, | |||||
| pageable = pageable, | |||||
| ) | |||||
| val records = page.content.map { ledger -> toRecordResponse(ledger) } | |||||
| return RecordsRes(records, page.totalElements.toInt()) | |||||
| } | |||||
| private fun resolveDateRange(startDate: LocalDate?, endDate: LocalDate?): Pair<LocalDate?, LocalDate?> { | |||||
| val start = startDate ?: endDate?.minusMonths(3) ?: LocalDate.now().minusMonths(3) | |||||
| val endInclusive = endDate ?: LocalDate.now() | |||||
| return Pair(start, endInclusive.plusDays(1)) | |||||
| } | |||||
| private fun toRecordResponse(ledger: StockLedger): StockIssueHandleRecordResponse { | |||||
| val stockOutLine = ledger.stockOutLine | |||||
| val lotLine = stockOutLine?.inventoryLotLine | |||||
| val lot = lotLine?.inventoryLot | |||||
| val item = lot?.item ?: ledger.inventory?.item | |||||
| val handlerId = stockOutLine?.stockOut?.handler ?: stockOutLine?.handledBy | |||||
| val handlerName: String? = handlerId?.let { id -> | |||||
| userService.find(id).orElse(null)?.name | |||||
| } | |||||
| val uomDesc = ledger.uomId?.let { uomId -> | |||||
| uomConversionRepository.findByIdAndDeletedFalse(uomId)?.udfudesc | |||||
| } | |||||
| val qty = BigDecimal(ledger.outQty?.toString() ?: "0") | |||||
| val remarks = stockOutLine?.stockOut?.remarks | |||||
| return StockIssueHandleRecordResponse( | |||||
| id = ledger.id ?: 0L, | |||||
| stockOutLineId = stockOutLine?.id, | |||||
| stockOutId = stockOutLine?.stockOut?.id, | |||||
| handledAt = ledger.date, | |||||
| itemId = item?.id ?: ledger.itemId, | |||||
| itemCode = ledger.itemCode ?: item?.code, | |||||
| itemName = item?.name, | |||||
| lotNo = lot?.lotNo, | |||||
| storeLocation = lotLine?.warehouse?.code, | |||||
| expiryDate = lot?.expiryDate, | |||||
| qty = qty, | |||||
| uomDesc = uomDesc, | |||||
| handlerId = handlerId, | |||||
| handlerName = handlerName, | |||||
| remarks = remarks, | |||||
| type = ledger.type, | |||||
| ) | |||||
| } | |||||
| private fun error(code: String, message: String): MessageResponse = MessageResponse( | |||||
| id = null, | |||||
| name = "Error", | |||||
| code = code, | |||||
| type = "stock_issue", | |||||
| message = message, | |||||
| errorPosition = null, | |||||
| ) | |||||
| } | |||||
| @@ -844,11 +844,11 @@ private fun updateInventoryTableAfterLotRejection(inventoryLotLine: InventoryLot | |||||
| if (itemId != null) { | if (itemId != null) { | ||||
| // Calculate onHoldQty (sum of holdQty from available lots only) | // Calculate onHoldQty (sum of holdQty from available lots only) | ||||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE.value) | |||||
| val onHoldQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId, InventoryLotLineStatus.AVAILABLE) | |||||
| .sumOf { it.holdQty ?: BigDecimal.ZERO } | .sumOf { it.holdQty ?: BigDecimal.ZERO } | ||||
| // Calculate unavailableQty (sum of inQty from unavailable lots only) | // Calculate unavailableQty (sum of inQty from unavailable lots only) | ||||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId,InventoryLotLineStatus.UNAVAILABLE.value) | |||||
| val unavailableQty = inventoryLotLineRepository.findAllByInventoryLotItemIdAndStatus(itemId,InventoryLotLineStatus.UNAVAILABLE) | |||||
| .sumOf { | .sumOf { | ||||
| val inQty = it.inQty ?: BigDecimal.ZERO | val inQty = it.inQty ?: BigDecimal.ZERO | ||||
| val outQty = it.outQty ?: BigDecimal.ZERO | val outQty = it.outQty ?: BigDecimal.ZERO | ||||
| @@ -11,6 +11,7 @@ import com.ffii.fpsms.modules.stock.service.StockInLineService | |||||
| import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | import com.ffii.fpsms.modules.stock.web.model.ExportQrCodeRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.LotLineInfo | import com.ffii.fpsms.modules.stock.web.model.LotLineInfo | ||||
| import com.ffii.fpsms.modules.stock.web.model.SearchInventoryLotLineInfoRequest | import com.ffii.fpsms.modules.stock.web.model.SearchInventoryLotLineInfoRequest | ||||
| import com.ffii.fpsms.modules.stock.web.model.SearchStockIssueBadItemLotLineRequest | |||||
| import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest | import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineQuantitiesRequest | ||||
| import jakarta.servlet.http.HttpServletResponse | import jakarta.servlet.http.HttpServletResponse | ||||
| import jakarta.validation.Valid | import jakarta.validation.Valid | ||||
| @@ -55,6 +56,13 @@ class InventoryLotLineController ( | |||||
| return inventoryLotLineService.allInventoryLotLinesByPage(request); | return inventoryLotLineService.allInventoryLotLinesByPage(request); | ||||
| } | } | ||||
| @GetMapping("/stockIssueBadItemSearch") | |||||
| fun searchStockIssueBadItemLotLines( | |||||
| @ModelAttribute request: SearchStockIssueBadItemLotLineRequest, | |||||
| ): RecordsRes<InventoryLotLineInfo> { | |||||
| return inventoryLotLineService.searchStockIssueBadItemLotLines(request) | |||||
| } | |||||
| @GetMapping("/lot-detail/{stockInLineId}") | @GetMapping("/lot-detail/{stockInLineId}") | ||||
| fun getLotDetail(@Valid @PathVariable stockInLineId: Long): LotLineInfo { | fun getLotDetail(@Valid @PathVariable stockInLineId: Long): LotLineInfo { | ||||
| val stockInLine = stockInLineRepository.findById(stockInLineId).orElseThrow { | val stockInLine = stockInLineRepository.findById(stockInLineId).orElseThrow { | ||||
| @@ -0,0 +1,69 @@ | |||||
| package com.ffii.fpsms.modules.stock.web | |||||
| import com.ffii.core.response.RecordsRes | |||||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||||
| import com.ffii.fpsms.modules.stock.service.StockIssueService | |||||
| import com.ffii.fpsms.modules.stock.web.model.HandleBadItemRequest | |||||
| import com.ffii.fpsms.modules.stock.web.model.SearchStockIssueRecordRequest | |||||
| import com.ffii.fpsms.modules.stock.web.model.StockIssueHandleRecordResponse | |||||
| import org.springframework.format.annotation.DateTimeFormat | |||||
| import org.springframework.web.bind.annotation.* | |||||
| import java.time.LocalDate | |||||
| @RestController | |||||
| @RequestMapping("/stockIssue") | |||||
| class StockIssueController( | |||||
| private val stockIssueService: StockIssueService, | |||||
| ) { | |||||
| @PostMapping("/handleBadItem") | |||||
| fun handleBadItem(@RequestBody request: HandleBadItemRequest): MessageResponse { | |||||
| return stockIssueService.handleBadItem(request) | |||||
| } | |||||
| @GetMapping("/badItemRecords") | |||||
| fun getBadItemRecords( | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) itemName: String?, | |||||
| @RequestParam(required = false) lotNo: String?, | |||||
| @RequestParam(defaultValue = "0") pageNum: Int, | |||||
| @RequestParam(defaultValue = "20") pageSize: Int, | |||||
| ): RecordsRes<StockIssueHandleRecordResponse> { | |||||
| return stockIssueService.getBadItemHandleRecords( | |||||
| SearchStockIssueRecordRequest( | |||||
| startDate = startDate, | |||||
| endDate = endDate, | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| lotNo = lotNo, | |||||
| pageNum = pageNum, | |||||
| pageSize = pageSize, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| @GetMapping("/expiryItemRecords") | |||||
| fun getExpiryItemRecords( | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) startDate: LocalDate?, | |||||
| @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) endDate: LocalDate?, | |||||
| @RequestParam(required = false) itemCode: String?, | |||||
| @RequestParam(required = false) itemName: String?, | |||||
| @RequestParam(required = false) lotNo: String?, | |||||
| @RequestParam(defaultValue = "0") pageNum: Int, | |||||
| @RequestParam(defaultValue = "20") pageSize: Int, | |||||
| ): RecordsRes<StockIssueHandleRecordResponse> { | |||||
| return stockIssueService.getExpiryItemHandleRecords( | |||||
| SearchStockIssueRecordRequest( | |||||
| startDate = startDate, | |||||
| endDate = endDate, | |||||
| itemCode = itemCode, | |||||
| itemName = itemName, | |||||
| lotNo = lotNo, | |||||
| pageNum = pageNum, | |||||
| pageSize = pageSize, | |||||
| ), | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -3,5 +3,7 @@ package com.ffii.fpsms.modules.stock.web.model | |||||
| data class SearchInventoryLotLineInfoRequest( | data class SearchInventoryLotLineInfoRequest( | ||||
| val itemId: Long? = null, | val itemId: Long? = null, | ||||
| val pageSize: Int?, | val pageSize: Int?, | ||||
| val pageNum: Int? | |||||
| val pageNum: Int?, | |||||
| /** When true: non-expired lots with inQty > outQty; includes available and unavailable status. */ | |||||
| val stockIssueBadItem: Boolean? = false, | |||||
| ) | ) | ||||
| @@ -0,0 +1,10 @@ | |||||
| package com.ffii.fpsms.modules.stock.web.model | |||||
| data class SearchStockIssueBadItemLotLineRequest( | |||||
| val itemCode: String? = null, | |||||
| val itemName: String? = null, | |||||
| val itemType: String? = null, | |||||
| val lotNo: String? = null, | |||||
| val pageSize: Int? = null, | |||||
| val pageNum: Int? = null, | |||||
| ) | |||||
| @@ -0,0 +1,41 @@ | |||||
| package com.ffii.fpsms.modules.stock.web.model | |||||
| import java.math.BigDecimal | |||||
| import java.time.LocalDate | |||||
| data class HandleBadItemRequest( | |||||
| val inventoryLotLineId: Long, | |||||
| val qty: Double, | |||||
| val remarks: String? = null, | |||||
| val handler: Long? = null, | |||||
| ) | |||||
| data class SearchStockIssueRecordRequest( | |||||
| val startDate: LocalDate? = null, | |||||
| val endDate: LocalDate? = null, | |||||
| val itemCode: String? = null, | |||||
| val itemName: String? = null, | |||||
| val lotNo: String? = null, | |||||
| val pageNum: Int = 0, | |||||
| val pageSize: Int = 20, | |||||
| ) | |||||
| data class StockIssueHandleRecordResponse( | |||||
| val id: Long, | |||||
| val stockOutLineId: Long?, | |||||
| val stockOutId: Long?, | |||||
| /** Business ledger date (stock_ledger.date), not created timestamp. */ | |||||
| val handledAt: LocalDate?, | |||||
| val itemId: Long?, | |||||
| val itemCode: String?, | |||||
| val itemName: String?, | |||||
| val lotNo: String?, | |||||
| val storeLocation: String?, | |||||
| val expiryDate: LocalDate?, | |||||
| val qty: BigDecimal, | |||||
| val uomDesc: String?, | |||||
| val handlerId: Long?, | |||||
| val handlerName: String?, | |||||
| val remarks: String?, | |||||
| val type: String?, | |||||
| ) | |||||
| @@ -0,0 +1,6 @@ | |||||
| --liquibase formatted sql | |||||
| -- 建立 stock_ledger 表的索引 | |||||
| --changeset Enson:20260521-01 | |||||
| CREATE INDEX idx_sl_deleted_type_date_id | |||||
| ON stock_ledger (deleted, type, date, id); | |||||
| @@ -0,0 +1,6 @@ | |||||
| --liquibase formatted sql | |||||
| -- 建立 items 表的索引 | |||||
| --changeset Enson:20260525-01 | |||||
| CREATE INDEX idx_items_deleted | |||||
| ON items (deleted); | |||||
| @@ -0,0 +1,5 @@ | |||||
| --liquibase formatted sql | |||||
| -- 建立 items 表的索引 | |||||
| --changeset Enson:20260603-01 | |||||
| CREATE INDEX idx_item_uom_deleted_itemId ON item_uom (deleted, itemId); | |||||