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) | |||
| } | |||
| 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( | |||
| id = existingDeliveryOrderLine?.id, | |||
| itemId = itemId, | |||
| uomIdM18 = itemUom?.uom?.id, | |||
| uomIdM18 = m18UomId, | |||
| uomId= stockUomId, | |||
| deliveryOrderId = deliveryOrderId, | |||
| qtyM18 = line.qty, | |||
| qty = itemUomService.convertQtyToStockQty(itemId?:0, itemUom?.uom?.id?: 0, line.qty), | |||
| qtyM18 = sourceQty, | |||
| qty = stockQty, | |||
| up = line.up, | |||
| price = line.amt, | |||
| // m18CurrencyId = mainpo.curId, | |||
| @@ -1,6 +1,7 @@ | |||
| package com.ffii.fpsms.modules.master.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.data.jpa.repository.Query | |||
| import org.springframework.stereotype.Repository | |||
| import java.io.Serializable | |||
| @@ -15,4 +16,17 @@ interface BomMaterialRepository : AbstractRepository<BomMaterial, Long> { | |||
| fun findAllByBomIdAndDeletedIsFalse(bomId: Long): List<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> { | |||
| 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 findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? | |||
| @@ -9,6 +9,28 @@ import java.io.Serializable | |||
| interface ItemUomRespository : AbstractRepository<ItemUom, Long> { | |||
| 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 findByM18IdAndDeletedIsFalse(m18Id: Serializable): ItemUom? | |||
| @@ -59,6 +59,7 @@ open class BomService( | |||
| private val equipmentDetailRepository: EquipmentDetailRepository, | |||
| private val bomWeightingScoreRepository: BomWeightingScoreRepository, | |||
| private val itemUomService: ItemUomService, | |||
| private val masterDataIssueService: MasterDataIssueService, | |||
| private val jobOrderRepository: JobOrderRepository, | |||
| private val productProcessRepository: ProductProcessRepository, | |||
| private val m18BomForShopService: M18BomForShopService, | |||
| @@ -118,6 +119,11 @@ open class BomService( | |||
| 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? { | |||
| return bomRepository.findByIdAndDeletedIsFalse(id) | |||
| } | |||
| @@ -223,6 +229,13 @@ open class BomService( | |||
| request.timeSequence?.let { bom.timeSequence = it } | |||
| request.complexity?.let { bom.complexity = 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 replaceProcesses = request.processes != null | |||
| @@ -1610,8 +1623,9 @@ open class BomService( | |||
| .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 | |||
| 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 { | |||
| FileInputStream(path.toFile()).use { input -> | |||
| val workbook2: Workbook = XSSFWorkbook(input) | |||
| @@ -1628,6 +1642,11 @@ open class BomService( | |||
| } | |||
| val bom = importExcelBomBasicInfo(sheet) | |||
| bom.isDrink = isDrink | |||
| bom.type = when { | |||
| isDrink -> "Drink" | |||
| isPowderMixture -> "Powder_Mixture" | |||
| else -> "Other" | |||
| } | |||
| bomRepository.saveAndFlush(bom) | |||
| importExcelBomProcess(bom, sheet) | |||
| importExcelBomMaterial(bom, sheet) | |||
| @@ -1679,6 +1698,7 @@ open class BomService( | |||
| allergicSubstances = fgBom.allergicSubstances | |||
| uom = fgBom.uom | |||
| isDrink = fgBom.isDrink | |||
| type = fgBom.type | |||
| } | |||
| wipBom.baseScore = calculateBaseScore(wipBom) | |||
| bomRepository.saveAndFlush(wipBom) | |||
| @@ -1702,6 +1722,7 @@ open class BomService( | |||
| allergicSubstances = fgBom.allergicSubstances | |||
| uom = fgBom.uom | |||
| isDrink = fgBom.isDrink | |||
| type = fgBom.type | |||
| } | |||
| wipBom.baseScore = calculateBaseScore(wipBom) | |||
| bomRepository.saveAndFlush(wipBom) | |||
| @@ -2884,6 +2905,7 @@ println("=====================================") | |||
| isFloat = bom.isFloat, | |||
| isDense = bom.isDense, | |||
| isDrink = bom.isDrink, | |||
| isPowderMixture = bom.type?.equals("Powder_Mixture", ignoreCase = true) == true, | |||
| scrapRate = bom.scrapRate, | |||
| allergicSubstances = bom.allergicSubstances, | |||
| 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.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.core.exception.BadRequestException | |||
| import java.util.logging.Logger | |||
| import java.nio.file.Files | |||
| @@ -38,7 +40,9 @@ import org.springframework.core.io.FileSystemResource | |||
| @RestController | |||
| @RequestMapping("/bom") | |||
| 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) | |||
| @@ -108,6 +112,16 @@ fun downloadBomFormatIssueLog( | |||
| 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") | |||
| fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | |||
| 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.fpsms.modules.master.entity.Items | |||
| 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.MessageResponse | |||
| import com.ffii.fpsms.modules.master.web.models.NewItemRequest | |||
| @@ -24,7 +26,8 @@ import org.slf4j.LoggerFactory | |||
| @RestController | |||
| @RequestMapping("/items") | |||
| class ItemsController( | |||
| private val itemsService: ItemsService | |||
| private val itemsService: ItemsService, | |||
| private val masterDataIssueService: MasterDataIssueService, | |||
| ) { | |||
| private val logger: Logger = LoggerFactory.getLogger(ItemsController::class.java) | |||
| @@ -33,6 +36,11 @@ class ItemsController( | |||
| return itemsService.allItems() | |||
| } | |||
| @GetMapping("/master-data/issues") | |||
| fun getItemMasterDataIssues(): List<MasterDataIssueResponse> { | |||
| return masterDataIssueService.findItemMasterDataIssues() | |||
| } | |||
| @GetMapping("/consumables") | |||
| fun allConsumables(): List<Map<String, Any>> { | |||
| 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 complexity: Int? = null, | |||
| val isDrink: Boolean? = null, | |||
| val isPowderMixture: Boolean? = null, | |||
| // children | |||
| val materials: List<EditBomMaterialRequest>? = null, | |||
| @@ -64,7 +64,8 @@ data class BomFormatCheckResponse( | |||
| data class ImportBomItemRequest( | |||
| val fileName: String, | |||
| val isAlsoWip: Boolean = false, | |||
| val isDrink: Boolean = false | |||
| val isDrink: Boolean = false, | |||
| val isPowderMixture: Boolean = false, | |||
| ) | |||
| data class ImportBomRequestPayload( | |||
| @@ -112,6 +113,7 @@ data class BomDetailResponse( | |||
| val isFloat: Int?, | |||
| val isDense: Int?, | |||
| val isDrink: Boolean?, | |||
| val isPowderMixture: Boolean?, | |||
| val scrapRate: Int?, | |||
| val allergicSubstances: 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") | |||
| 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(""" | |||
| select | |||
| 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.SaveInventoryLotLineRequest | |||
| 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.json.Json | |||
| import net.sf.jasperreports.engine.JasperCompileManager | |||
| import org.springframework.core.io.ClassPathResource | |||
| import org.springframework.data.domain.PageRequest | |||
| import java.time.LocalDate | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import java.io.FileNotFoundException | |||
| @@ -77,13 +79,42 @@ open class InventoryLotLineService( | |||
| open fun allInventoryLotLinesByPage(request: SearchInventoryLotLineInfoRequest): RecordsRes<InventoryLotLineInfo> { | |||
| 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 total = response.totalElements | |||
| 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. | |||
| */ | |||
| @@ -184,11 +215,11 @@ open class InventoryLotLineService( | |||
| } | |||
| // 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 } | |||
| // 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 { | |||
| val inQty = it.inQty ?: 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) { | |||
| // 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 } | |||
| // 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 { | |||
| val inQty = it.inQty ?: 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.LotLineInfo | |||
| 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 jakarta.servlet.http.HttpServletResponse | |||
| import jakarta.validation.Valid | |||
| @@ -55,6 +56,13 @@ class InventoryLotLineController ( | |||
| return inventoryLotLineService.allInventoryLotLinesByPage(request); | |||
| } | |||
| @GetMapping("/stockIssueBadItemSearch") | |||
| fun searchStockIssueBadItemLotLines( | |||
| @ModelAttribute request: SearchStockIssueBadItemLotLineRequest, | |||
| ): RecordsRes<InventoryLotLineInfo> { | |||
| return inventoryLotLineService.searchStockIssueBadItemLotLines(request) | |||
| } | |||
| @GetMapping("/lot-detail/{stockInLineId}") | |||
| fun getLotDetail(@Valid @PathVariable stockInLineId: Long): LotLineInfo { | |||
| 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( | |||
| val itemId: Long? = null, | |||
| 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); | |||