From 9331b7ebddc5faa7d3d87f887abf994f5d1274ae Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 3 Jun 2026 13:09:44 +0800 Subject: [PATCH] bom import fix item/bom uom issue page new bad item hnadle page do if uomid same qty =m18qty --- .../m18/service/M18DeliveryOrderService.kt | 19 +- .../master/entity/BomMaterialRepository.kt | 14 + .../modules/master/entity/BomRepository.kt | 10 + .../master/entity/ItemUomRespository.kt | 22 + .../modules/master/service/BomService.kt | 26 +- .../master/service/MasterDataIssueService.kt | 888 ++++++++++++++++++ .../fpsms/modules/master/web/BomController.kt | 16 +- .../modules/master/web/ItemsController.kt | 10 +- .../master/web/MasterDataIssueController.kt | 20 + .../web/models/BomComboIssueResponse.kt | 10 + .../master/web/models/EditBomRequest.kt | 1 + .../master/web/models/ItemUomRequest.kt | 4 +- .../web/models/MasterDataIssueResponse.kt | 33 + .../models/MasterDataIssueSummaryResponse.kt | 9 + .../models/MasterDataIssueSummaryTiming.kt | 14 + .../entity/InventoryLotLineRepository.kt | 41 + .../stock/service/InventoryLotLineService.kt | 37 +- .../stock/service/StockIssueService.kt | 190 ++++ .../stock/service/StockOutLineService.kt | 4 +- .../stock/web/InventoryLotLineController.kt | 8 + .../modules/stock/web/StockIssueController.kt | 69 ++ .../SearchInventoryLotLineInfoRequest.kt | 4 +- .../SearchStockIssueBadItemLotLineRequest.kt | 10 + .../stock/web/model/StockIssueModels.kt | 41 + .../changes/20260521_Enson/02_setting.sql | 6 + .../changes/20260525_Enson/02_setting.sql | 6 + .../changes/20260603_Enson/02_setting.sql | 5 + 27 files changed, 1503 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/MasterDataIssueController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboIssueResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryResponse.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryTiming.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchStockIssueBadItemLotLineRequest.kt create mode 100644 src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt create mode 100644 src/main/resources/db/changelog/changes/20260521_Enson/02_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20260525_Enson/02_setting.sql create mode 100644 src/main/resources/db/changelog/changes/20260603_Enson/02_setting.sql diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 08781e3..b7e86e3 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -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, diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt index c6f2903..44c8a69 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt @@ -1,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 { fun findAllByBomIdAndDeletedIsFalse(bomId: Long): List 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 } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt index 358ddb3..0af6b48 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt @@ -10,6 +10,16 @@ import org.springframework.data.repository.query.Param interface BomRepository : AbstractRepository { fun findAllByDeletedIsFalse(): List + @Query( + """ + SELECT b FROM Bom b + LEFT JOIN FETCH b.item + LEFT JOIN FETCH b.uom + WHERE b.deleted = false + """, + ) + fun findAllActiveWithItemAndUom(): List + fun findByIdAndDeletedIsFalse(id: Serializable): Bom? fun findByM18IdAndDeletedIsFalse(m18Id: Long): Bom? diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt index c84cd49..19ddb06 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt @@ -9,6 +9,28 @@ import java.io.Serializable interface ItemUomRespository : AbstractRepository { fun findAllByItemIdAndDeletedIsFalse(itemId: Serializable): List + /** 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 + + /** 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 + fun findByIdAndDeletedIsFalse(id: Serializable): ItemUom? fun findByM18IdAndDeletedIsFalse(m18Id: Serializable): ItemUom? diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index 79f554a..f50ee12 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -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 = + 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, diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt new file mode 100644 index 0000000..ee25ccb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt @@ -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 { + val issues = mutableListOf() + 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 { + val issues = mutableListOf() + 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 timed(block: () -> T): Pair { + 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, + uomsByItemId: Map>, + materialsByBomId: Map>, + uomCache: Map, + ): Int { + val headerKeys = mutableSetOf() + val materialKeys = mutableSetOf() + 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, + uomsByItemId: Map>, + deletedUomsByItemId: Map>, + ): Int { + val keys = mutableSetOf() + 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, + uomsByItemId: Map>, + ) { + 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, + uomsByItemId: Map>, + uomCache: Map, + ) { + 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>, + deletedUomsByItemId: Map>, + ): 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>, + 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, + 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>, + deletedUomsByItemId: Map>, + materials: List = emptyList(), + ): Map { + val map = mutableMapOf() + 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> { + val grouped = mutableMapOf>() + for (row in itemUomRespository.findAllActiveWithUom()) { + val itemId = row.item?.id + ?: continue + grouped.computeIfAbsent(itemId) { mutableListOf() }.add(row) + } + return grouped + } + + private fun loadDeletedUomsByItemId(): Map> { + val grouped = mutableMapOf>() + 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>): List = + uomsByItemId[itemId] ?: emptyList() + + private fun rowModifiedTime(row: ItemUom): LocalDateTime? = + row.modified ?: row.m18LastModifyDate + + private fun newestRow(rows: List): ItemUom? = + rows.maxByOrNull { rowModifiedTime(it) ?: LocalDateTime.MIN } + + private fun findSalesUnitRow(itemId: Long, uomsByItemId: Map>): ItemUom? = + uomsForItem(itemId, uomsByItemId).firstOrNull { it.salesUnit == true } + + private fun findStockUnitRow(itemId: Long, uomsByItemId: Map>): ItemUom? = + uomsForItem(itemId, uomsByItemId).firstOrNull { it.stockUnit == true } + + private fun findBaseUnitRow(itemId: Long, uomsByItemId: Map>): ItemUom? = + uomsForItem(itemId, uomsByItemId).firstOrNull { it.baseUnit == true } + + private fun evaluateUnitHealth( + activeUoms: List, + deletedUoms: List, + 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>, + deletedUomsByItemId: Map> = 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, + uomsByItemId: Map>, + ) { + 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, + uomsByItemId: Map>, + uomCache: Map = 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, + uomsByItemId: Map>, + deletedUomsByItemId: Map> = 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, + allUoms: List, + 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, + 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 = emptyMap()): String { + if (uomId == null) return "-" + val uom = uomCache[uomId] ?: uomConversionService.findById(uomId) + return formatUom(uom) + } + + private fun sortIssues(issues: List): List = + issues.sortedWith( + compareBy( + { it.scope }, + { it.bomCode?.uppercase() ?: "" }, + { it.itemCode?.uppercase() ?: "" }, + { it.issueCode }, + { it.bomId ?: 0L }, + { it.itemId ?: 0L }, + ), + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt index 5e97b94..ad0b4da 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt @@ -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 { + return bomService.findComboIssues(); + } + + @GetMapping("/master-data/issues") + fun getBomMasterDataIssues(): List { + return masterDataIssueService.findBomMasterDataIssues(); + } + @PostMapping("/import-bom") fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity { val reportResult = bomService.importBOM(payload.batchId, payload.items) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt index 86ade40..ad812cd 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt @@ -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 { + return masterDataIssueService.findItemMasterDataIssues() + } + @GetMapping("/consumables") fun allConsumables(): List> { return itemsService.allConsumables() diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/MasterDataIssueController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/MasterDataIssueController.kt new file mode 100644 index 0000000..ff2469b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/MasterDataIssueController.kt @@ -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) +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboIssueResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboIssueResponse.kt new file mode 100644 index 0000000..498d91f --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboIssueResponse.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt index 30ddd64..f9b46e6 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt @@ -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? = null, diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt index c7ccedf..6f8d739 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt @@ -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?, diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueResponse.kt new file mode 100644 index 0000000..69160ab --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueResponse.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryResponse.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryResponse.kt new file mode 100644 index 0000000..56af5d5 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryResponse.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryTiming.kt b/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryTiming.kt new file mode 100644 index 0000000..4372920 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryTiming.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt index d82a0e5..a7bb2a4 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt @@ -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 + @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, + pageable: Pageable, + ): Page + + @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, + pageable: Pageable, + ): Page + @Query(""" select i.id as id, diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt index 4c46df9..712ca3e 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt @@ -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 { 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(records, total.toInt()); } + open fun searchStockIssueBadItemLotLines( + request: SearchStockIssueBadItemLotLineRequest, + ): RecordsRes { + 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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt new file mode 100644 index 0000000..bceb4dc --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt @@ -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 { + return searchHandleRecords(request, "Bad") + } + + open fun getExpiryItemHandleRecords(request: SearchStockIssueRecordRequest): RecordsRes { + 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 { + 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 { + 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, + ) +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt index 0a9671d..715e71f 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt @@ -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 diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt index af12378..46bdf3c 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt @@ -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 { + return inventoryLotLineService.searchStockIssueBadItemLotLines(request) + } + @GetMapping("/lot-detail/{stockInLineId}") fun getLotDetail(@Valid @PathVariable stockInLineId: Long): LotLineInfo { val stockInLine = stockInLineRepository.findById(stockInLineId).orElseThrow { diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt new file mode 100644 index 0000000..33425fb --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt @@ -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 { + 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 { + return stockIssueService.getExpiryItemHandleRecords( + SearchStockIssueRecordRequest( + startDate = startDate, + endDate = endDate, + itemCode = itemCode, + itemName = itemName, + lotNo = lotNo, + pageNum = pageNum, + pageSize = pageSize, + ), + ) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt index 7c7206e..53d7327 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt @@ -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, ) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchStockIssueBadItemLotLineRequest.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchStockIssueBadItemLotLineRequest.kt new file mode 100644 index 0000000..1189969 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchStockIssueBadItemLotLineRequest.kt @@ -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, +) diff --git a/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt new file mode 100644 index 0000000..91d7930 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt @@ -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?, +) diff --git a/src/main/resources/db/changelog/changes/20260521_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260521_Enson/02_setting.sql new file mode 100644 index 0000000..db77534 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260521_Enson/02_setting.sql @@ -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); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260525_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260525_Enson/02_setting.sql new file mode 100644 index 0000000..6956252 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260525_Enson/02_setting.sql @@ -0,0 +1,6 @@ +--liquibase formatted sql + +-- 建立 items 表的索引 +--changeset Enson:20260525-01 +CREATE INDEX idx_items_deleted +ON items (deleted); \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260603_Enson/02_setting.sql b/src/main/resources/db/changelog/changes/20260603_Enson/02_setting.sql new file mode 100644 index 0000000..76e0cd7 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260603_Enson/02_setting.sql @@ -0,0 +1,5 @@ +--liquibase formatted sql + +-- 建立 items 表的索引 +--changeset Enson:20260603-01 +CREATE INDEX idx_item_uom_deleted_itemId ON item_uom (deleted, itemId); \ No newline at end of file