Преглед изворни кода

bom import fix

item/bom uom issue page

new bad item hnadle page

do if uomid same qty =m18qty
production
CANCERYS\kw093 пре 2 недеља
родитељ
комит
9331b7ebdd
27 измењених фајлова са 1503 додато и 14 уклоњено
  1. +16
    -3
      src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt
  2. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomMaterialRepository.kt
  3. +10
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/BomRepository.kt
  4. +22
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt
  5. +24
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  6. +888
    -0
      src/main/java/com/ffii/fpsms/modules/master/service/MasterDataIssueService.kt
  7. +15
    -1
      src/main/java/com/ffii/fpsms/modules/master/web/BomController.kt
  8. +9
    -1
      src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
  9. +20
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/MasterDataIssueController.kt
  10. +10
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/BomComboIssueResponse.kt
  11. +1
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/EditBomRequest.kt
  12. +3
    -1
      src/main/java/com/ffii/fpsms/modules/master/web/models/ItemUomRequest.kt
  13. +33
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueResponse.kt
  14. +9
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryResponse.kt
  15. +14
    -0
      src/main/java/com/ffii/fpsms/modules/master/web/models/MasterDataIssueSummaryTiming.kt
  16. +41
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/InventoryLotLineRepository.kt
  17. +34
    -3
      src/main/java/com/ffii/fpsms/modules/stock/service/InventoryLotLineService.kt
  18. +190
    -0
      src/main/java/com/ffii/fpsms/modules/stock/service/StockIssueService.kt
  19. +2
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockOutLineService.kt
  20. +8
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/InventoryLotLineController.kt
  21. +69
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/StockIssueController.kt
  22. +3
    -1
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchInventoryLotLineInfoRequest.kt
  23. +10
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/SearchStockIssueBadItemLotLineRequest.kt
  24. +41
    -0
      src/main/java/com/ffii/fpsms/modules/stock/web/model/StockIssueModels.kt
  25. +6
    -0
      src/main/resources/db/changelog/changes/20260521_Enson/02_setting.sql
  26. +6
    -0
      src/main/resources/db/changelog/changes/20260525_Enson/02_setting.sql
  27. +5
    -0
      src/main/resources/db/changelog/changes/20260603_Enson/02_setting.sql

+ 16
- 3
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,


+ 14
- 0
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<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
- 0
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<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?


+ 22
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/ItemUomRespository.kt Прегледај датотеку

@@ -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?


+ 24
- 2
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<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,


+ 888
- 0
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<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 },
),
)
}

+ 15
- 1
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<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)


+ 9
- 1
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<MasterDataIssueResponse> {
return masterDataIssueService.findItemMasterDataIssues()
}

@GetMapping("/consumables")
fun allConsumables(): List<Map<String, Any>> {
return itemsService.allConsumables()


+ 20
- 0
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)
}

+ 10
- 0
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,
)

+ 1
- 0
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<EditBomMaterialRequest>? = null,


+ 3
- 1
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?,


+ 33
- 0
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,
)

+ 9
- 0
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,
)

+ 14
- 0
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,
)

+ 41
- 0
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<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,


+ 34
- 3
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<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


+ 190
- 0
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<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,
)
}

+ 2
- 2
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


+ 8
- 0
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<InventoryLotLineInfo> {
return inventoryLotLineService.searchStockIssueBadItemLotLines(request)
}

@GetMapping("/lot-detail/{stockInLineId}")
fun getLotDetail(@Valid @PathVariable stockInLineId: Long): LotLineInfo {
val stockInLine = stockInLineRepository.findById(stockInLineId).orElseThrow {


+ 69
- 0
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<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
- 1
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,
)

+ 10
- 0
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,
)

+ 41
- 0
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?,
)

+ 6
- 0
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);

+ 6
- 0
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);

+ 5
- 0
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);

Loading…
Откажи
Сачувај